feat(agenda): unified timeline of deadlines + appointments across projects

t-paliad-030. Adds `/agenda` — a single page that merges every visible
deadline and appointment into a day-grouped timeline, the third overview
surface alongside Dashboard and the per-resource lists.

- AgendaService: merges paliad.deadlines + paliad.appointments, gated by
  the same team-membership predicate used everywhere else; personal
  appointments stay creator-only. Items are sorted by date and tagged
  with urgency (overdue / today / tomorrow / this_week / later) so the
  client can apply the traffic-light colours without re-deriving buckets.
- GET /api/agenda?from&to&types and GET /agenda with the same server-side
  hydration pattern as /dashboard (JSON payload spliced into the shell so
  the timeline paints on first frame).
- Frontend: agenda.tsx + client/agenda.ts render a day-grouped timeline
  with type/range chips; filters round-trip through the URL.
- Sidebar entry under "Übersicht"; DE+EN i18n across all new keys.
This commit is contained in:
m
2026-04-22 23:38:03 +02:00
parent b06a040e2b
commit 0d6c58a337
12 changed files with 1199 additions and 1 deletions

85
frontend/src/agenda.tsx Normal file
View File

@@ -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 "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title data-i18n="agenda.title">Agenda &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
<script dangerouslySetInnerHTML={{ __html: HYDRATION_SCRIPT }} />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/agenda" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<div className="akten-header-row">
<div>
<h1 data-i18n="agenda.heading">Agenda</h1>
<p className="tool-subtitle" data-i18n="agenda.subtitle">
Kommende Fristen und Termine &uuml;ber alle sichtbaren Akten, nach Tag gruppiert.
</p>
</div>
</div>
</div>
<div id="agenda-unavailable" className="akten-unavailable" style="display:none">
<p data-i18n="agenda.unavailable">
Agenda zurzeit nicht verf&uuml;gbar &mdash; bitte Administrator kontaktieren.
</p>
</div>
<div className="agenda-controls">
<div className="agenda-filter-group" role="group" aria-labelledby="agenda-type-heading">
<span id="agenda-type-heading" className="agenda-filter-label" data-i18n="agenda.filter.type">Ansicht</span>
<div className="agenda-chip-row">
<button type="button" className="agenda-chip" data-type="both" data-i18n="agenda.filter.both">Beides</button>
<button type="button" className="agenda-chip" data-type="deadlines" data-i18n="agenda.filter.deadlines">Nur Fristen</button>
<button type="button" className="agenda-chip" data-type="appointments" data-i18n="agenda.filter.appointments">Nur Termine</button>
</div>
</div>
<div className="agenda-filter-group" role="group" aria-labelledby="agenda-range-heading">
<span id="agenda-range-heading" className="agenda-filter-label" data-i18n="agenda.filter.range">Zeitraum</span>
<div className="agenda-chip-row">
<button type="button" className="agenda-chip" data-range="7" data-i18n="agenda.range.7">7 Tage</button>
<button type="button" className="agenda-chip" data-range="14" data-i18n="agenda.range.14">14 Tage</button>
<button type="button" className="agenda-chip" data-range="30" data-i18n="agenda.range.30">30 Tage</button>
<button type="button" className="agenda-chip" data-range="90" data-i18n="agenda.range.90">90 Tage</button>
</div>
</div>
</div>
<div className="agenda-loading" id="agenda-loading" style="display:none" data-i18n="agenda.loading">
L&auml;dt &hellip;
</div>
<div className="agenda-timeline" id="agenda-timeline" />
<div className="akten-empty" id="agenda-empty" style="display:none">
<h2 data-i18n="agenda.empty.title">Keine Eintr&auml;ge im Zeitraum</h2>
<p data-i18n="agenda.empty.hint">
Nichts F&auml;lliges &mdash; erweitern Sie den Zeitraum oder legen Sie neue Fristen oder Termine an.
</p>
</div>
</div>
</section>
</main>
<Footer />
<script src="/assets/agenda.js"></script>
</body>
</html>
);
}

