Files
paliad/frontend/src/client/events.ts
mAi 045accc6d9 mAi: #89 - deadline rule field binary Auto/Custom + canonical rule-label display
t-paliad-258. m's verdict on t-paliad-251's rule UI: "too many options"
(4 'Oral hearings' across courts, etc.). Replace the full deadline_rules
catalog dropdown + sort selector with a binary model and unify the rule
display contract across every surface that prints a rule label.

Binary Rule field on the deadline form
- Auto (default): rule_id is derived from the chosen Type. The resolved
  rule renders read-only as 'Auto | <Name · Citation>' next to the
  field. No catalog picker, no sort options.
- Custom: free-text input. Stored as deadlines.custom_rule_text (new
  nullable column, migration 122). Mutually exclusive with rule_id at
  the persistence boundary.
- Toggle link flips between modes. Re-toggling to Auto re-resolves from
  the current Type — no stale state.

Schema + service (additive)
- migration 122 adds paliad.deadlines.custom_rule_text (nullable).
  Existing rows: empty custom_rule_text + non-null rule_id = Auto-
  equivalent. Both NULL = "keine Regel" (consistent with today).
- models.Deadline.CustomRuleText + service SELECTs include the column.
- CreateDeadlineInput accepts custom_rule_text; the service drops it
  when rule_id is set (catalog wins; simple invariant at the boundary).
- UpdateDeadlineInput grows a {RuleSet, RuleID, CustomRuleText} triple.
  RuleSet=true is the discriminator so absent fields don't overwrite
  the row (PATCH semantics). RuleID and CustomRuleText are mutually
  exclusive in one request; service rejects "both set".
- EventListItem (the /api/events union) carries CustomRuleText so list
  surfaces can render it.

Frontend: deadlines-new
- Drop the rule <select>, the by_proceeding/by_court/alpha sort
  dropdown, the override-warning slot, and the collapsed-by-Regel Typ
  view. Strip the (Rule→Type) auto-fill machinery — direction is now
  one-way (Type → Auto-resolved Rule).
- Keep Type→Rule resolution: resolveAutoRuleForType picks the canonical
  rule by project's proceeding, then jurisdiction match, then first
  candidate. Same logic, just re-aimed at the read-only display.
- Standardtitel preserves the chain (event type → Auto rule label →
  Custom text → proceeding → fallback) so the recipe still produces a
  sensible title even when Custom is used.

Frontend: deadlines-detail
- Read-only display: catalog rule → Name · Citation, else
  custom_rule_text + Custom badge, else legacy rule_code, else "—".
- Edit mode: mirror the create form with the Auto/Custom toggle.
  enterEdit initialises the mode from the persisted deadline; Save
  PATCHes with rule_set:true + the chosen rule pointer.

Rule-label addendum (m's 14:31 follow-up)
- Canonical contract everywhere: Name primary, Citation muted secondary
  ("Notice of Appeal · UPC.RoP.220.1"). Custom rules render the text
  with a "Custom" pill.
- New frontend/src/client/rule-label.ts exports formatRuleLabel /
  formatRuleLabelHTML / formatCustomRuleLabelHTML — one helper per
  shape (plain text vs muted-citation HTML).
- Wired into: deadlines-new Auto display, deadlines-detail read +
  Standardtitel, events.ts ruleDisplay (REGEL column on /events),
  projects-detail.ts Fristen table, views/shape-list.ts generic
  rule column.
- Verfahrensablauf (views/verfahrensablauf-core.ts) already renders
  name + citation chip separately and matches the canonical pattern;
  no change needed. Schriftsätze table is column-shaped (name + code
  in distinct columns) and out of scope per the addendum.

CSS
- New .rule-mode-auto / .rule-mode-custom / .rule-label-* family.
- Drop the dead .rule-sort-select rule and the .event-type-collapsed*
  family (retired with the catalog dropdown).

i18n
- DE+EN. Remove 10 stale keys (rule.none, autofill, autofill_inline,
  mismatch, override, override_warn, sort.*). Add 6 (auto_no_match,
  auto_pick_type, custom_badge, custom_placeholder,
  mode.toggle_to_auto, mode.toggle_to_custom).

Build hygiene
- go build + go test ./internal/... clean.
- frontend bun build clean (2803 keys, scan clean).

Out of scope (per issue)
- Promoting Custom entries back to the catalog ("save as new rule").
- Filtering/searching custom_rule_text in deadline lists.
- Touching the event-type browse modal (Part 1 of #82 — that stays).

Files
- internal/db/migrations/122_deadlines_custom_rule_text.{up,down}.sql
- internal/models/models.go
- internal/services/deadline_service.go (Create+Update+SELECT)
- internal/services/event_service.go (union projection)
- frontend/src/client/rule-label.ts (new helper)
- frontend/src/client/deadlines-new.ts (rewrite)
- frontend/src/client/deadlines-detail.ts (Auto/Custom editor + display)
- frontend/src/client/events.ts (REGEL column)
- frontend/src/client/projects-detail.ts (Fristen table cell)
- frontend/src/client/views/shape-list.ts (generic rule column)
- frontend/src/client/i18n.ts + i18n-keys.ts (DE+EN delta)
- frontend/src/deadlines-new.tsx (strip dropdown+sort, add toggle)
- frontend/src/deadlines-detail.tsx (Auto/Custom edit slots)
- frontend/src/styles/global.css (rule-mode + rule-label families)
2026-05-25 14:54:51 +02:00

1017 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";
import { formatRuleLabelHTML, formatCustomRuleLabelHTML } from "./rule-label";
// 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;
// t-paliad-258 — free-text rule label when the deadline was created
// via the Custom rule path. Mutually exclusive with rule_id.
custom_rule_text?: 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 "";
// t-paliad-258 addendum — canonical display contract: Name primary,
// Citation muted secondary ("Notice of Appeal · UPC.RoP.220.1").
// Custom rules render the lawyer's free text + a "Custom" badge.
// Legacy rule-code-only saves (Fristenrechner, no rule_id) still
// show the bare citation as last-resort fallback.
const hasName = (item.rule_name && item.rule_name.trim()) ||
(item.rule_name_en && item.rule_name_en.trim());
if (hasName || (item.rule_code && item.rule_code.trim())) {
return formatRuleLabelHTML(
{
name: item.rule_name || "",
name_en: item.rule_name_en,
rule_code: item.rule_code,
},
esc,
);
}
if (item.custom_rule_text && item.custom_rule_text.trim()) {
return formatCustomRuleLabelHTML(item.custom_rule_text, esc);
}
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()]);
});