CLAUDE.md
Rama main · réplica de lectura del repositorio original.
# 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).