# 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/` off `main` | Any bug fix with blast radius | | Small feature | `feat/` off `main` | Isolated enhancement | | Large / long-running feature | `dev/` 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/` 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/ git merge main # last sync # run full tsc + tests + manual smoke git checkout main git merge --no-ff dev/ # explicit merge commit — no fast-forward /deploy ``` ### Short-lived fix/feat branch workflow ```bash git checkout main git checkout -b fix/ # … edit, commit … git checkout main git merge fix/ # fast-forward is fine git branch -d fix/ /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`) 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 # 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 # 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`. 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 `` 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: ` 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/?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 — 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. --- ## 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@:2222/dennis/TravStats.git` | Private Vollsicherung im Haus, inkl. aller lokalen Branches | **Regel beim Pushen:** ```bash git push origin # 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, ``). Vollstaendiges Konzept: `D:\Projekte\CC\docs\git-konzept.md` ## Brand: `` (mandatory) This project is part of Dennis Wittke's **abrechen2** umbrella brand. Everything published from here must carry at least the `` wordmark (below 120 px the `` icon or the round `` 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 ``" / "A project by ``". - 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).