Ctrl K
ComunidadGuías…

CLAUDE.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# TravStats

Self-hosted travel logbook (flights, cruises, more) — Express/TypeScript backend + React/Vite/TypeScript frontend.

## Dev Commands

```bash
# Install everything
npm run install:all

# Run backend + frontend together
npm run dev           # alternative: scripts/dev-all.sh

# Run them separately
npm run dev:backend   # port 8000
npm run dev:frontend  # port 3000
```

## Deploy & Release Workflow

**No PRs.** Solo project — no pull-request ceremony. Branching strategy
is scoped to change size:

| Change size | Branch | When |
|---|---|---|
| Trivial (≤3 files, docs/tooling, no app logic, no DB) | Direct commit to `main` — only with the owner's explicit OK | Typo, lint fix, doc tweak |
| Risky fix (multi-file, logic, migration, dep bump) | `fix/<slug>` off `main` | Any bug fix with blast radius |
| Small feature | `feat/<slug>` off `main` | Isolated enhancement |
| Large / long-running feature | `dev/<slug>` off `main`, NEVER commit to main until complete | Multi-phase work (e.g. `dev/multi-domain-v1` for cruise) |

**Rule of thumb:** develop on a branch. `main` mirrors what is (about to be)
released, so landing anything there is a product decision, not a workflow step.

**`main` is the deploy trunk** — every commit on `main` is a candidate
for `/deploy`. Dev branches never deploy.

> **Merging into `main` is the owner's RELEASE decision.** A branch being
> green, reviewed and complete says nothing about whether it should ship.
> When a branch is done: report it, then ask as a **single, isolated
> question** whether to merge — never bundled into a list of next steps
> where a general "ja" could be mistaken for release consent.

### Who writes to `main` (since 2026-09-25)

Two writers: the owner and the Claude sessions. Three rules hold regardless:

1. **Before moving `main`, fetch both remotes** (`git fetch origin main &&
   git fetch forgejo main`). If either moved, merge it into the branch being
   integrated and re-run the gates first.
2. **Fast-forward only, pushed to both remotes.** Never force-push `main`.
   When the two disagree, Forgejo is the side PRs land on — reconcile from it.
3. **A Forgejo PR is `forgejo#N` in every commit message**, including the
   merge commit (Forgejo's default "Merge pull request #128" reads as GitHub
   #128 once it reaches GitHub). `.forgejo/workflows/` runs on the homelab
   runner — change it deliberately.

The three-writer era with Paperclip (2026-09-25 to -27) and its other rules:
`docs/claude-md-history.md`.

### Long-running feature branches (e.g. `dev/multi-domain-v1`)

When a `dev/<slug>` branch is active:

1. **Pull `main` into the dev branch after every main release.** Keep merges
   early + often so conflicts stay small:
   ```bash
   git checkout dev/multi-domain-v1
   git merge main
   ```
2. **Do NOT touch `backend/VERSION` or `CHANGELOG.md` on a dev branch** —
   both are owned by `/deploy` on main. Editing them on the branch creates
   guaranteed merge conflicts. `main → dev` merges carry them automatically.
3. **Do NOT `rebase main`** onto a long-running dev branch. Rebase rewrites
   history and breaks pre-commit hook caches and any commit
   refs baked into memory / plan docs. Use `merge` every time.
4. **Final merge when the feature is ready:**
   ```bash
   git checkout dev/<slug>
   git merge main             # last sync
   # run full tsc + tests + manual smoke
   git checkout main
   git merge --no-ff dev/<slug>   # explicit merge commit — no fast-forward
   /deploy
   ```

### Short-lived fix/feat branch workflow

```bash
git checkout main
git checkout -b fix/<slug>
# … edit, commit …
git checkout main
git merge fix/<slug>         # fast-forward is fine
git branch -d fix/<slug>
/deploy
```

### RC-first rule (every release, no exceptions)

> **Full pipeline + staging:** see **`docs/RELEASE_WORKFLOW.md`**. Since
> 2026-07-04 the RC lands on the **RC Server** (CT106, a prod-DATA mirror)
> FIRST — cloned via `scripts/stage-rc-from-prod.sh` — and only then on prod.
> "RC Server" is the renamed role of the former beta server (host unchanged).

Every release — major, minor, patch, security fix, beta bump — starts
as a **Release Candidate**:

1. `/deploy` builds `:X.Y.Z-rc.N` (or `:X.Y.Z-security-rc.N`, etc.),
   pushes to GHCR, deploys that RC tag to Underworld.
2. Same RC also gets a **git tag** (`v<RC_TAG>`) and a **GitHub
   Pre-release** (`--prerelease`, never `--latest`) — so every RC is
   visible in all three places (GHCR tag, git tag, GH Releases list).
3. The RC runs on prod and gets verified (health check + user UAT).
4. **Only on the user's explicit promotion command** (e.g. "promote",
   "mach den echten Release", "final") are the final tags
   `:X.Y.Z` / `:latest` / `:stable` cut via `docker buildx imagetools
   create` — byte-identical retag, no rebuild.
5. Docker Hub mirror and `/release` (final GitHub Release with
   `--latest`) only after promotion.

No final tag ever comes from a fresh build. No release happens without
an explicit manual command from the user.

### `/deploy` — builds an RC
Use the `deploy` skill. Runs fully automatically up to the RC deploy:
1. Analyses commits since the last version bump.
2. Auto-determines the bump type (`feat:` → minor, `fix:/chore:/…` →
   patch).
3. Drafts the changelog entry as prose.
4. Shows version + changelog draft → **one confirmation**.
5. Writes `backend/VERSION` + `CHANGELOG.md` and commits.
6. Builds the RC Docker image, pushes to GHCR, deploys the RC tag to
   prod, health check and cleanup. Stops and waits for promotion.
7. **Announce the RC in Discord** — after the RC is live, post to
   `#beta-channel`:
   ```bash
   cd tools/discord-setup && npm run announce rc <RC_TAG>   # e.g. 2.3.0-rc.1
   ```
   Reads the matching `CHANGELOG.md` entry automatically (the `-rc.N`
   suffix is stripped for the lookup). Needs `tools/discord-setup/.env`
   (bot token + guild id) — see `tools/discord-setup/README.md`.

### `/release` — GitHub release (after promotion only)
Use the `release` skill. Requires the final tags (`:X.Y.Z` / `:latest`
/ `:stable`) to already exist on GHCR from a prior promotion — the
skill refuses to run otherwise. Aggregates every changelog entry since
the last GitHub release, creates a git tag, publishes a GitHub release
with `--latest`. No new deploy — the code is already running.
After the GitHub release is published, **announce it in Discord** — post
to `#announcements`:
```bash
cd tools/discord-setup && npm run announce release <X.Y.Z>   # e.g. 2.3.0
```

## Build Checks (MANDATORY before `/deploy`)

