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>
424 lines
30 KiB
Markdown
424 lines
30 KiB
Markdown
# 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.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.
|