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>
30 KiB
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 | . - 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.calendarIdwhile 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-basedcalendarsarray in the move popup,calendarId === 5inapi.service.tsand the filter). Dateobjects 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 hardcodesapi.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 getPUBLIConly;publicneeds no credential),POST /,PUT /:id,PUT /move/:id,DELETE /:id. Writes needrequireAppAccess('calendar'). - Calendar ids: public 1, members 2, management 3, choir 4, birthdays 5
(
calendarNamesinevents.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 onlystatus='DELETED'. The admin feed therefore returns deleted rows; the frontend filters them out. PRIVATEexists 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/Berlinusing the server's localgetHours(), 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.repeatFrequencygoes out verbatim asRRULE:FREQ=…. - Internal iCal feeds need
?password=; passwords are env vars (MEMBER_CREDENTIAL,CHOIR_CREDENTIALfor 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; anythingnew URL()rejects is a field error. Nothing is rewritten while the user types. - Birthdays calendar ⇒
repeatFrequency = 'YEARLY'. Any other existingrepeatFrequencyis 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)
icalgenerator.service.ts: RFC 5545 text escaping (\\,;,,, newline →\n) for SUMMARY/DESCRIPTION/LOCATION, and line folding at 75 octets. Also stopformatDatefrom mutating the event'sDate(it callssetDateon it for the whole-day end).GET /calendar/events/subscriptionsbehindrequireAppAccess('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.- 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.
- Scaffold.
create-next-appoutput 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.jsonfor port 4200. Pin the Node version to what Plesk offers. - 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. - Auth shell.
proxy.ts,/admin/loginforwarder, admin layout with the three failure states and the header (name, Abonnieren, Abmelden). - List.
useEvents, chips, range/search/sort, month grouping, URL state, quick-publish, loading/empty/error states. - Sheet. Form, validation, save/move flow, delete, duplicate, dirty guard, FAB.
- Landing page.
- API PR (can run in parallel with phases 1–5), then the subscriptions dialog.
- 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). Runnext buildwith and without env vars. - Docs + cutover. Write the docs from Q18, update
CLAUDE.md, open the PR. The user convertscalendar.nachklang.artto 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/nodeis^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/makesnpm installfail 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) andunrs-resolver(ESLint import resolution) did not run their install scripts. Lint and build pass without them; whether the standalone server needssharpon Plesk is open (we don't usenext/imageyet). .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
YEARLYrecurrence, 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
statusfrom the API is treated (and saved back) asPRIVATE, never asPUBLIC. - Calendar labels: Öffentlich, Mitglieder, Chor, Management, Geburtstage.
- New events default to
DRAFT, like the Angular app. Facts found along the way (move todocs/gotchas.mdin phase 8): - The API returns raw DB rows:
wholeDayis most likely0/1(inferred from the MariaDB driver's defaults, not observed), and a deleted event's row hasnullin every column exceptstatus.lib/events.tscoerces both. @date-fns/tzresolves a nonexistent spring-forward time (02:30) to 03:30 CEST, and an ambiguous autumn time to the second occurrence (CET). Pinned inlib/dates.test.ts.- The Vitest config is
vitest.config.mts, not.ts: Vite warns about ESM in a CommonJS-loaded file becausepackage.jsonhas 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):
wholeDayis0/1on the wire (observed; confirms the phase-1 guess).GET /admin/meanswers 200 for an account without the calendar permission - onlyappsshows it. The layout'sapps.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 onlocalhost:3000reacheslocalhost:4200too. - The shadcn CLI (v4.19) generated imports from the npm package
cn(shadcn's own clsx+tailwind-merge replacement) and installed it, plusnext-themesfor the toaster. Both were removed: components importcnfrom@/lib/utilslike feedback, and toasts are pinned to light. Re-check this after everyshadcn 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);withRangeswaps 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 todocs/gotchas.mdin phase 8): - Historical DST matters for birthdays: until 1995 German summer time ended in September, so an
instant like
1994-10-02T22:00Zis 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
closeOnClickis set. - Base UI toggles expose
aria-pressed, not Radix'sdata-state=on; style pressed items witharia-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
smviewport 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 todocs/gotchas.mdin 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-styleuntil their exit animation ends; in a hidden tab that never happens. Query[data-open]when checking what's open. - In a hidden tab,
matchMediachangeevents 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;
formatDateno 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 validatedAPI_BASE_URL.- Tests: new
test/calendar/icalgenerator.service.test.ts(11, all fail against the old generator), 4 subscription cases inevents.router.test.ts. Full API suite 217/217,tscclean. Live check against the local API: CRLF only, max 75 octets per line, a multi-line description with,/;/umlauts stays one escapedDESCRIPTION. 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/toGoogleCalendarnow 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, nottext/calendar(pre-existing, not changed - calendar apps have coped). Candidate for the API wish list or a one-line fix. ORGANIZERis 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
truncateinside 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.artaccount.
- in-memory, seeded by the API's own dev scripts, incl. the
- API:
npx tscin the API repo, thennode API/dist/app.jsstarted from a scratch directory holding its own.env(dotenv reads the cwd), withTZ=Europe/Berlin. The API repo's own.envis never loaded. The API's logger still writes toAPI/logs/(gitignored). - Sign in by POSTing the seeded credentials to
http://localhost:3000/admin/auth/sign-in/emailfrom a page onlocalhost: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/createdBynames can come back empty for legacy rows (nullable since migration 003); the footer has to handle that.