Files
paliad/frontend/src/client/dashboard.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

543 lines
19 KiB
TypeScript

import { initI18n, onLangChange, t, tDyn, getLang, translateEvent } from "./i18n";
import { initSidebar } from "./sidebar";
import { renderAgendaTimeline, type AgendaItem } from "./agenda-render";
interface DashboardUser {
id: string;
email: string;
display_name: string;
office: string;
role: string;
}
interface DeadlineSummary {
overdue: number;
today: number;
this_week: number;
next_week: number;
later: number;
}
interface MatterSummary {
active: number;
archived: number;
total: number;
}
interface UpcomingDeadline {
id: string;
title: string;
due_date: string;
project_id: string;
project_title: string;
project_reference: string;
urgency: "overdue" | "today" | "urgent" | "soon";
}
interface UpcomingAppointment {
id: string;
title: string;
start_at: string;
end_at: string | null;
type: string | null;
project_id: string | null;
project_title: string | null;
project_reference: string | null;
}
interface ActivityEntry {
timestamp: string;
actor_email: string | null;
actor_name: string | null;
project_id: string;
project_title: string;
project_reference: string;
action: string | null;
details: string;
description: string | null;
metadata: Record<string, unknown> | null;
}
interface DashboardData {
user: DashboardUser | null;
deadline_summary: DeadlineSummary;
matter_summary: MatterSummary;
upcoming_deadlines: UpcomingDeadline[];
upcoming_appointments: UpcomingAppointment[];
recent_activity: ActivityEntry[];
}
declare global {
interface Window {
__PALIAD_DASHBOARD__?: DashboardData | null;
}
}
const POLL_INTERVAL_MS = 60_000;
// 30-day look-ahead matches the agenda.tsx default chip and the server's
// default `to=today+30d` window — keeps the inline agenda visually
// consistent with /agenda when users follow the "full agenda" link.
const AGENDA_LOOKAHEAD_DAYS = 30;
const COLLAPSE_KEY_PREFIX = "paliad:dashboard:collapse:";
let data: DashboardData | null = null;
let agendaItems: AgendaItem[] | null = null;
async function loadDashboard(): Promise<void> {
const unavailable = document.getElementById("dashboard-unavailable")!;
try {
const resp = await fetch("/api/dashboard");
if (resp.status === 503) {
unavailable.style.display = "block";
return;
}
if (!resp.ok) {
unavailable.style.display = "block";
return;
}
data = await resp.json();
render();
} catch {
unavailable.style.display = "block";
}
}
function render(): void {
if (!data) return;
renderGreeting(data.user);
renderSummary(data.deadline_summary);
renderMatters(data.matter_summary);
renderDeadlines(data.upcoming_deadlines);
renderAppointments(data.upcoming_appointments);
renderAgenda();
renderActivity(data.recent_activity);
toggleOnboardingHint(data.user);
}
function renderGreeting(user: DashboardUser | null): void {
const nameEl = document.getElementById("dashboard-greeting-name")!;
const chip = document.getElementById("dashboard-office-chip")!;
const dateEl = document.getElementById("dashboard-date")!;
if (user) {
nameEl.textContent = user.display_name ? `, ${user.display_name}` : "";
const officeLabel = tDyn(`office.${user.office}`) || user.office;
chip.textContent = officeLabel;
chip.className = `dashboard-office-chip office-chip office-${user.office}`;
chip.style.display = "inline-block";
} else {
nameEl.textContent = "";
chip.style.display = "none";
}
const now = new Date();
dateEl.textContent = now.toLocaleDateString(
getLang() === "de" ? "de-DE" : "en-GB",
{ weekday: "long", year: "numeric", month: "long", day: "numeric" },
);
}
function renderSummary(s: DeadlineSummary): void {
setCount("dashboard-count-overdue", s.overdue);
setCount("dashboard-count-today", s.today);
setCount("dashboard-count-this-week", s.this_week);
setCount("dashboard-count-next-week", s.next_week);
setCount("dashboard-count-later", s.later);
// Überfällig is an emergency category — hide the card entirely on a clean
// slate (the .dashboard-summary-grid uses auto-fit so the row re-flows to
// 4 cards) and trip the alarm styling when there's anything overdue. See
// t-paliad-105 / t-paliad-106 / t-paliad-110.
const overdueCard = document.getElementById("dashboard-card-overdue")!;
overdueCard.classList.toggle("dashboard-card-overdue-hidden", s.overdue === 0);
overdueCard.classList.toggle("dashboard-card-alarm", s.overdue > 0);
}
function renderMatters(s: MatterSummary): void {
setCount("dashboard-matter-active", s.active);
setCount("dashboard-matter-archived", s.archived);
setCount("dashboard-matter-total", s.total);
}
function renderDeadlines(items: UpcomingDeadline[]): void {
const list = document.getElementById("dashboard-deadlines-list")!;
const empty = document.getElementById("dashboard-deadlines-empty")!;
if (!items.length) {
list.innerHTML = "";
list.style.display = "none";
empty.style.display = "block";
return;
}
list.style.display = "";
empty.style.display = "none";
list.innerHTML = items.map((d) => {
const urgencyClass = `dashboard-urgency-${d.urgency}`;
const urgencyLabel = tDyn(`dashboard.urgency.${d.urgency}`);
return `<li class="dashboard-list-item">
<a href="/projects/${esc(d.project_id)}/deadlines" class="dashboard-list-link">
<div class="dashboard-list-main">
<span class="dashboard-list-title">${esc(d.title)}</span>
<span class="dashboard-list-ref" title="${escAttr(`${d.project_reference} · ${d.project_title}`)}">${esc(d.project_reference)} &middot; ${esc(d.project_title)}</span>
</div>
<div class="dashboard-list-meta">
<span class="dashboard-urgency-badge ${urgencyClass}" title="${escAttr(urgencyLabel)}">${esc(formatRelative(d.due_date))}</span>
</div>
</a>
</li>`;
}).join("");
}
function renderAppointments(items: UpcomingAppointment[]): void {
const list = document.getElementById("dashboard-appointments-list")!;
const empty = document.getElementById("dashboard-appointments-empty")!;
if (!items.length) {
list.innerHTML = "";
list.style.display = "none";
empty.style.display = "block";
return;
}
list.style.display = "";
empty.style.display = "none";
list.innerHTML = items.map((a) => {
const dot = a.type
? `<span class="dashboard-termin-dot dashboard-termin-${esc(a.type)}" aria-hidden="true"></span>`
: `<span class="dashboard-termin-dot" aria-hidden="true"></span>`;
const href = a.project_id ? `/projects/${esc(a.project_id)}/appointments` : "#";
const tag = a.project_id ? "a" : "div";
const projectLine = a.project_reference && a.project_title
? `<span class="dashboard-list-ref" title="${escAttr(`${a.project_reference} · ${a.project_title}`)}">${esc(a.project_reference)} &middot; ${esc(a.project_title)}</span>`
: "";
return `<li class="dashboard-list-item">
<${tag} href="${href}" class="dashboard-list-link">
<div class="dashboard-list-main">
<span class="dashboard-list-title">${dot}${esc(a.title)}</span>
${projectLine}
</div>
<div class="dashboard-list-meta">
<span class="dashboard-appt-time">${esc(formatDateTime(a.start_at))}</span>
</div>
</${tag}>
</li>`;
}).join("");
}
function renderActivity(items: ActivityEntry[]): void {
const list = document.getElementById("dashboard-activity-list")!;
const empty = document.getElementById("dashboard-activity-empty")!;
if (!items.length) {
list.innerHTML = "";
list.style.display = "none";
empty.style.display = "block";
return;
}
list.style.display = "";
empty.style.display = "none";
list.innerHTML = items.map((e) => {
const actor = e.actor_name || e.actor_email || t("dashboard.activity.system");
const shortKey = e.action ? `dashboard.action.short.${e.action}` : "";
const translated = shortKey ? tDyn(shortKey) : "";
const hasI18n = translated !== "" && translated !== shortKey;
const shortAction = hasI18n
? translated
: (e.action || t("dashboard.activity.event"));
// Localize the muted detail line so it speaks DE in DE and EN in EN —
// historical rows carry English nouns inside DE narrative ("Deadline „ok"
// geändert", "Note zu deadline hinzugefügt"); translateEvent parses both
// legacy and new (value-only) shapes.
const stored = e.description ?? (hasI18n ? "" : e.details);
const { description: detail } = translateEvent(e.action, "", stored);
// For checklist_* events with a known instance_id, deep-link the project
// ref straight to the instance — saves a click vs. landing on the project.
// Falls back to /projects/{id} for any other event or when the instance
// ID is missing (older rows pre-metadata, or checklist_deleted).
const ref = activityHref(e);
return `<li class="dashboard-activity-item">
<span class="dashboard-activity-time">${esc(formatDateTime(e.timestamp))}</span>
<div class="dashboard-activity-body">
<p class="dashboard-activity-summary"><strong>${esc(actor)}</strong> ${esc(shortAction)}</p>
<p class="dashboard-activity-detail">
<a href="${escAttr(ref)}" class="dashboard-activity-project">${esc(e.project_reference)}</a>${detail ? ` <span>${esc(detail)}</span>` : ""}
</p>
</div>
</li>`;
}).join("");
// Row-level click handler: clicking anywhere on the row navigates to the
// same target as the inner .dashboard-activity-project link. Inner <a>/
// <button> clicks bubble through unchanged (Cmd-click → new tab still
// works) and text remains selectable — same pattern as .entity-table rows
// (t-098/099) and the project Verlauf cards (t-paliad-103).
list.querySelectorAll<HTMLLIElement>(".dashboard-activity-item").forEach((row) => {
const link = row.querySelector<HTMLAnchorElement>(".dashboard-activity-project");
if (!link) return;
row.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
if (target.closest("a") || target.closest("button")) return;
window.location.href = link.href;
});
});
}
// Resolve an activity row to the most-specific deep-link target. Mirrors the
// rules in projects-detail.ts:eventDetailHref so the activity feed and the
// project Verlauf agree on where each event family points. Falls back to the
// owning project page when no metadata is wired (older rows or _deleted/
// deadlines_imported events). Wired families: checklist_*, deadline_*,
// appointment_*, note_created — see t-paliad-097/102.
function activityHref(e: ActivityEntry): string {
const action = e.action ?? "";
const meta = (e.metadata ?? null) as Record<string, unknown> | null;
if (meta) {
if (action.startsWith("checklist_") && action !== "checklist_deleted") {
const id = meta["checklist_instance_id"];
if (typeof id === "string" && id) return `/checklists/instances/${id}`;
}
if (
action.startsWith("deadline_") &&
action !== "deadline_deleted" &&
action !== "deadlines_imported"
) {
const id = meta["deadline_id"];
if (typeof id === "string" && id) return `/deadlines/${id}`;
}
if (action.startsWith("appointment_") && action !== "appointment_deleted") {
const id = meta["appointment_id"];
if (typeof id === "string" && id) return `/appointments/${id}`;
}
if (action === "note_created") {
const apptID = meta["appointment_id"];
if (typeof apptID === "string" && apptID) return `/appointments/${apptID}`;
const deadlineID = meta["deadline_id"];
if (typeof deadlineID === "string" && deadlineID) return `/deadlines/${deadlineID}`;
}
}
return `/projects/${e.project_id}`;
}
// Render the inline Agenda section. Items are fetched once on mount via
// loadAgenda(); subsequent re-renders (lang change, dashboard poll) reuse
// the cached array. The dashboard inline agenda is read-only — no chip
// filters, default 30-day window — see CollapsibleSection in
// dashboard.tsx for the surrounding shell.
function renderAgenda(): void {
const timeline = document.getElementById("dashboard-agenda-timeline");
const empty = document.getElementById("dashboard-agenda-empty");
if (!timeline || !empty) return;
if (agendaItems === null) {
// Items haven't landed yet — keep the timeline blank but hide empty
// hint so we don't flash "nothing due" before the fetch resolves.
timeline.innerHTML = "";
timeline.style.display = "none";
empty.style.display = "none";
return;
}
if (!agendaItems.length) {
timeline.innerHTML = "";
timeline.style.display = "none";
empty.style.display = "block";
return;
}
empty.style.display = "none";
timeline.style.display = "";
timeline.innerHTML = renderAgendaTimeline(agendaItems);
}
async function loadAgenda(): Promise<void> {
const from = toAgendaDate(startOfToday());
const to = toAgendaDate(addDays(startOfToday(), AGENDA_LOOKAHEAD_DAYS - 1));
try {
const resp = await fetch(`/api/agenda?from=${from}&to=${to}&types=deadlines,appointments`);
if (!resp.ok) {
// Fail silently — the rest of the dashboard still loads. The
// inline agenda is best-effort: a 503 (DB-less knowledge-platform
// deploy) or 401 (session timed out, should be caught by the
// page-level redirect) just leaves the section empty.
agendaItems = [];
renderAgenda();
return;
}
agendaItems = (await resp.json()) as AgendaItem[];
renderAgenda();
} catch {
agendaItems = [];
renderAgenda();
}
}
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 toAgendaDate(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}`;
}
// Wire collapsible-section toggles. Each .dashboard-section carries a
// data-collapse-key and the SSR markup renders aria-expanded="true" so
// unstyled fallback shows everything; here we restore persisted state and
// attach click handlers. Persistence is per-section via localStorage —
// keys live under paliad:dashboard:collapse:<section> per the brief.
function initCollapsibleSections(): void {
const sections = document.querySelectorAll<HTMLElement>(".dashboard-section[data-collapse-key]");
sections.forEach((section) => {
const key = section.dataset.collapseKey || "";
if (!key) return;
const stored = localStorage.getItem(COLLAPSE_KEY_PREFIX + key);
const collapsed = stored === "true";
applyCollapseState(section, collapsed);
const toggle = section.querySelector<HTMLButtonElement>(".dashboard-section-toggle");
if (!toggle) return;
toggle.addEventListener("click", () => {
const nowExpanded = section.getAttribute("aria-expanded") === "true";
const nextCollapsed = nowExpanded; // expanded → collapsing
applyCollapseState(section, nextCollapsed);
try {
localStorage.setItem(COLLAPSE_KEY_PREFIX + key, String(nextCollapsed));
} catch {
// localStorage may be full or disabled (Safari private mode);
// collapse still works for the current page life. Silent.
}
});
});
// Re-localise the toggle aria-labels on language switch so screen
// readers always read the current language. The visible heading text
// is handled by the i18n applyTranslations pass already.
syncCollapseAriaLabels();
}
function applyCollapseState(section: HTMLElement, collapsed: boolean): void {
section.setAttribute("aria-expanded", String(!collapsed));
const toggle = section.querySelector<HTMLButtonElement>(".dashboard-section-toggle");
if (toggle) {
toggle.setAttribute("aria-expanded", String(!collapsed));
toggle.setAttribute(
"aria-label",
collapsed ? t("dashboard.section.expand") : t("dashboard.section.collapse"),
);
}
}
function syncCollapseAriaLabels(): void {
document
.querySelectorAll<HTMLElement>(".dashboard-section[data-collapse-key]")
.forEach((section) => {
const collapsed = section.getAttribute("aria-expanded") !== "true";
applyCollapseState(section, collapsed);
});
}
function toggleOnboardingHint(user: DashboardUser | null): void {
// Belt-and-braces: the server-side gate (gateOnboarded in handlers.go)
// already redirects users without a paliad.users row to /onboarding before
// the dashboard HTML is served. If the gate ever misses (e.g. DB lookup
// errored and we fell through), push the user to /onboarding here so they
// don't get stuck on a blank dashboard.
if (!user) {
window.location.href = "/onboarding";
return;
}
const onboarding = document.getElementById("dashboard-onboarding")!;
onboarding.style.display = "none";
}
function setCount(id: string, n: number): void {
const el = document.getElementById(id);
if (el) el.textContent = String(n);
}
function formatRelative(isoDate: string): string {
const due = new Date(isoDate + "T00:00:00");
if (isNaN(due.getTime())) return isoDate;
const today = new Date();
today.setHours(0, 0, 0, 0);
const diffDays = Math.round((due.getTime() - today.getTime()) / 86400000);
const lang = getLang();
if (diffDays < 0) {
const n = Math.abs(diffDays);
return lang === "de"
? (n === 1 ? "vor 1 Tag" : `vor ${n} Tagen`)
: (n === 1 ? "1 day ago" : `${n} days ago`);
}
if (diffDays === 0) return t("dashboard.when.today");
if (diffDays === 1) return t("dashboard.when.tomorrow");
return lang === "de" ? `in ${diffDays} Tagen` : `in ${diffDays} days`;
}
function formatDateTime(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
const locale = getLang() === "de" ? "de-DE" : "en-GB";
return d.toLocaleString(locale, {
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s ?? "";
return d.innerHTML;
}
function escAttr(s: string): string {
return (s ?? "").replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}
function schedulePolling(): void {
// Refresh the payload every minute so open dashboards stay current when
// teammates create Akten/Fristen. Uses the JSON endpoint — no page reload.
// The inline agenda is refreshed on the same cadence to stay in sync
// with the deadlines/appointments rails above it.
window.setInterval(() => {
void loadDashboard();
void loadAgenda();
}, POLL_INTERVAL_MS);
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
initCollapsibleSections();
onLangChange(() => {
render();
syncCollapseAriaLabels();
});
// Inline agenda fetch is independent of the main dashboard payload.
// Kicked off in parallel so the agenda section paints as soon as the
// /api/agenda response lands instead of waiting on the dashboard
// payload poll.
void loadAgenda();
const inlined = window.__PALIAD_DASHBOARD__;
if (inlined !== undefined) {
// Server-side hydration path: the handler spliced the payload directly
// into the HTML. Render synchronously, then start polling.
if (inlined === null) {
document.getElementById("dashboard-unavailable")!.style.display = "block";
} else {
data = inlined;
render();
}
schedulePolling();
return;
}
// Fallback for dev or when the handler couldn't splice (no DB, etc.).
void loadDashboard().then(schedulePolling);
});