# Changelog
All notable changes to TravStats are documented here.
Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
## [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 ``. 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 — `` is
rendered inside ``, 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 `
` 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 `
`
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. `` 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 `` 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)** — `` 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 `