diff --git a/cmd/server/main.go b/cmd/server/main.go
index 6639fc0..cc9cd94 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -116,6 +116,7 @@ func main() {
ChecklistInst: services.NewChecklistInstanceService(pool, projectSvc),
Mail: mailSvc,
Invite: inviteSvc,
+ Agenda: services.NewAgendaService(pool, users),
}
log.Println("Phase B services initialised")
diff --git a/frontend/build.ts b/frontend/build.ts
index d4f5faa..00caaf2 100644
--- a/frontend/build.ts
+++ b/frontend/build.ts
@@ -25,6 +25,7 @@ import { renderAppointmentsDetail } from "./src/appointments-detail";
import { renderAppointmentsCalendar } from "./src/appointments-calendar";
import { renderSettings } from "./src/settings";
import { renderDashboard } from "./src/dashboard";
+import { renderAgenda } from "./src/agenda";
import { renderOnboarding } from "./src/onboarding";
const DIST = join(import.meta.dir, "dist");
@@ -62,6 +63,7 @@ async function build() {
join(import.meta.dir, "src/client/appointments-calendar.ts"),
join(import.meta.dir, "src/client/settings.ts"),
join(import.meta.dir, "src/client/dashboard.ts"),
+ join(import.meta.dir, "src/client/agenda.ts"),
join(import.meta.dir, "src/client/onboarding.ts"),
],
outdir: join(DIST, "assets"),
@@ -109,6 +111,7 @@ async function build() {
await Bun.write(join(DIST, "appointments-calendar.html"), renderAppointmentsCalendar());
await Bun.write(join(DIST, "settings.html"), renderSettings());
await Bun.write(join(DIST, "dashboard.html"), renderDashboard());
+ await Bun.write(join(DIST, "agenda.html"), renderAgenda());
await Bun.write(join(DIST, "onboarding.html"), renderOnboarding());
console.log("Build complete \u2192 dist/");
diff --git a/frontend/src/agenda.tsx b/frontend/src/agenda.tsx
new file mode 100644
index 0000000..e8f9e65
--- /dev/null
+++ b/frontend/src/agenda.tsx
@@ -0,0 +1,85 @@
+import { h } from "./jsx";
+import { Sidebar } from "./components/Sidebar";
+import { Footer } from "./components/Footer";
+
+// The /*__PALIAD_AGENDA_DATA__*/ token is replaced at request time by the Go
+// handler (internal/handlers/agenda_shell.go) with a JSON payload assigned
+// to window.__PALIAD_AGENDA__. Keep the token intact and exactly once.
+const HYDRATION_SCRIPT = "/*__PALIAD_AGENDA_DATA__*/";
+
+export function renderAgenda(): string {
+ return "" + (
+
+
+
+
+ Agenda — Paliad
+
+
+
+
+
+
+
+
+
+
+
+
+
Agenda
+
+ Kommende Fristen und Termine über alle sichtbaren Akten, nach Tag gruppiert.
+
+
+
+
+
+
+
+ Agenda zurzeit nicht verfügbar — bitte Administrator kontaktieren.
+
+
+
+
+
+
Ansicht
+
+
+
+
+
+
+
+
+
Zeitraum
+
+
+
+
+
+
+
+
+
+
+ Lädt …
+
+
+
+
+
+
Keine Einträge im Zeitraum
+
+ Nichts Fälliges — erweitern Sie den Zeitraum oder legen Sie neue Fristen oder Termine an.
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/client/agenda.ts b/frontend/src/client/agenda.ts
new file mode 100644
index 0000000..0f02bd0
--- /dev/null
+++ b/frontend/src/client/agenda.ts
@@ -0,0 +1,355 @@
+import { initI18n, onLangChange, t, getLang } from "./i18n";
+import { initSidebar } from "./sidebar";
+
+type Urgency = "overdue" | "today" | "tomorrow" | "this_week" | "later";
+type AgendaType = "deadline" | "appointment";
+type TypeFilter = "both" | "deadlines" | "appointments";
+
+interface AgendaItem {
+ id: string;
+ type: AgendaType;
+ title: string;
+ date: string; // ISO 8601
+ end_at?: string | null;
+ due_date?: string | null; // YYYY-MM-DD (deadlines only)
+ status?: string | null; // deadlines: pending/completed/...
+ location?: string | null;
+ appointment_type?: string | null;
+ urgency: Urgency;
+ project_id?: string | null;
+ project_title?: string | null;
+ project_type?: string | null; // client | litigation | patent | case | project
+ project_reference?: string | null;
+}
+
+interface AgendaPayload {
+ items: AgendaItem[];
+ from: string;
+ to: string;
+ types: string[];
+}
+
+declare global {
+ interface Window {
+ __PALIAD_AGENDA__?: AgendaPayload | null;
+ }
+}
+
+// Range presets match the TSX chips; 30d stays the default (server agrees).
+const RANGE_DAYS_DEFAULT = 30;
+const VALID_RANGES = new Set([7, 14, 30, 90]);
+
+const state = {
+ items: [] as AgendaItem[],
+ type: "both" as TypeFilter,
+ rangeDays: RANGE_DAYS_DEFAULT,
+};
+
+document.addEventListener("DOMContentLoaded", () => {
+ initI18n();
+ initSidebar();
+ readInitialStateFromURL();
+
+ const inlined = window.__PALIAD_AGENDA__;
+ if (inlined !== undefined) {
+ if (inlined === null) {
+ showUnavailable();
+ } else {
+ hydrate(inlined);
+ }
+ } else {
+ void refetch();
+ }
+
+ wireControls();
+ onLangChange(() => render());
+});
+
+// Pull initial state from ?types=...&range=... so reloads and bookmarks work.
+// Any deviation triggers a refetch via wireControls once the UI is ready.
+function readInitialStateFromURL(): void {
+ const q = new URLSearchParams(window.location.search);
+ const typesRaw = q.get("types");
+ if (typesRaw) {
+ const set = new Set(typesRaw.split(",").map((s) => s.trim()));
+ const hasD = set.has("deadlines");
+ const hasA = set.has("appointments");
+ if (hasD && !hasA) state.type = "deadlines";
+ else if (hasA && !hasD) state.type = "appointments";
+ else state.type = "both";
+ }
+ const rangeRaw = q.get("range");
+ if (rangeRaw) {
+ const n = parseInt(rangeRaw, 10);
+ if (!isNaN(n) && VALID_RANGES.has(n)) state.rangeDays = n;
+ }
+}
+
+function hydrate(payload: AgendaPayload): void {
+ state.items = payload.items;
+ // Infer type filter from server payload when the URL didn't pin it.
+ if (!window.location.search.includes("types=")) {
+ const set = new Set(payload.types);
+ if (set.has("deadlines") && !set.has("appointments")) state.type = "deadlines";
+ else if (set.has("appointments") && !set.has("deadlines")) state.type = "appointments";
+ else state.type = "both";
+ }
+ render();
+}
+
+function wireControls(): void {
+ document.querySelectorAll(".agenda-chip[data-type]").forEach((btn) => {
+ btn.addEventListener("click", () => {
+ const next = (btn.dataset.type || "both") as TypeFilter;
+ if (state.type === next) return;
+ state.type = next;
+ pushURL();
+ void refetch();
+ });
+ });
+ document.querySelectorAll(".agenda-chip[data-range]").forEach((btn) => {
+ btn.addEventListener("click", () => {
+ const next = parseInt(btn.dataset.range || "30", 10);
+ if (!VALID_RANGES.has(next) || state.rangeDays === next) return;
+ state.rangeDays = next;
+ pushURL();
+ void refetch();
+ });
+ });
+ syncChips();
+}
+
+function pushURL(): void {
+ const q = new URLSearchParams(window.location.search);
+ q.set("range", String(state.rangeDays));
+ q.set("types", typesParam(state.type));
+ history.replaceState(null, "", `${window.location.pathname}?${q.toString()}`);
+}
+
+function typesParam(tf: TypeFilter): string {
+ if (tf === "deadlines") return "deadlines";
+ if (tf === "appointments") return "appointments";
+ return "deadlines,appointments";
+}
+
+async function refetch(): Promise {
+ const loading = document.getElementById("agenda-loading")!;
+ const timeline = document.getElementById("agenda-timeline")!;
+ const empty = document.getElementById("agenda-empty")!;
+ loading.style.display = "block";
+ timeline.style.display = "none";
+ empty.style.display = "none";
+ syncChips();
+
+ const from = toISODate(startOfToday());
+ const to = toISODate(addDays(startOfToday(), state.rangeDays - 1));
+ const url = `/api/agenda?from=${from}&to=${to}&types=${typesParam(state.type)}`;
+ try {
+ const resp = await fetch(url);
+ if (resp.status === 503) {
+ showUnavailable();
+ return;
+ }
+ if (!resp.ok) throw new Error(`status ${resp.status}`);
+ state.items = (await resp.json()) as AgendaItem[];
+ render();
+ } catch {
+ showUnavailable();
+ } finally {
+ loading.style.display = "none";
+ }
+}
+
+function showUnavailable(): void {
+ document.getElementById("agenda-unavailable")!.style.display = "block";
+ document.getElementById("agenda-timeline")!.style.display = "none";
+ document.getElementById("agenda-empty")!.style.display = "none";
+}
+
+function render(): void {
+ syncChips();
+ const timeline = document.getElementById("agenda-timeline")!;
+ const empty = document.getElementById("agenda-empty")!;
+
+ if (!state.items.length) {
+ timeline.innerHTML = "";
+ timeline.style.display = "none";
+ empty.style.display = "block";
+ return;
+ }
+ empty.style.display = "none";
+ timeline.style.display = "";
+
+ const buckets = groupByDay(state.items);
+ timeline.innerHTML = buckets.map((b) => renderDay(b)).join("");
+}
+
+interface DayBucket {
+ dayKey: string; // YYYY-MM-DD local
+ day: Date;
+ items: AgendaItem[];
+}
+
+function groupByDay(items: AgendaItem[]): DayBucket[] {
+ const map = new Map();
+ for (const it of items) {
+ const d = new Date(it.date);
+ if (isNaN(d.getTime())) continue;
+ const key = toLocalDayKey(d);
+ let b = map.get(key);
+ if (!b) {
+ b = { dayKey: key, day: new Date(d.getFullYear(), d.getMonth(), d.getDate()), items: [] };
+ map.set(key, b);
+ }
+ b.items.push(it);
+ }
+ return Array.from(map.values()).sort((a, b) => a.day.getTime() - b.day.getTime());
+}
+
+function renderDay(bucket: DayBucket): string {
+ return `
+
+ ${esc(relativeDayLabel(bucket.day))}
+ ${esc(fullDateLabel(bucket.day))}
+
+
+ ${bucket.items.map(renderItem).join("")}
+
+ `;
+}
+
+function renderItem(it: AgendaItem): string {
+ const urgencyClass = `agenda-item-${it.urgency}`;
+ const typeClass = `agenda-item-type-${it.type}`;
+ const iconHTML = it.type === "deadline" ? deadlineIcon() : appointmentIcon();
+ const detailHref = itemDetailHref(it);
+ const project = it.project_id
+ ? `${esc(formatProjectLabel(it))}`
+ : "";
+
+ const timePart = it.type === "appointment"
+ ? `${esc(formatAppointmentTime(it))}`
+ : "";
+ const urgencyTag = `${esc(t(`agenda.urgency.${it.urgency}`))}`;
+ const locationPart = it.type === "appointment" && it.location
+ ? `${esc(it.location)}`
+ : "";
+ const typeLabelKey = it.type === "deadline"
+ ? "agenda.label.deadline"
+ : (it.appointment_type ? `agenda.appointment_type.${it.appointment_type}` : "agenda.label.appointment");
+ const typeLabel = t(typeLabelKey);
+
+ return `
+
+ ${iconHTML}
+
+
+ ${esc(typeLabel)}:
+ ${esc(it.title)}
+
+
+ ${project}
+ ${timePart}
+ ${locationPart}
+
+
+
+ ${urgencyTag}
+
+
+ `;
+}
+
+function itemDetailHref(it: AgendaItem): string {
+ return it.type === "deadline"
+ ? `/deadlines/${encodeURIComponent(it.id)}`
+ : `/appointments/${encodeURIComponent(it.id)}`;
+}
+
+function formatProjectLabel(it: AgendaItem): string {
+ const ref = it.project_reference ? `${it.project_reference} · ` : "";
+ const title = it.project_title || "";
+ return `${ref}${title}`.trim();
+}
+
+function formatAppointmentTime(it: AgendaItem): string {
+ const start = new Date(it.date);
+ if (isNaN(start.getTime())) return "";
+ const locale = getLang() === "de" ? "de-DE" : "en-GB";
+ const startStr = start.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" });
+ if (!it.end_at) return startStr;
+ const end = new Date(it.end_at);
+ if (isNaN(end.getTime())) return startStr;
+ const endStr = end.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" });
+ return `${startStr}–${endStr}`;
+}
+
+function relativeDayLabel(day: Date): string {
+ const today = startOfToday();
+ const diff = Math.round((day.getTime() - today.getTime()) / 86400000);
+ if (diff < 0) {
+ const n = Math.abs(diff);
+ return getLang() === "de"
+ ? (n === 1 ? "Gestern" : `vor ${n} Tagen`)
+ : (n === 1 ? "Yesterday" : `${n} days ago`);
+ }
+ if (diff === 0) return t("agenda.day.today");
+ if (diff === 1) return t("agenda.day.tomorrow");
+ return getLang() === "de" ? `in ${diff} Tagen` : `in ${diff} days`;
+}
+
+function fullDateLabel(day: Date): string {
+ const locale = getLang() === "de" ? "de-DE" : "en-GB";
+ return day.toLocaleDateString(locale, {
+ weekday: "long",
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ });
+}
+
+function syncChips(): void {
+ document.querySelectorAll(".agenda-chip[data-type]").forEach((btn) => {
+ btn.classList.toggle("agenda-chip-active", btn.dataset.type === state.type);
+ });
+ document.querySelectorAll(".agenda-chip[data-range]").forEach((btn) => {
+ btn.classList.toggle("agenda-chip-active", btn.dataset.range === String(state.rangeDays));
+ });
+}
+
+function startOfToday(): Date {
+ const d = new Date();
+ d.setHours(0, 0, 0, 0);
+ return d;
+}
+
+function addDays(d: Date, days: number): Date {
+ const r = new Date(d);
+ r.setDate(r.getDate() + days);
+ return r;
+}
+
+function toISODate(d: Date): string {
+ const y = d.getFullYear();
+ const m = String(d.getMonth() + 1).padStart(2, "0");
+ const day = String(d.getDate()).padStart(2, "0");
+ return `${y}-${m}-${day}`;
+}
+
+function toLocalDayKey(d: Date): string {
+ return toISODate(d);
+}
+
+function esc(s: string): string {
+ const div = document.createElement("div");
+ div.textContent = s ?? "";
+ return div.innerHTML;
+}
+
+function deadlineIcon(): string {
+ return '';
+}
+
+function appointmentIcon(): string {
+ return '';
+}
diff --git a/frontend/src/client/i18n.ts b/frontend/src/client/i18n.ts
index ec69ff4..11e51a8 100644
--- a/frontend/src/client/i18n.ts
+++ b/frontend/src/client/i18n.ts
@@ -27,6 +27,7 @@ const translations: Record> = {
"nav.fristen": "Fristen",
"nav.termine": "Termine",
"nav.dashboard": "Dashboard",
+ "nav.agenda": "Agenda",
"nav.group.uebersicht": "\u00dcbersicht",
"nav.group.arbeit": "Arbeit",
"nav.group.werkzeuge": "Werkzeuge",
@@ -1011,6 +1012,37 @@ const translations: Record> = {
"notizen.time.just_now": "gerade eben",
"notizen.error.empty": "Notiz darf nicht leer sein.",
"notizen.error.generic": "Aktion fehlgeschlagen. Bitte erneut versuchen.",
+
+ // Agenda (t-paliad-030) — unified timeline across projects
+ "agenda.title": "Agenda — Paliad",
+ "agenda.heading": "Agenda",
+ "agenda.subtitle": "Kommende Fristen und Termine über alle sichtbaren Akten, nach Tag gruppiert.",
+ "agenda.unavailable": "Agenda zurzeit nicht verfügbar — bitte Administrator kontaktieren.",
+ "agenda.loading": "Lädt …",
+ "agenda.filter.type": "Ansicht",
+ "agenda.filter.both": "Beides",
+ "agenda.filter.deadlines": "Nur Fristen",
+ "agenda.filter.appointments": "Nur Termine",
+ "agenda.filter.range": "Zeitraum",
+ "agenda.range.7": "7 Tage",
+ "agenda.range.14": "14 Tage",
+ "agenda.range.30": "30 Tage",
+ "agenda.range.90": "90 Tage",
+ "agenda.empty.title": "Keine Einträge im Zeitraum",
+ "agenda.empty.hint": "Nichts Fälliges — erweitern Sie den Zeitraum oder legen Sie neue Fristen oder Termine an.",
+ "agenda.label.deadline": "Frist",
+ "agenda.label.appointment": "Termin",
+ "agenda.appointment_type.hearing": "Verhandlung",
+ "agenda.appointment_type.meeting": "Besprechung",
+ "agenda.appointment_type.consultation": "Mandantentermin",
+ "agenda.appointment_type.deadline_hearing": "Fristentermin",
+ "agenda.day.today": "Heute",
+ "agenda.day.tomorrow": "Morgen",
+ "agenda.urgency.overdue": "Überfällig",
+ "agenda.urgency.today": "Heute",
+ "agenda.urgency.tomorrow": "Morgen",
+ "agenda.urgency.this_week": "Diese Woche",
+ "agenda.urgency.later": "Später",
},
en: {
@@ -1030,6 +1062,7 @@ const translations: Record> = {
"nav.fristen": "Deadlines",
"nav.termine": "Appointments",
"nav.dashboard": "Dashboard",
+ "nav.agenda": "Agenda",
"nav.group.uebersicht": "Overview",
"nav.group.arbeit": "Work",
"nav.group.werkzeuge": "Tools",
@@ -2014,6 +2047,37 @@ const translations: Record> = {
"notizen.time.just_now": "just now",
"notizen.error.empty": "Note cannot be empty.",
"notizen.error.generic": "Action failed. Please try again.",
+
+ // Agenda (t-paliad-030) — unified timeline across projects
+ "agenda.title": "Agenda — Paliad",
+ "agenda.heading": "Agenda",
+ "agenda.subtitle": "Upcoming deadlines and appointments across all visible matters, grouped by day.",
+ "agenda.unavailable": "Agenda is currently unavailable — please contact an administrator.",
+ "agenda.loading": "Loading …",
+ "agenda.filter.type": "View",
+ "agenda.filter.both": "Both",
+ "agenda.filter.deadlines": "Deadlines only",
+ "agenda.filter.appointments": "Appointments only",
+ "agenda.filter.range": "Range",
+ "agenda.range.7": "7 days",
+ "agenda.range.14": "14 days",
+ "agenda.range.30": "30 days",
+ "agenda.range.90": "90 days",
+ "agenda.empty.title": "Nothing in this range",
+ "agenda.empty.hint": "Nothing due — widen the range or create new deadlines or appointments.",
+ "agenda.label.deadline": "Deadline",
+ "agenda.label.appointment": "Appointment",
+ "agenda.appointment_type.hearing": "Hearing",
+ "agenda.appointment_type.meeting": "Meeting",
+ "agenda.appointment_type.consultation": "Client meeting",
+ "agenda.appointment_type.deadline_hearing": "Deadline hearing",
+ "agenda.day.today": "Today",
+ "agenda.day.tomorrow": "Tomorrow",
+ "agenda.urgency.overdue": "Overdue",
+ "agenda.urgency.today": "Today",
+ "agenda.urgency.tomorrow": "Tomorrow",
+ "agenda.urgency.this_week": "This week",
+ "agenda.urgency.later": "Later",
},
};
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx
index 1a8e5d5..5ed0dfb 100644
--- a/frontend/src/components/Sidebar.tsx
+++ b/frontend/src/components/Sidebar.tsx
@@ -16,6 +16,7 @@ const ICON_MENU = '