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

237 lines
9.0 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.

// Shared agenda timeline rendering primitives. The standalone /agenda page
// (client/agenda.ts) and the inline Agenda section on /dashboard
// (client/dashboard.ts) both render the same item shape; this module is
// the single source of truth for how an AgendaItem turns into HTML.
//
// Stateless. The caller fetches /api/agenda, hands the items to
// renderAgendaTimeline(), and drops the resulting HTML into a container.
// i18n labels are resolved at render time via t/tDyn from ./i18n, so the
// onLangChange hook on the calling page re-renders correctly.
import { t, tDyn, getLang } from "./i18n";
// Two-eyes glyph 👀 inside .approval-pill--icon. Kept in sync with the
// matching constants in events.ts / inbox.ts / dashboard.ts.
const APPROVAL_PILL_GLYPH = "👀";
// Sparkle glyph ✨ for Paliadin-drafted pending rows (t-paliad-161).
// Renders alongside (not in place of) 👀 — orthogonal axes.
const AGENT_PILL_GLYPH = "✨";
export type Urgency = "overdue" | "today" | "tomorrow" | "this_week" | "later";
export type AgendaType = "deadline" | "appointment";
export 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;
location?: string | null;
appointment_type?: string | null;
urgency: Urgency;
project_id?: string | null;
project_title?: string | null;
project_type?: string | null;
project_reference?: string | null;
approval_status?: "approved" | "pending" | "legacy" | null;
requester_kind?: "user" | "agent" | null;
}
interface DayBucket {
dayKey: string;
day: Date;
items: AgendaItem[];
}
// Render a full timeline (day buckets with items) for an array of agenda
// items. Returns a single HTML string ready to assign via innerHTML.
// The empty case returns an empty string — callers that want an empty-
// state UI handle it themselves (different copy on /agenda vs the
// dashboard inline slot).
export function renderAgendaTimeline(items: AgendaItem[]): string {
if (!items.length) return "";
const buckets = groupByDay(items);
return buckets.map((b) => renderDay(b)).join("");
}
function groupByDay(items: AgendaItem[]): DayBucket[] {
const map = new Map<string, DayBucket>();
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 {
const expected = expectedUrgency(bucket.day);
return `<section class="agenda-day">
<h2 class="agenda-day-heading">
<span class="agenda-day-relative">${esc(relativeDayLabel(bucket.day))}</span>
<span class="agenda-day-full">${esc(fullDateLabel(bucket.day))}</span>
</h2>
<ul class="agenda-items">
${bucket.items.map((it) => renderItem(it, expected)).join("")}
</ul>
</section>`;
}
// F-32: an item's urgency tag duplicates the day-bucket heading in the
// common case (a "Heute" item under HEUTE, a "Diese Woche" item under "in 3
// Tagen"). The tag stays only when it disagrees with the bucket — e.g. an
// "Überfällig" deadline that lands in today's bucket because of a filter
// quirk. expectedUrgency mirrors the server's bucketing rule against the
// bucket's day.
function expectedUrgency(day: Date): Urgency {
const today = startOfToday();
const diff = Math.round((day.getTime() - today.getTime()) / 86400000);
if (diff < 0) return "overdue";
if (diff === 0) return "today";
if (diff === 1) return "tomorrow";
if (diff <= 6) return "this_week";
return "later";
}
function renderItem(it: AgendaItem, bucketUrgency: Urgency): string {
const urgencyClass = `agenda-item-${it.urgency}`;
const typeClass = `agenda-item-type-${it.type}`;
const pendingClass = it.approval_status === "pending" ? " entity-row--pending-update" : "";
const iconHTML = it.type === "deadline" ? deadlineIcon() : appointmentIcon();
const detailHref = itemDetailHref(it);
const project = it.project_id
? `<a class="agenda-item-project" href="/projects/${esc(it.project_id)}">${esc(formatProjectLabel(it))}</a>`
: "";
const pendingLabel = it.approval_status === "pending" ? tDyn("approvals.pending_update.label") : "";
const pendingPill = it.approval_status === "pending"
? `<span class="approval-pill approval-pill--icon" title="${esc(pendingLabel)}" aria-label="${esc(pendingLabel)}">${APPROVAL_PILL_GLYPH}</span>`
: "";
const agentLabel = tDyn("approvals.agent.label");
const agentPill = it.approval_status === "pending" && it.requester_kind === "agent"
? `<span class="approval-pill approval-pill--agent" title="${esc(agentLabel)}" aria-label="${esc(agentLabel)}">${AGENT_PILL_GLYPH}</span>`
: "";
const timePart = it.type === "appointment"
? `<span class="agenda-item-time">${esc(formatAppointmentTime(it))}</span>`
: "";
const urgencyTag = it.urgency !== bucketUrgency
? `<span class="agenda-item-urgency">${esc(tDyn(`agenda.urgency.${it.urgency}`))}</span>`
: "";
const locationPart = it.type === "appointment" && it.location
? `<span class="agenda-item-location">${esc(it.location)}</span>`
: "";
const typeLabelKey = it.type === "deadline"
? "agenda.label.deadline"
: (it.appointment_type ? `agenda.appointment_type.${it.appointment_type}` : "agenda.label.appointment");
const typeLabel = tDyn(typeLabelKey);
return `<li class="agenda-item ${typeClass} ${urgencyClass}${pendingClass}">
<a class="agenda-item-link" href="${esc(detailHref)}">
<span class="agenda-item-icon" aria-hidden="true">${iconHTML}</span>
<span class="agenda-item-main">
<span class="agenda-item-headline">
<span class="agenda-item-type-label">${esc(typeLabel)}:</span>
<span class="agenda-item-title">${esc(it.title)}</span>
${pendingPill}
${agentPill}
</span>
<span class="agenda-item-sub">
${project}
${timePart}
${locationPart}
</span>
</span>
<span class="agenda-item-meta">
${urgencyTag}
</span>
</a>
</li>`;
}
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 startOfToday(): Date {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
}
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 '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15 14"/></svg>';
}
function appointmentIcon(): string {
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="17" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>';
}