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:
@@ -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());
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">← Zurück zur Übersicht</a>
|
||||
<a href="/events?type=appointment" className="back-link" data-i18n="appointments.detail.back">← Zurück zur Übersicht</a>
|
||||
|
||||
<div id="appointment-loading" className="entity-loading" data-i18n="appointments.detail.loading">Lädt…</div>
|
||||
<div id="appointment-not-found" style="display:none" className="entity-empty">
|
||||
|
||||
@@ -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">← Zurück zur Übersicht</a>
|
||||
<a href="/events?type=appointment" className="back-link" id="appointment-new-back" data-i18n="appointments.neu.back">← Zurück zur Übersicht</a>
|
||||
<h1 data-i18n="appointments.neu.heading">Neuer Termin</h1>
|
||||
<p className="tool-subtitle" data-i18n="appointments.neu.subtitle">
|
||||
Persö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>
|
||||
|
||||
@@ -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")!;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">← Zurück zur Fristenübersicht</a>
|
||||
<a href="/events?type=deadline" className="back-link" data-i18n="deadlines.detail.back">← Zurück zur Fristenübersicht</a>
|
||||
|
||||
<div id="deadline-loading" className="entity-loading">
|
||||
<p data-i18n="deadlines.detail.loading">Lädt…</p>
|
||||
|
||||
@@ -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">← Zurück zur Übersicht</a>
|
||||
<a href="/events?type=deadline" className="back-link" id="deadline-new-back" data-i18n="deadlines.neu.back">← Zurück zur Ü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ür alle Personen, die die Akte sehen kö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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
12
internal/handlers/events_pages.go
Normal file
12
internal/handlers/events_pages.go
Normal 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")
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user