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.
198 lines
7.8 KiB
TypeScript
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();
|
|
}
|