```bash
# Backend
cd backend && npx tsc --noEmit && npm run lint && npm test -- --forceExit

# Frontend
cd frontend && npx tsc --noEmit && npm run lint && npx vitest --run

# Repo-level checks (file-size ratchet + Prisma schema drift).
# check:drift needs a reachable Postgres and does not read backend/.env.
DATABASE_URL="postgresql://…" npm run check

# Coverage ratchet — after a run with coverage and the json-summary reporter
# (`npx vitest --run --coverage --coverage.reporter=json-summary`,
#  `npx jest --forceExit --coverage --coverageReporters=json-summary`).
npm run check:coverage -- frontend
npm run check:coverage -- backend
```

CI (`.github/workflows/ci.yml`) runs all of this as required jobs; how it got
there is in `docs/claude-md-history.md`.

A green badge therefore covers the backend suite too. See **Rules** below for
what is machine-enforced and what is not.

## Docker & Deployment

Deployment details (server IP, SSH commands, compose paths) live in
**`CLAUDE.local.md`** (gitignored, created locally).

## Where a finding goes — issue or board item

This repository is public. A GitHub issue is therefore a **published statement**,
and the tracker is what a stranger reads to judge whether the project is alive
and honest. That makes the tracker worth keeping signal-only.

The deciding question is **not** who found it. It is:

> Does a person running TravStats need to know this?

| Finding | Where |
|---|---|
| Anything a user can hit, see, or must act on — a wrong number on screen, data at risk, a broken import | **GitHub issue**, however it was found. Owner, tester, or a routine prod check makes no difference. |
| The same thing, but the write-up quotes internal paths, host names, sample data or measurement runs | **Forgejo issue** on `dennis/TravStats`. Same finding, private write-up — see below. |
| Internal engineering and tooling — our own scripts, test-suite noise, log hygiene with no user-visible effect, chores like wiring a check into CI | **Leitstand item**, no issue anywhere |
| Exploitable **and** still open | **GitHub private security advisory** (draft), never a public issue; publish it once the fix ships |
| Infrastructure, hosts, scope decisions, pentest notes | stays out of all three — `CLAUDE.local.md`, `ROADMAP.local.md`, `TravStats-local` on Forgejo |

An internal finding becomes an item in `roadmap.local.yaml` with
`source: { type: audit }` (found by a sweep) or `{ type: owner }` (a decision or
a wish). Those carry a **title**, because they have no live anchor to read one
from — the schema enforces it, and it enforces the mirror rule too: a `github`
or `forgejo` item must NOT carry a title, since its title is read live.

### Three trackers, and the two rules that make that safe

**Reference collisions — solved by convention.** The numbering spaces are
independent, and they have already collided: GitHub `#36` is a zod dependency
bump, Forgejo `#36` is the boarding-pass OCR bug. Since 2026-09-01 there is a
third space — the Companion keeps its own Forgejo tracker on
`dennis/TravStatsCompanion`, which started at `#1` and will therefore collide
with both of the others within the month. So:

> | Written | Means |
> |---|---|
> | `#36` | GitHub, `Abrechen2/TravStats` |
> | `forgejo#36` | Forgejo, `dennis/TravStats` |
> | `companion#3` | Forgejo, `dennis/TravStatsCompanion` |

Three rules hold that together, and each exists because of a specific way it
would otherwise fail:

1. **Never write a bare number for anything but GitHub**, even where the
   surrounding text makes it obvious. It will not be obvious in `git log` two
   years from now, and GitHub will happily render it as a link to the wrong
   thing.
2. **A reference means the same thing in every repository it is read in.**
   `forgejo#42` is `dennis/TravStats` issue 42 *including when written inside
   the Companion repo* — even though that repo's own `origin` is also Forgejo,
   which is exactly the reading that would go wrong. Likewise `companion#3` is
   the Companion's issue 3 including inside the Companion itself. A prefix that
   depended on where you were standing would be no prefix at all.
3. **`companion#N` is the Forgejo tracker, and stays reserved for it.** The
   Companion also has a GitHub remote, deliberately unused (the standing rule
   is Forgejo-only). If that tracker is ever opened, it needs a form of its own;
   it must not inherit the bare `#N`, which is spoken for.

Cross-repository references are normal here and should be written in full where
prose allows — "`dennis/TravStats` forgejo#42" costs four words and survives
being quoted into an issue body, a changelog, or a chat log with no repository
around it.

The Leitstand measures Forgejo items live through `tea`, as it does GitHub
through `gh`: `source: { type: forgejo, ref: 36 }` is a first-class board item
and carries no title. Configure it with a `forgejo:` block in
`roadmap.local.yaml`.

**Which tracker.** GitHub is the published statement: it is what a stranger
reads to judge whether the project is alive and honest, so it stays signal-only
and free of internal detail. Forgejo is where the same finding can name a path
under `~/projekte`, a container on `192.168.178.x`, or a passenger name off a
test boarding pass. When a finding needs both, the GitHub issue is the short
public one and links nowhere internal; the Forgejo issue carries the evidence.

## Commit Requirement

**Every change must be committed before the session ends.**

## Architecture

```
backend/src/
  index.ts           # Express app entry
  routes/            # Route handlers — one file per domain
  middleware/        # auth, rateLimit, error
  services/          # Business logic
  schemas/           # Zod validation schemas
  utils/             # Helpers (logger, password, …)
  db.ts              # Prisma client singleton

frontend/src/
  pages/             # Route-level components
  components/        # Reusable UI components
  store/             # Zustand state stores
  lib/               # API client (api.ts), logger
  hooks/             # Custom React hooks
  i18n/              # react-i18next translations (de/en)
```

## The server is the record — the Companion is only a companion

Owner, 2026-10-20, verbatim: „denke daran das der Server immer alles wissen muss
die App ist nur ein Companion".

Everything a client collects or derives — the Companion first of all — reaches
this server. A client holds a cache, never the only copy; the server is what
the web shows, what backups carry and what survives a lost phone. A feature
whose data lives on a device only contradicts this rule and needs the owner's
explicit answer. The server side of a Companion feature is part of the feature,
not a follow-up.

First case: the Companion's trip recording was designed device-only
(`adrs/0013` point 4 in the Companion repo) and has been reversed —
companion#73 (upload) and forgejo#316. Slice 1 of forgejo#316 is built
(`routes/trips/tripRecording.ts`: receive, store, show on the trip map, GPX
export; spec `docs/superpowers/specs/2026-10-20-trip-recording-server.md`).
Slice 2 is open: the stay and place suggestions do not yet read the recorded
points as evidence.

## Language Policy

- **Code, comments, commits, CHANGELOG, README, ADRs, runbooks — always
  English**, globally (see `~/.claude/rules/common/language.md`).
- **Frontend user-facing copy**: German primary, English secondary. DE and EN
  move together — enforced by `frontend/src/i18n/__tests__/localeKeyParity.test.ts`,
  because a missing key is silent: react-i18next renders the key itself, so a
  German string added without its mirror ships `tours.leg.mode.ferry` as copy.

## Critical Gotchas

These are design invariants — facts about how this code is wired, not rules.
The rules, and which of them a machine actually holds, are in **Rules —
enforced, and merely practised** below.

