Merge: Agenda — upcoming deadlines + appointments timeline

# Conflicts:
#	frontend/src/styles/global.css
This commit is contained in:
m
2026-04-23 00:04:37 +02:00
12 changed files with 1199 additions and 1 deletions

View File

@@ -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")

View File

@@ -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";
import { renderChangelog } from "./src/changelog";
@@ -63,6 +64,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"),
join(import.meta.dir, "src/client/changelog.ts"),
],
@@ -111,6 +113,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());
await Bun.write(join(DIST, "changelog.html"), renderChangelog());

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",
@@ -1033,6 +1034,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: {
@@ -1052,6 +1084,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",
@@ -2058,6 +2091,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>';
const ICON_SEARCH = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>';
@@ -91,7 +92,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

@@ -5688,3 +5688,210 @@ input[type="range"]::-moz-range-thumb {
font-style: italic;
padding: 1.5rem 0;
}
/* ============================================================
* 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); }

144
internal/handlers/agenda.go Normal file
View File

@@ -0,0 +1,144 @@
package handlers
import (
"net/http"
"strings"
"time"
"mgit.msbls.de/m/patholo/internal/auth"
"mgit.msbls.de/m/patholo/internal/services"
)
// GET /api/agenda?from=YYYY-MM-DD&to=YYYY-MM-DD&types=deadlines,appointments
//
// Returns a merged, date-sorted slice of AgendaItems across every Project the
// caller can see. Defaults: from=today, to=today+30d, both types included.
func handleAgendaAPI(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
filter, err := parseAgendaFilter(r)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
items, err := dbSvc.agenda.List(r.Context(), uid, filter)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, items)
}
// GET /agenda — server-rendered page shell. Server-side hydration pattern
// (same as /dashboard): the handler splices the initial payload into the HTML
// so the client can paint the timeline on first frame.
func handleAgendaPage(w http.ResponseWriter, r *http.Request) {
uid, hasUser := auth.UserIDFromContext(r.Context())
filter, err := parseAgendaFilter(r)
if err != nil {
// Fall back to defaults on bad query strings — landing with garbled
// params shouldn't 400 the page, only the API endpoint.
filter = defaultAgendaFilter()
}
var payload []byte
if hasUser && dbSvc != nil {
if items, err := dbSvc.agenda.List(r.Context(), uid, filter); err == nil {
payload = mustJSON(map[string]any{
"items": items,
"from": filter.From.Format("2006-01-02"),
"to": filter.To.Format("2006-01-02"),
"types": agendaTypesFromFilter(filter),
})
}
}
serveAgendaShell(w, r, payload)
}
// parseAgendaFilter reads ?from, ?to, ?types from the query string and
// returns a normalised AgendaFilter with UTC day-aligned boundaries. Unknown
// type tokens are ignored rather than rejected.
func parseAgendaFilter(r *http.Request) (services.AgendaFilter, error) {
q := r.URL.Query()
filter := defaultAgendaFilter()
if raw := strings.TrimSpace(q.Get("from")); raw != "" {
t, err := time.Parse("2006-01-02", raw)
if err != nil {
return services.AgendaFilter{}, fmtInvalidDate("from")
}
filter.From = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
}
if raw := strings.TrimSpace(q.Get("to")); raw != "" {
t, err := time.Parse("2006-01-02", raw)
if err != nil {
return services.AgendaFilter{}, fmtInvalidDate("to")
}
// `to` is inclusive in the query UX — translate to an exclusive
// upper bound at the next UTC midnight so an all-day deadline on
// that date is included.
filter.To = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC).AddDate(0, 0, 1)
}
if filter.To.Before(filter.From) {
filter.To = filter.From.AddDate(0, 0, 1)
}
if raw := strings.TrimSpace(q.Get("types")); raw != "" {
filter.IncludeDeadlines = false
filter.IncludeAppointments = false
for tok := range strings.SplitSeq(raw, ",") {
switch strings.TrimSpace(tok) {
case "deadlines":
filter.IncludeDeadlines = true
case "appointments":
filter.IncludeAppointments = true
}
}
if !filter.IncludeDeadlines && !filter.IncludeAppointments {
// Empty/unknown token list → fall back to both so the page still
// shows something useful.
filter.IncludeDeadlines = true
filter.IncludeAppointments = true
}
}
return filter, nil
}
// defaultAgendaFilter is today → today+30d, both types.
func defaultAgendaFilter() services.AgendaFilter {
now := time.Now().UTC()
from := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
return services.AgendaFilter{
From: from,
To: from.AddDate(0, 0, 30),
IncludeDeadlines: true,
IncludeAppointments: true,
}
}
func agendaTypesFromFilter(f services.AgendaFilter) []string {
out := make([]string, 0, 2)
if f.IncludeDeadlines {
out = append(out, "deadlines")
}
if f.IncludeAppointments {
out = append(out, "appointments")
}
return out
}
type agendaErr struct{ msg string }
func (e agendaErr) Error() string { return e.msg }
func fmtInvalidDate(field string) error {
return agendaErr{msg: "invalid " + field + " — expected YYYY-MM-DD"}
}

View File

@@ -0,0 +1,58 @@
package handlers
import (
"bytes"
"log"
"net/http"
"os"
"path/filepath"
"sync"
)
// Same server-side hydration trick as the dashboard: the agenda page shell
// is pre-rendered by bun (`renderAgenda()` → dist/agenda.html) with a
// placeholder token; the handler splices in the JSON payload at request time
// so the client paints without a second round-trip.
const agendaDataPlaceholder = "/*__PALIAD_AGENDA_DATA__*/"
var (
agendaShellOnce sync.Once
agendaShellBytes []byte
agendaShellErr error
)
func loadAgendaShell() ([]byte, error) {
agendaShellOnce.Do(func() {
path := filepath.Join("dist", "agenda.html")
agendaShellBytes, agendaShellErr = os.ReadFile(path)
if agendaShellErr != nil {
return
}
if !bytes.Contains(agendaShellBytes, []byte(agendaDataPlaceholder)) {
log.Printf("warning: agenda.html is missing the data placeholder — client will fall back to /api/agenda")
}
})
return agendaShellBytes, agendaShellErr
}
func serveAgendaShell(w http.ResponseWriter, _ *http.Request, payload []byte) {
shell, err := loadAgendaShell()
if err != nil {
http.Error(w, "agenda shell unavailable", http.StatusInternalServerError)
return
}
var body []byte
if len(payload) > 0 {
inline := append([]byte("window.__PALIAD_AGENDA__="), escapeForScript(payload)...)
inline = append(inline, ';')
body = bytes.Replace(shell, []byte(agendaDataPlaceholder), inline, 1)
} else {
body = bytes.Replace(shell, []byte(agendaDataPlaceholder),
[]byte("window.__PALIAD_AGENDA__=null;"), 1)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
}

View File

@@ -29,6 +29,7 @@ type Services struct {
ChecklistInst *services.ChecklistInstanceService
Mail *services.MailService
Invite *services.InviteService
Agenda *services.AgendaService
}
func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc *Services) {
@@ -53,6 +54,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
checklistInst: svc.ChecklistInst,
mail: svc.Mail,
invite: svc.Invite,
agenda: svc.Agenda,
}
}
@@ -193,6 +195,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("GET /api/users", handleListUsers)
protected.HandleFunc("GET /api/offices", handleListOffices)
protected.HandleFunc("GET /api/dashboard", handleDashboardAPI)
protected.HandleFunc("GET /api/agenda", handleAgendaAPI)
// Invitations — send a colleague a Paliad invite email.
protected.HandleFunc("POST /api/invite", handleInvite)
@@ -207,6 +210,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
// payload inline; client boots from window.__PALIAD_DASHBOARD__ with no
// waterfall fetch (design audit §2.3).
protected.HandleFunc("GET /dashboard", gateOnboarded(handleDashboardPage))
protected.HandleFunc("GET /agenda", gateOnboarded(handleAgendaPage))
// Phase D — server-rendered Projects pages.
protected.HandleFunc("GET /projects", gateOnboarded(handleProjectsListPage))

