Files
paliad/frontend/src/client/events.ts
m 4bab520119 feat(approval-pill): icon-only eye pill on /deadlines + /appointments + /agenda
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.
2026-05-08 18:18:16 +02:00

1071 lines
40 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";
// 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 "&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;
}
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) : "&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.
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 || "&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>`;
}
// 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()]);
});