# 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=`. 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).