F-9 from t-paliad-074. Aligns the i18n key namespace with the codebase's
English-language convention; no UI text changes.
Renames in frontend/src/client/i18n.ts (the source-of-truth file):
akten.* -> projects.* (merged with projekte.*; projekte wins on collision,
the more-recently-edited value, per brief)
fristen.* -> deadlines.*
termine.* -> appointments.*
projekte.* -> projects.*
notizen.* -> notes.*
Scope of changes:
- 760 key lines renamed in i18n.ts (380 unique keys × 2 langs)
- 70 akten/projekte suffix collisions resolved by dropping akten.* lines
(140 lines dropped total — projekte values preserved)
- 19 inner-segment fixes (e.g. projects.detail.fristen.add ->
projects.detail.deadlines.add, and template-literal sites like
`tDyn(`fristen.${x}`)` whose suffix begins with ${...})
- 476 caller-side replacements across 27 *.ts/*.tsx files
(literal t() / tDyn() args, template-literal prefixes,
"prefix." string concatenations, data-i18n attributes)
- i18n-keys.ts (generated) regenerated by build.ts: 1218 keys total
t-paliad-078's typed registry + build-time data-i18n scanner caught this
rename was complete: `bun run build` reports "i18n scan: data-i18n
attributes clean", meaning every literal data-i18n attribute in TSX/TS
sources references a key that exists in i18n.ts post-rename.
Out of scope (per brief): backend Go service rename (t-paliad-080 F-4),
URL paths (/akten, /projekte routes still server-side), CSS class names
(akten-table, akten-form, etc.), and German sub-tokens like .akte (label
"Akte:") or .no_akten (the modal hint when no project is linked).
194 lines
5.9 KiB
TypeScript
194 lines
5.9 KiB
TypeScript
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
|
|
import { initSidebar } from "./sidebar";
|
|
|
|
interface Appointment {
|
|
id: string;
|
|
project_id?: string;
|
|
title: string;
|
|
start_at: string;
|
|
end_at?: string;
|
|
appointment_type?: string;
|
|
project_reference?: string;
|
|
project_title?: string;
|
|
}
|
|
|
|
let allAppointments: Appointment[] = [];
|
|
let viewYear = 0;
|
|
let viewMonth = 0;
|
|
|
|
function esc(s: string): string {
|
|
const d = document.createElement("div");
|
|
d.textContent = s;
|
|
return d.innerHTML;
|
|
}
|
|
|
|
function fmtMonth(year: number, month: number): string {
|
|
return `${tDyn(`cal.month.${month}`)} ${year}`;
|
|
}
|
|
|
|
function isoDate(year: number, month: number, day: number): string {
|
|
const m = String(month + 1).padStart(2, "0");
|
|
const d = String(day).padStart(2, "0");
|
|
return `${year}-${m}-${d}`;
|
|
}
|
|
|
|
async function loadAppointments() {
|
|
// Pull a wide window (current month plus a little buffer either side).
|
|
// We could narrow this, but the user typically navigates ±1-2 months
|
|
// and the dataset is small.
|
|
try {
|
|
const resp = await fetch("/api/appointments");
|
|
if (resp.ok) allAppointments = await resp.json();
|
|
} catch {
|
|
/* non-fatal */
|
|
}
|
|
}
|
|
|
|
function appointmentsForDate(iso: string): Appointment[] {
|
|
return allAppointments.filter((t) => t.start_at.slice(0, 10) === iso);
|
|
}
|
|
|
|
function typeClass(t?: string): string {
|
|
return t ? `termin-type-${t}` : "termin-type-default";
|
|
}
|
|
|
|
function fmtTime(iso: string): string {
|
|
try {
|
|
const d = new Date(iso);
|
|
return d.toLocaleTimeString(getLang() === "de" ? "de-DE" : "en-GB", {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
});
|
|
} catch {
|
|
return iso;
|
|
}
|
|
}
|
|
|
|
function render() {
|
|
document.getElementById("cal-month-label")!.textContent = fmtMonth(viewYear, viewMonth);
|
|
|
|
const firstDay = new Date(viewYear, viewMonth, 1);
|
|
const jsWeekday = firstDay.getDay();
|
|
const offset = (jsWeekday + 6) % 7;
|
|
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
|
|
const today = new Date();
|
|
const todayISO = isoDate(today.getFullYear(), today.getMonth(), today.getDate());
|
|
|
|
const cells: string[] = [];
|
|
for (let i = 0; i < offset; i++) {
|
|
cells.push(`<div class="frist-cal-cell frist-cal-cell-empty"></div>`);
|
|
}
|
|
for (let day = 1; day <= daysInMonth; day++) {
|
|
const iso = isoDate(viewYear, viewMonth, day);
|
|
const items = appointmentsForDate(iso);
|
|
const isToday = iso === todayISO;
|
|
|
|
const dots = items
|
|
.slice(0, 4)
|
|
.map((tt) => `<span class="termin-dot ${typeClass(tt.appointment_type)}" title="${esc(tt.title)}"></span>`)
|
|
.join("");
|
|
const more = items.length > 4 ? `<span class="frist-cal-more">+${items.length - 4}</span>` : "";
|
|
|
|
cells.push(
|
|
`<div class="frist-cal-cell${isToday ? " frist-cal-today" : ""}${items.length > 0 ? " frist-cal-cell-has" : ""}" data-iso="${iso}">
|
|
<span class="frist-cal-day">${day}</span>
|
|
<div class="frist-cal-dots">${dots}${more}</div>
|
|
</div>`,
|
|
);
|
|
}
|
|
|
|
const grid = document.getElementById("appointment-cal-grid")!;
|
|
grid.innerHTML = cells.join("");
|
|
|
|
grid.querySelectorAll<HTMLElement>(".frist-cal-cell-has").forEach((cell) => {
|
|
cell.addEventListener("click", () => openPopup(cell.dataset.iso!));
|
|
});
|
|
|
|
const monthStart = isoDate(viewYear, viewMonth, 1);
|
|
const monthEnd = isoDate(viewYear, viewMonth, daysInMonth);
|
|
const hasInMonth = allAppointments.some((tt) => {
|
|
const iso = tt.start_at.slice(0, 10);
|
|
return iso >= monthStart && iso <= monthEnd;
|
|
});
|
|
const empty = document.getElementById("appointment-cal-empty")!;
|
|
empty.style.display = hasInMonth ? "none" : "";
|
|
}
|
|
|
|
function openPopup(iso: string) {
|
|
const items = appointmentsForDate(iso);
|
|
if (items.length === 0) return;
|
|
const popup = document.getElementById("cal-popup")!;
|
|
const dateEl = document.getElementById("cal-popup-date")!;
|
|
const list = document.getElementById("cal-popup-list")!;
|
|
|
|
const d = new Date(iso + "T00:00:00");
|
|
dateEl.textContent = d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
|
|
weekday: "long",
|
|
year: "numeric",
|
|
month: "long",
|
|
day: "numeric",
|
|
});
|
|
|
|
list.innerHTML = items
|
|
.map((tt) => {
|
|
const akteRef = tt.project_id
|
|
? `<a href="/projects/${esc(tt.project_id)}" class="frist-cal-popup-project">${esc(tt.project_reference ?? "")}</a>`
|
|
: `<span class="termin-personal-tag">${esc(t("appointments.personal"))}</span>`;
|
|
return `<li class="frist-cal-popup-item">
|
|
<span class="termin-dot ${typeClass(tt.appointment_type)}"></span>
|
|
<span class="frist-cal-popup-time">${esc(fmtTime(tt.start_at))}</span>
|
|
<a href="/appointments/${esc(tt.id)}" class="frist-cal-popup-title">${esc(tt.title)}</a>
|
|
${akteRef}
|
|
</li>`;
|
|
})
|
|
.join("");
|
|
popup.style.display = "flex";
|
|
}
|
|
|
|
function initPopup() {
|
|
const popup = document.getElementById("cal-popup")!;
|
|
const close = document.getElementById("cal-popup-close")!;
|
|
close.addEventListener("click", () => (popup.style.display = "none"));
|
|
popup.addEventListener("click", (e) => {
|
|
if (e.target === e.currentTarget) popup.style.display = "none";
|
|
});
|
|
}
|
|
|
|
function initNav() {
|
|
document.getElementById("cal-prev")!.addEventListener("click", () => {
|
|
viewMonth -= 1;
|
|
if (viewMonth < 0) {
|
|
viewMonth = 11;
|
|
viewYear -= 1;
|
|
}
|
|
render();
|
|
});
|
|
document.getElementById("cal-next")!.addEventListener("click", () => {
|
|
viewMonth += 1;
|
|
if (viewMonth > 11) {
|
|
viewMonth = 0;
|
|
viewYear += 1;
|
|
}
|
|
render();
|
|
});
|
|
document.getElementById("cal-today")!.addEventListener("click", () => {
|
|
const now = new Date();
|
|
viewYear = now.getFullYear();
|
|
viewMonth = now.getMonth();
|
|
render();
|
|
});
|
|
}
|
|
|
|
document.addEventListener("DOMContentLoaded", async () => {
|
|
initI18n();
|
|
initSidebar();
|
|
const now = new Date();
|
|
viewYear = now.getFullYear();
|
|
viewMonth = now.getMonth();
|
|
initNav();
|
|
initPopup();
|
|
onLangChange(render);
|
|
await loadAppointments();
|
|
render();
|
|
});
|