Merge: Agenda — upcoming deadlines + appointments timeline
# Conflicts: # frontend/src/styles/global.css
This commit is contained in:
@@ -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")
|
||||
|
||||
|
||||
@@ -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
85
frontend/src/agenda.tsx
Normal 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 — 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 ü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ügbar — 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ädt …
|
||||
</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äge im Zeitraum</h2>
|
||||
<p data-i18n="agenda.empty.hint">
|
||||
Nichts Fälliges — 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>
|
||||
);
|
||||
}
|
||||
355
frontend/src/client/agenda.ts
Normal file
355
frontend/src/client/agenda.ts
Normal 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>';
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
144
internal/handlers/agenda.go
Normal 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"}
|
||||
}
|
||||
58
internal/handlers/agenda_shell.go
Normal file
58
internal/handlers/agenda_shell.go
Normal 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)
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -31,6 +31,7 @@ type dbServices struct {
|
||||
checklistInst *services.ChecklistInstanceService
|
||||
mail *services.MailService
|
||||
invite *services.InviteService
|
||||
agenda *services.AgendaService
|
||||
}
|
||||
|
||||
var dbSvc *dbServices
|
||||
|
||||
274
internal/services/agenda_service.go
Normal file
274
internal/services/agenda_service.go
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user