feat(t-paliad-115): canonicalise list URL on /events; redirect old paths

PR-2 of t-paliad-115. The unified Fristen + Termine surface now lives at
/events. Old /deadlines and /appointments list URLs 301-redirect to
/events?type=deadline and /events?type=appointment so existing bookmarks
still land on the right view. Detail pages (/deadlines/{id},
/appointments/{id}) stay type-specific.

Backend (Go).
- New `GET /events` route → handleEventsListPage serves dist/events.html.
- `GET /deadlines` → handleDeadlinesListRedirect (301 → /events?type=deadline).
- `GET /appointments` → handleAppointmentsListRedirect (301 → /events?type=appointment).
- /deadlines/new, /deadlines/calendar, /deadlines/{id}, /appointments/new,
  /appointments/calendar, /appointments/{id} unchanged — type-specific
  detail / form / legacy-calendar surfaces stay where they are.

Frontend.
- build.ts now emits ONE events.html (not events-deadlines /
  events-appointments) with defaultType="all" baked in. The page reads
  ?type=… and ?view=… on hydration, so /events?type=deadline lands on
  the Fristen-only Cards view, /events?view=calendar opens the calendar,
  and bare /events shows the Beides view.
- Sidebar Fristen / Termine entries point at /events?type=deadline and
  /events?type=appointment. The SSR active-state matches exactly via
  href === currentPath, so detail/new/calendar pages that pass
  currentPath="/events?type=deadline" (resp. appointment) still
  highlight the right entry.
- events.ts hydration adds applySidebarTypeHighlight(): on bare /events
  the sidebar SSRs with neither entry lit, and we re-highlight the
  matching entry whenever the in-page chip toggle changes the active
  type. Sidebar stays in sync without a server round-trip.
- Updated every list-target reference: palette-actions.ts (Cmd-K
  navigation), deadlines-detail.ts + appointments-detail.ts (post-delete
  redirect), and the back-link / cancel hrefs in the *-new + *-detail +
  *-calendar TSX templates. Detail-page Sidebar/BottomNav currentPath
  also moved from "/deadlines" → "/events?type=deadline" so the new
  highlight contract holds end-to-end.

Out of scope (per task brief).
- A third "Ereignisse / Alle Events" sidebar entry pointing at /events
  bare. m's call: keep two entries; defer until signal.
- Removing /deadlines/calendar + /appointments/calendar standalone
  pages. The new /events?view=calendar covers the same need but the
  legacy URLs stay live for one cycle.

Build clean: `cd frontend && bun run build` + `go build/vet/test ./...`.
This commit is contained in:
m
2026-05-04 14:40:53 +02:00
parent 1dad1c7371
commit 56522adffe
17 changed files with 94 additions and 61 deletions

View File

