Files
Calendar_Frontend/docs/rewrite-plan.md
PaddyandClaude Opus 5.5 0cda40f85e 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>
2026-09-28 22:15:59 +02:00

424 lines
30 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.