Rewrite the calendar frontend in Next.js

Replaces the Angular 18 app with Next.js 16 / React 19 / Tailwind 4 / shadcn,
the same stack and design system as nachklang-feedback, planned in
docs/rewrite-plan.md.

- Admin: all calendars in one mobile-friendly list (month groups, calendar
  chips, Kommend/Vergangen/Alle, search, sort, filters in the URL), an edit
  sheet for create/edit/duplicate/delete, publish confirmation, and an
  "Abonnieren" dialog with subscription links for every calendar.
- Public landing page: subscribe links from env instead of hardcoded, plus
  the next public events, server-rendered and revalidated every 5 minutes.
- All times handled explicitly as Europe/Berlin; whole-day events stored as
  00:00-23:59. Fixes carried over from the Angular app: the move dialog
  leaking a cancelled calendar change, saves that looked saved when they
  failed, in-place Date mutation, and duplicated calendar-id mappings.
- Same shared-session auth as before (proxy.ts gate, /admin/me, 401 vs 403).
- 112 Vitest tests over lib/; docs in docs/ (architecture, features,
  gotchas, API wish list, deployment).

Needs the API branch feature/calendar-ical-escaping-subscriptions deployed
first (GET /calendar/events/subscriptions).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
PaddyandClaude Opus 5.5 committed 2026-09-28 22:15:59 +02:00
1 parent f82255961b
commit 0cda40f85e
138 files changed
+15942 -24010

No files matched your search

+99
View File
@@ -0,0 +1,99 @@
# Architecture
Next.js 16 (App Router) + React 19 + TypeScript + Tailwind 4 + shadcn (preset `b7ClO5que`, Base UI
primitives) - the same stack as `nachklang-feedback`. Rewritten from Angular in September 2026; the
decisions behind it are in [rewrite-plan.md](rewrite-plan.md).
## Routes
| Path | What | Rendering |
|---|---|---|
| `/` | Public landing page: subscribe links + next public events | Server component, static, `revalidate = 300` |
| `/admin` | Event management (list + edit sheet) | Client, behind `proxy.ts` and the `/me` gate |
| `/admin/login` | Forwarder to the admin app's login | Client |
## Authentication
This app has no login and no credential of its own. The session is an httpOnly cookie
(`__Secure-nachklang.session_token` in production, `nachklang.session_token` over http in dev) that
the **API** sets on `.nachklang.art`; it is shared by calendar, feedback, tickets and admin.
1. `proxy.ts` - a convenience gate on `/admin/*`: no session cookie → `/admin/login?redirect=…`.
It only sees the cookie's presence, never its validity.
2. `app/admin/login/page.tsx` forwards to `${NEXT_PUBLIC_ADMIN_APP_URL}/login?redirect=<absolute URL>`.
3. `app/admin/layout.tsx` calls `GET /admin/me` and decides:
- **401** → login (the only status that redirects);
- **403**, or 200 without `"calendar"` in `apps` → "Kein Zugriff" (no redirect: it would loop);
- anything else (5xx, network, CORS = status 0) → "nicht erreichbar" (no redirect).
4. Every API call uses `credentials: "include"` (`lib/api.ts`). Without it the browser sends no
cookie and the API answers 401.
5. Any 401 later (session ended in another app) → toast + login with this page as return target.
`GET /admin/me` answers **200** for an account without the calendar permission; only `apps` shows
it. The layout's `apps.includes("calendar")` check is load-bearing.
## Data flow (admin)
- `components/admin/useEvents.ts` loads all five calendars in parallel from
`GET /calendar/events/{slug}/json` (there is no all-in-one endpoint). Each calendar loads and fails
on its own; a per-calendar request counter drops stale responses. Deleted events (the admin feed
returns them) are dropped by `parseApiEvent`.
- Filtering, sorting and month grouping are client-side (`lib/filter.ts`); nothing is re-fetched when
a filter changes. The view state lives in the URL (`lib/url-state.ts`, `useListState`):
`?cal=public,choir&range=past&sort=start_desc&q=probe`, defaults omitted. The default sort depends
on the range (`Vergangen` = newest first).
- Writes (`components/admin/EventSheet.tsx`):
- validate (`validateEventForm`) → focus the first invalid field, no request;
- new → `POST /calendar/events`;
- edit → `PUT /calendar/events/move/:id` if the calendar changed, then `PUT /calendar/events/:id`
only if anything else changed (`contentChanged`);
- the sheet closes only after the API answered; a failure keeps the edits and shows the reason.
- After a save the list updates at once (`eventFromPayload`), then the affected calendars are
re-fetched for server-side fields.
- **PUT replaces the whole event** and defaults missing fields (`status ?? 'PUBLIC'`,
`repeatFrequency ?? ''`). Every write sends the complete `EventPayload` - including the quick
"Veröffentlichen", which uses `payloadFromEvent`.
## Time
All wall-clock times mean **Europe/Berlin**, whatever the browser's zone (`lib/dates.ts`,
`@date-fns/tz`). Forms hold `"YYYY-MM-DD"` / `"HH:mm"` strings; the API gets UTC ISO instants.
- Whole-day events are stored as 00:00 on the first day to 23:59 on the last (inclusive), Berlin.
The API's iCal export treats the end date as inclusive and adds a day.
- Repeating events (any `repeatFrequency`) count as current and sort by their next occurrence
(`nextOccurrence`); a 29 February birthday falls on 28 February in common years.
- Vitest runs with `TZ=America/New_York` so Berlin handling can't pass by accident.
## Calendars
`lib/calendars.ts` is the single list: id, slug, German label, colours, default visibility and a
forced recurrence (Birthdays → `YEARLY`). It mirrors `calendarNames` in the API's
`events.router.ts`; a new calendar needs an entry in both.
## Public landing page
`app/page.tsx` fetches `GET /calendar/events/public/json` on the server (`lib/public-calendar.ts`,
`next: { revalidate: 300 }`), anonymously. On any failure the events section is simply left out;
the subscribe links never depend on the API. Links from the database are only rendered if they pass
`normaliseUrl` (http/https) - old rows were never validated.
Subscription URLs for the internal calendars carry the shared password and come from
`GET /calendar/events/subscriptions` (editors only), shown in `SubscriptionsDialog`.
## Configuration
| Variable | When | Purpose |
|---|---|---|
| `NEXT_PUBLIC_API_BASE_URL` | build time (inlined) | API origin for the browser and the subscribe links |
| `NEXT_PUBLIC_ADMIN_APP_URL` | build time (inlined) | Where signing in happens |
| `API_BASE_URL` | runtime, optional | Server-side fetch of the landing page's events; may be internal |
`lib/env.ts` throws on a missing `NEXT_PUBLIC_*` value, so a misconfigured build fails instead of
shipping `localhost` URLs.
## Tests
`npm test` - Vitest over the pure logic in `lib/` (dates, form rules, filtering, URL state, parsing,
API client with a stubbed `fetch`, landing page selection). Components are checked in the browser,
not unit-tested (decision Q10).