- **NEVER run `taskkill`** — do not kill processes (`node.exe` or
  any other). If a port is busy, ask the user to handle it.
- **Pino logger** — `utils/logger.ts` exports the logger as a **default**
  export, so import it as `import logger from '../utils/logger'` (there is no
  named `logger` export; the named exports are the category loggers like
  `httpLogger`, `parserLogger`).
- **Prisma JSON fields** — cast via
  `as unknown as Prisma.InputJsonValue`, never directly from
  `Record<string, unknown>`. Since Prisma 7 `Prisma` and `PrismaClient` are
  imported from the project re-export `backend/src/prisma.ts`, never from
  `@prisma/client`: that package's types still say
  `export * from ".prisma/client/default"`, a path the `prisma-client`
  generator never writes, so importing from it yields nothing and every
  `Prisma.*` type quietly becomes an error. The JSON null sentinel is
  `typeof Prisma.JsonNull` — `Prisma.NullTypes` is a value in 7, not a
  namespace of types. The client itself is generated TypeScript under
  `backend/src/generated/prisma` (gitignored; `postinstall` and the Docker
  build both run `prisma generate`), and a client is only ever constructed
  through `createPrismaClient()` in `backend/src/prismaClient.ts`, because the
  Rust-free client needs an explicit `@prisma/adapter-pg` driver adapter.
  Client middleware is gone: `db.ts` logs through a `$extends` extension, and
  a test that needs to watch queries uses `observeQueries()` from `db.ts`.
- **deck.gl + MapLibre** — use the `MapboxOverlay` + `useControl`
  pattern (NOT the `<DeckGL>` React component — causes a WebGL
  conflict with MapLibre 5.x).
- **GeoJSON coordinates** — come from `geometry.coordinates`
  (LineString), NOT from `departureAirport.lat/lon` (which are
  unpopulated).
- **Auth cookie** — the JWT is an HttpOnly cookie (not a bearer
  token). Set `withCredentials: true` on every Axios instance.
- **Prisma migrations** — schema changes always via
  `npx prisma migrate dev` (never manually), and `npm run check:drift` must
  stay green. **One documented exception kind: a rename Prisma cannot see.**
  `20260925230000_loyalty_memberships` (lodging_memberships →
  loyalty_memberships) is hand-written, because `migrate dev` reads a renamed
  model as "drop the old table, create a new one" — every card, and with it
  every stay's link to its card, would be gone. The rule for any such
  migration: it ships with a test that replays the file against rows in the
  OLD shape and asserts they survive
  (`backend/src/__tests__/migration.loyaltyMemberships.test.ts` is the
  template), and `check:drift` stays green afterwards, so the hand-written SQL
  and `schema.prisma` provably describe the same database. The time model's
  `20260927014000_time_model_backfill_marker` is the second one: its
  migration, `rollback.sql` and `undo-backfill.sql` are replayed by
  `backend/src/__tests__/migration.timeModelBackfillMarker.test.ts`.
- **`partialIndexes` preview feature (since 2.7)** — `schema.prisma` enables
  it so a partial unique index can be declared: the lodging-chain catalogue is
  unique by name among its owner-less rows only
  (`@@unique([name], where: raw("user_id IS NULL"))`), which a plain
  `(user_id, name)` unique cannot hold because Postgres treats NULLs as
  distinct. It also makes Prisma see the hand-written partial index on
  `flights` (2026-05-03), which is now declared in the schema — without that
  line the next migration would drop it. If `check:drift` ever reports a
  partial index as a difference, treat it the way a postgis diff was treated
  under `postgresqlExtensions`: a real signal (the feature or a declaration
  went missing), not noise to be migrated away.
- **React hooks** — `useTranslation` is imported from
  `'../hooks/useTranslation'` (a project wrapper), not directly from
  `react-i18next`.
- **Beta gating (since 2.4.0)** — unfinished features register in
  `frontend/src/config/betaFeatures.ts` and hide behind `betaFeaturesEnabled`
  (admin_settings column, default `false`). **That registry is the list — do
  not restate it here.** Each entry carries `why` it is hidden and
  `returnsWhen` it may come back, and a test fails when either is missing.
  Un-gating deletes the entry AND every `isFeatureVisible` call it named. The
  flag is instance state, never persisted client-side (`partialize` in
  `settingsStore.ts`). A `returnsWhen` that has come true is a reason to ask
  the owner, not a licence to ship (the 2026-09-01/-05 episode is in
  `docs/claude-md-history.md`).
- **Map colour modes (since 2.4.0)** — flight and cruise colouring are
  explicit modes (`lib/flightColor.ts`, `lib/cruiseColor.ts` + their
  Zustand stores). Layers AND the legend must resolve colours through
  these stores — never hardcode an arc colour or legend swatch, and never
  let a view/tab decide the mode implicitly. That implicit override is
  exactly the bug 2.4.0 removed.
- **Domain gating** — every new page/feature must check
  `useEnabledDomains()` on the frontend; every new parser-target must register
  in `backend/src/shared/domains.ts` and its frontend mirror at
  `frontend/src/shared/domains.ts`. Shared code paths iterate
  `AVAILABLE_DOMAINS`, never `enabledDomains.includes('flight')`.
- **Shareable entry types are one registry** (since 2026-10-16) —
  `backend/src/services/sharing/shareTypes.ts`. A domain added to
  `shared/domains.ts` is classified in `DOMAIN_SHARING` (shared, or why not);
  a table with a `share_key` column needs a `SHARE_TYPES` entry and an adapter, or
  `shareTypes.test.ts` fails (a missing `DERIVE` entry is a type error). Bus was missing from ten
  hand-kept lists until forgejo#288 and ferry carried a `share_key` nothing read.
  Every write to a shareable table propagates or carries
  `// share-exempt: <reason>` within the two lines above it.
