Files
paliad/frontend/src/client/paliadin-context.ts
mAi 0f98d2cd39 refactor(calendar): t-paliad-224 — retire standalone calendar pages + prune dead code
Delete the four orphan files behind /deadlines/calendar +
/appointments/calendar:
- frontend/src/{deadlines,appointments}-calendar.tsx
- frontend/src/client/{deadlines,appointments}-calendar.ts
The standalone pages were unreachable from the UI since t-paliad-110
(Sidebar/BottomNav point at /events?type=…); their only role was as
bookmark targets.

Handlers in internal/handlers/{deadlines,appointments}_pages.go now
301-redirect to /events?type=…&view=calendar so bookmarks still
work. Route registrations in handlers.go remain unchanged — the
gate + redirect pair gives us the same URL surface with one canonical
renderer.

build.ts: drop the renderDeadlinesCalendar / renderAppointmentsCalendar
imports + entry-point bundle paths + dist HTML writes.

frontend/src/client/paliadin-context.ts: drop the two route-key
matches for the standalone URLs (the client never sees those
pathnames any more — 301 fires server-side).

Dead CSS pruned in frontend/src/styles/global.css (~180 lines):
- .frist-calendar, .frist-cal-{controls,month-label,grid,cell,…}
  block (lines 7464-7613 pre-refactor)
- @media (max-width: 700px) { .frist-cal-cell { min-height: 64px; } }
- .termin-cal-legend{,-item}
- .frist-cal-popup-time
- .frist-cal-dot.events-cal-dot-appointment

All verified by grep across frontend/ + internal/ to have no
non-calendar consumers before deletion.

Dead i18n keys removed (DE + EN + i18n-keys.ts union type):
- deadlines.kalender.{title,heading,subtitle,list,today,empty}
- appointments.kalender.{title,heading,subtitle,list,empty}
- deadlines.list.calendar, appointments.list.calendar (button labels
  on the deleted standalone routes)
