Rewrite the calendar frontend in Next.js
Replaces the Angular 18 app with Next.js 16 / React 19 / Tailwind 4 / shadcn, the same stack and design system as nachklang-feedback, planned in docs/rewrite-plan.md. - Admin: all calendars in one mobile-friendly list (month groups, calendar chips, Kommend/Vergangen/Alle, search, sort, filters in the URL), an edit sheet for create/edit/duplicate/delete, publish confirmation, and an "Abonnieren" dialog with subscription links for every calendar. - Public landing page: subscribe links from env instead of hardcoded, plus the next public events, server-rendered and revalidated every 5 minutes. - All times handled explicitly as Europe/Berlin; whole-day events stored as 00:00-23:59. Fixes carried over from the Angular app: the move dialog leaking a cancelled calendar change, saves that looked saved when they failed, in-place Date mutation, and duplicated calendar-id mappings. - Same shared-session auth as before (proxy.ts gate, /admin/me, 401 vs 403). - 112 Vitest tests over lib/; docs in docs/ (architecture, features, gotchas, API wish list, deployment). Needs the API branch feature/calendar-ical-escaping-subscriptions deployed first (GET /calendar/events/subscriptions). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
138 files changed
+15942
-24010
No files matched your search
@@ -0,0 +1,423 @@
|
||||
# Plan: rewrite the calendar frontend in Next.js
|
||||
|
||||
Status: agreed 2026-09-27; phases 0-8 done 2026-09-28, not yet committed or deployed. Branch: `feature/nextjs-rewrite`.
|
||||
|
||||
Goal: replace the Angular 18 app with a Next.js app that matches `nachklang-feedback` in stack,
|
||||
look and auth behaviour, works properly on phones, and fixes the bugs found in the current
|
||||
implementation instead of porting them.
|
||||
|
||||
---
|
||||
|
||||
## 1. Decisions
|
||||
|
||||
| # | Topic | Decision |
|
||||
|---|-------|----------|
|
||||
| Q1 | Repo | Rewrite in place in `Calendar_Frontend` on `feature/nextjs-rewrite`; the Angular tree is replaced, history keeps it. |
|
||||
| Q2 | Hosting | `output: "standalone"` Node app on Plesk/Passenger, same as feedback. |
|
||||
| Q3 | Stack | Copy feedback exactly: Next 16, React 19, Tailwind 4, shadcn `base-luma` preset `b7ClO5que`, Inter, lucide, ESLint. Port `lib/env.ts`, `lib/admin-auth.ts`, the `adminApi` request/error pattern, `proxy.ts`, and the admin layout's 401/403/unavailable handling. |
|
||||
| Q4 | Language | German only, no i18n. |
|
||||
| Q5 | API scope | Frontend-only, **except** the small API PR in §5 (Q21/Q22). Everything else goes to `docs/api-wishlist.md`. |
|
||||
| Q6 | Admin layout | Agenda list grouped by month; editing happens in a Sheet (bottom drawer on mobile, side panel on desktop). Month grid is a later feature. |
|
||||
| Q7 | Calendars | All calendars at once, colour-coded, toggled with filter chips. Filter state lives in the URL (`?cal=public,choir&range=future&q=…`). Birthdays off by default. |
|
||||
| Q8 | Move | No separate "Move" action: calendar is a form field; the app calls `PUT /move/:id` when it changed. |
|
||||
| Q9 | Landing page | Port the current page, add a "Nächste Termine" list from the anonymous public JSON, read the API URL from env. |
|
||||
| Q10 | Tests | Vitest for pure logic (dates/timezone, whole-day, URL normalisation, validation, filter/sort, calendar mapping). No e2e. |
|
||||
| Q11 | API | Read; findings in §3. |
|
||||
| Q12 | Form | Fields and rules in §4.3. |
|
||||
| Q13 | Libraries | Plain `useState` + pure `validateEvent()` + a small `useEvents` hook. No react-hook-form/zod/TanStack Query. |
|
||||
| Q14 | Recurrence | Not editable. Birthdays get `YEARLY` automatically (moved from `api.service.ts` into form logic), shown as a "jährlich" badge. |
|
||||
| Q15 | Save/delete | Sheet stays open until the API confirms; failure keeps the edits and shows the error; 401 → login. Dirty-close confirmation. Delete via AlertDialog, no undo. Quick "Veröffentlichen" on draft list items. |
|
||||
| Q16 | Extras | All four: duplicate, text search, mobile "+" FAB, iCal-link copy for internal calendars (via the Q22 endpoint). |
|
||||
| Q17 | Cutover | Verify locally on port 4200; the user switches the Plesk subdomain to a Node app and deploys. Old static build kept for ~1 week as rollback. |
|
||||
| Q18 | Docs | Create `docs/architecture.md`, `docs/features.md`, `docs/api-wishlist.md`, `docs/gotchas.md`; update `CLAUDE.md`; add Next `AGENTS.md` note like feedback. |
|
||||
| Q19 | Timezone | All form date handling explicitly in `Europe/Berlin` via `@date-fns/tz`; send UTC ISO strings. |
|
||||
| Q20 | Whole-day | Date-only inputs, inclusive end date; saved as start 00:00 / end 23:59 Berlin. No bulk migration of old rows. |
|
||||
| Q21 | Descriptions | Textarea stays; API PR adds RFC 5545 escaping + line folding to the iCal generator first. |
|
||||
| Q22 | Internal iCal links | New API endpoint `GET /calendar/events/subscriptions` (editors only) returning the subscribe URLs. |
|
||||
| Q23 | Recurring in filters | "Has a `repeatFrequency`" (not "calendar 5") means always current; sorted by next occurrence. |
|
||||
| Q24 | API sequencing | One API PR on its own branch, reviewed and deployed before the frontend goes live. |
|
||||
|
||||
---
|
||||
|
||||
## 2. Problems in the Angular app this rewrite fixes
|
||||
|
||||
Found by reading the code on 2026-09-27; do not port these.
|
||||
|
||||
- 14-column table with editing inside the rows; unusable on phones. Toolbar is four `<select>`s joined by ` | `.
|
||||
- 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.
|
||||
Reference in new issue
Block a user