Files
paliad/frontend/src/client/agenda.ts
m e824898a6d feat(navbar/dashboard): t-paliad-162 reorg sidebar groups + inline Agenda + collapsible sections
Sidebar:
- Paliadin lifted out of Übersicht to a top-level entry directly under
  Home (owner-only reveal logic unchanged — same id reused).
- Agenda removed from sidebar; the standalone /agenda route stays for
  direct-link compatibility but the dashboard hosts its content inline.
- Projekte moved into Übersicht; Fristen + Termine moved into a new
  Ansichten group; the Arbeit group is gone.
- Werkzeuge / Wissen / Ressourcen collapsed into one Werkzeuge group
  per m's brief order (calculators → reference → content).
- BottomNav agenda slot repointed to /events?type=deadline so the
  overdue+today badge still has a sensible target on mobile.

Dashboard:
- Agenda renders inline as a new collapsible section between the
  upcoming-rails grid and Letzte Aktivität, with a "Vollständige Agenda
  öffnen →" link to the standalone page.
- Letzte Aktivität moved under Agenda per m's design call.
- Sections (summary, deadlines, appointments, agenda, activity) become
  collapsible via a chevron toggle; state persists in
  localStorage[paliad:dashboard:collapse:<section>]. Matters card stays
  whole-card-tappable, so it's intentionally left non-collapsible.
- Inline agenda fetches /api/agenda directly with a 30-day window and
  refreshes on the existing 60s dashboard poll.

Render primitives:
- New client/agenda-render.ts hosts renderAgendaTimeline + AgendaItem
  type, shared by client/agenda.ts and client/dashboard.ts. Standalone
  agenda.ts shrinks accordingly; behaviour is identical.

i18n:
- Added nav.group.ansichten + dashboard.agenda.* + dashboard.section.*
  keys (DE/EN). Removed nav.group.{arbeit,wissen,ressourcen} (no other
  callers; i18n-keys.ts auto-regenerated).
2026-05-08 20:20:57 +02:00

227 lines
7.0 KiB
TypeScript

import { initI18n, onLangChange } from "./i18n";
import { initSidebar } from "./sidebar";
import { attachEventTypeMultiSelectFilter, type FilterHandle } from "./event-types";
import { renderAgendaTimeline, type AgendaItem } from "./agenda-render";
let eventTypeFilter: FilterHandle | null = null;
type TypeFilter = "both" | "deadlines" | "appointments";
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<HTMLButtonElement>(".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<HTMLButtonElement>(".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();
const eventTrigger = document.getElementById("agenda-filter-event-type") as HTMLButtonElement | null;
const eventPanel = document.getElementById("agenda-filter-event-type-panel") as HTMLElement | null;
if (eventTrigger && eventPanel) {
const q = new URLSearchParams(window.location.search);
const initialEventIDs: string[] = [];
let initialIncludeUntyped = false;
const raw = q.get("event_type") ?? "";
if (raw) {
for (const tok of raw.split(",")) {
const t = tok.trim();
if (!t) continue;
if (t === "none") initialIncludeUntyped = true;
else initialEventIDs.push(t);
}
}
eventTypeFilter = attachEventTypeMultiSelectFilter(eventTrigger, eventPanel, {
initialIDs: initialEventIDs,
initialIncludeUntyped,
onChange: () => {
pushURL();
void refetch();
},
});
}
}
function pushURL(): void {
const q = new URLSearchParams(window.location.search);
q.set("range", String(state.rangeDays));
q.set("types", typesParam(state.type));
const eventQuery = eventTypeFilter?.toQueryValue() ?? "";
if (eventQuery) q.set("event_type", eventQuery);
else q.delete("event_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<void> {
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 eventQuery = eventTypeFilter?.toQueryValue() ?? "";
const eventParam = eventQuery ? `&event_type=${encodeURIComponent(eventQuery)}` : "";
const url = `/api/agenda?from=${from}&to=${to}&types=${typesParam(state.type)}${eventParam}`;
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 = "";
timeline.innerHTML = renderAgendaTimeline(state.items);
}
function syncChips(): void {
document.querySelectorAll<HTMLButtonElement>(".agenda-chip[data-type]").forEach((btn) => {
btn.classList.toggle("agenda-chip-active", btn.dataset.type === state.type);
});
document.querySelectorAll<HTMLButtonElement>(".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}`;
}