- events.calendar.empty (replaced by cal.day.no_entries inside
  mountCalendar's day view)

Per head decisions §11 Q1 + Q8 (drop standalone pages as 301s; drop
dead i18n now).

Tests: go build ./... clean; go test ./internal/... 9 packages pass;
cd frontend && bun run build clean (2535 i18n keys); bun test
frontend/src/client/{calendar,views}/ all 73/73 pass.
2026-05-20 15:23:28 +02:00

198 lines
7.8 KiB
TypeScript

// paliadin-context.ts — structured page-context payload builder for the
// Paliadin inline widget (t-paliad-161).
//
// The standalone /paliadin page submits turns with only `page_origin`
// (single string, the URL pathname). The inline widget submits a richer
// payload: route_name + primary_entity_type + primary_entity_id + the
// user's text selection + UI hints. The Go backend persists this jsonb
// in paliad.paliadin_turns.context (migration 070) AND prepends a
// flattened `[ctx …]` block to the tmux envelope so SKILL.md can branch
// on it before answering.
//
// Design: docs/design-paliadin-inline-2026-05-08.md §4.
export interface PaliadinContext {
route_name: string;
page_origin: string;
primary_entity_type?: "project" | "deadline" | "appointment";
primary_entity_id?: string;
user_selection_text?: string;
view_mode?: "list" | "cards" | "calendar" | "tree";
filter_summary?: string;
}
const SELECTION_MAX = 1000;
// UUID match — relaxed: any 8-4-4-4-12 hex pattern. Catches /projects/<id>
// and /deadlines/<id> regardless of trailing path segments.
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/**
* Compute the Paliadin context for the current page. Reads
* window.location + window.getSelection() at call time, so callers
* should invoke this immediately before sending a turn — not at widget
* boot — to capture the user's selection in the moment they typed.
*
* Returns null when the visibility predicate fails (e.g. on /paliadin,
* /login, /onboarding) — callers SHOULD short-circuit on null instead
* of sending an empty payload.
*/
export function computePaliadinContext(): PaliadinContext | null {
const pathname = window.location.pathname || "";
if (!shouldSendContext(pathname)) {
return null;
}
const search = window.location.search || "";
const ctx: PaliadinContext = {
route_name: routeNameFor(pathname),
page_origin: pathname + search,
};
const entity = extractPrimaryEntity(pathname);
if (entity) {
ctx.primary_entity_type = entity.type;
ctx.primary_entity_id = entity.id;
}
const selection = readSelection();
if (selection) {
ctx.user_selection_text = selection;
}
const view = readViewMode();
if (view) {
ctx.view_mode = view;
}
const filter = readFilterSummary();
if (filter) {
ctx.filter_summary = filter;
}
return ctx;
}
/**
* The widget hides itself on routes where Paliadin is either redundant
* (the standalone /paliadin) or unavailable (auth flows). Mirrored here
* for the context-payload predicate so a stray send from one of those
* pages doesn't surface an empty `[ctx]` block.
*/
export function shouldSendContext(pathname: string): boolean {
if (pathname === "/paliadin" || pathname.startsWith("/paliadin/")) return false;
if (pathname === "/login" || pathname.startsWith("/login/")) return false;
if (pathname === "/onboarding") return false;
return true;
}
/**
* Map a URL pathname to a stable route key. Stable across query-string
* + ID variations so the SKILL.md / starter registry can branch on it
* without fragile URL parsing.
*/
export function routeNameFor(pathname: string): string {
// Order matters — most-specific first.
if (/^\/projects\/[^/]+$/.test(pathname)) return "projects.detail";
if (pathname === "/projects" || pathname === "/projects/") return "projects.list";
if (/^\/projects\/[^/]+\/settings/.test(pathname)) return "projects.settings";
if (/^\/deadlines\/[^/]+$/.test(pathname)) return "deadlines.detail";
if (pathname === "/deadlines/new") return "deadlines.new";
if (pathname === "/deadlines") return "deadlines.list";
if (/^\/appointments\/[^/]+$/.test(pathname)) return "appointments.detail";
if (pathname === "/appointments/new") return "appointments.new";
if (pathname === "/appointments") return "appointments.list";
// /deadlines/calendar + /appointments/calendar are 301 redirects to
// /events?type=…&view=calendar since t-paliad-224 — the client never
// sees those pathnames any more.
if (pathname === "/agenda") return "agenda";
if (pathname === "/inbox") return "inbox";
if (pathname === "/dashboard" || pathname === "/") return "dashboard";
if (pathname === "/team") return "team";
if (pathname === "/courts") return "courts";
if (pathname === "/glossary") return "glossary";
if (pathname === "/links") return "links";
if (pathname === "/downloads") return "downloads";
if (pathname === "/checklists") return "checklists";
if (pathname.startsWith("/tools/fristenrechner")) return "tools.fristenrechner";
if (pathname.startsWith("/tools/kostenrechner")) return "tools.kostenrechner";
if (pathname.startsWith("/tools/gebuehrentabellen")) return "tools.gebuehrentabellen";
if (pathname === "/events") return "events";
if (pathname.startsWith("/views/")) return "views.detail";
if (pathname === "/views") return "views.list";
if (pathname.startsWith("/admin/")) return "admin." + pathname.slice("/admin/".length).split("/")[0];
if (pathname === "/admin") return "admin";
if (pathname === "/settings") return "settings";
return "other";
}
/**
* Pull the primary entity (type + uuid) out of the URL when the route
* encodes one. Returns null on routes that have no primary entity
* (dashboard, agenda, lists, tools).
*/
export function extractPrimaryEntity(
pathname: string,
): { type: "project" | "deadline" | "appointment"; id: string } | null {
const projectMatch = pathname.match(/^\/projects\/([^/]+)(?:\/|$)/);
if (projectMatch && UUID_RE.test(projectMatch[1])) {
return { type: "project", id: projectMatch[1] };
}
const deadlineMatch = pathname.match(/^\/deadlines\/([^/]+)$/);
if (deadlineMatch && UUID_RE.test(deadlineMatch[1])) {
return { type: "deadline", id: deadlineMatch[1] };
}
const apptMatch = pathname.match(/^\/appointments\/([^/]+)$/);
if (apptMatch && UUID_RE.test(apptMatch[1])) {
return { type: "appointment", id: apptMatch[1] };
}
return null;
}
/**
* Capture the user's current text selection, capped at SELECTION_MAX.
* Returns empty string when there's no selection or when the selection
* is collapsed (caret with no range).
*
* Privacy floor (§4.3): respects the widget's "send selection" toggle,
* stored in localStorage under `paliadin:send-selection`. Default on
* (m's Q5 lock-in); flip to off → returns empty string regardless of
* what's selected.
*/
export function readSelection(): string {
if (localStorage.getItem("paliadin:send-selection") === "off") {
return "";
}
const sel = window.getSelection();
if (!sel || sel.isCollapsed) return "";
const text = sel.toString().trim();
if (!text) return "";
if (text.length > SELECTION_MAX) {
return text.slice(0, SELECTION_MAX);
}
return text;
}
/**
* Probe the page for an active "view mode" hint — set by /events,
* /projects (tree vs list), /deadlines (calendar vs list). The frontend
* stores these as `data-view-mode` attributes on a known root element
* or in localStorage; this helper centralises the lookup so future
* pages adding a new view mode don't have to teach the widget about
* themselves.
*/
export function readViewMode(): "list" | "cards" | "calendar" | "tree" | "" {
const root = document.querySelector<HTMLElement>("[data-view-mode]");
if (!root) return "";
const v = root.dataset.viewMode || "";
if (v === "list" || v === "cards" || v === "calendar" || v === "tree") return v;
return "";
}
/**
* Pull a short human-readable summary of active list filters from a
* known DOM hook. Pages that want to participate set
* `data-filter-summary="status=overdue · project=Acme"` on a root
* element. Empty = no summary.
*/
export function readFilterSummary(): string {
const root = document.querySelector<HTMLElement>("[data-filter-summary]");
if (!root) return "";
return (root.dataset.filterSummary || "").trim();
}