View File

@@ -31,6 +31,7 @@ type dbServices struct {
checklistInst *services.ChecklistInstanceService
mail *services.MailService
invite *services.InviteService
agenda *services.AgendaService
}
var dbSvc *dbServices

View File

@@ -0,0 +1,274 @@
package services
// AgendaService builds a merged, date-sorted feed of deadlines + appointments
// across every Project the caller can see. It underpins the `/agenda` page —
// a unified timeline that is neither deadline-centric (like /deadlines) nor
// appointment-centric (like /appointments/calendar).
//
// Visibility: reuses the same team-membership predicate applied everywhere
// else (paliad.project_teams + path walk). Personal Appointments (project_id
// IS NULL) remain creator-only.
import (
"context"
"fmt"
"sort"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
// AgendaService returns agenda feed rows for the Dashboard's /agenda page.
type AgendaService struct {
db *sqlx.DB
users *UserService
}
// NewAgendaService wires the service.
func NewAgendaService(db *sqlx.DB, users *UserService) *AgendaService {
return &AgendaService{db: db, users: users}
}
// AgendaItem is one row in the merged feed. `Type` is "deadline" or
// "appointment"; date fields are populated differently per type (deadlines
// have a date-only DueDate, appointments have StartAt/EndAt). The client
// groups by the local calendar day derived from `Date`.
type AgendaItem struct {
ID uuid.UUID `json:"id"`
Type string `json:"type"` // "deadline" | "appointment"
Title string `json:"title"`
Date time.Time `json:"date"` // canonical sort key (day start for deadlines, start_at for appointments)
EndAt *time.Time `json:"end_at,omitempty"` // appointments only
DueDate *string `json:"due_date,omitempty"` // deadlines only (YYYY-MM-DD)
Status *string `json:"status,omitempty"` // deadlines: pending/completed/...
Location *string `json:"location,omitempty"` // appointments only
AppointmentType *string `json:"appointment_type,omitempty"`
Urgency string `json:"urgency"` // overdue | today | tomorrow | this_week | later
ProjectID *uuid.UUID `json:"project_id,omitempty"`
ProjectTitle *string `json:"project_title,omitempty"`
ProjectType *string `json:"project_type,omitempty"`
ProjectRef *string `json:"project_reference,omitempty"`
}
// AgendaFilter narrows the merged feed.
type AgendaFilter struct {
From time.Time // inclusive, UTC
To time.Time // exclusive, UTC
IncludeDeadlines bool
IncludeAppointments bool
}
// List returns all AgendaItems for the user's visible projects within
// [From, To), sorted by Date ascending. Completed deadlines are excluded —
// the agenda is about what's coming up, not audit history.
func (s *AgendaService) List(ctx context.Context, userID uuid.UUID, f AgendaFilter) ([]AgendaItem, error) {
if !f.IncludeDeadlines && !f.IncludeAppointments {
return []AgendaItem{}, nil
}
if f.To.Before(f.From) || f.To.Equal(f.From) {
return []AgendaItem{}, nil
}
user, err := s.users.GetByID(ctx, userID)
if err != nil {
return nil, err
}
if user == nil {
return []AgendaItem{}, nil
}
items := make([]AgendaItem, 0, 64)
if f.IncludeDeadlines {
rows, err := s.loadDeadlines(ctx, userID, user.Role, f.From, f.To)
if err != nil {
return nil, err
}
items = append(items, rows...)
}
if f.IncludeAppointments {
rows, err := s.loadAppointments(ctx, userID, user.Role, f.From, f.To)
if err != nil {
return nil, err
}
items = append(items, rows...)
}
sort.SliceStable(items, func(i, j int) bool {
if items[i].Date.Equal(items[j].Date) {
// Stable tiebreaker: deadlines before appointments on the same
// instant, then alphabetic by title so the feed is deterministic.
if items[i].Type != items[j].Type {
return items[i].Type == "deadline"
}
return items[i].Title < items[j].Title
}
return items[i].Date.Before(items[j].Date)
})
annotateAgendaUrgency(items, time.Now().UTC())
return items, nil
}
// loadDeadlines pulls pending deadlines whose due_date falls in [from, to).
// Completed deadlines are hidden — agenda is forward-looking.
func (s *AgendaService) loadDeadlines(ctx context.Context, userID uuid.UUID, role string, from, to time.Time) ([]AgendaItem, error) {
// due_date is a DATE; compare against the date portion of the window.
fromDate := from.Format("2006-01-02")
toDate := to.Format("2006-01-02")
query := `
SELECT f.id,
f.title,
f.due_date,
f.status,
p.id AS project_id,
p.title AS project_title,
p.type AS project_type,
p.reference AS project_reference
FROM paliad.deadlines f
JOIN paliad.projects p ON p.id = f.project_id
WHERE f.status = 'pending'
AND f.due_date >= $3::date
AND f.due_date < $4::date
AND ($2 = 'admin' OR EXISTS (
SELECT 1 FROM paliad.project_teams pt
WHERE pt.user_id = $1
AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
))
ORDER BY f.due_date ASC, f.created_at ASC`
type row struct {
ID uuid.UUID `db:"id"`
Title string `db:"title"`
DueDate time.Time `db:"due_date"`
Status string `db:"status"`
ProjectID uuid.UUID `db:"project_id"`
ProjectTitle string `db:"project_title"`
ProjectType string `db:"project_type"`
ProjectReference *string `db:"project_reference"`
}
var rows []row
if err := s.db.SelectContext(ctx, &rows, query, userID, role, fromDate, toDate); err != nil {
return nil, fmt.Errorf("agenda deadlines: %w", err)
}
out := make([]AgendaItem, 0, len(rows))
for _, r := range rows {
due := r.DueDate.Format("2006-01-02")
status := r.Status
projectID := r.ProjectID
projectTitle := r.ProjectTitle
projectType := r.ProjectType
out = append(out, AgendaItem{
ID: r.ID,
Type: "deadline",
Title: r.Title,
Date: time.Date(r.DueDate.Year(), r.DueDate.Month(), r.DueDate.Day(), 0, 0, 0, 0, time.UTC),
DueDate: &due,
Status: &status,
ProjectID: &projectID,
ProjectTitle: &projectTitle,
ProjectType: &projectType,
ProjectRef: r.ProjectReference,
})
}
return out, nil
}
// loadAppointments pulls appointments whose start_at falls in [from, to).
// Includes personal appointments (project_id IS NULL, creator-only) and
// project-attached appointments subject to the team predicate.
func (s *AgendaService) loadAppointments(ctx context.Context, userID uuid.UUID, role string, from, to time.Time) ([]AgendaItem, error) {
query := `
SELECT t.id,
t.title,
t.start_at,
t.end_at,
t.location,
t.appointment_type,
t.project_id,
p.title AS project_title,
p.type AS project_type,
p.reference AS project_reference
FROM paliad.appointments t
LEFT JOIN paliad.projects p ON p.id = t.project_id
WHERE t.start_at >= $3
AND t.start_at < $4
AND (
(t.project_id IS NULL AND t.created_by = $1)
OR (t.project_id IS NOT NULL AND ($2 = 'admin' OR EXISTS (
SELECT 1 FROM paliad.project_teams pt
WHERE pt.user_id = $1
AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
)))
)
ORDER BY t.start_at ASC, t.created_at ASC`
type row struct {
ID uuid.UUID `db:"id"`
Title string `db:"title"`
StartAt time.Time `db:"start_at"`
EndAt *time.Time `db:"end_at"`
Location *string `db:"location"`
AppointmentType *string `db:"appointment_type"`
ProjectID *uuid.UUID `db:"project_id"`
ProjectTitle *string `db:"project_title"`
ProjectType *string `db:"project_type"`
ProjectReference *string `db:"project_reference"`
}
var rows []row
if err := s.db.SelectContext(ctx, &rows, query, userID, role, from, to); err != nil {
return nil, fmt.Errorf("agenda appointments: %w", err)
}
out := make([]AgendaItem, 0, len(rows))
for _, r := range rows {
out = append(out, AgendaItem{
ID: r.ID,
Type: "appointment",
Title: r.Title,
Date: r.StartAt,
EndAt: r.EndAt,
Location: r.Location,
AppointmentType: r.AppointmentType,
ProjectID: r.ProjectID,
ProjectTitle: r.ProjectTitle,
ProjectType: r.ProjectType,
ProjectRef: r.ProjectReference,
})
}
return out, nil
}
// annotateAgendaUrgency classifies each item so the client can apply the
// traffic-light styling without re-deriving the buckets.
//
// overdue — in the past (deadlines only; appointments only go "later")
// today — same calendar day (UTC — kept in sync with server window)
// tomorrow — next calendar day
// this_week — within the next 7 days (exclusive of today/tomorrow)
// later — beyond 7 days
func annotateAgendaUrgency(items []AgendaItem, now time.Time) {
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
tomorrow := today.AddDate(0, 0, 1)
dayAfterTomorrow := today.AddDate(0, 0, 2)
endOfWeek := today.AddDate(0, 0, 7)
for i := range items {
d := items[i].Date
switch {
case d.Before(today):
items[i].Urgency = "overdue"
case !d.Before(today) && d.Before(tomorrow):
items[i].Urgency = "today"
case !d.Before(tomorrow) && d.Before(dayAfterTomorrow):
items[i].Urgency = "tomorrow"
case !d.Before(dayAfterTomorrow) && d.Before(endOfWeek):
items[i].Urgency = "this_week"
default:
items[i].Urgency = "later"
}
}
}