Files
paliad/frontend/src/client/events.ts
mAi d0f732d0ec refactor(events): t-paliad-224 — fold Kalender tab into mountCalendar()
The /events Kalender view now mounts the canonical mountCalendar()
module from frontend/src/client/calendar/ — same renderer Custom
Views uses for shape=calendar. Drops the events-page-specific
month-grid + popup code path entirely.

What replaces what
- renderCalendar() / openCalPopup() / calDotClass / fmtMonthYear /
  isoDate / itemDateISO and the calYear/calMonth module state →
  one mountCalendar() handle (lazy, urlState=true).
- events-cal-prev / events-cal-next / events-cal-today buttons →
  toolbar in mountCalendar (includes its own 'Heute' button).
- modal popup on cell click → drill-down to day view (matches
  /views; head decision §11 Q2).
- @media min-height shrink on .frist-cal-cell → views-calendar-*
  responsive surface (CSS unchanged from /views).

Behavioural deltas vs pre-refactor
- /events Kalender now persists view+anchor in ?cal_view + ?cal_date
  (head decision §11 Q3) — refresh / share-link safe.
- Pills are kind-coded (deadline / appointment) rather than urgency-
  coded; matches /views (head decision §11 Q4 — drop subtype dot
  colouring, file as follow-up).
- Empty-month message gone; the per-day no-entries state from the
  day-view replaces it (head decision §11 Q8 — drop dead i18n).

Adapter: toCalendarItem() preserves the pre-refactor bucketing rule
— deadlines bucket on due_date, appointments on start_at, both fall
back to event_date.

events.tsx: 31-line calendar subtree (toolbar + grid + modal +
empty hint) reduces to a single host div. mountCalendar fills it
when the user picks Kalender.
2026-05-20 15:23:28 +02:00