View File

@@ -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<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();
}
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<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 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<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 {
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(renderItem).join("")}
</ul>
</section>`;
}
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
? `<a class="agenda-item-project" href="/projects/${esc(it.project_id)}">${esc(formatProjectLabel(it))}</a>`
: "";
const timePart = it.type === "appointment"
? `<span class="agenda-item-time">${esc(formatAppointmentTime(it))}</span>`
: "";
const urgencyTag = `<span class="agenda-item-urgency">${esc(t(`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 = t(typeLabelKey);
return `<li class="agenda-item ${typeClass} ${urgencyClass}">
<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>
</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 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}`;
}
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>';
}

View File

@@ -27,6 +27,7 @@ const translations: Record<Lang, Record<string, string>> = {
"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<Lang, Record<string, string>> = {
"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<Lang, Record<string, string>> = {
"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<Lang, Record<string, string>> = {
"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",
},
};

View File

@@ -16,6 +16,7 @@ const ICON_MENU = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" st
const ICON_FOLDER = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>';
const ICON_CALENDAR = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="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>';
const ICON_GAUGE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 14l3.5-3.5"/><path d="M3 12a9 9 0 0 1 18 0"/><path d="M12 3v2"/><path d="M3 12H5"/><path d="M19 12h2"/><path d="M5.6 5.6l1.4 1.4"/><path d="M17 7l1.4-1.4"/></svg>';
const ICON_AGENDA = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="17" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="7" y1="13" x2="13" y2="13"/><line x1="7" y1="17" x2="17" y2="17"/></svg>';
const ICON_GEAR = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>';
const ICON_MAIL = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><polyline points="3 7 12 13 21 7"/></svg>';
@@ -74,7 +75,8 @@ export function Sidebar({ currentPath }: SidebarProps): string {
{navItem("/", ICON_HOME, "nav.home", "Home", currentPath)}
{group("nav.group.uebersicht", "\u00DCbersicht",
navItem("/dashboard", ICON_GAUGE, "nav.dashboard", "Dashboard", currentPath),
navItem("/dashboard", ICON_GAUGE, "nav.dashboard", "Dashboard", currentPath) +
navItem("/agenda", ICON_AGENDA, "nav.agenda", "Agenda", currentPath),
)}
{group("nav.group.arbeit", "Arbeit",

View File

@@ -5381,3 +5381,210 @@ input[type="range"]::-moz-range-thumb {
resize: vertical;
min-height: 5rem;
}
/* ============================================================
* Agenda — unified timeline across projects
* ============================================================ */
.agenda-controls {
display: flex;
flex-wrap: wrap;
gap: 1.5rem 2rem;
align-items: flex-start;
margin: 1.5rem 0 1.25rem 0;
}
.agenda-filter-group {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.agenda-filter-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--color-text-muted, #6b7280);
}
.agenda-chip-row {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.agenda-chip {
appearance: none;
background: var(--color-surface-subtle, #f3f4f6);
border: 1px solid transparent;
border-radius: 999px;
padding: 0.35rem 0.85rem;
font-size: 0.85rem;
font-weight: 500;
color: var(--color-text, #1f2937);
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.agenda-chip:hover { background: var(--color-surface, #e5e7eb); }
.agenda-chip-active {
background: var(--color-accent-lime, #c6f41c);
border-color: rgba(0,0,0,0.1);
color: #1a1a1a;
}
.agenda-loading {
padding: 1rem 0;
color: var(--color-text-muted, #6b7280);
font-style: italic;
}
.agenda-timeline {
display: flex;
flex-direction: column;
gap: 1.75rem;
margin-top: 0.5rem;
}
.agenda-day { }
.agenda-day-heading {
display: flex;
align-items: baseline;
gap: 0.7rem;
font-size: 1.1rem;
font-weight: 600;
margin: 0 0 0.6rem 0;
padding-bottom: 0.35rem;
border-bottom: 1px solid var(--color-border, #e5e7eb);
}
.agenda-day-relative { color: var(--color-text, #111827); }
.agenda-day-full {
color: var(--color-text-muted, #6b7280);
font-weight: 400;
font-size: 0.95rem;
}
.agenda-items {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.agenda-item-link {
display: grid;
grid-template-columns: 2rem 1fr auto;
gap: 0.7rem;
align-items: center;
padding: 0.6rem 0.85rem;
border-radius: 8px;
border: 1px solid var(--color-border, #e5e7eb);
background: var(--color-surface-0, #ffffff);
text-decoration: none;
color: inherit;
transition: background 0.15s, border-color 0.15s, box-shadow 0.15s;
}
.agenda-item-link:hover {
background: var(--color-surface-subtle, #f9fafb);
border-color: #d1d5db;
}
.agenda-item-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 50%;
background: var(--color-surface-subtle, #f3f4f6);
color: var(--color-text-muted, #6b7280);
}
.agenda-item-icon svg { width: 1.1rem; height: 1.1rem; }
.agenda-item-main {
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
}
.agenda-item-headline {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
align-items: baseline;
font-size: 0.95rem;
}
.agenda-item-type-label {
font-weight: 600;
color: var(--color-text-muted, #6b7280);
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.agenda-item-title {
font-weight: 600;
color: var(--color-text, #111827);
overflow: hidden;
text-overflow: ellipsis;
}
.agenda-item-sub {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
font-size: 0.82rem;
color: var(--color-text-muted, #6b7280);
}
.agenda-item-project {
color: var(--color-text-muted, #6b7280);
text-decoration: none;
border-bottom: 1px dotted currentColor;
}
.agenda-item-project:hover {
color: var(--color-text, #111827);
}
.agenda-item-time { font-variant-numeric: tabular-nums; }
.agenda-item-location::before { content: "· "; }
.agenda-item-meta {
display: flex;
align-items: center;
}
.agenda-item-urgency {
display: inline-block;
padding: 0.2rem 0.55rem;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
white-space: nowrap;
}
/* Urgency colour system — shares the red/amber/green vocabulary with the
* Fristen page (--frist-red / --frist-amber / --frist-green) so a user who
* learned the traffic lights there reads the agenda without relearning. */
.agenda-item-overdue .agenda-item-urgency { background: #fee2e2; color: #b91c1c; }
.agenda-item-overdue .agenda-item-icon { background: #fee2e2; color: #b91c1c; }
.agenda-item-overdue .agenda-item-link { border-left: 3px solid var(--frist-red, #ef4444); }
.agenda-item-today .agenda-item-urgency { background: #fee2e2; color: #b91c1c; }
.agenda-item-today .agenda-item-icon { background: #fee2e2; color: #b91c1c; }
.agenda-item-today .agenda-item-link { border-left: 3px solid var(--frist-red, #ef4444); }
.agenda-item-tomorrow .agenda-item-urgency { background: #fef3c7; color: #92400e; }
.agenda-item-tomorrow .agenda-item-icon { background: #fef3c7; color: #92400e; }
.agenda-item-tomorrow .agenda-item-link { border-left: 3px solid var(--frist-amber, #f59e0b); }
.agenda-item-this_week .agenda-item-urgency { background: #fef3c7; color: #b45309; }
.agenda-item-this_week .agenda-item-icon { background: #fef3c7; color: #b45309; }
.agenda-item-this_week .agenda-item-link { border-left: 3px solid var(--frist-amber, #f59e0b); }
.agenda-item-later .agenda-item-urgency { background: #ecfccb; color: #365314; }
.agenda-item-later .agenda-item-icon { background: #ecfccb; color: #365314; }
.agenda-item-later .agenda-item-link { border-left: 3px solid var(--frist-green, #22c55e); }