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

30 KiB
Raw Permalink Blame History

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.

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.

  1. 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.
  2. 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.
  3. Auth shell. proxy.ts, /admin/login forwarder, admin layout with the three failure states and the header (name, Abonnieren, Abmelden).
  4. List. useEvents, chips, range/search/sort, month grouping, URL state, quick-publish, loading/empty/error states.
  5. Sheet. Form, validation, save/move flow, delete, duplicate, dirty guard, FAB.
  6. Landing page.
  7. API PR (can run in parallel with phases 1–5), then the subscriptions dialog.
  8. 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.
  9. 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 · " 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.