1000 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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";
import { mountCalendar, type CalendarHandle, type CalendarItem } from "./calendar/mount-calendar";
// Two-eyes glyph 👀 inside .approval-pill--icon. m's 2026-05-08 follow-
// up: "two eyes instead of the one." Emoji rather than SVG keeps the
// pill markup trivially short and inherits the user's emoji font.
const APPROVAL_PILL_GLYPH = "👀";
// Sparkle glyph ✨ inside .approval-pill--agent (t-paliad-161). Renders
// next to (not in place of) 👀 when the pending row originated from a
// Paliadin chat suggestion. The two glyphs are orthogonal: 👀 = "needs
// approval", ✨ = "Paliadin drafted this". Both can coexist; either can
// appear alone in future autopilot states.
const AGENT_PILL_GLYPH = "✨";
// 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";
// t-paliad-161: when approval_status='pending', tells us whether the row
// was drafted by a user or by Paliadin (✨ glyph). NULL when not pending.
requester_kind?: "user" | "agent";
// 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" },
];
// Appointment status options — m/paliad#54: the legacy 'upcoming' /
// "Ab heute" option was a UI lie (backend never narrowed past events for
// appointments) and is removed. 'today' is the sane default — matches the
// dashboard tile. 'all' stays as the explicit opt-in for past events.
const STATUS_OPTIONS_APPOINTMENT: StatusOption[] = [
{ 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: "all", key: "events.filter.status.all" },
];
function statusOptionsFor(type: EventTypeChoice): StatusOption[] {
if (type === "appointment") return STATUS_OPTIONS_APPOINTMENT;
return STATUS_OPTIONS_DEADLINE;
}
function defaultStatusFor(type: EventTypeChoice): string {
return type === "appointment" ? "today" : "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;
// Calendar handle is created lazily when /events first switches into the
// Kalender view (t-paliad-224). The handle owns its own month/week/day
// state + ?cal_view / ?cal_date URL contract via mountCalendar.
let calendar: CalendarHandle | null = null;
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 "&mdash;";
}
function eventTypeDisplay(item: EventListItem): string {
if (item.type !== "deadline") return "";
const ids = item.event_type_ids ?? [];
if (ids.length === 0) return "&mdash;";
const labels: string[] = [];
for (const id of ids) {
const et = eventTypeByID.get(id);
if (et) labels.push(eventTypeLabel(et));
}
if (labels.length === 0) return "&mdash;";
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;
teardownCalendar();
}
function render() {
if (!loadedOK) return;
if (currentView === "calendar") {
renderCalendarView();
} 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) : "&mdash;";
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>`
: "&mdash;";
// 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.
//
// t-paliad-161 ✨: when the pending row came from a Paliadin
// suggestion (requester_kind='agent'), drop a second pill next to 👀.
// Two glyphs read together as "needs approval, drafted by Paliadin".
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_GLYPH}</span>`
: "";
const agentLabel = t("approvals.agent.label");
const agentPill = item.approval_status === "pending" && item.requester_kind === "agent"
? `<span class="approval-pill approval-pill--agent" title="${esc(agentLabel)}" aria-label="${esc(agentLabel)}">${AGENT_PILL_GLYPH}</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 : ""}${agentPill ? " " + agentPill : ""}</td>
<td class="frist-col-project">${projectCell}</td>
<td class="frist-col-rule events-col-rule">${ruleLabel || "&mdash;"}</td>
<td class="entity-col-event-type">${eventTypeCell || "&mdash;"}</td>
<td class="events-col-location">${locationCell}</td>
<td class="events-col-appointment-type">${appointmentTypeCell}</td>
<td class="entity-col-status">${statusCell}</td>
</tr>`;
}
// toCalendarItem adapts an EventListItem to the canonical CalendarItem
// shape consumed by mountCalendar (t-paliad-224). Bucketing date matches
// the pre-refactor behaviour: deadlines bucket on due_date (fallback to
// event_date); appointments bucket on start_at (fallback to event_date).
function toCalendarItem(item: EventListItem): CalendarItem {
let bucketDate: string;
if (item.type === "deadline") {
bucketDate = item.due_date ?? item.event_date;
} else if (item.start_at) {
bucketDate = item.start_at;
} else {
bucketDate = item.event_date;
}
return {
kind: item.type,
id: item.id,
title: item.title,
event_date: bucketDate,
project_id: item.project_id,
project_title: item.project_title,
project_reference: item.project_reference,
};
}
function renderCalendarView() {
const host = document.getElementById("events-calendar-wrap");
if (!host) return;
const tableEmpty = document.getElementById("events-empty")!;
const tableEmptyFiltered = document.getElementById("events-empty-filtered")!;
tableEmpty.style.display = "none";
tableEmptyFiltered.style.display = "none";
(host as HTMLElement).hidden = false;
const items = allItems.map(toCalendarItem);
if (calendar) {
calendar.update(items);
return;
}
// urlState=true: the Kalender tab persists its month/week/day + anchor
// in ?cal_view + ?cal_date so a refresh / shared link lands on the same
// calendar state (per t-paliad-224 §11 Q3 head decision).
calendar = mountCalendar(host as HTMLElement, items, {
urlState: true,
defaultView: "month",
});
}
function teardownCalendar() {
if (!calendar) return;
calendar.destroy();
calendar = null;
}
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 = mountCalendar() canon (month/week/day); cards + table
// both hidden. The handle is torn down when the user leaves Kalender
// so its URL state isn't reapplied to other shapes.
summary.style.display = currentView === "cards" ? "" : "none";
tableWrap.style.display = currentView === "calendar" ? "none" : "";
calWrap.hidden = currentView !== "calendar";
if (currentView === "calendar") {
if (loadedOK) renderCalendarView();
} else {
teardownCalendar();
}
}
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;
const titleCell = row.querySelector<HTMLElement>(".events-title");
const title = (titleCell?.textContent || "").trim();
const msg = t("deadlines.complete.confirm").replace("{title}", title || "?");
if (!window.confirm(msg)) {
cb.checked = false;
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() {
// Kalender state (view + anchor) lives inside mountCalendar; no
// events-page-level wiring needed. The view chips below switch
// between Karten / Liste / Kalender; applyView() handles the
// mount + teardown.
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();
});
});
}
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()]);
});