diff --git a/cmd/server/main.go b/cmd/server/main.go index 6639fc0..cc9cd94 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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") diff --git a/frontend/build.ts b/frontend/build.ts index d4f5faa..00caaf2 100644 --- a/frontend/build.ts +++ b/frontend/build.ts @@ -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"; const DIST = join(import.meta.dir, "dist"); @@ -62,6 +63,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"), ], outdir: join(DIST, "assets"), @@ -109,6 +111,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()); console.log("Build complete \u2192 dist/"); diff --git a/frontend/src/agenda.tsx b/frontend/src/agenda.tsx new file mode 100644 index 0000000..e8f9e65 --- /dev/null +++ b/frontend/src/agenda.tsx @@ -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 "" + ( + + + + + Agenda — Paliad + + + + + ); +} diff --git a/frontend/src/client/agenda.ts b/frontend/src/client/agenda.ts new file mode 100644 index 0000000..0f02bd0 --- /dev/null +++ b/frontend/src/client/agenda.ts @@ -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(".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(".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 { + 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(); + 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 `
+

+ ${esc(relativeDayLabel(bucket.day))} + ${esc(fullDateLabel(bucket.day))} +

+
    + ${bucket.items.map(renderItem).join("")} +
+
`; +} + +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 + ? `${esc(formatProjectLabel(it))}` + : ""; + + const timePart = it.type === "appointment" + ? `${esc(formatAppointmentTime(it))}` + : ""; + const urgencyTag = `${esc(t(`agenda.urgency.${it.urgency}`))}`; + const locationPart = it.type === "appointment" && it.location + ? `${esc(it.location)}` + : ""; + 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 `
  • + + + + + ${esc(typeLabel)}: + ${esc(it.title)} + + + ${project} + ${timePart} + ${locationPart} + + + + ${urgencyTag} + + +
  • `; +} + +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(".agenda-chip[data-type]").forEach((btn) => { + btn.classList.toggle("agenda-chip-active", btn.dataset.type === state.type); + }); + document.querySelectorAll(".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 ''; +} + +function appointmentIcon(): string { + return ''; +} diff --git a/frontend/src/client/i18n.ts b/frontend/src/client/i18n.ts index ec69ff4..11e51a8 100644 --- a/frontend/src/client/i18n.ts +++ b/frontend/src/client/i18n.ts @@ -27,6 +27,7 @@ const translations: Record> = { "nav.fristen": "Fristen", "nav.termine": "Termine", "nav.dashboard": "Dashboard", + "nav.agenda": "Agenda", "nav.group.uebersicht": "\u00dcbersicht", "nav.group.arbeit": "Arbeit", "nav.group.werkzeuge": "Werkzeuge", @@ -1011,6 +1012,37 @@ const translations: Record> = { "notizen.time.just_now": "gerade eben", "notizen.error.empty": "Notiz darf nicht leer sein.", "notizen.error.generic": "Aktion fehlgeschlagen. Bitte erneut versuchen.", + + // Agenda (t-paliad-030) — unified timeline across projects + "agenda.title": "Agenda — Paliad", + "agenda.heading": "Agenda", + "agenda.subtitle": "Kommende Fristen und Termine über alle sichtbaren Akten, nach Tag gruppiert.", + "agenda.unavailable": "Agenda zurzeit nicht verfügbar — bitte Administrator kontaktieren.", + "agenda.loading": "Lädt …", + "agenda.filter.type": "Ansicht", + "agenda.filter.both": "Beides", + "agenda.filter.deadlines": "Nur Fristen", + "agenda.filter.appointments": "Nur Termine", + "agenda.filter.range": "Zeitraum", + "agenda.range.7": "7 Tage", + "agenda.range.14": "14 Tage", + "agenda.range.30": "30 Tage", + "agenda.range.90": "90 Tage", + "agenda.empty.title": "Keine Einträge im Zeitraum", + "agenda.empty.hint": "Nichts Fälliges — erweitern Sie den Zeitraum oder legen Sie neue Fristen oder Termine an.", + "agenda.label.deadline": "Frist", + "agenda.label.appointment": "Termin", + "agenda.appointment_type.hearing": "Verhandlung", + "agenda.appointment_type.meeting": "Besprechung", + "agenda.appointment_type.consultation": "Mandantentermin", + "agenda.appointment_type.deadline_hearing": "Fristentermin", + "agenda.day.today": "Heute", + "agenda.day.tomorrow": "Morgen", + "agenda.urgency.overdue": "Überfällig", + "agenda.urgency.today": "Heute", + "agenda.urgency.tomorrow": "Morgen", + "agenda.urgency.this_week": "Diese Woche", + "agenda.urgency.later": "Später", }, en: { @@ -1030,6 +1062,7 @@ const translations: Record> = { "nav.fristen": "Deadlines", "nav.termine": "Appointments", "nav.dashboard": "Dashboard", + "nav.agenda": "Agenda", "nav.group.uebersicht": "Overview", "nav.group.arbeit": "Work", "nav.group.werkzeuge": "Tools", @@ -2014,6 +2047,37 @@ const translations: Record> = { "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", }, }; diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 1a8e5d5..5ed0dfb 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -16,6 +16,7 @@ const ICON_MENU = ' 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) +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 37b1fbb..1c29d5b 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -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, } } @@ -186,6 +188,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) @@ -200,6 +203,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)) diff --git a/internal/handlers/projects.go b/internal/handlers/projects.go index 733f411..f36014c 100644 --- a/internal/handlers/projects.go +++ b/internal/handlers/projects.go @@ -31,6 +31,7 @@ type dbServices struct { checklistInst *services.ChecklistInstanceService mail *services.MailService invite *services.InviteService + agenda *services.AgendaService } var dbSvc *dbServices diff --git a/internal/services/agenda_service.go b/internal/services/agenda_service.go new file mode 100644 index 0000000..8c11c6c --- /dev/null +++ b/internal/services/agenda_service.go @@ -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" + } + } +}