feat(t-paliad-110): add shared EventsPage component + bucket-aware backend tweaks

PR-2 of the Fristen+Termine unification. Pure additive change — the existing
deadlines.tsx + appointments.tsx pages stay live; this PR introduces the new
events.tsx shell + client/events.ts runtime that PR-3 will mount onto the
two routes.

Frontend (new):
- frontend/src/events.tsx — shared shell with the 3-chip type toggle
  (Fristen / Termine / Beides), the 5-card summary row (Überfällig
  conditional + 4 universal cards), the union filter row, and the unified
  table that renders a discriminated row per type. Two header CTAs ("Neue
  Frist" + "Neuer Termin") collapse to the relevant one in single-type mode.
- frontend/src/client/events.ts — runtime. Reads window.__PALIAD_EVENTS__
  (PR-3 will inject defaultType from the Go handler), derives the rest from
  ?type query param. Card click sets status filter; the events endpoint
  takes care of bucket-aware appointment-side date windowing so both rails
  stay in sync in Beides mode. Hide-on-uniform pattern applied per column
  (rule, event_type, location, appointment_type, status, row-type chip).
- frontend/build.ts — emits events-deadlines.html + events-appointments.html
  from one renderEvents(currentPath) so each output gets the right Sidebar
  highlight; client/events.ts bundle added.
- 16 i18n keys (DE+EN): events.toggle.*, events.summary.later,
  events.col.*, events.row.type.*, events.empty.*, events.unavailable plus
  the new deadlines.summary.later / deadlines.filter.later pair for the
  Später bucket.
- CSS: --bucket-later (#1d4ed8 light / #60a5fa dark) for the Später card,
  matching events-table--hide-* column hiders, .events-row-type-chip
  styling, .event-type-chip-row spacing.

Backend tweaks (small):
- DeadlineFilterLater (`later`): pending deadlines past Mon-week-after.
  Click-target for the Später card.
- EventService.ListVisibleForUser now derives an appointment-side date
  window from a bucket-style status (today/this_week/next_week/later) so
  card clicks filter both rails consistently. Overdue/Completed exclude
  appointments entirely (no appointment analogue).
- pickLater / pickEarlier helpers intersect the bucket-derived window with
  any caller-supplied from/to.

go build/vet/test ./... clean. bun run build clean (1394 keys, IIFE prologue
guard passes).
This commit is contained in:
m
2026-05-04 13:46:33 +02:00
parent 2102dfd07d
commit fe9c1b7de2
8 changed files with 1146 additions and 12 deletions

View File

@@ -16,6 +16,7 @@ import { renderProjects } from "./src/projects";
import { renderProjectsNew } from "./src/projects-new";
import { renderProjectsDetail } from "./src/projects-detail";
import { renderDeadlines } from "./src/deadlines";
import { renderEvents } from "./src/events";
import { renderDeadlinesNew } from "./src/deadlines-new";
import { renderDeadlinesDetail } from "./src/deadlines-detail";
import { renderDeadlinesCalendar } from "./src/deadlines-calendar";
@@ -240,6 +241,7 @@ async function build() {
join(import.meta.dir, "src/client/projects-new.ts"),
join(import.meta.dir, "src/client/projects-detail.ts"),
join(import.meta.dir, "src/client/deadlines.ts"),
join(import.meta.dir, "src/client/events.ts"),
join(import.meta.dir, "src/client/deadlines-new.ts"),
join(import.meta.dir, "src/client/deadlines-detail.ts"),
join(import.meta.dir, "src/client/deadlines-calendar.ts"),
@@ -349,6 +351,11 @@ async function build() {
await Bun.write(join(DIST, "projects-new.html"), renderProjectsNew());
await Bun.write(join(DIST, "projects-detail.html"), renderProjectsDetail());
await Bun.write(join(DIST, "deadlines.html"), renderDeadlines());
// t-paliad-110 — shared EventsPage shell. Two HTML outputs (events-deadlines
// / events-appointments) so each route gets a Sidebar/BottomNav highlighted
// for the matching nav entry; the Go handler injects defaultType at runtime.
await Bun.write(join(DIST, "events-deadlines.html"), renderEvents("/deadlines"));
await Bun.write(join(DIST, "events-appointments.html"), renderEvents("/appointments"));
await Bun.write(join(DIST, "deadlines-new.html"), renderDeadlinesNew());
await Bun.write(join(DIST, "deadlines-detail.html"), renderDeadlinesDetail());
await Bun.write(join(DIST, "deadlines-calendar.html"), renderDeadlinesCalendar());

View File

@@ -0,0 +1,725 @@
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
import {
attachEventTypeMultiSelectFilter,
fetchEventTypes,
eventTypeLabel,
type EventType,
type FilterHandle,
} from "./event-types";
// EventsPage shared client (t-paliad-110). Drives /deadlines and
// /appointments off the same shell — the route handler injects
// `window.__PALIAD_EVENTS__ = { defaultType: "deadline" | "appointment" }`
// on first paint and we read it here to set the initial chip + filter
// visibility. The chip toggle on the page lets the user widen to
// "Beides" without a route change; the URL gets `?type=` for state.
declare global {
interface Window {
__PALIAD_EVENTS__?: {
defaultType?: EventTypeChoice;
};
}
}
type EventTypeChoice = "deadline" | "appointment" | "all";
interface EventListItem {
type: "deadline" | "appointment";
id: string;
title: string;
description?: string;
event_date: string;
project_id?: string;
project_reference?: string;
project_title?: string;
project_type?: string;
// deadline-only
due_date?: string;
status?: string;
completed_at?: string;
source?: string;
rule_id?: string;
rule_code?: string;
rule_name?: string;
rule_name_en?: string;
event_type_ids?: string[];
// appointment-only
start_at?: string;
end_at?: string;
location?: string;
appointment_type?: string;
}
interface EventSummary {
deadlines?: {
overdue: number;
today: number;
this_week: number;
next_week: number;
later: number;
completed: number;
total: number;
};
appointments?: {
today: number;
this_week: number;
next_week: number;
later: number;
total: number;
};
}
interface Project {
id: string;
reference?: string | null;
title: string;
}
interface Me {
id: string;
job_title: string | null;
global_role: string;
}
const PERSONAL = "__personal__";
let currentType: EventTypeChoice = "deadline";
let statusFilter = "pending";
let projectFilter = "";
let appointmentTypeFilter = "";
let allItems: EventListItem[] = [];
let allProjects: Project[] = [];
let me: Me | null = null;
let eventTypeFilter: FilterHandle | null = null;
let eventTypeByID: Map<string, EventType> = new Map();
let loadedOK = false;
function urlParams(): URLSearchParams {
return new URLSearchParams(window.location.search);
}
function defaultType(): EventTypeChoice {
const injected = window.__PALIAD_EVENTS__?.defaultType;
if (injected === "deadline" || injected === "appointment" || injected === "all") {
return injected;
}
// Fallback: derive from path so a stale handler bundle still routes correctly.
if (window.location.pathname.startsWith("/appointments")) return "appointment";
return "deadline";
}
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function setCount(id: string, n: number) {
const el = document.getElementById(id);
if (el) el.textContent = String(n);
}
function fmtDateOnly(iso: string): string {
try {
const d = new Date(iso.length === 10 ? iso + "T00:00:00" : iso);
return d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
} catch {
return iso;
}
}
function fmtDateTime(iso: string): string {
try {
const d = new Date(iso);
return d.toLocaleString(getLang() === "de" ? "de-DE" : "en-GB", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return iso;
}
}
function fmtTime(iso: string): string {
try {
const d = new Date(iso);
return d.toLocaleTimeString(getLang() === "de" ? "de-DE" : "en-GB", {
hour: "2-digit",
minute: "2-digit",
});
} catch {
return "";
}
}
// formatDateCell yields the per-row date label. Deadlines render as
// "31.08.2026" (date-only). Appointments render as "15.09.2026 09:00",
// extended with "11:00" when end_at is on the same day, or
// "→ 16.09.2026 11:00" when end_at spills into a later day.
function formatDateCell(item: EventListItem): string {
if (item.type === "deadline") {
return fmtDateOnly(item.due_date ?? item.event_date);
}
if (!item.start_at) return "";
const start = new Date(item.start_at);
const startLabel = fmtDateTime(item.start_at);
if (!item.end_at) return startLabel;
const end = new Date(item.end_at);
const sameDay =
start.getFullYear() === end.getFullYear() &&
start.getMonth() === end.getMonth() &&
start.getDate() === end.getDate();
if (sameDay) {
return `${startLabel}${fmtTime(item.end_at)}`;
}
return `${startLabel}${fmtDateTime(item.end_at)}`;
}
function urgencyClass(item: EventListItem): string {
if (item.type === "deadline") {
if (item.status === "completed") return "frist-urgency-done";
}
const today = new Date();
today.setHours(0, 0, 0, 0);
const d = new Date(item.event_date);
const diffDays = Math.floor((d.getTime() - today.getTime()) / 86400000);
if (diffDays < 0) return "frist-urgency-overdue";
if (diffDays <= 7) return "frist-urgency-soon";
return "frist-urgency-later";
}
function ruleDisplay(item: EventListItem): string {
if (item.type !== "deadline") return "";
const lang = getLang();
const localized = lang === "en" ? item.rule_name_en : item.rule_name;
if (localized && localized.trim()) return esc(localized);
return "&mdash;";
}
function eventTypeDisplay(item: EventListItem): string {
if (item.type !== "deadline") return "";
const ids = item.event_type_ids ?? [];
if (ids.length === 0) return "&mdash;";
const labels: string[] = [];
for (const id of ids) {
const et = eventTypeByID.get(id);
if (et) labels.push(eventTypeLabel(et));
}
if (labels.length === 0) return "&mdash;";
return labels.map((l) => `<span class="entity-event-type-pill">${esc(l)}</span>`).join(" ");
}
function rowTypeChip(item: EventListItem): string {
const label = item.type === "deadline" ? t("events.row.type.deadline") : t("events.row.type.appointment");
const cls = item.type === "deadline" ? "events-row-type-frist" : "events-row-type-termin";
return `<span class="events-row-type-chip ${cls}">${esc(label)}</span>`;
}
function canReopen(): boolean {
return !!me && me.global_role === "global_admin";
}
async function loadProjects() {
try {
const resp = await fetch("/api/projects");
if (resp.ok) allProjects = await resp.json();
} catch {
/* non-fatal */
}
}
async function loadMe() {
try {
const resp = await fetch("/api/me");
if (resp.ok) me = await resp.json();
} catch {
/* non-fatal */
}
}
async function loadEventTypes() {
try {
const types = await fetchEventTypes();
eventTypeByID = new Map(types.map((et) => [et.id, et]));
} catch {
/* non-fatal */
}
}
async function loadSummary() {
try {
const params = new URLSearchParams();
params.set("type", currentType);
if (projectFilter && projectFilter !== PERSONAL) {
params.set("project_id", projectFilter);
}
const resp = await fetch(`/api/events/summary?${params.toString()}`);
if (!resp.ok) return;
const sum: EventSummary = await resp.json();
// 4 universal cards: pick the deadline value when present, otherwise
// the appointment value. In Beides mode, sum both rails.
const today = (sum.deadlines?.today ?? 0) + (sum.appointments?.today ?? 0);
const thisWeek = (sum.deadlines?.this_week ?? 0) + (sum.appointments?.this_week ?? 0);
const nextWeek = (sum.deadlines?.next_week ?? 0) + (sum.appointments?.next_week ?? 0);
const later = (sum.deadlines?.later ?? 0) + (sum.appointments?.later ?? 0);
setCount("events-sum-today", today);
setCount("events-sum-week", thisWeek);
setCount("events-sum-next-week", nextWeek);
setCount("events-sum-later", later);
// Überfällig is deadline-only and conditional. Hide on appointment view
// OR when the count is zero. Alarm-style when count > 0 on a view that
// includes deadlines (mirrors t-paliad-105 / t-paliad-106 behaviour).
const overdue = sum.deadlines?.overdue ?? 0;
setCount("events-sum-overdue", overdue);
applyOverdueState(overdue);
} catch {
/* non-fatal */
}
}
function applyOverdueState(overdue: number) {
const card = document.querySelector<HTMLElement>('.frist-summary-card[data-bucket="overdue"]');
if (!card) return;
const includeDeadlines = currentType === "deadline" || currentType === "all";
const hidden = !includeDeadlines || overdue === 0;
card.classList.toggle("frist-card-overdue-hidden", hidden);
card.classList.toggle("frist-card-alarm", !hidden && overdue > 0);
}
async function loadList() {
const unavailable = document.getElementById("events-unavailable")!;
const tableWrap = document.querySelector<HTMLElement>(".entity-table-wrap")!;
try {
const params = new URLSearchParams();
params.set("type", currentType);
if (currentType !== "appointment" && statusFilter) {
// Status filter is deadline-only. In Beides mode the status still
// maps onto the deadline side and the EventService handles bucket-
// aware appointment filtering for Heute/Diese Woche/etc.
params.set("status", statusFilter);
}
if (projectFilter && projectFilter !== PERSONAL) {
params.set("project_id", projectFilter);
}
if (currentType !== "appointment") {
const eventTypeQuery = eventTypeFilter?.toQueryValue() ?? "";
if (eventTypeQuery) params.set("event_type", eventTypeQuery);
}
if (currentType === "appointment" && appointmentTypeFilter) {
params.set("type_filter", appointmentTypeFilter);
}
const resp = await fetch(`/api/events?${params.toString()}`);
if (resp.status === 503) {
unavailable.style.display = "block";
tableWrap.style.display = "none";
document.getElementById("events-empty")!.style.display = "none";
return;
}
if (!resp.ok) {
unavailable.style.display = "block";
tableWrap.style.display = "none";
return;
}
let data: EventListItem[] = await resp.json();
// "Personal only" is a UI shorthand for project_id IS NULL on the
// appointment side. Filter client-side because the backend doesn't
// expose a NULL-id query (matches current /appointments behaviour).
if (projectFilter === PERSONAL) {
data = data.filter((x) => x.type === "appointment" && !x.project_id);
}
allItems = data;
loadedOK = true;
render();
} catch {
unavailable.style.display = "block";
tableWrap.style.display = "none";
}
}
function render() {
if (!loadedOK) return;
const tbody = document.getElementById("events-body")!;
const empty = document.getElementById("events-empty")!;
const emptyFiltered = document.getElementById("events-empty-filtered")!;
const tableWrap = document.querySelector<HTMLElement>(".entity-table-wrap")!;
const table = document.getElementById("events-table");
if (allItems.length === 0) {
tbody.innerHTML = "";
tableWrap.style.display = "none";
if (isFilterPristine()) {
empty.style.display = "block";
emptyFiltered.style.display = "none";
} else {
empty.style.display = "none";
emptyFiltered.style.display = "block";
}
return;
}
tableWrap.style.display = "";
empty.style.display = "none";
emptyFiltered.style.display = "none";
const showReopen = canReopen();
tbody.innerHTML = allItems.map((item) => renderRow(item, showReopen)).join("");
// Hide-on-uniform pattern (t-paliad-073, generalised in t-088). When all
// visible rows agree on a column we drop the column entirely so empty
// cells don't add noise. Each column has its own data signal so we can
// toggle independently.
const anyDeadline = allItems.some((x) => x.type === "deadline");
const anyAppointment = allItems.some((x) => x.type === "appointment");
const anyEventType = allItems.some((x) => (x.event_type_ids ?? []).length > 0);
const anyLocation = allItems.some((x) => !!x.location);
const anyAppointmentType = allItems.some((x) => !!x.appointment_type);
const anyRule = allItems.some((x) => x.type === "deadline" && (x.rule_name || x.rule_name_en));
const anyStatus = anyDeadline; // appointments don't carry a status column
table?.classList.toggle("events-table--hide-row-type", !anyDeadline || !anyAppointment);
table?.classList.toggle("events-table--hide-rule", !anyRule);
table?.classList.toggle("entity-table--hide-event-type", !anyEventType);
table?.classList.toggle("events-table--hide-location", !anyLocation);
table?.classList.toggle("events-table--hide-appointment-type", !anyAppointmentType);
table?.classList.toggle("entity-table--hide-status", !anyStatus);
wireRowHandlers(tbody);
}
function renderRow(item: EventListItem, showReopen: boolean): string {
const urgency = urgencyClass(item);
const dateLabel = formatDateCell(item);
const ruleLabel = item.type === "deadline" ? ruleDisplay(item) : "";
const eventTypeCell = item.type === "deadline" ? eventTypeDisplay(item) : "";
const projectCell = item.project_id
? `<a class="entity-ref-link" href="/projects/${esc(item.project_id)}">${esc(item.project_reference ?? "")}</a>`
+ `<span class="frist-project-title" title="${esc(item.project_title ?? "")}">${esc(item.project_title ?? "")}</span>`
: `<span class="termin-personal-tag">${esc(t("appointments.personal"))}</span>`;
let checkCell = "";
let titleClass = "";
let statusCell = "";
if (item.type === "deadline") {
const isDone = item.status === "completed";
titleClass = isDone ? "frist-title-done" : "";
const statusLabel = tDyn(`deadlines.status.${item.status ?? "pending"}`) || (item.status ?? "");
statusCell = `<span class="entity-status-chip entity-status-${esc(item.status ?? "")}">${esc(statusLabel)}</span>`;
const reopenLabel = esc(t("deadlines.action.reopen"));
if (isDone) {
checkCell = showReopen
? `<button type="button" class="frist-reopen-btn" aria-label="${reopenLabel}" title="${reopenLabel}">↻</button>`
: `<input type="checkbox" class="frist-complete-cb" checked disabled aria-label="${esc(t("deadlines.complete.action"))}" />`;
} else {
checkCell = `<input type="checkbox" class="frist-complete-cb" aria-label="${esc(t("deadlines.complete.action"))}" />`;
}
} else {
const typeClass = item.appointment_type ? `termin-type-${item.appointment_type}` : "";
checkCell = `<span class="termin-dot ${typeClass}"></span>`;
}
const locationCell = item.location ? esc(item.location) : "&mdash;";
const appointmentTypeCell = item.appointment_type
? `<span class="termin-type-chip termin-type-${esc(item.appointment_type)}">${esc(tDyn(`appointments.type.${item.appointment_type}`) || item.appointment_type)}</span>`
: "&mdash;";
return `<tr class="frist-row events-row events-row-${item.type}" data-id="${esc(item.id)}" data-type="${item.type}">
<td class="frist-col-check">${checkCell}</td>
<td class="events-col-row-type">${rowTypeChip(item)}</td>
<td class="frist-col-due ${urgency}"><span class="frist-due-dot"></span>${esc(dateLabel)}</td>
<td class="frist-col-title ${titleClass}">${esc(item.title)}</td>
<td class="frist-col-project">${projectCell}</td>
<td class="frist-col-rule events-col-rule">${ruleLabel || "&mdash;"}</td>
<td class="entity-col-event-type">${eventTypeCell || "&mdash;"}</td>
<td class="events-col-location">${locationCell}</td>
<td class="events-col-appointment-type">${appointmentTypeCell}</td>
<td class="entity-col-status">${statusCell}</td>
</tr>`;
}
function wireRowHandlers(tbody: HTMLElement) {
tbody.querySelectorAll<HTMLTableRowElement>(".events-row").forEach((row) => {
const id = row.dataset.id!;
const type = row.dataset.type!;
row.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
if (
target.closest(".frist-complete-cb") ||
target.closest(".frist-reopen-btn") ||
target.closest("a")
) return;
window.location.href = type === "deadline" ? `/deadlines/${id}` : `/appointments/${id}`;
});
const cb = row.querySelector<HTMLInputElement>(".frist-complete-cb");
if (cb && !cb.disabled) {
cb.addEventListener("change", async () => {
if (!cb.checked) return;
cb.disabled = true;
try {
const resp = await fetch(`/api/deadlines/${id}/complete`, { method: "PATCH" });
if (resp.ok) {
await Promise.all([loadList(), loadSummary()]);
} else {
cb.checked = false;
cb.disabled = false;
}
} catch {
cb.checked = false;
cb.disabled = false;
}
});
}
const reopenBtn = row.querySelector<HTMLButtonElement>(".frist-reopen-btn");
if (reopenBtn) {
reopenBtn.addEventListener("click", async () => {
reopenBtn.disabled = true;
try {
const resp = await fetch(`/api/deadlines/${id}/reopen`, { method: "PATCH" });
if (resp.ok) {
await Promise.all([loadList(), loadSummary()]);
} else {
reopenBtn.disabled = false;
}
} catch {
reopenBtn.disabled = false;
}
});
}
});
}
function isFilterPristine(): boolean {
return (
statusFilter === "pending" &&
!projectFilter &&
!appointmentTypeFilter &&
(eventTypeFilter?.toQueryValue() ?? "") === ""
);
}
function applyTypeVisibility() {
// Page-level chrome — heading, subtitle, actions, type chips' active state.
const heading = document.getElementById("events-heading");
const subtitle = document.getElementById("events-subtitle");
const title = document.getElementById("events-title");
const isAppointment = currentType === "appointment";
const isDeadline = currentType === "deadline";
const isAll = currentType === "all";
if (heading) heading.textContent = isAppointment ? t("appointments.list.heading") : t("deadlines.list.heading");
if (subtitle) subtitle.textContent = isAppointment ? t("appointments.list.subtitle") : t("deadlines.list.subtitle");
if (title) title.textContent = isAppointment ? t("appointments.list.title") : t("deadlines.list.title");
// Calendar links: hide the irrelevant one in single-type modes.
toggleDisplay("events-action-deadline-cal", isDeadline || isAll, "inline-flex");
toggleDisplay("events-action-appointment-cal", isAppointment || isAll, "inline-flex");
toggleDisplay("events-action-new-deadline", isDeadline || isAll, "inline-flex");
toggleDisplay("events-action-new-appointment", isAppointment || isAll, "inline-flex");
// Empty-state CTAs.
toggleDisplay("events-empty-cta-deadline", isDeadline || isAll, "inline-flex");
toggleDisplay("events-empty-cta-appointment", isAppointment || isAll, "inline-flex");
// Status filter is deadline-only.
toggleFilterPair("events-filter-status", !isAppointment);
// Event-type multi-select also deadline-only (appointments have no event_types).
toggleFilterPair("events-filter-event-type", !isAppointment, "events-filter-event-type-label");
toggleDisplay("events-filter-event-type-panel", !isAppointment, "block");
// Termin-Typ is appointment-only.
toggleFilterPair("events-filter-appointment-type", !isDeadline);
// "Personal only" project option only makes sense with appointments in scope.
const projectSel = document.getElementById("events-filter-project") as HTMLSelectElement | null;
if (projectSel) {
const personalOpt = projectSel.querySelector<HTMLOptionElement>('option[value="__personal__"]');
if (personalOpt) {
personalOpt.hidden = isDeadline;
personalOpt.disabled = isDeadline;
if (isDeadline && projectFilter === PERSONAL) {
projectFilter = "";
projectSel.value = "";
}
}
}
// Update chip active state.
document.querySelectorAll<HTMLButtonElement>('[data-event-type]').forEach((b) => {
b.classList.toggle("agenda-chip-active", b.dataset.eventType === currentType);
});
// Refresh overdue card visibility on type change.
applyOverdueState(parseInt(document.getElementById("events-sum-overdue")?.textContent ?? "0", 10));
}
function toggleDisplay(id: string, show: boolean, displayWhenShown = "block") {
const el = document.getElementById(id);
if (!el) return;
el.style.display = show ? displayWhenShown : "none";
}
// toggleFilterPair shows/hides a control AND its sibling label so the
// filter row collapses cleanly when a filter doesn't apply for the
// current type.
function toggleFilterPair(controlID: string, show: boolean, labelID?: string) {
toggleDisplay(controlID, show, "");
// Default: derive label by id="events-filter-X" → label htmlFor="events-filter-X"
const labelTarget = labelID ?? "";
if (labelTarget) {
toggleDisplay(labelTarget, show, "");
return;
}
const label = document.querySelector<HTMLLabelElement>(`label[for="${controlID}"]`);
if (label) label.style.display = show ? "" : "none";
}
function syncURLParams() {
const url = new URL(window.location.href);
url.searchParams.delete("type");
url.searchParams.delete("status");
url.searchParams.delete("project_id");
url.searchParams.delete("event_type");
url.searchParams.delete("type_filter");
// Default type per route comes from window.__PALIAD_EVENTS__; only
// surface in the URL when the user opted into something else.
if (currentType !== defaultType()) url.searchParams.set("type", currentType);
if (currentType !== "appointment" && statusFilter && statusFilter !== "pending") {
url.searchParams.set("status", statusFilter);
}
if (projectFilter) url.searchParams.set("project_id", projectFilter);
if (currentType !== "appointment") {
const eventQuery = eventTypeFilter?.toQueryValue() ?? "";
if (eventQuery) url.searchParams.set("event_type", eventQuery);
}
if (currentType === "appointment" && appointmentTypeFilter) {
url.searchParams.set("type_filter", appointmentTypeFilter);
}
window.history.replaceState(null, "", url.toString());
}
function populateProjectFilter() {
const sel = document.getElementById("events-filter-project") as HTMLSelectElement;
const options: string[] = [
`<option value="">${esc(t("deadlines.filter.akte.all"))}</option>`,
`<option value="${PERSONAL}">${esc(t("appointments.filter.akte.personal"))}</option>`,
];
for (const a of allProjects) {
options.push(
`<option value="${esc(a.id)}">${esc(a.reference || "")}${esc(a.title)}</option>`,
);
}
sel.innerHTML = options.join("");
if (projectFilter) sel.value = projectFilter;
}
function initFilters() {
const params = urlParams();
currentType = (params.get("type") as EventTypeChoice) || defaultType();
if (params.has("status")) statusFilter = params.get("status")!;
if (params.has("project_id")) projectFilter = params.get("project_id")!;
if (params.has("type_filter")) appointmentTypeFilter = params.get("type_filter")!;
const status = document.getElementById("events-filter-status") as HTMLSelectElement;
const project = document.getElementById("events-filter-project") as HTMLSelectElement;
const appointmentType = document.getElementById("events-filter-appointment-type") as HTMLSelectElement;
const eventTrigger = document.getElementById("events-filter-event-type") as HTMLButtonElement;
const eventPanel = document.getElementById("events-filter-event-type-panel") as HTMLElement;
status.value = statusFilter;
appointmentType.value = appointmentTypeFilter;
let initialEventIDs: string[] = [];
let initialIncludeUntyped = false;
const initialEventRaw = params.get("event_type") ?? "";
if (initialEventRaw) {
for (const tok of initialEventRaw.split(",")) {
const tt = tok.trim();
if (!tt) continue;
if (tt === "none") initialIncludeUntyped = true;
else initialEventIDs.push(tt);
}
}
status.addEventListener("change", async () => {
statusFilter = status.value;
syncURLParams();
await Promise.all([loadList(), loadSummary()]);
});
project.addEventListener("change", async () => {
projectFilter = project.value;
syncURLParams();
await Promise.all([loadList(), loadSummary()]);
});
appointmentType.addEventListener("change", async () => {
appointmentTypeFilter = appointmentType.value;
syncURLParams();
await loadList();
});
if (eventTrigger && eventPanel) {
eventTypeFilter = attachEventTypeMultiSelectFilter(eventTrigger, eventPanel, {
initialIDs: initialEventIDs,
initialIncludeUntyped: initialIncludeUntyped,
onChange: async () => {
syncURLParams();
await loadList();
},
});
}
document.querySelectorAll<HTMLButtonElement>('[data-event-type]').forEach((b) => {
b.addEventListener("click", async () => {
const next = b.dataset.eventType as EventTypeChoice;
if (next === currentType) return;
currentType = next;
applyTypeVisibility();
syncURLParams();
await Promise.all([loadList(), loadSummary()]);
});
});
}
function initSummaryCards() {
document.querySelectorAll<HTMLButtonElement>(".frist-summary-card").forEach((card) => {
card.addEventListener("click", async () => {
const bucket = card.dataset.bucket!;
// Map the card to the matching status filter; the events endpoint
// handles bucket-aware appointment filtering internally so both
// rails reflect the click consistently in Beides mode.
statusFilter = bucket;
const status = document.getElementById("events-filter-status") as HTMLSelectElement;
if (status) status.value = bucket;
syncURLParams();
await Promise.all([loadList(), loadSummary()]);
});
});
}
document.addEventListener("DOMContentLoaded", async () => {
initI18n();
initSidebar();
initFilters();
initSummaryCards();
applyTypeVisibility();
onLangChange(() => {
applyTypeVisibility();
render();
});
await Promise.all([loadProjects(), loadMe(), loadEventTypes()]);
populateProjectFilter();
await Promise.all([loadList(), loadSummary()]);
});

View File

@@ -526,6 +526,7 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.summary.thisweek": "Diese Woche",
"deadlines.summary.nextweek": "N\u00e4chste Woche",
"deadlines.summary.completed": "Erledigt",
"deadlines.summary.later": "Später",
"deadlines.filter.status": "Status",
"deadlines.filter.akte": "Projekt",
"deadlines.filter.all": "Alle (offen & erledigt)",
@@ -535,6 +536,7 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.filter.thisweek": "Diese Woche",
"deadlines.filter.nextweek": "N\u00e4chste Woche",
"deadlines.filter.completed": "Erledigt",
"deadlines.filter.later": "Später",
"deadlines.filter.akte.all": "Alle Projekte",
"deadlines.col.due": "F\u00e4llig",
"deadlines.col.title": "Titel",
@@ -1104,6 +1106,24 @@ const translations: Record<Lang, Record<string, string>> = {
"appointments.kalender.subtitle": "Monats\u00fcbersicht aller Termine.",
"appointments.kalender.list": "Listenansicht",
"appointments.kalender.empty": "Keine Termine im ausgew\u00e4hlten Zeitraum.",
// t-paliad-110 \u2014 unified Events page (rendered on both /deadlines and
// /appointments). The user-facing "Fristen" / "Termine" branding stays;
// these keys cover the shared chrome (chip toggle, type chip per row,
// generic Datum column header, etc).
"events.toggle.deadline": "Fristen",
"events.toggle.appointment": "Termine",
"events.toggle.all": "Beides",
"events.summary.later": "Sp\u00e4ter",
"events.col.date": "Datum",
"events.col.location": "Ort",
"events.col.appointment_type": "Termin-Typ",
"events.row.type.deadline": "Frist",
"events.row.type.appointment": "Termin",
"events.empty.title": "Keine Eintr\u00e4ge vorhanden",
"events.empty.hint": "Sobald Fristen oder Termine angelegt werden, erscheinen sie hier.",
"events.empty.filtered": "Keine Eintr\u00e4ge mit diesen Filtern.",
"events.unavailable": "Termin- und Fristenverwaltung zurzeit nicht verf\u00fcgbar \u2014 bitte Administrator kontaktieren.",
"caldav.title": "CalDAV-Synchronisation \u2014 Paliad",
"caldav.heading": "CalDAV-Synchronisation",
"caldav.subtitle": "Synchronisieren Sie Ihre Paliad-Termine mit Ihrem externen Kalender (Nextcloud, iCloud, Outlook, mailcow\u2026). Das Passwort wird verschl\u00fcsselt gespeichert und nie zur\u00fcckgegeben.",
@@ -2004,6 +2024,7 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.summary.thisweek": "This week",
"deadlines.summary.nextweek": "Next week",
"deadlines.summary.completed": "Done",
"deadlines.summary.later": "Later",
"deadlines.filter.status": "Status",
"deadlines.filter.akte": "Matter",
"deadlines.filter.all": "All (open & completed)",
@@ -2013,6 +2034,7 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.filter.thisweek": "This week",
"deadlines.filter.nextweek": "Next week",
"deadlines.filter.completed": "Done",
"deadlines.filter.later": "Later",
"deadlines.filter.akte.all": "All matters",
"deadlines.col.due": "Due",
"deadlines.col.title": "Title",
@@ -2578,6 +2600,21 @@ const translations: Record<Lang, Record<string, string>> = {
"appointments.kalender.subtitle": "Monthly overview of all appointments.",
"appointments.kalender.list": "List view",
"appointments.kalender.empty": "No appointments in the selected period.",
// t-paliad-110 \u2014 unified Events page (rendered on /deadlines + /appointments).
"events.toggle.deadline": "Deadlines",
"events.toggle.appointment": "Appointments",
"events.toggle.all": "Both",
"events.summary.later": "Later",
"events.col.date": "Date",
"events.col.location": "Location",
"events.col.appointment_type": "Appointment type",
"events.row.type.deadline": "Deadline",
"events.row.type.appointment": "Appointment",
"events.empty.title": "Nothing yet",
"events.empty.hint": "Once deadlines or appointments are added, they appear here.",
"events.empty.filtered": "No entries match these filters.",
"events.unavailable": "Deadline / appointment management currently unavailable \u2014 please contact your administrator.",
"caldav.title": "CalDAV sync \u2014 Paliad",
"caldav.heading": "CalDAV sync",
"caldav.subtitle": "Sync your Paliad appointments with your external calendar (Nextcloud, iCloud, Outlook, mailcow\u2026). The password is stored encrypted and never returned.",

226
frontend/src/events.tsx Normal file
View File

@@ -0,0 +1,226 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// EventsPage is the shared shell rendered on both /deadlines and
// /appointments (t-paliad-110). The current default type ("deadline" /
// "appointment") is injected by the Go handler via
// `window.__PALIAD_EVENTS__` and consumed by client/events.ts on init,
// which then drives the heading, the bucket cards, and the filter row
// without a re-mount.
//
// `currentPath` is what powers the Sidebar / BottomNav active highlight —
// "/deadlines" for the Fristen entry, "/appointments" for the Termine
// entry. Both routes share the rest of the markup verbatim.
export function renderEvents(currentPath: "/deadlines" | "/appointments"): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title id="events-title" data-i18n="deadlines.list.title">Fristen &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath={currentPath} />
<BottomNav currentPath={currentPath} />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<div className="entity-header-row">
<div>
<h1 id="events-heading" data-i18n="deadlines.list.heading">Fristen</h1>
<p
className="tool-subtitle"
id="events-subtitle"
data-i18n="deadlines.list.subtitle"
>
Persistente Fristen f&uuml;r Ihre Akten. &Uuml;berf&auml;llig, heute, diese Woche, n&auml;chste Woche &mdash; auf einen Blick.
</p>
</div>
<div className="fristen-header-actions" id="events-actions">
<a
href="/deadlines/calendar"
className="btn-secondary"
id="events-action-deadline-cal"
data-i18n="deadlines.list.calendar"
>
Kalenderansicht
</a>
<a
href="/appointments/calendar"
className="btn-secondary"
id="events-action-appointment-cal"
data-i18n="appointments.list.calendar"
>
Kalenderansicht
</a>
<a
href="/deadlines/new"
className="btn-primary btn-cta-lime"
id="events-action-new-deadline"
data-i18n="deadlines.list.new"
>
Neue Frist
</a>
<a
href="/appointments/new"
className="btn-primary btn-cta-lime"
id="events-action-new-appointment"
data-i18n="appointments.list.new"
>
Neuer Termin
</a>
</div>
</div>
</div>
<div className="agenda-chip-row event-type-chip-row" role="tablist" id="events-type-chips">
<button
type="button"
className="agenda-chip"
data-event-type="deadline"
data-i18n="events.toggle.deadline"
role="tab"
>
Fristen
</button>
<button
type="button"
className="agenda-chip"
data-event-type="appointment"
data-i18n="events.toggle.appointment"
role="tab"
>
Termine
</button>
<button
type="button"
className="agenda-chip"
data-event-type="all"
data-i18n="events.toggle.all"
role="tab"
>
Beides
</button>
</div>
<div className="frist-summary-cards" id="events-summary">
<button type="button" className="frist-summary-card frist-card-overdue" data-bucket="overdue">
<span className="frist-summary-dot" />
<span className="frist-summary-count" id="events-sum-overdue">0</span>
<span className="frist-summary-label" data-i18n="deadlines.summary.overdue">&Uuml;berf&auml;llig</span>
</button>
<button type="button" className="frist-summary-card frist-card-today" data-bucket="today">
<span className="frist-summary-dot" />
<span className="frist-summary-count" id="events-sum-today">0</span>
<span className="frist-summary-label" data-i18n="deadlines.summary.today">Heute</span>
</button>
<button type="button" className="frist-summary-card frist-card-week" data-bucket="this_week">
<span className="frist-summary-dot" />
<span className="frist-summary-count" id="events-sum-week">0</span>
<span className="frist-summary-label" data-i18n="deadlines.summary.thisweek">Diese Woche</span>
</button>
<button type="button" className="frist-summary-card frist-card-next-week" data-bucket="next_week">
<span className="frist-summary-dot" />
<span className="frist-summary-count" id="events-sum-next-week">0</span>
<span className="frist-summary-label" data-i18n="deadlines.summary.nextweek">N&auml;chste Woche</span>
</button>
<button type="button" className="frist-summary-card frist-card-later" data-bucket="later">
<span className="frist-summary-dot" />
<span className="frist-summary-count" id="events-sum-later">0</span>
<span className="frist-summary-label" data-i18n="deadlines.summary.later">Sp&auml;ter</span>
</button>
</div>
<div className="entity-controls">
<div className="filter-row">
<label className="filter-label" htmlFor="events-filter-status" data-i18n="deadlines.filter.status">Status</label>
<select id="events-filter-status" className="entity-select">
<option value="all" data-i18n="deadlines.filter.all">Alle (offen &amp; erledigt)</option>
<option value="pending" data-i18n="deadlines.filter.pending">Alle offenen</option>
<option value="overdue" data-i18n="deadlines.filter.overdue">&Uuml;berf&auml;llig</option>
<option value="today" data-i18n="deadlines.filter.today">Heute</option>
<option value="this_week" data-i18n="deadlines.filter.thisweek">Diese Woche</option>
<option value="next_week" data-i18n="deadlines.filter.nextweek">N&auml;chste Woche</option>
<option value="later" data-i18n="deadlines.filter.later">Sp&auml;ter</option>
<option value="completed" data-i18n="deadlines.filter.completed">Erledigt</option>
</select>
<label className="filter-label" htmlFor="events-filter-project" data-i18n="deadlines.filter.akte">Projekt</label>
<select id="events-filter-project" className="entity-select">
<option value="" data-i18n="deadlines.filter.akte.all">Alle Projekte</option>
<option value="__personal__" data-i18n="appointments.filter.akte.personal">Nur pers&ouml;nliche</option>
</select>
<label className="filter-label" htmlFor="events-filter-event-type" id="events-filter-event-type-label" data-i18n="deadlines.filter.event_type">Typ</label>
<button type="button" id="events-filter-event-type" className="entity-select multi-trigger" aria-haspopup="listbox" />
<div id="events-filter-event-type-panel" className="multi-panel" hidden />
<label className="filter-label" htmlFor="events-filter-appointment-type" id="events-filter-appointment-type-label" data-i18n="appointments.filter.type">Typ</label>
<select id="events-filter-appointment-type" className="entity-select">
<option value="" data-i18n="appointments.filter.type.all">Alle Typen</option>
<option value="hearing" data-i18n="appointments.type.hearing">Verhandlung</option>
<option value="meeting" data-i18n="appointments.type.meeting">Besprechung</option>
<option value="consultation" data-i18n="appointments.type.consultation">Beratung</option>
<option value="deadline_hearing" data-i18n="appointments.type.deadline_hearing">Fristverhandlung</option>
</select>
</div>
</div>
<div id="events-unavailable" className="entity-unavailable" style="display:none">
<p data-i18n="events.unavailable">
Termin- und Fristenverwaltung zurzeit nicht verf&uuml;gbar &mdash; bitte Administrator kontaktieren.
</p>
</div>
<div className="entity-table-wrap">
<table className="entity-table fristen-table events-table" id="events-table">
<thead>
<tr>
<th />
<th className="events-col-row-type" />
<th data-i18n="events.col.date">Datum</th>
<th data-i18n="deadlines.col.title">Titel</th>
<th data-i18n="deadlines.col.akte">Projekt</th>
<th className="events-col-rule" data-i18n="deadlines.col.rule">Regel</th>
<th className="entity-col-event-type" data-i18n="deadlines.col.event_type">Typ</th>
<th className="events-col-location" data-i18n="events.col.location">Ort</th>
<th className="events-col-appointment-type" data-i18n="events.col.appointment_type">Termin-Typ</th>
<th className="entity-col-status" data-i18n="deadlines.col.status">Status</th>
</tr>
</thead>
<tbody id="events-body" />
</table>
</div>
<div className="entity-empty" id="events-empty" style="display:none">
<h2 data-i18n="events.empty.title">Keine Eintr&auml;ge vorhanden</h2>
<p data-i18n="events.empty.hint">
Sobald Fristen oder Termine angelegt werden, erscheinen sie hier.
</p>
<a href="/deadlines/new" className="btn-primary btn-cta-lime" id="events-empty-cta-deadline" data-i18n="deadlines.list.new">Neue Frist</a>
<a href="/appointments/new" className="btn-primary btn-cta-lime" id="events-empty-cta-appointment" data-i18n="appointments.list.new">Neuer Termin</a>
</div>
<div className="entity-empty entity-empty-filtered" id="events-empty-filtered" style="display:none">
<p data-i18n="events.empty.filtered">Keine Eintr&auml;ge mit diesen Filtern.</p>
</div>
</div>
</section>
</main>
<Footer />
<script src="/assets/events.js"></script>
</body>
</html>
);
}

View File

@@ -618,6 +618,7 @@ export type I18nKey =
| "deadlines.filter.all"
| "deadlines.filter.completed"
| "deadlines.filter.event_type"
| "deadlines.filter.later"
| "deadlines.filter.nextweek"
| "deadlines.filter.overdue"
| "deadlines.filter.pending"
@@ -678,6 +679,7 @@ export type I18nKey =
| "deadlines.step3"
| "deadlines.subtitle"
| "deadlines.summary.completed"
| "deadlines.summary.later"
| "deadlines.summary.nextweek"
| "deadlines.summary.overdue"
| "deadlines.summary.thisweek"
@@ -825,6 +827,19 @@ export type I18nKey =
| "event_types.picker.no_match"
| "event_types.picker.remove"
| "event_types.picker.search"
| "events.col.appointment_type"
| "events.col.date"
| "events.col.location"
| "events.empty.filtered"
| "events.empty.hint"
| "events.empty.title"
| "events.row.type.appointment"
| "events.row.type.deadline"
| "events.summary.later"
| "events.toggle.all"
| "events.toggle.appointment"
| "events.toggle.deadline"
| "events.unavailable"
| "footer.text"
| "gebuehren.col.courtfee"
| "gebuehren.col.fee"

View File

@@ -5145,6 +5145,7 @@ input[type="range"]::-moz-range-thumb {
--bucket-today: #c2410c;
--bucket-week: #a16207;
--bucket-next-week: #16a34a;
--bucket-later: #1d4ed8;
--bucket-done: #6b7280;
}
:root[data-theme="dark"] {
@@ -5152,6 +5153,7 @@ input[type="range"]::-moz-range-thumb {
--bucket-today: #fb923c;
--bucket-week: #fbbf24;
--bucket-next-week: #4ade80;
--bucket-later: #60a5fa;
--bucket-done: #9ca3af;
}
@@ -5217,6 +5219,7 @@ input[type="range"]::-moz-range-thumb {
.frist-card-week { border-left-color: var(--bucket-week); color: var(--bucket-week); }
.frist-card-next-week { border-left-color: var(--bucket-next-week); color: var(--bucket-next-week); }
.frist-card-upcoming { border-left-color: var(--bucket-next-week); color: var(--bucket-next-week); }
.frist-card-later { border-left-color: var(--bucket-later); color: var(--bucket-later); }
.frist-card-completed { border-left-color: var(--bucket-done); color: var(--bucket-done); }
.fristen-table .frist-col-check {
@@ -8656,6 +8659,41 @@ dialog.quick-add-sheet::backdrop {
/* Hide-on-uniform: when no row has any event_type, hide the column. */
.entity-table--hide-event-type .entity-col-event-type { display: none; }
/* t-paliad-110 — column hiders for the unified EventsPage. The shared
table renders the union of deadline + appointment columns; each
`events-table--hide-*` modifier collapses one column when no visible
row supplies the matching field, mirroring the t-073 pattern. */
.events-table--hide-row-type .events-col-row-type { display: none; }
.events-table--hide-rule .events-col-rule { display: none; }
.events-table--hide-location .events-col-location { display: none; }
.events-table--hide-appointment-type .events-col-appointment-type { display: none; }
/* Type chip rendered in the leftmost data column when the table mixes
deadlines + appointments — at-a-glance type signal that survives
monochrome printing. */
.events-row-type-chip {
display: inline-block;
padding: 0.05rem 0.45rem;
border-radius: 999px;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
border: 1px solid var(--color-border);
background: var(--color-surface-muted);
color: var(--color-text-muted);
white-space: nowrap;
}
.events-row-type-frist { color: var(--bucket-today); border-color: var(--bucket-today); }
.events-row-type-termin { color: var(--bucket-next-week); border-color: var(--bucket-next-week); }
/* Type chip toggle row (Fristen / Termine / Beides) above the bucket
cards — reuses the agenda-chip visuals so users get muscle-memory
parity with /agenda. */
.event-type-chip-row {
margin: 0.5rem 0 1rem;
}
/* Add-modal styling — extends the existing .modal-overlay/.modal pattern. */
.event-type-add-modal {
width: 28rem;

View File

@@ -84,6 +84,7 @@ const (
DeadlineFilterToday DeadlineStatusFilter = "today"
DeadlineFilterThisWeek DeadlineStatusFilter = "this_week"
DeadlineFilterNextWeek DeadlineStatusFilter = "next_week"
DeadlineFilterLater DeadlineStatusFilter = "later"
DeadlineFilterUpcoming DeadlineStatusFilter = "upcoming"
DeadlineFilterCompleted DeadlineStatusFilter = "completed"
DeadlineFilterPending DeadlineStatusFilter = "pending"
@@ -143,6 +144,12 @@ func (s *DeadlineService) ListVisibleForUser(ctx context.Context, userID uuid.UU
conds = append(conds, `f.status = 'pending' AND f.due_date >= :next_monday AND f.due_date < :week_after`)
args["next_monday"] = b.nextMonday
args["week_after"] = b.weekAfter
case DeadlineFilterLater:
// "Später" — pending deadlines past next Sunday (t-paliad-110).
// The card on /deadlines clicks through to this filter; the dropdown
// also exposes it as the always-future-of-bucketed-window option.
conds = append(conds, `f.status = 'pending' AND f.due_date >= :week_after`)
args["week_after"] = b.weekAfter
case DeadlineFilterUpcoming:
// Back-compat: "upcoming" used to mean "anything pending past this week".
// Kept so legacy bookmarks / third-party links don't 4xx; the new UI

View File

@@ -126,18 +126,33 @@ func (s *EventService) ListVisibleForUser(ctx context.Context, userID uuid.UUID,
}
if wantAppointments {
af := AppointmentListFilter{
ProjectID: filter.ProjectID,
From: filter.From,
To: filter.To,
Type: filter.AppointmentType,
}
rows, err := s.appointments.ListVisibleForUser(ctx, userID, af)
if err != nil {
return nil, err
}
for _, r := range rows {
out = append(out, projectAppointment(r))
// Status is a deadline-only filter, but it doubles as the bucket
// click-through target on the unified events page. So when the
// caller set a bucket-style status (today / this_week / next_week
// / later), apply the matching date window to the appointment side
// too — clicking "Heute" then shows today's deadlines AND today's
// appointments. For overdue/completed (no appointment analogue),
// skip the appointment query entirely.
if shouldExcludeAppointmentsForStatus(filter.Status) {
// no-op
} else {
af := AppointmentListFilter{
ProjectID: filter.ProjectID,
From: filter.From,
To: filter.To,
Type: filter.AppointmentType,
}
bounds := computeDeadlineBucketBounds(time.Now().UTC())
from, to := bucketAppointmentWindow(filter.Status, bounds)
af.From = pickLater(af.From, from)
af.To = pickEarlier(af.To, to)
rows, err := s.appointments.ListVisibleForUser(ctx, userID, af)
if err != nil {
return nil, err
}
for _, r := range rows {
out = append(out, projectAppointment(r))
}
}
}
@@ -209,6 +224,70 @@ func projectAppointment(a models.AppointmentWithProject) EventListItem {
}
}
// shouldExcludeAppointmentsForStatus returns true for deadline-status
// values that have no appointment analogue (overdue, completed). When
// the user sets one of those, the appointment rail collapses to empty.
func shouldExcludeAppointmentsForStatus(status DeadlineStatusFilter) bool {
switch status {
case DeadlineFilterOverdue, DeadlineFilterCompleted:
return true
}
return false
}
// bucketAppointmentWindow returns the [from, to) date window that
// matches a bucket-style deadline status — used to filter the
// appointment side when the user clicks a card on the unified events
// page. Returns (nil, nil) for non-bucket statuses (pending / all /
// upcoming / "" / overdue / completed — those are handled separately).
func bucketAppointmentWindow(status DeadlineStatusFilter, b deadlineBucketBounds) (*time.Time, *time.Time) {
switch status {
case DeadlineFilterToday:
t := b.tomorrow
return &b.today, &t
case DeadlineFilterThisWeek:
t := b.nextMonday
return &b.tomorrow, &t
case DeadlineFilterNextWeek:
t := b.weekAfter
return &b.nextMonday, &t
case DeadlineFilterLater:
return &b.weekAfter, nil
}
return nil, nil
}
// pickLater returns whichever of the two times is later (nil propagates
// the non-nil value; both nil → nil). Used to intersect bucket-derived
// windows with caller-supplied from filters.
func pickLater(a, b *time.Time) *time.Time {
if a == nil {
return b
}
if b == nil {
return a
}
if a.After(*b) {
return a
}
return b
}
// pickEarlier returns whichever of the two times is earlier (nil
// propagates the non-nil value; both nil → nil).
func pickEarlier(a, b *time.Time) *time.Time {
if a == nil {
return b
}
if b == nil {
return a
}
if a.Before(*b) {
return a
}
return b
}
// inDateWindow returns true when due is inside [from, to]. Both ends are
// optional. The deadline ListFilter has no date-range support today, so we
// post-filter in memory — fine because the per-user deadline set is small.