# Gotchas Things that turned out differently than expected, so the same wrong assumption isn't made twice. Collected during the rewrite (September 2026); the progress log in [rewrite-plan.md](rewrite-plan.md) has the context for each. ## The API - **PUT replaces the whole event.** Missing fields fall back to `status: 'PUBLIC'` and `repeatFrequency: ''`. Always send the full `EventPayload`, even to change one field. - **"0 rows were updated" comes back as HTTP 200.** `api.updateEvent` turns that body into an error. - **The API returns raw DB values.** `wholeDay` is `0`/`1` (observed), and a deleted event's latest version is a row with every field `null` except `status: 'DELETED'`. `parseApiEvent` handles both. - **`GET /admin/me` answers 200 for accounts without the calendar permission.** Only `apps` says so. - **The admin feed ignores `includes_calendars`**; the public/iCal feeds follow it. So an event can appear in a calendar's subscription although it belongs to another calendar. - **`PRIVATE` is in the status enum but nothing uses it.** The app shows it read-only and keeps it. - **The iCal times depend on the API process's time zone** (formatted with local `getHours()`, labelled Europe/Berlin). Inferred from the code; not checked on the server. - **Only the admin DB pool reads `DB_PORT`.** A local calendar test database has to listen on 3306. ## Time - **Historical DST.** Until 1995 German summer time ended in September, so `1994-10-02T22:00Z` is 23:00 on 2 October in Berlin, not midnight on the 3rd. The date code gets it right; hand-written test data easily doesn't. - **DST edges in forms.** A nonexistent spring time (02:30) becomes 03:30 CEST; an ambiguous autumn time resolves to the second occurrence (CET). Pinned in `lib/dates.test.ts`. ## Next.js / hosting - **Standalone output doesn't serve `.next/static` by itself.** Without the copy, CSS and JS are 404s. The `postbuild` script copies it into `.next/standalone/.next/static`; there is no `public/` folder to copy (the favicon lives in `app/`). Verified by running `.next/standalone/server.js`. - **`NEXT_PUBLIC_*` values are inlined at build time.** They must be set when `npm run build` runs, not only when the server starts; `lib/env.ts` fails the build if they're missing. - **The build prerenders `/` by calling the API.** If the build machine can't reach it, the build still succeeds and the events section appears after the first revalidation (≤ 5 min). - **With `revalidate`, a short API outage doesn't blank the landing page's events** in a running server - Next keeps serving the cached fetch. - **The dev server must run on port 4200.** That's the origin the API and the admin app trust; another port breaks sign-out and the return redirect. - **Dev runs React strict mode**, so every calendar is fetched twice on load in development. - **A leftover Angular `node_modules`** makes `npm install` fail on a vite peer conflict. - **npm 11 blocks install scripts by default** (`sharp`, `unrs-resolver` didn't run theirs). Lint and build work without them; the app doesn't use `next/image`. - **Vitest 5 needs `@types/node` ≥ 22**; the config is `vitest.config.mts` because `package.json` has no `"type": "module"`. ## shadcn / Base UI - **`npx shadcn add` imports `cn` from the npm package `cn`** and adds it (plus `next-themes` for the toaster). This repo uses `@/lib/utils` like feedback, and toasts are light-only - fix both after every `shadcn add`. - **Generated components have English labels** ("Close"); translated to "Schließen" in `dialog.tsx` and `sheet.tsx`. - **Base UI, not Radix:** toggles expose `aria-pressed` (not `data-state=on`), triggers take a `render` prop (not `asChild`), menu radio items stay open unless `closeOnClick` is set. - **Base UI Select ignores synthetic `click()`s**; automated tests need real pointer or keyboard input. - **Closed dialogs stay in the DOM** with `data-closed` 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**, so a resize there doesn't switch the sheet between bottom and side. It works in a visible window. ## CSS - **Responsive rules inside a capped panel need container queries**, not viewport breakpoints (the edit sheet's footer labels use `@container` / `@md:`). - **An implicit grid column grows to its longest unbreakable content** (a URL) and defeats `truncate`; use `grid-cols-[minmax(0,1fr)]`. - **`break-all` splits ordinary words mid-word** ("App-s"); only use it on text without spaces.