F-8 from the t-paliad-074 audit. Replaces silent `?? key` fallback with a
typed key surface so drift caught at compile/build time, not in prod.
- New `frontend/src/i18n-keys.ts` (generated): `I18nKey` literal union of
all 1288 keys in `i18n.ts`. Regenerated by `frontend/build.ts` on every
build; written only when content changes (no spurious diffs).
- `t(key: I18nKey)` is now strict — `t("fristn.detail.title")` fails
`tsc --noEmit`. New `tDyn(key: string)` is the explicit escape hatch
for runtime-composed keys (`tDyn(\`fristen.status.${x}\`)`); 27 dynamic
call sites migrated.
- Build-time scan in `build.ts` walks `src/**/*.{ts,tsx}` for literal
`data-i18n` / `data-i18n-placeholder` / `data-i18n-title` attributes
and aborts the build on any value not in the key set. Skips `${...}`
interpolations (can't resolve statically). Applied before bundling so
no artefact ships when an unknown literal is present.
Surfaced and fixed during migration:
- `data-i18n="fristen.save.modal.project"` (fristenrechner.ts:145) →
`fristen.save.modal.akte` — F-04-class bug, would render the raw key.
- `t("termine.field.project.none")` (appointments-new.ts:30) →
`termine.field.akte.none` — same class.
- `t("checklisten.instance.project.open")` (checklists-instance.ts:155)
→ `checklisten.instance.akte.open` — same class.
- 4 duplicate-key entries in `i18n.ts` removed (TS1117): `nav.termine`
and `akten.detail.tab.termine` each appeared twice in DE and twice in
EN with identical values.
Out of scope (per brief): the German-vs-English i18n-key namespace split
flagged as F-9, JSX intrinsic typing, and the `akten` → `projects`
half-rename in checklists-detail.ts. Those stay tsc-noisy until separate
tasks land.
182 lines
5.5 KiB
TypeScript
182 lines
5.5 KiB
TypeScript
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
|
|
import { initSidebar } from "./sidebar";
|
|
|
|
interface Deadline {
|
|
id: string;
|
|
project_id: string;
|
|
title: string;
|
|
due_date: string;
|
|
status: string;
|
|
project_reference: string;
|
|
project_title: string;
|
|
}
|
|
|
|
let allDeadlines: Deadline[] = [];
|
|
let viewYear = 0;
|
|
let viewMonth = 0; // 0-11
|
|
|
|
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 urgencyClass(due: string, status: string): string {
|
|
if (status === "completed") return "frist-urgency-done";
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
const d = new Date(due.slice(0, 10) + "T00:00:00");
|
|
const diffDays = Math.floor((d.getTime() - today.getTime()) / 86400000);
|
|
if (diffDays < 0) return "frist-urgency-overdue";
|
|
if (diffDays <= 7) return "frist-urgency-soon";
|
|
return "frist-urgency-later";
|
|
}
|
|
|
|
async function loadDeadlines() {
|
|
try {
|
|
const resp = await fetch("/api/deadlines?status=all");
|
|
if (resp.ok) allDeadlines = await resp.json();
|
|
} catch {
|
|
/* non-fatal */
|
|
}
|
|
}
|
|
|
|
function deadlinesForDate(iso: string): Deadline[] {
|
|
return allDeadlines.filter((f) => f.due_date.slice(0, 10) === iso);
|
|
}
|
|
|
|
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}`;
|
|
}
|
|
|
|
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 = deadlinesForDate(iso);
|
|
const isToday = iso === todayISO;
|
|
|
|
const dots = items
|
|
.slice(0, 4)
|
|
.map((f) => `<span class="frist-cal-dot ${urgencyClass(f.due_date, f.status)}"></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("deadline-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 = allDeadlines.some((f) => {
|
|
const iso = f.due_date.slice(0, 10);
|
|
return iso >= monthStart && iso <= monthEnd;
|
|
});
|
|
const empty = document.getElementById("deadline-cal-empty")!;
|
|
empty.style.display = hasInMonth ? "none" : "";
|
|
}
|
|
|
|
function openPopup(iso: string) {
|
|
const items = deadlinesForDate(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((f) => {
|
|
const cls = urgencyClass(f.due_date, f.status);
|
|
return `<li class="frist-cal-popup-item">
|
|
<span class="frist-cal-dot ${cls}"></span>
|
|
<a href="/deadlines/${esc(f.id)}" class="frist-cal-popup-title">${esc(f.title)}</a>
|
|
<a href="/projects/${esc(f.project_id)}" class="frist-cal-popup-project">${esc(f.project_reference)}</a>
|
|
</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 loadDeadlines();
|
|
render();
|
|
});
|