- **Cruise stops (3-state invariant)** — each stop is exactly one of:
  (1) **matched port** — `portId` set, `isAtSea = false`,
  `unresolvedPortName = null`; (2) **sea day** — `isAtSea = true`,
  `portId = null`, `unresolvedPortName = null`; (3) **unresolved port** —
  `portId = null`, `isAtSea = false`, `unresolvedPortName` non-empty (an
  imported port that couldn't be matched to the catalog; the name is
  preserved, the stop stays a port call, the user resolves it later). Zod
  (`schemas/cruise.ts`) rejects any other combination. An unresolved stop
  counts as a port call (`totalPortCalls`, frontend `countUniquePorts`) but
  is coordinate-less, so it's excluded from legs/distance/map. `dayNumber` is
  the day of the cruise, not a list position (owner, 2026-09-19, forgejo#126):
  the stops editor keeps each stop's day, gives a new stop the day after the
  one before it, and pushes a stop moved behind a later day to the next free
  day (`components/Cruise/cruiseDayNumbers.ts`). It also clears
  `unresolvedPortName` whenever a stop becomes a matched port or a sea day.
- **Dashboard is multi-domain** — `frontend/src/pages/DashboardPage.tsx` is a thin shell delegating to per-tab components under `frontend/src/components/Dashboard/tabs/`. Tab modes are domain-scoped via `frontend/src/types/dashboard.ts` (no more global `VisMode` union). URL carries tab + mode (`/dashboard/<tab>?mode=<mode>`); `localStorage` remembers the last mode per domain. `MapContainer3D` uses a private `MapMode = "routes" | "heatmap" | "trips" | "globe"` internally; retired modes `hexagon`, `contour`, `columns`, `trip-routes` are gone. Flight-only modes are opt-in via `showInternalCruises={false}` on FlightsTab.
- **Cruise sea-routes** — `backend/src/services/schematicRouter.ts`: coarse
  1° A* water path → Douglas-Peucker to 3–8 waypoints; raw port coords are
  always first/last; a disconnected sea yields a straight `[dep, arr]` chord.
  Successful routes live in `RouteStore` (`services/routing/routeStore.ts`:
  hot in-memory set plus one file per route on disk) — there is no second
  cache in front of it. The frontend fetches waypoints per leg via
  `GET /api/v1/cruises/:id/geometry` and runs a Catmull-Rom spline through
  them; with no geometry, `buildCruiseArcs` (`components/layers/cruiseArcsLayer.ts`)
  draws a straight chord. `tools/sea-route-lab/` is a browser-only prototype
  bench, not wired into the app.
- **Marnet shipping-lane router is in-house** (`searoute-ts` was dropped) —
  `backend/data/marnet/marnet.geojson`, loaded by `services/marnet/marnetGraph.ts`,
  entry point `routeMarnet()` in `services/marnet/marnetRouter.ts`, used by
  `schematicRouter.ts` and `cruiseDistance/marnetCalculator.ts`. The Dockerfile
  must copy the GeoJSON into the image, or the first cruise request throws
  ENOENT.
- **Ship + Port seeds are idempotent** — `seedShipsFromCSV` /
  `seedPortsFromCSV` skip rows whose `imo` / `unlocode` already exists.
  User-added rows (`isUserAdded=true`) are never overwritten by
  re-seeding. Seed test suites wipe ALL rows in `beforeEach` and re-seed
  in `afterAll` to keep the dev DB in a predictable populated state.
- **Airline logos (chain since 2.5.0)** — `resolveAirlineLogo`
  (`backend/src/services/airlineLogo/airlineLogoService.ts`) resolves a code+variant
  through four tiers, each returning `null` on a miss so it falls through (a tier
  must NEVER return a placeholder — that invariant the whole chain rests on):
  **logostream** (premium, only where an admin key is configured) → **vendored**
  (`vendoredLogos.ts`, the ICON tier: serves ONLY `icon` + `logo-white`→`icon-mono.svg`,
  no wordmarks) → **kiwi** (`images.kiwi.com`, the keyless default for wordmark-shaped
  variants — a finished 128px SQUARE brand tile that carries its own background;
  IATA-only, unknown codes return a byte-stable grey-aeroplane placeholder guarded by
  md5) → **Daisycon** (tail net). The frontend renders the tile **BARE**
  (`AirlineWordmarkCell.tsx`): there is no manifest endpoint, no brand-colour map and
  no `isDark` luminance plate any more — all three were DELETED because painting a
  plate behind a tile that already has its own background shipped an INVISIBLE logo in
  2.5.0-beta.1 (and every unit test passed while it was invisible — so changes here get
  a browser look, not just green tests). The disk cache (`logoCache.ts`,
  `getCachedLogoEntry`) is stale-while-revalidate: it always serves cached bytes and
  kicks a background refresh when stale, coalesced per key. Entries carry
  `fetchedAt`/`lastAttemptAt`/`source`; a FAILED refresh writes only `lastAttemptAt`
  (never `fetchedAt`), so a stale entry stays visibly stale for the next retry instead
  of freezing forever. A nightly cron sweep (`jobs/airlineLogoRefreshScheduler.ts`,
  3 AM UTC) plus an admin re-sync (`POST /admin/airline-logos/refresh`) keep stored
  logos current.
- **Schema drift** — ask `npm run check:drift`, not a doc. `prisma migrate dev`
  is normal (history: `docs/claude-md-history.md`).
- **Cruise parser is live** —
  `backend/src/services/cruiseBookingParser.ts` implements the full
  AIDA / TUI / generic-LLM extraction pipeline. It is wired into
  `routes/emailParse.ts` + `routes/pdfParse.ts` (both branch on
  `parsed.domain === 'cruise'` and call `parseCruiseBookingText` →
  `resolveCruiseEntities`). Sample booking emails for regression
  tests live under `test-samples/Kreuzfahrt-emails/`.
- **Immich albums** — a trip links Immich albums in one of two modes.
  **Link mode** stores zero bytes: images stream through an ownership- and
  membership-checked proxy (`routes/immich/assetProxy.ts`) with browser ETag
  caching. **Import mode** downloads originals into `getTripPhotoDir()` as
  ordinary `TripPhoto` rows, idempotent via the `(tripId, immichAssetId)` unique
  index. Three things that look wrong but are deliberate:
  (1) `POST /resync` checks `isImportInFlight(linkId)` and resolves the
  connection **before** resetting the job row to `pending` — reversing that order
  clobbers a live `running` job and then strands it forever, because
  `startAlbumImport` refuses an in-flight link.
  (2) An ENV-provided connection counts as **shared** (`isShared = source !== "user"`),
  deliberately diverging from `apiKeyResolver.hasApiKeyAccess`.
  (3) **Egress depends on who owns the connection** (forgejo#411,
  `services/egress/privateTargets.ts`, same rule for Dawarich). The admin-global
  tier, ENV and an admin's OWN connection keep LAN access — a self-hosted Immich
  lives on the LAN. An ordinary account's own connection reaches public
  addresses only: refused on save and on `/test` as `invalidUrl` with code
  `PRIVATE_TARGET`, and every request re-checks the URL and pins DNS through a
  guarded lookup (rebinding gains nothing). Admin status is read per
  resolution, so a demoted account loses its LAN target on the next request.
  Redirects are never followed for anyone (a 3xx is `protocol`), and the asset
  proxy forwards only `image/*` / `video/*` bodies, with an idle timeout.
  Every Immich error body uses the fixed kind vocabulary
  (`notConfigured|unreachable|auth|notFound|protocol|invalidUrl`) that the
  frontend's `immichFailureKind()` parses — prose in `{error: …}` silently
  degrades to a generic toast. `invalidUrl` (a rejected/malformed base URL, the
  user's own typo) is deliberately distinct from `protocol` (Immich answered but
  the payload/version was unexpected) so a URL typo does not send the user
  debugging their server version.
- **Comparison is two features since 2026-10-16** — `/compare` is a leaderboard of
  server-built totals (`services/comparison/**`, one `LeaderboardConsent` per direction). Across
  servers those totals travel Ed25519-signed but NOT end-to-end encrypted (owner decision
  2026-10-16) — and they are totals only: `leaderboardSnapshotSchema` has no field that can hold
  a record. Full records travel only through the device-encrypted transfer at
  `/encrypted-share` (beta key `encryptedShare`), which the leaderboard must never import. A
  comparison consent is NOT a `ShareConsent`: that row authorises trip pushes into an account.
  The server identity lives in `admin_settings` (Admin → Instanz); `FEDERATION_ENABLED=1`
  env vars override it.
- **Two-factor and passkeys are two DIFFERENT trades** — read this before
  touching either. TOTP (`routes/auth/twoFactor.ts`) is a second factor *on top
  of* the password: the login handler answers a correct password with
  `{requiresTwoFactor: true}` plus a `twofa_token` cookie. That branch must stay
  **ABOVE** the `mustChangePassword` branch in `routes/auth.ts` — an account with
  both flags would otherwise get a `change_token` on the password alone, and
  `force-change-password` consumes only that cookie, which is a full account
  takeover. A test pins the ordering; do not "tidy" it.
  A passkey (`routes/auth/passkeys.ts`) is the opposite trade: it *replaces* the
  password AND satisfies two-factor by itself, so `login/verify` issues the
  session directly and never consults `twoFactorEnabledAt`. That is only sound
  because both ceremonies demand `userVerification: "required"` and pass
  `requireUserVerification: true` — the assertion proves possession *plus* a
  local gesture. **Relaxing that to `"preferred"` silently converts the passkey
  route into a 2FA bypass.** Sign-in is username-less on purpose (no
  `allowCredentials`), which is what lets a syncing password manager offer its
  discoverable credential. A credential is bound to ONE rpId forever, so the
  rpId is an explicit admin setting and never guessed from the `Host` header;
  origins are a list because several may share one rpId, but a bare IP is not a
  valid rpId and plain http outside localhost is not a secure context at all —
  `passkeyUnavailableReason()` says which, and the UI explains instead of
  drawing a button that always fails. The passkey login button ALSO checks
  `window.isSecureContext` — the server only knows its configured origins, so an
  instance reached over plain-http LAN would otherwise show a button the browser
  can never honour (found in beta UAT).
- **Every `/api` response is `Cache-Control: no-store` by default** — one
  middleware in `index.ts`, mounted above the health route. This is a security
  boundary, not a perf tweak: beta UAT caught Cloudflare caching an
  authenticated per-user GET (`/auth/passkeys`) in a shared edge cache and
  serving it across sessions. Authenticated API responses carry no other
  cache-control, so without this default a CDN or proxy is free to cross-serve
  private data. Handlers that legitimately cache (airline logos, the Immich
  asset proxy) set `Cache-Control: private, max-age=…` themselves and override
  the default — `private` also keeps them out of shared caches. Never add a
  blanket cacheable header to `/api`, and if a new endpoint must cache, use
  `private`, never `public`.
- **express-rate-limit 8 reads your `keyGenerator`'s source** (since
  2026-09-06, #294). It calls `toString()` on the function and, if it finds
  `req.ip` without `ipKeyGenerator`, logs `ERR_ERL_KEY_GEN_IPV6` through
  `console.error` once per limiter — at BUILD time, not per request, so a
  boot prints twenty-odd error lines and the suite stays green. The
  complaint is right: one IPv6 host owns far more than one address, so a raw
  `req.ip` key hands it a fresh bucket per request. `userOrIpKey` in
  `middleware/rateLimit.ts` masks to /56 via `ipKeyGenerator`;
  `rateLimit.ipv6.test.ts` checks the silent boot and the shared bucket. A
  new limiter with its own key function must do the same.

## Rules — enforced, and merely practised

The split is the point. An **enforced** rule has a named check that fails; a
**practised** one holds only while someone remembers it. Do not move a rule
into the first list without naming its check, and do not read the second list
as decoration — it is where the properties worth the most currently live.

### Enforced

| Rule | Check |
|---|---|
| `any` is forbidden — **frontend only** | `@typescript-eslint/no-explicit-any` (error, via `tseslint.configs.recommended`) + `eslint . --max-warnings 0`. The backend sets it to `'warn'` and runs bare `eslint src`, so a backend `any` passes. `.d.ts` is exempt on purpose. |
| No unused variables (`^_` opts out) | same two eslint configs — error on the frontend, warn on the backend |
| The tree is Prettier-formatted — code, config and YAML; `.prettierignore` names each exclusion and why | `prettier --check .` from the root (`format` job in `.github/workflows/ci.yml`, `npm run format:check`) plus a `prettier --write` pre-commit hook. The whole-tree formatting commit (`51348f58`) is in `.git-blame-ignore-revs` — run `git config blame.ignoreRevsFile .git-blame-ignore-revs` once per clone |
| DE and EN move together | `frontend/src/i18n/__tests__/localeKeyParity.test.ts` — reads the namespace list from the filesystem, so a new namespace is covered the day it is added, and keeps no allow-list |
| No source file over 800 lines | `scripts/check-file-size.mjs` (`npm run check:size`) |
| Coverage does not fall below its recorded figure | `scripts/check-coverage.mjs` vs `scripts/coverage-baseline.json` — required in both the Vitest and the Jest job. Recorded 2026-09-16: frontend 56.21 % lines, backend 76.35 % (forgejo#62) |
| `schema.prisma` agrees with `prisma/migrations` | `backend/scripts/check-schema-drift.ts` (`npm run check:drift`, root or backend) — replays the migrations into a shadow DB (`--from-migrations`), so the answer does not depend on which branch your dev database last saw |
| No NEW axe (WCAG 2.x A/AA) violation on the main routes, and no fixed one left in the list — a ratchet | `e2e/a11y.ratchet.spec.ts` vs `e2e/a11y.baseline.json` (`Accessibility ratchet (axe)` step of the `e2e` job in `ci.yml`). Per route and rule id it fails on more nodes than recorded and on an entry that no longer violates; `A11Y_UPDATE=1` rewrites the baseline and refuses to raise it. Today: the logo link and the account button name their visible text wrongly (`label-content-name-mismatch`, every route) and dimmed-off domain rows lack contrast |
| Every served endpoint appears in the OpenAPI spec | `backend/src/__tests__/openapi.coverage.test.ts` vs `services/openapi/pending.ts` |
| Every documented 200 carries a JSON schema | `backend/src/__tests__/openapi.responseSchema.test.ts` vs `openapi.responseSchema.baseline.json` |
| Every beta gate key is registered, with a reason and an un-gating condition | `frontend/src/__tests__/config/betaFeatures.test.ts` — source-scans for `isFeatureVisible("…")` |
| `/api` answers `no-store` unless a handler opts into `private` | `backend/src/__tests__/apiNoStore.test.ts` |
| A frontend test reaches no network, and renders no NEW `act(...)` warning | `frontend/src/__tests__/setup.ts` — the network half fails outright; the act half is a ratchet against `consoleActBaseline.json` (34 files frozen 2026-09-15). It does not fail on a stale entry, on purpose: an act warning is timing-dependent, so that half would be flaky. |
| 2FA is asked before a forced password change | `backend/src/routes/__tests__/twoFactor.login.test.ts` — "asks for the second factor even when a password change is also due" |
| No private key, no conflict marker, no >15 MB blob in a commit | `.pre-commit-config.yaml` |
| A router answers in ONE response shape — bare or `{success, data}` — per `docs/adr/0001-api-response-shape.md` | `backend/src/__tests__/apiResponseShape.ratchet.test.ts` vs `apiResponseShape.baseline.json` — a new router file must be assigned a family; a bare-family router gains no envelope; the twelve frozen leaks only shrink |
| The leaderboard never imports the encrypted share | `frontend/src/components/encryptedShare/__tests__/separation.test.ts` |
| The leaderboard metric catalogue is the same on both sides | `frontend/src/shared/__tests__/comparisonMetrics.mirror.test.ts` |
| Nothing outside `shared/time/` reads the host's zone — host-local `Date` getters/setters, `new Date(y, m, …)`, `toLocale*String`/`Intl.DateTimeFormat` without `timeZone`, date-fns `format` / date-fns-tz, and the clock in status files (ADR 0002, D6) | `scripts/eslint/timeRules.mjs` — `time/no-host-local-date`, `time/no-zoneless-format`, `time/no-zone-library`, `time/no-ambient-now` — as errors in both eslint configs. Both trees reached zero offenders in phase 4 and their `eslint-suppressions.json` files are gone, so a new offender fails outright; a tree that ever needs to freeze offenders again uses ESLint bulk suppressions in its own `eslint-suppressions.json` (a fixed one leaves a stale entry that fails until `--prune-suppressions`). Rules and ratchet tested in `frontend/src/__tests__/lint/timeRules.test.ts` |
| A test's verdict does not depend on the host's zone | `frontend-tests-tz` / `backend-tests-tz` in `.github/workflows/ci.yml` re-run (the Forgejo workflow, written by Paperclip, does not carry them — owner, 2026-09-27) the suites under `TZ=Pacific/Kiritimati` and `TZ=America/St_Johns`; `scripts/check-tz-ratchet.mjs` vs `scripts/tz-failures-baseline.json` fails on a new failure and on a listed test that now passes |
| Server and web answer every time question the same way | `shared/time/vectors.json`, run by `backend/src/shared/time/__tests__/vectors*.test.ts` and `frontend/src/shared/time/__tests__/vectors.test.ts`, each also under the two odd zones |
| Every table with a `share_key` column is a registered shareable type, every domain is classified, every type has an adapter and is named in the DE+EN inbox, every fact field has a DE+EN label | `backend/src/services/sharing/__tests__/shareTypes.test.ts` |
| Every write to a shareable table propagates or names its reason at the call | `backend/src/services/sharing/__tests__/propagate.writePaths.test.ts` |

Six of these are **ratchets** carrying a list of today's offenders — file
size, OpenAPI coverage, OpenAPI response schemas, response-shape leaks, the
time lint suppressions and the odd-zone failures. Each
fails on a *stale* entry as well as a new one, so the list can only ever
shrink. The act ratchet is a seventh and the one exception: it fails on a new
offender but only PRINTS on a stale entry, because the thing it measures is
timing-dependent and a flaky guard is worse than a weak one. The coverage
ratchet is an eighth and holds a number rather than a list: a fall of more than
0.25 pp fails, a rise only prints a request to `--update`, and `--update`
refuses to lower. The frontend figure counts EVERY source file
(`coverage.include`); without that, v8 left the 93 untested modules out of the
denominator and reported 65.81 % instead of 56.21 %.

**Where they run.** The pre-commit hooks and two workflows are automatic.
`ci.yml` runs typecheck + lint for both trees, Vitest, the backend Jest suite,
Prettier on the whole tree, `check:size` (`static` job) and `check:drift`
(its own `schema-drift` job, the only static check that needs a database) as
required jobs; the Jest/Vitest ratchets ride along with the suite that owns
them. `security.yml` runs `npm audit` on production deps (the gate) and Trivy
(a report in the job log) on every push to `main` and weekly. Nothing is
uploaded to GitHub code scanning, and its CodeQL default setup is off (owner,
2026-10-25); Dependabot alerts, security updates and secret scanning are on.
A red job is never normal.

**One drift script:** `backend/scripts/check-schema-drift.ts` replays the
migrations (`--from-migrations`); `npm run check:drift` at the root delegates
to it. A postgis diff is a real signal, not noise.

**The 800-line number is ratified** (owner, 2026-09-05). Files already over it
are frozen in `scripts/file-size-baseline.json`: a listed file may shrink,
never grow, and leaves the list at 800 or below — `--update` refuses to raise
an entry.

History of all three (red CI until 2026-09-16, the two drift scripts, the
provisional number): `docs/claude-md-history.md`.

### Practised, not enforced

Nothing fails when these break. They are also the candidate list for the next
check.

- **Abstention is a result.** A value that cannot be derived is null or
  absent, never zero. `shared/flightDuration.ts` states it: "`null`, not 0 — a
  zero would silently drag every average down, which is the bug this file
  exists to end". `utils/continents.ts` returns null for a transcontinental
  country with no coordinates rather than putting Vladivostok in Europe.
  `services/punctualityStats.ts` keeps "no delay recorded" (null, out of the
  sample) apart from "on time" (inside a 15-minute grace band) and names no
  best/worst airline under three flights. `shared/lodgingTiming.ts` carries
  `nightsKnown` because a same-day stay and an unknown span are both 0, "and
  an average over the second is a lie". `routes/stats.ts` names the
  anti-pattern it removed: a year-over-year delta on a lifetime set, "a
  comparison that could not exist, presented as data".
- **A counting rule has exactly one home.** `shared/lodgingCounting.ts` owns
  "does this stay count" (owner rule: not until check-out is past),
  `shared/placeCounting.ts` the same for places, and
  `shared/statusDerivation.ts` / `shared/flightDuration.ts` /
  `utils/continents.ts` their own questions. `continents.ts` exists because
  two byte-identical copies "carried a 'keep both in sync' comment and had
  already drifted". Most are mirrored backend↔frontend with a "change both
  together" header, and **nothing checks the mirrors** — each side has its own
  test asserting the same truth table, which is a convention, not a guard.
  `shared/flightCounting.ts` is the same move for flights.
- **Comments justify, they do not describe.** The good ones name the defect
  that appeared when the code was the other way, and what it measured:
  `airportLookup.ts` on merge precedence ("measured at 242 of 878 airport
  references on a real account … the worst 1.6 km out"); `ollamaTextParser.ts`
  on why `num_ctx` must match the other two parsers (a mismatch "did not
  answer within 240 s — which is why a hotel confirmation timed out");
  `config/constants.ts` on why a seed needs 1000 rows, not one ("measured in
  the wild at 57 rows"). A comment restating the line below it is noise.
- **Never document an invariant you do not test.** The claim follows the
  test, never the other way round — an empty spec beats a confident one. The
  `/stats/timeseries` example (forgejo#46) is in `docs/claude-md-history.md`.
- **A visible change goes in the changelog, even when it is a fix.** `Fixed`
  is the largest section of 2.6.0; 2.5.1 and 2.5.2 are fix-only releases. Each
  entry is a sentence a user would recognise, then the cause — "**A backup no
  longer loses every photograph.** The file archive carried three of the six
  upload directories"; "**Flight costs are no longer added across
  currencies.** 300 USD and 300 EUR were reported as '600 €'". Never "various
  improvements".
- **A bounded query bounds the work, not the scan.** `take`/`skip` belong in
  the Prisma call, and a bound needs a total ordering to be correct — hence
  the tie-breaker in `routes/flights.ts` ("departureTime is nullable and not
  unique, so paginating on it alone would skip/duplicate rows at the 500-row
  page boundaries"). Where the sort key is derived and cannot be pushed down,
  the order is sort-then-slice and says so (`routes/lodging.ts`). Unevenly
  held: `/stats/routes` and `routes/achievements.ts` load the full set and
  `.slice()` in JS.
- **Every behaviour change ships with a test that fails without it.** Not
  test-first — the guard lands beside the change and names the bug in its
  title. Where a test cannot see the thing, say so: the airline-logo chain
  shipped an invisible logo in 2.5.0-beta.1 "and every unit test passed while
  it was invisible", so changes there get a browser look, not just green
  tests.
- **A failure must reach the user as itself — four defect classes.** On
  2026-09-26 a tester found three roadtrip defects, a review of rail (built the
  same week) found five more, and a sweep of every other domain found about
  thirty, all of the same four kinds and all with green suites, because each
  test mocked the happy path:
  1. *A picker offers a subset* — built from a derived collection (stays)
     instead of the entity (lodgings), so anything without the derived row
     cannot be found, and nothing lets the user create the missing link.
  2. *A choice does not carry its data* — picking an entity must bring
     coordinates, name, country, time zone and ids into the form; a form that
     waits on "location missing" after a pick is this bug.
  3. *A provider failure becomes silent success* — an adapter returning
     `null`/`[]`, a route answering 200 with a fallback, a dialog calling
     `onSaved()` without reading the result, or a 10 s client timeout in front
     of a server that may take longer. Every failure (network, 4xx/5xx, 429,
     auth, quota, timeout, disabled, no result) gets its own reason, and the UI
     says it. Work that can outlast the client runs as a job
     (`services/jobs/jobRegistry.ts`) the client polls.
  4. *A re-derivation destroys good data* — an edit re-fetches geometry, an FX
     snapshot or weather only when its inputs changed, and a failed re-fetch
     keeps the stored value instead of downgrading it.
  Two smaller relatives travel with them: a server's English error string
  shown raw in the German UI (send a stable code, map it to DE/EN copy), and
  local-calendar logic done in UTC. **The test for a picker, a form or an
  external call drives the failure path and asserts what the user sees.**
- **Zod at every boundary; no `console.log`; `async/await` in new code, and a
  `.then()` only with a failure path the user can see; spread instead of
  mutation; `strict: true`.** Real, and none of them checked — `no-console` is
  explicitly `'off'` in the backend eslint config, and nothing looks for an
  unvalidated `req.body`. The `.then()` rule was narrowed on 2026-10-30
  (forgejo#385): about 265 production `.then(` calls exist, and the bug is the
  `.then(set).catch(log)` idiom that swallows a failure, not the syntax. No
  mass rewrite; a touched handler converts to `try/await/catch` instead.

### Global rules this project deliberately does not follow

`~/.claude/rules/common/*` lives outside this repo and cannot be corrected
from it, so the divergence is recorded here.

- **Repository pattern** — Prisma is called from routes and services directly;
  there are no repositories, and adding a layer would be a rewrite with no
  beneficiary. Single-source-of-truth work happens in `shared/*` instead.
- **The `{success, data, error}` envelope on every response** — this API has
  two shapes, one per router family (bare for flights/stats/auth/settings,
  enveloped for lodging/places/cruises/imports), and every client including
  the Companion reads the shape its router speaks. Settled on 2026-09-05 in
  `docs/adr/0001-api-response-shape.md`: both stay, a router never mixes
  them, a new router declares its family in the ratchet baseline.
- **The model-selection table** (Sonnet/Opus/Haiku) — stale, and a harness
  setting rather than a property of this code.
- **Mandatory agent usage** ("planner for complex features", "code-reviewer
  after writing code") — solo project, no PR ceremony; the gate is the Build
  Checks list, not a roster.
- **"TDD is MANDATORY, write the test first"** — replaced by the weaker, truer
  rule above: every behaviour change ships with a test that fails without it.
  Order is not the point.
- **80% minimum coverage** — measured since 2026-09-16 (forgejo#62), and it is
  not 80: backend 76 % lines, frontend 56 %. The floor is the ratchet at the
  real figure, not a target — a fixed 80 would reward tests written to move a
  number.

### Open — do not settle these in passing

- **Which CI gates a merge.** Since 2026-09-25 there are two: GitHub Actions
  (`.github/workflows/ci.yml`, described above) and the homelab Forgejo runner
  (`.forgejo/workflows/ci.yml`, written by Paperclip, which is out of service
  since 2026-09-27; the runner itself still runs). Until the owner decides, a merge
  needs both green — a second red job that everyone learns to ignore is how
  the database jobs stayed broken unnoticed in September.

Thirteen design-system decisions of 2026-09-05 are recorded with the owner's
answer to each in `ClaudeDesign/handoff/2026-09-05-web-redesign-rueckmeldung.md`
§9 — read that table before re-opening any. Two were reversed since:
`domainColors` is out of beta (2026-09-09), and the dashboard domain strip is
hidden (2026-09-28) — "Nur" in the domain filter navigates to `/dashboard/:tab`,
whose routes must stay because eight map modes exist only on a single-domain
view (`TAB_MODE_REGISTRY` keys modes by tab). `DomainTabStrip.tsx` and
`NextUpEntry.tsx` stay unused in the tree as the way back. Bus, ferry and rental
got tab routes of their own on 2026-11-01 (forgejo#405: `bus`/`ferry`/`rental` in
`TAB_MODE_REGISTRY`, rendered by `Dashboard/tabs/OverlayDomainTab.tsx` from the "Alle"
map's overlay hooks), so every filter row's "Nur" navigates; the retired strip does
not list them. Details: `docs/claude-md-history.md`.

Two standing rules came out of the same day, and they are about the browser
build's target rather than any one screen:

- **The web build is drawn for iPads.** The phone is the Companion's job
  (owner, 2026-09-28), so a phone-width regression is not a release blocker
  while a tablet one is.
- **Touch sizing follows the POINTER, not the width** (`useCoarsePointer`).
  A tablet is wide and finger-operated at once, so a width test alone served
  every iPad 40 px rows and a 36×21 px button — measured on four iPad
  viewports. The sheet/dropdown choice still follows the width
  (`useIsPhoneViewport`); only the target sizes follow the input.

## Version

Source of truth: `backend/VERSION`.

Version bumps and changelog entries are managed by the `/deploy` skill
— do not edit them manually.

## Security

How it is built (facts): JWT in an HttpOnly cookie, never `localStorage`;
Zod schemas at every boundary, in `backend/src/schemas/`; Prisma everywhere,
so no hand-built SQL; React's own escaping and no `dangerouslySetInnerHTML`;
`express-rate-limit` on auth and expensive endpoints. Secrets come from a
gitignored `.env`, and the JWT/encryption keys are generated on first boot into
`/app/data/secrets/` (a subdir of the single data volume).

Of these, only two are checked: `detect-private-key` in
`.pre-commit-config.yaml` stops a key entering a commit, and
`apiNoStore.test.ts` holds the `Cache-Control` default. The rest is practised.
`scripts/security-scan.sh` is a sweep you run, not a gate.

Security findings: `PENTEST_FINDINGS.md` (when present).

## Testing

```bash
# Frontend (Vitest, no DB required)
cd frontend && npx vitest --run

# Backend (Jest, requires PostgreSQL)
cd backend && npm test -- --forceExit

# E2E (Playwright, requires a running dev server)
npx playwright test

# Everything at once
bash scripts/run-tests.sh   # alternative: scripts/run-tests.ps1
```

## Monitoring & Logs

- Logs in `data/logs/` (`app.log`, `error.log`, `http.log`,
  `parser*.log`).
- Pino structured JSON — the `LOG_LEVEL` env var controls verbosity.
- Health check: `GET /api/v1/health`.

## Machine-specific Info

→ See **`CLAUDE.local.md`** for: server IP (Underworld), SSH paths,
Docker Compose paths, local port mappings.

<!-- serena:start -->
# Serena — Code Intelligence

Serena (MCP server `serena`, user scope) gives symbol-level code navigation backed by
language servers. It detects the project from the working directory; no index to rebuild.

- Before editing a function, class or method, run `find_referencing_symbols` on it and
  report the callers that are affected (blast radius) to the user.
- Prefer `get_symbols_overview` / `find_symbol` over reading whole files when exploring
  unfamiliar code; fall back to Grep for plain text (config keys, strings, docs).
- For renames use `rename_symbol` instead of find-and-replace.
- Serena's symbol-editing tools (`replace_symbol_body`, `insert_after_symbol`, …) are
  optional; the built-in Edit tool remains fine for small changes.
<!-- serena:end -->

---

## Git-Remotes — Forgejo und GitHub (seit 2026-08-01)

Dieses Repository hat **zwei Remotes**:

| Remote | Ziel | Rolle |
|--------|------|-------|
| `origin` | GitHub | Massgeblich fuer Oeffentliches — PRs, Actions, Dependabot, GHCR |
| `forgejo` | `ssh://git@<forgejo-host>:2222/dennis/TravStats.git` | Private Vollsicherung im Haus, inkl. aller lokalen Branches |

**Regel beim Pushen:**

```bash
git push origin <branch>    # oeffentlich, wie bisher
git push forgejo --all      # zusaetzlich IMMER - alle lokalen Branches
git push forgejo --tags
```

**Dateien, die `.gitignore` hier ausschliesst** (`AGENTS.md`, `CLAUDE.local.md`,
`roadmap.local.yaml`, `ROADMAP.local.md`, Pentest-Notizen), liegen im Begleitrepo
**`TravStats-local`** auf Forgejo. Git kennt keine Ignore-Regeln pro Remote,
deshalb der Umweg.

> **`CLAUDE.md` gehoert NICHT dazu** — sie ist hier getrackt und oeffentlich, trotz
> der `*.md`-Regel in `.gitignore`. Sie reist also als normaler Commit nach GitHub
> und Forgejo. Eine Kopie im Spiegel waere eine dritte Quelle, die nur driften kann;
> am 25.08.2026 war genau das der Fall. Gemessen: `CLAUDE.md` und `README.md` sind
> die einzigen zwei Dateien, die beide Repos tracken — beide sind vom Spiegeln
> ausgenommen.

### Der Spiegel wird von JEDEM Rechner gepflegt (seit 2026-08-25)

`TravStats-local` liegt als Schwester-Checkout **neben** diesem Repo
(`../TravStats-local`) und traegt `sync-local-mirror.sh`:

```bash
cd ../TravStats-local
./sync-local-mirror.sh status   # was weicht ab, wer ist voraus
./sync-local-mirror.sh pull     # Spiegel -> Arbeits-Checkout (Sitzungsanfang)
./sync-local-mirror.sh push     # Arbeits-Checkout -> Spiegel (Sitzungsende)
```

**`pull` beim Sitzungsanfang, `push` am Ende — auf beiden Rechnern.** Das ist
kein Ritual, sondern die einzige Absicherung: ein `git pull` traegt diese
Dateien nie, weil sie hier ignoriert sind.

Das Skript bricht ab, wenn beide Seiten sich bewegt haben, statt zu kopieren
(Hintergrund: `docs/claude-md-history.md`).

Nicht gespiegelt, mit Absicht: `README.md` (gehoert dem Spiegel),
`.roadmap/*` (Leitstand-Build-Artefakte), `.claude/settings.local.json` und
`.claude/worktrees/*` (maschinenspezifisch).

**Secrets gehoeren in keines von beiden** — dafuer ist Infisical zustaendig
(CT 141, `<secrets-host>`).

Vollstaendiges Konzept: `D:\Projekte\CC\docs\git-konzept.md`

## Brand: `<abrechen2 />` (mandatory)

This project is part of Dennis Wittke's **abrechen2** umbrella brand. Everything
published from here must carry at least the `<abrechen2 />` wordmark (below
120 px the `<a2/>` icon or the round `<a2>` avatar) **linking to
https://www.abrechen2.de/** (EN `/en/`). The project keeps its own name and logo;
abrechen2 appears as the sender: "Ein Projekt von `<abrechen2 />`" / "A project
by `<abrechen2 />`".

- Brand guide: `\\CARDINAL\Unraid\Abrechen2-Brand` (`/mnt/user/Unraid/Abrechen2-Brand`
  on Cardinal): logos, favicons, templates, fonts, `05_Brand-Guide/brand-guide.pdf`
- Never typeset the logo in a font (JetBrains Mono turns `/>` into a ligature).
  Use the outlined SVG paths from `01_Logo/SVG`.
- Only `<` and `/>` in the accent colour: Glut `#C2410C` on light, Signal-Orange
  `#FF7A1A` on dark. Minimum width 120 px. No gradients, shadows or outlines.
- Imprint and privacy policy live on www.abrechen2.de, not in this project.
- Full rule: `~/.claude/rules/common/branding.md`
- **Here:** the About section of the web app shows the credit. travstats.de shows it in the footer (TravStatsWeb `A2Credit.astro`). Privacy policy: https://www.abrechen2.de/travstats/datenschutz/ (also the App Store privacy URL for the Companion).