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

+33
View File
@@ -0,0 +1,33 @@
# API wish list
Changes to the API that would help this app, collected during the rewrite. None of them block it.
Done items move to the bottom.
## Open
- **List the calendars.** Ids, slugs and names are hardcoded in `lib/calendars.ts` and must be kept in
step with `calendarNames` in `events.router.ts`. An endpoint would make a new calendar a data change.
- **All calendars in one request.** The admin list makes five parallel requests on every load.
- **Restore / history.** Every save already writes a row to `event_versions`, and delete is a
`DELETED` version - so "Wiederherstellen" and a change history are possible without schema changes,
only endpoints are missing. The UI's delete dialog says it can't be undone "hier".
- **Don't return deleted events** from the admin feed (or behind a flag). They arrive as rows with
every field `null` except `status`, and the client has to know to drop them.
- **Richer recurrence.** `repeatFrequency` goes out as `RRULE:FREQ=<value>` with no interval, end
or count. With those, recurrence could become editable in the form (today only Geburtstage get
`YEARLY`, automatically).
- **A failed update should not be a 200.** `PUT /calendar/events/:id` answers 200
`{"message": "0 rows were updated"}` when nothing was written; the client treats that body as an
error. A 404/409 would say it properly.
- **Explicit time zones.** The API relies on the process running in Berlin time for the iCal output
(see the API's `docs/calendar-ical.md`). Setting `TZ` explicitly at start-up, or formatting with an
explicit zone, would remove the dependency on the server's configuration.
- **`ORGANIZER` as a cal-address** (`ORGANIZER;CN=Name:mailto:…`) instead of a bare name.
- **`DB_PORT` for every pool.** Only the admin pool reads it; calendar, feedback and tickets always
use 3306.
## Done
- iCal escaping, line folding and CRLF; `text/calendar` content type (API branch
`feature/calendar-ical-escaping-subscriptions`, 2026-09-28).
- `GET /calendar/events/subscriptions` for the "Abonnieren" dialog (same branch).
+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).
+69
View File
@@ -0,0 +1,69 @@
# Deployment and switchover
How the Next.js app replaces the Angular build on `calendar.nachklang.art`. **The deploy itself is
done by a person** (never by Claude). Steps marked *unverified* could not be checked without access
to the Plesk server.
## Order matters
1. **API first**: merge and deploy the API branch `feature/calendar-ical-escaping-subscriptions`
(rebuild + restart; no new env vars - it uses the existing `API_BASE_URL` and
`*_CREDENTIAL` values). The old Angular app keeps working against it; the new app's "Abonnieren"
dialog needs it.
2. **Then the frontend.**
## Build
Node version: `.nvmrc` says 26, copied from the API repo - *unverified* that Plesk offers it; use
the version the API runs on.
```bash
npm ci
NEXT_PUBLIC_API_BASE_URL=https://api.nachklang.art \
NEXT_PUBLIC_ADMIN_APP_URL=https://admin.nachklang.art \
npm run build
```
- Both values are **inlined at build time** - they must be set for `npm run build`, not just at
runtime. (Values from the Angular `environment.prod.ts`.)
- `npm run build` runs `postbuild`, which copies `.next/static` into `.next/standalone/.next/static`.
Without it the standalone server serves the pages without CSS and JS.
- The build fetches the public events from the API to prerender `/`. If it can't reach the API it
still succeeds.
## Run (Plesk Node.js / Passenger)
- Application startup file: `.next/standalone/server.js`.
- The server listens on `PORT` (and `HOSTNAME`). Whether Passenger sets `PORT` itself or needs it
configured is *unverified* - check how the feedback app is set up on the same server.
- Optional runtime env: `API_BASE_URL` (internal address for the landing page's server-side fetch).
- The subdomain changes from serving static files to a Node.js app. *Unverified* how Plesk handles
that switch for an existing domain (document root vs. application root).
Checked locally (2026-09-28): `PORT=4300 node .next/standalone/server.js` serves `/` with events,
CSS/JS (200), the favicon, and redirects `/admin` without a cookie to `/admin/login`.
## Things that must already be true (unchanged from the Angular app)
The origin stays `https://calendar.nachklang.art`, so nothing should need changing - inferred from
the Angular app already using the same cookie auth:
- the API's CORS list contains `https://calendar.nachklang.art` (verified in `app.factory.ts`);
- the admin app's `NEXT_PUBLIC_ALLOWED_REDIRECT_ORIGINS` contains it (otherwise signing in lands on
the admin start page instead of coming back);
- the API's better-auth trusted origins contain it (otherwise sign-out fails).
## Smoke test after the switch
1. `/` loads styled, shows "Nächste Termine", and the Apple link opens a subscription.
2. `/admin` while signed out → admin login → back to `/admin` after signing in.
3. The list shows all calendars; an edit saves; "Abonnieren" lists links.
4. An account without calendar permission sees "Kein Zugriff" (no redirect loop).
5. "Abmelden" signs out and offers to sign in again.
6. On a phone: list, "+" button, bottom sheet.
7. An existing iCal subscription still updates (the feed URL is unchanged).
## Rollback
Keep the last Angular build (`dist/nachklang-calendar`) for about a week and switch the subdomain
back to it if needed. The API change is backwards compatible with the Angular app, so it can stay.
+49
View File
@@ -0,0 +1,49 @@
# Features
What the calendar app does, as of the Next.js rewrite (September 2026).
## Public page (`/`)
- Subscribe to the public calendar: Apple Kalender (`webcal://`), Google Kalender (deep link), or
copy the iCal URL (shows the URL if the clipboard isn't available).
- "Nächste Termine": the next four public events with date, time and place; an event with a web
link opens it. Refreshed every five minutes; hidden if the API can't be reached.
- Link to the Verwaltung (`/admin`).
## Verwaltung (`/admin`)
Sign-in happens in the admin app (one account for all Nachklang apps). Accounts need the calendar
permission; without it the page says so instead of redirecting.
**List**
- All calendars in one list, grouped by month, colour-coded per calendar.
- Calendar chips toggle calendars on and off, with a count each; Geburtstage is off by default.
- Kommend / Vergangen / Alle. "Vergangen" shows the newest first; "Alle" scrolls to a "Heute" marker.
- Search over title and location.
- Sort by start or by creation date (the latter as a flat list).
- The view is in the URL, so a link reproduces it.
- Drafts show an "Entwurf" badge and a "Veröffentlichen" button, which asks for confirmation and
says who will see the event.
- Repeating events (birthdays) show a "jährlich" badge and appear at their next occurrence.
- Failed calendars are marked on their chip with a retry banner; the rest of the list stays usable.
**Edit sheet** (bottom sheet on phones, side panel on larger screens)
- Create ("Neuer Termin" in the header, "+" button on phones), edit, duplicate, delete.
- Fields: Kalender, Titel, Ganztägig, Beginn, Ende, Ort, Link, Beschreibung (multi-line), Status
(Entwurf / Veröffentlicht; "Privat" is kept but not selectable).
- Moving the start moves the end with it. Whole-day events take dates only.
- A link without `https://` is completed; anything that isn't a web address is flagged.
- Moving an event into Geburtstage makes it yearly; moving it out removes that again.
- Errors appear next to their field; a failed save keeps the edits and says why.
- Closing with unsaved changes asks first; so does leaving the page.
- Shows who created and last changed the event.
**Abonnieren** (header)
- Subscription links for every calendar, including the internal ones with their password: Apple,
Google, or copy. Chor and Geburtstage share a password.
## Not (yet) possible
- Restoring deleted events and editing recurrence need API work first - see
[api-wishlist.md](api-wishlist.md).
- A month-grid view was deferred (decision Q6); the agenda list came first.
+71
View File
@@ -0,0 +1,71 @@
# Gotchas
Things that turned out differently than expected, so the same wrong assumption isn't made twice.
Collected during the rewrite (September 2026); the progress log in
[rewrite-plan.md](rewrite-plan.md) has the context for each.
## The API
- **PUT replaces the whole event.** Missing fields fall back to `status: 'PUBLIC'` and
`repeatFrequency: ''`. Always send the full `EventPayload`, even to change one field.
- **"0 rows were updated" comes back as HTTP 200.** `api.updateEvent` turns that body into an error.
- **The API returns raw DB values.** `wholeDay` is `0`/`1` (observed), and a deleted event's latest
version is a row with every field `null` except `status: 'DELETED'`. `parseApiEvent` handles both.
- **`GET /admin/me` answers 200 for accounts without the calendar permission.** Only `apps` says so.
- **The admin feed ignores `includes_calendars`**; the public/iCal feeds follow it. So an event can
appear in a calendar's subscription although it belongs to another calendar.
- **`PRIVATE` is in the status enum but nothing uses it.** The app shows it read-only and keeps it.
- **The iCal times depend on the API process's time zone** (formatted with local `getHours()`,
labelled Europe/Berlin). Inferred from the code; not checked on the server.
- **Only the admin DB pool reads `DB_PORT`.** A local calendar test database has to listen on 3306.
## Time
- **Historical DST.** Until 1995 German summer time ended in September, so `1994-10-02T22:00Z` is
23:00 on 2 October in Berlin, not midnight on the 3rd. The date code gets it right; hand-written
test data easily doesn't.
- **DST edges in forms.** A nonexistent spring time (02:30) becomes 03:30 CEST; an ambiguous autumn
time resolves to the second occurrence (CET). Pinned in `lib/dates.test.ts`.
## Next.js / hosting
- **Standalone output doesn't serve `.next/static` by itself.** Without the copy, CSS and JS are
404s. The `postbuild` script copies it into `.next/standalone/.next/static`; there is no `public/`
folder to copy (the favicon lives in `app/`). Verified by running `.next/standalone/server.js`.
- **`NEXT_PUBLIC_*` values are inlined at build time.** They must be set when `npm run build` runs,
not only when the server starts; `lib/env.ts` fails the build if they're missing.
- **The build prerenders `/` by calling the API.** If the build machine can't reach it, the build
still succeeds and the events section appears after the first revalidation (≤ 5 min).
- **With `revalidate`, a short API outage doesn't blank the landing page's events** in a running
server - Next keeps serving the cached fetch.
- **The dev server must run on port 4200.** That's the origin the API and the admin app trust;
another port breaks sign-out and the return redirect.
- **Dev runs React strict mode**, so every calendar is fetched twice on load in development.
- **A leftover Angular `node_modules`** makes `npm install` fail on a vite peer conflict.
- **npm 11 blocks install scripts by default** (`sharp`, `unrs-resolver` didn't run theirs). Lint and
build work without them; the app doesn't use `next/image`.
- **Vitest 5 needs `@types/node` ≥ 22**; the config is `vitest.config.mts` because `package.json`
has no `"type": "module"`.
## shadcn / Base UI
- **`npx shadcn add` imports `cn` from the npm package `cn`** and adds it (plus `next-themes` for the
toaster). This repo uses `@/lib/utils` like feedback, and toasts are light-only - fix both after
every `shadcn add`.
- **Generated components have English labels** ("Close"); translated to "Schließen" in `dialog.tsx`
and `sheet.tsx`.
- **Base UI, not Radix:** toggles expose `aria-pressed` (not `data-state=on`), triggers take a
`render` prop (not `asChild`), menu radio items stay open unless `closeOnClick` is set.
- **Base UI Select ignores synthetic `click()`s**; automated tests need real pointer or keyboard input.
- **Closed dialogs stay in the DOM** with `data-closed` until their exit animation ends - in a hidden
tab that never happens. Query `[data-open]` when checking what's open.
- **In a hidden tab `matchMedia` `change` events don't fire either**, so a resize there doesn't switch
the sheet between bottom and side. It works in a visible window.
## CSS
- **Responsive rules inside a capped panel need container queries**, not viewport breakpoints (the
edit sheet's footer labels use `@container` / `@md:`).
- **An implicit grid column grows to its longest unbreakable content** (a URL) and defeats
`truncate`; use `grid-cols-[minmax(0,1fr)]`.
- **`break-all` splits ordinary words mid-word** ("App-s"); only use it on text without spaces.
+423
View File
@@ -0,0 +1,423 @@
# Plan: rewrite the calendar frontend in Next.js
Status: agreed 2026-09-27; phases 0-8 done 2026-09-28, not yet committed or deployed. Branch: `feature/nextjs-rewrite`.
Goal: replace the Angular 18 app with a Next.js app that matches `nachklang-feedback` in stack,
look and auth behaviour, works properly on phones, and fixes the bugs found in the current
implementation instead of porting them.
---
## 1. Decisions
| # | Topic | Decision |
|---|-------|----------|
| Q1 | Repo | Rewrite in place in `Calendar_Frontend` on `feature/nextjs-rewrite`; the Angular tree is replaced, history keeps it. |
| Q2 | Hosting | `output: "standalone"` Node app on Plesk/Passenger, same as feedback. |
| Q3 | Stack | Copy feedback exactly: Next 16, React 19, Tailwind 4, shadcn `base-luma` preset `b7ClO5que`, Inter, lucide, ESLint. Port `lib/env.ts`, `lib/admin-auth.ts`, the `adminApi` request/error pattern, `proxy.ts`, and the admin layout's 401/403/unavailable handling. |
| Q4 | Language | German only, no i18n. |
| Q5 | API scope | Frontend-only, **except** the small API PR in §5 (Q21/Q22). Everything else goes to `docs/api-wishlist.md`. |
| Q6 | Admin layout | Agenda list grouped by month; editing happens in a Sheet (bottom drawer on mobile, side panel on desktop). Month grid is a later feature. |
| Q7 | Calendars | All calendars at once, colour-coded, toggled with filter chips. Filter state lives in the URL (`?cal=public,choir&range=future&q=…`). Birthdays off by default. |
| Q8 | Move | No separate "Move" action: calendar is a form field; the app calls `PUT /move/:id` when it changed. |
| Q9 | Landing page | Port the current page, add a "Nächste Termine" list from the anonymous public JSON, read the API URL from env. |
| Q10 | Tests | Vitest for pure logic (dates/timezone, whole-day, URL normalisation, validation, filter/sort, calendar mapping). No e2e. |
| Q11 | API | Read; findings in §3. |
| Q12 | Form | Fields and rules in §4.3. |
| Q13 | Libraries | Plain `useState` + pure `validateEvent()` + a small `useEvents` hook. No react-hook-form/zod/TanStack Query. |
| Q14 | Recurrence | Not editable. Birthdays get `YEARLY` automatically (moved from `api.service.ts` into form logic), shown as a "jährlich" badge. |
| Q15 | Save/delete | Sheet stays open until the API confirms; failure keeps the edits and shows the error; 401 → login. Dirty-close confirmation. Delete via AlertDialog, no undo. Quick "Veröffentlichen" on draft list items. |
| Q16 | Extras | All four: duplicate, text search, mobile "+" FAB, iCal-link copy for internal calendars (via the Q22 endpoint). |
| Q17 | Cutover | Verify locally on port 4200; the user switches the Plesk subdomain to a Node app and deploys. Old static build kept for ~1 week as rollback. |
| Q18 | Docs | Create `docs/architecture.md`, `docs/features.md`, `docs/api-wishlist.md`, `docs/gotchas.md`; update `CLAUDE.md`; add Next `AGENTS.md` note like feedback. |
| Q19 | Timezone | All form date handling explicitly in `Europe/Berlin` via `@date-fns/tz`; send UTC ISO strings. |
| Q20 | Whole-day | Date-only inputs, inclusive end date; saved as start 00:00 / end 23:59 Berlin. No bulk migration of old rows. |
| Q21 | Descriptions | Textarea stays; API PR adds RFC 5545 escaping + line folding to the iCal generator first. |
| Q22 | Internal iCal links | New API endpoint `GET /calendar/events/subscriptions` (editors only) returning the subscribe URLs. |
| Q23 | Recurring in filters | "Has a `repeatFrequency`" (not "calendar 5") means always current; sorted by next occurrence. |
| Q24 | API sequencing | One API PR on its own branch, reviewed and deployed before the frontend goes live. |
---
## 2. Problems in the Angular app this rewrite fixes
Found by reading the code on 2026-09-27; do not port these.
- 14-column table with editing inside the rows; unusable on phones. Toolbar is four `<select>`s joined by `&nbsp;|&nbsp;`.
- All feedback via `window.alert` / `window.confirm`.
- Admin UI in English, landing page German.
- Calendar column shows the raw id ("5").
- One calendar at a time, no search.
- **Move dialog:** changes `data.event.calendarId` while you choose, so Cancel still leaves the changed id on the row, and the next Save sends it. The dropdown also has no Birthdays option.
- Calendar ids hardcoded three times (`getCalendarId`, the index-based `calendars` array in the move popup, `calendarId === 5` in `api.service.ts` and the filter).
- `Date` objects mutated in place, everything in browser local time; whole-day events keep arbitrary times.
- Save closes the edit row before the request finishes, so a failed save looks saved.
- Changing the filter re-fetches from the server.
- URL regex forces `https://` and rewrites the value as a side effect.
- Dead `LoginComponent`; landing page hardcodes `api.nachklang.art`.
---
## 3. API facts the frontend must respect
Read from `API/src/models/calendar/events/*` on 2026-09-27.
- Endpoints under `/calendar/events/`: `GET /:calendar/json` (editors get all statuses incl. `DELETED`,
anonymous callers get `PUBLIC` only; `public` needs no credential), `POST /`, `PUT /:id`,
`PUT /move/:id`, `DELETE /:id`. Writes need `requireAppAccess('calendar')`.
- Calendar ids: public 1, members 2, management 3, choir 4, birthdays 5
(`calendarNames` in `events.router.ts`). The frontend keeps these in one typed constant.
- **PUT replaces the whole event** and defaults missing fields (`repeatFrequency ?? ''`,
`status ?? 'PUBLIC'`). Always send the full event, including for quick-publish.
- PUT answering 200 with `"0 rows were updated"` counts as a failure.
- Every write inserts into `event_versions`; delete inserts a version with only `status='DELETED'`.
The admin feed therefore returns deleted rows; the frontend filters them out.
- `PRIVATE` exists in the enum but nothing uses it: show read-only as "Privat", keep it on save.
- Admin feed ignores `includes_calendars`; public feeds follow it.
- iCal: timed events are written as `TZID=Europe/Berlin` using the server's local `getHours()`,
so the API process must run in Berlin time (inferred from the code, not checked on the server).
Whole-day end date gets +1 day, i.e. the stored end is the inclusive last day.
`repeatFrequency` goes out verbatim as `RRULE:FREQ=…`.
- Internal iCal feeds need `?password=`; passwords are env vars (`MEMBER_CREDENTIAL`,
`CHOIR_CREDENTIAL` for choir **and** birthdays, `MANAGEMENT_CREDENTIAL`).
- CORS already allows `https://calendar.nachklang.art` (`app.factory.ts`).
---
## 4. Target design
### 4.1 Structure
```
app/
layout.tsx root, Inter, lang="de"
page.tsx landing page
admin/
layout.tsx /me gate: 401 → login, 403 → "Kein Zugriff", else "nicht erreichbar"
login/page.tsx forwarder page proxy.ts sends signed-out visitors to
page.tsx agenda list + sheet
components/
ui/ shadcn components (sheet, dialog, alert-dialog, badge, button, input,
textarea, switch, select, toggle-group, sonner, …)
landing/ subscribe cards, upcoming events
admin/ EventList, EventListItem, CalendarChips, Toolbar, EventSheet,
EventForm, DeleteDialog, SubscriptionLinks, AddEventFab
lib/
env.ts throws on missing NEXT_PUBLIC_* (copied from feedback)
admin-auth.ts login URL / sign-out (copied from feedback)
api.ts request helper with credentials:"include", ApiError(status, body)
calendars.ts id ↔ slug ↔ label ↔ colour, single source of truth
dates.ts Berlin wall-time ↔ UTC, whole-day normalisation, next occurrence
event-form.ts Event ↔ form state, validateEvent(), normaliseUrl(), defaults
filter.ts range/calendar/search filtering, sorting, month grouping
url-state.ts parse/serialise ?cal=&range=&q=&sort=
proxy.ts cookie-presence gate on /admin/*
```
Environment: `NEXT_PUBLIC_API_BASE_URL`, `NEXT_PUBLIC_ADMIN_APP_URL` (+ `.env.local.example`).
Dev: `next dev -p 4200`. 4200 is required because it's the port the API and admin app trust.
### 4.2 Admin list
- Loads all five calendars in parallel through `useEvents`. A calendar that fails to load shows an
inline error on its chip and doesn't blank the page. A 401 from any of them → login.
- Toolbar: calendar chips (colour dot + label), range toggle (Kommend / Vergangen / Alle),
search (title + location), sort (Beginn ↑/↓, Erstellt ↑/↓). Filtering and sorting happen
client-side, nothing re-fetched. State lives in the URL.
- List grouped by month. Each item shows a date block, title, time or "ganztägig", location,
calendar badge, and "Entwurf"/"Privat"/"jährlich" badges. Drafts get a "Veröffentlichen"
quick action.
- Recurring events (any `repeatFrequency`) always count as current and are sorted by next occurrence.
- Mobile: a fixed "+" button bottom-right. Desktop: a "Neuer Termin" button in the toolbar.
New events get the calendar pre-filled if exactly one chip is active, start at the next full
hour and last 2 h.
- Empty, loading (skeleton) and error states are all designed, not blank.
### 4.3 Event sheet (create / edit / duplicate)
Fields: Kalender (select) · Titel (required) · Ganztägig (switch) · Beginn/Ende (date, plus time
unless whole-day) · Ort · URL · Beschreibung (textarea) · Status (Entwurf / Veröffentlicht; `PRIVATE`
shown read-only). Footer: "Erstellt von X am …", "Zuletzt geändert von Y am …".
Rules:
- Changing the start moves the end by the same amount, so the event's length stays the same.
An end before the start is an error on the field.
- Whole-day: time inputs hidden, their values discarded; saved as 00:00 / 23:59 Berlin, inclusive end.
- URL: if there's no scheme, `https://` is added on save; anything `new URL()` rejects is a field error.
Nothing is rewritten while the user types.
- Birthdays calendar ⇒ `repeatFrequency = 'YEARLY'`. Any other existing `repeatFrequency` is kept.
- Errors appear next to their field. No alerts.
Save flow: button shows a spinner; the sheet stays open until done. For an existing event whose
calendar changed: `PUT /move/:id`, then `PUT /:id` with the full event. If only the first one
succeeds, say exactly that. Success → close + toast + update local state. Failure → stay open
with the edits and show the message. 401 → toast "Sitzung abgelaufen", then login.
Other actions in the sheet: Duplizieren (opens a new draft copy, no id, "(Kopie)" suffix),
Löschen (AlertDialog). Closing with unsaved changes asks for confirmation.
### 4.4 iCal subscription links (admin)
A "Abonnieren" dialog in the admin header lists each calendar's subscribe URL from
`GET /calendar/events/subscriptions`, with copy buttons plus Apple/Google deep links. Notes that
choir and birthdays share a password.
### 4.5 Landing page
Same content as today (Apple / Google / copy-link cards, German copy), rebuilt with the design
system. Adds "Nächste Termine": the next ~5 `PUBLIC` events from `/calendar/events/public/json`,
server-rendered with a short revalidate. If the API is down, the section is hidden and the rest
of the page still works. All URLs come from env.
---
## 5. API PR (separate branch in the API repo, ships first)
1. `icalgenerator.service.ts`: RFC 5545 text escaping (`\\`, `;`, `,`, newline → `\n`) for
SUMMARY/DESCRIPTION/LOCATION, and line folding at 75 octets. Also stop `formatDate` from
mutating the event's `Date` (it calls `setDate` on it for the whole-day end).
2. `GET /calendar/events/subscriptions` behind `requireAppAccess('calendar')`: returns
`{calendar, label, icalUrl}` per calendar with the configured password. It leaves out a calendar
whose credential is unset instead of returning an unusable URL.
3. Tests for both (the API uses Vitest; run against the Docker test setup).
Validate locally; the user reviews and deploys.
---
## 6. Phases
Each phase ends in a working, reviewable state. No commits without the user's approval.
0. **Scaffold.** `create-next-app` output shaped like feedback; copy the design-system files
(`globals.css`, `components.json`, `lib/utils.ts`), ESLint and Vitest. Removing the Angular files
(`src/`, `angular.json`, `karma.conf.js`, `tsconfig.*`, `.browserslistrc`) is a deletion, so
**ask the user before doing it**. Add `.claude/launch.json` for port 4200. Pin the Node version to
what Plesk offers.
1. **Foundations + tests.** `env`, `admin-auth`, `api`, `calendars`, `dates`, `event-form`, `filter`,
`url-state`, with Vitest covering DST boundaries, whole-day normalisation, URL normalisation,
validation, next-occurrence sorting, and deleted/recurring filtering.
2. **Auth shell.** `proxy.ts`, `/admin/login` forwarder, admin layout with the three failure states
and the header (name, Abonnieren, Abmelden).
3. **List.** `useEvents`, chips, range/search/sort, month grouping, URL state, quick-publish,
loading/empty/error states.
4. **Sheet.** Form, validation, save/move flow, delete, duplicate, dirty guard, FAB.
5. **Landing page.**
6. **API PR** (can run in parallel with phases 1–5), then the subscriptions dialog.
7. **Verification.** Local API on :3000 with a throwaway DB (Docker) and the admin app on :3002.
Check sign-in, return redirect, 401/403 states, and every CRUD path. Check at 375 px and on a
real phone over LAN (`allowedDevOrigins`). Run `next build` with and without env vars.
8. **Docs + cutover.** Write the docs from Q18, update `CLAUDE.md`, open the PR. The user converts
`calendar.nachklang.art` to a Node app in Plesk and deploys, keeping the old static build for ~1 week.
---
## Progress log
**Phase 0 - done 2026-09-27 (uncommitted).** Angular files removed with `git rm` (approved);
`favicon.ico` moved to `app/`. Config copied from feedback. `npm install`, `npm test`, `npm run lint`,
`npm run build` pass; the placeholder page renders on :4200 without console errors.
Deviations and surprises (move these to `docs/gotchas.md` in phase 8):
- `@types/node` is `^26`, not feedback's `^20`: Vitest 5 has a peer dependency on `@types/node` ≥ 22,
and npm refuses `^20`. 26 matches `.nvmrc`.
- A leftover Angular `node_modules/` makes `npm install` fail on a vite peer conflict. It has to be
deleted by hand when switching branches between Angular and Next.
- npm 11 now blocks install scripts by default: `sharp` (next/image) and `unrs-resolver` (ESLint
import resolution) did **not** run their install scripts. Lint and build pass without them;
whether the standalone server needs `sharp` on Plesk is open (we don't use `next/image` yet).
- `.nvmrc` = 26 is taken from the API repo, not from what Plesk offers.
**Phase 1 - done 2026-09-27 (uncommitted).** `lib/`: `types`, `calendars`, `dates`, `events`
(parsing), `event-form`, `filter`, `url-state`, `env`, `admin-auth`, `api`. 87 Vitest tests in
6 files, run with `TZ=America/New_York` so Berlin handling can't pass by accident. `tsc`,
lint and build are clean.
Decisions made while implementing (not asked explicitly - revisit if wrong):
- Moving an event **out of** Birthdays drops its `YEARLY` recurrence, so a moved birthday doesn't
become a yearly event somewhere else. A recurrence from any other source is kept.
- Recurring events appear under "Kommend" and "Alle", never under "Vergangen".
- A yearly event on 29 Feb falls on 28 Feb in common years.
- An unknown `status` from the API is treated (and saved back) as `PRIVATE`, never as `PUBLIC`.
- Calendar labels: Öffentlich, Mitglieder, Chor, Management, Geburtstage.
- New events default to `DRAFT`, like the Angular app.
Facts found along the way (move to `docs/gotchas.md` in phase 8):
- The API returns raw DB rows: `wholeDay` is most likely `0`/`1` (inferred from the MariaDB
driver's defaults, not observed), and a deleted event's row has `null` in every column except
`status`. `lib/events.ts` coerces both.
- `@date-fns/tz` resolves a nonexistent spring-forward time (02:30) to 03:30 CEST, and an
ambiguous autumn time to the second occurrence (CET). Pinned in `lib/dates.test.ts`.
- The Vitest config is `vitest.config.mts`, not `.ts`: Vite warns about ESM in a CommonJS-loaded
file because `package.json` has no `"type": "module"` (Next doesn't need one).
**Phase 2 - done 2026-09-28 (uncommitted).** `proxy.ts`, `/admin/login` forwarder,
`app/admin/layout.tsx` (/me gate, header, loading skeleton, failure screens), `AdminSession`
context, placeholder `/admin` page. shadcn components added under `components/ui/`.
Verified in the browser against a local API + throwaway MariaDB (podman; setup below):
signed out → forwarder → admin login with the full return URL; `/admin/login` while signed in →
straight back with the query kept; signed in → name in header; no calendar permission → "Kein
Zugriff", no redirect; API down → "nicht erreichbar", no redirect; stale cookie → 401 → login;
sign-out → POST 200 → login with return URL; 375 px header.
Facts found along the way (move to `docs/gotchas.md` in phase 8):
- `wholeDay` **is** `0`/`1` on the wire (observed; confirms the phase-1 guess).
- `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, a 403 alone won't come.
- The dev session cookie is `nachklang.session_token` (no `__Secure-` prefix over http). Cookies
ignore the port, so the one the API sets on `localhost:3000` reaches `localhost:4200` too.
- The shadcn CLI (v4.19) generated imports from the npm package `cn` (shadcn's own
clsx+tailwind-merge replacement) and installed it, plus `next-themes` for the toaster. Both were
removed: components import `cn` from `@/lib/utils` like feedback, and toasts are pinned to light.
**Re-check this after every `shadcn add`.**
- In the API, only the admin DB pool reads `DB_PORT`; the calendar pool always uses 3306. A local
test DB therefore has to listen on 3306.
**Phase 3 - done 2026-09-28 (uncommitted).** The event list: `useEvents` (5 calendars in parallel,
per-calendar loading/error, request counter against stale responses), `useListState` (URL ↔ filters),
`EventsToolbar` (debounced search, sort menu, reload, range toggle), `CalendarChips` (scrolling row on
phones, per-chip counts, load/error markers), `EventList` / `EventListItem` (month groups with sticky
headings, flat list for "Erstellt" sorts, date block, badges, past events dimmed, quick
"Veröffentlichen"), empty/loading/error states. New pure modules `lib/event-display.ts` and
`lib/api-errors.ts`; `toQueryString` keeps `cal=public,choir` readable. 97 tests.
Verified in the browser with test events across all calendars (desktop and 375 px, no horizontal
overflow): Birthdays chip → URL + next-occurrence sorting; search with umlauts over title + location;
empty state + "Suche leeren" also clears the box; range toggle can't be emptied; "Vergangen" excludes
birthdays; sort menu → flat list, closes on pick; quick publish → PUT with the full event, DB keeps
times/recurrence, new version row; API down → per-chip error markers + banner, loaded events stay.
Decisions made while implementing (not asked explicitly):
- Rows open nothing yet; the stretched title button appears once phase 4 passes `onOpen`.
**Phase 3 follow-ups - decided by the user 2026-09-28, done.**
- "Veröffentlichen" asks first (`PublishDialog`): shows date, time and calendar, and says who will
see it (public calendar → nachklang.art and all subscribers). Cancel sends nothing; the dialog stays
open with a spinner until the API answers, and stays open on failure for a retry.
- "Vergangen" defaults to newest first. The default sort now depends on the range
(`defaultSortFor`); `withRange` swaps a *default* sort along when the range changes, while a sort
someone picked is kept. Defaults stay out of the URL (`?range=past`, not `&sort=start_desc`).
- "Alle" scrolls to today: a "Heute · <Datum>" marker sits where the past ends
(`todayMarkerIndex`), and the page centres it when "Alle" opens and again after a change of
calendars or sort - not on search keystrokes. Smooth scrolling unless the user prefers reduced motion.
(The marker itself was Claude's addition to the request.)
Facts found along the way (move to `docs/gotchas.md` in phase 8):
- Historical DST matters for birthdays: until 1995 German summer time ended in **September**, so an
instant like `1994-10-02T22:00Z` is 23:00 on 2 Oct in Berlin, not midnight on 3 Oct. The date code
handles this correctly; hand-written test data easily gets it wrong.
- Next dev runs React strict mode, so every calendar is fetched twice on load in dev (once in prod).
- Base UI menu radio items stay open after a pick unless `closeOnClick` is set.
- Base UI toggles expose `aria-pressed`, not Radix's `data-state=on`; style pressed items with
`aria-pressed:`.
**Phase 4 - done 2026-09-28 (uncommitted).** `EventSheet` (create / edit / duplicate; bottom sheet
below 640 px, side panel above), `EventForm` (presentational; rules stay in `lib/event-form.ts`),
delete confirmation, discard-changes confirmation (+ `beforeunload` while dirty), "Neuer Termin" in
the header from `sm`, floating "+" below it. New pure helpers `contentChanged` and `eventFromPayload`.
107 tests.
Save flow as built: validate → focus the first invalid field; new → POST; edit → `PUT /move/:id` if
the calendar changed, then `PUT /:id` only if anything else changed; sheet closes only after the API
answered; a failure keeps the sheet and the edits and shows the reason inline; move-OK-update-failed
records the move and says so; 401 → toast + login. After a save the list is updated at once, then the
affected calendar(s) re-fetched; a saved event hidden by the current filters gets an info toast.
Verified against the local API (DB checked after each write): edit keeps untouched fields; start
change moves the end; validation + focus, no request; whole-day → 00:00-23:59; link without scheme →
`https://…`; pure move = 1 request; move into Birthdays = move + update with `YEARLY`; duplicate
→ "(Kopie)", draft, POST; delete with cancel/confirm; API down → inline error, edits kept, retry works;
375 px: bottom sheet at 92dvh, icon-only secondary buttons, nothing overflows.
Decisions made while implementing (not asked explicitly):
- "Duplizieren" is disabled while the form has unsaved changes (it would silently drop them).
- A new event gets the calendar preselected only when exactly one chip is active; otherwise the
user must pick one.
- An unchanged form closes without asking; any change asks before discarding.
Fix after user review (2026-09-28): the footer buttons were clipped in the desktop side panel.
Cause: "Löschen"/"Duplizieren" labels appeared from the `sm` *viewport* width, but the panel is
capped (448 px) regardless of the window. Now the panel is up to 512 px (`sm:max-w-lg`) and the
labels follow the footer's own width via a container query (`@container` / `@md:inline`), falling
back to icons with tooltips. Measured: 1024, 700, 640 and 500 px show labels and fit; 375 px shows
icons and fits.
Facts found along the way (move to `docs/gotchas.md` in phase 8):
- Base UI Select ignores synthetic `click()`s; it needs real pointer or keyboard input (relevant for
any future automated UI test).
- Base UI dialogs and sheets stay in the DOM with `data-closed`/`data-ending-style` until their exit
animation ends; in a hidden tab that never happens. Query `[data-open]` when checking what's open.
- In a hidden tab, `matchMedia` `change` events don't fire either (they're dispatched during
rendering), so a viewport resize there doesn't switch the sheet between bottom and side. Reload at
the target width when testing that way; in a visible window it switches live.
- Responsive rules inside a capped panel must use container queries, not viewport breakpoints.
**Phase 5 - done 2026-09-28 (uncommitted).** Landing page (`app/page.tsx`, server component).
First built in the dark/gold look of the Angular redesign (commit 6400616); **the user then asked for
it to match the rest of the app, more modern and simpler**, so it now uses the design system like the
admin area: light, Inter, a header with "Nachklang · Kalender" and a "Verwaltung" link, one heading
and one sentence, the three subscribe options as cards (a row each on phones, icon above text in the
three-column grid from `sm`), "Nächste Termine" in the admin list's style, a small footer. No
clef, staff lines, gradients or entrance animation any more.
Subscribe links built from `NEXT_PUBLIC_API_BASE_URL` (`lib/public-calendar.ts`), no longer
hardcoded. "Nächste Termine": the next 4 public events, fetched server-side with
`next: { revalidate: 300 }`, page `revalidate = 300`; an event's link is only rendered if it passes
`normaliseUrl` (http/https), since legacy rows were never validated. Optional server-only
`API_BASE_URL` like in feedback. `CopyLinkCard` shows the URL if the clipboard is unavailable.
111 tests.
Verified: SSR HTML contains the events and env-based links; 1024 px three equal cards, 375 px
full-width rows, no overflow; copy puts the iCal URL on the clipboard; `next build` with the API
down succeeds and prerenders the page without the events section.
Facts found along the way (move to `docs/gotchas.md` in phase 8):
- With `revalidate`, a short API outage doesn't blank the events section in a running server: Next
keeps serving the cached fetch. Only a cache miss (e.g. a build with the API down) hides it.
- The build prerenders `/` by calling the API from the build machine; on Plesk that's the server
itself. If it can't reach the API the build still succeeds, and the section appears after the first
revalidation.
**Phase 6 - done 2026-09-28 (uncommitted, both repos).**
API, branch `feature/calendar-ical-escaping-subscriptions` in the API repo (documented in the API's
`docs/calendar-ical.md`):
- iCal export: RFC 5545 text escaping for SUMMARY/DESCRIPTION/LOCATION, line folding at 75 octets
(UTF-8 aware), CRLF line endings; `formatDate` no longer mutates the event's end date.
- `GET /calendar/events/subscriptions` (editors only, shared password not accepted, `no-store`):
iCal URL per calendar, password included and URL-encoded; calendars without a configured
credential are omitted. Built from the API's validated `API_BASE_URL`.
- Tests: new `test/calendar/icalgenerator.service.test.ts` (11, all fail against the old
generator), 4 subscription cases in `events.router.test.ts`. Full API suite 217/217, `tsc` clean.
Live check against the local API: CRLF only, max 75 octets per line, a multi-line description
with `,`/`;`/umlauts stays one escaped `DESCRIPTION`.
Frontend: `SubscriptionsDialog` ("Abonnieren" in the admin header, icon-only on phones): per calendar
the URL, Apple (webcal) and Google links and "Link kopieren"; fetched on every open; note that choir
and birthdays share a password. `toWebcal`/`toGoogleCalendar` now work for any iCal URL (password
survives both conversions, tested). shadcn's English "Close" labels translated to "Schließen".
Found along the way:
- The iCal endpoint answers with `Content-Type: text/html`, not `text/calendar` (pre-existing, not
changed - calendar apps have coped). Candidate for the API wish list or a one-line fix.
- `ORGANIZER` is written as a bare name instead of a cal-address (pre-existing, not changed).
- A CSS grid's implicit column grows to its longest unbreakable content (a URL), which defeats
`truncate` inside it; `grid-cols-[minmax(0,1fr)]` fixes it (`SubscriptionsDialog`).
**Phase 6 follow-up (2026-09-28, user request):** the iCal endpoint now sends
`Content-Type: text/calendar; charset=utf-8` (API branch, +1 router test, 218/218; checked live).
**Phase 7 (verification)** happened within each phase rather than as its own step: every flow was
exercised in the browser against the local API with DB checks. Not done: a real phone over LAN (the
app calls `localhost:3000`, which a phone can't reach without changing the env), and a visual check
by Claude (the browser pane was hidden for most of the session - the user checked visually).
**Phase 8 - done 2026-09-28 (uncommitted).** Docs written: `architecture.md`, `features.md`,
`gotchas.md` (the progress-log findings, consolidated), `api-wishlist.md`, `deployment.md`
(order: API first; build env; standalone start; smoke test; rollback). `CLAUDE.md` rewritten for
Next; README links the docs. Deployment prep: standalone output doesn't serve `.next/static` on its
own, so `postbuild` now copies it; verified by running `.next/standalone/server.js` on :4300
(styled page, CSS/JS 200, `/admin` redirect).
Local test environment (throwaway, nothing points at shared infra):
- `podman run -d --name nk-calendar-test -p 3306:3306 -e TZ=Europe/Berlin -e MARIADB_ROOT_PASSWORD=… --tmpfs /var/lib/mysql -v API/docker/init:/docker-entrypoint-initdb.d:ro -v API/sql:/migrations:ro mariadb:11`
- in-memory, seeded by the API's own dev scripts, incl. the `dev@nachklang.art` account.
- API: `npx tsc` in the API repo, then `node API/dist/app.js` started **from a scratch directory**
holding its own `.env` (dotenv reads the cwd), with `TZ=Europe/Berlin`. The API repo's own `.env`
is never loaded. The API's logger still writes to `API/logs/` (gitignored).
- Sign in by POSTing the seeded credentials to `http://localhost:3000/admin/auth/sign-in/email`
from a page on `localhost:4200`; the admin app isn't needed.
---
## 7. Open risks
- Plesk subdomain conversion from static to Node is unverified for this domain. (Corrected: an
earlier version said feedback proves the toolkit works on the server - feedback's own plan records
its Plesk deploy as blocked and never documents how it runs, so that was an unsourced claim.)
- The API server's timezone is inferred, not checked. If it isn't Berlin, the iCal times are already
wrong today, independent of this rewrite.
- `lastModifiedBy`/`createdBy` names can come back empty for legacy rows (nullable since migration 003);
the footer has to handle that.