CHANGELOG.md
Rama main · réplica de lectura del repositorio original.
# Changelog
All notable changes to TravStats are documented here.
Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
## [Unreleased]
**Before upgrading to 2.8.0-rc.28:** take a backup. Thirty-three new migrations since 2.7.0: two tables for the TravTrack diagnostics log, new columns on the admin and user settings for the ADS-B providers and the AeroAPI plan, two tables that count AeroAPI calls and track fetches, and the external-services policy (a policy column on the admin and user settings, a table for stored test results, a switch for a local custom AI endpoint), and the columns that receive the one-time copy of the service environment variables, a table for place captures from the Companion, the per-area reminder preferences (filled once from each account's current reminder settings), and one that clears the favourite airline "Lufthansa" that earlier versions stored as a shipped default (any other airline you chose stays), and one that replaces seven single-column user indexes covered by wider ones and adds an index for TravTrack point sync, and one that adds search indexes for the airport search, and two for this candidate: tags on lodging houses, places, roadtrips and tours, and a share-link kind for a single cruise, stay or place visit, and five for this candidate: the mail-intake tables (forwarding address, mailboxes, allowed senders, fetched mails), a share key for roadtrips, the members and notices of a TravPlan, the guests of a TravPlan, and the simplified federation setup (a findable switch per account and a statistics level per remote share), and four for this candidate: user connections between servers (a state, a generation and the sharing switches on each remote share), plan members over a connection, the plans you are a member of on another server, and an outbox for connection notices. And two for this candidate: the mode corrections of TravTrack legs, and the admin's choice of exchange-rate source. And one for this candidate: the place names you accept for TravTrack stops. And seven for this candidate: TravTrack fixed places and their settings, lodging rating categories (the existing room, breakfast and service ratings are copied into a per-stay map; the old columns stay), own place categories, the link between a train ticket and its flight (Rail&Fly) and its opt-out, and a column on the client write references that a later migration of the same candidate drops again. The TravPlan guest tables from rc.13 are dropped again with their rows — guests were never in a final release; no other data is removed.
**Upgrading to 2.8.0-rc.15 — existing server connections ask to be confirmed again.** People who were already paired with someone on another server keep every trip shared so far, but nothing new travels until both sides confirm the connection once under Settings → Teilen and choose what it shares. Until then the statistics level of that connection is off, and leaderboard consents given to that person are withdrawn; both are set again when confirming.
**Upgrading to 2.8.0-rc.5 — external-service settings move out of the environment.** On its first start this version copies every external-service variable still set in the server environment (`AIRLABS_API_KEY`, `AVIATIONSTACK_API_KEY`, `OLLAMA_URL`, `OLLAMA_MODEL`, the Immich, Dawarich, Strava, WebDAV, federation and the other service variables) into the instance settings, encrypted, wherever the instance field is still empty, and then never reads them again. The log names the variables it copied, never their values. From then on these services are configured only under Administration → External services; database, secrets, ports, time zone, logging, backup paths and first-boot defaults stay environment variables. One new migration adds the columns the copy needs.
**Upgrading to 2.8.0-rc.6 — check your reminders.** Reminders are now set per area (flight, stay, cruise, rail, bus, ferry) under Settings → Benachrichtigungen. The upgrade fills them once from what each account had: an area that sent e-mail or push keeps doing so. Each area now has one list of lead times, taken from the e-mail switches (24 h / 2 h) where e-mail was on: an account that e-mailed only the 24-hour reminder but also had the 2-hour push keeps 24 h on both channels and loses the 2-hour push until it is ticked again. Nothing that was off is switched on. A stay's reminder is now counted back from its check-in time (15:00 in the stay's time zone when none is set) instead of firing at 08:00 on the day.
**Upgrading to 2.8.0-rc.27 — an ordinary account's own Immich or Dawarich server must be public.** A connection that a non-admin account set up for itself may no longer point at a private, loopback or link-local address; such a URL is refused when saved and every request using it fails with a clear "private address" message. The admin's global connection, a connection from the environment and an admin's own connection keep LAN access. An admin who wants a user on a LAN Immich sets it up as the global connection.
### Added
- **TravTrack has a new map page.** The map fills the width under a time bar (previous/next day, date, Tag · Gestern · 7 Tage · Monat · Jahr · Eigener, the day's coverage, last seen); layers, the day in numbers, the day log with legs, stops and logbook, and the replay sit in cards over the map, and a picked leg or stop opens its detail in the day log. On an iPad in portrait the cards fold away and the day log becomes a sheet. Export and deleting a range moved to the settings.
- **TravTrack is its own settings page**, replacing "Standort": recording and devices, fixed places, privacy zones, recognition and suggestions, sources and imports, export, deleting a range and the diagnostics. Old links to "Standort" land there.
- **TravTrack knows fixed places.** Home (from your residence, for the day shown), work and any number of other places, each with a radius and an optional weekly time window, plus — when switched on — the lodging you booked, from check-in to check-out. Everything recorded inside such a place shows as one stop named after it; the points themselves stay stored. Edit them on the settings page with a small map, or in the Companion.
- **Rail and bus segments in a flight booking become rail and bus rides** (Lufthansa Express Rail, AIRail, Express Bus and the like): they leave the flight kilometres, the flight CO₂ and the passport's airports, and stay in the trip. A flight already stored that is such a segment is offered in the inbox for conversion — never converted silently; converting moves its documents, carries taxes and fees into the price, and either completes in full or changes nothing.
- **A train ticket links to its flight (Rail&Fly).** An imported ticket that ends at the departure airport, or starts at the arrival airport, within a day of the flight is linked to it, whichever of the two arrived first; both pages show the link.
- **Lodging ratings have categories you choose.** Ten built-in categories can be switched on and off, and you can add your own; switching one off never rewrites an older stay's overall rating. The statistics show each category on its own, the spreadsheet import and the trip file carry them, and "Nichts zu beanstanden" now asks for at least three rated categories, all at 5.
- **Places can have your own categories** (Schwimmbad, Freizeitpark …) with an icon; deleting one moves its places to "Sonstiges" and says how many.
- **Roadtrips edit more easily.** A live nights count with a chip per station, a station added by clicking the map (named from the place search, or said so when nothing is found), road or ferry per leg, a warning before unsaved changes are left, starting from a lodging, a GPX file or a Google or Apple Maps link, a round-trip switch, campsites in the station search, a GPX export and the weather per station on request.
- **A package booking without a template can be read by the AI on request.** "Mit KI lesen" on a kept PDF turns it into a trip proposal you review before anything is saved; booking references, flight numbers, hotels and prices that are not in the document are dropped and listed.
- **Mit wem unterwegs shows the kilometres flown together and the first and last journey together**, each linked to its entry.
- **Flight insights show what the data services recorded:** time in the air from take-off to touchdown, the countries on the flight path and the codeshare flights with the airlines that actually flew them, each with how many flights it comes from.
- **Cruise insights show money and ships:** cost per sea day, the most expensive cruise, spending per cruise line, the biggest ship and the average ship age at sailing.
- **The spreadsheet export has sheets for trips, bookings, place lists and documents** (documents as a list only, never the files).
- **Smaller additions:** afternoon departures, flights without a seat number, stays still planned and undated nights are now shown; a record for the longest ferry crossing; the place lists have their own tab next to Orte; an ordinary account can download a diagnostic package with its own data only.
- **TravTrack (beta) shows a day as legs and stops.** The Track page lists each stretch of movement with its mode, distance, time and speed, and the stops between them; a leg can be picked in the list or on the map, which zooms to it. The mode comes from a logbook entry where one covers the leg, otherwise from the phone's motion, and reads "unbekannt" rather than guessing; it can be corrected per leg and reset to automatic. Points show their speed and altitude, range views carry distance, moving time and top speed, and the calendar flags days with a trip or a flight.
- **Admins choose the exchange-rate source** (#391). Administration → External services offers Frankfurter (ECB) by default, AllRatesToday or a custom address, plus an optional fallback; each provider stays within the time left for the whole lookup.
- **The import log is a table** with sorting, a column picker and paging; a run's date and contents stay visible in a narrow row.
- **A roadtrip cost can be pinned to the leg between two stations** in the web dialog, as the Companion already could.
- **TravTrack (beta) guesses a leg's mode from its speed when the phone sent no motion.** A steady walking, cycling or driving pace names the leg, always marked "unsicher"; train, bus, boat and flight still come only from a logbook entry or your correction, and a stretch that mixes paces stays unknown. Older days and imports benefit at once.
- **The Companion can send how you moved.** With "Bewegungsart erkennen" switched on in the app, each recorded point carries the phone's motion class, and legs are corroborated by it instead of by speed alone.
- **A TravTrack leg can be replayed and its points shown.** The leg card plays just that leg, shows its recorded points, and the map offers an accuracy filter and speed-scale presets (Zu Fuß, Rad, Auto, or scaled to the day).
- **TravTrack (beta) suggests a name for each stop.** A stop in the day list shows its best suggestion — a known place or lodging within 100 m first, then the nearest real destination such as a charger, supermarket, restaurant, hotel, office or bank; streets, vending machines and parcel lockers are never offered — with "Übernehmen" to accept it and "Andere Vorschläge" to pick another. An accepted name is stored on the server, travels in the export, and can be removed again.
- **Bookings can arrive by mail.** Settings → Mail-Eingang gives each account a forwarding address on the instance mailbox, or connects its own IMAP mailbox. Mails from allowed senders become proposals in the inbox — nothing is entered without your OK — and the original mail is attached to the entry it creates. Senders are checked by the receiving server's DMARC verdict, not by the From line alone.
- **Servers connect once, people connect to each other.** An admin pairs two servers once under Administration → Instanz — by entering the other server's address and key, or by scanning its QR code with the Companion (the Companion confirms with the admin's password, and a TOTP code where set). After that, people on the two servers can send each other a connection request under Settings → Teilen (beta, behind the federated sharing switch); it takes effect only when both sides have confirmed. Each side chooses, before and after confirming, what it shares over the connection: trips offered to the other person, plans, statistics (off, overview or full), achievements and the leaderboard. Either side can end the connection at any time; a server whose key or address changes asks both people to confirm again, on both servers, and ends the plan memberships over that connection until then.
- **Two servers on the same home network can connect.** Federation needs a public https address; an operator who runs two servers on one LAN can now list its address ranges in `FEDERATION_PRIVATE_PEERS` (for example `192.168.1.0/24`). Servers in those ranges may then use http, their own port and a bare IP address. Unset, nothing changes; loopback and link-local addresses stay refused either way, and a range that would include public addresses is ignored.
- **Connected people can see each other's achievements.** With the achievements switch on for a connection, the other person sees the badges you have earned and your totals under Settings → Teilen → Verbindungen → "Erfolge ansehen" — nothing else. A settings change that could not be delivered to the other server within 14 days now asks both sides to confirm the connection again instead of being dropped silently. The admin QR code says that it does not expire and must be scanned again after a key or address change.
- **Plans across servers.** A plan owner can invite a connected person on another server as a co-planner. The member sees and edits the plan from their own server and from the Companion — the plan itself stays on the owner's server, which checks every change — and can leave it again. A trip still offers "Auf anderem Server" to share it with a connection.
- **TravPlan (beta) plans together.** A plan can have co-planners who may do everything except delete it, and a notice board. A ferry booking can be read straight into the plan. The guest link of rc.13 is gone: people on another server join a plan through a connection (above).
- **Roadtrips can be shared** like the other areas, and travel along in the exchange file.
- **External services live in one place.** Administration → External services and Settings → Services list everything TravStats does not do itself — flight data, maps and routing, photos and location, AI, open data, sync and mail — in one style. Each entry has an info panel (why TravStats needs it, what it costs, where to get a key, its quota, what leaves the server), a status that says whether it is switched on, set up and last tested OK, and which key actually answers: your own, the instance's or the server's. "Recommended" marks what gives the best experience for free. Standard hides only URLs and source order; Expert shows them. An admin "off" now stops a service for every account, including own keys, the server key and background jobs. AeroAPI's budget sits in its own row.
- **Cloud AI needs each account's own consent.** An admin allows a provider for the instance; an account's documents go to it only after that account agreed, per provider and revocable. A remote Ollama and a custom endpoint count as cloud, and a changed address withdraws the consent.
- **adsb.lol and ADS-B Exchange can name the aircraft of a flight in the air right now.** Both are off until an admin switches them on under Administration → External services (adsb.lol by a switch, ADS-B Exchange with its own key). They answer only for today's flights, fill registration and type, and never overwrite what another provider supplied.
- **FlightAware AeroAPI knows its plan and its budget.** Admins pick Personal, Personal with feeder credit or a custom monthly budget; the instance key stops before the month's budget is spent and is rate-limited, while a user's own key never counts against it. Real flight tracks from AeroAPI after arrival are built in but stay off until the admin switches them on.
- **TravTrack (beta) explains its gaps.** With the diagnostics log switched on in the Companion (Settings → Location, off by default), the phone sends what its recorder, its uploads and iOS did — never a position. Settings → TravTrack → Diagnose lines one device's day up against its stored points and names, for every gap of ten minutes or more, the cause the log supports: location access not "Always", recording paused, fixes dropped on the phone and why, an upload failed or still waiting, the app terminated, or "unexplained" — with a note when iOS was allowed to pause recording on its own. The raw log downloads as JSON; it is kept 14 days and can be deleted at any time.
- **Setup can switch on the free services in one step.** The first-run page offers a switch that turns on the services that cost nothing and need no key — adsb.lol, rail timetables, open data (weather, elevation, Wikipedia) and airline logos — and says what leaves the server for each. The usage-statistics question stays its own question.
- **A WebDAV backup can be restored from the share.** Administration → Backups lists the backups on the share; a downloaded one is checked against the checksum written at upload and then appears in the backup list like any other. Older backups without a checksum are accepted on size and marked as such.
- **The server answers the Companion's new statistics:** ferry statistics, a trip's distance across every mode of transport, and the number of roadtrips. A trip's countries now include the places you visited on it, by the same rule as the passport.
- **Reminders per area, with your own lead time and channel.** Each area can be switched on or off, reminds 24 h, 2 h or at a custom time (1–168 h) before departure or check-in, and goes to the app, by e-mail or both. "App" is a push through the server; the page says when push is not available on a device and why, and the Companion then reminds locally.
- **Unnamed "Ort jetzt" captures from the Companion arrive in the inbox,** where they can be named, linked to an existing place or attached to a visit.
- **Settings in one consistent shape.** Every page uses the same row and save model, the Standard/Expert switch states what each level shows and what the defaults are, the search finds settings by what they do, and the external-service forms test and save the same way.
- **The dashboard map can hide routes per area.** A "Routen" switch in the map panel keeps only the markers — for flights, only the airports. It is also listed under Settings → Ansichten.
- **New stays start at 15:00 check-in and 11:00 check-out,** in the stay editor and in a stay created from a roadtrip station.
- **Download your own data.** Settings → Backup → "Meine Daten herunterladen" saves everything the account holds as one export file, the same one an admin export produces, without asking an admin. At most three downloads per hour.
- **Stored details now show on the detail pages.** A flight shows its check-in desk, baggage belt, runway times, taxi and air time; a ferry its fare class and delay; a rental its supplier, broker, booking and contract number; a cruise its ship's build year, size and capacity; a tour its route notes. Rail, bus, ferry and rental show the converted price with its exchange rate, as stays already did. Empty fields stay hidden.
- **Settings that did nothing now act.** The auto-update expiry hours set how long a pending flight update stays open (it was always 24 h), the cruise default line and cabin type fill a new cruise, "show cruise routes" hides cruise lines on the flat map, and the favourite airline fills a new flight (never an edited one).
- **A stay reminder carries the stay,** so the Companion opens the right stay from the notification.
- **What the Companion records now shows on the web.** Settings → Profil shows and edits your home place and passport expiry; a trip page shows its packing list and keeps it in step with the app (an edit made in the app meanwhile reloads the list instead of being overwritten).
- **Ferry and rental catch up with the other areas.** Both forms take tags and pick companions from your list; the detail pages show the tags; a public trip link counts ferry crossings and rentals and draws their legs.
- **Tracks, lines and places download as GPX or KML.** A recorded flight or cruise track, the stored line of a rail, bus or ferry ride, and your places (all, or one list) as waypoints. Only the owner can download; an entry without a line says so.
- **Server features that had no button now have one.** Visit-photo suggestions can be refused ("Nicht diese") and offered again; visit photos can be linked to Immich from the Immich row under Services, as a job you can watch; pending flight updates can be taken or dropped all at once; flight enrichment lists its candidates and enriches one flight with a stated reason when it cannot; admins can set the instance's Dawarich connection, start the country-days sweep and reload the airport, airline and aircraft catalogues.
- **Settings are ordered like the menu you read.** Account, display, the travel areas, trips, companions, loyalty, location, views, data, privacy and services; old links still open the right page. The location page uses the same rows as every other page, with the one-off tools folded away.
- **Settings, rebuilt from the ground up.** One settings area for everyone; admins find the server's pages in the same menu ("Server · nur Admin") instead of a second admin area. Every setting is one row with a short name, at most one line below it and the rest behind "?". A change saves at once; if saving fails your choice stays, the row says why and offers "Erneut versuchen" or "Verwerfen", and nothing pending is lost when you leave the page. Groups that save together (profile, mail server, credentials) ask before you leave them unsaved. Dangerous actions (deleting, restoring, revoking every public link) say what is affected and how much, and some ask you to type a word. The search finds every setting, Expert ones too, without changing your level. All external services live on one Services page, one card per service, with your own rows on top and the server's below for admins. Old addresses keep working.
- **Delete your own account.** Profil & Anmeldung → "Konto löschen" removes the account and all its data immediately, after your password (or a passkey older than a day) and, where set up, your second factor. The dialog offers to download your data first. The demo account and the last admin cannot delete themselves.
- **One home with its history.** Where you live is the home of its period: moving starts a new period, and past trips keep counting from the home they started at. The Companion reads and edits the same record.
- **Search everything from the top bar.** Press "/" to search flights, cruises, stays, places, rail, bus, ferry, rentals, tours and trips at once; hits are grouped by area and open the entry.
- **Statistics across every way you travel:** longest run of days on the road, hours shifted across time zones, "not seen for N years" per country, airport and place, travel by season and weekday, price per km for rail, bus and ferry, nights per country, a Schengen 90/180 counter and photo statistics. Each figure opens the entries behind it.
- **CO₂ for every mode,** from the UK government's 2025 conversion factors: per ride, per trip and per mode on the overview, "–" when a distance is unknown, a cruise as a lower bound, and an estimate of what the train saved against flying.
- **TravTrack in the year review and badges:** days and kilometres recorded, top speed, highest point and time per way of moving (privacy zones and GPS spikes left out), four TravTrack badges, and the altitude in GPX exports.
- **Import check-ins from Träwelling and travelynx,** and Google Timeline / Location History (old Records.json, semantic history and the new phone export), GeoJSON, KML and TCX into TravTrack. Files stay on your server; privacy zones and duplicates are handled like a GPX import.
- **TravPlan route plans on the web:** create a route, compare variants, assign ideas to a day by drag or tap, see ten-year climate means per station, and hand a plan's stations over to the roadtrip, ferry and stays in one step. A handover keeps what the phone already wrote and only withdraws stops nobody touched.
- **Tags on lodging houses, places, roadtrips and tours,** in the forms, the detail pages and the spreadsheet export.
- **A public link for a single cruise, stay or place visit,** next to the existing trip link.
- **Bus, ferry and rental join the rest:** a dashboard tab of their own, trip proposals, reminders (rental pick-up and return, roadtrip stations, planned place visits), badges with deeper levels (thresholds are a proposal) and forms that offer earlier entries as chips.
- **Roadtrip and tour kilometres and roadtrip nights in the year review,** and a year recorded only by TravTrack now gets a year review of its own.
- **Planned place visits in the calendar feed;** a rental shows the trip photos taken between pick-up and return; a house shows its stay documents; a visit's rating is editable on the web.
- **Merge the copies of a duplicate cruise** from data quality, and assign existing flights to a booking from a trip's bookings panel.
- **Maintenance can fetch a missing airport** and says whether the source was unreachable or does not know the code.
- **The globe draws its dots, cruise paths, night shade and connecting lines as native map layers,** on the dashboard and on the trip map.
- **Flight-track upload and the calendar subscription are out of beta** and available on every instance without the beta switch.
- **The parser page explains itself:** what it reads, where an unrecognised booking goes (a link to the template workshop) and how to share a new template as a request.
- **Settings search can show every hit,** not only the first eight; the data page opens with a jump list to Sicherung, Mitnehmen and Einlesen; the services page can show only the services that are active or set up.
- **Flight updates in the inbox can be applied or rejected all at once.** The button acts on the updates currently shown, so a filtered list is handled in one step.
- **A roadtrip's planned vehicle can be changed** after the trip exists, not only when it is created.
- **Admins can re-seed the airport, airline and aircraft catalogues** from Maintenance and run the country-day sweep as a job that reports its progress, instead of waiting for the next start-up.
### Security
- **A user-entered Immich or Dawarich address can no longer reach the server's own network.** An ordinary account could point its Immich URL at a server that redirected the photo proxy to internal addresses and read the answers. Private targets are now refused for ordinary accounts, redirects are never followed, the photo proxy passes only images and videos (no SVG) and drops a stream that stalls for 60 seconds.
- **Changing or resetting a password signs out paired devices and API tokens.** Before, only browser sessions ended, so a device paired by whoever had the account kept working after the reset. Unused pairing codes are dropped as well.
- **Unexpected server errors no longer show internal details.** A 500 now answers with a fixed message and a request id; the detail stays in the log.
- **The login page no longer reveals the admin's e-mail address**, which anyone could read without signing in. Tokens are signed and checked with an explicit algorithm.
- **Flags, certificate fonts and map styles load from the server itself.** Flags came from flagcdn and the certificate's fonts from Google, which sent every visitor's address to them without consent. Dependencies with known high-severity issues were updated, and CI now fails on high-severity audit findings and scans commits for secrets.
- **Registering a passkey needs a fresh password check, and a young passkey cannot confirm a deletion.** Adding a passkey now asks for the password again, and a passkey younger than a day cannot confirm an account deletion; a backup code confirms either the way a password does.
### Fixed
- **A fixed place's radius and schedule wrap on narrow rows**, and the /track time bar stays on one row on an iPad in landscape; the privacy-zone heading comes before its explanation.
- **A TravTrack day spent in one place shows that stop.** A phone at rest sends few positions, and a silence of more than two hours cut the day into single fixes too short to count as a stop; such a day showed none, even inside a fixed place. A silence is now bridged for stops — but only when nothing was removed inside it (privacy zone, pause, deleted range or a single deleted point), never across devices, and a name within 500 m of a privacy zone is not shown.
- **The stay reader no longer names a hotel group the document does not mention.** It used to map brands such as Holiday Inn or Courtyard to their group on its own.
- **An own flight template that reads only a route lets the next reader try**, as the generic reader already did.
- **"Stunden in der Luft" was block time.** The figures from gate to gate are now called flight time; "time in the air" is the new take-off-to-touchdown figure.
- **A TravTrack day recorded only coarsely shows its stops and legs**, with the mode left unknown instead of guessed.
- **The lime list colour no longer looks like a roadtrip on the map.**
- **"Tage unterwegs" counts train, bus and ferry days.** Rides were left out of it; a ride now counts its departure and arrival day on the stations' calendars, for each area that is switched on. The number rises for accounts with rides on days nothing else covered.
- **TravTrack stop names respect the stay-suggestions switch.** With it off, a stop offers only your own places and lodgings nearby and asks no outside service, and the row says so instead of "no name found".
- **A TravTrack day recorded only coarsely is drawn again.** Fixes worse than 1 km are left out only where finer ones exist on the same stretch.
- **TravTrack diagnostics name a stop the app made itself.** A gap that starts with the Companion ending its own recording now reads "recording paused" with the app's reasons (plan verdicts, location access, foreground or background), in German or English.
- **No page is wider than a 320 px screen.** The header search field was 13 px too wide on the narrowest phones.
- **TravTrack's speed guess works on real drives.** Speeds now come from windows of at least 30 seconds instead of single fix pairs, so 20 m of GPS noise no longer reads as a sprint and stop-and-go traffic no longer hides a drive; a long stretch at walking pace stays unknown rather than being called a car ride. A leg's top speed is taken from the same windows, so GPX days and imports show one too.
- **TravTrack no longer draws outliers.** Fixes with an accuracy worse than 1 km — a cell-tower guess kilometres off — are left out of the day's line and range views, as they already were from legs.
- **A night's wandering inside a hotel no longer splits the stop.** Two dwells less than 100 m apart with nothing in between straying further are one stop, not two stops and a fake leg.
- **A picked TravTrack leg is no longer hidden behind its card.** The map frames the leg beside the card at its real height, "Ganzer Tag" ends a leg's replay, and the speed legend uses the language's number format.
- **A cancelled status and other danger red is readable on its tint** (4.5:1); the globe time scrubber no longer carries a label screen readers cannot attach. An accessibility check over 33 main routes now fails on any new violation.
- **A place's local name no longer starts with a Latin copy of itself** when OpenStreetMap lists several scripts.
- **Demo cruise history follows the current year** instead of fixed years.
- **Stale figures after an edit are gone from the web pages** whose memoised values missed a dependency.
- **"Next flight" picks the flight that really departs next.** It compared the stored departure with the current time; for flights from the time before real departure instants were stored, that put a flight west of UTC too early or too late. It now reads the real departure instant.
- **A rail ride without a time leaves the spreadsheet as a date.** The export wrote it as a midnight date-time cell; it is now a date cell, matching how it is imported again.
- **Asiana Airlines leaves Star Alliance on 17 December 2026.** An alliance membership can now end on a date. Flights flown before the end still count for Star Alliance achievements; later ones do not.
- **The time-zone note no longer covers the sidebar.** It sits bottom right now.
- **The tours list shows its count like the other lists**, including 0.
- **Trip timeline tiles take the current domain colours** instead of the colours of 2.6.
- **Roadtrip side costs appear in the statistics.** The travel account lists ferry, toll, pitch and fuel costs per year and per currency, in the same form as the roadtrip cost panel, and never adds currencies together.
- **The connection settings look like the other settings pages.** Same rows and switches, the usual confirmation when you end a connection, framed invitations and error notices in plans, and one word for each thing ("Kopplung" for two servers, "Verbindung" for two people).
- **The save hint no longer promises that everything saves itself** on a page that has a save button.
- **One name for the travel types page:** every hint that pointed to "Bereiche & Farben" now says "Reisearten".
- **The reminder settings no longer crash** when the server sends no row for one of the newer areas.
- **A finished trip folds its packing list away,** and hides it when it is empty.
- **The flight-time estimate history follows the account** instead of staying in one browser.
- **Filling a flight only uses a hit on its own route,** and every airport source is asked before the page says it is unavailable.
- **Bulk refresh counts AeroAPI as a historical provider,** and the OpenSky "nothing to test" answer says the service is not configured.
- **Shutting down stops every scheduled job and closes the server cleanly.**
- **Areas such as markets, parks and museums are always recognised during a stay scan.** The two separate switches are gone; recognition runs whenever the stay scan runs, and suggestions still need your confirmation.
- **Large imports and busy editors no longer stall on badges.** An import checks achievements once at the end instead of after every 20 rows, and repeated edits queue at most one follow-up check.
- **The airport search answers from an index** instead of scanning the whole catalogue on every keystroke.
- **A cruise with many ports no longer fails to save.** Sea routes are measured before the save starts instead of inside it, where a long itinerary could run out of time.
- **The tour map loads its routes in one query** instead of one per route.
- **A companion typed but not confirmed with Enter is no longer lost on save,** in every form that picks companions.
- **Reminders for a switched-off area say so.** Settings → Benachrichtigungen shows such an area as off with a link to Bereiche & Farben, instead of accepting a reminder that could never be sent.
- **Rail, bus and ferry summaries count in the database** instead of loading every ride, and a PDF attachment is read once per import instead of once per reader.
- **The dashboard map's route switch also hides the native route layer,** greys out the route-only controls while routes are hidden, and `/dashboard?panel=routes` opens the panel directly.
- **Large logbooks draw the map with less memory and work:** the route cache is gone, the route store keeps a bounded hot set, and several per-frame recomputations were removed.
- **Rental, bus and ferry no longer say "(Beta)",** and the TravTrack description names its real sources: Companion, GPX, OwnTracks, Overland and Dawarich.
- **Settings without an effect are gone:** the live-tracking check interval and "only during flight" no longer appear (the API still accepts them).
- **The airport lookup no longer hangs or hammers its source.** It times out, remembers misses and reports an unreachable source as such instead of "not found".
- **A WebDAV backup that arrives cut short is refused** instead of being restored from a truncated file.
- **A device without WebGL2 sees a message instead of a crashed map page.**
- **A failed settings load no longer saves the defaults over your real settings.** The web page used to write its empty defaults back when the first load failed.
- **A backup no longer finishes as "successful" after a write error,** and an archive whose output stream fails is aborted instead of left half-written. A rental's receipts are filed in the same transaction that creates the rental.
- **Large backups and exports stream instead of being held in memory,** so they no longer exhaust the server on a big logbook. WebDAV uploads send an explicit length, a download gets its own side file, and a stalled transfer is stopped by a watchdog.
- **Streamed exports strip private share keys** the way every other API response already did.
- **A provider outage no longer ends a flight's live tracking.** The automatic update sweep treated a failed lookup as "flight finished"; it now retries, and it passes the departure airport so the right flight is found.
- **Statistics no longer show a page of zeros when a calculation fails;** the page reports the error instead. The cruise series counts completed cruises, and eight separate copies of the "does this flight count" rule are now one.
- **Admin settings no longer show every API key as "not configured" after a failed read.**
- **Airline logos missing during a provider outage at start-up come back** instead of being remembered as "no logo" for a day.
- **The nearest-airport search near a pole and with odd inputs** returns airports instead of nothing or an error.
- **Lists the server cuts short now say so:** a trip's photo gallery shows "first N of M", and the trip list, Dawarich stays, Immich albums and the parse-log export report when they were truncated.
- **Deleting a flight or cruise updates its trip's status and badges** in the same step.
- **A failed load says so instead of looking empty:** the dashboard no longer offers "add your first flight" when flights failed to load, the all-domains map names the domain that failed, and the flight panel's trips tab, the cruise map, the up-next card and the settings page show an error with retry. A failed settings read no longer stores a false snapshot, and a blocked auto-save is reported.
- **The globe draws every cruise,** also with more than 100.
- **Flight times in the year report and the map panel follow the shared duration rule,** and the report marks a total that contains estimated legs.
- **A failing Immich import stops polling and names its failure;** the ferry editor shows why a save failed.
- **Tag suggestions include ferry rides and rentals;** a bulk flight lookup validates its input, uses your own keys and reports provider failures.
- **External services no longer mention server variables, and no field shows a raw label.** The source-order line and every reference to environment variables are gone from both pages; fields such as the Ollama address and model have their German and English labels, and a test now fails on any field without one. AISStream has its own row.
- **WebDAV sync and download no longer fail in the browser while the server is still working.** Both run in the background; the page follows their progress and names the reason when one fails — sign-in, storage full, timeout, unreachable, cut short or checksum mismatch.
- **Lists the server cuts short say so on the page:** the trip list, a trip card whose countries may be incomplete, the Dawarich stay inbox, an Immich album and the parse-log export.
- **Large logbooks load faster.** Trip, flight and statistics queries read only the rows and columns they need, the airport, airline and aircraft searches filter in the database, and the geocoder caches are bounded. A finished trip is now marked completed within the hour rather than within five minutes.
- **Maps and long lists stay smooth.** Map layers are no longer rebuilt on every animation frame, the live slider and replay redraw less often, and the activity list shows 200 entries with "show more".
- **Setup no longer redirects past a failed step** when the page was left before its timer ran out.
- **The trip list searches and filters on the server,** so a trip beyond the first 500 is found, and "mehr laden" loads the next trips in the order the list shows.
- **A settings change made just before leaving the page is saved,** and two quick changes can no longer overwrite each other.
- **A pasted booking text that yields no flight no longer says it was a mail.** The import page named the wrong source after an empty parse.
- **First journeys keep early trips.** The first-journey figures no longer drop early trips, and path countries fold country codes and names into one entry.
- **A section created inside a trip keeps its tags** instead of losing them on save.
## [2.7.0] - 2026-10-29
TravStats 2.7.0 is the largest release since lodging became a domain. Web and
app now share one look; every figure in the statistics can show the entries it
is made of; documents and receipts live on the entry they belong to; forms
suggest what you entered before; places, photos and the passport grew up; rail, bus,
rental cars and ferries are their own domains; and TravPlan, which plans a
trip ahead, and TravTrack, your own location history on your server, wait
behind the beta switch together with roadtrips and tours. The forty audit
fixes first written for the never-promoted 2.6.3-rc.1 ship here too, further
down this entry.
**Before upgrading:** take a backup. Coming from 2.6.3, the first start runs
166 migrations, one of which rewrites stored cruise statuses from `flown` to
`completed`; it also recalculates stored cruise legs along the sea route and
re-routes stored straight ferry crossings once in the background, which can
take a minute on a large logbook. A database restored from before the update
is the way back; the previous image alone does not know the new columns.
From 2.7.0-rc.27 nothing changes: 2.7.0 is that release candidate, retagged.
### Added
- **A shared trip's dates sync to every member; its name does not.** Moving a shared trip's period moves it for everyone on the trip, each member gets an inbox notice they can undo, and each member keeps their own name for the trip.
- **TravPlan is switched on and off per account,** beside the domains in your settings, like any other area. Accounts that already plan with it keep it on.
- **Country and continent badges count ground contact.** A bus or ferry end, a service area or a fuel station places you in a country and on a continent, each piece of evidence on its own continent; a service area or fuel station counts as ground contact only, never as a visit.
- **Both odometer readings prove a finished roadtrip driven.**
- **TravTrack shows as soon as an admin switches it on,** and says how data comes in.
- **The "what's new" dialog ends with a note from the developer** after the 2.7 highlights.
- **TravTrack (beta): your own location history on your server.** The Companion records into it; a map of days and ranges, explored areas, monthly values, stays, a flight panel with the altitude profile and the photos taken on board, imports from GPX and a Dawarich copy, privacy zones that delete and never draw what lies inside them. Behind its own instance switch and the beta key.
- **Inbox questions from TravTrack show their stretch of track:** a small map, from–to, duration, distance and why the question was asked.
- **Duplicates are merged from the inbox, with undo.** Keep both, merge into one (refused with a reason when the copies carry different recordings or trips), and a ferry crossing inside a trip is offered to that trip. Dismiss, keep and link can be undone for ten minutes.
- **Statistics for one year.** Every statistics endpoint the Companion reads takes a year and says which year it answered for; the passport counts a country in every year with evidence for it.
- **Tram and U-Bahn rides run on their own tracks.** OpenRailRouting is asked with a tram or subway profile instead of the mainline one, so a U2 ride no longer detours over S-Bahn tracks; without such a profile the ride keeps a straight line with a reason. Rides can be routed again, one by one or all at once by an admin. Setup: docs/features/rail-routing.md.
- **The calendar subscription shows local times.** Flights and rides carry the departure and arrival time zone and the local time in the title ("✈ Jeju 12:05 → Gimpo 13:10 · KE1206"); the link prefers a host name over an IP and warns when Google cannot reach it; the Companion can add it to the phone's calendar.
- **A setup checklist for server-to-server sharing** in Admin → Instance, and the trip's share menu says why sharing with another server is not available yet.
- **TravPlan has its own switch** in Admin → Instance (still beta).
- **A place's visits, redesigned:** planned visits first, past ones by year, undated last, dates in your format, trip, photos, documents and rating on the row.
- **A rail ride knows whether it was a train, an U-Bahn, a tram or a bus.** The mode is shown and can be changed on the ride, and the rail statistics count trains only. Every ride stored before this version is a train.
- **A bahn.de connection's U-Bahn, tram and bus legs are reviewed in place.** The import dialog names each leg's mode; U-Bahn and tram legs can be kept as rail rides of that mode or skipped, bus legs can be saved as bus rides or skipped. Settings → Daten remembers the choice for each kind ("skip" until changed).
- **bahn.de stops are resolved from bahn.de's own station data,** so a stop named the way bahn.de prints it finds its catalogue station.
- **Settings → Mitreisende.** One page for everyone you travel with: how
often each person appears per area, rename (the name changes on every
entry), merge two spellings of the same person after a preview of what
moves, and delete people nobody is attached to. Merging is refused when
both are linked to different accounts.
- **"Mit wem unterwegs" on the statistics overview:** days, entries and
countries shared with each companion across all switched-on areas, stays
included, each figure with the entries behind it. A shared day counts
once; paying an expense alone does not count as travelling together.
- **Server connections exchange keys by file.** Admin → Server-Verbindungen
hands out a connection file (a ZIP with this server's address, public key,
fingerprint and a short DE/EN guide) and reads the other server's file
into the form. An imported server is marked "Noch nicht überprüft" until
its address and fingerprint have been compared with the other admin over
a second channel, and nothing is stored before "Server hinterlegen"; a
known server arriving with a different key is flagged. The manual fields
stay, folded away under "Von Hand eingeben".
- **Replacing the server key asks first.** When a key is already stored,
"Schlüssel erzeugen" explains that servers holding the old key refuse
this server until their admins check and store the new one.
- **Ferry crossings have a route map, the trip's photos taken during the
crossing and a chapter in the year in review.** The map draws the stored sea route, or a faint
straight line between the ports where there is none.
- **Bus rides have a route map and the trip's photos** taken during the ride.
- **Ferry and bus departure reminders.** The existing 24-hour and 2-hour
reminder switches now also mail before a ferry crossing or a bus ride,
in the port's or terminal's local time — for a domain the account has
switched on (both are still behind the beta switch), and only for a
departure with a time of day.
- **Lodging enrichment from OpenStreetMap shows what it found before it
writes:** the matched house with its OSM link and distance, and per field
what would be filled and what stays. Nothing changes until "Übernehmen";
if OpenStreetMap names a different house by then, nothing is written. The
match is stored on the lodging unless it already carries a reference.
- **Flight track upload reads FlightAware and Flightradar24 KML exports, KMZ
and Flightradar24's CSV,** reads their altitudes in the right unit, and
says why a file gives no track (still behind the beta switch).
- **Rentals appear on the statistics overview** as their own card and count
as an experience; they add no travel days and no countries.
- **Rail is out of beta.** It shows whenever an account has the rail domain
switched on, independent of the instance's beta switch.
- **Deutsche Bahn share links.** A bookmarklet (Settings → Data → Import →
Rail) takes a connection shared from bahn.de or DB Navigator
(`bahn.de/buchung/start?vbid=…`) into the rail review dialog. bahn.de is
asked from the browser on bahn.de itself, and the connection reaches
TravStats in the address fragment rather than in a request to bahn.de's
or anyone else's server; no ride is saved before review.
- **Settings closer to the agreed prototype**: tabs, breadcrumb, domain
colour dots, expert notes, search that switches to the expert level when a
hit lives there, one-open-at-a-time service accordions, larger switches for
touch, and a page for a switched-off domain that says how to switch it on.
Admin geocoder settings moved to External services → Maps & place search.
- **New account settings**: a default colour for new trips (automatic colours
now rotate the same way however a trip is created), whether sea days count
as cruise days, a "Live-Verfolgung" switch for the Companion's in-flight
recording, and seat statistics on or off.
- **WebDAV sync per account** (expert): the account's export goes to the
user's own WebDAV share (e.g. Nextcloud) daily, weekly or on "sync now",
gzipped, with a chosen number of kept versions; clean-up only ever deletes
exports this account uploaded. The last result is shown with its reason;
a run that uploaded but could not clean up says "partial". Ordinary
accounts may use public https servers only; administrators may also use a
share on their own network.
- **Admin → Hotel chains** (expert): search, create, rename, edit loyalty
programme name and brand colour, merge (every link moves along), and delete
only when nothing hangs on the chain; a deleted chain does not come back
with the next seed.
- Normal accounts can see whether keyless instance services are switched on.
- **Map cards for every ground domain.** Rail, bus, ferry and rental routes
open a card with their own statistics — first and last journey, average
duration with fastest and slowest, average delay, total distance with what
it measures (air line, along the track, estimated sea route) — and, where
one exists, a picture of the train, ship or car model with its credit.
Stations and ports open a card with a Wikipedia photo and summary,
departures, arrivals and top destinations. Hovering shows a compact
profile. Pictures and Wikipedia text use the operator-logo open-data switch;
an outage says so instead of looking like "no picture".
- **Station, terminal and port names on the map**, spaced by the same label
pass as airports and hotels.
- **Trips shared across servers** (beta, `federatedTripSharing`): pair with a
person on another TravStats server by invitation code, offer a trip, keep it
in step both ways with conflicts shown, and end, pause or resume it. A server
restored from backup, or a peer with a new key, catches up after
confirmation.
- **The Companion's trip recording reaches the server**: stored, drawn on the
trip map with a day filter, exportable as GPX and deletable.
- **Recorded flight tracks** (beta, `flightTrackUpload`): upload a GPX or KML
export and see the flown line on the flight page and the maps.
- **Calendar subscription** (beta, `calendarFeed`): a secret iCal URL for
upcoming entries.
- **Imports from other flight apps**: OpenFlights, AirTrail, Jetlog, Flighty
and App in the Air exports.
- **Passport corrections**: add, correct or exclude countries by hand.
- **Settings have one page per menu entry**, with a Standard and an Expert
level per account.
- **Template workshop**: rail, bus, ferry and rental templates, learning from
screenshots via OCR, and export for the community repository.
- Inbox: per-user switches for trip and stay suggestions; stay cards show a
map and the probable place.
- Admin: a 24-hour import mode that lifts upload and import limits; the
template repository URL is an instance setting.
- A GPS jitter filter on every recording source before anything is measured;
each recording says how much it left out.
- **Compare with friends is a leaderboard of totals.** `/compare` ranks you and
the people who agreed — kilometres, trips, time away, countries, places —
per year or overall. Each person decides which figures the other may see,
and a pending request shares nothing until it is accepted. Friends on other
TravStats servers join through an invitation code; between servers only
signed totals travel, never single trips.
- **Server connections in Admin → Instanz.** The instance's identity and the
servers it trusts are managed in one card; the key is generated there and
never shown.
- **Ferry crossings can be shared** with a trip and travel in the
`.travstats` file.
- **A trip shows its travellers with their sharing state** — linked, shares
this trip, request open — and offers one action per person. A person who
agreed but is not yet on the trip can be added from the share dialog.
- **One "Teilen" button** in the trip head for a companion's copy or a public
link.
- **Dawarich can suggest whole venues beyond markets.** A separate opt-in adds shopping malls, parks, zoos, theme parks and museum grounds. Independent overlapping venues stay reviewable, and visits are recorded only after confirmation.
- **Markets are suggested as whole venues.** With separate consent, Dawarich
scans automatically fetch public OpenStreetMap market boundaries for coarse
regions along your track. A visit across several stalls stays one suggestion,
with original point IDs, boundary sources and visible coverage gaps. Cached
maps, bounded requests and remembered decisions avoid repeated work and
duplicate visits; only confirmation records a visit. Hidden Places pause
scanning, and manual scans can review the last 7, 14 or 30 days.
- **Dawarich can suggest stays.** With your consent, Dawarich location history
is scanned for places you stayed. Each suggestion is reviewed, edited and
accepted or rejected, and a rejection is remembered. A slow scan can no
longer overwrite the result of a newer one, and a polling error disappears
once a read succeeds.
- **Buses are part of "Upcoming".** They appear with the same day-by-day
countdown as trains.
- **Ferry crossings are a journey of their own.** A ferry booked with a vehicle
— operator, departure and arrival port with local times, crossing duration,
booking reference, seat or cabin, vehicle dimensions, passengers, price and
the ticket PDF — is now an entry like a rail or bus journey instead of a line
in the expenses. Ports are found by search, a ticket can be previewed before
it is saved, an optional AIS lookup shows the live position, and a crossing
can be linked as a leg of a roadtrip. The domain sits behind the beta switch.
- **A moment without a trip has a place: notes.** A note is a date (or a date and
a time on the clock of the place it was written), a text, optionally a place
and one photo — nothing else is assumed. Notes live under Mehr › Sammlungen,
appear on a trip's timeline when their day falls inside it, and are **private
by default**; a note switched to shareable is shown only where you already
share a trip (to the members of a shared trip, read-only, without position or
photo; on a public link only if you pick "Notizen"). Sending a note twice
from the phone creates it once.
- **A trip can be shared with a companion who has an account on the same
server.** Each person gets a linked copy; changes to the shared facts —
flights, stays, cruises, rail, coach and rental rides, stops — reach the
others as a notice in the inbox and can be undone; price, seat, booking
reference and notes stay private. Consent is asked first and can be withdrawn,
and a copy can be detached. A single trip also travels as a `.travstats` file
to another instance, through a proposal you review before anything is created.
- **Public share links** for a flight, a trip or a year in review, behind an
instance switch that is **off by default**. You choose the fields; nothing
else leaves the server (a test per field proves it), the link expires (a
flight link two hours after landing), can be revoked at once, never appears
in a server log (the token travels in the URL fragment) and carries photos
without location data: the stripper keeps a short allow-list of JPEG and PNG
parts and drops everything after the image, so the second image and the
motion-photo video a phone appends are gone too.
- **Booking deadlines.** Free cancellation until, payment due and online
check-in opening are stored on stays, rentals, bookings and flights; a
deadline known only as a day stays a day ("bis Ende 12. Oktober") and never
gets an invented time. Templates may carry them, and a re-read mail fills a
deadline only where it is empty.
- **Trip expenses grew up.** A payer (a companion or a name), the exchange rate
with its source (bank, cached or typed by hand), receipts that can be attached
while creating the cost, and 13 categories. A home-currency total counts only
what has a recorded rate and lists the rest per currency.
- **The inbox asks six more questions about your data**, as questions and never
as verdicts: possible duplicates, entries that cannot both be true (a flight
leaving before the previous one landed, two houses on one night), a missing
flight number, an unknown airport, a trip without an end, entries outside
the trip's days.
- **Every statistic says how it is counted and opens the entries behind it** —
flights, cruises, lodging, places, roadtrips, tours, rail, rental, coach
rides, the year in review and the overview: unit, time and zone rule, source,
coverage and what is left out, in one "So wird gezählt" under each block, and
a tap on any figure lists the entries it stands on. Extremes list the entries
they were taken from; every one of the 192 badge rules shows what its
progress stands on, under a work budget and a rate limit of its own.
- **The coach (bus) domain, behind the beta switch**: logbook, detail page,
trip map and timeline, statistics tab, night buses in the night account,
badges, dashboard overlay, spreadsheet round trip and sync. A ride is drawn
**along the road** when the instance has a routing service: the line is
fetched once when the ride is saved and frozen, its length becomes the
distance, and "Luftlinie" appears only for a ride that has no road line. An
instance without a routing service says so in the form and keeps the straight
line; one click routes the rides saved before ("Alle Luftlinien-Fahrten über
die Straße berechnen"). One daily budget (300 per person, 1,500 per instance,
under OpenRouteService's free 2,000) covers every call, and the shared demo
account never calls the provider. Setup: `docs/features/routing-provider.md`.
- **Photos: a nightly scan and a review of many suggestions at once.** An opt-in
switch in Settings → Externe Dienste (it says what leaves the server and how
the last run ended), and in the inbox visit suggestions show place, time span
and reasoning together; you accept, correct or reject several in one go, and
one that a logged visit already explains is flagged, not hidden.
- **Parsers moved out of the server.** Issuer readers (Berge & Meer, Booking.com,
TUI Cruises, DB, Sixt, airlines) are template files in the template
repository, read by one generic engine with a per-document time budget; the
home country orders templates and never filters them. The template workshop
derives templates for cruises, places and package tours from rows you mark.
- **Package tours import as a trip**: the operator's mail or PDF is read into a
trip with its flights, stays and total price — after a preview — and a
package booking can be entered by hand (operator, travellers, booking day).
- **Rental invoices and rental details**: single fee lines on an invoice
(refuelling, tolls, cleaning, extra km, damage handling…) are shown beside the
booked price and taken over one by one without counting the cost twice; the
provider list is data, with logos where the provider's site has one; deposit,
odometer, hand-over evidence by what it shows and a compact return card.
- **Rail**: a journey with changes is one logbook entry, the change between two
trains can be marked tight, seats and tickets per train read at a glance, a
DB share link imports a connection (and says plainly why it cannot when
bahn.de refuses), and the import review shows unread booking facts instead of
a dash.
- **Cruises**: river ships and river cruises, kept apart in the statistics (a
portless river day is not a sea day), a day card with time in port and an
all-aboard time, and a booking read again is compared with the stored
itinerary change by change.
- **Forms ask before they lose your input**: closing, Back and navigation ask
when a form changed; a field error sits at its field with a jump to the first;
a save is never sent twice, and a create whose answer was lost offers a
reload instead of a blind retry.
- **Help is reachable by tap, keyboard and screen reader**: one toggletip for
every field help; keyboard focus shows a 2 px outline on every control and
field; touch targets follow the pointer, not the width.
- **Deleting an account removes its uploaded files.** Photos, documents,
receipts and note photos go with the account; an hourly sweep moves orphaned
files to a quarantine and deletes them after 30 days — and returns them if a
row points to them again, so a database restore to an older point cannot
destroy newer files.
- **Creates from the phone's outbox are exactly-once** for rail, coach rides,
expenses, tours and notes (`clientRef`); a push reminder can be muted per trip
on each phone.
- **A stale page repairs itself**: after an update a browser holding an old page
reloads once instead of staying blank, and says so if that fails.
- **One look for web and app.** The web app now uses the Companion app's
colours, spacing and type, one heading per page, and a dark mode built from
the colour tokens. Settings are split into groups — flights, cruises and
stays each have their own page — and the admin area is a page of its own.
- **Every figure explains itself.** Clicking a statistic or a ranking row opens
the entries behind it with each one's contribution; what cannot be assigned
is named, not dropped, and the panel can be shared as a link. The running
year is compared with the same period last year instead of a whole year.
- **Documents and receipts on their entry.** Boarding passes, hotel invoices and
confirmations are stored as documents on the flight, cruise or stay they
belong to; price, currency and booking code can be taken over from them, each
value shown first.
- **Costs that belong to no booking** — tolls, ferries, parking, fuel — on a trip
or a roadtrip section, counted in the cost statistics. Deleting a section
hands its costs to the trip instead of dropping them.
- **Help while you enter.** Forms suggest seats, flight numbers, terminals,
frequent-flyer numbers, rooms, board, tags and recent currencies from your own
history; a new entry lands in the trip whose dates it falls into. A suggestion
never overwrites what you typed.
- **Loyalty programmes in one place** — airline, hotel and cruise programmes with
number and current status under Settings → Loyalty programmes, one button per
alliance, and stays, nights or flights per year that lead to the list behind.
- **Trip suggestions in the inbox.** TravStats reads from your flights, cruises,
stays and place visits when you were away and proposes a trip, or the trip the
entries belong to; nothing is created until you accept.
- **Places, passport and photos.** Places are a full domain with lists,
checklists and a CSV import that also takes a Google Takeout export; photos
hang on places, stays, flights and cruises and in the trip journal.
- **The Excel export reads back** — edit the spreadsheet and import it again, or
carry it into another account. TravStats installs as a web app.
- **AI and open data, under the admin's control.** The trip summary states only
what is in the data; an admin can switch the AI parser off entirely, use an
OpenAI-compatible provider (a provider outside your network only with explicit
consent), and switch on weather from Open-Meteo and descriptions from
Wikipedia. Dawarich location history counts as evidence for a country.
- **The Companion app** (beta): pairing by QR code; push notifications for
flight changes and departure reminders, end-to-end encrypted through a relay
an admin switches on, with FlightAware AeroAPI as an optional status source;
and a sync change log that never silently overwrites a newer version.
- **Behind the beta switch:** roadtrips and day tours with elevation profiles,
rail as a domain of its own, rental cars as a domain of their own, recorded
GPS tracks for cruises, and hotel enrichment from OpenStreetMap.
- **A new demo account** — an invented traveller from the Rhineland with ten
years of travel. A new installation creates it and switches the beta features
on as it does.
- **Add existing entries to a trip from the trip itself.** "+ Eintrag
hinzufügen" on a trip's timeline opens a picker with one tab per enabled
domain; an entry that sits in another trip says so before it is moved.
- **A train ride with changes is one logbook entry** (rail, behind the beta
switch): the list shows "Augsburg → Hannover → Bielefeld" as one row with the
total duration and the number of changes, a page for the whole ride lists its
trains, and each train keeps its own page. Statistics still count trains.
- **A weight unit for the baggage allowance.** Settings → Units & formats has a
kg/lb choice; an allowance typed as a bare number ("23") is shown with it.
- **Reminder and account mails in the app's look**, with a plain-text part,
light and dark, and more of the entry's facts (terminal, gate, seat, booking
reference; a stay's address, nights and board). Reminders go out only for
domains you have switched on; invitation and password mails are bilingual.
- **Display settings follow you to every browser.** Colours, map look, the
dashboard's domain filter, hidden statistics sections and table columns are
stored with your account instead of only in one browser. Page size and the
open map panel stay per device.
- **Place names you can read, in every script.** A place found in Seoul, Moscow,
Tokyo or Cairo is named in Latin script where OpenStreetMap has one, and the
name on the sign is kept beside it ("Gyeongbokgung Palace · 경복궁") — stored
with the place and shown in the places list, on the place page, in the trip
and on the map. A name is stored exactly as you typed it; an older place
whose two halves sit in one string is split only where the map names the
local half on its own, so "CU 삼청점" stays one name. A place saved under its
Latin name in a country with another script gets the name on the sign added
where OpenStreetMap knows the same object.
- **A visit finds its trip.** A place visit saved without a trip goes into the
trip whose days it falls on, when exactly one trip does; with none or several
it stays unassigned.
- **iPhone photos (HEIC) can be attached** to trips, places and stays. The
original is kept; browsers get a JPEG copy. Capture time and position are now
read from every uploaded photo.
- **Rental cars** (beta) record a licence plate and suggest the common
providers; rail and rental lists use the same table, header and filters as
the other logbooks, and the map settings cover tours, roadtrips, rail and
rentals — rentals optionally without the connecting line.
- **Rental cars** (beta) take the odometer reading at pick-up and return; the
kilometres driven follow from them unless the invoice states its own figure,
and a return reading below the pick-up one is refused.
- **A seat reservation booked later finds its journeys** (beta). Importing a
Deutsche Bahn reservation offers to attach coach and seat to the train
journeys already logged, shows what would change, and asks when several
journeys fit.
- **Places nearby list the important ones first.** Picking a place near you
shows a longer list with sights, stations and landmarks ahead of shops and
benches, and a place search prefers results near where you are.
- **The Companion can send a flight's recorded track and its observed takeoff
and landing**; a reported landing marks the flight as flown at once, and the
times arrive as suggestions to confirm.
### Changed
- **The production dependencies carry no known vulnerability:** DOMPurify (critical, used for PDF export), postal-mime 4.1 (.eml previews), body-parser, multer, fast-uri and zod are updated.
- **Rental cars, coach rides and ferry crossings are out of beta.** They show whenever you enable the area in your settings.
- **Completing a stay from OpenStreetMap is out of beta,** with a stricter name rule: a town or landmark word alone no longer matches a house, and English and German names are compared too. Measured on 200 real stays: 1 wrong match in 156 (was 2 in 152).
- **Detect places automatically** is on by default with the stay scan; its two area switches are expert settings.
- **The export cuts privacy zones out of older recordings** (trip recordings and flight tracks from before TravTrack). Stored data and the admin backup stay complete.
- The README and store texts describe TravStats as a logbook first, with optional recording through the Companion.
- **The lodging import preview says what each row will do.** The action reads "Aufenthalt hinzufügen", "Hotel und Aufenthalt anlegen", "Weiteren Aufenthalt anlegen" or "Aufenthalt aktualisieren" instead of a bare "Anlegen"; a matched hotel is shown with its stored name and city as plain text, with the import's spelling beside it where it differs; the summary counts what will be added, skipped and still needs a decision, and the reminder to decide only shows while something is undecided. A guessed match says what it rests on (name and city, name only, a similar name, the same place) instead of a green "Passendes Hotel gefunden"; several possible hotels offer a choice; "Keine Koordinaten" is gone from rows that join a stored hotel. "Anderes Hotel? Zuordnung aufheben" replaces "Nicht dasselbe Hotel — neu anlegen", which never created anything.
- **Recorded flight paths are off on the dashboard by default.** The map
shows routes; a recording belongs to one flight. A new map option
"Aufgezeichnete Flugwege zeigen (wo vorhanden)" draws them as thin lines on
top of the routes, which stay visible even when every flight on a route was
recorded. On the flight page the recording is shown over the
dashed great circle.
- Ferry crossings stored as a straight line are re-routed along the sea once,
in the background, after the update.
- An invitation code that is retried after it already connected reads as
"already connected".
- The end-to-end encrypted transfer moved to its own page,
`/encrypted-share`, behind its own beta switch.
- A shared trip is copied for each person on their own calendar: a copy's
status (planned, under way, done) follows the recipient's time zone.
- If the recipient already logged the same flight or crossing on another of
their trips, sharing moves that entry into the shared trip and links it
instead of creating a duplicate; the notice says so and can be undone.
- A provider change that reaches every member through the shared trip makes
one inbox item, not one per person.
- Ferry edits and deletes accept an optional read version. Conflicting writes
return HTTP 409; edits claim the version atomically in their transaction.
Older clients can continue to write without a version precondition.
- Settings expose supported travel domains independently of enabled domains,
allowing compatible clients to offer a disabled domain again.
- TravPlan uses shared theme tokens and the selected application locale for
number formatting.
- Full data export includes saved TravPlan content and proposals; daily quota counters are explicitly excluded.
- **A sailed cruise is `completed`, not `flown`.** The flight word leaked into
cruises; `flown` is still accepted on write and read as `completed`, and
stored rows were migrated.
- **Private files are served at addresses that do not end in an image or
document extension** (receipts, profile pictures, admin log pages), because a
proxy that caches by extension served them without asking the app. The old
addresses redirect.
- **Numbers follow the app's language**, never the browser's.
- **A trip's entry count and the trip page include coach rides**; the trip
export, trip merge, micro-trip cleanup, delete question, countries and the AI
summary brief know them, and so does sharing.
- **The what's-new dialog** is short again — eight items — and describes what 2.7.0
contains; a test keeps it from growing back.
- **Cruise kilometres go up.** A cruise leg is now measured along the sea route
the map draws; some legs used to count only as a straight line. Stored legs
are recalculated once on the first start after the update.
- **Restoring a backup from an older version works.** The restore empties the
schema inside its own transaction, loads the archive and brings it up to this
version's migrations before it reports success; a failed migration at startup
now stops the container instead of starting on an outdated schema.
### Fixed
- **Two members moving a shared trip's dates at the same moment both save.** One of the two saves used to fail; and an undo that raced a member's own save could overwrite it with the old dates.
- **A replace import from a spreadsheet no longer starts a backup for the demo account or beside a backup already running.**
- **Places count in every category they carry** except the transit kind (forgejo#299).
- A booked or cancelled cruise counts in no cruise figure.
- The Dutch Sixt invoice's fee lines are read (forgejo#280, forgejo#237).
- The ferry port search finds the port of the town you typed.
- The year filter applies to the route network and the route list in the statistics.
- The profile shows your real username and no placeholder e-mail address.
- Ferry crossings are offered in the map's add menu; the narrow rail list names each ride's mode and line; on a phone the TravTrack map sits right under the range control; an empty inbox shows one message with one action; flight-track gaps are drawn dashed.
- **Uploading a document from the web works again.** Every web upload had failed with "No file uploaded" since September.
- **A server no longer crashes when another server cannot be reached over IPv6** while checking it; addresses are tried IPv4 first.
- A share link of a trip without dates ends after the trip's last entry.
- Saving part of the display settings over the API no longer clears the time zone.
- Station names of rail, bus and ferry on the trip map no longer print over each other; a cruise leg's hover shows its ports and sailings.
- **A skipped row no longer fails the whole lodging import.** A skipped row with a cleared date or name made the server refuse every row; skipped rows are now sent without their fields.
- **"Auswahl importieren" is only enabled for a preview the server will accept.** Names and cities over 200 characters, negative prices and stays over ten years are flagged on the row instead of failing the import, a price without a currency is flagged on updates as well, and a row set to add a stay with both dates cleared is flagged instead of creating nothing.
- **Picking one of several possible hotels checks that hotel's stays for the same check-in,** and an update shows every changed field, an edited price or currency included.
- **Importing the same bahn.de connection twice no longer saves its rides twice.** The repeat was looked up by the printed stop name, while the first import had stored the catalogue's name.
- **Two buses or trains leaving the same stop in the same minute are no longer taken for one ride.** A ride counts as already logged only when its destination and its line or train number agree too; where one side names no line, the leg is kept ticked and marked "möglicherweise schon erfasst".
- **A ticket with a booking number finds a ride saved earlier from a bahn.de connection without one,** and a busy day no longer hides the ride sought behind older rides at the same stop.
- **A recorded flight is drawn whole.** Sparse fixes from a phone in the
cabin were dropped as single points, so LH719 Seoul-Munich showed only its
last third. Every fix is kept; recorded stretches are solid and the holes
between them dashed.
- A recording with holes no longer sets its distance against the great
circle ("−60 %"), since it measures only its recorded stretches.
- The start of a recorded flight is no longer hidden under the map
attribution on the flight page.
- TravPlan is refused by the server while the beta switch is off; before, its
endpoints answered any signed-in client.
- The import card promises AI reading only for the kinds of booking that
have it.
- The empty tours view no longer covers the list button.
- A large KML file is read in linear time instead of blocking the server for
seconds, and a very large one no longer crashes the reader.
- Lodging enrichment never overwrites a value saved while OpenStreetMap was
being asked.
- **A Deutsche Bahn share link no longer reads as a failed import.** Such a
link carries no booking reference, price or seat, so the review dialog now
says so in one line instead of flagging every empty field as "not found in
the document".
- **A train bahn.de names by number alone reads "Zug 57031"** (EN "Train
57031") on map cards, in the trip overview, the review and the ride
pickers, instead of a bare number. The number is
stored as the train number; a category is kept only where bahn.de states
one. Rides imported from a share link in rc.20 read the same way.
- Bus lines are drawn on the globe.
- A Wikimedia Commons photo on a map card carries its author and licence
where Commons provides them; a non-free local photo is not shown.
- "Last here" handles date-only rides, and no longer names an older visit as
the latest when a newer arrival's date is unknown.
- Close and toggle buttons on iPad map cards are 44 px; the AI trip-summary
offer wraps on narrow screens.
- Deleting an account also deletes its pending sync changes.
- **A global API key is no longer shown to every user.** The key-status
answer carried stored values; it now carries only whether a key is set. A
key the instance cannot decrypt no longer counts as "active for everyone".
- The trip overview lists ferry crossings, and the trip map draws rail, ferry
and rental — on the globe as well.
- Overnight journeys show their arrival day on cards and tooltips.
- A date-only journey no longer gets an invented duration.
- The "far from your destinations" hotel warning considers arrivals by rail,
bus and ferry, not only flights.
- Map labels no longer print over each other in "All" and "Important", on the
globe (where they also keep clear of the time control) and on the trip map.
- A failed route or rental load on a map says so and can be retried instead of
looking like no data.
- iPad touch targets in the menu, the recording panel and settings follow the
pointer, not the width.
- The statistics breakdown names ferries in both languages.
- A settings search result opens its folded group.
- Sharing records an entry's author only where it is known and checks a
received change's author.
- Public-link lifetimes are calculated from the server clock, preventing a fast
browser clock from rejecting “Until landing (+2 hours)” or “90 days”.
Explicit expiry timestamps remain supported for existing API clients.
- Public-link permission is now under Administration → Instance. Personal
settings retain link management and point admins to the instance switch.
- **Backup retention keeps what it should, and a remote copy is deleted only
when you say so.** Old backups were pruned by the wrong identity. A WebDAV
delete that fails with 401, 423 or 500 now keeps both copies and the
database row.
- **Rail lines are re-fetched when their inputs change, and a failed fetch
keeps the stored route.** The distance is shown consistently.
- **A year-only or month-only flight stays in its year** — in the year summary,
the time series, the passport and the flight's own times — instead of landing
on 31 December of the year before at airports west of UTC. The precision is
stored with the flight.
- **Counting rules have one home.** The busiest day, "connection", the time-travel
index (landing earlier than you left, date included), a trip's cost, a trip's
duration, "new this year" (in your zone), the country distribution (an end
without an airport code proves no country) and the aircraft registration
(one hull, whatever its case) each follow one rule everywhere they appear.
- **An empty value is shown as unknown, not as 0**: business rates, average flight
duration, a rail tile without timed rides. Seats are placed by the cabin's own
layout, or not at all.
- **A Takeout re-import no longer repeats places**: a place is found in every
spelling of its Google id, and a trip stop within 100 m on the same day is not
added twice.
- **First writes no longer race into a unique violation** (display preferences,
place-list entries): two settings saved at the same moment on a new account.
- **A fresh setup says it is set up**, the welcome hint no longer covers the
empty-state buttons, and a session that only exists as a cookie signs in.
- **Opening Settings no longer eats the limit for adding flights.** The bulk-refresh
preview shared the flight-creation limit of 20 an hour, so about twenty visits to
Settings → Konto blocked adding flights for an hour; it now has its own limit,
and the card shows one state, in plain words and in the reader's language.
- **One unreadable row no longer rejects a whole flight file**; the preview names
the line and the reason.
- **An expired reset link is said when the page opens**, with a way to a new one.
- **The trip map frames a trip's coach rides** and does not call the trip empty;
the trip card counts them; the trip export no longer leaves them out.
- **Layout**: the admin pages, the cruise statistics, a lodging tile and the
backup form no longer widen the page on a phone; the flights toolbar and
settings selects wrap.
- **Security and hardening**: a refused cross-origin request answers 403 and not
500; templates cannot impose unbounded regex cost on the server; the public
share API is exempt from the origin list on purpose (it carries no cookie and
answers no cross-origin read); dependency alerts that have a fix are patched.
- **Backups**: an archive from an older version migrates inside the restore's own
transaction, and a newer one is refused by name.
- **Smaller corrections you can see**: a train ticket with a change of trains keeps
every train; a day-only ride keeps its day in the form; the autumn hour that
occurs twice keeps its occurrence on edit; a rental's actual return can no
longer precede its actual pick-up; roadtrip station drafts survive a lost
connection and are merged before they overwrite anything; overlapping stays
are a notice on saving, never a block; two of your own places can be merged
without losing a visit, photo or list; a hovered station, train or rental on
the map says what it is; every table, dialog and list reaches the touch size
on a coarse pointer.
- **A flight is "flown" once it has landed.** It stayed "scheduled" for six
hours after its scheduled arrival. A landing reported by the live flight
data now counts at once, and a flight without live data turns an hour after
its scheduled arrival; a delayed flight the live checks are still following
is left alone until its arrival is recorded.
- **A trip lasts as long in the statistics as on its own page.** A trip from
the 15th to the 18th showed four days on its page and three as the average
trip length, because the statistics counted nights.
- **A scanned boarding pass keeps its seat and booking reference.** A barcode
read in the browser filled flight and route, but left seat and booking
reference empty although the barcode carried both.
- **An Excel export in the other language can be imported.** An English
export was refused as "no known sheets" under a German interface, and the
other way round.
- A roadtrip still underway calls its distance "Strecke · geplant" instead of
"Gefahren", which counted legs that were still ahead.
- A stay's page labels its per-night figure as the average of the total
("Ø Gesamtkosten pro Nacht"), so it no longer reads like the room rate.
- A flight imported twice says it is already in the logbook and offers to open
it, instead of asking you to check correct input.
- German counts use the singular: "1 Flug", "1 Fahrt", "1 Miete", and on the
roadtrip card "1 Nacht", "1 Station", "1 Tour".
- Rail text import keeps operator, booking number, class and price.
- The hotel and cruise import previews are reachable by keyboard; a dialog no
longer loses its focus while it saves; every field of the flight review is
named by its label.
- A roadtrip that has not started no longer claims more days ahead than it has.
- A plain flight confirmation keeps its booking reference and both times when
read without an AI model.
- The trip card counts rail and rental entries; the rental form accepts a
German decimal comma.
- **A first import of Deutsche Bahn tickets gets through.** The Online-Ticket
layout DB prints since spring 2024 is read without an AI model; before, only
tickets in the older layout were. Saving train journeys no longer stops after
twenty in an hour (now 300), and reading PDFs no longer after twenty in
fifteen minutes (now 60).
- A Deutsche Bahn ticket in the new layout WITH a change of trains keeps every
train, each with its coach and seat (rc.5 read only the first).
- **A dialog no longer closes when a text selection ends beside it.** Selecting
a field's text and letting go of the mouse outside the dialog closed it and
took the form with it.
- The flight list keeps the planned and the actual time side by side again;
the actual time had wrapped onto a line of its own.
- The stay reminder mail linked a page that does not exist.
- The rail import asks the next AI provider when the first one does not
answer. An Ollama address that was entered once and no longer runs used to
make every ticket "AI parser not reachable" while a configured OpenAI key
was never asked.
- Every parser — stays, cruises, flights and the trip summary, not only rail —
now asks the next AI provider when the first one does not answer.
- **A flight's takeoff is known while it is in the air.** One live check now
runs 30 minutes after departure; before, a long-haul flight's actual
departure arrived only shortly before landing.
- Hovering a train, a station or a rental on the map says what it is.
- A rail or rental list that cannot load says so in amber with a retry, and the
figures above those lists count the whole filtered list, not just one page.
- **Restoring a backup from an older version** runs the missing migrations in
the same transaction, so a failure leaves the database as it was; a backup
from a newer version is refused, and the server no longer starts on a
half-finished migration.
- A request from a foreign web origin is refused with 403 instead of a server
error.
- The rail form labels the arrival delay and keeps it beside the arrival time.
- The rental list names the car actually driven before the booked class, shows
a status on every rental, and a rental returned where it was picked up is
drawn as one station dot.
- A Hawaiian place name with an okina (ʻ) is no longer treated as a foreign
script.
- **A train ride with changes is one ride.** It gets one reminder and one "next
up" entry, named by where it ends ("Deine Fahrt nach Wien"), instead of one
per train; a single train is named by its destination too.
- The name on the sign is added only from the place itself: a longer map name
counts only when it adds what kind of place it is ("Jongmyo Shrine"), not
when it names another object ("Mount Fuji Weather Station").
- The Companion can tell an unknown flight from a server that does not offer
the route: the flight routes answer `FLIGHT_NOT_FOUND`.
### Audit fixes first written for 2.6.3-rc.1
A second instance audited the tree from the outside and reported forty-two
findings; forty are fixed here, along with a crash found separately. Nothing
here adds a feature — it is the release where a number that was wrong becomes
right, and where several doors that were open get shut. Three of the fixes
change figures that are already on screen; they are named below.
#### Security
- **A receipt file belongs to whoever uploaded it.** Knowing another account's
receipt URL was enough to obtain the file: permission was derived from "does
this caller own something that points at this file", and a caller writes their
own entries — so pointing at a stranger's receipt was enough to be allowed it.
The uploader is now recorded at upload time, from the session, where no
request can reach it. Existing receipts keep working.
- **A stay, a cruise or a booking can only be filed under your own trip.** The
server checked the hotel a stay was nested under and then took the trip,
booking and loyalty card from the request without asking whose they were. One
account could file an entry against another account's trip, and it then
appeared on that person's own timeline.
- **A read-only access token can no longer switch on two-factor or delete a
passkey.** Those decide who gets in, and a token that may write travel data has
no business making them.
- **Two-factor is asked before a forced password change, on every door.** An
administrator's forced password change simply did not apply to an account with
two-factor switched on.
- **Changing a password ends every other session.** The sign-in cookie carried
only an account and an expiry, so somebody already signed in stayed signed in
for up to a week after the account was recovered.
- **An empty instance can only have one first administrator.** Two registrations
arriving together both counted zero users and both became admin.
- **A long API key is no longer stored unprotected.** The check for "is this
already encrypted" guessed from length and shape, and a long hex token
satisfied the guess — so it was stored as typed and then read back as nothing,
leaving the provider looking unconfigured while the key sat in the clear.
- **One unreadable image no longer takes the service down.** A photograph the
text recognition could not decode ended the whole server process; from outside
it looked like a flaky gateway. It answers with an error now.
- **The map library's critical vulnerability is closed** (MapLibre 6).
#### Fixed
- **Confirming flight detection no longer deletes your other trips.** It ended by
removing every trip with no flight on it — which is every rail trip, every
cruise, every hotel weekend — and their stops, routes, photographs and journal
entries went with them. It ran even when the run created nothing at all, so
confirming the dialog with nothing selected was enough.
- **Merging two trips keeps everything on them.** Hotel stays and visited places
lost the trip they belonged to; a linked photo album was deleted, and it took
photographs the merge had just moved onto the surviving trip with it.
- **"Tidy up" only offers trips that really are empty.** A trip carrying a hotel
stay, a visited place, a linked album, a summary, a cover image or tags counted
as empty and was offered for removal — pre-selected.
- **A merged trip keeps its cover image.** The picture survived; the link to it
still named the trip that had just been merged away, so the cover came back
blank.
- **Restoring a backup works, and says so honestly.** Restored uploads landed one
directory too deep and could not be found under the names the database had
kept; a restore over an existing database reported success while changing
nothing, because the tool logged its errors and exited zero. The restore dialog
also offered a field the server never read and no longer shows it, and it now
fits on a small screen, announces itself to a screen reader, and closes on
Escape.
- **Deleting a hotel deletes its photographs.** The entries went; the image files
stayed on disk for ever, inside every backup, with nothing left that could name
them.
- **A rejected upload does not stay on disk.** One bad request was enough to
leave a file behind, quietly, every time.
- **Clearing a hotel stay's dates clears what hung off them.** The dates went,
but the check-in and check-out times stayed on a stay with no day to hang them
on, the status came out wrong, and the exchange-rate day stayed on the old
date.
- **A stay's price counts the nights it actually had.** A stay recorded without
dates but with a night count got no total at all, and one dated only by month
was billed for the whole month. An explicitly free stay was billed at the
per-night rate instead of staying free.
- **A flight cannot arrive before it departs, and an ordinary westward flight is
no longer refused.** Order was decided by comparing two wall-clock times read
off two different clocks, so Munich 10:00 to London 09:45 — forty-five minutes
in the air — was rejected, while London 10:00 to Berlin 10:30 was stored as a
flight that lands half an hour before take-off. _This changes stored durations
for flights that crossed a time zone._
- **A bulk import stores what a single entry stores.** Cabin class, aircraft
registration, Mode-S address and every special-flight field were accepted and
then dropped, so a flight imported as First Class carried a first-class carbon
figure against a blank cabin. Imported prices also had no converted amount.
- **Cost per hour and per kilometre ignore what they could not convert.** A
booking in a currency with no available rate contributed its distance and its
hours to the average while contributing nothing to the cost, so the rates read
far cheaper than they were. _This raises the cost figures on the statistics
page._
- **A trip without a flight follows its own dates.** A hand-made trip stayed
"completed" after being moved into the future, through the edit, an explicit
recalculation and the nightly pass alike. Trips built from hotel stays alone
are now derived too. _This corrects the status of existing trips._
- **Trip detection keeps the flight you took, not the one that was cancelled.** A
cancelled leg beat its rebooking purely by being first in the list, and two
genuine departures from the same airport on one day were treated as a
duplicate.
- **A recorded track does not invent the stretch it did not record.** A GPX file
marks where recording stopped and restarted; those gaps were joined into a
straight line and counted as distance travelled. Adopting a track for a leg
also re-measured the simplified line and came out short of the figure shown
beside it.
- **A change made while settings are saving is not lost.** On a slow connection
the page marked the newer edit as saved and never sent it.
- **Signing in as another account keeps that account's e-mail address.** The
guard against leftovers from the previous account ran too late and deleted the
address the server had just supplied — and the next save wrote the blank back.
- **A bookmarked hotel, place, cruise or trip opens.** Opening a detail page
directly bounced back to the dashboard while the settings were still loading.
- **Settings survive a round trip.** The cost and tail-number switches came back
to their defaults after saving, and cruise defaults reverted in a second
browser, taking the good values with them on the next save.
- **Seven flight fields are stored, and a delay follows its own schedule.** They
were accepted and written nowhere; a corrected departure time left the old
delay standing beside it.
- **The profile form can be used with a screen reader**, and the account name is
shown rather than offered for editing — saving it stored a second name that the
next load replaced.
- **Moving a stop moves the leg that ends at it.** The distance stayed as it was;
a hand-drawn line now keeps its shape and is marked as needing re-anchoring
instead of being silently trusted.
- **The last English word in the German settings navigation is translated**
(#321).
#### Changed
- **An instance's own settings are one record, and are read as one.** Nothing in
the database said the settings row was unique, and eleven places created one if
they found none — so two requests arriving together on a fresh instance could
make two, after which a setting saved on one page was read from the other.
- **The map keeps its colours.** The map rendering library stays on its current
major version behind a compatibility bridge; the newer one draws every arc
white.
## [2.6.3] - 2026-09-27
A fix for a sign-in failure on new installations, and the matching fix for
installations behind an HTTPS reverse proxy.
### Fixed
- **A fresh install reached over plain HTTP can sign in again.** Opening
TravStats at a LAN address such as `http://192.168.0.10:3000`, the setup
wizard and the login both answered "success", and the very next page said
"No token provided" — the administrator could never get in. The bundled
`docker-compose.prod.yml` passes `COOKIE_SECURE` through as an empty value
when you set nothing, and the server read that empty value as "always mark
the session cookie Secure". Browsers refuse to store a Secure cookie on a
plain-http page, so the session was thrown away the moment it was issued.
It went unnoticed because `localhost` counts as secure in every browser. An
empty `COOKIE_SECURE` now means what the compose file always said it means:
detect it — the cookie is marked Secure only when the request arrived over
HTTPS, directly or through a reverse proxy that sends
`X-Forwarded-Proto: https`. `COOKIE_SECURE=false` and `COOKIE_SECURE=true`
behave as before. If you worked around this with `COOKIE_SECURE=false` and
reach TravStats over HTTPS, remove that line again so the cookie is Secure.
- **Behind an HTTPS reverse proxy the session cookie is marked Secure without
configuration.** The web server inside the container replaced the proxy's
`X-Forwarded-Proto: https` with its own `http`, so automatic detection could
never see HTTPS. Until now that was hidden by the empty `COOKIE_SECURE` above,
which forced Secure on everyone; with that fixed, the container now keeps the
scheme your proxy reports, and falls back to its own when the proxy sends
none. A proxy that does not send the header still needs `COOKIE_SECURE=true`.
## [2.6.2] - 2026-09-07
A round of tester reports from the days after 2.6.0 went out, plus the
dependency and housekeeping work that had been waiting on the trunk since
2.6.1. Nothing here changes how anything is stored.
### Fixed
- **A half-star rating can be saved.** The rating picker in a hotel stay
offers half steps, but the server refused anything below a full star, so
rating breakfast half a star failed the whole save with a red line at the
bottom of the form. Half stars are accepted now, in the editor and in an
import alike.
- **The per-domain section of the statistics page speaks the reader's
language.** "Distance", "Nights", "Places visited" and the rest were German
words written into the code, so an English page showed a German block with
German thousands separators. They are translated now, and the numbers follow
the chosen language.
- **"Next up" opens the thing it names.** Clicking the next hotel led to the
hotel list, not the hotel; a flight belonging to a trip led to the trip. It
now opens the entry's own page.
- **A closed airport can be found by name.** Importing old bookings runs into
airports that no longer exist, and a 2004 confirmation says "Berlin", never
"TXL" — so Berlin-Tegel could not be attached at all. A search can now ask
for closed airports, while an everyday search still leaves them out. The
same search also honours the result limit it always advertised instead of
always returning ten.
- **A backup archive is compressed even behind a proxy.** An instance reached
through a tunnel or reverse proxy served every response uncompressed,
because the web server skips compression for proxied requests unless told
otherwise. Nothing reported it; it simply cost bandwidth.
### Added
- **Every time zone, not six.** Display settings offered Berlin, Paris, UTC,
New York, Los Angeles and Singapore. The list now holds every zone the
browser knows, grouped by region.
- **A new hotel stay starts in the right currency.** The price field always
opened in euros, whatever country the hotel stood in and whatever the
account's own base currency said. It now follows the hotel's country, and
falls back to the account's currency. A saved stay keeps what it was saved
with.
### Changed
- **Two-factor secrets survive a library upgrade.** The one-time-password
library moved three major versions; the new one refuses the shorter secrets
the old one issued, which would have locked every account that had
two-factor switched on. The shorter secrets are still accepted.
- **A flight lookup that answers unexpectedly is named, not mistaken for a
crash.** One flight-data provider occasionally answers with something other
than a list; that used to be logged as a failure and cached.
- **The image states its licence correctly** (AGPL-3.0-or-later), and the
route-distance backfill runs inside the container as documented.
- Dependency updates across the frontend and backend, including the
translation runtime, and the rate limiter's handling of IPv6 addresses so
one host can no longer take a fresh bucket per request.
## [2.6.1] - 2026-09-05
Two things the owner ran into on the day 2.6.0 went live, both on the edit
side: a flight recorded without its times could never be given them, and a
house with many stays turned its page into a scroll marathon.
### Fixed
- **A historical flight can leave "historical" and be given its times.** The
edit form offered no way to enter a clock on such a flight: its status is a
read-only pill, and the year/month/day pickers carry no time — so a flight
imported without its times was stuck that way for good. The edit form now has
the same "historical flight (route only)" checkbox as the create form.
Unchecking it shows date and time; a known day is kept, a year-only or
year-and-month date becomes empty rather than a fabricated January 1st, and
the save is refused until both clocks are typed, so no midday is invented on
the way out. The status is then derived from the times, as everywhere else.
Checking the box on a flown flight is the reverse move.
### Changed
- **The stays of a house scroll in a box of their own.** A house with dozens
of stays stretched the whole detail page, pushing the map, the spend card
and the ratings off screen. The list now scrolls inside a bounded box beside
them, on wide screens only — on a phone the page keeps scrolling as one, so
there is no scroll trap inside a scroll.
## [2.6.0] - 2026-08-19
The release where the other ways of travelling catch up with flying. Hotels
become a domain of their own — recorded, imported, mapped and counted like
flights and cruises — and every price can be written in the currency it was
actually paid in. Cruises, stays and places gain statistics at the depth
flights already had, and a trip can now be walked, driven and cycled rather
than only flown. Two-factor authentication and passkeys arrive alongside.
Some of the larger additions ship behind the instance beta switch, which is
off by default: the passport, the places domain and per-domain colours. They
are complete and tested, but had not been through a release review in time for
this one. Tours, device pairing and the Dawarich connection started out behind
that switch too and came out from behind it in the later candidates, once the
conditions their own gates named had come true. The admin panel lists exactly
what the switch turns on, read from the same registry the application itself
checks.
The candidates after rc.27 also rework how countries are counted — by the
evidence that proves a visit, not by where a flight touched down — and carry
the findings of a full audit of the trackers on 4 September: twelve defects a
tester found on the release candidate, three map defects, and nine older fixes
that had shipped without a test.
### Added
- **A country counts by what proves you were there, not by where a flight touched down.** The country count was too high and too low at once, which is why it looked plausible for so long: a four-hour connection counted as a visit, while a country reached by car and slept in for a week did not exist at all, and three countries with completed hotel stays and no flight ever were missing. Every country now carries the strongest evidence for it, in four rungs — _slept_ (a completed stay, a house on record, a port call over a night, arrival and departure on different local days), _visited_ (a same-day stay or a recorded place), _transited_ (driven through, read from location history) and _connection_ (changed planes and nothing else). Only the connection rung is left out of the headline by default; a connection country is greyed and kept, never hidden, because the tier is inferred from recorded flights and flights are recorded incompletely. A day trip is no longer a connection: MUC–FCO–FRA is a day in Rome. Cruise port calls and recorded places now prove a country the way a landing does, and a house entered without any stay counts as one night (owner's rule); a booking still ahead and a cancelled stay prove nothing. There is deliberately no hour threshold — six hours and twelve hours returned the same set of countries on a real account, so a dial would promise a precision the data does not hold. Only attested days count: a landing in January and the next departure five years later no longer read as five years of presence. **This moves the headline for every account**: it rises for accounts with cruises, places or hotel stays, and falls for accounts whose countries rested on connections.
- **Every counted country names its evidence, and the record is one click away.** The passport table shows each country's rung, the records that proved it, the days present, and the time on the ground where a pair of flights bounded one. A duration that cannot be derived reads "unknown", never zero, and a country no flight touched shows a dash — the two differ because only the first says "add the missing flight". Expanding a row lists the flights, cruises, places and houses behind it, each linking to the page that edits it. A country page (`GET /stats/countries/:code`) answers for a country reached only by ship or by a recorded place, instead of a 404 behind a clickable row.
- **How strictly countries are counted is a setting — an instance default, and a per-user override.** Whether a connection counts is a personal definition, not a property of the server, so an administrator sets the starting point and anyone may override it. The choice is one of the evidence rungs; there is no hours option on purpose. Changing it moves the headline and nothing else: the country list is identical at every threshold, and the year in review and the achievements read the same threshold rather than counting airside stopovers. Badge progress can drop for an account holding connection-only countries; a badge already earned stays earned.
- **Driving through a country is its own rung, read from location history.** A Dawarich connection is now reduced nightly to country-days — "on this day, in this country" — and folded into the passport as a fifth kind of evidence. Coordinates exist only for the length of one loop and are never stored, returned or logged; what a row keeps is the day, the country and a count of fixes, so a day held up by four hundred points and one held up by a single point can be told apart. A country is a connection when every point ever recorded there lay within an airport's grounds you are known to have flown through; a point beyond that lifts it onto the stronger rung. The road rung and its filter only appear once an account has location history, because a filter that always returns nothing reads as a bug.
- **The passport says once that the counting changed, with your real figures.** A number that moves without explanation reads as data loss, so the page shows "Zählweise geändert — vorher 32 Länder, jetzt 35" the first time the old rule and the new one disagree, computed from the same rows so the two can never drift, with a link to the evidence explanation and a dismiss that is remembered in your browser.
- **Data that contradicts itself is flagged and queued — never rejected, never silently corrected.** Four checks fire only where two sources inside one record disagree: an address whose country differs from the stored one, coordinates that fall outside the claimed country, a country resting solely on records with no date, and a check-out before its check-in. A third-party geocoder does not get a veto over your own data, so the record is written and the question waits. A flag never changes a number by itself. A point in the sea or in a territory the offline outlines do not attribute is not a disagreement. A nightly sweep and every import run the checks.
- **The pending-updates page is now the inbox.** It holds the flight proposals it always had plus the data-quality questions, one list for the user with two tables underneath. Each flag states both conflicting values and marks neither as correct. The two answers are different kinds of statement, and each carries its consequence: "I corrected it" lets a surviving contradiction come back; "This is right" never asks again — the escape hatch for a district that shares a country's name. The nav badge counts both halves independently, so one failing cannot hide the other.
- **A document announces itself.** A pasted mail, a `.msg`/`.eml` file or a PDF can be sent with `domain: "auto"` and the server decides whether it is a flight, a cruise or a hotel — a property of the document, not of the button that sent it. Detection is a weighted scorer rather than the language model, so it works on an instance with no Ollama; a signal counts once however often it repeats, because marketing mail is the longest thing anybody forwards. A weak answer still returns what it parsed, with the runners-up, so a client can offer a switch instead of asking for the file again. An explicitly named domain keeps its exact previous answer (forgejo#57).
- **A photograph is a document.** `POST /parse-image` reads a photographed confirmation — a hotel bill, the most photographed travel document there is — through OCR and hands the text to the same parser as a mail or a PDF. Until now an image could only ever become a flight. The OCR worker reads German as well as English, since the lodging parser matches on "Nächte", and degrades to English on an air-gapped instance rather than failing (forgejo#58).
- **A photographed boarding pass is read by its barcode first.** The parse route decoded only the printed text while the propose route beside it, handed the same picture, decoded the PDF417 or Aztec code and used OCR merely to fill gaps — so a Wallet screenshot could come back "unreadable" with the answer sitting in the middle of the picture. A decoded barcode wins every field it carries; OCR keeps the fields no barcode holds (gate, terminal, boarding group). The airline is now reported by its printed name rather than its two-letter code on both routes (forgejo#37).
- **The travel records, the whole route network and the year in review come from the server.** `GET /stats/records` computes the seven records (longest and shortest leg, longest time aloft, streaks, delays) once, as numbers with units rather than formatted sentences; `GET /stats/network` returns every airport with coordinates and every route with a count and a distance, deliberately unbounded because a truncated network is not a smaller globe but a wrong one; `GET /stats/wrapped?year=` reads the year off the data and reports a favourite the year cannot support as absent rather than zero, and a year with no flights no longer headlines as the biggest flying year of your life (forgejo#41, forgejo#42, forgejo#44).
- **Four more curated checklists, each verified rather than recalled.** The New 7 Wonders of Nature, the UNESCO Biosphere Reserves (646 of ~759 — the register entries with no coordinate are dropped rather than pinned at 0/0, and the gap is stated), and the national parks of Germany (16) and the United States (63). Two coordinates guessed from memory turned out to be an Italian village and an American singer, so every row was checked; a natural wonder gets an arrival point, never the centroid of a rainforest (forgejo#66). Alongside them, fourteen museum warships — USS Missouri, HMS Victory, Mikasa, Vasa, U 995 — with two collector achievements.
- **A geotagged photo suggests the place it was taken at.** A trip with a linked Immich album is already a list of dated, positioned points, and nothing was reading them. A photo is the only anchor that is not a proxy — a hotel is booked in the town you came to see, but a photograph is a GPS fix at the moment the shutter opened — so its radius is one kilometre and it outranks a hotel or a logged place as the stated reason.
- **Places can be imported from a CSV or a Google Takeout list, and the rows it cannot place come back to you.** German decimals are read as such ("41,8902"), and a row with no coordinates — every row of a Takeout export — is neither dropped nor written: it returns as _needs input_ with your note intact, for one click in the picker. A place added by hand now carries the same identity a later import would mint, so the Colosseum picked today and imported next month is one place, not two. The batch appears in the import log and can be undone. The web import panel for places stays behind the beta switch with the rest of the domain; the routes are live for the API and the Companion.
- **Several flight proposals can be answered in one request.** `POST /pending-updates/apply` and `/reject` take a list of ids and report an outcome per id — a flight edited meanwhile, one already applied, one that is not yours — rather than one success flag that would silently drop changes you believe you accepted (forgejo#33).
- **The backlog refresh says which kind of nothing happened.** "The provider has nothing on this leg" and "the provider answered and your record was already complete" shared one counter, so a run that had just written three tail numbers read as a run that changed nothing. _Already complete_ is its own line now, and every silent per-flight result carries the provider's own reason — no key, a date the free tier refuses, or a leg it genuinely does not know.
- **A flight that landed without its actual times is asked about one last time.** For an overnight flight both arrival checks fell on a date the free plan refuses, and once the sweep marked it flown nothing ever asked again. The sweep now leaves one late check outstanding and spends it through the one provider that answers for a past date — exactly once, values filled and never overwritten, and only for flights the sweep has just flipped, so the cost is the number of flights you actually took, not the size of your logbook.
- **A fresh install lands on a starting point, not an empty dashboard.** The
empty state says what the map will show and routes to the import section
(#237).
- **An account carries a first and last name**, shown in the account menu and
returned with the profile (#241).
- **Tours: a trip can be walked, driven and cycled, not only flown.** A trip
now carries route sections of its own — road, hike, bike and ferry — each
with a section list, an editor that works with the mouse, and its own line on
the dashboard map under a Touren tab. Recorded tracks can be pulled from a
Dawarich instance and adopted onto a section. Hidden behind the beta switch:
everything works end to end, but the feature has not been through a release
review, and the Dawarich connection carries a second switch of its own
because a connection with nothing to connect to is worse than no card at all.
- **A passport.** Stamps, a continent quota band and a country table, built
entirely from flights already recorded — nothing new is asked of you. Behind
the beta switch for this release.
- **Statistics for cruises, stays and places, at the depth flights already
had.** Each gets its own rhythm, quality and curiosity blocks rather than a
handful of totals. Cruise money is deliberately reported per currency and
never summed: cruises carry no exchange-rate snapshot, and adding the numbers
would have repeated a bug that already exists one domain over.
- **Photographs belong to the hotel.** A picture of the building, the lobby or
the view is a fact about the house and survives every stay, so it hangs off
the lodging rather than off one visit.
- **A statistics tab shows only the blocks you want.** Someone who records no
prices had a cost block on every screen. Only the HIDDEN blocks are
remembered, so a section added in a later release still appears rather than
being silently swallowed by an old preference.
- **Twenty-five new achievements for places**, taking the catalogue to 276.
Places were the thinnest domain; cities, continents, streaks, latitudes and
ratings now count for something.
- **The beta switch says what it turns on.** The admin panel lists every gated
feature with a plain sentence, read from the same registry the gates
themselves use — so it cannot claim something the build does not do.
- **A per-domain colour can be chosen** rather than taken from the brand. It
applies to statistics, the trip timeline, the activity rail and the import
log. Behind the beta switch while the question of whether an instance may
repaint its own domains is settled.
- **The route editor is worked with the mouse.** Clicking a different leg
switches to it, which it did not before: the click was swallowed and quietly
added a waypoint to the leg already open, so reaching for one leg edited
another. Right-clicking now offers what fits under the cursor — edit that leg,
delete this waypoint, delete the whole selection, simplify, close. A Select
tool turns the drag into a selection box, so several waypoints can be removed
at once, and the ports are never among them. One undo takes a box-delete back
whole.
- **The API documentation is complete.** It described a curated subset while
claiming it could not drift; measured against what the server actually serves,
244 endpoints had no entry. All of them are documented now — 241 operations
covering every domain a normal account can reach — and the check that found
the gap has become a plain completeness test with no exception list left to
add to. An endpoint written from here on fails the build the day it is written
unless it is documented with it.
- **A cruise or a flight on a trip's timeline opens where it stands.** They could
not be clicked at all — not viewed, not edited — while the hotel entry beside
them already led to its own page. A click now expands the entry inside the
trip: the cruise shows its ship, cabin, price and the itinerary day by day,
with sea days named as such; the flight shows airline and number, aircraft,
seat and class. A button in the panel opens the full page for editing.
- **A list's symbol is picked, not typed.** The symbol field was a bare text
box: you had to know an emoji and then find one to paste. There is a picker
behind it now, with search — typing "pommes" finds the fries. Its emoji
database is served by your own instance rather than fetched from a content
network, so it works on an installation with no way out to the internet,
which is most of them. Pasting into the field still works, and if the picker
cannot load it says so instead of leaving a control that does nothing.
- **A places list can wear its own symbol on the map.** A list of fifty-eight
McDonald's spelled the same name fifty-eight times across the map, which reads
as noise rather than as a list. A list can now carry a symbol and ask for its
places to be labelled with that instead of their names. The dot underneath is
unchanged: it still carries the list's colour, and still tells a visited place
from one on the wishlist by being filled rather than hollow. A list without a
symbol keeps its names, and so does a place that belongs to no list. Above all
of it the map has one switch of its own — as each list says, always names, or
always symbols — so a single flip brings every name back for a moment without
editing a list.
- **The places list can be filtered by list.** It could already be narrowed by
status, category and country, but not by the lists you build yourself — even
though the dashboard had offered exactly that all along. With 58 McDonald's
in one list sitting beside a hundred other places, "show me only that list"
is the filter that makes the page usable. A list with no entries yet shows
nothing, rather than quietly showing everything.
- **Flights are part of the spreadsheet, in both directions.** The export
carried cruises, lodging and places but not the largest domain; now it does,
with the eighteen fields you would actually edit in a table. Airports travel
as IATA codes rather than coordinates — a code is what you can type and check
— and the server resolves one into a real airport, which is what makes adding
a missing flight from a row possible at all. Changing a code moves the flight;
an unknown code is refused rather than quietly leaving the old route.
- **Visits can be imported.** The export always wrote a Visits sheet and the
import ignored it without a word, so editing visits and sending the file back
did nothing. A visit now imports against its place — several visits to the
same place on the same day stay several visits. Cruise stops and stays remain
export-only, and the import panel says so instead of leaving you to find out.
- **The spreadsheet import asks what to do with what is already there.**
_Add only_ creates and never touches an existing entry. _Merge_ — the
default — updates the rows the file names and creates the rest. _Replace
contents_ additionally deletes everything the file leaves out, and is the
only mode that can lose data: it takes a full backup before writing a single
row, shows how many entries would disappear, and will not proceed unless you
confirm that number. If the backup cannot be made, the import does not run.
An empty sheet deletes nothing at all — clearing a domain has to be asked for
row by row, never by handing over a blank tab.
- **The whole logbook exports to one spreadsheet, and comes back.** A sheet per
table — cruises and their stops, properties and their stays, places and their
visits — readable, filterable, and re-importable. Column A carries the row's
id: a row that returns with one updates the record it names, a row without one
is created. An import is previewed before anything is written, and shows what
it would change per sheet. A row carrying an id that is not yours is refused,
never applied.
- **A place gets its address from its pin.** Places arrived located but
undescribed — the checklist catalogue has no address column and names a
country for 14 of its 1,261 entries. New places now fill in address, city and
country when they are saved or ticked, and a nightly pass fills in the ones
recorded earlier.
- **Places can be sized on the map, like every other domain.** The map settings
had a size slider for flights, cruises and lodging but not for places; the
place dot was pinned at one size and its labels at one zoom level, so zooming
in never revealed another name.
- **The API documentation covers cruises, catalogues, geography and
achievements.** It described flights and trips and claimed to be complete;
measured against what the server actually serves, 244 endpoints had no entry.
Coverage is now enforced by a test that walks the live route table, so an
endpoint can no longer ship undocumented. The remaining gaps are listed by
name rather than left to be discovered.
- **Every dashboard tab has the same activity list.** Four tabs carried four
different panels, places carried none, and "All" listed flights and cruises
while the tab bar above it counted four domains. One list serves all of them
now, and picking an entry focuses it on the map instead of navigating away
from the dashboard — a hotel used to throw you off the dashboard entirely.
- **The lodging list has a "Last stay" column, and it sorts.** A hotel is dated
by its newest stay, planned ones included, rather than by the order the
hotels were typed in.
- **A list remembers how it was sorted.** The choice survives a reload instead
of springing back to the name column.
- **A place can be attached to a trip.** The visit form offers the trip, the way
a lodging stay always has. The interface never set it, though the API had
accepted it from the day the visit routes were written.
- **A cruise can be attached to a trip.** The cruise form offers the trip, the
way a flight, a lodging stay and a place visit already did — a cruise was the
last entry whose trip could only be set through the API. The list and the
cruise page name the trip it belongs to, and the trip counts the cruise and
its price.
- **A place list you build yourself, and checklists we ship.** Group places into
named lists — a wishlist, a route, "the ones worth the detour" — and reorder
them by hand. Alongside them come curated checklists: the New 7 Wonders, the
Seven Wonders of the Ancient World, and the complete UNESCO World Heritage
List with 1,247 sites. Subscribing writes one row, not 1,247: a catalogue
target becomes a real place only when you tick it, so a corrected coordinate
still reaches someone who subscribed a release ago. Ticking never happens by
itself — the suggestion logic proposes and you confirm, because a wrongly set
tick is indistinguishable from a real one afterwards.
- **Proof for a visit, with a caption.** Photographs attach to a single visit
and carry a caption you can type.
- **Places have a place in the map settings**, and on the map they are drawn as
the same plain dot every other domain uses — including in the key beside it.
- **Places become a domain of their own — hidden behind the beta switch.** A
place is a first-class entry with its own list, detail page, form and map
layer; a trip is a _view_ of it rather than its owner, so "every McDonald's
I have been to" is expressible and the same place on two trips is one entry
with two visits. A place is visited, planned or on the wishlist — planned is
derived from a dated future visit, never stored. Invisible to ordinary users:
the domain, its routes and its dashboard tab all sit behind the instance beta
flag, and turning the flag off hides the data without touching it. Two
migrations ship with it; the second copies coordinate-carrying POI trip stops
into places and deletes nothing.
- **A flight has a page to read it on.** A flight carries around 58 fields and
the table shows nine columns; everything else — seat, gate, terminal, booking
reference, companions, baggage allowance, overflown countries — was readable
only by opening the edit form, which meant putting the record into an
editable state to look something up. The row now opens `/flights/:id` the way
a cruise row and a lodging row always have.
- **Every list summarises the rows it is showing** — count, and the figures
that matter for that domain — marked "filtered" when a filter is narrowing
them, and silent while nothing is known.
- **The sea route of a cruise can be corrected by hand.** Enter edit mode on
the detail map, tap a leg, and drag its waypoints — insert new ones on the
guide line, remove them on hover, undo and redo the whole gesture chain. The
corrected line is stored with the cruise, its distance replaces the routed
one, and "back to automatic" returns the leg to the router at any time. A
leg arriving with dozens of raw router points presents at most 25 handles —
the ones that carry the shape.
- **Pick a point on the map, everywhere.** Every address input — lodgings,
trip stops, custom ports, special-flight venues — now opens one large map
modal: search, click, or drag the pin, and the resolved address underneath
is filled into the form on apply. It replaces the cramped inline mini-map.
- **The booking total is read from the mail.** The LLM parser now extracts the
final price and its currency from a booking confirmation; the total lands on
the booking, not on each flight segment — "Endpreis EUR 4,359.14" no longer
arrives as an empty price.
- **Trips from imports are optional — and dated.** A new switch on the import
hub controls whether flights sharing a booking reference silently become a
trip; switched off, the booking reference is kept so "detect trips" can
group them later. Auto-created trips now carry the date range of their
flights instead of none.
- **Hotels and other stays are a full domain.** A lodging carries its type
(hotel, guesthouse, apartment, hostel, campsite), its chain, its position on
the map and its own stays; a stay carries dates, room, board, per-category
ratings, price, loyalty programme and the trip it belongs to. The domain
switches on and off like the others — off means it is gone from the
navigation, the dashboard, the map, the statistics and the achievements,
while the data stays untouched.
- **Booking confirmations are read for you.** Booking.com mails are parsed by a
deterministic template — measured against 95 real confirmations, it now finds
the stay in 93 of them and the price in all 93, across both layouts the
sender uses. Anything the template does not recognise falls back to the LLM
path, exactly as flights and cruises do, and PDF attachments go through the
same pipeline.
- **A CSV import that shows you what it will do first.** The preview names
every row it considers questionable, orders those first, offers a mapping for
the columns it could not place, and commits the run as ONE batch that can be
reverted whole. Hotels without coordinates are geocoded afterwards, in the
background, so a slow lookup never blocks the import.
- **Loyalty programmes live in one place.** A card is created once, covers as
many chains and individual hotels as it really covers, and each stay derives
its programme instead of asking again. A programme that is already on file
offers to extend to the chain you are looking at rather than refusing you.
- **Any currency, and an honest total.** A price can be recorded in any of the
155 ISO-4217 currencies. Conversion asks the European Central Bank first —
authoritative and self-hostable, back to 1999 — and, for the currencies the
ECB does not publish, a keyless public dataset that an administrator can
switch off entirely. Where no source has a rate, the amount is kept in its
own currency, marked "kein Kurs", left out of every total, and the total says
how many stays it left out. You may enter a rate yourself; it is always shown
as yours, never as an official one.
- **Two-factor authentication and passkeys.** TOTP as a second factor with
single-use recovery codes, or a passkey that replaces the password entirely.
Both are configured in the new security section; an administrator can reset a
locked-out user's second factor.
- **Statistics and achievements grew with the domain.** 41 new lodging and
cross-domain achievements, punctuality figures for flights (average delay,
on-time rate, best and worst airline and route), trip-level insights, and a
spend breakdown per currency.
- **Places are picked, not typed.** One shared location input — search, pasted
coordinates or a pin on the map — now serves hotels, cruise stops and custom
ports. The geocoding services are instance-configurable.
- **Two doors for getting data in.** A one-off list import lives in the
settings; every domain page and the dashboard offer the single-entry chooser,
where the first route IS the drop zone — one control takes a `.msg`, an
`.eml` and a `.pdf` alike, so nobody has to classify their own file first.
- **Every import leaves a trace, and can be taken back.** Flights, cruises and
stays record where each row came from and which run created it, so the same
document read twice adds nothing the second time, and a whole run can be
undone as one batch.
- **A Google Maps saved-places list, imported as itself.** Each row carries
Google's own id for the place, so a second import recognises what is already
there. Such a list holds no dates and no prices, and the import says so.
- **Package-tour travel documents are read.** Measured against seven real
bookings: the invoice yields the trip, its price and its flights with IATA
codes; the travel documents yield the day-by-day itinerary and the hotels
with their addresses. Fourteen lines that look like flights and are not —
un-ticketed placeholder legs, separately ticketed ones, the rail transfer to
the airport — are refused WITH a reason rather than dropped silently.
- **The trip map shows the hotels**, not only the flights: one pin per house
with this trip's nights on it, and the flight arcs lie flat on the map
instead of bowing out of it.
- **The lodging table says what a row still needs** — an address the geocoder
could not find, no address at all, a name and nothing else, or a country
field holding something that is not a country. Nothing is shown for a sound
row.
- **The logostream and Google Places keys can be tested from the admin page**,
like every other provider key. The Places check sends one real, billed
request and its result names the price (~0.03 USD) — a test that costs money
must say so.
- **Hotel statistics grow into their own chapter.** What a night cost — by year,
country, chain and board, with the median taken over nights rather than
bookings — and what it was worth: the value of award nights, and all four
rating columns separately instead of one blended score. Where the nights
happened: continents, the northernmost night, the centre of gravity of your
travel computed on the sphere. When they happened: by weekday, by season, and
the longest unbroken run away from home. Nights per loyalty programme per
calendar year. And a travel account that splits every year into hotel, ship,
aircraft and home — naming the days it cannot account for rather than hiding
them.
- **34 new achievements** for the measures that had none, in German and English.
- **A hotel you cannot date is still a hotel you slept in.** A stay may now
record only a month, only a year, or no date at all, and says which — instead
of forcing a guess that later reads as fact.
- **A stay can carry its check-in and check-out time.** Both are optional and
refine the day rather than replace it — night counts, statuses and currency
conversion keep reading the dates. The "Next up" line counts down to the real
15:00 check-in instead of midnight, and no longer drops today's stay the
moment the date turns.
- **The map modal answers "what is here?".** Dropping a pin now lists the named
places around it — the hotel, the snack bar, the station. Tapping one snaps
the pin there and carries that place's full details into the form, while
confirming without picking keeps the plain reverse-geocoded address as
before. Address and place lookups run in parallel, so one failing never takes
the other down.
- **19 more achievements**, this time for the curious corners: Friday the 13th,
a Christmas flight, a palindrome date, flight number 666, a 777 flown on a
777, three flights in one day, a same-day out-and-back, a single flight
across eight time zones, the aisle-seat streak that completes the
window/middle trilogy, a flight below the Antarctic Circle, an equator
crossing at sea, same-ship loyalty, and the diamond rungs of the airline and
aircraft ladders.
- **Country flags from the server.** `GET /api/v1/country-flags/:iso` answers
with a flag SVG (`variant=flat` or `square`), cached privately for a day with
an ETag, and a 404 is a valid answer; `GET /api/v1/country-flags?codes=DE,FR`
returns up to 250 flags in one request, so a client can preload the
countries it actually has. Flags and airline logos now come from one source.
- **Days away, per domain and in total.** `GET /api/v1/stats/summary` carries
`daysAway`: the distinct calendar days on which the account flew, sailed,
slept or recorded a place, and their union. Days, not kilometres or nights,
are the one measure the domains can be compared on, and the union is what a
client cannot compute itself without counting a hotel night during a flight
three times.
- **Passport rows say what was recorded there.** Each country on
`GET /api/v1/stats/passport` names its port calls and recorded places, and
a country proved by a stay carries the city slept in most and the nights.
- **A trip lists its route count.** `GET /api/v1/trips` carries
`_count.routes` beside flights, cruises and stays, so a trip row can read
"6 entries · 3 sections" without one request per trip.
### Changed
- **Tours, the Companion app pairing and the Dawarich connection stay behind the beta switch.** All three work end to end and were briefly offered to everyone in a release candidate; the owner ruled them unfinished. An instance with the switch off does not offer the Tours tab, the trip route editor, the Devices section that mints a claim code, or the Dawarich card. Each gate names why it is still there and what brings it back; the places domain stays gated as well (forgejo#65, forgejo#73).
- **A route is the pair, not the direction.** FRA→WAW and WAW→FRA were two routes with one flight each while the Companion's globe counted one route with two. Both legs now land on one entry, keyed by the two codes sorted, so an existing account's top-routes list reorders: two entries of one collapse into one of two (forgejo#42).
- **A flight's duration is owned by the database and cannot go stale.** It was derived independently in three server places and again in the app, and eight code paths write the timestamps it depends on. It is now a generated column that Postgres refuses to hand-write, so no present or future write path can leave an old number behind. Rows whose duration depends on the airport catalogue are deliberately not stored, so a timezone correction lands on the next read. On the way, `GET /flights/:id` and the create and update responses stopped returning a bare row that could show a null duration where the list showed a number (forgejo#45).
- **The flight table is indexed for the shape the statistics actually ask.** Two composite indexes replace ten single-column ones for the hot queries; on a multi-user instance the counting queries stop reading other users' rows (measured 5007 buffers down to about 20). On a single-account instance this buys nothing and costs nothing — the planner ignores an index that does not help (forgejo#48).
- **"Transit" is renamed "connection" wherever a country's evidence is shown**, in stored settings as well as on screen, because _Transit_ is the word a German reader attaches to driving through the moment a road rung exists. A stored threshold of "visited" becomes "transited" by the same reasoning, so no instance's headline moves on the rename alone.
- **Newest first, everywhere.** What is coming up stands above what is past,
and an entry with no date sits at the bottom rather than sorting as though it
happened in 1970.
- **The cruise route editor opens full-screen.** "Edit route" used to arm an
editor inside the small preview card in the page margin, where a leg is a
hairline: hitting one took three attempts, so the button read as one that
does nothing. Same editor, same handles, on the whole window.
- **One currency, one distance unit.** There were two currency settings: one
under Einheiten & Formate that only flight surfaces read, and one under
Unterkunfts-Präferenzen that lodging, statistics and achievements read — the
general one was domain-specific and the domain-specific one was general.
There is one now, under Einheiten & Formate, and it applies everywhere.
Conversions already recorded keep the rate they were taken at. The distance
unit now also governs cruise distances, which were fixed to kilometres while
flight distances followed the setting.
- **Funktionen and Automatische Updates moved to the Flug tab**, where their
only consumers are: both Funktionen toggles are read by flight surfaces
alone, and auto-update runs entirely against flights. Existing links to those
sections keep working.
- **The three domain lists behave alike.** The same actions as icons, the same
message when loading fails, a clickable row on every list, and one filter
bar — search, status and year open everywhere, everything a single domain
owns behind one button carrying a count.
- **Six delete dialogs said six different things.** One shape now: what is
deleted, how much goes with it, and what survives. Deleting a trip says for
the first time that its photos go with it.
- **The three domain lists are one family.** Flights, cruises and lodging
share the same table now: status pills, sortable column headers, a column
picker, and more width.
- **The next upcoming entry moved into the domain strip.** It used to float
over the map and covered the map-mode switcher completely. It now sits beside
the tabs, follows the active tab, and names the trip it belongs to.
- **The map legend names what it draws.** Lodgings appear as a circle rather
than a line — a stay is a place, not a route — and airports and ports finally
have a swatch of their own.
- **Every `/api` response is `Cache-Control: no-store` by default.** A shared
cache was observed serving one signed-in user's response to another.
- **The import hub carries every enabled domain.** The e-mail and PDF routes
moved back out of it on purpose (2026-08-15): one door for lists, one for a
single entry, in each area's own add dialog.
- **A stay counts once it is over.** Lodging figures counted every house on
record, including ones only bookmarked from a saved-places import, and
bookings that have not happened yet. A bookmark is not a visit, and a booking
for next month is not a night slept: both are now reported separately rather
than folded into a total or dropped from it. What counts is decided in one
place.
- **Lodging pins take a colour mode** like flights and cruises, the list panel
remembers whether it was open, and the figures moved off the map into the
statistics, where figures belong.
### Fixed
- **"What's new" tells the whole release.** The 2.6.0 overview listed five
points and left out the ones a reader would notice first — countries
counted by evidence, tours and the Companion pairing out of beta, the
spreadsheet round trip, the grown statistics — and said nothing about the
beta switch. Eight points now, and the one that describes what sits behind
the switch wears a Beta mark, so nobody looks for a page their instance does
not show.
### Fixed
- **A booking that names its airport is filed into that airport, not into the
city's current one.** A 2008 Germanwings confirmation printing
"Berlin-Schönefeld" came back from the parser as `BER`, the code of an
airport that opened in 2020, and two flights were filed there. When the mail
carries the distinctive name of another airport of the same city, closed ones
included, that airport's code replaces the model's; two names on one leg, or
the returned airport's own name, leave the code alone.
- **Two beta gates closed properly.** With the instance's beta switch off, the
statistics page still offered the "POI / Besuche" tab, chip and summary card
to an account that had the domain on from beta days, and the passport URL
answered with "enable the flights domain" to a reader whose flights were on.
The tab strip and the overview follow the same rule as the deep link now,
and a closed passport gate goes home like /places.
- **The inbox is always in the menu.** The Posteingang entry under System
appeared only while something was open, or while you were already on the
page — an empty inbox had no way in from the UI at all. The entry is always
there now; the badge alone says whether anything waits.
- **A flight saved with a date and no times no longer counts its placeholder
clocks as flight time.** Such a row carries "12:00 → 13:00" that nobody
measured, and three figures subtracted it anyway — the business tile's cost
per flight hour, the achievement engine's hours in the air and the summary a
pending update compares against — while the overview beside them estimated
the same flight from its distance. One rule now, shared by every screen: the
clocks count only when they are real instants; otherwise the flight is
estimated from its coordinates and labelled as such, or contributes nothing
at all. Hours in the air and cost per hour move for accounts with such rows.
- **The direction arrows are back on the map.** rc.28 was the first candidate to
carry the page's Content-Security-Policy, and its `connect-src` did not admit
`data:` — deck.gl fetches its icon atlases from inline SVGs, so every cruise
arrow and the aeroplane marker vanished with "deck: Failed to fetch" in the
console. `data:` and `blob:` are allowed for connections now, and a test reads
the nginx config so the scheme cannot be tightened away again.
- **Refreshing the backlog keeps the aircraft type and the actual times.** Three flights the owner had just taken came back with a registration and nothing else, out of a provider response that carried the aircraft model beside the registration and both actual times beside the scheduled ones — five fields were copied out and the rest dropped. The patch now carries all eight, on the same never-overwrite terms; the candidate query was widened so flights refreshed once are not locked out for ever, and it asks for both spellings of empty (125 rows NULL against 35 rows `""` on a real account — all three reported flights were in the 35).
- **An overnight flight no longer gets the neighbouring day's aeroplane.** The provider answers with every flight that touches a local date — departing on it or landing on it — and for an overnight flight those are two different aeroplanes a day apart. LX93 stored as departing on the 2nd was answered with the flight that landed on the 2nd, which the rotation guard rightly refused, so every long-haul leg on the account carried no actual times while the short hop beside it had its own. Candidates are filtered on their local departure date before the pick.
- **A flight in the air is asked about, whatever day it left on.** An up-front gate refused any live lookup for a flight whose departure date was not today — on the reasoning that real-time queries only cover today. An aeroplane that took off yesterday and has not landed is on the real-time feed this minute; both arrival checks of an overnight flight were being refused. The exception asks for a measured position in the air, so an ad-hoc lookup for a date last May is still refused as the historical question it is.
- **A flight number is not unique within a day — the airport decides.** LX93 on 2026-09-02 came back three times: the long-haul that left the day before, a feeder that afternoon, and the one actually flown. The departure airport now narrows the candidates, as a preference rather than a filter, so an older row with no code is no worse off. The lookup cache key carries the airport too, because two flights sharing a number on one day are different answers and the old key would have served the first caller's aeroplane to the second.
- **A provider may not rename the carrier you flew with.** LX1104 Zurich–Munich, operated by Helvetic and stored as such, was rewritten to "United Airlines" by an auto-applied update while the codes beside it still said Helvetic — on a codeshare the marketing and the operating carrier are two true answers and the provider may report either. Your own text now wins; an empty column is still filled. It also ends the "SWISS" against "Swiss" proposal on every refresh.
- **The lodging import finds a house under a decorated name.** "Hotel Meteora" beside "Hotel Restaurant Meteora", "Krafft Basel" beside "Hotel Krafft Basel" — five pairs on one account, because the matcher keyed on the exact name and the coordinate rule behind it never fired: a booking mail and a saved-places export both arrive without a pin. Two names are now offered as one house when the town agrees and they share an identifying word; a hit is a guess you confirm, never a silent merge, and "Hotel Rose" in Portland and in Bietigheim stay two houses (forgejo#84).
- **The postcode stays out of the city, from both parsers.** "188973 Singapur" and "BW 78467 Konstanz" sat in the city column: the template admitted four or five digits and nothing in front, the language model copied the city as printed, and the geocoder never repairs a city that is wrong rather than empty. One splitter now takes a leading code off the city — six digits, a state abbreviation in front, Dutch letters, a Luxembourg L- prefix — and both parsers use it (forgejo#85).
- **The same airline is the same code, on every surface.** One account counted 31, 27, 26 and 25 airlines on four screens, because identity was the name: "SWISS" and "Swiss" were two carriers, "LOT" and "LOT - Polish Airlines" two more, and four flights with no airline formed a group labelled "". Two rows are now the same airline when they share an IATA code, resolved through the catalogue; a row with no airline is counted apart and the breakdown says how many it left out (forgejo#81).
- **"Länder besucht" for a single year no longer shows the lifetime set.** The lodging domain had no per-year country index, so the overview fell back to every country ever slept in under a "2024" header — 35 where 15 were flown and 7 slept in that year. Stays are keyed by their check-in year now, like flights. A house entered without any stay still counts as its country; that is the owner's rule from the country-counting design, and the issue's second half stands the other way (forgejo#80).
- **A planned, priced stay no longer reports its hotel as "0 €".** The spend cell chose its branch from all stays but summed only the ones that count, so a hotel not yet slept in read as free. Nothing counted reads "—", and a priced stay still ahead shows a muted "geplant: 149,90 €" beneath it (forgejo#82).
- **"Add POI" on the map opens the place form.** The map's "+" menu offered it whenever the domain was switched on, and clicking it did nothing — the slot held a comment from before the domain existed. The form now opens and stays on the map after a save, so you see the pin; the menu entry follows the same visibility rule as the tab strip (#288).
- **One formatter for every amount — no more "EUR 40206" beside "40.206 €".** The same trip total was written three ways across the trip page, the cruise page and the trip card; ten sites carried their own spelling, and three of them defaulted a missing currency to EUR, turning "unknown" into a claim. Every amount now goes through one formatter, and a missing currency yields a bare number rather than an invented euro (forgejo#86).
- **The continents tile counts against the one shared table.** Statistiken → Flüge said "Kontinente 6/ 6" while its own caption said "of the 7" and the passport said 6/7 — three homes for one number, including a private six-bucket table with no Antarctica that filed McMurdo and Bermuda under "Other". Every airport now resolves through the same continent table the passport uses, and the denominator travels with the response (forgejo#87).
- **Modal backdrops dim the page again instead of painting it black.** The add-flight form opened from the map sat on a solid black page. Its backdrop used Tailwind 3 spelling for the 50 % opacity, which Tailwind 4 drops without an error — thirteen hand-rolled backdrops had the dead class; a guard now scans for it (#289).
- **The map camera stays where you left it across a domain switch.** Every dashboard tab owns its own map, and each handed MapLibre the same fixed starting view, so a user zoomed into Berlin on the flight tab landed back at zoom 2 on the cruise tab, every time — and after every save. The last camera is remembered for the session, per engine, since the flat map and the globe use different zoom scales. Deliberately not persisted across a reload; that is a product decision not yet taken (#290).
- **A year with no priced flight has no cost total, not "0 €".** The cost rule started at zero and returned zero when nothing was added, a claim that the flights were free where the truth is that nothing was recorded. The tile now shows a dash with "No price recorded — 3 flights without one", and the trend pill appears only when both years have a number (forgejo#83).
- **The backup routes answer with the honest status, not 500.** "That backup does not exist", "that backup is not finished" and "WebDAV was never switched on" all arrived as a server fault, filled the error log with client conditions, and the documented 404 could never be produced. They are 404, 400 and 409 now; only a share that answered badly is a 502, with the share's own words kept (forgejo#77).
- **The address backfill bounds the work, not the scan.** Both nightly passes — places with a pin and no description, lodgings with a pin and no address — read a user's first 500 rows and filtered those, so an account with more complete rows than that could never get an address for anything newer; the bound had become "the first page, forever". The scan now walks in cursor pages until the candidates are in hand (forgejo#43).
- **A pin in no settlement still names the place it lies in.** Five landmarks — national parks and a bay — read "—" in the place column, because the reverse geocoder consulted only city, town, village and municipality, and wilderness has none. The chain continues into hamlet, suburb, district, county and state, smallest first; the district or the state beats nothing. Rows already empty fill on the next nightly run.
- **A hotel-only trip shows the country of its stays.** A trip with linked stays and no flight or cruise read "?" for its countries, because the derivation knew only airports and ports. A linked stay contributes its country the way a linked cruise does; a cancelled booking never put anyone anywhere and is skipped.
- **The trend chart reads the airport clock, like everything else.** It bucketed by the stored UTC instant while every other "when did I fly" figure reads the clock at the departure airport, so a flight leaving Bangkok at 06:00 on 1 January sat in the old year on the chart and in the new year in the country list. The fetch window is widened a day at each edge so the flight is found at all, and one filter decides what is in the window for the series and the totals alike (forgejo#46).
- **A cancelled house is not a visited one, and an unlock date means an unlock.** A house whose stays were all cancelled still read as visited and still proved a country to the achievements. And 103 badges stood "unlocked below their requirement" because the unlock date defaulted to the row's creation — a badge at 86 of 100 carried the day tracking began, and COUNTRIES_100 appeared to predate COUNTRIES_50. Nothing on screen was wrong; the column was. The date is now set only on a genuine unlock, cleared on revocation, and the migration nulls every row sitting below its requirement.
- **A bookmarked house proves no country, so its flag goes too.** Un-marking a hotel as visited removed the country from the passport immediately while the inbox still asked about a country that no longer existed — the same two-places-two-answers fault this rework exists to remove, reappearing inside the feature built to catch it. A house's own address is still checked whether or not anybody slept there.
- **The hero tile counts countries by the rule, like everything else.** Five consumers moved onto the shared evidence rule; the hero tile — which backs the Companion's start board — kept reading "countries a flight touched", so one account answered "how many countries" twice, a swipe apart, with different numbers. It was also the very number the owner was looking at when he reported the count as too high.
- **Achievements no longer join country names where the passport joins codes.** One country counted several times — 88 against the passport's 32, from 56 distinct spellings for 33 codes ("Deutschland" and "Germany", "Cesko" and "Tschechien"). The name-to-code join now exists once and every country list resolves through it; a two-letter Japanese place name is no longer published as a country code.
- **The parser reads the card, not the margin around it.** A Wallet screenshot yielded the status-bar clock, the word "Lufthansa" and the security number — no flight, no route, no date. Tesseract picks one global threshold for the whole image, which a navy-on-orange card in a black letterbox defeats twice. Thresholding per neighbourhood takes the twelve sample passes from 16 correct fields to 37 with fewer wrong ones. Reading more of the card also read the barcode as rubble, so printed city names now win over stray three-letter fragments, and lines of one- and two-letter debris are dropped before airport codes are harvested (forgejo#36, forgejo#37).
- **The pass says which flight, not which column.** An ordinary German boarding pass came back as GRP → MUC on no date, with the boarding time for a gate and "KLASSE" for a booking reference: the date pattern demanded a clock beside it, the route took the first two three-letter tokens in reading order — which are the column headings — "Boarding" was read as a gate label, and any six-letter word was a booking reference. That last one merged a scan into an unrelated flight, since a PNR is looked up before the flight number. Known airports are preferred over reading order, a date may stand without a time, and an unlabelled reference now needs a digit (forgejo#36).
- **A gate is not a flight, however OCR spells it.** Gates print as "B08" and "E06", and O-for-0 turns them into "BO8" and "EO6" — exactly the shape of a flight number, printed above the real one. Three of twelve sample passes came back as flights EO6, KII8 and BO8. Every candidate is collected and the first with a real carrier prefix wins; an unknown prefix is still accepted when nothing better is on the pass (forgejo#36).
- **"ab 380 EUR" is a price, not flight AB380.** Six of eight archived Emirates promotions came back as a flight. The guard for it existed from the start and never fired: the candidate reached it in lower case, and the check sliced two letters, so "Nur 7 Tage gültig" passed as NUR7. Compared upper-cased on the whole alphabetic prefix; all eight promotions now yield nothing and every real booking still resolves (forgejo#35).
- **The flight wins over the weekday and the fare beside it.** A genuine Emirates confirmation produced an invented MO24 and lost its real EK0050: the context lookahead did not cross a line break, the flight number sits alone on its line, and the line below — weekday, date, "Flughafen" — contained the word the rule wanted. Every match of every pattern is collected and one whose prefix the airline catalogue knows is preferred. Measured over 337 archived mails: EK0050, UA384, LH2040, AF1423 and others recovered; DL3, BER36, PRO1 and other phantoms dropped (#291).
- **The evidence rule guards every provider, not just the fallback.** The rule that a booking must carry a flight number or both ends of a route — a date is not evidence, every marketing mail has one — lived in the template parser, which runs only when the language model is unavailable. On every instance with an LLM configured the guarded path was the one that did not run, and a "30 EUR Oster-Geschenk" promotion came back as three flights with every field empty. The rule now applies to whatever the chosen provider returned (forgejo#35).
- **A mail with no booking is an answer, not a server fault.** A provider that read the mail and found nothing was filed as a provider failure; with the chain exhausted the request became HTTP 500, and the import modal sent the user looking for a broken instance. Finding nothing now returns an empty result; only a chain in which no parser ran at all still fails (forgejo#35).
- **The route a mail prints in brackets is recovered.** An Air France confirmation writes each leg as "München (MUC), DEUTSCHLAND" and the model returned the legs without codes; nothing put them back although the text held them. The rule is structural — a three-letter code in brackets — because the airport catalogue contains DIE, DER, DAS, UND and BEI as airports somewhere, and the hand-kept safe list lacked Nantes, the very leg reported lost. Pairing is all-or-nothing, so a stray bracketed word leaves the route empty rather than guessed (forgejo#35).
- **The hotel parser is shown the booking, not the link farm.** A 2009 hotels.com confirmation came back "no booking found", and rewriting its mixed German/English dates changed nothing. Two thirds of the mail was tracking URLs; with links replaced by placeholders the whole document parsed on the first try. The cleaning the flight parser has applied all along now runs on the lodging side too — for the model only, since a template may key on a link (forgejo#34).
- **The hotel parser says who looked and what was dropped.** "No parser produced a result" read as "no parser ran", which is how a working instance came to be reported as having none; a truncated document was cut silently; and a model answer missing one required field was discarded without a word. Each is now named in the result or the log. The rule itself — a name and both dates — is unchanged (forgejo#34).
- **Saving no longer collides with a badge re-check.** Every achievement was re-evaluated inside one long transaction that most call sites did not await, so two runs for one user overlapped — a flight saved while a place tick was still going — and deadlocked in Postgres, or wrote to rows deleted underneath them. The run now decides first without touching the database and opens a transaction only when there is something to write; runs for one user are chained, and the call sites wait for them, so the work cannot outlive the request (forgejo#39).
- **A missing flight-data API key is reported as such**, not as "no flights
found" — the lookup answers `not_configured` instead of an empty result
(#232).
- **Manual flight entry no longer warns about an estimate "based on boarding
time"** that was never computed (#235).
- **The German UI says du throughout.** Formal and informal address were
mixed, sometimes on one screen; a test now reads every German string for
the formal form (#236).
- **Airport fields no longer render a second, empty label** holding only a
required asterisk (#239).
- **Ordinary marketing email was being read as a flight booking.** An Emirates
promotion and an airline's holiday greeting each came back as a bookings with
a reference of "LEIDER" and "NSCHEN" — the latter being the tail of
"WÜNSCHEN", because the umlaut broke a word boundary and left six clean
letters behind. A booking reference must now be labelled as one, and a
candidate needs a flight number or both ends of a route: a date is not
evidence, since every promotional email has one.
- **An old mailbox imported into the wrong decade.** A 2007 confirmation became
two flights in July 2026, complete with a trip named after them. A message
that names only a day and a month is now read against the date the message
itself carries, taken from the file rather than guessed from today.
- **Re-importing the same file ended in silence.** After reviewing four legs
the dialog simply closed and nothing changed, because the server had skipped
every row as already present and the count was thrown away. Every outcome is
now reported — imported, partly imported, all duplicates.
- **Buttons that promised work they would not do.** A duplicate-only import
offered "import 1 row" and imported none; the final step of a multi-leg
review said "next" while it was the click that wrote everything; a mapping
that produced no readable row kept its continue button live. Each now says
what it will actually do.
- **A lodging saved while its coordinates were being discarded.** Typing an
out-of-range latitude left the number in the field, dropped it silently and
saved the record without a location. All four dialogs that take coordinates
now say which value is wrong and decline to save until it is fixed.
- **An airport field called its own contents invalid.** Searching for something
that did not exist and then picking a real airport left the "no airports
found" message on screen beside a perfectly valid selection.
- **The statistics and trips pages scrolled sideways on a phone.** Wide charts
and the category filter row pushed the whole document past a 390px screen;
they now scroll inside themselves.
- **Screen readers were told the wrong thing.** Twelve controls announced
either an untranslated English word or a raw internal key — including the
domain switches in settings, which read "domain.flight" aloud.
- **A statistics tab could be opened by URL that the account had switched off**,
and the place statistics rendered for an instance where the domain was hidden
entirely.
- **Twenty-five new achievements spoke English on a German page.** A missing
German entry fell through to English rather than to the name already stored,
so "Stadtbummler" appeared as "City stroller" — with nothing broken, blank or
failing to show it.
- **A fresh installation created no demo cruises**, because the demo data was
seeded before the ports and ships it depends on. A first start also logged
errors for a handful of offshore helidecks that share an identifier, which is
a property of the source data rather than a fault.
- **The import log could not tell two imports apart.** Email imports were
recorded without a filename, so several on the same day were
indistinguishable and reverting the right one was guesswork.
- **The parser availability notice contradicted the parser.** The import screen
could say no AI parser was available while the very next result was labelled
as coming from one; both now answer from the same place.
- **A subscribed checklist reported itself complete.** 47 of 1,248 world
heritage sites showed as "47 / 47 — complete", because the number beside it
was never a total. The catalogue size now travels with it.
- **The "All" map knows what a place pin means.** It drew places without their
list's colour and, once lists could carry a symbol, without that either — so
a list styled on the places map looked untouched next to the flights and
cruises. The tab did not load the lists at all. Both maps in it now resolve
colour and symbol through the same step, so a pin cannot show one list's
colour and another's symbol.
- **Airports no longer carry "(Duplicate)" in their name.** The catalogue is
built from OurAirports, whose editors mark rows they suspect of being
duplicates by writing the marker into the name itself. The seeder passed those
through word for word, so the picker offered "(Duplicate)Vlora International
Airport" and "(Duplicate)Wolf's Fang Runway" as though that were their names —
a hundred and thirty-two such rows in the current file, in half a dozen bracket
shapes, some of them trailing rather than leading. The marker is now stripped
on import, and the entries already in your catalogue are repaired when the new
version starts. The rows are kept rather than discarded: for three of these
codes the marked row is the only one carrying that code at all, and one of them
is the airfield the seven-continents achievement depends on.
- **Addresses arrive in the alphabet you read.** Filling in an address asks a
geocoder, and it answered in the local language of the place — so a German
logbook collected entries in Japanese, Arabic, Armenian and Greek script:
text you cannot read, sort, or type into a search box. New entries now come
back in Latin script, and the nightly pass fetches the older ones again.
Accented names stay exactly as they are: the line is drawn at the script, not
the language, so Lëtzebuerg and Đà Nẵng are left alone.
- **A country is one country, however it is spelled.** Filling in a place's
address asks a geocoder, and it answers in the country's own language — so
the same country arrived as "Egypt" on one row and "مصر" on the next. The
places summary counted the spellings rather than the countries and reported
20 as 23, the country filter offered whichever spelling happened to sort
first, and an export sheet could hold four spellings for two countries. All
three now read the country code, and show its name in your language.
- **Help sits on the field it explains.** The settings and the admin area opened
with collapsible boxes that explained three or four fields at once, from above
the form — so the sentence about a control sat rows away from the control, and
collapsing the box once hid it for good. Each explanation now hangs on its own
field, and what a section needs to say up front is simply visible. Anything
with consequences — a restore replaces everything, a deactivated account keeps
its data, debug logging costs disk — stays as standing text rather than
becoming a tooltip that cannot be reached on a phone. The help icons are
keyboard-reachable for the first time; they were clickable `div`s before.
- **A place on the "All" map is a plain dot again**, the same mark every other
domain draws, and the country of a place shows its name rather than an em dash
next to a bare code.
- **Every "when did I fly" figure now reads the clock at the airport.** Eight of
them read the stored timestamp instead: the time-of-day buckets, weekend
warrior, fastest day, milestone year, busiest month, season explorer, most
countries in one day, and first airport visits — plus the year buckets on the
overview tab and the country year index. A flight leaving Bangkok late in the
evening could be counted onto the following day, so two figures on one screen
could disagree about the same flight. Rows stored as a wall clock are no longer
converted a second time, and a date-only row reports no hour at all rather than
being counted on its 12:00 placeholder.
- **Antarctica's airfields can be flown to.** Rothera, Marambio, Union Glacier,
Novo and Teniente Marsh were not in the catalogue, so the flights could not be
recorded at all. The seed admitted only large, medium and closed airports, and
Antarctica — having no scheduled traffic — is listed almost entirely as small.
Wolf's Fang was the lone exception, which meant "Seven Continents" effectively
ran through a single airfield. The catalogue now carries 34 Antarctic fields
instead of 12. Existing installations pick them up on the next airport
re-sync in the admin settings.
- **The Unraid install guide matched no longer-existing files.** Both template
links pointed at the pre-move filenames and returned 404, and the documented
`DATABASE_URL` used the database container's name — the custom Docker network
is gone and the default bridge has no DNS, so it never resolved. Same defect in
the Ollama endpoint.
- **The flight sidebar opened on the oldest flight.** The two sort labels were
swapped, and "newest first" — the default — did nothing at all.
- **A bookmark to a domain page no longer bounces to the dashboard.** Opening
or reloading /flights, /cruises or /lodging redirected to the start page,
because the check for whether a domain is switched on ran before the list of
switched-on domains had arrived.
- **The dashboard no longer offers a tab and an "add" button for domains that
are switched off.**
- **A stay's trip field is where you would look for it.** It sat unlabelled at
the bottom of "Loyalty programme", between membership numbers — the word
"trip" existed only for screen readers, so the sole thing visible was the
option "Not linked to a trip". It has its own section now.
- **A price held on the booking no longer reads as unknown.** A flight in a
package booking has no price of its own on purpose; the booking carries one
all-in figure. The column said "k.A.", which claims the number is missing. It
says "Pauschal" now, with the reason on hover.
- **A place ticked off a checklist no longer says "Visited" above "Visits · 0".**
Ticking records that you have been somewhere without claiming a date —
deliberate — but the empty text denied it, and hid that photo proof attaches
to a visit.
- **The startup log no longer reports the wrong backup rule.** It said "no
major-version bump" while the rule takes a snapshot before any version change
on an existing install. It prints the actual reason now.
- **A backup no longer loses every photograph.** The file archive carried three
of the six upload directories, so trip photos, place photos and profile
pictures were never in it. Photo records store only a filename, so a restore
brought back every record with none of the images — silently, Immich-imported
originals included. Backups taken before this release are incomplete and
cannot be repaired after the fact.
- **"Export all data" now exports all data.** It selected flights, achievements
and settings while calling itself a backup in its own filename. It covers
twelve domains now.
- **Cruise badges and the statistics page measure the same distance.** The badge
ladder measured straight lines across the map, including across land, while
the statistics page measured the sea route.
- **Total flight time is one number, and it says when it is estimated.** The
same figure appeared three times on one screen with three results, and the
average appeared twice under one label with two denominators. A flight with
only a date now contributes a great-circle estimate that is counted and
labelled as one, instead of counting as zero in one place and as an unmarked
estimate in another. Expect these figures to rise.
- **Flight costs are no longer added across currencies.** 300 USD and 300 EUR
were reported as "600 €", with your own display symbol on it. Amounts are
converted at the rate for the day they were spent; what cannot be converted
honestly is reported per currency instead of being folded into a total.
- **"Lufthansa" and "Deutsche Lufthansa" are one airline** in the headline tile,
as they always were in the ranking below it.
- **An empty list no longer denies the data behind the filter.** A library of
22 cruises, searched for a word none of them carries, answered "no cruises
recorded yet". The list now says nothing matches, and offers to clear the
filter.
- **A dropped connection no longer claims the entry was deleted.** Every load
failure on a detail page was reported as "not found". Only a real 404 says
that now; everything else says it could not be loaded and offers a retry.
- **The map key no longer covers the attribution** (#273). Covering it is a
licence question rather than a tidiness one — CARTO and OpenStreetMap both
require the credit to stay visible.
- **The map key reads plural throughout** (#272). Its domain rows were plural
while the two marker rows were singular.
- **The trip insight tiles line up with the trip cards** (#271). They ran to
the browser edge on a grid of their own.
- **Blocking dialogs answer the keyboard.** They share one frame now: Escape
closes them, the page behind stops scrolling, and focus returns to whatever
opened them.
- **"Duplicated" stops posing as a travel status.** It sits beside the real
status as a data-quality note.
- **Map markers anchor correctly on every map.** MapLibre's stylesheet is now
loaded globally; a lone marker used to sit only roughly right, and several
markers stacked down the page as blocks.
- **A country field that names no country is filled from the pin.** A booking
confirmation that prints only "Downtown Dubai" left a city standing in the
country filter as its own country — no flag, one country too many in every
count. Emptying a field deliberately still keeps it empty, and a country name
the resolver knows is never overruled by a lookup.
- **The parser judges what it is looking at, instead of only transcribing it.**
The kind of place (a campground is not a hotel), the group behind a brand
("Courtyard by Marriott" -> Marriott), and the country a city sits in are now
asked for. Measured on real confirmations: six campgrounds recognised as
campsites, and US bookings that used to store "TX" — a state — now store the
country.
- **An unknown chain is offered, never created behind your back.** The import
used to add any chain name it was handed, so a single run could mint entries
nobody chose. The preview now flags a chain the catalogue does not know and
asks once.
- **The lodging filter lists countries, not spellings.** One entry covers
"Deutschland" and "Germany", named in the reader's language.
- **A hotel confirmation could not be read at all once a flight had been
parsed before it.** The lodging and cruise parsers asked the language model
for one context size and the flight parser for another; a model is loaded per
context size, so every alternation forced a full 9.9 GB reload. Measured
against a real host: a request matching the loaded model answers in 7
seconds, one that forces the reload did not answer within 240 — well past the
parser's own limit, so it gave up and offered manual entry. All three now ask
for the same size.
- **The total on a confirmation is read from the document, not guessed.** Two
identical runs of the same booking returned the full amount once and the bare
room rate the other time — a difference of 2,142 AED on one stay. A total the
document actually labels now overrules the model. A number counts as money
only if it carries a currency or two decimals, so "(1 Erwachsener)" beside the
word "Gesamtpreis" stays a guest count; and the amount belonging to a label is
the nearest one, not the last.
- **Confirmations are read for what they say.** The meal plan, the per-night
rate and how many people the booking covers were never asked for, though 61 %,
47 % and 42 % of real confirmations state them. A booking for more than one
person now points at the companions field — it never invents a name, because
a confirmation names the booker, not the companion.
- **A country is a country, whatever language wrote it.** The lodging filter
listed 60 entries for 33 countries, "Deutschland" beside "Germany". Grouping
now runs on the ISO code; the original text is kept untouched as the record of
what the source said.
- **The import looks for the house before creating it a second time.** It
compared names and cities only, so one building became two records whenever a
booking mail and a saved-places export wrote it differently. It now also
compares where the house IS, and reads through decoration like "Hotel",
"Restaurant" or "GmbH".
- **A house number goes where the country puts it** — "50 Southwest Morrison
Street", not "Southwest Morrison Street 50".
- **A total that leaves entries out now says so** — on the stay, on the lodging,
in the list and in the statistics strip, rather than quietly summing what it
could and presenting it as complete.
- **The lodging list stopped at 200 rows without a word.** With more hotels than
that, everything past the cut simply did not exist for the list — while the
counter above it reported the true number.
- **A price whose currency was thrown away is no longer read as euros.** The API
refuses an amount without a currency instead of guessing one.
- **"Länder besucht" counts every country a flight touched** (#233), not only
the ones it started or ended in.
- Numerous smaller corrections from the beta review: a stay priced only per
night now reaches the trip total, the overall rating is derived on every write
path, a hotel-only trip shows its cost, and the import surface no longer has
dead ends.
- **Clearing a location no longer invents a new one.** Emptying address, city
and country handed the geocoder the hotel's NAME and adopted whatever came
back, so a cleared row returned with a different address and a matching pin
somewhere else entirely. A deliberately emptied field is an instruction, not
a gap.
- **A flag for a country that names itself in its own language.** The resolver
understood German and English, while imported data arrives as "Italia",
"Sverige", "Việt Nam" or "Schweiz/Suisse/Svizzera/Svizra". It now reads a
country name in any language the runtime knows — the interface stays German
and English; those are different questions. The flag also NAMES its country
now, as a tooltip and to screen readers, instead of being a decoration they
were told to ignore.
- **A hotel check-in no longer sorts above the flight that brought you there.**
A stay carries no time of day and is stored at midnight, which the timeline
read as 00:00 — the earliest moment of the day — rather than as unknown.
- **The lodging form scrolls.** With the map picker or the advanced panel open
it grew past the viewport with nothing to scroll and the save button out of
reach. Its map now follows a search instead of staying on the world view with
the pin off-screen, and deliberately does NOT follow a marker drag.
- **Sample documents can no longer be committed** — the ignore rules covered
mail files but not documents.
- **Flight arcs start at the airport again.** Every flight kept its own copy of
the departure and arrival coordinates, written by whichever source created it
— and providers quote different reference points for the same airport, so one
airport sat in several places at once. Measured on one account: 58 of 694
references disagreed with the catalogue, the worst by 1.6 km, which drew arcs
that visibly missed their own airport dot. The catalogue is the authority now,
both when drawing the map and when writing a flight.
- **The map says what is clickable.** Airport dots, route arcs, ports and
lodging pins all open something on click; the pointer never showed it.
- **Beta features can be switched on from the admin surface**, instead of only
through an API call.
- **Unticking a chain no longer looks like deleting the card.** A loyalty
programme leaves that chain's page by design — it now says so before you save,
and names where the card stays reachable.
- **Names on the trip map thin out with zoom**, as they already did everywhere
else.
- **Something planned is no longer counted as done.** One tooltip reading
"1× geflogen" over a route nobody had flown yet led to thirteen places across
every domain with the same blindness: countries "visited" by a scheduled
flight, cruise figures counting bookings, cruise achievements unlocked by
booking alone, a chain's average rating including unvisited stays, "last
visit" dates in the future, flown-km sums including planned flights, and trip
superlatives crowned by a merely planned trip. Every count, label and unlock
now asks whether the thing actually happened; a mixed route says
"3× geflogen · 1× geplant". The itinerary map of a booked cruise keeps its
port pins — it just no longer claims you have been there.
- **A brand-new account is not greeted with old news.** Signing up put "New in
TravStats 2.6.0" in front of someone who had never run 2.5. The running
version is now recorded as seen when the account is created — on
registration, admin-created users and first-boot setup alike.
- **An untouched flight form no longer classifies the flight.** Seat class and
category were silently preselected ("Economy", "Business"), so a
never-classified flight was stored as an economy business trip. Both start
empty now, and the settings page gained an explicit "no default" choice.
- **A selected flight no longer follows you into another domain.** The popup
card and its selection rings survived the dashboard tab switch and kept
rendering over the lodging map.
- **The login page stops advertising a locked door.** With registration
disabled the "Register" link is hidden; invitation links still work, and the
rejection now arrives in the UI's language.
- **A lodging without coordinates is no longer a dead row.** The dashboard
list links every lodging to its detail page and marks the unlocated ones;
"no location on file" on the detail page now offers to search one right
there.
- **The place search can no longer fail silently.** The OSM request now
identifies itself (it sent no User-Agent — exactly the traffic public OSM
infrastructure blanket-blocks), retries without an unsupported UI language
instead of returning nothing, tolerates a base URL already ending in /api,
and a geocoder outage now reads "search unavailable" instead of the
misleading "no results" — including in the admin connection test.
- **Messages arrive in the UI's language.** API-key test results and the
registration rejection were English prose in a German UI; they now travel as
message keys the frontend translates.
- **Ones are singular.** "1 Übernachtungen", "1 Hotel(s)" and friends now
inflect properly, and the multiplier reads "×" everywhere.
- **The flights tab greets an empty logbook** with the cruises tab's empty
state — what the tab shows, plus both ways to get a first flight in; the
lodging tab's card gained the same call to action.
- **Airport search stopped ranking closed heliports.** "GRU" returns São Paulo
instead of nine closed US strips whose identifiers merely contain the
letters; closed airports stay findable by their exact code.
- **Small honesty fixes across the UI.** Tab counts say "(n planned)" so they
stop contradicting the flown-only statistics; the settings footer claims
auto-save only where auto-save exists; the whole domain tile toggles; the
flight-number search date field got a real label; the support menu speaks
the UI's language.
- **SMTP credentials can be deleted again.** The mail settings offered no way
to take a stored password back out — the toggle only stops sending, and
saving can overwrite the secret but never clear it. A delete button now
appears wherever something is actually stored, behind a confirmation that
names the consequence, and clearing the configuration empties the form with
it.
- **The beta-features switch applies immediately.** Turning it on or off used
to reach the gated surfaces — the Devices entry, the POI dashboard tab, the
trip summary card — only after a full page reload.
- **The parsers stopped filling in what the document does not say.** A cruise
confirmation without any flight numbers no longer arrives with an invented
one; a booking's price is the total actually charged for everyone after
discounts, not one guest's pre-discount fare; a two-digit year in a hotel
confirmation is read against the travel period rather than the letter date,
and only an explicitly labelled booking number is taken as one.
- **Countries are counted once, however they were spelled.** The lodging
statistics grouped by the country text a booking mail happened to use, so
"Deutschland" and "Germany" appeared as two countries with the nights and the
money split between them — in the country list, the price and rating
rankings, and the cross-domain "countries visited" tile, where flights and
cruises had always contributed codes. Continents were affected too: the
lookup understands codes and English names, so German text fell back to a
coarse coordinate guess, and a house without coordinates lost its continent
entirely.
- **Flight totals count flights that happened.** The year card put a flight
count covering booked and cancelled flights next to a distance and a flight
time covering only the flown ones, and the mobile app's home board could
report one flight with zero kilometres and zero airports. Every headline
figure now describes the same set, and what is merely booked is reported
beside it instead of inside it.
- **"Aktive Reisetage" counts days, not one tally per domain.** A day with a
flight and a hotel night counted twice, and the figure could pass 365 in a
year.
- **A stay without a date is no longer called a double booking.** The rhythm
section reported undated stays as "n Nächte doppelt gebucht"; it now measures
overlap against the nights that can be placed on a calendar at all, and says
plainly how many stays it had to leave out.
- **The lodging statistics page stopped covering itself.** The figures strip
and the currency card float over the dashboard map by design; on the
statistics page they sat on top of the tiles below, and the translucent
currency card let the hotel names show through.
- **Confirmation dialogs answer the mouse again.** An invisible backdrop lay
over every dialog built on the shared component: the click landed on the
backdrop, which closes the dialog, so confirming looked like it had worked
while nothing happened — including the SMTP deletion this release
introduces. Dialogs also close on Escape now and announce themselves to
screen readers.
- **The map picker opens where the point is** — it showed the world view
whenever the position arrived while the picker was shut, leaving the pin off
screen — and it lists the places around a pin the search placed, not just one
the map placed.
- **Amounts follow the interface language.** Only seven currencies carried a
locale, so for the other 148 the amount was written in the browser's language
while the exchange rate beneath it followed the interface — two number
formats in one card.
- **Smaller counting corrections.** Round trips are limited by the thinner
direction (three flights out and one back is one round trip, not two); the
continent tile stopped counting its own "other" bucket as a continent; the
revisit rate no longer treats an unresolved port as a return visit; the
sea-day share cannot exceed the days sailed; a flight with no airline is not
ranked as an airline called "Unknown"; cruise lines are ordered by how often
they were sailed; and the country distribution card says "origin and
destination", which is what it has counted since 2.5.
- **Achievements react to catalogue edits again.** Points, tiers and wording
were frozen on any installation whose stored achievement count happened to
match the seed count. Two badges also corrected themselves: the loyalty badge
sat at gold between the bronze and silver rungs of its own ladder, and a
distance badge claimed 50,000 km is the Earth's circumference.
### Security
- **The page carries a Content-Security-Policy.** Helmet set one, but only on `/api` responses, which govern nothing that matters for script injection — the HTML that decides whether injected script may run was served with no CSP at all. `script-src 'self'` with no `unsafe-eval`, measured against the production bundle rather than hoped for; `connect-src` stays deliberately broad because a self-hosted instance talks to the Immich, Dawarich or OSRM the operator names, often over plain http on a LAN. Verified in a browser — the first attempt silently blocked the fonts and Leaflet's stylesheet, which no reading of the header would have shown (forgejo#29).
- **A deactivated account is refused a session, on every path.** Login answered a disabled account with HTTP 200 and a fresh cookie; the lock held on the next request, but the door still handed out a key. Five routes mint a session — login, registration, first-boot setup, two-factor verification and passkey assertion — and all five now go through one function that refuses an inactive subject. The two-factor branch is guarded separately, so a disabled account no longer receives a "password accepted, now finish signing in" (forgejo#31).
- **Every unlimited route now carries a verdict.** The rule "rate limiting on every expensive endpoint" was practised but unreadable: a reader could not tell "considered" from "forgotten". Twenty-five routers now carry a limiter or one sentence saying why they need none. Limited on the way, by cost: the Immich photo-journey scan (the most expensive request in the app), CSV preview and parse, training and photo uploads, the cruise geometry batch, the achievements leaderboard — the one route whose cost scales with the instance rather than the caller — and the checklist suggestions (forgejo#51).
- **Rate limits count per user, not per address.** The flight-lookup, analytics, PDF-parse and boarding-pass limiters sat behind authentication but were keyed by IP, so a household — or every user behind one reverse proxy — shared a single bucket and one person's lookups blocked everyone on the same connection. A guard now demands the per-user key on every limiter that runs after authentication (forgejo#68).
- **Refreshing the airline templates is an admin action.** Any signed-in account could replace the template registry every user of the instance parses with, and spend the instance's three GitHub refreshes per hour. The status stays readable by everyone — which airlines the mail parser understands is a user question — and the page explains why the button is missing instead of offering one that always fails (forgejo#67).
- **The data export no longer carries stored API keys.** It pulled the entire
settings record into a downloadable file, including OpenAI, Claude, AirLabs,
Aviationstack, AeroDataBox, OpenSky and Immich credentials. They are encrypted
at rest, but nothing in an export needs them.
- Closed an IDOR in the lodging import commit path, scoped receipt access to the
owner, and hardened the import HTTP surface (four findings from a scoped
review).
- **A read-only access token could change flights.** The pending-updates routes
checked only that a caller was authenticated, so a token issued for reading
could apply, edit or delete a pending update — and applying one writes to the
flight underneath. Those routes now require write scope like every other
mutating route; read tokens keep their read access.
## [2.5.2] - 2026-08-12
Hotfix release. Three ways live flight data went wrong, no database changes.
### Fixed
- **Flight numbers printed with a leading zero found nothing at all.**
Airlines write "EK051" on the itinerary and that is what gets stored, but
every flight-data provider keys on the unpadded form, "EK51", and answers
the padded query with an empty result. Live tracking, automatic updates and
enrichment therefore did nothing whatsoever for Emirates, Qatar, Gulf Air,
EgyptAir and every other airline that pads — silently, because "no data"
looks the same as "nothing to report". Requests now drop the padding while
the stored number keeps your spelling: the answer is mapped back onto it,
so an automatic update cannot quietly rename your flight to the provider's
convention.
- **Departure and arrival times were an airport's offset too early.**
AirLabs returns both a local and a UTC time, and neither carries a zone
marker, so the UTC value was read a second time as local — an Emirates
flight leaving Dubai at 15:55 was recorded as departing 07:55 UTC instead
of 11:55. The UTC values are now marked as such before conversion; times
that only exist as local are still converted as before.
- **Live tracking could move a flight to the previous day.** Flight-data
providers identify a rotation by its LOCAL departure date, but the
automatic update asked for the UTC day of the departure instant. For any
early-morning departure east of Greenwich those differ — a Sydney 06:00
departure is still the previous day in UTC — so the update fetched the
previous day's aircraft and, with approval disabled, rewrote the flight a
full day into the past, actual times and "flown" status included. Lookups
now use the departure airport's local calendar day, and any API answer
whose scheduled departure is more than 12 hours away from the stored one
is rejected as the wrong rotation instead of being proposed at all.
- **The built-in parser misread Emirates bookings.** The itinerary tables
put the time, the weekday and a two-digit-year date on three separate
lines — the parser required four-digit years and never associated the
times, so the actual legs came out dateless. Worse, the legal footer
("Erlass Nr. 2 von 1985") parsed as the flight date 1985-01-02, because
an unrecognised month silently became January. Month names are now
validated, implausible years are rejected, two-digit years are
understood, and a time on the line above its date is paired with it.
- **Very long booking emails were silently cut off before the AI parser saw
them.** Anything beyond 5,000 characters was dropped without a trace —
and return legs live at the end of an itinerary. The window is now
12,000 characters and a truncation is logged.
### Upgrade notes
- No database migrations in this release.
- Flights that live tracking already shifted to a wrong day are not
corrected retroactively — check recent long-haul arrivals whose departure
was an early morning east of UTC.
- Times already stored an offset too early are likewise not corrected
retroactively. Re-running a lookup on an affected flight now returns the
right value.
## [2.5.1] - 2026-08-09
Patch release. Three fixes, no database changes.
### Fixed
- **The aeroplane between two airports flew nose-down.** The glyph in the flight
table's route column was rotated by a further 45 degrees although it already
points due east, so a page of flights read as a page of crashes. It flies level
now.
- **An ordinary action wrote to the error log.** Adding a companion you already
have was implemented as "create the record, and put it right when that fails",
so normal use filled the error log with entries that described nothing wrong —
83 of them on a single start. It is one operation now, and the log stays quiet.
- **A mistake in a request is no longer recorded as a fault of the server.** A
rejected form or an expired session was written at error level, exactly like a
crash, so the log could not answer whether anything was actually broken — and
anyone who could reach the port could make it grow without logging in. Genuine
server faults still log as errors; everything below them is a warning.
### Upgrade notes
- No database migrations in this release.
## [2.5.0] - 2026-08-09
Large minor release. Trips can pull in your Immich photo albums, travel
companions become real records instead of loose text, airlines and aircraft
get a proper catalogue behind the scenes, the flight list is rebuilt as a
departures board, adding and editing a flight finally use the same form, and
status stops being something you set by hand.
### Added
- **Immich photo albums on a trip** (#154). Link an album from your own Immich
server in one of two modes: _linked_, where the images stream through an
access-checked proxy and take no storage here at all, or _imported_, where
the originals are copied in as ordinary trip photos. The gallery groups by
album, any picture can become the trip's cover, and a re-sync collects
stragglers. Photos run oldest first and divide into days, so an album reads
the way the journey did, and the grouping can be switched off. The key field
names the read-only permissions it needs, so nobody has to hand over a
full-access key. The album picker has a search filter (#181), and an
administrator can configure one instance-wide connection (#182).
- **Travel companions are real entries.** Co-passengers used to be free text
on each record. They are now created once and reused everywhere — flights,
trips, cruises and special flights all share the same picker. Existing
entries are migrated on first boot.
- **Airline and aircraft master data** (#189, #191). Both get a catalogue with
the same admin treatment ships and ports already had, seeded from OpenFlights
and searchable from the flight form. Airports join the same page.
- **Airline logos as brand tiles.** The flight list shows each airline as a
logo tile, resolved through a four-tier chain with a keyless default, cached
on disk and refreshed nightly.
- **The flight list is a departures board.** Departure and arrival share one
time column, country flags mark the route, actions are icons, and a dot on
the row says where the data came from.
- **One flight form, with scheduled and actual times** (#199, #200). Adding and
editing use the same mask. New: scheduled vs. actual times with a derived
delay — the recorded time sits beside the scheduled one in the flight list,
red when later and green when earlier — plus boarding group, trip
assignment, cost and receipt.
- **Status is derived from the dates** — for flights, cruises and trips alike,
re-derived hourly. Only "cancelled" is still set by hand.
- **Grouped navigation and a central import hub.** The desktop bar gains
Logbuch/Support/System submenus; every import route now starts in one place.
- **Prices belong to a booking.** A multi-leg booking carries one price instead
of a repeated per-segment total, editable from the trip page, with
per-currency totals ("EUR 300 + USD 480") rather than naive summing.
- **The trip timeline rail is coloured by progress** (#184) instead of graying
out entries.
- Aircraft statistics rank types rather than tail numbers, the airline ranking
carries its IATA code, and the achievements summary carries a rank.
### Fixed
- **Settings marked "auto-saved" that were not** (#198). Flight defaults now
actually persist — and clearing a field clears it, in every form.
- **An expired session showed the dashboard and a shower of 401s** instead of
the login page.
- **A flight more than 30 minutes late lost its arrival permanently.** It now
keeps chasing it.
- **A half-imported airport catalogue rendered UTC as if it were local time.**
Re-seeding now leaves a usable catalogue behind rather than just rows, and a
closed airfield is never picked as the nearest airport.
- **"Countries visited" ignored the selected year** and carried a
year-over-year comparison that could only ever read zero. It is year-scoped
now, and a country reached both by air and by sea counts once, not twice.
- **A blank time was silently stored as noon**, producing invented delays; a
recorded cabin class was priced but never saved.
- **A diary entry's preview printed its own markup** (#231). The timeline card
showed `**bold**` with the asterisks intact while the same entry rendered
properly once opened. The preview now renders, with headings and lists
flattened to fit a two-line card; the headline above it is stripped to plain
text instead.
- **The uploaded-photos box no longer lingers** once an album is linked (#179).
- Trip aggregates use airport-local times and see manually added flights.
- **A trip's card and its own page disagreed.** The card read "?" countries and
no total where the trip's page read five countries and a price. The country
fallback had only been taught to the detail endpoint, and the card's total was
hidden behind the cost-tracking toggle — which since #192 governs the
taxes/fees breakdown, not whether a price is shown — while also ignoring
prices entered on a flight rather than a booking. Both surfaces now answer
from the same sources, and a trip made only of cruises takes its countries
from the ports it called at instead of showing "?".
- **The cruise tab counted one country too many.** The port catalogue lists both
"United States" and "United States of America", so counting names counted the
same country twice. The tally folds them; the country list below still shows
the names as recorded.
- **The built-in demo account is now marked as one.** Created through the Docker
demo-user switch it was not, so its sample flights and cruises counted towards
the instance-wide statistics as though they were real travel. Existing
installs are corrected on startup.
- **Aircraft types are written consistently.** A library built up over years
could hold "Airbus A350-900", "B737-800" and "A320neo" side by side in the
same column; only newly entered flights were ever normalised. Stored types are
brought to the catalogue's spelling on startup, and a type the catalogue does
not know is left exactly as you typed it.
- **Cruise regions are translated, all of them.** Ten regions had German names
hardcoded while the port catalogue ships fifty-four, so a German page showed
"Mittelmeer" and "Ostsee" next to "North Sea" and "Iberian Atlantic" — and an
English page got the German names for those ten. All fifty-four now exist in
both languages.
- **Two airline rankings on the statistics page disagreed.** One counted only
the flights actually flown, the other counted every booking including
cancelled and still-scheduled ones, so the same airline appeared with two
different totals a few centimetres apart. Both now count flown and historical
flights, the way every other statistic does — and a cancelled flight no longer
adds its distance to an aircraft's lifetime total.
- **Cruises counted towards neither the distance nor the cost of a trip.** A
trip made only of cruises showed no kilometres and no total although the
prices and the computed sea routes were on file. Cruises now count exactly as
flights do, on the trip card and on the trip's own page.
### Security
- react-router 6 → 7, closing the open-redirect advisories, plus every other
open dependency alert in the app and in the Discord tool.
- Secrets shorter than 16 bytes were returned as ciphertext by a length
heuristic; upload-cleanup paths were hardened; API-key material no longer
reaches log messages.
### Upgrade notes
- **Seven database migrations** run automatically on first boot of this
version. Take your usual backup first.
- The Docker image now vendors the airline logo set and the OpenFlights seed
data; no configuration change is required.
- A one-shot backfill migrates existing companion text and booking prices at
startup.
- Two further one-off repairs run at startup alongside the migrations: stored
aircraft types are normalised to the catalogue's spelling, and an existing
demo account is flagged as a demo account. Both are safe to re-run and skip
what they do not recognise.
## [2.4.0] - 2026-07-11
Minor release. Six reported bugs, an overhaul of how the map is coloured, and a
switch that keeps unfinished features out of sight until they are ready.
### Added
- **Map colouring is now an explicit choice, for flights and for cruises.**
Previously the colour picker was quietly overridden by whichever view you were
in, so picking a colour appeared to do nothing at all. Both domains now offer
the same three modes: **Status** (two colours — flown/sailed vs. planned),
**Frequency** for flights (one base colour, deeper the more often you fly a
route) or **Per cruise** for ships (a distinct colour per voyage), and
**Solid** (one colour for everything). The legend follows the mode you pick,
and status mode gives you a separate colour picker per state.
- **Flat route shape for flights** (#183). Flight routes can now be drawn flat on
the map like cruise routes, instead of as 3D arcs. Arcs look better from a
distance, but their endpoints drift away from the airports as you zoom in; the
flat shape stays accurate at any zoom.
- **Beta-features switch.** Features that are not finished yet are now hidden by
default and can be revealed instance-wide by an administrator. On a fresh
install this hides the POI dashboard tab, the Devices settings page and the
trip AI summary.
- **The profile picture can be removed.** A Remove button next to the file
picker deletes the stored picture; the initial-letter avatar returns.
- **Flight price and currency are always editable** (#192). The fields sat
behind the cost-tracking toggle while cruises always showed them — an
imported price was visible in the flight list but not editable. The toggle
now only gates the taxes/fees breakdown and the business statistics.
- **The map control panel starts collapsed and remembers its state** (#194).
It no longer springs open on every reload; opening or closing it persists
across reloads and the 2D/globe switch.
- **The settings domain switch moved into the sidebar.** As a top-right pill
row it was easy to miss; on desktop it now sits directly above the section
list it scopes. On phones it stays in the top bar.
- **Booking reference and ticket number when adding a flight manually** (#197).
Both fields only appeared in the edit dialog after a flight was saved. The
manual add form now offers them directly, and the booking reference is
uppercased on entry so manually added flights group into the same booking
as imported ones.
- **The What's New dialog covers 2.4.0.** A short in-app summary of this
release's changes (in German and English), shown once after updating.
### Fixed
- **Cruise port names with non-English characters render correctly** (#185).
Ålesund, Flåm, Málaga and Kuşadası previously drew as empty boxes on the map.
- **Cruise direction arrows no longer sit inside the ports.** They were anchored
at a fixed position in the route's point list rather than along its actual
length, which on a short leg put them on top of the harbour. They now sit at
even intervals along the leg, and very short hops get none.
- **The profile picture and birthday are saved** (#186). Uploading a picture had
no backend endpoint at all, and typing a birthday never triggered a save —
while the page still displayed "auto-saved". Both now persist.
- **Airport and port dots are the same size** on the flat map (#187).
- **The statistics "compare with" year survives a page reload** (#188).
- **Airlines are shown by name, not by code** (#178). ICAO codes now resolve to
full names, from the same catalogue the backend uses.
- **Achievements: continents are resolved by country.** The Arctic was counted as
Antarctica (which made Svalbard flights unlock an Antarctica badge) and
Australia as Asia. Badges that no longer qualify are now revoked instead of
staying unlocked forever.
- **Direction arrows on out-and-back cruise routes no longer stack into an "X".**
When the same water is sailed in both directions (there and back on one
route), the two midpoint arrows landed on exactly the same spot. They now
flank the midpoint, each pointing in its own direction of travel. Arrows also
render sharp at every size — they were rasterised at their native size and
went blurry when scaled up on high-DPI screens.
- **The admin instance-settings form no longer resets while you type** (#190).
Every keystroke re-triggered the form's initial load, which overwrote the
input with the server's values — editing the public URL was impossible. The
underlying cause (an unstable translation-function identity re-firing
effects on every render) is fixed app-wide.
- **Uploading a profile picture no longer ends in "Not found".** The web
server's static-asset cache rule intercepted API URLs ending in an image
extension, so the freshly uploaded picture 404'd before the request ever
reached the backend. Image receipts were affected by the same rule.
- **The year dropdown in the map panel is readable again** (#196). On Windows
the dropdown popup rendered white-on-white; the options now carry explicit
dark styling. The globe's performance dropdown had the same flaw.
- **The map panel chevron points the way the panel moves** (#195) — down while
open, up while collapsed — and is larger.
- **Dark text on amber buttons** (#193). The trip module's primary buttons
referenced a CSS variable that does not exist, so their text silently
rendered near-white on the amber accent; several admin and cruise buttons
hardcoded white. All of them now use the same dark text as the standard
primary button.
- **Every button in the app follows the same system.** A dozen controls still
carried hardcoded blues, greens and oranges from before the design system
existed — the admin API-key and parser saves, the airport-seeding dialog, the
pending-update card and editor, the flight review submit, the duplicate
warning and the delete-demo-user button — so on those surfaces the loudest
control was the off-brand one. They now use the shared primary, secondary and
danger classes and follow the accent colour. Two were also wrong on the
merits, not just off-palette: the pending-update card highlighted **Edit**
while **Apply** — the action the card exists for — sat in green beside it, and
the duplicate warning painted the risky "add anyway" escape hatch louder than
the recommended merge. In both, the recommended action is now the primary one.
Deleting the demo user and rejecting a pending update are destructive, and now
look it.
- **An update now actually reaches the browser.** The app shell (`index.html`)
was served without any caching instructions, so browsers were free to reuse
their copy for hours without asking the server. That stale copy still points
at the previous build's JavaScript files: either they are still in the
browser's cache and you keep running the old frontend against the new backend,
or they have been evicted, the request 404s and the page stays blank. The
shell is now revalidated on every load, while the versioned assets keep their
long cache. If you have ever had to hard-refresh TravStats after an update,
this was why.
- **Cruise direction arrows no longer pile up on shared shipping lanes.**
Different cruises routed onto the same lane each placed their own arrows,
stacking into unreadable clusters. Overlapping arrows are now hidden and
reappear as you zoom in; a selected cruise always keeps its own.
### Changed — please read before updating
- **Flight colouring:** existing settings migrate to **Status** mode. The
combined view looks unchanged; the flights-only view switches from the
frequency ramp to the two status colours. Switch back to **Frequency** in the
map panel if you prefer it.
- **Cruise colouring:** the Cruises tab no longer colours each voyage
automatically. It starts in **Status** mode — pick **Per cruise** in the map
panel to get the old look back (one click, and now discoverable).
- **Hidden by default:** POI tab, Devices settings and the trip AI summary now
require the beta-features switch.
### Removed
- **The "Map settings" section is gone from the flight settings tab.** Base
map, start zoom, marker style and route colour had no effect anywhere since
the in-map control panel took over — every control there was a silent no-op.
## [2.3.1] - 2026-07-11
Patch release. Fixes the release-highlights dialog, which never appeared in
2.3.0 — and with it the usage-statistics consent prompt, so nobody was ever
asked.
### Fixed
- **The "What's new" dialog now actually appears.** In 2.3.0 the dialog was
matched to the running version by an exact string comparison, and its only
entry was tagged with the version the feature had originally been planned
for. On a 2.3.0 installation nothing matched, so the dialog stayed hidden —
taking the anonymous-usage-statistics consent prompt with it, since that
prompt is shown inside the dialog. Highlights are now matched to any version
at or above the one they describe, so the dialog also reaches someone who
skips several releases at once, and dismissing it is remembered across
later patch updates.
- **Database index name reconciled with the schema.** A hand-written migration
had created the trip-photos index under a different name than the schema
expects, so every newly generated migration on every branch carried a
spurious rename. Renamed once (metadata only — no data is touched).
## [2.3.0] - 2026-07-11
**TravStats 2.3.** Aggregates the entire `dev/v2.3` forward line — a
redesigned map/globe appearance system, per-cruise and status-based map
coloring, a new statistics scorecard, unresolved cruise ports, device
pairing for the companion app — and adds opt-in anonymous usage
statistics with a What's-New dialog.
### Added
- **Anonymous usage statistics (opt-in)** — TravStats can now optionally
report a small, anonymous usage summary to a public dashboard at
`travstats.de/stats`, so the community's reach is visible. **Off by
default** and admin-controlled: a consent choice appears during first-boot
setup and in the What's-New dialog, with a permanent toggle under
Admin → Instance. The payload is coarse and anonymous — version, enabled
domains, rounded distance totals, feature usage — and **never** IP
addresses, names, travel details, or API keys. Withdrawing consent asks
the server to erase this installation's record. Self-hosters can disable
all sending with a single environment variable. Full transparency page at
`travstats.de/docs/usage-statistics`.
- **What's-New dialog** — after an update, a one-time dialog summarises what
changed in the new version.
- **Continuous map sliders** — line width and marker size are now
stepless sliders with a live value (replacing the fixed Thin/Normal/
Thick and Off/S/M/L presets), on both the 2D map and the globe, plus a
new **cruise route-arrow size** slider (0 hides the arrows).
- **2D-map hover tooltips match the globe** — airports, ports, flight
routes and cruise routes now show a country flag, ICAO/IATA code,
place and count on hover.
- **POI/stop labels on the trip map** — trip stops (POIs, hotels, …)
show their name on the map instead of a bare coloured dot.
- **Redesigned map & globe experience** — consolidated appearance
control panel shared between the 2D map and globe, mode-aware
presets (marker size, route width, basemap parity), readable
port/airport labels with priority reveal on zoom, flag-based markers
(flagcdn, not emoji), and an activity histogram with playback
replacing the old time slider.
- **Per-cruise and status-based map coloring** — cruises can now use a
distinct color per trip or the default two-tone (sailed/planned)
status coloring; flights get a warm/cool status split
(flown/upcoming) in the "All" view, with intensity scaled by route
frequency.
- **Statistics: KPI scorecard & trend charts** — a new overview
scorecard (sparklines, time-range control) and a canonical
flights-per-period chart, backed by a new `GET /stats/timeseries`
endpoint.
- **Unresolved cruise ports** — an imported stop that can't be matched
to the port catalog is kept as a port call with its original name
(🔶 in the timeline) instead of being silently downgraded to a sea
day; resolvable later from the stops editor.
- **Cruise table improvements** — sortable columns, inline
duplicate/edit/delete actions, and country-flag chips per cruise.
- **Device pairing for the companion app** — secure claim-code flow
(start/status/claim/unpair) with a live QR on the new Settings →
Devices page, plus per-device metadata on API tokens.
- **Cross-device app settings sync** — `GET/PUT /api/v1/app-settings`
persists mobile-app preferences server-side.
- **Boarding-pass QR from photos** — digital boarding passes can now
be decoded from an uploaded image, not just a live scan.
- **Trip diary entries** — a read-only diary view with Markdown
rendering on the journey detail page.
- **Discord community integration** — a community link in the app
header, plus release/beta/RC announcement tooling.
- A proper 404 page for unknown routes (was a blank page).
### Fixed
- **Admin backup page no longer scroll-jumps** — the backup screen's
5-second status poll was flipping the full-page loading state on every
tick, blanking the table and snapping the scroll position back to the
top every few seconds; the poll now refreshes the list silently (#180).
- **Loading labels showed a raw translation key** — several admin and app
loading states displayed the literal text `common:loading` instead of
"Loading…", and two "close" controls (including a screen-reader label) were
likewise unresolved. All now show the correct text, guarded by a test.
- Cruise stops with an ambiguous name (e.g. "Naples") now resolve to
the correctly-catalogued port instead of a same-named placeholder in
another country (#169).
- The map colour legend now sits in a compact bottom-right table, so the
globe's activity time-histogram no longer overlaps it on the "All" tab.
- Cruise-arc arrows now point in the correct travel direction and have
a bordered, easier-to-read shape (Discord bug report #160).
- Overlapping airport/port labels in dense map clusters are
decluttered.
- The stats overlay no longer collides with the map's Add button.
- Time histogram: proportionate collapsed layout, a properly
positioned year axis, confusing speed selector removed.
- Statistics trend deltas show percent-only and hide cleanly when
there's no comparison window; all-time charts trim empty leading
buckets.
- Various cruise-stop edge cases (unresolved-port singular/plural
copy, sea-day toggle clearing stale names, import-preview wording).
- Boarding-pass duplicate detection is now timezone-aware.
- AI trip-summary generation uses the long parser timeout instead of a
10s default.
## [2.2.2] - 2026-07-04
### Fixed
- **No phantom vertical scrollbar on horizontal tab bars** — the trip-detail,
admin, statistics and parser tab bars no longer show a stray vertical scroll
bar; horizontal scrolling on small screens is unaffected. (#155)
- **Bug reports attach the diagnostic bundle as a file** — "Report a bug" now
downloads the bundle for you to drag into the GitHub form instead of copying
it for a paste that GitHub rejects as too large. (#157)
- **Admin panel shows the clean release version** — after switching an RC image
back to the release tag, the panel no longer looks stuck on the RC version;
the exact build is shown on hover. (#156)
- **Correct English comparison label in statistics** — the year-over-year delta
badge showed the German "ggü." in the English UI; it now reads "vs".
## [2.2.1] - 2026-07-04
### Fixed
- **Uploaded photos now survive container updates** — Trip photos, receipts and
imported files were stored inside the container instead of on the mounted data
volume, so they disappeared on every image update. Uploads now live on the
persistent `/app/data` volume automatically, with no extra bind-mount
required; an existing manual upload mount is detected and left untouched. (#152)
- **Large photo and PDF uploads no longer fail** — The built-in web server
capped request bodies at 1 MB and rejected larger photo or PDF uploads with a
"content too large" error. The limit is now 100 MB, in line with the file
sizes the app already accepts. (#153)
- **Trip timeline dots align on the connector line** — On a journey's timeline
the event dots sat slightly off the vertical line at an inconsistent height
and were drawn as hollow rings. They are now filled, centred on each event
card, and sit exactly on the line, which runs cleanly from the first to the
last event. (Discord bug report)
- **Correct plurals for trip counts** — Trip counters always showed the
singular form (e.g. "532 Nacht", "4 Flug"). German and English plurals now
render correctly ("532 Nächte", "4 Flüge"). (Discord bug report)
## [2.2.0] - 2026-06-14
### Added
- **Cruise itinerary dates** — Each cruise stop now stores its calendar date,
extracted from booking confirmations on import (even when no arrival or
departure times are listed) and editable per stop, including sea days. Stop
dates appear on the cruise detail timeline. (#132)
- **Cruise route name** — Cruises gain an optional route/itinerary name
(e.g. "Kanaren mit Marokko") as printed on booking confirmations, captured
on import and shown in the cruise list and detail header. (#133)
### Fixed
- **German and local port names now match on cruise import** — The importer
resolves German exonyms ("Lissabon", "Singapur", "Venedig") and local
endonyms ("Lisboa", "Roma", "Napoli") to the correct catalog port instead
of leaving them unmatched, with a fuzzy fallback for near spellings. Rome
and Florence map to their actual ports of call (Civitavecchia, Livorno),
and a short catalog name can no longer be mis-matched to a longer parsed
name ("Atlantis" no longer matches "Atla").
- **Airport selectors no longer auto-expand on modal open** — Pre-filled
airport fields (such as the fly & cruise import preview) only open their
dropdown when focused, instead of all popping open at once when a dialog
opens.
### Changed
- **Consistent "Add flight" button placement** — The flights page now shows a
heading with the "Add flight" button right-aligned, matching the cruises and
trips pages.
## [2.1.0] - 2026-06-13
### Added
- **Worldwide port catalog** — The cruise port catalog grew from ~670 to
over 12,000 seaports (imported from the public UN/LOCODE dataset), so ports
like Taranto that were previously missing now resolve directly in manual
entry and import.
- **Port map-search fallback** — When a port isn't in the catalog, the picker
looks it up via OpenStreetMap and fills in its coordinates automatically
instead of forcing manual latitude/longitude entry.
### Fixed
- **Cruise dates were lost or shifted** — Entering only a date in the cruise
form left it unsaved (so it never appeared in the overview), and entered
dates could drift to the previous day across timezones. Cruise start/end are
now date-only and timezone-stable.
- **Accented ports weren't found** — Searching "Malaga", "Warnemunde" or
"Tromso" returned nothing because the catalog stores accented names; the
search now ignores diacritics. Added German exonyms (e.g. Tarent → Taranto).
- **Cruise PDF/email import extracted nothing** — Real "Mein Schiff" bookings
parsed to empty results because the LLM extraction prompt was
over-constrained; the rewritten prompt reliably extracts ship, dates, cabin,
price, itinerary ports and bundled fly & cruise flights.
- **Port search failures were hidden** — A network or auth error during port
search now surfaces an error instead of silently looking like "no results".
### Security
- **API write-scope on cruise endpoints** — Cruise, port and ship write
endpoints now require a write-scoped Personal Access Token (consistent with
flights/trips), and the new port-lookup endpoint is rate-limited per user.
## [2.0.1] - 2026-06-13
### Fixed
- **Cruise parsing ignored the configured Ollama server.** The cruise
booking parser always connected to `localhost:11434` instead of the
Ollama URL/model set in admin settings, so a correctly configured — and
successfully tested — remote Ollama was never actually used. Cruise
PDF/email imports failed with an "LLM unreachable" error even though the
connection test passed. The parser now resolves its endpoint from admin
settings, matching the flight parser.
- **Parser failures were logged as an empty object.** Backend error logs
showed `error:{}` with no message or stack for parser errors (and 16
other call sites), hiding the root cause. Errors now serialize their
message and stack, and the Ollama-unreachable error names the endpoint
and points to Settings.
### Changed
- **LLM parser configuration moved to the General admin tab.** The Ollama /
parser settings sat under the "Flight" admin tab, where cruise-only users
never found them; they now live under "General".
## [2.0.0-rc.12] - 2026-06-13 (Release Candidate)
### Fixed
- **Cruise import preview triggered a render-loop.** The editable import
preview reported its state through an effect whose dependencies changed
on every render, causing a "Maximum update depth exceeded" loop
(~193 console errors per import). Saving still worked but the screen
thrashed; the reporting is now stable and the preview renders cleanly.
- **Imported fly & cruise trip was mislabeled "completed".** A booking with
bundled flights auto-creates a trip; with no explicit status it fell back
to the "completed" default, so an upcoming voyage showed as already done.
The status is now derived from the cruise dates (upcoming → planned,
past → completed, in range → in progress).
## [2.0.0-rc.11] - 2026-06-13 (Release Candidate)
### Fixed
- **Fly & cruise import failed at the final save step.** Importing a cruise
with bundled flights created the cruise and the flights, but then errored
while grouping them into a trip — the flight IDs were sent empty. The trip
now links the cruise and its flights correctly.
## [2.0.0-rc.10] - 2026-06-13 (Release Candidate)
### Fixed
- **Cruise import failed to save bookings that list only dates, no times.**
Itinerary stop times extracted as a bare date (e.g. `2026-06-15T00:00`,
without seconds or offset) were rejected by the cruise validation, so the
whole import 400'd on save. Partial datetimes are now coerced to full ISO
on the way in.
## [2.0.0-rc.9] - 2026-06-13 (Release Candidate)
Cruise-import overhaul on top of rc.8. No schema change.
### Fixed
- **Duplicate "Add cruise" button.** The dashboard cruise tab showed two
"+ Kreuzfahrt hinzufügen" buttons; the correctly-placed toolbar button
now opens the import chooser, and the stray floating one is gone.
### Added
- **Editable cruise import preview.** After a booking is parsed, every
field is editable inline before saving — the matched ship and ports now
show by name, with an expandable itinerary editor, and missing/unmatched
fields are flagged. (The preview was previously read-only.)
- **Fly & cruise flights.** Cruise bookings that bundle flights now extract
those flights too: they appear as opt-in cards with airports pre-filled
(your home airport + the nearest airport to the embarkation/disembarkation
port, all editable), and the cruise plus its flights can be grouped into
one trip so the journey map shows fly → cruise → fly.
## [2.0.0-rc.8] - 2026-06-13 (Release Candidate)
Bug fixes and import/UX improvements on top of rc.7. No schema change.
### Fixed
- **Airport popups always showed "0 flights".** Clicking an airport on
the map opened a card reporting 0 total flights and no routes, even
though the map clearly drew arcs through it. The popup now aggregates
the same flight data that draws the routes, so totals, top routes,
airlines and distances match the map on every tab and honour the
active year filter.
- **Inconsistent CO₂ figures.** The per-flight CO₂ value, the dashboard
"elephants" aggregate and the demo data each used a different formula,
so the numbers disagreed. All three now use one model (distance band ×
cabin-class factor), and a one-shot backfill fills in CO₂ for older
flights that never had it computed.
### Changed
- **Trip cover image is now an upload.** The trip editor lets you upload
an image file for the cover directly — including while creating a new
trip — instead of pasting an image URL.
- **Cruise add flow matches flights.** Adding a cruise now opens a single
chooser like the flight form: import a booking (email or PDF, auto-
detected) or enter it manually — on both the Cruises page and the
dashboard cruise tab.
## [2.0.0-rc.7] - 2026-06-12 (Release Candidate)
Closes the findings of the V2 function & usability audit. No schema change.
### Fixed
- **Unreadable form fields on dark surfaces.** A Tailwind color named
`base` shadowed the `text-base` font-size utility and rendered
datetime inputs and dropdowns (e.g. in the cruise editor) near-black
on the dark theme.
- **Minimal cruises were invisible.** A cruise entered with only a
departure and arrival port (no detailed stop list) drew no route on
any map, listed neither port on its detail page, and reported
contradictory port counts (4 in the list vs 0 on the detail page).
Routes, distances, statistics and achievements now all run over the
full departure → stops → arrival sequence, and changing the
departure/arrival port recomputes the route.
- **Past cruises stayed "Scheduled" forever.** Scheduled cruises whose
end date passed more than 48 hours ago now flip to Completed
automatically — cancelled and historical entries are never touched.
- **"Go to settings" on a disabled dashboard tab led nowhere.** The
link now lands directly on the Modules section.
- **English leftovers in the German UI.** Loading, not-found and
save-error messages, picker buttons and screen-reader labels across
the cruise surfaces are now properly localized (DE/EN), and the logo
badge reads V2.0 instead of V1.0.
- **Parser failures exposed raw internals.** When the LLM parser
(Ollama/OpenAI/Claude) is unreachable, the import dialog now shows a
clear, actionable message instead of a connection stack trace — and
the cruise statistics endpoint no longer fails entirely when a single
cruise carries malformed data.
### Added
- **German port names in the port search.** Typing "Lissabon",
"Kopenhagen" or "Venedig" now finds Lisbon, Copenhagen and Venice —
about 80 common German exonyms map to the English catalog names,
matching even while you type.
- **Cruise dashboard tab states.** The cruise map tab now shows a
loading indicator, an error banner when data can't be fetched, and an
empty-state card guiding new users to add or import their first
cruise. The cruise editor labels its departure/arrival port fields,
and search dropdowns no longer pop open on their own.
## [2.0.0-rc.6] - 2026-06-11 (Release Candidate)
### Added
- **Trips are journeys now, not bookings.** Trip auto-detection only
proposes real multi-leg journeys (3+ flights) — a plain out-and-back
booking stays trip-less. A new "Clean up" tool on the Trips page
dissolves legacy one-booking micro-trips (the flights are kept), and
a merge mode combines several trips into one real journey.
- **Compact trip filter in the flight list.** The one-chip-per-trip row
(unusable with many trips) is replaced by quick chips for the newest
trips plus a searchable, year-grouped "Filter by trip" popover.
### Fixed
- **Disabled domains no longer leak content.** With the cruise domain
switched off, cruises still appeared on the dashboard "All" map, in
the cross-domain statistics, on trip detail pages and maps, and behind
the dimmed cruise tab (including its controls). Disabled domains are
now fully hidden — their data is not even fetched; trip details show a
"N cruise segments hidden" hint and disabled dashboard tabs show an
enable-in-settings stub instead.
## [2.0.0-rc.5] - 2026-06-10 (Release Candidate)
Full V2 feature audit across both domains — closes a batch of usability
gaps, mostly in the new cruise module. No schema change.
### Added
- **Pick which trip the journey map shows.** Journey mode rendered an
arbitrary first trip with no way to choose another; it now offers a
trip selector (with a hint when no cross-domain trips exist).
- **Fix unmatched ports during cruise import.** Ports the parser couldn't
match were downgraded to sea days and could only be corrected after
saving. The import preview now lets you pick the right port per stop
before saving.
- **Delete a cruise.** The cruise detail page now has a delete action
(with confirmation) — previously a mis-imported cruise was stuck.
### Fixed
- **The flight status filter hid historical and duplicated flights.**
Unchecking any one status (flown/scheduled/cancelled) silently dropped
every historical/duplicated flight; those two statuses now stay visible.
- **Editing a flight could shift its stored time.** The edit modal showed
times in the browser's timezone but saved them as airport-local, so
opening and saving without changing anything drifted the departure /
arrival when the two timezones differed. Times are now consistently
airport-local and round-trip losslessly.
- **The cruise stops editor showed blank ports when editing.** The selected
port is now shown for each existing stop.
- **Your own flight API key was labeled "Shared".** The provider card now
reads the correct per-provider key status.
- **Cruise import failures were opaque.** The real cause (e.g. Ollama not
reachable) is now surfaced instead of a generic "parsing failed".
- **Change-password accepted too-short passwords.** The modal now enforces
the backend's 8-character minimum with specific error messages.
- **English users saw stray German labels** (flight-row tooltips, scanner
steps) and an unstyled duplicate-flight dialog — both fixed.
### Changed
- **Cruise create opens straight to the form.** Removed the fake
email-import chooser (a dead-end stub); real email/PDF import stays on
the cruise list page.
- **Removed the no-op "Require user flight API keys" admin toggle** — it
had no backend behaviour.
- The in-map visualization control is hidden in the airport-frequency and
journey modes so it can't contradict the toolbar.
### Tooling
- Dead-code cleanup (retired 3D map-mode layers + their i18n keys),
corrected stale cruise-routing notes in CLAUDE.md, and routed the map's
cruise-overlay gate through the domain-gating API.
## [2.0.0-rc.4] - 2026-06-10 (Release Candidate)
### Fixed
- **The map could still drop a flight on very large accounts.** The rc.3
fix paginates the full flight set, but the backend ordered each page by
`departureTime` alone — a column that is nullable and not unique. At a
500-flight page boundary, rows sharing a timestamp (or the cluster of
flights with no timestamp at all) could be reordered between requests and
silently skipped or drawn twice. Both the paginated flight list and the
map's `/geo` feed now sort with a stable `id` tie-breaker, so every
flight is loaded exactly once regardless of account size.
## [2.0.0-rc.3] - 2026-06-09 (Release Candidate)
### Fixed
- **The dashboard map only showed your 100 most recent flights.** The
multi-domain refactor replaced V1's paginated map load with a single
unbounded `/geo` request, which the backend caps at 100 (newest first).
On accounts with more than 100 flights, every older flight silently
vanished from **all** map views (overview, heatmap, globe, journey) — a
one-off trip from years ago simply wasn't there. The map now paginates the
full set again, so every flight is plotted regardless of age.
### Changed
- **Single-flown routes are easier to spot.** A route flown just once
rendered at the faintest possible treatment (alpha 114, 1px) and was easy
to lose in an empty ocean. Raised the visibility floor (alpha ≥ 160,
minimum 2px width) so a lone long-haul reads clearly.
## [2.0.0-rc.2] - 2026-06-09 (Release Candidate)
Second 2.0 release candidate. Data-integrity hardening uncovered while
verifying real flight data on the rc.1 build, plus a trip-detection UX
improvement. No schema change.
### Added
- **Expandable trip-detection cards.** Each auto-detected trip in the
"Reisen erkennen" review modal can be expanded to reveal its
constituent legs (date · flight number · departure → arrival · status).
The proposal payload now carries a `legs` array.
### Fixed
- **Airport IATA collisions resolved to the wrong airport.** OurAirports
assigns synthetic ICAO placeholders (e.g. `US-0226`) to minor airfields,
some of which carry a spurious IATA that collides with a real
international airport — so a flight to Antananarivo (IATA `TNR`) could be
stamped with the coordinates of a tiny "Tulsa Downtown Airpark" and drawn
to Oklahoma. Airport lookup now prefers the authoritative airport
(active over closed, real 4-letter ICAO over a synthetic placeholder)
across the cache, batch and create paths.
### Tooling
- **`fixMistaggedDurations` maintenance script.** Detects flights whose
legacy time-semantics tag inflates/deflates the displayed duration
(e.g. a Sydney→Dubai leg showing 21 h instead of 14 h) by comparing the
naïve and timezone-converted durations against the great-circle minimum
flight time, and retags clearly mis-tagged rows. Genuinely corrupt rows
(zero/negative duration) are reported for manual review, never silently
changed.
## [2.0.0-rc.1] - 2026-06-09 (Release Candidate)
First release candidate for **TravStats 2.0** — the multi-domain
release. TravStats is no longer a flight-only logbook: it now tracks
**cruises** as a first-class domain alongside flights, with a shared
dashboard, shared stats, and a unified import pipeline. This RC
aggregates the entire `2.0.0-beta.1` … `2.0.0-beta.28` line; see those
entries below for the granular history.
### Added — Cruise domain
- **Cruise logbook**: full CRUD for cruises, ships, and ports, with
per-cruise itineraries (port calls + sea days).
- **In-house sea-route renderer**: a vendored Eurostat _marnet_
shipping-lane graph with an A* pathfinder (`services/marnet/`)
replaces the abandoned `searoute-ts` package, so cruise legs follow
real shipping lanes instead of falling back to coarse arcs.
- **Cruise booking parser**: AIDA / TUI / generic-LLM extraction wired
into the email and PDF import routes, with ship/port entity
resolution.
- **Cruise stats**: a dedicated cruise stats page plus cross-domain
KPIs (flights + cruises) on the combined "Alle" view.
### Added — Multi-domain platform
- **Domain-scoped dashboard**: Alle / Flüge / Kreuzfahrten tabs with
per-domain visualization modes; the URL carries tab + mode and the
last-used mode persists per domain.
- **Domain registry**: a shared front-/back-end domain registry drives
gating, parsing targets, and per-domain brand palettes (dark-only).
- **Special flights (Sonderflüge)** as a flight sub-type.
- **Globe modernization**: MapLibre + deck.gl rewrite with GPU-based
earth occlusion and smoothed map ↔ globe transitions.
### Fixed
- **Flights to IATA-less airports no longer vanish from the map**
(#120): airfields with only an ICAO code (common for small / pre-1990
airports) saved fine but were silently dropped by the route render
gate. Airports are now identified by IATA → ICAO → coordinate key, so
any leg with valid geometry renders.
## [2.0.0-beta.28] - 2026-05-12 (Beta)
### Fixed
- **Infinite re-render loop on the dashboard fired ~8 GETs/sec at
`/api/v1/flights`.** `useEnabledDomains()` returned a fresh
`isEnabled` arrow function on every render; `DashboardPage` listed it
in a `useEffect` dependency array, so the effect re-ran on every
render, called `setCounts({...})` with a new object reference,
triggered another render — and so on. With dev-tools open on the
Globe view, the network log filled with thousands of 304-cached
calls; with caching disabled this would hammer the backend.
`MapContainer3D` already documented this trap and worked around it
locally; the hook-level fix (memoize `isEnabled` against the
`enabled` array via `useCallback`) closes the trap once and for all
so future callers cannot trip into it.
## [2.0.0-beta.27] - 2026-05-10 (Beta)
### Fixed
- **Bulk historical refresh aborted client-side after 10 s** while the
backend kept running. The default axios timeout is 10 s, but
`runBulkRefresh` loops sequentially through up to 25 flights, each
calling AeroDataBox / Aviationstack — easily 30–60 s on a healthy
link. The UI gave up mid-loop, surfaced a red `timeout of 10000ms
exceeded` toast, and the user had to re-click before any data
showed up. Bumped the bulk-refresh request to the parser-class
180 s timeout so the call stays open for the full server-side
iteration.
- **Settings → Auffrischen-Buttons used raw `bg-blue-600`** instead of
the brand-amber `btn-primary`. Side-fix while in the file.
### Performance
- **Globe hover tooltip moved into a leaf component.** Sweeping the
cursor over airport / port labels triggered React re-renders at
60–120 Hz of the entire 1600-line `GlobeView` tree (every onHover
event called `setTooltip`); on lower-end GPUs this read as visible
jank during the cursor-following tooltip. The new `HoverTooltip`
exposes an imperative `show / hide` API via `forwardRef`, so only
the 30-line tooltip subtree re-renders on cursor movement — the
parent and its `MapboxOverlay` setProps stay put.
- **Aktivität-Panel rows replaced 720 `onMouseEnter` / `onMouseLeave`
JS handlers** (2 per row × 354 rows + cruises) with a Tailwind
`hover:bg-white/[0.04]` class. The browser now handles row hover
purely in CSS — no listeners attached, no inline-style mutation
during scroll.
## [2.0.0-beta.26] - 2026-05-10 (Beta)
### Fixed
- **Sonder-Flug entry was invisible from the dashboard top-bar add
button** — `DashboardLayout` mounted `SimplifiedFlightFormV2` without
the `onPickSpecialFlight` prop, so the special-flight chooser card
silently dropped out of the lookup step for every user coming through
this path. The chooser button label was also calling a missing i18n
key (`specialFlights:chooser.pickAction`); now uses the correct
`chooser.cta`.
- **NavigationBar missing on Trips pages** — `/trips` and `/trips/:id`
had no top-bar at all (no logo, no nav links, no user menu) while
every other top-level page mounted `<NavigationBar />`. Added it to
both, plus the loading state on `/trips/:id` so the chrome doesn't
pop in once the trip resolves.
- **Map info pill bled through the dashboard's Aktivität toggle on the
Alle and Flüge tabs** — `MapContainer3D` rendered a "X Flüge · Y
Routen" pill at top-left z-10, sitting under the tab-level
Aktivität button + domain-legend chips at z-30. The "Routen" tail
peeked out between the two pills. Added a `hideInfoPill` prop and
set it on AllTab + FlightsTab.
- **Route-Details button on the flight tooltip silently did nothing**
when the Aktivität sidebar was closed — `<RouteDetailsSidebar>` is
rendered inside `<FlightPanel>`, which only mounts while the sidebar
is open. Clicking the button just flipped the store flag and looked
unresponsive. FlightsTab now auto-opens the sidebar on any non-null
`detailMode` transition so the details actually appear.
### Visual / brand
- **Cruise-port markers + UN/LOCODE labels on the flat map**: hover
tooltip ported from globe so airport / port hover bubbles now show
IATA + name + count + last-visit / last-call across both surfaces.
Labels also gain the same low-zoom hide threshold the airport IATA
labels already use.
- **Globe arc + cruise-path widths** realigned to the flat-map
`sqrt(count)` formula so the visual jump between map ↔ globe
transitions is smooth instead of jumping in line weight.
- **Three-round brand-violation sweep** against `BRAND.md`:
- Domain-color leaks fixed across AchievementPopup tier gradients,
Admin/UserManagement role badge, DataSourceBadges, Stats seat-class
and zone bars, Training flight-highlighter palette, FlightCalendar
intensity ramp.
- Email-import "Beste Option" hero card on the cruise side moved off
the retired green/teal pattern to the same brand-amber gradient
the flight side already uses; FlightLookupStep "Sonder-Flug" card
border moved off Hotel-domain purple to flight-domain amber.
- Stats progress bars (aircraft / airline / country) and the Setup +
SetupPage success/error banners moved off light-mode Tailwind
palette to brand state tokens (`--success` / `--danger` /
`--warning` / `--accent`).
- Admin LoggingManager + BackupManagement, Training TemplateReview
- BoardingPassAnnotation, ParserPage tabs, ReceiptUpload drag-
state and AirportSeedingBanner all rerouted to brand tokens.
- Cruise port markers on the map now render in the canonical
`--domain-cruise` hex (#6fa0d6) instead of sky-400.
- **Shared tooltip labels** moved out of `map.globe.pinned.*` into a
dedicated top-level `map.tooltip.*` namespace shared by both the
globe pinned card and the flat-map hover bubble (no functional
change, just consistent key location).
- **Globe edge-clipping fixed** — `EarthOcclusionExtension` now reads
the live MapLibre camera distance off the deck.gl viewport instead
of an empirical zoom heuristic that was off by 3-22× from the real
camera. Routes no longer disappear at the rim when zoomed out.
### Fixed
- **Alle-tab map legend now reflects domain pills** — The legend stripe
(sky-blue cruise + orange flight) hard-coded both rows, so toggling
cruise off in the filter dropdown left the legend lying that cruises
were still on screen. Each row is now gated on its domain pill state;
if both pills are off, the legend block is hidden entirely.
## [2.0.0-beta.24] - 2026-05-09 (Beta)
### Added
- **Filter button shows active state** — When the year dropdown picks
a specific year or any domain pill is off, the dashboard top-bar
Filter button switches to an accent border + the picked year next to
the label, and a "n/m" pill displays how many domains are active.
Without this indicator users can't tell at a glance whether they're
seeing the full dataset or a filtered slice.
- **"Filter zurücksetzen" link in the dropdown** — Single click to
clear year + domain selection back to the unfiltered defaults.
Disabled when no filter is active.
## [2.0.0-beta.23] - 2026-05-09 (Beta)
### Fixed
- **Double tooltip on flat-map airport markers** — Beta.21 added a
hover bubble via deck.gl's `getTooltip` to surface full place names
on every marker. On the flat map, airport markers already trigger
the rich `AirportTooltip` (departures/arrivals/distance/route
counts) on click, so the new hover bubble layered a second tooltip
on top of the rich one. The hover bubble now skips flat-map
airports (`routes-dot` / `routes-label`) and fires only on flat-map
ports + every globe marker. Globe airports keep the hover bubble
because they have no rich-tooltip equivalent.
- **Markers clipped where cruise paths cross them** — Globe airport +
port markers rendered at altitude 0 while cruise paths sit at 5 km;
the depth buffer let the path's fragments occlude the marker dot at
shallow camera angles. Lifted both the dot and the label to 8 km
altitude (`MARKER_ALTITUDE_M`) so the marker is reliably above any
path that crosses it. On the flat map the symptom was different:
cruise paths were composed AFTER airport visuals in the layer
stack, so they drew on top of airport dots at every intersection.
Reordered the flat-map layer composition: cruise arcs/arrows go
below flight arcs and airport markers, cruise port halo+dot+label
go on top of everything (their pixel-radius cap keeps them readable
at every zoom).
## [2.0.0-beta.22] - 2026-05-09 (Beta)
### Fixed
- **Year + domain filter now actually filters data** — Beta.20 added
the year dropdown and domain pill row but only updated the store;
the data layer didn't read either. Wired both into the Alle, Flüge,
and Kreuzfahrten tabs:
- Alle tab filters flights by `departureTime` within the year range
and cruises by interval overlap (start/end), and gates each domain
on/off via the pill row. Activity panel reflects the same
filtered set.
- Flüge / Kreuzfahrten apply the year filter only — domain pills are
cross-domain and a domain-dedicated tab keeps showing its domain
regardless of pill state.
- `MapContainer3D` gained a `cruisesOverride` prop that bypasses its
internal cruise fetch so the tabs can hand it the pre-filtered set.
## [2.0.0-beta.21] - 2026-05-09 (Beta)
### Changed
- **Flat-map cruise port labels show UN/LOCODE instead of full name**
— Mirrors how airport markers show 3-letter IATA codes ("HAM",
"JFK"). Ports now show the 5-letter UN/LOCODE ("DEHAM", "USJFK")
with a fallback to the full name when the UN/LOCODE column is null.
The Globe view already did this — flat-map was inconsistent.
### Added
- **Hover tooltip on every airport + port marker** — Shows the full
place name in a small dark bubble on hover. Wired through
`getTooltip` on every MapboxOverlay (DeckGLMap, GlobeView,
CruiseRouteMap, TripMap) using a shared
`components/map/markerTooltip.ts` helper. Routes / arcs / paths
are explicitly excluded so the cursor stays clean while panning.
## [2.0.0-beta.20] - 2026-05-09 (Beta)
### Changed
- **Globe mode is now exclusive to the Alle tab** — The 3-D globe is
cross-domain by design (mixes flights and cruises on one sphere), so
exposing it on per-domain tabs caused two problems: clicking it on
Flüge/Kreuzfahrten swapped to a globe view that broke domain scoping,
and the heatmap legend never matched. The in-map FAB now takes an
`availableModes` subset — `[routes, heatmap, globe]` on Alle,
`[routes, heatmap, trips]` on Flüge, and the FAB is hidden entirely
on Kreuzfahrten (cruise modes don't map 1:1 to the FAB's Map modes).
- **Flat-map cruise port markers re-aligned with airport markers** —
Ports were a single 4–10 px scatter dot scaled by visit count, while
airports are a meter-based dot + halo ring stack. On the same map
they read as two unrelated marker systems. The port layer now mirrors
the airport stack: 2.2 km solid dot in sky-blue + 6 km halo ring +
clickable port-name label (TextLayer, billboarded). Same visual
weight at every zoom.
- **Empty ship-marker toggle removed** — The "Schiffsmarker" checkbox
in the globe FAB row had no real handler — toggling it left the
layer state untouched. Removed the toggle and the dormant
shipMarkers state plumbing in GlobeView and buildGlobeLayers.
### Added
- **Year + domain dropdown filter** — The dashboard filter dropdown
now offers a single year selector (current year + 14 years back, or
"Alle Jahre") instead of two from/to date pickers. Per-day
precision is unnecessary for the use cases TravStats supports, and
consolidates against the Globe day slider so the two don't fight.
On the Alle tab a domain pill row toggles flight / cruise / poi
visibility together. Store extended with `setYear` (mirrors to the
underlying `time` range so existing range-overlap consumers keep
working) and `setDomains`.
## [2.0.0-beta.19] - 2026-05-09 (Beta)
### Fixed
- **Cruise paths clipped into the globe** — Cruise sea-route geometry
comes from the backend as 2-D `[lng, lat]` points, so deck.gl
rendered the path at exactly altitude 0. That shares depth-buffer
values with the sphere mesh and produces visible z-fighting /
clipping where segments dip into the globe. Lifted each path
point to 5 km altitude in `globe-cruise-paths`'s `getPath` —
invisible at any user-relevant zoom but well above the depth
precision noise.
## [2.0.0-beta.18] - 2026-05-09 (Beta)
### Fixed
- **Globe pinned-card "Open last flight" CTA was a no-op** — The CTA
was wired to a callback named `onFlightClick` that mapped to
`setSelection([flight])` upstream, which only highlights the row in
the activity panel. Renamed to `onFlightOpen` and re-wired in
AllTab + FlightsTab to actually open the flight edit modal — same
modal the activity-panel "Details" button opens.
### Added
- **Cruise pinned-card now has an "Open cruise" CTA** — Symmetric to
the flight CTA. New `onCruiseOpen` prop on GlobeView /
MapContainer3D, wired in AllTab to navigate to `/cruises/:id` —
same destination the activity-panel cruise "Details" button uses.
## [2.0.0-beta.17] - 2026-05-09 (Beta)
### Fixed
- **Globe canvas still disappeared on click after beta.16** — Removing
`flyToArc` and adding try/catch wasn't enough. Per Gemini's diagnosis
(matching upstream issue #512), the actual root cause is MapLibre 5's
`locationOccludedOpacity` feature: it performs an internal occlusion
pass that does not restore `gl.SCISSOR_TEST`, leaving the shared GL
context corrupted on next deck.gl draw. With `interleaved: true` in
`MapboxOverlay`, deck.gl and MapLibre share the WebGL context, so any
popup creation triggered a state leak that blanked the canvas.
- **Replaced `maplibregl.Popup` with a custom React overlay** —
Pinned-card now mounts as an absolutely-positioned `<div>` whose
`left/top` come from `map.project([lng, lat])`, refreshed on every
MapLibre `render` event. Back-of-globe culling is replicated in JS
via the same dot-product math the EarthOcclusionExtension shader
uses. Same UX (anchored, follows camera, fades when occluded), zero
GL-state risk.
- **`PinnedCardBoundary` error boundary around the card** — Defense
in depth: a throw inside the React subtree now logs and renders
null, instead of bubbling up and unmounting GlobeView.
## [2.0.0-beta.16] - 2026-05-09 (Beta)
### Fixed
- **Globe disappeared completely on flight-route click** — Critical
regression in beta.12-15: clicking an aggregated arc fired both
`setPinned()` (mounting the new MapLibre Popup) and a 1.5 s
`flyTo()` camera animation. The popup mount + camera flyTo +
globe re-render combo crashed the WebGL canvas in some camera
states, leaving a black screen instead of the globe. Two-layer
fix:
- **Drop the camera flyTo on click.** With the popup now anchored
to the click coordinate, flying the camera away from where the
user just tapped is anti-pattern anyway, and removing it
eliminates the race entirely.
- **Wrap the popup mount in try/catch + finite-coord guard.** A
single throw from MapLibre's projection or anchor-flipping math
used to bubble up and unmount the entire GlobeView. Now caught
and logged; popup missing is recoverable, canvas going away is
not.
## [2.0.0-beta.15] - 2026-05-09 (Beta)
### Changed
- **Inactive-tab / section-nav contrast bumped consistently** — The
TripDetailPage tab fix in beta.10 used a one-off
`rgba(230,237,243,0.65)` to lift inactive tabs above the borderline
WCAG-AA contrast `--text-muted` (#8b949e) was hitting against
`--bg-base`. That fix is now a proper design token, `--text-secondary`
(#a8b3bf), applied everywhere the same pattern was leaking through:
AdvancedStatsPage tab strip (Alle / Flug / Kreuzfahrt filter),
AdminPage tabs + section sidebar, SettingsPage section sidebar, and
TripModal sub-tabs. `--text-muted` itself is unchanged and stays
reserved for genuinely de-emphasised content (captions, scrollbars,
borders).
## [2.0.0-beta.14] - 2026-05-09 (Beta)
### Fixed
- **Globe pinned-card width stretched to viewport on long content** —
The MapLibre Popup wrapper was created with `maxWidth: "none"`, which
let the inner card balloon out to the full viewport width as soon as
the content didn't naturally cap itself. Capped at `320px` so the
inner card's own min/max widths drive the layout.
- **Cruise card duplicated the port / sea-day count** — Both fields
used pluralised i18n keys that already include the count
(`{{count}} Häfen`), and then rendered the count again as the value
cell, producing rows like `6 HÄFEN 6`. Replaced the pluralised
keys with plain `Häfen` / `Seetage` labels.
- **Port region rendered lowercase** — The `port.region` column in
the seed (e.g. `mediterranean`) is the raw enum value. Capitalised
for display.
## [2.0.0-beta.13] - 2026-05-09 (Beta)
### Added
- **Globe pinned-card content density (Phase B)** — The pinned detail
card that opens on click now follows a 3-tier hierarchy: heading
(icon + identifier), hero stat (the one number you want to see),
and a metadata grid of 3-5 high-value facts. Concrete fields:
- **Airport**: visit count + name + longest route from here +
top airline + top aircraft + last visit date.
- **Port**: visit count + country + region + ships visited
(truncated to 3) + longest port-call duration + last call.
- **Flight route**: times flown + total kilometres (sum across all
aggregated flights on this route) + last flight date + aircraft
types + top airline + the existing "Open last flight" CTA.
- **Cruise**: cruise line + date range + port count + sea-day count
- embark / debark ports.
All aggregations are pure helpers in `Globe/cardStats.ts` derived
from the `flights` / `cruises` arrays already passed to GlobeView —
no extra API roundtrip. Card content extracted into
`Globe/PinnedCard.tsx` (~330 LoC) so the GlobeView shell stays
focused on map + popup lifecycle. 21 new i18n keys per locale.
## [2.0.0-beta.12] - 2026-05-09 (Beta)
### Changed
- **Globe pinned-card now anchors to the clicked feature** — The detail
card that opens when you click an airport, port, route arc, or cruise
path used to live in a fixed bottom-right slot, disconnected from
the feature it described. It now mounts inside a MapLibre `Popup`
bound to the lng/lat where the click landed (`PickingInfo.coordinate`
for arcs and cruise paths, `position` for airport/port markers), so
the card visually belongs to the feature with a tail pointing at it.
MapLibre handles edge-aware anchor flipping near the viewport
borders, and `locationOccludedOpacity:0` uses the renderer's own
globe-visibility math to fade the card when the anchor rotates to
the back of the earth. The card content itself is unchanged in this
beta — Phase B (denser content per Gemini + Codex review) lands in
the next bump.
## [2.0.0-beta.11] - 2026-05-09 (Beta)
### Fixed
- **Activity-toggle and legend overlapped on the dashboard `Alle` tab** —
The `☰ Aktivität` button and the flight/cruise legend were two
independent absolutely-positioned pills with hand-tuned offsets
(toggle at `left:12`, legend at `left:128`). Tight gap and variable
letter widths caused the legend pill to render on top of the toggle's
right edge. Both pills now share a single absolute flex container
with `gap:8`, so they auto-flow without offset math; the container as
a whole shifts to `left:340` when the sidebar opens.
## [2.0.0-beta.10] - 2026-05-09 (Beta)
### Added
- **Trip System V2** — A redesigned Trip surface with a dedicated detail page
carrying five sub-tabs (Overview / Timeline / Map / Gallery / Logistics).
Iterations 1-9 land in this beta: list-card redesign, stops + journal
entries, real Map tab with click-to-fly and Globe/Flat projection toggle,
per-proposal review flow for trip auto-detection, TripModal split into
4 sub-tabs with cover preview, photo gallery with lightbox + upload, and
LLM-generated trip summaries via Ollama (gemma3:12b, 3-paragraph DE
travel-blog format, persisted to `trip.summary`).
- **Special Flights as a Flight subtype** — Sightseeing, repositioning,
training, and charter flights can now be logged with type-specific
metadata (event location, label, lat/lon) without distorting the Flight
schema or the Trip system. Special-flight overlays render on top of
routes-mode with their own legend and tooltip variant — they are NOT a
separate domain or MapMode.
- **Globe modernization** — The 3D Globe is rewritten on MapLibre 5 +
deck.gl `MapboxOverlay` + `useControl`, replacing the legacy
`react-globe.gl` + Three.js engine. Earth-occlusion is now GPU
per-fragment via a custom deck.gl extension reading camera state every
frame. New helper modules (`buildGlobeLayers`, `arcUtils`,
`heatmapUtils`, `globeLayerTypes`) replace inline implementations.
- **Merged 1.5.0 trunk** — beta.10 carries the full 1.5 main payload:
FR24 CSV importer, generic CSV importer with column-mapping wizard,
shared import preview modal, AeroDataBox extended-field capture,
dataSource badges, historical-flight Day select, `POST /import/parse`
API endpoint, and the `route_distance` backfill script.
### Changed
- **Settings → Import promoted to top-level (General tab)** — The
1.5.0 import section landed nested under Settings → Flug → Import,
one tab-level deep. With V2 multi-domain ahead (cruise / hotel / POI
imports) and feedback that the nesting hid the section, Import now
lives in the General tab right after Backup. The three tiles
(Flightradar24 CSV, generic CSV with mapping wizard, Round-Trip
XLSX) and all underlying parsers are unchanged.
- **Globe default basemap** — From `standard` (openfreemap liberty) to
`dark` (CartoDB dark-matter), matching BRAND.md §1.1's dark-only app
shell. Users can still pick any style; the choice persists in
`sessionStorage`.
- **TripDetailPage TabBar inactive contrast** — Inactive tab labels
brightened from `--text-muted` to white at 65 % opacity with a hover
transition to `--text-primary`.
- **Globe perf** — Texture sharpness, live-mode smoothness, and removal
of the night cycle. Frame pacing is now fully GPU-driven.
### Fixed
- **Trip delete-confirmation heading was nearly invisible** — The `<h2>`
in the delete-confirm modal on the Trip detail page was missing an
explicit text colour and inherited a muted shade against the dark
surface. Resolved by adding `style={{ color: "var(--text-primary)" }}`
in line with the existing TripsTab and FlightPanel patterns.
- **`TEST_LEAK_*` regression-test achievements bled into the user UI** —
The `achievements.scheduledLeak.test.ts` regression suite seeds nine
`TEST_LEAK_*` rows into the achievements table to verify
scheduled-flight isolation, but the seeds remained after the test ran
and showed up in the live Achievements page (e.g. "Leak: First
Aircraft Type"). The list / recent / leaderboard endpoints now filter
`code NOT LIKE 'TEST_%'`, and the dev DB has been cleaned.
- **deck.gl warning `fontSettings.sdf is required to render outline`** —
The IATA label TextLayer set `outlineWidth: 2` + `outlineColor` but
never enabled the SDF font flag, so the outline never actually
rendered and deck.gl logged a warning every frame.
`fontSettings: { sdf: true }` is now set explicitly.
### Database
- **Four new migrations** — `20260509120000_trip_metadata`,
`20260509130000_trip_stops_and_journal`, `20260509140000_trip_photos`,
`20260509150000_special_flights_subtype`. All additive, idempotent,
zero-downtime safe. Plus the 1.5.0 `_add_aerodatabox_extended_fields`
migration carried in via the main merge.
## [2.0.0-beta.9] - 2026-05-05 (Beta)
### Added
- **Brand token system wired through the app** — `BRAND.md` per-domain palette (flight `#f0a947`, cruise `#6fa0d6`, hotel `#b072d6`, poi `#5ec2b2`) is now the single source of truth. The shared domain registry (`shared/domains.ts` on both backend and frontend) carries the canonical hexes, so dashboard tab strip, AllTab legend, journey layers, and the legend-driven map controls all read from one place. Stats screen consolidated onto a shared `StatCard` surface — ~30 arbitrary Tailwind gradients replaced with brand tokens.
- **Merged 1.4.0 trunk** — beta.9 carries the full 1.4 main payload: AeroDataBox provider + bulk historical refresh, persisted tail number / Mode-S / codeshare metadata, Aircraft hulls ranking + per-tail profile pages (`/aircraft/:reg`), API tokens for headless ingestion, OpenAPI spec at `/api/v1/openapi.json` + Swagger UI at `/api/v1/docs`, demo account flag (`isDemo`), upgrade-backup hook on container entrypoint.
### Changed
- **Dark-only theme**, light-mode classes retired across components. `themeStore` simplified to `mapTheme` only — no more `isDarkMode` toggle. `<html class="dark">` is set defensively at boot so a stale `localStorage` payload can't strip it.
## [1.5.1] - 2026-05-12
### Fixed
- **`backfillRouteDistance.ts` now actually runs in production** — The v1.5.0 CHANGELOG advertised the script as runnable via `docker exec TravStats npx tsx /app/backend/scripts/backfillRouteDistance.ts`, but the production Dockerfile only copied `backend/dist/` out of the builder stage so `/app/backend/scripts/` did not exist in the shipped image and every operator who followed the documented path got "No such file or directory". The Dockerfile now explicitly copies `backend/scripts/` into the production stage; the documented command resolves as promised.
- **AeroDataBox lookup now persists airport `shortName` and `municipalityName`** — The v1.5.0 release notes claimed AeroDataBox lookups populate these airport-side columns, but the adapter's airport-object interface declared only `{iata, icao, name}` and the fields were filled only by the airport seeder on first boot — provider responses never reached them. New `enrichAirportMetadata()` helper backfills NULL slots from AeroDataBox responses on every lookup, never overwrites a curated value, invalidates the airport cache after a write. Six new lock-in tests cover the contract: happy-path, never-overwrite-curated, both-already-set no-op, empty-payload no-op, unknown-code, and whitespace trimming.
### Tests
- Backend Jest: 657 → 663 (+6 for `enrichAirportMetadata`). Frontend Vitest unchanged at 357/357.
## [1.5.0] - 2026-05-08
### Added
- **`GET /api/v1/diagnostics`** — User-scoped, redacted JSON snapshot of selected flights + trips for bug reports. Filters: `flightIds`, `tripIds`, `airline`, `since`. Reduces the manual hand-glueing maintainers used to need (manual `?airline=…` query + JSON trim + redact). Closes #105. Distinct from the existing `/diagnostic-export` (which returns server log tails). `recentErrors` is reserved as `[]` for now — error-events table is v1.5.x scope. Documented in the OpenAPI spec under the `Diagnostics` tag (rc.5).
- **FR24 CSV importer** — Settings → Import → "From Flightradar24" takes the unmodified `my.flightradar24.com` CSV export. Server-side timezone conversion via the existing airport lookup, hardcoded column map for FR24's known schema (handles the leading blank line and embedded `(IATA/ICAO)` annotations), `Flight reason → category`, `Seat number → seatNumber`, `Note → notes`. Status auto-defaults from `dep_utc` vs. now (past → `flown`, future → `scheduled`). Closes #99.
- **Generic CSV importer with column-mapping wizard** — Drop any flat per-flight CSV; a wizard opens automatically and lets you assign each column to a TravStats field (Departure IATA, Departure Time, Arrival IATA, Status, Airline, Aircraft, …) with auto-suggestions for known field names.
- **Re-import TravStats Excel relocated to Settings → Import** — The round-trip XLSX/CSV/JSON importer (existing rows updated by ID, new rows created) moved off the dashboard into the new dedicated section so all three importers share one place.
- **Shared import preview modal** — Every importer routes through the same preview screen: per-row checkboxes, flag badges (`duplicate`, `duration_mismatch`, `unresolvable_airport`, `date_invalid`), commit dispatched in 20-flight chunks via the existing `/flights/batch` route. Each row tagged with the appropriate `dataSource` (`imported_fr24`, `imported_generic_csv`, `imported_roundtrip`).
- **AeroDataBox extended-field capture** — Lookups now persist `runwayDepartureTime`, `runwayArrivalTime`, `isCargo`, `aerodataboxQualityTags`, `baggageBelt`, `checkInDesk`, plus airport `shortName` and `municipalityName`. Backfill onto existing rows runs through the existing scheduler.
- **DataSource badges for imports** — Flight cards now show `📊 imported_fr24` (orange), `📥 imported_generic_csv`, `↻ imported_roundtrip` so the import provenance is visible at a glance, distinct from `📧 email_import` and `🎫 boarding_pass_scan`.
- **Historical-flight Day select** — The historical-mode date input gained a Day dropdown (Year + Month + Day) so flights with a remembered exact date but no precise time can store as `YYYY-MM-DD` with `depTimeSemantics: 'DATE_ONLY'`. Year-only and Year+Month entry continue to work unchanged.
- **Year-inferred warning badge** — BCBP boarding-pass barcodes carry only a Julian day-of-year (no year ever) and the Tesseract OCR fallback silently substitutes the current year when none is captured. Both paths now mark `departureTime` as inferred so `FlightReviewModal` shows the existing yellow "!" badge with a "Year guessed — please verify" tooltip.
- **Historical flights now feed time-insensitive stats** — Old logbook entries (status `historical`) contribute to airport count, country count, continent count, distance, route counts, seat-class breakdown, and milestone-year — every stat that depends only on date / airport / airline. Time-of-day buckets, layovers, durations, fastest-route km/h, and midnight crossings stay `flown`-only because historical timestamps carry placeholder times that would skew them.
- **`POST /api/v1/import/parse` API endpoint** — External tools and Personal-Access-Token consumers can now run the full FR24 / Generic-CSV import flow over the API: parse → preview → batch-commit. Body is `{source: "fr24", csv}` or `{source: "generic_csv", csv, mapping}`; returns the same `PreviewRowInput[]` shape the browser produces. Read-scope tokens are sufficient (the parser is a pure function, no DB writes). External scripts no longer need to reimplement the RFC-4180 lexer or FR24-specific column mapping.
- **Generic-CSV wizard auto-detection** — Headers matching common aliases (`Flight Date` → date, `Origin`/`dep`/`from` → fromIata, `Destination`/`arr`/`to` → toIata, `Tail`/`reg` → registration, `carrier` → airline, `dpt_local`/`arr_local` → dep/arr-time, …) are pre-mapped on upload, with a green "Auto-erkannt" badge so the user knows what to double-check.
- **Inline CSV sample preview in the wizard** — Each dropdown option shows `header_name (Sample: row-1 value)` plus a muted preview line below selected dropdowns, so cryptic headers like `dpt_local` or `ac_reg` are immediately readable without opening the source file.
- **`backend/scripts/backfillRouteDistance.ts` script** — One-shot Node script that walks all flights with `route_distance NULL` + non-NULL coords, computes Haversine, UPDATEs in batches of 500. Idempotent (`where: { routeDistance: null }`), supports `--dry-run` and `--batch-size` flags. Run via `docker exec TravStats npx tsx /app/backend/scripts/backfillRouteDistance.ts` to backfill legacy NULL distances on any host that imported flight data before v1.5.0-rc.3. Norberts box would populate ~231 NULLs in well under a second.
### Changed
- **Routes-layer rendering** — Maximum line width reduced from 7 px to 4 px so dense maps stay readable. Routes carrying only future scheduled flights render as solid sky-blue arcs (no gradient). Routes with both past and upcoming flights render with a hardcoded two-tier red core (red-400 below median frequency, red-600 at/above) plus blue tips, replacing the heatmap-tinted core. Routes with only flown legs continue to use the frequency-driven heatmap palette.
- **Settings → Import UI** — Redesigned to match the rest of the Settings design system: outer `SectionCard` with title, three sub-cards in a responsive grid (1 / 2 / 3 columns by viewport), `btn-primary` file pickers with 📁 prefix replacing native `<input type="file">` buttons, red-tinted error blocks, green-tinted success blocks.
- **Generic CSV import description** — Reworded so the upload-then-wizard flow is explicit ("Upload a flat per-flight CSV — after upload, a mapping wizard opens so you can match each column to a TravStats field"), avoiding the prior implication that "Wizard" was a button.
### Fixed
- **`durationMinutes` returned 0 / negative numbers on DATE_ONLY rows** — Manually entered round-trips between airports in different timezones (e.g. RAK↔MAD with both sides marked DATE_ONLY) produced -120 / 0 / +120 minute durations because the 12:00 wall-clock placeholder was put through real airport-timezone conversion. The `tzAwareDurationMinutes` type union didn't even include `DATE_ONLY`; a silent cast in the flights list endpoint mapped it to `UNKNOWN` and ran a naive UTC diff. Now returns `null` when either side is `DATE_ONLY`, and the display layer falls back to the great-circle estimate (Iberia RAK→MAD now reads "~1h 35min" instead of "0min" or "-2h"). Closes #106A; closes #103 as a side-effect (PUT was failing on the resulting bad rows).
- **Free-text airline names not resolved to IATA/ICAO** — Manual entries like `"Iberia"` saved with `airlineIata=null` / `airlineIcao=null`, so airline filters, codeshare detection, and stats grouping treated spelling variants as separate carriers. New `resolveAirlineCodes()` does strict-exact lookup (canonical name, IATA code, ICAO code, or explicit alias — no fuzzy substring matching, since "Air India" → "Indian Airlines" is worse than `null`). Wired into `POST /flights`, `PUT /flights/:id`, and `POST /flights/batch` so all importers + manual entry benefit. `airlines.ts` gains an `icao` field on every entry. Closes #106B.
- **Trip auto-assignment skipping the negative-duration leg** — The RAK→MAD return that ended up with `durationMinutes=-120` (cause above) was silently filtered out of trip association on the frontend. Closes #104 as a side-effect of the duration fix.
- **Chronological-order refine now date-granular for DATE_ONLY** — The RC3 `arrivalLocal must not precede departureLocal` check was triggering on same-day DATE_ONLY round-trips because the 12:00 placeholder shifted by airport timezone produced arrUTC < depUTC. Now compares only the date portion when either side is `DATE_ONLY`; cross-day mismatches still rejected.
- **Two-stage-review quality findings on import code** — Several issues caught by the post-implementation review: silent chunk-error swallowing in commit dispatcher (now surfaced), hardcoded English in mapping-wizard labels (now fully i18n'd DE+EN), missing parse-error truncation hint, `useState(initialChecked)` stale-state risk in `PreviewModal`, missing `aria-labelledby` on the modal.
- **`imported_fr24` badge collided with `historical_enrichment` colour** — Both rendered as amber on the same flight card when an FR24-imported flight got enriched. `imported_fr24` now uses orange; the others stay amber.
- **API silently accepted historical flights with `arr_time` before `dep_time`** — The `createFlightSchema` chronological-order refine previously skipped `historical` and `duplicated` statuses, so an overnight flight without day-rollover (Norberts ATL→MUC 1986 case, arrival 8 h before departure) passed through `POST /flights` and `POST /flights/batch` silently. The refine now applies to every status when both times are set; NULL `arrival_time` remains legal for historicals so legitimate date-only bulk imports keep working. Surfaced by Norberts UAT on his Unraid-2 instance.
- **API silently accepted historical / flown flights with future dates** — No year-axis sanity check; future-dated `historical` rows (Norbert had three 2026-dated EI-flight rows from a year-typo at import time) and future-dated `flown` rows were both accepted. Schema now rejects them. `scheduled` with a past date is intentionally NOT a hard reject — the create handlers log `flight_create_scheduled_in_past` for ops review instead, since legitimate edge cases (just-departed manually re-edited rows) exist.
- **`PUT /flights/:id` had no chronological refine at all** — `updateFlightSchema` was missing both the chronological-order and year-axis refines that `createFlightSchema` had since the start. Updates that flipped `arr` before `dep`, or that retroactively re-stamped a historical to the future, all passed silently. The same shared refines now also gate every update payload.
- **`route_distance` was never written by the regular insert paths** — Only Provider lookups (AeroDataBox, AirLabs) populated it; `POST /flights`, `POST /flights/batch`, and `PUT /flights/:id` left it NULL. Result: total km, longest flight, and the distance-tier achievements were blank for every user who hadn't explicitly run a Provider refresh — including all 231 of Norberts flights. Haversine is now computed from the enriched dep/arr coords on every insert and recomputed on `PUT` whenever a coord changes. Same source of truth as `co2Kg`, which had been computing Haversine internally for CO2 but not persisting the distance.
- **Import preview modal redesign** — Flightradar24's CSV exporter uses UTC+4 instead of `Asia/Kolkata` (+5:30) for Bengaluru, so BLR↔non-India flights tripped `duration_mismatch` and got their checkbox disabled by the import preview. The flag is now treated as a soft warning: the row stays selectable and renders the backend-calculated Date/Status normally, with an explicit "Duration mismatch — using calculated arrival from departure + duration" badge. Reported by jay-tau in issue #99 UAT.
- **Import preview modal redesign** — Cancel and Import buttons no longer concatenate as "CancelImport 25 rows"; the modal now uses the same centred-overlay / rounded-card / flex-footer pattern as `DiagnosticExportModal`. After a successful commit it transitions to a "✅ Imported N flights" success state with a link to the flights list, instead of silently closing (which read like the import had failed when the user re-opened it and saw the just-imported rows flagged as `exact_match` duplicates).
- **Generic-CSV wizard UX** — Persistent status footer replaces the silently-disabled Continue button: "Pflichtfelder fehlen: Datum, Von IATA" in red when blocked, "Alle Pflichtfelder zugeordnet · 3 optional übersprungen" in green when ready. Required and Optional fields are grouped into separate sections. Collision detection: the same CSV column mapped to two TravStats fields turns both dropdowns red and blocks Continue until resolved.
- **i18n plural keys for the import preview** — `commit` / `imported` / `failed` migrated from the legacy `_plural` suffix (broken in i18next v23) to `_one` / `_other`, so "Import 2 row" / "✅ 2 Flug importiert" pluralises correctly.
- **FR24 import: arrival time stored 1h30 off on BLR-international (rc.6)** — RC.2 made `duration_mismatch` rows selectable but stored `arr_utc = dep_utc + (broken) Duration`, inheriting FR24's wrong Bengaluru offset (UTC+4 instead of `Asia/Kolkata` +5:30) on the very rows the flag was meant to warn about. RC.6 anchors `arr_utc` on `arr_local + arr_tz` — the authoritative signal — and uses Duration only to disambiguate the calendar day on trans-meridian flights via a best-day-shift search (0/+1/+2/+3 days from dep date). The `duration_mismatch` flag still fires for transparency when even the best day-shifted candidate is more than 30 min off the duration-derived target. Reported by jay-tau in issue #99 RC.2 UAT (BLR→LHR / BA119 / AF191 round-trip showed -1h30 / +1h30 / -1h30 drift). The trans-meridian LAX→SYD test case had hardcoded `delta = 14h55`, which embedded FR24's stored Duration; the real wall-clock flight time is 13h55 (LAX PDT → SYD AEDT, both in DST on 2023-03-22) and the test was updated to match the correct semantics.
- **Sticky preview-table header bled through scrolling rows (rc.6)** — `<thead>` in `PreviewModal` lacked a stacking context, so rows with rgba warning/error backgrounds painted over the column headers when the preview overflowed. Added `z-10` to the sticky thead.
- **Trip auto-detection orphaned DATE_ONLY return legs (rc.7)** — `detectTrips` reads flights ordered by `departureTime asc`. For manually entered DATE_ONLY round-trips, default timestamps don't reflect within-day chain order — Norberts 2009-09-21 return-day stored RAK→MAD with `dep=12:00 UTC` and the connecting MAD→MUC anchor with `dep=10:00 UTC`, so a raw-timestamp sort placed the anchor BEFORE the return leg. The home-loop walker then closed the loop early and orphaned the return (issue #104). The earlier `durationMinutes` fix (#106A, rc.4) cleaned up presentation but didn't change DB sort order, so the RC.4 changelog entry crediting this as a side-effect of #106A was premature. RC.7 adds `chainCoherentSort`: within each calendar date, Kahn's topological sort using arr→dep airport matches as chain edges, with `departureTime` as a stable tiebreak between unrelated same-day chains. Single-flight days and already-timestamp-correct days are unaffected; multi-day order is preserved. The home-loop and continuity heuristics now see Norberts MUC↺RAK return leg in the right place and capture all 4 flights.
### Tests
- **E2E Playwright critical paths for all three importers** — End-to-end coverage for FR24, Generic-CSV (full mapping-wizard flow), and Round-Trip imports against a fresh dev DB. 5 new tests, suite now 90 across 6 files.
- **Regression tests for scheduled-flight leakage** — Locked-in coverage that scheduled flights cannot trigger general achievements (`flights_count`, `airports`, `countries`, `continents`, …) or contaminate `highestAirport` / similar unique stats. The engine architecture filters at the SQL layer; these tests prevent silent regressions.
- **Bug-slot unit tests** — 32 new tests for the historical-date shape discriminator (`useFlightForm.dateShape.test.ts` covering 4 storage shapes), 7 for `bcbpToScanResult`, 4 for Tesseract `extractDateTimes`, plus 10 for historical-flight stats inclusion. Backend 607/607, frontend 348/348.
- **RC2 regression coverage** — 6 new wizard tests (auto-mapping exact + alias, missing-required hint, collision detection, sample rendering, cancel), 3 preview-modal tests (soft-warning row stays importable, success-state transition, error-state recovery), 1 backend lock-in test for the `duration_mismatch` BLR-route contract using jay-tau's exact numbers, and 11 backend tests for the new `/import/parse` route + parsers (parser unit tests + supertest integration: 401 unauth, 200 happy paths for both sources, 400 malformed body, 400 oversized body). Backend 619/619, frontend 357/357.
- **RC3 API-hardening regression coverage** — 5 schema tests (G1 chronological order for historicals, G2/G3 year-axis sanity for historical/flown, G5 same refines on update payloads), 3 backfill-script tests (idempotency, NULL-coord skip, Haversine sanity for short/long-haul/trans-Tasman), plus 5 implicit deltas in pre-existing flight-route tests. Backend 632/632.
- **RC4 data-integrity coverage** — 13 new tests (`dataIntegrity.rc4.test.ts`) covering the `tzAwareDurationMinutes` DATE_ONLY return-null path (5 tests), the strict-exact airline resolver (8 tests including the defensive "Indian Airlines must NOT resolve to AI/Air India" lock-in), and the granularity-aware chronology refine (4 tests). Existing G1 hardening test message pattern generalised for the new "arrival date must not precede departure date" wording. Backend 652/652, frontend 357/357.
### Database
- **One new migration** — `20260508120000_add_aerodatabox_extended_fields`: 9 additive `ALTER TABLE ADD COLUMN` statements for the AeroDataBox extended fields (Flight: `runwayDepartureTime`, `runwayArrivalTime`, `isCargo`, `aerodataboxLastUpdatedUtc`, `aerodataboxQualityTags`, `baggageBelt`, `checkInDesk`; Airport: `shortName`, `municipalityName`). Zero-downtime safe, idempotent.
### Docs
- **Roadmap restructured** — v1.5 (silent field capture) and v1.6 (importers) merged into a single V1 finale; v1.7–v1.9 (social / insights / PWA) moved into V2 as v2.3–v2.5.
## [1.4.0] - 2026-05-04
### Added
- **AeroDataBox as a third flight-lookup provider** — Closes the gap that AirLabs and Aviationstack-Free leave open: historical lookup for any date in the last 365 days, plus terminal / gate / aircraft registration / Mode-S / operating-vs-marketing carrier on codeshares. Wired into the cascade as a tertiary fallback for out-of-live-window queries and historical enrichment. RapidAPI BASIC free tier (600 API-units / month).
- **Bulk historical refresh of existing flights** — Settings → API Keys gains a "Refresh all flights in 365-day range" card under the AeroDataBox key. Backfills tail number, Mode-S, codeshare metadata, distance, airline IATA/ICAO on flights entered before this provider was wired up, never overwriting curated values. A confirmation modal shows how many API calls the run will cost. Disabled on the demo account so shared keys aren't drained by tutorial sessions.
- **Per-provider quota indicators with capability badges** — Each provider's quota is surfaced honestly on the API Keys page: AeroDataBox shows the live `X/600 API units` (primary) plus the secondary HTTP-request counter; AirLabs / Aviationstack-Free are labelled "provider doesn't expose quota — free tier ~1000/month"; OpenSky says "no monthly limit — IP-based rate-limit only". A "365-day lookup" capability badge marks AeroDataBox as the historical-fallback workhorse so it's clear at a glance which provider does what.
- **Persisted tail number, Mode-S code, distance and codeshare metadata** — Flights enriched via lookup now carry the operating registration, the 24-bit Mode-S hex identifier, the operating airline when it differs from the marketing carrier, the great-circle distance in km, and airline IATA/ICAO. Feeds the new aircraft-hulls ranking and per-tail profile pages.
- **Aircraft hulls ranking + per-tail profile pages** — Stats → Advanced gains a leaderboard of the registrations you've flown most often. Each tail number opens its own page (`/aircraft/:reg`) showing type, flights flown, total distance, and the timeline of every flight on that hull. Works against any flight with an `aircraftRegistration` (provider-sourced or manually entered).
- **Currency input accepts any ISO 4217 code** — The currency picker on flight and cost forms is now a styled combobox that takes any three-letter ISO 4217 code, not just the hardcoded shortlist. Filters as you type, falls back to the raw code for exotic currencies, surfaces the symbol when known. Closes #100.
- **Airline logos in flight cards and trip sidebar** — Each carrier now renders its logo next to the airline name, served from the Daisycon free aviation-logo CDN. Falls back to a letter-box monogram (LH, BA, …) for carriers not in the CDN — no broken image icons.
- **Bulk export with `GET /flights?all=true`** — Skips the default 500-row cap so a script or AI agent can pull a full account snapshot in one call. Bounded only by the per-PAT rate-limit bucket; cookie-session callers still default to 100.
- **Round-trip-fair Excel export** — `.xlsx` export now carries every editable field (43 columns), real numeric and date cells (instead of strings), an autoFilter on the header row, and a frozen header. Re-importing the same file is loss-free across all boarding-pass and email-import metadata that the import side already accepts.
### Fixed
- **AeroDataBox quota indicator showed 2400 instead of the actual 600** — RapidAPI sends two independent rate-limit counters: `X-RateLimit-API-Units-*` (the real BASIC tier quota = 600/month) and `X-RateLimit-Requests-*` (a separate HTTP-request counter ~4× larger and irrelevant for plan budgeting). The indicator was reading the latter, so BASIC-tier users saw a misleading "2400 left" headroom. Now reads API-Units as primary and surfaces the HTTP-request count as a secondary detail only.
- **Closed and decommissioned airports missing from the seed** — The seeder filtered out OurAirports rows where `type='closed'`, excluding every decommissioned IATA code (BER's predecessor SXF, TXL, etc.). Older flights re-imported via Excel or boarding-pass scan failed to enrich because the lookup couldn't find the code. Closed airports are now seeded on first install, and a one-shot boot-time backfill (`backfillClosedAirports.ts`) adds them to existing installs without re-running the full seed.
- **API-key test endpoints rejected masked-input retests** — Saving a key, navigating away, returning and clicking "Test" sent the masked display value (`••••abc1`) to the test endpoint instead of the persisted key, so the test always failed with 401 even though the saved key was valid. Test endpoints now fall back to the persisted key when the input is empty or contains the mask placeholder.
- **Past + scheduled flights on the same airport pair drew two overlapping arcs** — Routes carrying both flown and scheduled legs rendered two separately-tinted arcs at the same `arcHeight`, producing a visible Z-fight (Madagascar regression). The route layer now collapses both onto a single canonical arc carrying both flight IDs; the "scheduled" signal lives in a fragment-shader-driven gradient on that same arc rather than a separate midpoint marker.
- **`PUT /flights/:id` silently dropped boarding-pass and email-import fields** — Field-level updates for `seatNumber`, `boardingGroup`, `gate`, `terminal`, `bookingReference`, `ticketNumber`, `baggageAllowance`, `frequentFlyerNumber`, `bookingClassLetter`, `coPassengers`, and `dataSource` were accepted by the Zod schema but discarded by the route handler. Editing any of these in the UI or via API now persists. Clients can also explicitly override `depTimeSemantics` / `arrTimeSemantics` so a `UTC` semantics flag beats the implicit default during boarding-pass re-imports.
- **Health check missing under `/api/v1/health`** — Reverse proxies that pin the `/api/v1/*` prefix could not reach the unprefixed `/health` route. Both paths now respond with the same payload.
- **Display version drifted across env, image, and runtime** — `APP_VERSION` was duplicated across the compose env, the build-arg, and the runtime export and could silently desync — last seen on rc.1 where System-Info showed `1.3.0` on a host whose compose was not bumped. The runtime now reads `backend/VERSION` from disk on boot as the single source of truth, strips RC suffixes (`-rc.N`, `-security-rc.N`, `-beta`, `-alpha`) from the user-facing display version, and exposes both raw `buildVersion` and tidy `appVersion` via `/api/v1/version`. The compose `APP_VERSION` env var is removed.
- **Empty UI after every container restart on Unraid + similar setups (rc.5 boot-race fix)** — supervisord launched nginx and the Express backend in parallel. nginx accepted `/api/*` immediately and proxied to a port where Express wasn't listening yet, so every browser open during the 30-90 s migration + airport-backfill window cached `502 / no live upstreams` and never refetched until the user hard-reloaded. Two-layer fix: (1) supervisord now starts nginx via `scripts/wait-then-nginx.sh`, which polls backend `/health` and only `exec`s nginx when ready (5-minute fallthrough so the container never hangs forever); (2) the frontend Axios clients gained a gateway-retry interceptor that retries idempotent verbs on 502/503/504 / `ERR_NETWORK` with exponential backoff (3 attempts, 0.5/1/2 s) so any future short backend outage heals on its own. Docker `HEALTHCHECK --start-period` bumped 40 s → 180 s to absorb the longest observed first-boot path (closed-airport backfill). Verified end-to-end on a fresh local stack: 0 `no live upstreams` events in `nginx error.log`, port 80 cleanly held closed for the entire boot window, then opened with HTTP 200.
### Changed
- **Routes-mode "scheduled" indicator now lives inside the arc, not as a separate dot** — Earlier 1.4.0 RCs marked routes carrying an upcoming flight with a small cyan-bordered scatter dot at the arc midpoint. The dot read as a foreign visual element on a heatmap-dense map. Replaced by an in-arc symmetric gradient: the same single arc fades from sky-blue at both ends through a heatmap-coloured core. Implemented via an `UpcomingArcLayer` that subclasses `ArcLayer` and injects ~10 lines of fragment-shader code at `fs:DECKGL_FILTER_COLOR`, using the existing `geometry.uv.x` segment ratio. Single line per route, blue-leaning baseline so an upcoming-route arc reads as primarily scheduled with a heatmap-tinted core.
- **Currency picker is a styled combobox** — Replaces the native `<datalist>` with a custom dropdown matching the rest of the form styling. Same accept-any-ISO-4217 behaviour, just visually consistent across browsers (native `<datalist>` rendered very differently in Firefox / Chrome / Safari).
- **Email-import regex warning copy is calmer** — The "automatic import is still limited" warning under email import now leads with a clearer title, separates the explanation from the recommendation with a paragraph break, and points at the Ollama setup more directly.
### Security
- **Per-minute burst limiter stacked on `/airports/search`** — The autocomplete endpoint is unauthenticated by design (signup-flow needs it before credentials exist) and was already capped at 100 / 15 min. A scraper could still fire ~100 requests in seconds before the sustained ceiling tripped; a 30 / min burst limiter now layers on top so abusive enumeration is bounded both per-second and per-window. PAT-authenticated callers retain the 10× multiplier on both buckets.
- **Closed 14 Dependabot alerts on the frontend lockfile** — axios bumped from `1.15.0` to `1.16.0` (declared `^1.15.2`), sweeping up 13 alerts (3 high, 9 medium, 1 low) covering prototype-pollution gadgets, CRLF injection, NO_PROXY bypass, and header injection. Backend was already on the safe range; this fix syncs the frontend. `uuid` is forced to `^14` via an override (transitive via `exceljs@4.4.0` was on `8.3.2`) — our usage only hits `uuid.v4()` so the buffer-bounds-check vuln in `v3/v5/v6` was not actually reachable, but the override is preferable to dismissing.
### Docs
- **OpenAPI spec scope clarified** — The published spec is the cura