@@ -346,12 +346,11 @@ async function build() {
await Bun.write(join(DIST, "projects.html"), renderProjects());
await Bun.write(join(DIST, "projects-new.html"), renderProjectsNew());
await Bun.write(join(DIST, "projects-detail.html"), renderProjectsDetail());
// 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
// defaultType is baked into each artefact via inline hydration.
await Bun.write(join(DIST, "events-deadlines.html"), renderEvents("/deadlines"));
await Bun.write(join(DIST, "events-appointments.html"), renderEvents("/appointments"));
// t-paliad-115 — shared EventsPage at the canonical /events URL.
// One HTML output; defaultType="all" baked in. Sidebar Fristen /
// Termine entries point at /events?type=… and events.ts re-highlights
// the matching one at hydration time based on the active type.
await Bun.write(join(DIST, "events.html"), renderEvents());
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

@@ -18,8 +18,8 @@ export function renderAppointmentsCalendar(): string {
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/appointments" />
<BottomNav currentPath="/appointments" />
<Sidebar currentPath="/events?type=appointment" />
<BottomNav currentPath="/events?type=appointment" />
<main>
<section className="tool-page">
@@ -33,7 +33,7 @@ export function renderAppointmentsCalendar(): string {
</p>
</div>
<div className="fristen-header-actions">
<a href="/appointments" className="btn-secondary" data-i18n="appointments.kalender.list">Listenansicht</a>
<a href="/events?type=appointment" className="btn-secondary" data-i18n="appointments.kalender.list">Listenansicht</a>
<a href="/appointments/new" className="btn-primary btn-cta-lime" data-i18n="appointments.list.new">Neuer Termin</a>
</div>
</div>

View File

@@ -18,13 +18,13 @@ export function renderAppointmentsDetail(): string {
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/appointments" />
<BottomNav currentPath="/appointments" />
<Sidebar currentPath="/events?type=appointment" />
<BottomNav currentPath="/events?type=appointment" />
<main>
<section className="tool-page">
<div className="container container-narrow">
<a href="/appointments" className="back-link" data-i18n="appointments.detail.back">&larr; Zur&uuml;ck zur &Uuml;bersicht</a>
<a href="/events?type=appointment" className="back-link" data-i18n="appointments.detail.back">&larr; Zur&uuml;ck zur &Uuml;bersicht</a>
<div id="appointment-loading" className="entity-loading" data-i18n="appointments.detail.loading">L&auml;dt&hellip;</div>
<div id="appointment-not-found" style="display:none" className="entity-empty">

View File

@@ -18,14 +18,14 @@ export function renderAppointmentsNew(): string {
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/appointments/new" />
<BottomNav currentPath="/appointments/new" />
<Sidebar currentPath="/events?type=appointment" />
<BottomNav currentPath="/events?type=appointment" />
<main>
<section className="tool-page">
<div className="container container-narrow">
<div className="tool-header">
<a href="/appointments" className="back-link" id="appointment-new-back" data-i18n="appointments.neu.back">&larr; Zur&uuml;ck zur &Uuml;bersicht</a>
<a href="/events?type=appointment" className="back-link" id="appointment-new-back" data-i18n="appointments.neu.back">&larr; Zur&uuml;ck zur &Uuml;bersicht</a>
<h1 data-i18n="appointments.neu.heading">Neuer Termin</h1>
<p className="tool-subtitle" data-i18n="appointments.neu.subtitle">
Pers&ouml;nlich oder einer Akte zugeordnet. Bei aktiver CalDAV-Synchronisation erscheint der Termin auch im externen Kalender.
@@ -87,7 +87,7 @@ export function renderAppointmentsNew(): string {
<p className="form-msg" id="appointment-new-msg" />
<div className="form-actions">
<a href="/appointments" id="appointment-new-cancel" className="btn-cancel" data-i18n="appointments.neu.cancel">Abbrechen</a>
<a href="/events?type=appointment" id="appointment-new-cancel" className="btn-cancel" data-i18n="appointments.neu.cancel">Abbrechen</a>
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="appointments.neu.submit">Termin anlegen</button>
</div>
</form>

View File

@@ -170,7 +170,7 @@ async function deleteAppointment() {
try {
const resp = await fetch(`/api/appointments/${appointment.id}`, { method: "DELETE" });
if (resp.ok || resp.status === 204) {
window.location.href = "/appointments";
window.location.href = "/events?type=appointment";
} else {
const data = await resp.json().catch(() => ({}) as { error?: string });
const msg = document.getElementById("appointment-edit-msg")!;

View File

@@ -379,7 +379,7 @@ function initDelete() {
confirmBtn.disabled = true;
const resp = await fetch(`/api/deadlines/${deadline.id}`, { method: "DELETE" });
if (resp.ok) {
const target = project ? `/projects/${project.id}/deadlines` : "/deadlines";
const target = project ? `/projects/${project.id}/deadlines` : "/events?type=deadline";
window.location.href = target;
} else {
confirmBtn.disabled = false;

View File

@@ -111,9 +111,7 @@ function defaultType(): EventTypeChoice {
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";
return "all";
}
function esc(s: string): string {
@@ -741,6 +739,26 @@ function applyTypeVisibility() {
// Refresh overdue card visibility on type change.
applyOverdueState(parseInt(document.getElementById("events-sum-overdue")?.textContent ?? "0", 10));
// Sidebar highlight (t-paliad-115). The bare /events HTML SSRs with
// currentPath="/events" so neither Fristen nor Termine sidebar entries
// match — we re-highlight at hydration based on the active type so the
// user always sees the correct nav entry lit. Same logic for BottomNav,
// which today doesn't carry these entries but stays consistent if it
// ever does.
applySidebarTypeHighlight();
}
function applySidebarTypeHighlight() {
const items = document.querySelectorAll<HTMLAnchorElement>(".sidebar-item");
for (const a of items) {
const href = a.getAttribute("href") ?? "";
if (href === "/events?type=deadline") {
a.classList.toggle("active", currentType === "deadline");
} else if (href === "/events?type=appointment") {
a.classList.toggle("active", currentType === "appointment");
}
}
}
function toggleDisplay(id: string, show: boolean, displayWhenShown = "block") {

View File

@@ -124,8 +124,8 @@ export function runAction(id: string): void {
switch (id) {
case "nav.dashboard": window.location.href = "/dashboard"; return;
case "nav.projects": window.location.href = "/projects"; return;
case "nav.deadlines": window.location.href = "/deadlines"; return;
case "nav.appointments": window.location.href = "/appointments"; return;
case "nav.deadlines": window.location.href = "/events?type=deadline"; return;
case "nav.appointments": window.location.href = "/events?type=appointment"; return;
case "nav.agenda": window.location.href = "/agenda"; return;
case "nav.team": window.location.href = "/team"; return;
case "nav.glossary": window.location.href = "/glossary"; return;

View File

@@ -117,8 +117,8 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
{group("nav.group.arbeit", "Arbeit",
navItem("/projects", ICON_FOLDER, "nav.projekte", "Projekte", currentPath) +
navItem("/deadlines", ICON_CLOCK, "nav.fristen", "Fristen", currentPath) +
navItem("/appointments", ICON_CALENDAR, "nav.termine", "Termine", currentPath),
navItem("/events?type=deadline", ICON_CLOCK, "nav.fristen", "Fristen", currentPath) +
navItem("/events?type=appointment", ICON_CALENDAR, "nav.termine", "Termine", currentPath),
)}
{group("nav.group.werkzeuge", "Werkzeuge",

View File

@@ -18,8 +18,8 @@ export function renderDeadlinesCalendar(): string {
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/deadlines" />
<BottomNav currentPath="/deadlines" />
<Sidebar currentPath="/events?type=deadline" />
<BottomNav currentPath="/events?type=deadline" />
<main>
<section className="tool-page">
@@ -33,7 +33,7 @@ export function renderDeadlinesCalendar(): string {
</p>
</div>
<div className="fristen-header-actions">
<a href="/deadlines" className="btn-secondary" data-i18n="deadlines.kalender.list">Listenansicht</a>
<a href="/events?type=deadline" className="btn-secondary" data-i18n="deadlines.kalender.list">Listenansicht</a>
<a href="/deadlines/new" className="btn-primary btn-cta-lime" data-i18n="deadlines.list.new">Neue Frist</a>
</div>
</div>

View File

@@ -18,13 +18,13 @@ export function renderDeadlinesDetail(): string {
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/deadlines" />
<BottomNav currentPath="/deadlines" />
<Sidebar currentPath="/events?type=deadline" />
<BottomNav currentPath="/events?type=deadline" />
<main>
<section className="tool-page">
<div className="container">
<a href="/deadlines" className="back-link" data-i18n="deadlines.detail.back">&larr; Zur&uuml;ck zur Fristen&uuml;bersicht</a>
<a href="/events?type=deadline" className="back-link" data-i18n="deadlines.detail.back">&larr; Zur&uuml;ck zur Fristen&uuml;bersicht</a>
<div id="deadline-loading" className="entity-loading">
<p data-i18n="deadlines.detail.loading">L&auml;dt&hellip;</p>

View File

@@ -18,14 +18,14 @@ export function renderDeadlinesNew(): string {
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/deadlines/new" />
<BottomNav currentPath="/deadlines/new" />
<Sidebar currentPath="/events?type=deadline" />
<BottomNav currentPath="/events?type=deadline" />
<main>
<section className="tool-page">
<div className="container container-narrow">
<div className="tool-header">
<a href="/deadlines" className="back-link" id="deadline-new-back" data-i18n="deadlines.neu.back">&larr; Zur&uuml;ck zur &Uuml;bersicht</a>
<a href="/events?type=deadline" className="back-link" id="deadline-new-back" data-i18n="deadlines.neu.back">&larr; Zur&uuml;ck zur &Uuml;bersicht</a>
<h1 data-i18n="deadlines.neu.heading">Neue Frist anlegen</h1>
<p className="tool-subtitle" data-i18n="deadlines.neu.subtitle">
Eine persistente Frist an einer Akte. Sichtbar f&uuml;r alle Personen, die die Akte sehen k&ouml;nnen.
@@ -81,7 +81,7 @@ export function renderDeadlinesNew(): string {
<p className="form-msg" id="deadline-new-msg" />
<div className="form-actions">
<a href="/deadlines" id="deadline-new-cancel" className="btn-cancel" data-i18n="deadlines.neu.cancel">Abbrechen</a>
<a href="/events?type=deadline" id="deadline-new-cancel" className="btn-cancel" data-i18n="deadlines.neu.cancel">Abbrechen</a>
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="deadlines.neu.submit">Frist anlegen</button>
</div>
</form>

View File

@@ -4,19 +4,16 @@ 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 default type ("deadline" / "appointment")
// determines which Sidebar entry is highlighted AND is inlined into the
// head as `window.__PALIAD_EVENTS__` so client/events.ts paints the right
// heading, bucket cards, and filter row on first frame — no waterfall.
//
// We render two separate HTML outputs (one per default type) at build
// time and the Go handler ServeFiles the matching one — that keeps the
// hydration trivial (just a static literal) instead of needing a
// dashboard-style placeholder swap on every request.
export function renderEvents(currentPath: "/deadlines" | "/appointments"): string {
const defaultType = currentPath === "/appointments" ? "appointment" : "deadline";
const hydration = `window.__PALIAD_EVENTS__=${JSON.stringify({ defaultType })};`;
// EventsPage is the shared shell that lives at /events (t-paliad-115).
// One HTML output: `currentPath` is "/events" so the sidebar Fristen /
// Termine entries (which point at /events?type=…) don't SSR-highlight on
// the bare URL — events.ts re-highlights at hydration based on the
// active type. The defaultType is "all" so a visit to bare /events
// shows the unified Beides view; visits via sidebar carry ?type=… in
// the URL and override.
export function renderEvents(): string {
const hydration = `window.__PALIAD_EVENTS__=${JSON.stringify({ defaultType: "all" })};`;
const currentPath = "/events";
return "<!DOCTYPE html>" + (
<html lang="de">
<head>

View File

@@ -7,10 +7,13 @@ import "net/http"
// client TS bundles call /api/appointments* to populate the DOM and read
// id/project_id from window.location.
// /appointments now renders the unified EventsPage shell (t-paliad-110)
// with defaultType="appointment" baked into the build artefact.
func handleAppointmentsListPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/events-appointments.html")
// handleAppointmentsListRedirect 301-redirects the legacy /appointments
// list URL to the canonical /events?type=appointment (t-paliad-115).
// Detail page /appointments/{id} stays type-specific. Drop this redirect
// once we're confident no caches / bookmarks / external links still hit
// the old URL.
func handleAppointmentsListRedirect(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/events?type=appointment", http.StatusMovedPermanently)
}
func handleAppointmentsNewPage(w http.ResponseWriter, r *http.Request) {

View File

@@ -7,12 +7,12 @@ import "net/http"
// client TS bundles call /api/deadlines* to populate the DOM and read
// id/project_id from window.location.
// /deadlines now renders the unified EventsPage shell (t-paliad-110).
// The build emits two HTML outputs from one renderEvents() — this one
// hydrates window.__PALIAD_EVENTS__ with defaultType="deadline" so
// client/events.ts paints the deadline view first frame.
func handleDeadlinesListPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/events-deadlines.html")
// handleDeadlinesListRedirect 301-redirects the legacy /deadlines list URL
// to the canonical /events?type=deadline (t-paliad-115). Detail page
// /deadlines/{id} stays type-specific. Drop this redirect once we're
// confident no caches / bookmarks / external links still hit the old URL.
func handleDeadlinesListRedirect(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/events?type=deadline", http.StatusMovedPermanently)
}
func handleDeadlinesNewPage(w http.ResponseWriter, r *http.Request) {

View File

@@ -0,0 +1,12 @@
package handlers
import "net/http"
// handleEventsListPage serves the unified EventsPage shell at the canonical
// /events URL (t-paliad-115). The page hydrates with defaultType="all"; the
// client reads ?type=… and ?view=… from the URL to drive filter + view
// selection on first frame, so /events?type=deadline lands directly on the
// Fristen-only Cards view, /events?view=calendar opens the calendar, etc.
func handleEventsListPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/events.html")
}

View File

@@ -297,14 +297,18 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("GET /projects/{id}/deadlines/new", gateOnboarded(handleDeadlinesNewPage))
protected.HandleFunc("GET /projects/{id}/appointments/new", gateOnboarded(handleAppointmentsNewPage))
// Phase E — Deadlines (persistent deadline) pages
protected.HandleFunc("GET /deadlines", gateOnboarded(handleDeadlinesListPage))
// t-paliad-115 — canonical /events URL for the unified Fristen + Termine
// surface. The page reads ?type=… for prefilled type filter and ?view=…
// for prefilled view (cards / list / calendar). The legacy /deadlines and
// /appointments list URLs 301-redirect to the typed /events variants.
protected.HandleFunc("GET /events", gateOnboarded(handleEventsListPage))
protected.HandleFunc("GET /deadlines", gateOnboarded(handleDeadlinesListRedirect))
protected.HandleFunc("GET /deadlines/new", gateOnboarded(handleDeadlinesNewPage))
protected.HandleFunc("GET /deadlines/calendar", gateOnboarded(handleDeadlinesCalendarPage))
protected.HandleFunc("GET /deadlines/{id}", gateOnboarded(handleDeadlinesDetailPage))
// Phase F — Appointments pages
protected.HandleFunc("GET /appointments", gateOnboarded(handleAppointmentsListPage))
protected.HandleFunc("GET /appointments", gateOnboarded(handleAppointmentsListRedirect))
protected.HandleFunc("GET /appointments/new", gateOnboarded(handleAppointmentsNewPage))
protected.HandleFunc("GET /appointments/calendar", gateOnboarded(handleAppointmentsCalendarPage))
protected.HandleFunc("GET /appointments/{id}", gateOnboarded(handleAppointmentsDetailPage))