Ctrl K
ComunidadGuías…

README.md

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

Archivo original ↗Preguntar a KiBot
<div align="center">

<img src="docs/images/logo.svg" alt="TravStats" width="140" />

# TravStats

**Self-hosted travel logbook for small households and groups (1–10 users).**
A logbook first — record trips by hand, scan a boarding pass, import a confirmation email, or let the Companion app (in beta) record them on your phone — and TravStats turns them into history, stats and maps.

[![License: AGPL v3](https://img.shields.io/badge/License-AGPL_v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0)
[![Release](https://img.shields.io/github/v/release/Abrechen2/TravStats?include_prereleases&sort=semver)](https://github.com/Abrechen2/TravStats/releases)
[![GHCR](https://img.shields.io/badge/container-ghcr.io-181717?logo=github)](https://github.com/Abrechen2/TravStats/pkgs/container/travstats)
[![Docker Hub](https://img.shields.io/docker/pulls/abrechen2/travstats?logo=docker&label=Docker%20Hub)](https://hub.docker.com/r/abrechen2/travstats)
[![CI](https://github.com/Abrechen2/TravStats/actions/workflows/ci.yml/badge.svg)](https://github.com/Abrechen2/TravStats/actions/workflows/ci.yml)
[![Discord](https://img.shields.io/badge/Discord-join%20chat-5865F2?logo=discord&logoColor=white)](https://discord.gg/CRnjB9f78t)
[![Documentation](https://img.shields.io/badge/docs-travstats.de-0A7EA4?logo=readthedocs&logoColor=white)](https://travstats.de/docs/)

**[travstats.de](https://travstats.de)** ·
**[Documentation](https://travstats.de/docs/)** — installation, the parsers,
the API and day-to-day operation.

</div>

---

<p align="center">
  <img src="docs/images/globe.png" alt="Interactive 3D globe with flights and cruises radiating from a Munich hub across Europe, Asia and the Atlantic" width="90%" />
  <br />
  <sub>Multi-domain 3D globe — flights and cruises on one map, colour-coded by frequency and status</sub>
</p>

## Why TravStats

Log every flight **and cruise** you take, visualise your routes on
interactive 2D and 3D maps, collect 270+ achievements, and import flights
from boarding passes (QR / PDF417 / OCR), confirmation emails, or
Excel/CSV — all on your own server, no cloud, no telemetry.

A logbook first — you record trips by hand, scan a boarding pass, or
import a confirmation email, and TravStats turns them into history,
stats and maps. Recording is optional and opt-in: the TravStats
Companion app (in beta) can record your own flights and trips on your
phone and upload them to your own server. TravTrack is an opt-in location
history with privacy zones: positions arrive from the Companion, from
OwnTracks and Overland (a per-device ingest token, `POST /api/v1/travtrack/ingest/owntracks`
and `/overland`), or from files and a copy of your Dawarich history (below).
TravStats does not follow
other aircraft or people, and nobody sees where you are unless you
create a share link yourself. Flight data is optionally enriched from
AirLabs / Aviationstack / OpenSky, but everything you record lives in
your own PostgreSQL. No accounts on someone else's servers, no analytics,
no ads.

- 🚢 **Flights *and* cruises** — import a cruise from email or PDF: the ship, every port of call and sea day; fly-and-cruise trips link both
- 🗺️ **Multi-domain maps** — routes, heatmap, animated trips and a 3D globe for flights and cruises, colour-coded by frequency and status
- 📊 **Cross-domain statistics** — distance, countries, active days, top airlines and cruise lines, with year and all-time views
- 🏆 **270+ achievements** across flights, cruises and cross-domain — bronze to diamond
- 🎫 **Boarding-pass scanner** — QR / barcode / OCR
- 📧 **Email import** — plain text, HTML, Outlook `.msg`, `.eml`, with optional local LLM parsing via Ollama
- 📍 **TravTrack location history** *(opt-in)* — record from the Companion, OwnTracks or Overland, or import GPX / KML / GeoJSON / TCX files, a Google Timeline export or a copy of your Dawarich history; privacy zones drop fixes on the server
- 🚆 **More than flights and cruises** — rail, bus, ferry, rental cars, hotels and places, road trips and tours, each with its own map layer and statistics
- 🖼️ **Immich albums** on trips, and **trip sharing** between servers with consent on both sides
- 📑 **Excel/CSV round-trip import** — export, edit in Excel, re-import; rows with an `id` update existing flights
- 🤖 **Public REST API + OpenAPI 3.0 / Swagger UI** — Personal Access Tokens with `read` / `write` / `admin` scopes for AI agents and automation
- 💾 **Automated backups** with retention + optional WebDAV sync
- 🔐 **Invite-only by default** — toggle public registration anytime from the admin UI; JWT in HttpOnly cookies, rate limiting on every sensitive endpoint
- 🌐 **German + English UI** with browser-locale auto-detection, i18n-ready

## Is TravStats for you?

Being honest about the fit costs one paragraph and saves an evening of
someone else's time.

**It probably is, if you** want your travel history on hardware you
control; keep a household's or a small group's trips in one place; already
run Docker and Postgres, or are happy to let the bundled compose file run
Postgres for you; like filling in a logbook after the fact and getting
maps, stats and achievements out of it; and want flights *and* cruises
(and hotels and places) in one timeline rather than four apps.

**It probably is not, if you** want live flight tracking or a radar — this
records where you have been, it does not watch aircraft move; expect a
hosted account you just sign up for, because there is no SaaS version and
running it means running a server; need to onboard more than a handful of
people, since it is built and tested for roughly 1–10 users on one
instance and has no SSO, no org/tenant model and no per-team permissions;
need it on a phone as the primary interface — the web UI is responsive,
but the native app is not released yet; or need automatic, hands-off
import of everything, because a lot of travel history is typed in or
imported from files you supply.

---

## Screenshots

<table>
  <tr>
    <td width="50%"><img src="docs/images/cruises.png" alt="Cruise list across Royal Caribbean, AIDA, MSC, TUI and more with dates, ports, status and price" /></td>
    <td width="50%"><img src="docs/images/cruise-detail.png" alt="Cruise detail: a Norwegian-fjords itinerary with port timeline and sea-route map" /></td>
  </tr>
  <tr>
    <td align="center"><sub>Every cruise — ship, line, ports, sea days, status</sub></td>
    <td align="center"><sub>Per-cruise itinerary with the reconstructed sea route</sub></td>
  </tr>
  <tr>
    <td width="50%"><img src="docs/images/stats.png" alt="Cross-domain statistics: lifetime totals, countries, active days and a stacked activity-per-year chart" /></td>
    <td width="50%"><img src="docs/images/achievements.png" alt="Achievements gallery with 143 unlockables across flights, cruises and cross-domain" /></td>
  </tr>
  <tr>
    <td align="center"><sub>Cross-domain stats with year and all-time views</sub></td>
    <td align="center"><sub>270+ achievements, bronze to diamond</sub></td>
  </tr>
</table>

---

## Quick start

The only thing you set in a file is a database password. Everything else —
instance name, user cap, API keys, Ollama model, backup schedule, WebDAV —
is captured by the first-run setup wizard in the browser.

The same route with screenshots and the per-platform detail:
[Installation](https://travstats.de/docs/getting-started/installation/) and
[First run](https://travstats.de/docs/getting-started/first-run/).

### Option A — Stack (bundled Postgres, recommended)

```bash
# 1. Grab the compose file
curl -O https://raw.githubusercontent.com/Abrechen2/TravStats/main/docker-compose.prod.yml

# 2. Set one variable
echo "DB_PASSWORD=$(openssl rand -base64 32)" > .env

# 3. Start
docker compose -f docker-compose.prod.yml up -d

# 4. Open the setup wizard
open http://localhost:3000/setup
```

That's the complete install. The container seeds the airport database on
first boot (~30 s), auto-generates a JWT secret, and the setup wizard
persists instance settings to the database.

### Option B — External Postgres

If you already run a Postgres server (homelab, managed DB), skip the
bundled `db` service by providing `DATABASE_URL` and removing the `db` and
`depends_on` blocks from the compose file:

```bash
DATABASE_URL=postgresql://user:pass@postgres.lan:5432/travstats
```

### Unraid

Community Apps templates live in a dedicated repo —
[Abrechen2/docker-templates](https://github.com/Abrechen2/docker-templates).
Install `travstats-db` from there first, then `TravStats`, set the
`Database URL` password, open `/setup`. Walk-through with screenshots in
[`docs/unraid/README.md`](docs/unraid/README.md).

> **Optional local LLM parsing:** install the
> [Ollama Community App](https://unraid.net/community/apps?q=ollama)
> (or run Ollama anywhere else on your network), pull `gemma3:12b` (~7.5 GB),
> then point TravStats at it from **Admin → Parser**. Multi-flight
> confirmation mails and unknown airline templates are then handled locally —
> nothing leaves your network.

### Image tags

Both registries (GHCR and Docker Hub) carry the same digests for these
moving tags. Pick the one your platform defaults to.

| Tag | Points to | Use for |
|---|---|---|
| `:latest`, `:stable` | Newest promoted stable release | Normal production. Auto-updates to the next promoted release. |
| `:X.Y.Z` | Pinned immutable release | Reproducible installs, audit, regulated environments. |
| `:rc-latest` | Newest Release Candidate | Beta testers — receive every fresh RC via `docker compose pull`. May include breaking schema changes; an in-place backup is taken automatically on the first start of ANY new version (see [Upgrades](#upgrades)). |

<!-- These rows deliberately name no version numbers. They used to read
     "currently 2.2.0" / "currently 2.2.1-rc.1" and were still saying that
     at 2.5.2 and 2.6.0-rc.26 — hand-maintained version prose goes stale
     the first time someone ships without re-reading the README, and then
     it misinforms with an air of precision. The badges at the top of this
     file resolve live; the releases page is always current. -->

Which version those moving tags currently resolve to is on the
[releases page](https://github.com/Abrechen2/TravStats/releases) and in the
Release badge at the top of this file.

Specific RC and beta tags (`:X.Y.Z-rc.N`, `:X.Y.Z-beta.N`) and dev builds
live on GHCR only — Docker Hub only mirrors the moving tags above plus
pinned final releases.

---

## Configuration

Almost nothing to configure via environment variables. The setup wizard
captures everything instance-level (name, public URL, user cap,
registration mode) and the admin UI handles API keys, Ollama, backup
schedule and WebDAV sync.

| Env variable | When to set it | Default |
|---|---|---|
| `DB_PASSWORD` | Always — shared secret between app and bundled Postgres | **required** |
| `APP_PORT` | Different host port | `3000` |
| `DATABASE_URL` | External Postgres instead of the bundled service | *(derived from `DB_PASSWORD`)* |
| `COOKIE_SECURE` | Reverse proxy doesn't send `X-Forwarded-Proto` | *(auto-detected)* |
| `CORS_ORIGIN` | Frontend lives on a different hostname than the API | *(same-origin only)* |
| `TRUST_PROXY` | Another reverse proxy (NPM, Traefik, Caddy …) sits in front — name its address, e.g. `loopback, 192.168.1.10`, or every visitor shares one login rate limit | `loopback` |
| `TZ` | Non-UTC container clock (not recommended) | `UTC` |
| `SKIP_PRE_MIGRATION_BACKUP` | Upgrade even though the automatic pre-upgrade backup cannot be taken — see [Upgrades](#upgrades) | *(unset; only the literal `true` counts)* |

See [`.env.prod.example`](.env.prod.example) for the annotated list.

### Behind a reverse proxy or CDN

**Never let a cache rule apply to `/api/`.** Every API response — receipts,
avatars, photos, documents, exports — belongs to one signed-in user. The app
marks all of them `Cache-Control: no-store` (or `private` where a browser may
keep them), but several common proxy settings ignore that header:

- **Nginx Proxy Manager → "Cache Assets"** caches every URL ending in `.png`,
  `.jpg`, `.svg`, `.js`, `.css` … for 30 minutes, keyed on host and path only,
  ignores the app's `Cache-Control` and `Set-Cookie`, and strips
  `Cache-Control` from the response. Whatever it caches is handed to the next
  visitor, signed in or not.
- **Cloudflare** caches a fixed list of extensions by default (`.pdf`, `.png`,
  `.jpg`, `.zip`, `.gz`, `.csv`, `.xlsx` …). It honours `no-store` — unless a
  proxy in front of the app removed it, which is exactly what the NPM switch
  above does. "Cache Everything" page/cache rules and "Edge Cache TTL"
  overrides ignore the header outright.

The API itself serves no private data at a URL that ends in a file
extension: files live at extension-less URLs (`…/content`, `…/file`),
older URLs answer only with a redirect, and any other `/api/` path ending in a
cacheable extension is refused with a 404. That closes the gap for
extension-based rules, but not for a rule that caches everything. So:

- In Nginx Proxy Manager, leave **Cache Assets** off for the TravStats proxy
  host.
- In Cloudflare, scope any cache rule to exclude `/api/*` — or add a rule that
  bypasses the cache for `/api/*`.
- In any other proxy (Traefik, Caddy, plain nginx), do not put `proxy_cache`
  or an equivalent on `/api/`.

### Runtime-configurable from the admin UI

- Instance name, public URL, user cap, registration mode
- **AirLabs / OpenSky / Aviationstack** API keys (encrypted at rest)
- **Ollama** endpoint + model (default `gemma3:12b`)
- Backup schedule and retention
- WebDAV off-site backup sync (Nextcloud, HiDrive, …)
- SMTP for invitation and password-reset emails
- Logging level and retention

TravStats works without any API key; manual flight entry and boarding-pass
scanning cover the full feature set.

Every field in that panel is documented under
[Admin panel](https://travstats.de/docs/operations/admin-panel/), the optional
third-party integrations (flight-data APIs, Ollama, SMTP) under
[External services](https://travstats.de/docs/external-services/), and the
backup schedule, retention and WebDAV sync under
[Backups](https://travstats.de/docs/operations/backups/).

---

## Upgrades

**Service credentials move out of the environment (2.8).** API keys and addresses
of external services (flight data, routing, geocoder, AI providers, Immich,
Dawarich, Strava, WebDAV, federation, ferry positions and the like) are no longer
read from environment variables. The first start of this version copies each such
variable that is still set into the instance settings (only where the setting is
empty, secrets encrypted) and never reads it again — a variable set later is
ignored. Afterwards manage them under Admin › External services and remove them
from your compose file.

Starting a version you have not run on this data before takes a database
snapshot BEFORE any migration runs, into `/app/data/backups/` as
`pre-v<version>-upgrade-<timestamp>.sql`. It happens on every version change,
not only a major one: the 2.4.0 → 2.5.0 upgrade applied seven migrations, and
"major bumps only" would have skipped exactly the release that most needed a
snapshot.

**If that snapshot cannot be taken, the container refuses to start.** It
logs a `[pre-migration-backup] FATAL` block naming the cause and stops before
`prisma migrate deploy`. Nothing has been migrated at that point: the database
is untouched, and the previous image still runs against it. That refusal is
deliberate — it is recoverable, and a schema change taken without a snapshot
may not be.

The usual causes, in order of how often they happen:

- **`pg_dump` is older than the server.** `pg_dump` refuses to dump a server
  newer than itself, so the client must be at least the server's MAJOR version
  (a newer client against an older server is fine). This is the normal outcome
  of pointing TravStats at a Postgres you already run and later upgrading it.
  The fatal message names the two versions when it can read them.
- **The database container was renamed**, so the backup cannot reach it
  (`DOCKER_DB_CONTAINER`).
- **No free space** under the data volume.

To upgrade anyway, without a snapshot:

```yaml
environment:
  SKIP_PRE_MIGRATION_BACKUP: "true"
```

It boots with a warning instead of the refusal. Only the literal string `true`
counts — `1` and `yes` do not, because this is consent to migrate with no way
back. Take your own backup first, and unset it again afterwards.

---

## What's in a release

See [CHANGELOG.md](CHANGELOG.md) for the full history and
[ROADMAP.md](ROADMAP.md) for where things are heading. A native iOS &
Android app (boarding-pass scan, a 3D globe of your travels) is in the
works — no release date yet. Hotels and places, rail, bus, ferry and
rental, CO₂ figures, global search and the TravPlan planner (beta) are
already in the server and the web app.

## Known limitations

Things worth knowing before you commit a weekend to this. None of them are
secrets; they are just easier to read here than to discover later.

- **amd64 only.** The published images carry a single `linux/amd64`
  manifest. A Raspberry Pi or an Apple-Silicon Mac cannot run them
  natively — you would have to build the image yourself for your
  architecture.
- **Single instance, small groups.** Designed and tested for roughly 1–10
  users on one instance. There is no multi-tenancy, no SSO/OIDC/LDAP, and
  no per-team permission model; accounts are invite-only by default and
  managed by an admin.
- **Postgres is required.** Bundled in the compose file, but there is no
  SQLite fallback, so the database is part of what you operate and back up.
- **Enrichment depends on third-party APIs.** Flight enrichment via
  AirLabs / Aviationstack / OpenSky is optional and works on free tiers,
  which means their rate limits and occasional gaps are yours too. Nothing
  breaks without a key — you just enter more by hand.
- **Local LLM parsing needs real hardware.** The Ollama path
  (`gemma3:12b`) handles messy multi-flight mails and unknown airline
  templates. On a shared, CPU-only VM it has been measured at a fraction of
  a token per second, which times out and falls back to the template
  parser. Budget a machine with a GPU or accept the regex path.
- **Import is assisted, not automatic.** Boarding passes, confirmation
  emails and CSV/Excel are parsed for you, but there is no mailbox polling
  and no airline account linking. You bring the file or the text.
- **The native mobile app is not released.** It is in development; the web
  UI is responsive and is the way to use TravStats today.
- **Some features sit behind a beta flag.** An admin toggle hides
  unfinished areas on production instances. If something in the docs or a
  screenshot is not visible in your install, that is usually why.
- **Solo project.** One maintainer. Issues and fixes move at the pace that
  implies — see the commit history for an honest picture of it.

Symptoms that do have a fix — a container that won't start, a parser that
returns nothing, a reverse proxy that breaks the login cookie — are collected
in [Troubleshooting](https://travstats.de/docs/troubleshooting/).

## Security

See [SECURITY.md](SECURITY.md) for the hardening summary, audit history, and
verification commands. TL;DR: JWT in HttpOnly cookies, 18 distinct rate
limiters, Zod validation on every endpoint, Prisma-parameterised queries,
Helmet CSP, invite-only by default.

Report vulnerabilities via
[GitHub Security Advisories](https://github.com/Abrechen2/TravStats/security/advisories/new)
— **please do not open a public issue**.

## API for external tools

TravStats ships an authenticated REST API for AI agents, automation
scripts and integrations. Everything the web UI shows — flights,
cruises, lodging, places, trips and the statistics over them — is
reachable programmatically with the same token.

**1. Mint a Personal Access Token**: in the app, go to **Settings →
API Tokens**, give it a label and a scope (`read`, `write`, `admin`),
and copy the `ts_pat_…` value. The plaintext is shown exactly once —
only the bcrypt hash is persisted. Scopes, rotation and revocation:
[Personal access tokens](https://travstats.de/docs/api/personal-access-tokens/).

**2. Browse the spec**: open `https://<your-host>/api/v1/docs`
(Swagger UI) or fetch `/api/v1/openapi.json` for the raw OpenAPI 3.0
document. Request and response shapes come from the same Zod schemas
the backend validates with, so a documented field cannot describe a
shape the server rejects. Coverage is enforced separately: a test walks
the live route table and fails the build when an endpoint ships without
a spec entry. The admin API and the first-boot wizard are excluded on
purpose and named as such — they are not integration surfaces. A prose
walk-through of the endpoints, pagination and error shapes is at
[REST API](https://travstats.de/docs/api/rest-api/).

**3. Call it**: every endpoint accepts the token via the standard
`Authorization` header.

```bash
TOKEN="ts_pat_…"

# List your flights
curl -H "Authorization: Bearer $TOKEN" \
  https://travstats.example.com/api/v1/flights

# Create a flight (write or admin scope required)
curl -H "Authorization: Bearer $TOKEN" \
     -H "Content-Type: application/json" \
     -d '{"departure":{"iata":"FRA","lat":50.0379,"lon":8.5622},
          "arrival":{"iata":"JFK","lat":40.6413,"lon":-73.7781},
          "departureTime":"2026-05-01T08:00:00.000Z",
          "arrivalTime":"2026-05-01T17:00:00.000Z",
          "flightNumber":"LH400"}' \
  https://travstats.example.com/api/v1/flights
```

Pass `?merge=true` on `POST /flights` to enrich an existing matching
flight (boarding-pass re-import, email confirmation upgrade) instead
of creating a duplicate. Read-only tokens are blocked from mutating
endpoints with 403; admin endpoints additionally require an
`admin`-scoped token even if the owning user is an admin.

Token requests get their own per-token rate-limit bucket so an
aggressive script can't lock the user out of the web UI.

## Contributing

Bug reports, feature ideas and pull requests are welcome. The
[Report Bug](https://github.com/Abrechen2/TravStats/issues/new/choose) button
in the app copies an anonymised diagnostic bundle for you.
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full guide.

## Development

```bash
npm run install:all     # install backend + frontend
npm run dev             # backend :8000 + frontend :3000
npm run typecheck       # tsc --noEmit on both
npm run lint            # eslint on both
npm run test:frontend   # Vitest (backend tests need Postgres)
```

### What CI checks

[`.github/workflows/ci.yml`](.github/workflows/ci.yml) runs typecheck and
lint on both trees, the Vitest suite, and a Prettier check scoped to the
frontend files a change touches. The Jest backend suite runs there too
against a throwaway Postgres, but is marked advisory rather than blocking:
it currently has pre-existing test-isolation failures that have nothing to
do with the change under review. The measured numbers and both root causes
are written at the top of that job.

[`.github/workflows/security.yml`](.github/workflows/security.yml) runs
`npm audit` over production dependencies (blocking at *critical*), plus
Trivy, whose report is printed into the job log; nothing is uploaded to
GitHub code scanning.

Locally, [`.pre-commit-config.yaml`](.pre-commit-config.yaml) runs
typecheck and lint for whichever tree a commit touches, alongside the
formatting and hygiene hooks. Install it once with `pre-commit install`.

Deep-dive developer reference: [CLAUDE.md](CLAUDE.md).

---

## License

Copyright © 2026 Dennis Wittke · **AGPL-3.0-or-later**

You may use, modify and redistribute TravStats, but if you run it as a web
service (even modified) you must make the complete source code of your
modifications available under the same licence. See [LICENSE](LICENSE).

### Third-party data and assets

- **Airline logos** — [soaring-symbols](https://github.com/soaring-symbols/soaring-symbols),
  MIT, © 2024 Anh Thang. Vendored under `backend/data/airline-logos/` so a
  self-hosted instance gets logos with no API key and no external request.
  *The logos are the trademarks of the respective airlines. They are shown to
  identify the carrier of a flight (nominative use) and neither TravStats nor
  the asset authors claim any right in them, nor imply any endorsement by or
  affiliation with the airlines.*
- **Airports** — [OurAirports](https://ourairports.com/), public domain.
- **Shipping lanes** — Eurostat *marnet* network, © European Union.
- **Rail stations** — [Trainline stations](https://github.com/trainline-eu/stations),
  ODbL 1.0. A filtered copy (stations with a position that Trainline offers
  in its own search, plus their parents; nine columns) is vendored under
  `backend/data/rail/`, which carries the licence and how it was built. That
  derivative is itself available under the ODbL.
- **Train lookup and traced lines** — [Transitous](https://transitous.org/)
  and the timetables of [its sources](https://transitous.org/sources/), used
  for open-source, non-commercial purposes as its terms ask; German journeys
  can also be looked up through [db-rest](https://v6.db.transport.rest/). Both
  are asked only when a user presses "look the train up", and an admin can
  switch either off.

## Support the project

TravStats is a solo side project. If it's useful to you,
[a small donation via PayPal](https://www.paypal.com/donate?hosted_button_id=HW9MPYVURCT42)
keeps the lights on for AirLabs quota top-ups. ❤️

---

<div align="center">

**[⭐ Star on GitHub](https://github.com/Abrechen2/TravStats)** ·
[Releases](https://github.com/Abrechen2/TravStats/releases) ·
[Roadmap](ROADMAP.md) ·
[Issues](https://github.com/Abrechen2/TravStats/issues)

<sub>Made with ❤️ and a bit of AI for flight enthusiasts.</sub>

</div>

### Ferry data sources (beta)

Ferry terminal search uses the configured Photon service with OpenStreetMap's
`amenity=ferry_terminal` filter. The editor can switch back to general place
search, coordinates or a map pin when a terminal is missing. Provider outages
are shown separately from an empty search result.

In **Ferries → Add crossing → Read ferry ticket**, upload a text PDF/EML or
paste ticket text. A preview shows each proposed value with its source text.
English/German labelled tickets work without AI; unfamiliar layouts can use the
existing admin-enabled, consented AI provider chain. Scanned PDFs need pasted
text or manual entry; an EML currently reads the message body. Dates without a
printed year and ambiguous numeric dates are not guessed. Confirm port positions
before saving. One crossing is created per save, and an uploaded original is
attached to it. Cancelling leaves the original in the existing unfiled-document
inbox, subject to its normal retention rules.

Optional live vessel snapshots use [AISStream](https://aisstream.io/documentation).
Enter the AISStream key under Admin › External services,
then enter the ship's nine-digit MMSI on a crossing's detail page and request a
position. The first MMSI that answers is stored on the crossing (`vesselMmsi`)
and later lookups read only that vessel; edit the crossing to change it. This is
an explicit current-vessel lookup, not automatic ship matching or a historical
journey track. Requests wait at most 15 seconds, share requests for the same MMSI
and cache answers for 30 seconds. An account holds at most one open lookup and a
backend process at most two; beyond that the answer is `busy`.
No signal is distinct from a stopped ship. Receipt time and source time are
separate; no observation time is invented when the source omits it. No historical
crossing, distance or status is changed. No API key is sent to the browser.

### Maps in web and app

The vector maps use OpenFreeMap by default (Liberty, Positron, Dark, Bright).
No map server of your own and no API key is needed. The client loads map data
directly from the provider, which sees the IP address and the requested map
tiles. Trip overlays stay in the client. Attributions must not be removed.
Provider terms: https://openfreemap.org/tos/ and
https://openfreemap.org/privacy/. Availability is not guaranteed; offline
downloads are subject to separate limits and are not offered by this integration.

Operators may optionally set public HTTPS style URLs:
`MAP_STYLE_STANDARD_URL`, `MAP_STYLE_LIGHT_URL`, `MAP_STYLE_DARK_URL`,
`MAP_STYLE_VOYAGER_URL` (historical style ID for Bright). Do not use
credentials, query parameters or fragments: these URLs are public. Alternative
providers need matching usage rights and CORS for web and app; their styles must
reference every source, font and sprite they need correctly.

`GET /api/v1/map-config` returns version 1 and `styles` for clients. The web UI
loads the same configuration through `/api/v1/map-config/runtime` before the app
starts. The mobile map integration supports older servers without this endpoint
through the OpenFreeMap default. Changes to the server environment take effect
after an authorised restart; mobile code changes need a new app build.

The web dashboard, globe and detail maps share the same basemap catalogue.
Detail maps follow the saved basemap, place-label and relief settings, including
changes received through web preference sync. Existing style IDs and route,
marker and colour preferences are retained; `voyager` displays Bright.
Satellite and relief still use Esri and the OSM raster option remains available.