m's 2026-05-08 cosmetic ask: the "Wartet auf Genehmigung" badge ate
row width and read as a noisy block of text on every pending row.
Replace with a 22px eye-icon pill; the lifecycle label moves to the
hover tooltip (title attr + aria-label so screen readers still get
the full text).
Three pieces:
- global.css — new .approval-pill--icon modifier sets the pill to
a circular 22×22 hit target with a centered SVG. Base
.approval-pill (text-pill behavior) and --historic (inbox status
pill) stay untouched so the inbox surface keeps rendering the
full status + decider name.
- client/events.ts (the /deadlines + /appointments shell) and
client/agenda.ts each get a tiny APPROVAL_PILL_EYE_SVG constant
+ the new --icon class on the pending pill. Two definitions
(no shared icons module today; no other surfaces need this glyph
yet) — the duplication is two lines, easier to read than yet
another import.
What it looks like: 👁 in a soft amber circle, hovers to "Änderung
wartet auf Genehmigung" / "Erledigung wartet auf Genehmigung" / etc.
The lifecycle-specific label kept (no schema work) — Maria gated this
slice as pure-frontend; the richer "wartet auf Genehmigung von
<role>; angefragt am <date>" tooltip needs a backend join we're not
doing here.
Refs t-paliad-160 §C / m's 2026-05-08 18:15 batch Item B.
1071 lines
40 KiB
TypeScript
1071 lines
40 KiB
TypeScript
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
|
||
import { initSidebar } from "./sidebar";
|
||
import {
|
||
attachEventTypeMultiSelectFilter,
|
||
fetchEventTypes,
|
||
eventTypeLabel,
|
||
type EventType,
|
||
type FilterHandle,
|
||
} from "./event-types";
|
||
import { projectIndent } from "./project-indent";
|
||
|
||
// Eye-icon SVG used inside .approval-pill--icon. Kept as a string
|
||
// constant rather than a separate module since only events.ts and
|
||
// agenda.ts render it; the duplication is two lines, easier to read
|
||
// than yet another import.
|
||
const APPROVAL_PILL_EYE_SVG =
|
||
'<svg viewBox="0 0 24 24" aria-hidden="true">' +
|
||
'<path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7-10-7-10-7z"/>' +
|
||
'<circle cx="12" cy="12" r="3"/>' +
|
||
'</svg>';
|
||
|
||
// EventsPage shared client (t-paliad-110). Drives /deadlines and
|
||
// /appointments off the same shell — the route handler injects
|
||
// `window.__PALIAD_EVENTS__ = { defaultType: "deadline" | "appointment" }`
|
||
// on first paint and we read it here to set the initial chip + filter
|
||
// visibility. The chip toggle on the page lets the user widen to
|
||
// "Beides" without a route change; the URL gets `?type=` for state.
|
||
|
||
declare global {
|
||
interface Window {
|
||
__PALIAD_EVENTS__?: {
|
||
defaultType?: EventTypeChoice;
|
||
};
|
||
}
|
||
}
|
||
|
||
type EventTypeChoice = "deadline" | "appointment" | "all";
|
||
type EventView = "cards" | "list" | "calendar";
|
||
|
||
interface EventListItem {
|
||
type: "deadline" | "appointment";
|
||
id: string;
|
||
title: string;
|
||
description?: string;
|
||
event_date: string;
|
||
project_id?: string;
|
||
project_reference?: string;
|
||
project_title?: string;
|
||
project_type?: string;
|
||
|
||
// Approval workflow (t-paliad-138). "pending" → render the warning pill.
|
||
approval_status?: "approved" | "pending" | "legacy";
|
||
|
||
// deadline-only
|
||
due_date?: string;
|
||
status?: string;
|
||
completed_at?: string;
|
||
source?: string;
|
||
rule_id?: string;
|
||
rule_code?: string;
|
||
rule_name?: string;
|
||
rule_name_en?: string;
|
||
event_type_ids?: string[];
|
||
|
||
// appointment-only
|
||
start_at?: string;
|
||
end_at?: string;
|
||
location?: string;
|
||
appointment_type?: string;
|
||
}
|
||
|
||
interface EventSummary {
|
||
deadlines?: {
|
||
overdue: number;
|
||
today: number;
|
||
this_week: number;
|
||
next_week: number;
|
||
later: number;
|
||
completed: number;
|
||
total: number;
|
||
};
|
||
appointments?: {
|
||
today: number;
|
||
this_week: number;
|
||
next_week: number;
|
||
later: number;
|
||
total: number;
|
||
};
|
||
}
|
||
|
||
interface Project {
|
||
id: string;
|
||
reference?: string | null;
|
||
title: string;
|
||
path: string;
|
||
}
|
||
|
||
interface Me {
|
||
id: string;
|
||
job_title: string | null;
|
||
global_role: string;
|
||
}
|
||
|
||
const PERSONAL = "__personal__";
|
||
|
||
// Status options per Type. Deadlines carry the full set; appointments
|
||
// only get the date-bucket subset (no pending/overdue/completed — those
|
||
// are deadline-only concepts). type=all reuses the deadline set so users
|
||
// keep all deadline-side filters available in Beides mode.
|
||
type StatusOption = { value: string; key: string };
|
||
|
||
const STATUS_OPTIONS_DEADLINE: StatusOption[] = [
|
||
{ value: "all", key: "deadlines.filter.all" },
|
||
{ value: "pending", key: "deadlines.filter.pending" },
|
||
{ value: "overdue", key: "deadlines.filter.overdue" },
|
||
{ value: "today", key: "deadlines.filter.today" },
|
||
{ value: "this_week", key: "deadlines.filter.thisweek" },
|
||
{ value: "next_week", key: "deadlines.filter.nextweek" },
|
||
{ value: "later", key: "deadlines.filter.later" },
|
||
{ value: "completed", key: "deadlines.filter.completed" },
|
||
];
|
||
|
||
const STATUS_OPTIONS_APPOINTMENT: StatusOption[] = [
|
||
{ value: "all", key: "events.filter.status.all" },
|
||
{ value: "today", key: "deadlines.filter.today" },
|
||
{ value: "this_week", key: "deadlines.filter.thisweek" },
|
||
{ value: "next_week", key: "deadlines.filter.nextweek" },
|
||
{ value: "later", key: "deadlines.filter.later" },
|
||
];
|
||
|
||
function statusOptionsFor(type: EventTypeChoice): StatusOption[] {
|
||
if (type === "appointment") return STATUS_OPTIONS_APPOINTMENT;
|
||
return STATUS_OPTIONS_DEADLINE;
|
||
}
|
||
|
||
function defaultStatusFor(type: EventTypeChoice): string {
|
||
return type === "appointment" ? "all" : "pending";
|
||
}
|
||
|
||
let currentType: EventTypeChoice = "deadline";
|
||
let currentView: EventView = "cards";
|
||
let statusFilter = "pending";
|
||
let projectFilter = "";
|
||
let appointmentTypeFilter = "";
|
||
let allItems: EventListItem[] = [];
|
||
let allProjects: Project[] = [];
|
||
let me: Me | null = null;
|
||
let eventTypeFilter: FilterHandle | null = null;
|
||
let eventTypeByID: Map<string, EventType> = new Map();
|
||
let loadedOK = false;
|
||
let calYear = 0;
|
||
let calMonth = 0;
|
||
|
||
function urlParams(): URLSearchParams {
|
||
return new URLSearchParams(window.location.search);
|
||
}
|
||
|
||
function defaultType(): EventTypeChoice {
|
||
const injected = window.__PALIAD_EVENTS__?.defaultType;
|
||
if (injected === "deadline" || injected === "appointment" || injected === "all") {
|
||
return injected;
|
||
}
|
||
return "all";
|
||
}
|
||
|
||
function esc(s: string): string {
|
||
const d = document.createElement("div");
|
||
d.textContent = s;
|
||
return d.innerHTML;
|
||
}
|
||
|
||
function setCount(id: string, n: number) {
|
||
const el = document.getElementById(id);
|
||
if (el) el.textContent = String(n);
|
||
}
|
||
|
||
function fmtDateOnly(iso: string): string {
|
||
try {
|
||
const d = new Date(iso.length === 10 ? iso + "T00:00:00" : iso);
|
||
return d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
|
||
year: "numeric",
|
||
month: "2-digit",
|
||
day: "2-digit",
|
||
});
|
||
} catch {
|
||
return iso;
|
||
}
|
||
}
|
||
|
||
function fmtDateTime(iso: string): string {
|
||
try {
|
||
const d = new Date(iso);
|
||
return d.toLocaleString(getLang() === "de" ? "de-DE" : "en-GB", {
|
||
year: "numeric",
|
||
month: "2-digit",
|
||
day: "2-digit",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
});
|
||
} catch {
|
||
return iso;
|
||
}
|
||
}
|
||
|
||
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 "";
|
||
}
|
||
}
|
||
|
||
// formatDateCell yields the per-row date label. Deadlines render as
|
||
// "31.08.2026" (date-only). Appointments render as "15.09.2026 09:00",
|
||
// extended with "–11:00" when end_at is on the same day, or
|
||
// "→ 16.09.2026 11:00" when end_at spills into a later day.
|
||
function formatDateCell(item: EventListItem): string {
|
||
if (item.type === "deadline") {
|
||
return fmtDateOnly(item.due_date ?? item.event_date);
|
||
}
|
||
if (!item.start_at) return "";
|
||
const start = new Date(item.start_at);
|
||
const startLabel = fmtDateTime(item.start_at);
|
||
if (!item.end_at) return startLabel;
|
||
const end = new Date(item.end_at);
|
||
const sameDay =
|
||
start.getFullYear() === end.getFullYear() &&
|
||
start.getMonth() === end.getMonth() &&
|
||
start.getDate() === end.getDate();
|
||
if (sameDay) {
|
||
return `${startLabel}–${fmtTime(item.end_at)}`;
|
||
}
|
||
return `${startLabel} → ${fmtDateTime(item.end_at)}`;
|
||
}
|
||
|
||
function urgencyClass(item: EventListItem): string {
|
||
if (item.type === "deadline") {
|
||
if (item.status === "completed") return "frist-urgency-done";
|
||
}
|
||
const today = new Date();
|
||
today.setHours(0, 0, 0, 0);
|
||
const d = new Date(item.event_date);
|
||
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";
|
||
}
|
||
|
||
function ruleDisplay(item: EventListItem): string {
|
||
if (item.type !== "deadline") return "";
|
||
// Prefer the saved citation (RoP.023, R.151) over the rule name —
|
||
// REGEL is meant for the legal reference, not the rule's display
|
||
// name (which is the title column's job).
|
||
if (item.rule_code && item.rule_code.trim()) return esc(item.rule_code);
|
||
const lang = getLang();
|
||
const localized = lang === "en" ? item.rule_name_en : item.rule_name;
|
||
if (localized && localized.trim()) return esc(localized);
|
||
return "—";
|
||
}
|
||
|
||
function eventTypeDisplay(item: EventListItem): string {
|
||
if (item.type !== "deadline") return "";
|
||
const ids = item.event_type_ids ?? [];
|
||
if (ids.length === 0) return "—";
|
||
const labels: string[] = [];
|
||
for (const id of ids) {
|
||
const et = eventTypeByID.get(id);
|
||
if (et) labels.push(eventTypeLabel(et));
|
||
}
|
||
if (labels.length === 0) return "—";
|
||
return labels.map((l) => `<span class="entity-event-type-pill">${esc(l)}</span>`).join(" ");
|
||
}
|
||
|
||
function rowTypeChip(item: EventListItem): string {
|
||
const label = item.type === "deadline" ? t("events.row.type.deadline") : t("events.row.type.appointment");
|
||
const cls = item.type === "deadline" ? "events-row-type-frist" : "events-row-type-termin";
|
||
return `<span class="events-row-type-chip ${cls}">${esc(label)}</span>`;
|
||
}
|
||
|
||
function canReopen(): boolean {
|
||
return !!me && me.global_role === "global_admin";
|
||
}
|
||
|
||
async function loadProjects() {
|
||
try {
|
||
const resp = await fetch("/api/projects");
|
||
if (resp.ok) allProjects = await resp.json();
|
||
} catch {
|
||
/* non-fatal */
|
||
}
|
||
}
|
||
|
||
async function loadMe() {
|
||
try {
|
||
const resp = await fetch("/api/me");
|
||
if (resp.ok) me = await resp.json();
|
||
} catch {
|
||
/* non-fatal */
|
||
}
|
||
}
|
||
|
||
async function loadEventTypes() {
|
||
try {
|
||
const types = await fetchEventTypes();
|
||
eventTypeByID = new Map(types.map((et) => [et.id, et]));
|
||
} catch {
|
||
/* non-fatal */
|
||
}
|
||
}
|
||
|
||
async function loadSummary() {
|
||
try {
|
||
const params = new URLSearchParams();
|
||
params.set("type", currentType);
|
||
if (projectFilter === PERSONAL) {
|
||
// "Nur persönliche" → items the caller created. The backend
|
||
// applies this uniformly to both rails (deadlines + appointments)
|
||
// and ignores any project_id we'd send. See t-paliad-128.
|
||
params.set("personal_only", "true");
|
||
} else if (projectFilter) {
|
||
params.set("project_id", projectFilter);
|
||
}
|
||
const resp = await fetch(`/api/events/summary?${params.toString()}`);
|
||
if (!resp.ok) return;
|
||
const sum: EventSummary = await resp.json();
|
||
|
||
// 4 universal cards: pick the deadline value when present, otherwise
|
||
// the appointment value. In Beides mode, sum both rails.
|
||
const today = (sum.deadlines?.today ?? 0) + (sum.appointments?.today ?? 0);
|
||
const thisWeek = (sum.deadlines?.this_week ?? 0) + (sum.appointments?.this_week ?? 0);
|
||
const nextWeek = (sum.deadlines?.next_week ?? 0) + (sum.appointments?.next_week ?? 0);
|
||
const later = (sum.deadlines?.later ?? 0) + (sum.appointments?.later ?? 0);
|
||
|
||
setCount("events-sum-today", today);
|
||
setCount("events-sum-week", thisWeek);
|
||
setCount("events-sum-next-week", nextWeek);
|
||
setCount("events-sum-later", later);
|
||
|
||
// Überfällig is deadline-only and conditional. Hide on appointment view
|
||
// OR when the count is zero. Alarm-style when count > 0 on a view that
|
||
// includes deadlines (mirrors t-paliad-105 / t-paliad-106 behaviour).
|
||
const overdue = sum.deadlines?.overdue ?? 0;
|
||
setCount("events-sum-overdue", overdue);
|
||
applyOverdueState(overdue);
|
||
} catch {
|
||
/* non-fatal */
|
||
}
|
||
}
|
||
|
||
// Überfällig is deadline-view-only per t-paliad-110 spec. It stays hidden
|
||
// in Beides mode even when overdue > 0 — the design treats "overdue" as a
|
||
// deadline-specific concept (appointments don't go overdue, they happen),
|
||
// so showing the card in mixed mode would be a category error.
|
||
function applyOverdueState(overdue: number) {
|
||
const card = document.querySelector<HTMLElement>('.frist-summary-card[data-bucket="overdue"]');
|
||
if (!card) return;
|
||
const isDeadlineView = currentType === "deadline";
|
||
const hidden = !isDeadlineView || overdue === 0;
|
||
card.classList.toggle("frist-card-overdue-hidden", hidden);
|
||
card.classList.toggle("frist-card-alarm", isDeadlineView && overdue > 0);
|
||
}
|
||
|
||
async function loadList() {
|
||
const unavailable = document.getElementById("events-unavailable")!;
|
||
try {
|
||
const params = new URLSearchParams();
|
||
params.set("type", currentType);
|
||
if (statusFilter) {
|
||
// Status carries either a deadline filter (pending/overdue/completed,
|
||
// which only make sense when deadlines are in scope) or a date-bucket
|
||
// (today/this_week/next_week/later) which the backend applies to
|
||
// both rails. EventService.bucketAppointmentWindow turns bucket
|
||
// values into a start_at window; non-bucket values (all/"") are a
|
||
// no-op on the appointment side.
|
||
params.set("status", statusFilter);
|
||
}
|
||
if (projectFilter === PERSONAL) {
|
||
// "Nur persönliche" → items the caller created (t-paliad-128).
|
||
// Applies uniformly to both rails server-side; project_id is
|
||
// intentionally not sent (the two are mutually exclusive).
|
||
params.set("personal_only", "true");
|
||
} else if (projectFilter) {
|
||
params.set("project_id", projectFilter);
|
||
}
|
||
if (currentType !== "appointment") {
|
||
const eventTypeQuery = eventTypeFilter?.toQueryValue() ?? "";
|
||
if (eventTypeQuery) params.set("event_type", eventTypeQuery);
|
||
}
|
||
if (currentType === "appointment" && appointmentTypeFilter) {
|
||
params.set("type_filter", appointmentTypeFilter);
|
||
}
|
||
const resp = await fetch(`/api/events?${params.toString()}`);
|
||
if (resp.status === 503) {
|
||
unavailable.style.display = "block";
|
||
hideTableAndCalendar();
|
||
document.getElementById("events-empty")!.style.display = "none";
|
||
return;
|
||
}
|
||
if (!resp.ok) {
|
||
unavailable.style.display = "block";
|
||
hideTableAndCalendar();
|
||
return;
|
||
}
|
||
const data: EventListItem[] = await resp.json();
|
||
allItems = data;
|
||
loadedOK = true;
|
||
render();
|
||
} catch {
|
||
unavailable.style.display = "block";
|
||
hideTableAndCalendar();
|
||
}
|
||
}
|
||
|
||
function hideTableAndCalendar() {
|
||
const tableWrap = document.getElementById("events-table-wrap");
|
||
const calWrap = document.getElementById("events-calendar-wrap");
|
||
if (tableWrap) tableWrap.style.display = "none";
|
||
if (calWrap) calWrap.hidden = true;
|
||
}
|
||
|
||
function render() {
|
||
if (!loadedOK) return;
|
||
if (currentView === "calendar") {
|
||
renderCalendar();
|
||
} else {
|
||
renderTable();
|
||
}
|
||
}
|
||
|
||
function renderTable() {
|
||
const tbody = document.getElementById("events-body")!;
|
||
const empty = document.getElementById("events-empty")!;
|
||
const emptyFiltered = document.getElementById("events-empty-filtered")!;
|
||
const tableWrap = document.getElementById("events-table-wrap") as HTMLElement;
|
||
const table = document.getElementById("events-table");
|
||
|
||
if (allItems.length === 0) {
|
||
tbody.innerHTML = "";
|
||
tableWrap.style.display = "none";
|
||
if (isFilterPristine()) {
|
||
empty.style.display = "block";
|
||
emptyFiltered.style.display = "none";
|
||
} else {
|
||
empty.style.display = "none";
|
||
emptyFiltered.style.display = "block";
|
||
}
|
||
return;
|
||
}
|
||
|
||
tableWrap.style.display = "";
|
||
empty.style.display = "none";
|
||
emptyFiltered.style.display = "none";
|
||
|
||
const showReopen = canReopen();
|
||
tbody.innerHTML = allItems.map((item) => renderRow(item, showReopen)).join("");
|
||
|
||
// Hide-on-uniform pattern (t-paliad-073, generalised in t-088). When all
|
||
// visible rows agree on a column we drop the column entirely so empty
|
||
// cells don't add noise. Each column has its own data signal so we can
|
||
// toggle independently.
|
||
const anyDeadline = allItems.some((x) => x.type === "deadline");
|
||
const anyAppointment = allItems.some((x) => x.type === "appointment");
|
||
const anyEventType = allItems.some((x) => (x.event_type_ids ?? []).length > 0);
|
||
const anyLocation = allItems.some((x) => !!x.location);
|
||
const anyAppointmentType = allItems.some((x) => !!x.appointment_type);
|
||
const anyRule = allItems.some((x) => x.type === "deadline" && (x.rule_code || x.rule_name || x.rule_name_en));
|
||
const anyStatus = anyDeadline; // appointments don't carry a status column
|
||
|
||
table?.classList.toggle("events-table--hide-row-type", !anyDeadline || !anyAppointment);
|
||
table?.classList.toggle("events-table--hide-rule", !anyRule);
|
||
table?.classList.toggle("entity-table--hide-event-type", !anyEventType);
|
||
table?.classList.toggle("events-table--hide-location", !anyLocation);
|
||
table?.classList.toggle("events-table--hide-appointment-type", !anyAppointmentType);
|
||
table?.classList.toggle("entity-table--hide-status", !anyStatus);
|
||
|
||
wireRowHandlers(tbody);
|
||
}
|
||
|
||
function renderRow(item: EventListItem, showReopen: boolean): string {
|
||
const urgency = urgencyClass(item);
|
||
const dateLabel = formatDateCell(item);
|
||
const ruleLabel = item.type === "deadline" ? ruleDisplay(item) : "";
|
||
const eventTypeCell = item.type === "deadline" ? eventTypeDisplay(item) : "";
|
||
|
||
const projectCell = item.project_id
|
||
? `<a class="entity-ref-link" href="/projects/${esc(item.project_id)}">${esc(item.project_reference ?? "")}</a>`
|
||
+ `<span class="frist-project-title" title="${esc(item.project_title ?? "")}">${esc(item.project_title ?? "")}</span>`
|
||
: `<span class="termin-personal-tag">${esc(t("appointments.personal"))}</span>`;
|
||
|
||
let checkCell = "";
|
||
let titleClass = "";
|
||
let statusCell = "";
|
||
if (item.type === "deadline") {
|
||
const isDone = item.status === "completed";
|
||
titleClass = isDone ? "frist-title-done" : "";
|
||
const statusLabel = tDyn(`deadlines.status.${item.status ?? "pending"}`) || (item.status ?? "");
|
||
statusCell = `<span class="entity-status-chip entity-status-${esc(item.status ?? "")}">${esc(statusLabel)}</span>`;
|
||
const reopenLabel = esc(t("deadlines.action.reopen"));
|
||
if (isDone) {
|
||
checkCell = showReopen
|
||
? `<button type="button" class="frist-reopen-btn" aria-label="${reopenLabel}" title="${reopenLabel}">↻</button>`
|
||
: `<input type="checkbox" class="frist-complete-cb" checked disabled aria-label="${esc(t("deadlines.complete.action"))}" />`;
|
||
} else {
|
||
checkCell = `<input type="checkbox" class="frist-complete-cb" aria-label="${esc(t("deadlines.complete.action"))}" />`;
|
||
}
|
||
} else {
|
||
const typeClass = item.appointment_type ? `termin-type-${item.appointment_type}` : "";
|
||
checkCell = `<span class="termin-dot ${typeClass}"></span>`;
|
||
}
|
||
|
||
const locationCell = item.location ? esc(item.location) : "—";
|
||
const appointmentTypeCell = item.appointment_type
|
||
? `<span class="termin-type-chip termin-type-${esc(item.appointment_type)}">${esc(tDyn(`appointments.type.${item.appointment_type}`) || item.appointment_type)}</span>`
|
||
: "—";
|
||
|
||
// Approval pending pill (t-paliad-138 / m's 2026-05-08 cosmetic ask).
|
||
// Soft-tint the row + drop an eye-icon pill next to the title; hover
|
||
// reveals the lifecycle label. Inbox surface shows the full detail.
|
||
const pendingClass = item.approval_status === "pending" ? " entity-row--pending-update" : "";
|
||
const pendingLabel = item.approval_status === "pending" ? t("approvals.pending_update.label") : "";
|
||
const pendingPill = item.approval_status === "pending"
|
||
? `<span class="approval-pill approval-pill--icon" title="${esc(pendingLabel)}" aria-label="${esc(pendingLabel)}">${APPROVAL_PILL_EYE_SVG}</span>`
|
||
: "";
|
||
|
||
return `<tr class="frist-row events-row events-row-${item.type}${pendingClass}" data-id="${esc(item.id)}" data-type="${item.type}">
|
||
<td class="frist-col-check">${checkCell}</td>
|
||
<td class="events-col-row-type">${rowTypeChip(item)}</td>
|
||
<td class="frist-col-due ${urgency}"><span class="frist-due-dot"></span>${esc(dateLabel)}</td>
|
||
<td class="frist-col-title ${titleClass}">${esc(item.title)}${pendingPill ? " " + pendingPill : ""}</td>
|
||
<td class="frist-col-project">${projectCell}</td>
|
||
<td class="frist-col-rule events-col-rule">${ruleLabel || "—"}</td>
|
||
<td class="entity-col-event-type">${eventTypeCell || "—"}</td>
|
||
<td class="events-col-location">${locationCell}</td>
|
||
<td class="events-col-appointment-type">${appointmentTypeCell}</td>
|
||
<td class="entity-col-status">${statusCell}</td>
|
||
</tr>`;
|
||
}
|
||
|
||
// itemDateISO returns the per-item bucketing date (YYYY-MM-DD) used when
|
||
// plotting an event onto the calendar. Deadlines bucket on due_date;
|
||
// appointments on start_at's local-date component.
|
||
function itemDateISO(item: EventListItem): string {
|
||
if (item.type === "deadline") {
|
||
const src = item.due_date ?? item.event_date;
|
||
return src.slice(0, 10);
|
||
}
|
||
if (!item.start_at) return item.event_date.slice(0, 10);
|
||
const d = new Date(item.start_at);
|
||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||
}
|
||
|
||
function isoDate(year: number, month: number, day: number): string {
|
||
return `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||
}
|
||
|
||
function fmtMonthYear(year: number, month: number): string {
|
||
return `${tDyn(`cal.month.${month}`)} ${year}`;
|
||
}
|
||
|
||
function calDotClass(item: EventListItem): string {
|
||
// Per-item dot colour. Deadlines reuse the existing urgency palette;
|
||
// appointments get their own colour so they're visually distinct from
|
||
// deadlines on a mixed (Beides) calendar.
|
||
if (item.type === "appointment") return "events-cal-dot-appointment";
|
||
return urgencyClass(item).replace("frist-urgency-", "frist-urgency-");
|
||
}
|
||
|
||
function renderCalendar() {
|
||
const wrap = document.getElementById("events-calendar-wrap")!;
|
||
const grid = document.getElementById("events-cal-grid")!;
|
||
const empty = document.getElementById("events-cal-empty") as HTMLElement;
|
||
const monthLabel = document.getElementById("events-cal-month-label")!;
|
||
const tableEmpty = document.getElementById("events-empty")!;
|
||
const tableEmptyFiltered = document.getElementById("events-empty-filtered")!;
|
||
|
||
// Calendar always renders the visible month from allItems, regardless of
|
||
// pristine vs filtered state — empty calendar is allowed (the per-month
|
||
// empty hint communicates "no items in this month" without confusing it
|
||
// with the table-mode "no items at all" empty state).
|
||
tableEmpty.style.display = "none";
|
||
tableEmptyFiltered.style.display = "none";
|
||
wrap.hidden = false;
|
||
|
||
monthLabel.textContent = fmtMonthYear(calYear, calMonth);
|
||
|
||
const firstDay = new Date(calYear, calMonth, 1);
|
||
const jsWeekday = firstDay.getDay();
|
||
const offset = (jsWeekday + 6) % 7;
|
||
const daysInMonth = new Date(calYear, calMonth + 1, 0).getDate();
|
||
const today = new Date();
|
||
const todayISO = isoDate(today.getFullYear(), today.getMonth(), today.getDate());
|
||
|
||
// Bucket items by ISO date once, so day-cell rendering is O(d) not O(d*n).
|
||
const byDate = new Map<string, EventListItem[]>();
|
||
for (const item of allItems) {
|
||
const iso = itemDateISO(item);
|
||
const list = byDate.get(iso);
|
||
if (list) list.push(item);
|
||
else byDate.set(iso, [item]);
|
||
}
|
||
|
||
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(calYear, calMonth, day);
|
||
const items = byDate.get(iso) ?? [];
|
||
const isToday = iso === todayISO;
|
||
const dots = items
|
||
.slice(0, 4)
|
||
.map((it) => `<span class="frist-cal-dot ${calDotClass(it)}"></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>`,
|
||
);
|
||
}
|
||
grid.innerHTML = cells.join("");
|
||
|
||
grid.querySelectorAll<HTMLElement>(".frist-cal-cell-has").forEach((cell) => {
|
||
cell.addEventListener("click", () => openCalPopup(cell.dataset.iso!, byDate.get(cell.dataset.iso!) ?? []));
|
||
});
|
||
|
||
const monthStart = isoDate(calYear, calMonth, 1);
|
||
const monthEnd = isoDate(calYear, calMonth, daysInMonth);
|
||
const hasInMonth = allItems.some((it) => {
|
||
const iso = itemDateISO(it);
|
||
return iso >= monthStart && iso <= monthEnd;
|
||
});
|
||
empty.hidden = hasInMonth;
|
||
}
|
||
|
||
function openCalPopup(iso: string, items: EventListItem[]) {
|
||
if (items.length === 0) return;
|
||
const popup = document.getElementById("events-cal-popup") as HTMLElement;
|
||
const dateEl = document.getElementById("events-cal-popup-date")!;
|
||
const list = document.getElementById("events-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((it) => {
|
||
const cls = calDotClass(it);
|
||
const href = it.type === "deadline" ? `/deadlines/${esc(it.id)}` : `/appointments/${esc(it.id)}`;
|
||
const projectHref = it.project_id ? `/projects/${esc(it.project_id)}` : "";
|
||
const projectLabel = it.project_reference ?? "";
|
||
const projectCell = projectHref
|
||
? `<a href="${projectHref}" class="frist-cal-popup-akte">${esc(projectLabel)}</a>`
|
||
: "";
|
||
return `<li class="frist-cal-popup-item">
|
||
<span class="frist-cal-dot ${cls}"></span>
|
||
<a href="${href}" class="frist-cal-popup-title">${rowTypeChip(it)} ${esc(it.title)}</a>
|
||
${projectCell}
|
||
</li>`;
|
||
})
|
||
.join("");
|
||
popup.style.display = "flex";
|
||
}
|
||
|
||
function applyView() {
|
||
const tableWrap = document.getElementById("events-table-wrap") as HTMLElement;
|
||
const calWrap = document.getElementById("events-calendar-wrap") as HTMLElement;
|
||
const summary = document.getElementById("events-summary") as HTMLElement;
|
||
|
||
// Active state on selector buttons.
|
||
document.querySelectorAll<HTMLButtonElement>("[data-event-view]").forEach((b) => {
|
||
b.classList.toggle("events-view-btn-active", b.dataset.eventView === currentView);
|
||
});
|
||
|
||
// Body class so CSS can hook in if needed downstream.
|
||
document.body.classList.toggle("events-view-cards", currentView === "cards");
|
||
document.body.classList.toggle("events-view-list", currentView === "list");
|
||
document.body.classList.toggle("events-view-calendar", currentView === "calendar");
|
||
|
||
// Cards view = the original layout (5-card summary + table).
|
||
// List view = no summary cards, table only — gives more vertical space
|
||
// and matches users' mental model of a flat list.
|
||
// Calendar view = month grid; cards + table both hidden.
|
||
summary.style.display = currentView === "cards" ? "" : "none";
|
||
tableWrap.style.display = currentView === "calendar" ? "none" : "";
|
||
calWrap.hidden = currentView !== "calendar";
|
||
|
||
if (currentView === "calendar" && loadedOK) renderCalendar();
|
||
}
|
||
|
||
function wireRowHandlers(tbody: HTMLElement) {
|
||
tbody.querySelectorAll<HTMLTableRowElement>(".events-row").forEach((row) => {
|
||
const id = row.dataset.id!;
|
||
const type = row.dataset.type!;
|
||
row.addEventListener("click", (e) => {
|
||
const target = e.target as HTMLElement;
|
||
if (
|
||
target.closest(".frist-complete-cb") ||
|
||
target.closest(".frist-reopen-btn") ||
|
||
target.closest("a")
|
||
) return;
|
||
window.location.href = type === "deadline" ? `/deadlines/${id}` : `/appointments/${id}`;
|
||
});
|
||
|
||
const cb = row.querySelector<HTMLInputElement>(".frist-complete-cb");
|
||
if (cb && !cb.disabled) {
|
||
cb.addEventListener("change", async () => {
|
||
if (!cb.checked) return;
|
||
cb.disabled = true;
|
||
try {
|
||
const resp = await fetch(`/api/deadlines/${id}/complete`, { method: "PATCH" });
|
||
if (resp.ok) {
|
||
await Promise.all([loadList(), loadSummary()]);
|
||
} else {
|
||
cb.checked = false;
|
||
cb.disabled = false;
|
||
}
|
||
} catch {
|
||
cb.checked = false;
|
||
cb.disabled = false;
|
||
}
|
||
});
|
||
}
|
||
const reopenBtn = row.querySelector<HTMLButtonElement>(".frist-reopen-btn");
|
||
if (reopenBtn) {
|
||
reopenBtn.addEventListener("click", async () => {
|
||
reopenBtn.disabled = true;
|
||
try {
|
||
const resp = await fetch(`/api/deadlines/${id}/reopen`, { method: "PATCH" });
|
||
if (resp.ok) {
|
||
await Promise.all([loadList(), loadSummary()]);
|
||
} else {
|
||
reopenBtn.disabled = false;
|
||
}
|
||
} catch {
|
||
reopenBtn.disabled = false;
|
||
}
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
function isFilterPristine(): boolean {
|
||
return (
|
||
statusFilter === defaultStatusFor(currentType) &&
|
||
!projectFilter &&
|
||
!appointmentTypeFilter &&
|
||
(eventTypeFilter?.toQueryValue() ?? "") === ""
|
||
);
|
||
}
|
||
|
||
function applyTypeVisibility() {
|
||
// Page-level chrome — heading, subtitle, actions, type chips' active state.
|
||
const heading = document.getElementById("events-heading");
|
||
const subtitle = document.getElementById("events-subtitle");
|
||
const title = document.getElementById("events-title");
|
||
const isAppointment = currentType === "appointment";
|
||
const isDeadline = currentType === "deadline";
|
||
const isAll = currentType === "all";
|
||
|
||
if (heading) heading.textContent = isAppointment ? t("appointments.list.heading") : t("deadlines.list.heading");
|
||
if (subtitle) subtitle.textContent = isAppointment ? t("appointments.list.subtitle") : t("deadlines.list.subtitle");
|
||
if (title) title.textContent = isAppointment ? t("appointments.list.title") : t("deadlines.list.title");
|
||
|
||
// Single "Neu …" CTA per type. View axis is the calendar/list/cards selector.
|
||
toggleDisplay("events-action-new-deadline", isDeadline || isAll, "inline-flex");
|
||
toggleDisplay("events-action-new-appointment", isAppointment || isAll, "inline-flex");
|
||
|
||
// Empty-state CTAs.
|
||
toggleDisplay("events-empty-cta-deadline", isDeadline || isAll, "inline-flex");
|
||
toggleDisplay("events-empty-cta-appointment", isAppointment || isAll, "inline-flex");
|
||
|
||
// Status filter applies to all types — for appointments the option set
|
||
// narrows to date buckets only (Heute / Diese Woche / Nächste Woche /
|
||
// Später / Alle). populateStatusFilter handles the option swap and may
|
||
// reset statusFilter when switching from a deadline-only value.
|
||
populateStatusFilter();
|
||
// Event-type multi-select is deadline-only (appointments have no event_types).
|
||
toggleFilterPair("events-filter-event-type", !isAppointment);
|
||
// The panel is a popup the trigger owns via `panel.hidden`. Never stamp
|
||
// `display: block` on it from the type filter — that overrides the
|
||
// `.multi-panel[hidden]` CSS rule and leaves the panel visible on larger
|
||
// screens. When switching to appointment view, force-close it.
|
||
const eventTypePanel = document.getElementById("events-filter-event-type-panel") as HTMLElement | null;
|
||
if (eventTypePanel) {
|
||
eventTypePanel.style.display = "";
|
||
if (isAppointment) eventTypePanel.hidden = true;
|
||
}
|
||
// Termin-Typ is appointment-only.
|
||
toggleFilterPair("events-filter-appointment-type", !isDeadline);
|
||
|
||
// "Nur persönliche" applies uniformly to deadlines + appointments now
|
||
// (t-paliad-128) — items where the caller is the creator. Always available.
|
||
|
||
// Update chip active state.
|
||
document.querySelectorAll<HTMLButtonElement>('[data-event-type]').forEach((b) => {
|
||
b.classList.toggle("agenda-chip-active", b.dataset.eventType === currentType);
|
||
});
|
||
|
||
// Refresh overdue card visibility on type change.
|
||
applyOverdueState(parseInt(document.getElementById("events-sum-overdue")?.textContent ?? "0", 10));
|
||
|
||
// Sidebar highlight (t-paliad-115). The bare /events HTML SSRs with
|
||
// currentPath="/events" so neither Fristen nor Termine sidebar entries
|
||
// match — we re-highlight at hydration based on the active type so the
|
||
// user always sees the correct nav entry lit. Same logic for BottomNav,
|
||
// which today doesn't carry these entries but stays consistent if it
|
||
// ever does.
|
||
applySidebarTypeHighlight();
|
||
}
|
||
|
||
function applySidebarTypeHighlight() {
|
||
const items = document.querySelectorAll<HTMLAnchorElement>(".sidebar-item");
|
||
for (const a of items) {
|
||
const href = a.getAttribute("href") ?? "";
|
||
if (href === "/events?type=deadline") {
|
||
a.classList.toggle("active", currentType === "deadline");
|
||
} else if (href === "/events?type=appointment") {
|
||
a.classList.toggle("active", currentType === "appointment");
|
||
}
|
||
}
|
||
}
|
||
|
||
function toggleDisplay(id: string, show: boolean, displayWhenShown = "block") {
|
||
const el = document.getElementById(id);
|
||
if (!el) return;
|
||
el.style.display = show ? displayWhenShown : "none";
|
||
}
|
||
|
||
// toggleFilterPair shows/hides the wrapping .filter-group so the label,
|
||
// control, and (for the multi-select) the .multi-anchor collapse together
|
||
// when a filter doesn't apply for the current type.
|
||
function toggleFilterPair(controlID: string, show: boolean) {
|
||
const ctrl = document.getElementById(controlID);
|
||
const group = ctrl?.closest<HTMLElement>(".filter-group");
|
||
if (group) group.style.display = show ? "" : "none";
|
||
}
|
||
|
||
function syncURLParams() {
|
||
const url = new URL(window.location.href);
|
||
url.searchParams.delete("type");
|
||
url.searchParams.delete("view");
|
||
url.searchParams.delete("status");
|
||
url.searchParams.delete("project_id");
|
||
url.searchParams.delete("personal_only");
|
||
url.searchParams.delete("event_type");
|
||
url.searchParams.delete("type_filter");
|
||
// Default type per route comes from window.__PALIAD_EVENTS__; only
|
||
// surface in the URL when the user opted into something else.
|
||
if (currentType !== defaultType()) url.searchParams.set("type", currentType);
|
||
if (currentView !== "cards") url.searchParams.set("view", currentView);
|
||
if (statusFilter && statusFilter !== defaultStatusFor(currentType)) {
|
||
url.searchParams.set("status", statusFilter);
|
||
}
|
||
if (projectFilter) url.searchParams.set("project_id", projectFilter);
|
||
if (currentType !== "appointment") {
|
||
const eventQuery = eventTypeFilter?.toQueryValue() ?? "";
|
||
if (eventQuery) url.searchParams.set("event_type", eventQuery);
|
||
}
|
||
if (currentType === "appointment" && appointmentTypeFilter) {
|
||
url.searchParams.set("type_filter", appointmentTypeFilter);
|
||
}
|
||
window.history.replaceState(null, "", url.toString());
|
||
}
|
||
|
||
// populateStatusFilter rebuilds the Status `<select>` options for the
|
||
// current type. Called from applyTypeVisibility (type chip click +
|
||
// initial paint) and onLangChange (option labels are localised). When
|
||
// the previously selected status isn't valid for the new option set
|
||
// (e.g. user had `completed` selected and switches to Termine), it
|
||
// falls back to the per-type default and updates the URL params.
|
||
function populateStatusFilter() {
|
||
const sel = document.getElementById("events-filter-status") as HTMLSelectElement | null;
|
||
if (!sel) return;
|
||
const opts = statusOptionsFor(currentType);
|
||
sel.innerHTML = opts
|
||
.map((o) => `<option value="${esc(o.value)}">${esc(t(o.key))}</option>`)
|
||
.join("");
|
||
if (!opts.some((o) => o.value === statusFilter)) {
|
||
statusFilter = defaultStatusFor(currentType);
|
||
syncURLParams();
|
||
}
|
||
sel.value = statusFilter;
|
||
}
|
||
|
||
function populateProjectFilter() {
|
||
const sel = document.getElementById("events-filter-project") as HTMLSelectElement;
|
||
const options: string[] = [
|
||
`<option value="">${esc(t("deadlines.filter.akte.all"))}</option>`,
|
||
`<option value="${PERSONAL}">${esc(t("appointments.filter.akte.personal"))}</option>`,
|
||
];
|
||
for (const a of allProjects) {
|
||
const indent = projectIndent(a.path);
|
||
options.push(
|
||
`<option value="${esc(a.id)}">${indent}${esc(a.reference || "")} — ${esc(a.title)}</option>`,
|
||
);
|
||
}
|
||
sel.innerHTML = options.join("");
|
||
if (projectFilter) sel.value = projectFilter;
|
||
}
|
||
|
||
function initFilters() {
|
||
const params = urlParams();
|
||
currentType = (params.get("type") as EventTypeChoice) || defaultType();
|
||
const rawView = params.get("view");
|
||
if (rawView === "cards" || rawView === "list" || rawView === "calendar") {
|
||
currentView = rawView;
|
||
}
|
||
// Default depends on currentType: appointments default to "all" (no
|
||
// bucket filter), deadlines default to "pending". URL value wins.
|
||
if (params.has("status")) {
|
||
statusFilter = params.get("status")!;
|
||
} else {
|
||
statusFilter = defaultStatusFor(currentType);
|
||
}
|
||
if (params.has("project_id")) projectFilter = params.get("project_id")!;
|
||
// ?personal_only=true is a bookmark-friendly alias for project_id=__personal__.
|
||
// Both URLs land on the same UI state (PERSONAL sentinel in the project select).
|
||
if (params.get("personal_only") === "true") projectFilter = PERSONAL;
|
||
if (params.has("type_filter")) appointmentTypeFilter = params.get("type_filter")!;
|
||
|
||
const status = document.getElementById("events-filter-status") as HTMLSelectElement;
|
||
const project = document.getElementById("events-filter-project") as HTMLSelectElement;
|
||
const appointmentType = document.getElementById("events-filter-appointment-type") as HTMLSelectElement;
|
||
const eventTrigger = document.getElementById("events-filter-event-type") as HTMLButtonElement;
|
||
const eventPanel = document.getElementById("events-filter-event-type-panel") as HTMLElement;
|
||
|
||
status.value = statusFilter;
|
||
appointmentType.value = appointmentTypeFilter;
|
||
|
||
let initialEventIDs: string[] = [];
|
||
let initialIncludeUntyped = false;
|
||
const initialEventRaw = params.get("event_type") ?? "";
|
||
if (initialEventRaw) {
|
||
for (const tok of initialEventRaw.split(",")) {
|
||
const tt = tok.trim();
|
||
if (!tt) continue;
|
||
if (tt === "none") initialIncludeUntyped = true;
|
||
else initialEventIDs.push(tt);
|
||
}
|
||
}
|
||
|
||
status.addEventListener("change", async () => {
|
||
statusFilter = status.value;
|
||
syncURLParams();
|
||
await Promise.all([loadList(), loadSummary()]);
|
||
});
|
||
project.addEventListener("change", async () => {
|
||
projectFilter = project.value;
|
||
syncURLParams();
|
||
await Promise.all([loadList(), loadSummary()]);
|
||
});
|
||
appointmentType.addEventListener("change", async () => {
|
||
appointmentTypeFilter = appointmentType.value;
|
||
syncURLParams();
|
||
await loadList();
|
||
});
|
||
|
||
if (eventTrigger && eventPanel) {
|
||
eventTypeFilter = attachEventTypeMultiSelectFilter(eventTrigger, eventPanel, {
|
||
initialIDs: initialEventIDs,
|
||
initialIncludeUntyped: initialIncludeUntyped,
|
||
onChange: async () => {
|
||
syncURLParams();
|
||
await loadList();
|
||
},
|
||
});
|
||
}
|
||
|
||
document.querySelectorAll<HTMLButtonElement>('[data-event-type]').forEach((b) => {
|
||
b.addEventListener("click", async () => {
|
||
const next = b.dataset.eventType as EventTypeChoice;
|
||
if (next === currentType) return;
|
||
currentType = next;
|
||
applyTypeVisibility();
|
||
syncURLParams();
|
||
await Promise.all([loadList(), loadSummary()]);
|
||
});
|
||
});
|
||
}
|
||
|
||
function initView() {
|
||
// Calendar always opens on the current month — month navigation is
|
||
// local to the view (cheap pagination, doesn't refetch).
|
||
const now = new Date();
|
||
calYear = now.getFullYear();
|
||
calMonth = now.getMonth();
|
||
|
||
document.querySelectorAll<HTMLButtonElement>("[data-event-view]").forEach((btn) => {
|
||
btn.addEventListener("click", () => {
|
||
const next = btn.dataset.eventView as EventView;
|
||
if (next === currentView) return;
|
||
currentView = next;
|
||
applyView();
|
||
syncURLParams();
|
||
});
|
||
});
|
||
|
||
document.getElementById("events-cal-prev")?.addEventListener("click", () => {
|
||
calMonth -= 1;
|
||
if (calMonth < 0) { calMonth = 11; calYear -= 1; }
|
||
renderCalendar();
|
||
});
|
||
document.getElementById("events-cal-next")?.addEventListener("click", () => {
|
||
calMonth += 1;
|
||
if (calMonth > 11) { calMonth = 0; calYear += 1; }
|
||
renderCalendar();
|
||
});
|
||
document.getElementById("events-cal-today")?.addEventListener("click", () => {
|
||
const t = new Date();
|
||
calYear = t.getFullYear();
|
||
calMonth = t.getMonth();
|
||
renderCalendar();
|
||
});
|
||
|
||
const popup = document.getElementById("events-cal-popup") as HTMLElement;
|
||
document.getElementById("events-cal-popup-close")?.addEventListener("click", () => {
|
||
popup.style.display = "none";
|
||
});
|
||
popup?.addEventListener("click", (e) => {
|
||
if (e.target === popup) popup.style.display = "none";
|
||
});
|
||
}
|
||
|
||
function initSummaryCards() {
|
||
document.querySelectorAll<HTMLButtonElement>(".frist-summary-card").forEach((card) => {
|
||
card.addEventListener("click", async () => {
|
||
const bucket = card.dataset.bucket!;
|
||
// Map the card to the matching status filter; the events endpoint
|
||
// handles bucket-aware appointment filtering internally so both
|
||
// rails reflect the click consistently in Beides mode.
|
||
statusFilter = bucket;
|
||
const status = document.getElementById("events-filter-status") as HTMLSelectElement;
|
||
if (status) status.value = bucket;
|
||
syncURLParams();
|
||
await Promise.all([loadList(), loadSummary()]);
|
||
});
|
||
});
|
||
}
|
||
|
||
document.addEventListener("DOMContentLoaded", async () => {
|
||
initI18n();
|
||
initSidebar();
|
||
initFilters();
|
||
initView();
|
||
initSummaryCards();
|
||
applyTypeVisibility();
|
||
applyView();
|
||
onLangChange(() => {
|
||
// The static [data-i18n] options are retranslated by initI18n's
|
||
// applyTranslations(), but the project select is rebuilt at runtime
|
||
// and its "Alle Projekte" / "Nur persönliche" labels come from t() —
|
||
// re-run the populator so they pick up the new locale.
|
||
populateProjectFilter();
|
||
applyTypeVisibility();
|
||
render();
|
||
});
|
||
await Promise.all([loadProjects(), loadMe(), loadEventTypes()]);
|
||
populateProjectFilter();
|
||
await Promise.all([loadList(), loadSummary()]);
|
||
});
|