Merge remote-tracking branch 'origin/dev' into feat/storage-driver

# Conflicts:
#	server/src/db/migrations.ts
#	server/src/nest/journey/journey.controller.ts
#	server/tests/integration/leg-mode-incoming.test.ts
#	server/tests/unit/nest/journey.controller.test.ts
#	server/vitest.config.ts
This commit is contained in:
jubnl
2026-08-19 19:52:49 +02:00
154 changed files with 6148 additions and 823 deletions
+1
View File
@@ -15,6 +15,7 @@
*.sh text eol=lf
# Binary files — no line ending conversion
*.png binary
*.webp binary
*.jpg binary
*.jpeg binary
*.gif binary
+92 -78
View File
@@ -1,17 +1,16 @@
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/logo-trek-light.gif" />
<source media="(prefers-color-scheme: light)" srcset="docs/logo-trek-dark.gif" />
<img src="docs/logo-trek-dark.gif" alt="TREK" height="96" />
<source media="(prefers-color-scheme: dark)" srcset="docs/logo-trek-light.svg" />
<source media="(prefers-color-scheme: light)" srcset="docs/logo-trek-dark.svg" />
<img src="docs/logo-trek-dark.svg" alt="TREK" height="96" />
</picture>
<br />
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/subtitle-light.png" />
<source media="(prefers-color-scheme: light)" srcset="docs/subtitle-dark.png" />
<img src="docs/subtitle-dark.png" alt="Your trips. Your plan. Your server." height="28" />
<img src="docs/subtitle-dark.png" alt="your trip. your plan." height="28" />
</picture>
A self-hosted, real-time collaborative travel planner — with maps, budgets, packing lists, a journal, and AI built in.
@@ -41,61 +40,62 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
<div align="center">
<img src="https://github.com/liketrek/TREK-media/releases/download/readme-assets/TREK1.gif" alt="TREK — 60-second tour" width="100%" />
<img src="https://github.com/mauriceboe/trek-media/releases/download/readme-assets/TREK1.webp" alt="TREK, a tour of the app" width="100%" />
</div>
<br />
<div align="center">
<a href="docs/screenshots/dashboard.png"><img src="docs/screenshots/dashboard.png" alt="Dashboard" width="49%" /></a>
<a href="docs/screenshots/trip-planner.png"><img src="docs/screenshots/trip-planner.png" alt="Trip planner · day plan & route" width="49%" /></a>
<a href="docs/screenshots/journey.png"><img src="docs/screenshots/journey.png" alt="Journey journal" width="49%" /></a>
<a href="docs/screenshots/budget.png"><img src="docs/screenshots/budget.png" alt="Costs · expense splitting" width="49%" /></a>
<a href="docs/screenshots/atlas.png"><img src="docs/screenshots/atlas.png" alt="Atlas · visited countries" width="49%" /></a>
<a href="docs/screenshots/vacay.png"><img src="docs/screenshots/vacay.png" alt="Vacay planner" width="49%" /></a>
<a href="docs/screenshots/collections.png"><img src="docs/screenshots/collections.png" alt="Collections · saved place lists" width="49%" /></a>
<a href="docs/screenshots/admin.png"><img src="docs/screenshots/admin.png" alt="Admin panel" width="49%" /></a>
</div>
---
## What you get
<picture>
<source media="(max-width: 700px)" srcset="docs/tiles/grid-mobile.svg" />
<img src="docs/tiles/grid-desktop.svg" alt="TREK feature tiles" width="100%" />
<source media="(prefers-color-scheme: dark)" srcset="docs/tiles/grid-desktop-dark.svg" />
<img src="docs/tiles/grid-desktop.svg" alt="Plan, track, share" width="100%" />
</picture>
<details>
<summary><b>See all features</b></summary>
<br />
Most of what follows is an addon an admin switches on or off. Lists, Costs, Documents, Collab, Vacay and Atlas ship on; Journey, Collections, MCP, AI Parsing and AirTrail ship off and are marked below.
<table>
<tr>
<td width="50%" valign="top">
#### 🧭 Trip planning
#### 🧭 Planning
- **Drag & drop planner** — organise places into day plans with reordering and cross-day moves
- **Interactive map** — Leaflet or Mapbox GL with 3D buildings, terrain, photo markers, clustering, route visualization
- **Place search** — Google Places (photos, ratings, hours) or OpenStreetMap (free, no API key)
- **Place import** — shared Google Maps / Naver Maps lists, plus GPX and KML/KMZ/GeoJSON map files
- **Day notes** — timestamped, icon-tagged notes with drag-and-drop reordering
- **Route optimisation** — auto-sort places and export to Google Maps
- **Weather forecasts** — 16-day via Open-Meteo (no key) + historical climate fallback
- **Category filter** — show only matching pins on the map
- **Day plans**: drag places between days and reorder inside a day, with undo. Notes and bookings drag the same way, and a map marker drops straight onto a day
- **Maps**: Leaflet, Mapbox GL or MapLibre GL (OpenFreeMap, no token), with clustering, photo markers and route lines. 3D buildings and terrain are Mapbox only
- **Place search**: Google Places when a key is set (photos, ratings, opening hours), otherwise OpenStreetMap with no key
- **Place enrichment**: descriptions, facts, hours and photo candidates from OpenStreetMap, Wikipedia, Wikidata and Wikimedia Commons
- **POI explore**: pull OpenStreetMap POIs by category for the current viewport over Overpass
- **Import**: shared Google Maps and Naver Maps lists, plus GPX, KML and KMZ files
- **Export**: GPX of a trip's places and tracks, and an ICS feed per trip or across all of them
- **Routes**: auto-sort a day (nearest neighbour then 2-opt, locked stops and hotel anchors stay put), driving, walking or cycling profiles over OSRM, then open it in Google Maps or CoMaps
- **Public transport**: door-to-door itineraries over Transitous
- **Weather**: 16-day forecast from Open-Meteo, no key. Dates outside that window read the archive for the same date instead
- **Day notes**: markdown body with an icon and a colour, reordered by drag and drop or moved to another day
- **Trip dates**: move a trip and the days re-date themselves, either dragging the bookings along or re-anchoring them. Trips also copy and archive
</td>
<td width="50%" valign="top">
#### 🧳 Travel management
#### 🧳 Bookings and money
- **Reservations** — flights, accommodations, restaurants with status, confirmation numbers, files; import from booking confirmation emails and PDFs ([KDE Itinerary](https://invent.kde.org/pim/kitinerary))
- **Costs** — track and split trip expenses (Splitwise-style): per-person / per-day breakdowns, settle-up, multi-currency
- **Packing lists** — categories, templates, user assignment, progress tracking
- **Bag tracking** — optional weight tracking with iOS-style distribution
- **Document manager** — attach docs, tickets, PDFs to trips / places / reservations (≤ 50 MB each)
- **PDF export** — full trip plan as PDF with cover page, images, notes
- **Reservations**: 16 booking types with status, confirmation code, travellers and attached files
- **Flights and trains**: several legs with stopovers, per-leg times and endpoint timezones, against 4,045 bundled airports so local times resolve without a key
- **Accommodation**: a stay spans a range of days with a check-in window, and shows on every night it covers
- **Booking import**: EML, PDF, PKPass, HTML and TXT confirmations through [KItinerary](https://invent.kde.org/pim/kitinerary). Needs the `kitinerary-extractor` binary, which ships in the Docker image
- **AirTrail** (off by default): link a self-hosted AirTrail instance to import flights as reservations and keep them in sync
- **Costs**: split expenses in integer cents with equal or custom shares, several payers per expense, settle-up suggestions, a settlement log, and CSV export
- **Currencies**: a currency per expense with the rate frozen at entry. Rates come from Frankfurter, no key
- **Packing lists**: categories, admin-managed templates, assignees, three visibility tiers, and a packed-of-total line. Bags with weight roll-up are a separate admin switch, off by default
- **To-dos**: assignee, due date, priority, and a reminder before one falls due
- **Files**: attach to a trip, place, day entry or reservation. 50 MB each, 500 MB for video, with trash and restore
- **PDF export**: cover page, place photos, day notes, bookings and costs, with an optional page break per day
</td>
</tr>
@@ -104,64 +104,70 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
#### 👥 Collaboration
- **Real-time sync** — WebSocket. Changes appear instantly across all connected users
- **Multi-user trips** — invite members with role-based access
- **Invite links** — one-time or reusable links with expiry
- **SSO (OIDC)** — Google, Apple, Authentik, Keycloak, or any OIDC provider
- **2FA** — TOTP + backup codes
- **Passkeys** — passwordless WebAuthn login (fingerprint / face / PIN / security key), admin-toggleable
- **Collab suite** — group chat, shared notes, polls, day check-ins
- **Real-time sync** (WebSocket): edits land live for everyone who has that trip open
- **Members**: add by email or username, hand ownership to someone else, or add guests who have no login at all
- **Permissions**: an admin maps each of 16 trip actions to admin, trip owner, trip member or everybody
- **Invite links**: one reusable link per trip with an optional expiry. Admins can also issue signup invites with a use limit that drop the new account straight into a trip
- **Public share**: a read-only trip page anyone can open without an account
- **Collab**: group chat with replies, reactions and link previews, shared notes with attachments, polls, and a What's Next list of upcoming activities. Each of the four switches on its own
#### 📔 Journal, Atlas and Vacay
- **Journey** (off by default): dated entries with story, mood, weather and tags, photos and video from uploads or a linked [Immich](https://immich.app) or Synology Photos library, map views, co-authors, and a public share link
- **Atlas**: mark countries and sub-national regions visited on [geoBoundaries](https://www.geoboundaries.org/) outlines, plus a bucket list, travel stats and a consecutive-years-travelled count
- **Vacay**: leave calendar with half days, public holidays from date.nager.at, school holiday overlays for 16 European countries, carry-over between years, and joint or read-only sharing of a plan
- **Collections** (off by default): a place library outside any trip, with labels, ratings, a custom image per place, copy-into-a-trip, and sharing by invite
</td>
<td width="50%" valign="top">
#### 📱 Mobile & PWA
#### 🧩 Plugins
- **Installable** — iOS and Android, straight from the browser, no App Store needed
- **Offline support** — Service Worker caches tiles, API, uploads via Workbox
- **Native feel** — fullscreen standalone, themed status bar, splash screen
- **Touch optimised** — mobile-specific layouts with safe-area handling
- **Third-party plugins**: install from the TREK registry or sideload a zip, switch on per instance, with their own pages under `/plugins/<id>`
- **Sandboxed**: one child process per plugin, 63 grantable permissions, an admin-edited outbound host allowlist, memory and RPC caps, and daily caps on AI and notification calls
- **Trust**: registry downloads are pinned by sha256 and checked against the author's minisign key. A sideloaded zip is marked unverified, and `TREK_PLUGINS_ENABLED=false` turns the whole system off
- **Extension points**: map markers and layers, place details, day schedules, PDF sections, Atlas layers, journal rows, trip warnings, calendar sources, route profiles and notification channels. Plugin pages run in an opaque-origin iframe
- **SDK**: [`trek-plugin-sdk`](https://www.npmjs.com/package/trek-plugin-sdk) on npm, with a manifest validator, a mock host, and a dev-link mode that runs a local build against real data
#### 🤖 AI and MCP
- **MCP server** (off by default): OAuth 2.1 with mandatory PKCE and dynamic client registration. 199 tools, 30 resources, 4 prompts
- **Scopes**: 29 scopes in 14 groups, each one tickable on the consent screen. Tokens are bound to the `/mcp` resource, and every tool call lands in the audit log
- **Reach**: create trips and days, edit places, packing lists, to-dos, costs, reservations, collections and journeys, mark countries visited, all inside the scopes the token holds
- **Prompts**: `trip-summary`, plus `packing-list` and `budget-overview` when those addons are on
- **Addon-aware**: seven addon gates decide which tools and resources a session sees. Flipping one drops live sessions so the surface re-registers
- **Booking extraction** (off by default): read a confirmation with a local Ollama server, any OpenAI-compatible endpoint, or Anthropic, configured instance-wide or per user
</td>
</tr>
<tr>
<td width="50%" valign="top">
#### 🧩 Addons (admin-toggleable)
#### 📱 Mobile and offline
- **Lists** — packing lists + to-dos with templates, member assignments, optional bag tracking
- **Costs** — expense tracker with splits and settle-up (who owes whom), multi-currency
- **Documents** — file attachments on trips, places, and reservations
- **Collab** — chat, notes, polls, day-by-day attendance
- **Vacay** — personal vacation planner with calendar, 100+ country holidays, approved school holiday overlays, carry-over tracking
- **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI
- **Journey** — magazine-style travel journal with entries, photos (Immich/Synology), maps, moods
- **AirTrail** — connect a self-hosted AirTrail instance to import and sync flights into reservations
- **MCP** — expose TREK to AI assistants via OAuth 2.1
- **Installable**: iOS and Android straight from the browser, no App Store. Runs standalone without browser chrome, with the status bar tinted per theme
- **Phone layouts**: a separate shell below 768px with its own tokens, a bottom bar and safe-area insets. Which items sit in that bar is up to the user
- **Offline reads**: the app shell and every route chunk are precached, and trips, places and file blobs live in a per-user IndexedDB store, so a trip opens with no network
- **Offline writes**: mutations queue and replay with an `X-Idempotency-Key`, so a reconnect cannot double-apply. A stale edit is parked for a keep-mine or keep-theirs choice
- **Offline maps**: pre-download a trip's raster tiles, and clear them again
- **API responses are never cached** by the service worker. They vary per session, so they always come from the network
</td>
<td width="50%" valign="top">
#### 🤖 AI / MCP
#### ⚙️ Admin, accounts and security
- **Built-in MCP server** — OAuth 2.1 authenticated. 150+ tools, 30 resources
- **Granular scopes** — 27 OAuth scopes across 13 permission groups
- **Full automation** — AI can create trips, plan days, build packing lists, manage budgets, mark countries visited
- **Pre-built prompts** — `trip-summary`, `packing-list`, `budget-overview`
- **Addon-aware** — exposes Atlas, Collab, Vacay when those addons are on
</td>
</tr>
<tr>
<td colspan="2" valign="top">
#### ⚙️ Admin & customisation
- **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar
- **20 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID, TR, JA, KO, UK, GR
- **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history
- **Notifications** — per-user preferences across email (SMTP), webhook, ntfy, and an in-app notification center
- **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates
- **Login methods**: password login, password registration, OIDC login, OIDC registration and passkey login each switch on and off on their own. `OIDC_ONLY` leaves SSO as the only way in
- **SSO (OIDC)**: one provider by discovery, with PKCE and `id_token` verification (Authentik, Keycloak, Google and the like)
- **2FA**: TOTP with ten single-use backup codes, and an admin can require it instance-wide
- **Passkeys**: WebAuthn login by fingerprint, face, PIN or security key, off until an admin enables it. A passkey also satisfies the 2FA requirement
- **Hardening**: per-IP limits on login, password reset and 2FA attempts, a password policy, secrets encrypted at rest and masked on read, and an SSRF guard on every URL you configure
- **Admin panel**: users and invites, the permission matrix, packing templates, categories, addons, plugins, API keys, MCP tokens and OAuth sessions, backups, audit log, and GitHub releases
- **Backups**: manual or scheduled hourly, daily, weekly or monthly, with retention in days. The zip carries the at-rest key, so a restore can decrypt its own secrets
- **Notifications**: a per-user matrix of events against in-app, email (SMTP), webhook and ntfy, plus any channel a plugin registers
- **Appearance**: light, dark or follow the OS, seven colour schemes plus a custom accent, transparency, compact density, reduce motion, and text size per tier
- **23 languages**: en, de, es, fr, it, nl, hu, ru, zh, zh-TW, pl, cs, ar (right to left), br, id, tr, ja, ko, uk, gr, sv, vi, ca
- **In-app help**: the wiki ships inside the image and is served from disk at `/help`, so the docs match the version you are running
</td>
</tr>
@@ -171,6 +177,14 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
<br />
<div align="center">
<a href="docs/screenshots/showcase-desktop-1.webp"><img src="docs/screenshots/showcase-desktop-1.webp" alt="Atlas · dashboard · trip planner" width="100%" /></a>
<a href="docs/screenshots/showcase-mobile.webp"><img src="docs/screenshots/showcase-mobile.webp" alt="Mobile PWA · dashboard, day plan, map, costs" width="100%" /></a>
<a href="docs/screenshots/showcase-desktop-2.webp"><img src="docs/screenshots/showcase-desktop-2.webp" alt="Collections · journey journal · costs" width="100%" /></a>
</div>
<br />
## AI usage
We use LLM-assisted coding tools across parts of this codebase. Nothing ships that a
@@ -195,7 +209,7 @@ Open `http://localhost:3000`. On first boot TREK seeds an admin account — if y
<div align="center">
&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#docker-compose-production">Docker Compose</a>&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#helm-kubernetes">Helm / Kubernetes</a>&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#install-as-app-pwa">Install as PWA</a>&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#reverse-proxy">Reverse Proxy</a>&nbsp;&nbsp;·&nbsp;&nbsp;
<a href="#docker-compose-production">Docker Compose</a>&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#helm-kubernetes">Helm / Kubernetes</a>&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#install-as-app-pwa">Install as PWA</a>&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#reverse-proxy">Reverse Proxy</a>
</div>
+2 -1
View File
@@ -1029,7 +1029,8 @@ export const healthApi = {
}
export const weatherApi = {
get: (lat: number, lng: number, date: string): Promise<WeatherResult> => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.get')),
// `time` (HH:MM) makes a past date answer for that hour instead of the day (#1614).
get: (lat: number, lng: number, date: string, time?: string): Promise<WeatherResult> => apiClient.get('/weather', { params: { lat, lng, date, time } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.get')),
getCurrent: (lat: number, lng: number, lang?: string): Promise<WeatherResult> => apiClient.get('/weather', { params: { lat, lng, lang } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.getCurrent')),
getDetailed: (lat: number, lng: number, date: string, lang?: string): Promise<WeatherResult> => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.getDetailed')),
}
@@ -836,4 +836,46 @@ describe('EntryEditor', () => {
expect(amazing.className).toContain('border-zinc-200')
expect(amazing.getAttribute('style')).toBe('')
})
// #1614 — entry_time was stored, sent and displayed everywhere, but the desktop
// editor never rendered an input for it.
it('FE-JRN-EDITOR-041: shows the stored time and saves an edited one', async () => {
const user = userEvent.setup()
const { onSave } = mountEditor(buildEntry({ id: 10, title: 'Old', entry_time: '14:30:00' }))
// The column carries HH:MM:SS; a time input only accepts HH:MM.
const timeInput = screen.getByDisplayValue('14:30')
expect(timeInput).toHaveAttribute('type', 'time')
fireEvent.change(timeInput, { target: { value: '09:05' } })
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(onSave).toHaveBeenCalled())
expect(onSave.mock.calls[0][0]).toEqual(expect.objectContaining({ entry_time: '09:05' }))
})
it('FE-JRN-EDITOR-042: clearing the time sends null rather than an empty string', async () => {
const user = userEvent.setup()
const { onSave } = mountEditor(buildEntry({ id: 10, entry_time: '14:30:00' }))
fireEvent.change(screen.getByDisplayValue('14:30'), { target: { value: '' } })
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(onSave).toHaveBeenCalled())
expect(onSave.mock.calls[0][0]).toEqual(expect.objectContaining({ entry_time: null }))
})
it('FE-JRN-EDITOR-043: the camera route is its own input, so the library picker survives', () => {
const { container } = mountEditor(buildEntry({ id: 10 }))
const inputs = Array.from(container.querySelectorAll('input[type="file"]'))
const camera = inputs.filter(i => i.getAttribute('capture') === 'environment')
const library = inputs.filter(i => !i.hasAttribute('capture'))
expect(camera).toHaveLength(1)
expect(library).toHaveLength(1)
// The library picker keeps multi-select; forcing capture onto it would drop that.
expect(library[0]).toHaveAttribute('multiple')
expect(camera[0]).not.toHaveAttribute('multiple')
})
})
@@ -1,5 +1,5 @@
import { useEffect, useState, useRef } from 'react'
import { X, Plus, Image, Minus, Check, MapPin, Locate } from 'lucide-react'
import { X, Plus, Image, Minus, Check, MapPin, Locate, Camera } from 'lucide-react'
import { normalizeImageFiles } from '../../utils/convertHeic'
import { type ResilientResult, type UploadProgress } from '../../utils/uploadQueue'
import { useTranslation } from '../../i18n'
@@ -34,7 +34,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
const [title, setTitle] = useState(entry.title || '')
const [story, setStory] = useState(entry.story || '')
const [entryDate, setEntryDate] = useState(entry.entry_date || new Date().toISOString().split('T')[0])
const [entryTime, setEntryTime] = useState(entry.entry_time || '')
const [entryTime, setEntryTime] = useState(entry.entry_time?.slice(0, 5) || '')
const [locationName, setLocationName] = useState(entry.location_name || '')
const [locationLat, setLocationLat] = useState<number | null>(entry.location_lat ?? null)
const [locationLng, setLocationLng] = useState<number | null>(entry.location_lng ?? null)
@@ -60,6 +60,10 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
const [externalProvider, setExternalProvider] = useState<string | null>(null)
const [pendingProviderGroups, setPendingProviderGroups] = useState<PendingProviderGroup[]>([])
const fileRef = useRef<HTMLInputElement>(null)
// Own input: putting `capture` on the picker above would take the photo library
// away on a phone, which is the more common way in. This one only ever opens the
// camera, so tablets and laptops get the same route the phone sheet already has.
const cameraRef = useRef<HTMLInputElement>(null)
const storyRef = useRef<HTMLTextAreaElement>(null)
const persistedEntryIdRef = useRef<number | null>(entry.id > 0 ? entry.id : null)
@@ -71,7 +75,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
title !== (entry.title || '') ||
story !== (entry.story || '') ||
entryDate !== (entry.entry_date || new Date().toISOString().split('T')[0]) ||
entryTime !== (entry.entry_time || '') ||
entryTime !== (entry.entry_time?.slice(0, 5) || '') ||
locationName !== (entry.location_name || '') ||
(locationLat ?? null) !== (entry.location_lat ?? null) ||
(locationLng ?? null) !== (entry.location_lng ?? null) ||
@@ -258,6 +262,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
<div>
<input ref={fileRef} type="file" accept="image/*" multiple onChange={handleFileChange} onClick={e => { (e.target as HTMLInputElement).value = '' }} className="hidden" />
<input ref={cameraRef} type="file" accept="image/*" capture="environment" onChange={handleFileChange} onClick={e => { (e.target as HTMLInputElement).value = '' }} className="hidden" />
<div className="flex gap-2">
<button
onClick={() => { setPhotoTab('upload'); setShowGalleryPick(false); fileRef.current?.click() }}
@@ -282,6 +287,15 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
<Image size={13} /> {t('journey.editor.fromGallery')}
</button>
)}
<button
onClick={() => { setPhotoTab('upload'); setShowGalleryPick(false); cameraRef.current?.click() }}
disabled={saving}
aria-label={t('journey.photo.add')}
title={t('journey.photo.add')}
className="border border-dashed border-zinc-200 dark:border-zinc-700 rounded-xl px-4 text-[12px] text-zinc-500 hover:border-zinc-400 dark:hover:border-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-800 flex items-center justify-center disabled:opacity-50"
>
<Camera size={14} />
</button>
<button
onClick={() => { setPhotoTab('external'); setShowGalleryPick(false) }}
disabled={saving}
@@ -559,9 +573,23 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('journey.editor.date')}</label>
<DatePicker value={entryDate} onChange={setEntryDate} tripDates={tripDates} />
{/* Time sat in state and went to the server, it just had no input here — so a
draft's auto-stamped clock time showed up in the timeline, the map, the PDF
and the public share, and the desktop had no way to correct it (#1614). */}
<div className="grid grid-cols-[1fr_104px] gap-2">
<div>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('journey.editor.date')}</label>
<DatePicker value={entryDate} onChange={setEntryDate} tripDates={tripDates} />
</div>
<div>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('mobileJourney.time')}</label>
<input
type="time"
value={entryTime}
onChange={e => setEntryTime(e.target.value)}
className="w-full h-[42px] px-3 rounded-xl border border-zinc-200 dark:border-zinc-700 bg-transparent text-[13px] text-zinc-900 dark:text-white outline-none focus:border-zinc-400 dark:focus:border-zinc-500 [font-variant-numeric:tabular-nums]"
/>
</div>
</div>
<div className="relative">
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('journey.editor.location')}</label>
@@ -1,6 +1,7 @@
// FE-JRN-GALLERY-001 to FE-JRN-GALLERY-016
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { act } from 'react'
import { http, HttpResponse } from 'msw'
import userEvent from '@testing-library/user-event'
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'
@@ -22,6 +23,31 @@ const uploadGalleryPhotos = vi.fn(
const JOURNEY_ID = 9
// The global IntersectionObserver stub never fires; this one hands the callback
// back so the gallery's load-more sentinel can be driven into view on demand.
const galleryObservers: { cb: IntersectionObserverCallback; el: Element | null }[] = []
function triggerIntersection() {
for (const o of galleryObservers) {
o.cb([{ isIntersecting: true, target: o.el } as unknown as IntersectionObserverEntry], null as never)
}
}
function stubIntersectionObserver() {
galleryObservers.length = 0
vi.stubGlobal('IntersectionObserver', class {
observe: (el: Element) => void
disconnect = vi.fn()
unobserve = vi.fn()
takeRecords = vi.fn(() => [])
constructor(cb: IntersectionObserverCallback) {
const entry: { cb: IntersectionObserverCallback; el: Element | null } = { cb, el: null }
galleryObservers.push(entry)
this.observe = (el: Element) => { entry.el = el }
}
})
}
function buildGalleryPhoto(overrides: Partial<GalleryPhoto> = {}): GalleryPhoto {
return {
id: 100,
@@ -303,4 +329,27 @@ describe('GalleryView', () => {
await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('Some photos failed to upload', 'error', undefined))
expect(onRefresh).not.toHaveBeenCalled()
})
// #1614 — a long trip's gallery is hundreds of tiles; rendering them all at once
// is what made scrolling it a chore.
it('FE-JRN-GALLERY-017: renders a first page and grows as the trigger comes into view', () => {
stubIntersectionObserver()
const many = Array.from({ length: 145 }, (_, i) => buildGalleryPhoto({ id: 100 + i, photo_id: 100 + i }))
const { container } = mountGallery(many)
const tiles = () => container.querySelectorAll('.aspect-square')
expect(tiles()).toHaveLength(60)
// The observer is stubbed in this suite, so drive the trigger the way it would.
act(() => { triggerIntersection() })
expect(tiles()).toHaveLength(120)
act(() => { triggerIntersection() })
expect(tiles()).toHaveLength(145)
})
it('FE-JRN-GALLERY-018: a gallery below one page shows no trigger at all', () => {
const { container } = mountGallery([buildGalleryPhoto(), buildGalleryPhoto({ id: 101, photo_id: 101 })])
expect(container.querySelectorAll('.aspect-square')).toHaveLength(2)
expect(container.querySelector('.animate-spin')).toBeNull()
})
})
@@ -10,6 +10,7 @@ import { getApiErrorMessage } from '../../types'
import type { JourneyEntry, GalleryPhoto, JourneyTrip } from '../../store/journeyStore'
import { photoUrl } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import { ProviderPicker } from './JourneyDetailPageProviderPicker'
import { ScrollTrigger } from './JourneyDetailPageScrollTrigger'
import EmptyState from '../shared/EmptyState'
export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick, onRefresh, onRegisterUpload }: {
@@ -54,6 +55,14 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
}, [])
const allPhotos = gallery
// A long trip's gallery is hundreds of tiles, and rendering them all at once is
// what makes scrolling it a chore (#1614). The provider picker has grown its own
// way in for the same reason; this reuses it rather than inventing a second one.
const GALLERY_PAGE = 60
const [visibleCount, setVisibleCount] = useState(GALLERY_PAGE)
const shownPhotos = allPhotos.slice(0, visibleCount)
// Deleting or adding photos must not leave the window stranded past the end.
useEffect(() => { setVisibleCount(c => Math.min(Math.max(c, GALLERY_PAGE), Math.max(allPhotos.length, GALLERY_PAGE))) }, [allPhotos.length])
const entriesWithContent = entries.filter(e => e.type !== 'skeleton' || e.title)
@@ -143,7 +152,7 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
<EmptyState scene="journey" title={t('journey.detail.noPhotos')} />
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5 pb-24 md:pb-6">
{allPhotos.map((photo, i) => (
{shownPhotos.map((photo, i) => (
<div
key={photo.id}
className="relative aspect-square rounded-lg overflow-hidden cursor-pointer group"
@@ -193,6 +202,9 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
))}
</div>
)}
{visibleCount < allPhotos.length && (
<ScrollTrigger onVisible={() => setVisibleCount(c => c + GALLERY_PAGE)} loading={false} />
)}
{/* Provider Photo Picker Modal */}
{showPicker && (
@@ -167,7 +167,7 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
{/* Header */}
{!embedded && <div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0">
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">
{provider === 'immich' ? 'Immich' : 'Synology Photos'}
{provider === 'immich' ? 'Immich' : provider === 'synologyphotos' ? 'Synology Photos' : provider}
</h2>
<button onClick={onClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
<X size={16} />
@@ -29,6 +29,10 @@ vi.mock('leaflet', () => {
getZoom: vi.fn(() => 10),
zoomIn: vi.fn(),
zoomOut: vi.fn(),
// The photo layer subscribes to zoom/pan and clusters in screen space (#1614).
on: vi.fn(),
off: vi.fn(),
latLngToContainerPoint: vi.fn(() => ({ x: 0, y: 0 })),
};
return {
default: {
@@ -38,6 +42,7 @@ vi.mock('leaflet', () => {
polyline: vi.fn(() => { const line: any = { addTo: vi.fn(() => line), bindTooltip: vi.fn(() => line) }; return line }),
divIcon: vi.fn(() => ({})),
latLngBounds: vi.fn(() => ({})),
layerGroup: vi.fn(() => ({ addLayer: vi.fn(), addTo: vi.fn(), remove: vi.fn() })),
},
map: vi.fn(() => mockMap),
tileLayer: vi.fn(() => ({ addTo: vi.fn() })),
@@ -576,4 +581,35 @@ describe('JourneyMap', () => {
expect(labels.some(l => typeof l === 'string' && l.includes('<img'))).toBe(false);
expect(labels).toContain('&lt;img src=x onerror=&quot;alert(1)&quot;&gt;');
});
// #1614 — photos placed by their own capture coordinates, collapsed by proximity.
it('FE-COMP-JOURNEYMAP-046: draws one thumbnail per cluster and counts the rest', () => {
render(
<JourneyMap
checkins={[]}
entries={[]}
photos={[
{ id: 'p1', lat: 48.85, lng: 2.29, thumbUrl: '/uploads/a.jpg' },
{ id: 'p2', lat: 48.85, lng: 2.29, thumbUrl: '/uploads/b.jpg' },
{ id: 'p3', lat: 48.85, lng: 2.29, thumbUrl: '/uploads/c.jpg' },
]}
/>,
);
// The mock projects every photo to the same point, so all three land in one
// bucket: one marker, and its badge says 3.
const html = vi.mocked(L.divIcon).mock.calls.map(c => String(c[0]?.html)).filter(h => h.includes('/uploads/'));
expect(html).toHaveLength(1);
expect(html[0]).toContain('/uploads/a.jpg');
expect(html[0]).toContain('>3<');
});
it('FE-COMP-JOURNEYMAP-047: a lone photo gets no count badge', () => {
render(
<JourneyMap checkins={[]} entries={[]} photos={[{ id: 'p1', lat: 1, lng: 2, thumbUrl: '/uploads/only.jpg' }]} />,
);
const html = vi.mocked(L.divIcon).mock.calls.map(c => String(c[0]?.html)).filter(h => h.includes('/uploads/'));
expect(html).toHaveLength(1);
expect(html[0]).not.toMatch(/>\d+</);
});
});
+97 -1
View File
@@ -14,12 +14,59 @@ export interface MapMarkerItem {
dayLabel: number
}
/**
* Grid clustering in screen space.
*
* The Journey maps have never had clustering, and the library the planner uses
* hangs off react-leaflet while this map drives Leaflet directly. Bucketing by
* rounded pixel position is a few lines, is deterministic, and is enough for the
* job: photos of one place collapse into one thumbnail with a count, and pulling
* the map apart separates them again.
*/
const PHOTO_CLUSTER_PX = 64
function clusterPhotos(
map: L.Map,
photos: MapPhoto[],
): { lat: number; lng: number; members: MapPhoto[] }[] {
const buckets = new Map<string, MapPhoto[]>()
for (const photo of photos) {
const pt = map.latLngToContainerPoint([photo.lat, photo.lng])
const key = `${Math.round(pt.x / PHOTO_CLUSTER_PX)}:${Math.round(pt.y / PHOTO_CLUSTER_PX)}`
const list = buckets.get(key)
if (list) list.push(photo)
else buckets.set(key, [photo])
}
return [...buckets.values()].map(members => ({
// Anchor on the first member rather than the centroid: the thumbnail shown is
// that photo's, so the pin should point where that picture was taken.
lat: members[0].lat,
lng: members[0].lng,
members,
}))
}
function photoMarkerHtml(thumbUrl: string, count: number): string {
const badge = count > 1
? `<span style="position:absolute;top:-6px;right:-6px;min-width:20px;height:20px;padding:0 5px;border-radius:10px;background:#fff;border:1.5px solid rgba(0,0,0,.12);box-shadow:0 1px 4px rgba(0,0,0,.22);display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:800;color:#111827;line-height:1;box-sizing:border-box;">${count}</span>`
: ''
return `<div style="position:relative;width:48px;height:48px;border-radius:12px;border:3px solid #fff;box-shadow:0 2px 8px rgba(0,0,0,.3);background-image:url('${encodeURI(thumbUrl)}');background-size:cover;background-position:center;"></div>${badge}`
}
export interface JourneyMapHandle {
highlightMarker: (id: string | null) => void
focusMarker: (id: string) => void
invalidateSize: () => void
}
/** A photo that knows where it was taken (#1614). */
export interface MapPhoto {
id: string
lat: number
lng: number
thumbUrl: string
}
interface MapEntry {
id: string
lat: number
@@ -35,6 +82,9 @@ interface Props {
ref?: Ref<JourneyMapHandle>
checkins: any[]
entries: MapEntry[]
/** Photos placed by their own capture coordinates, clustered by proximity. */
photos?: MapPhoto[]
onPhotoClick?: (photoIds: string[]) => void
trail?: { lat: number; lng: number }[]
/** Routed GPX geometries from the journey's trips (#1260). */
tracks?: JourneyTrack[]
@@ -92,7 +142,7 @@ const EMPTY_TRACKS: JourneyTrack[] = []
const TRACK_FALLBACK_COLOR = '#4f46e5'
function JourneyMap(
{ entries, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, ref }: Props,
{ entries, photos, onPhotoClick, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, ref }: Props,
) {
const stableTrail = trail || EMPTY_TRAIL
const stableTracks = tracks || EMPTY_TRACKS
@@ -104,6 +154,9 @@ function JourneyMap(
const highlightedRef = useRef<string | null>(null)
const onMarkerClickRef = useRef(onMarkerClick)
onMarkerClickRef.current = onMarkerClick
const photoLayerRef = useRef<L.LayerGroup | null>(null)
const onPhotoClickRef = useRef(onPhotoClick)
onPhotoClickRef.current = onPhotoClick
const darkRef = useRef(dark)
darkRef.current = dark
@@ -284,6 +337,49 @@ function JourneyMap(
}
}, [entries, stableTrail, stableTracks, dark, mapTileUrl, fullScreen, paddingBottom])
// Photo layer (#1614). Its own effect on purpose: photos arriving must not tear
// down and rebuild the map the way the entry effect does. Redrawn on zoom and
// pan because the clustering is done in screen space.
useEffect(() => {
const map = mapRef.current
if (!map) return
const draw = () => {
photoLayerRef.current?.remove()
photoLayerRef.current = null
if (!photos?.length) return
const group = L.layerGroup()
for (const cluster of clusterPhotos(map, photos)) {
const marker = L.marker([cluster.lat, cluster.lng], {
icon: L.divIcon({
className: '',
iconSize: [48, 48],
iconAnchor: [24, 24],
html: photoMarkerHtml(cluster.members[0].thumbUrl, cluster.members.length),
}),
// Below the entry pins: the itinerary is the point of the map, the photos
// are context.
zIndexOffset: -500,
})
marker.on('click', () => onPhotoClickRef.current?.(cluster.members.map(m => m.id)))
group.addLayer(marker)
}
group.addTo(map)
photoLayerRef.current = group
}
draw()
map.on('zoomend', draw)
map.on('moveend', draw)
return () => {
map.off('zoomend', draw)
map.off('moveend', draw)
photoLayerRef.current?.remove()
photoLayerRef.current = null
}
}, [photos, entries, stableTrail, stableTracks, dark, mapTileUrl, fullScreen, paddingBottom])
// react to activeMarkerId prop changes — runs after map is built
useEffect(() => {
if (!activeMarkerId || !mapRef.current) return
+34 -1
View File
@@ -548,7 +548,7 @@ describe('downloadTripPDF', () => {
})
})
// FE-W5PDF-001 to FE-W5PDF-018 — multi-day transport spans, the remaining
// FE-W5PDF-001 to FE-W5PDF-030 — multi-day transport spans, the remaining
// reservation subtitles, accommodation phases and cover fallbacks.
describe('downloadTripPDF remaining branches', () => {
const dA = { id: 10, day_number: 1, title: 'Day A', date: '2025-06-01' } as any
@@ -603,6 +603,20 @@ describe('downloadTripPDF remaining branches', () => {
expect(html).not.toContain('reservations.span.active')
})
it('FE-W5PDF-029: a multi-day parking is labelled drop-off/pickup and skips the middle day (#1937)', async () => {
await downloadTripPDF(spanArgs([{
id: 506, title: 'Airport Parking', type: 'parking', day_id: 10, end_day_id: 12,
reservation_time: '2025-06-01T05:30', reservation_end_time: '2025-06-03T19:00',
}]))
const html = srcdoc()
expect(html).toContain('reservations.span.dropOff: Airport Parking')
expect(html).toContain('reservations.span.pickup: Airport Parking')
expect(html).not.toContain('reservations.span.ongoing')
// Day B holds nothing else, so it prints the empty-day hint rather than the booking.
expect(html).toContain('dayplan.emptyDay')
})
it('FE-W5PDF-004: hotels, day-less and unknown-day reservations are all skipped', async () => {
await downloadTripPDF(spanArgs([
{ id: 503, title: 'Hotel Row', type: 'hotel', day_id: 10 },
@@ -691,6 +705,25 @@ describe('downloadTripPDF remaining branches', () => {
expect(html).toContain('AirX · X2 · AAA → BBB')
})
it('FE-W5PDF-030: a stopover flight prints the booking code of each segment that has one (#1943)', async () => {
await downloadTripPDF(spanArgs([{
id: 518, title: 'Layover', type: 'flight', day_id: 10, confirmation_number: 'BOOK1',
metadata: JSON.stringify({
legs: [
{ airline: 'LH', flight_number: 'LH1', from: 'FRA', to: 'BER', confirmation_number: 'ABC123' },
{ airline: 'ANA', flight_number: 'NH2', from: 'BER', to: 'HND' },
],
}),
}]))
const html = srcdoc()
expect(html).toContain('LH · LH1 · FRA → BER · ABC123')
// A segment without one keeps the line it had before, and the booking's own
// reference still prints once for the whole card.
expect(html).toContain('ANA · NH2 · BER → HND')
expect(html).toContain('Code: BOOK1')
})
it('FE-W5PDF-010: a single-leg flight with waypoints joins the whole route', async () => {
await downloadTripPDF(spanArgs([{
id: 517, title: 'Long Haul', type: 'flight', day_id: 10,
+16 -5
View File
@@ -5,6 +5,7 @@ import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship
import { accommodationsApi, mapsApi, pluginsApi } from '../../api/client'
import type { Trip, Day, Place, Category, AssignmentsMap, DayNote } from '../../types'
import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder'
import { hidesOnMiddleDay } from '../../utils/dayMerge'
import { safeHexColor } from '../../utils/safeColor'
import { renderIconMarkup } from '../../utils/iconMarkup'
import { formatMoney, formatMoneySum, splitReservationDateTime, type MoneyEntry } from '../../utils/formatters'
@@ -228,6 +229,9 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca
if (phase === 'single') return null
if (r.type === 'flight') return tr(`reservations.span.${phase === 'start' ? 'departure' : phase === 'end' ? 'arrival' : 'inTransit'}`)
if (r.type === 'car') return tr(`reservations.span.${phase === 'start' ? 'pickup' : phase === 'end' ? 'return' : 'active'}`)
// Parking reuses span.pickup for its end day: you drop the car off and collect it
// again later, so the rental car's wording carries over without a second key.
if (r.type === 'parking') return tr(`reservations.span.${phase === 'start' ? 'dropOff' : phase === 'end' ? 'pickup' : 'ongoing'}`)
return tr(`reservations.span.${phase === 'start' ? 'start' : phase === 'end' ? 'end' : 'ongoing'}`)
}
const pdfGetTransportForDay = (dayId: number) => (reservations || []).filter(r => {
@@ -251,9 +255,11 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca
const notes = (dayNotes || []).filter(n => n.day_id === day.id)
const cost = dayCost(assignments, day.id, loc, tripCur, fxRates)
// Reservations for this day (hotel rendered via accommodations block; car middle-phase rendered in sidebar header only)
// Reservations for this day (hotel rendered via accommodations block; car middle-phase rendered in sidebar header only,
// a multi-day parking's middle days nowhere at all)
const dayReservations = pdfGetTransportForDay(day.id)
.filter(r => !(r.type === 'car' && pdfGetSpanPhase(r, day.id) === 'middle'))
.filter(r => !hidesOnMiddleDay(r, day.id))
const merged = []
assigned.forEach(a => merged.push({ type: 'place', k: a.order_index ?? 0, data: a }))
@@ -279,10 +285,13 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca
if (r.type === 'flight') {
const legs = getFlightLegs(r)
if (legs.length > 1) {
// Multi-leg: one line per leg so every flight number + segment route is shown.
// Multi-leg: one line per leg so every flight number + segment route is
// shown, with the segment's own booking code where it has one (#1943).
// At the gate that is the number the airline asks for.
subtitleLines = legs.map(l =>
[l.airline, l.flight_number,
(l.from || l.to) ? [l.from, l.to].filter(Boolean).join(' → ') : '']
(l.from || l.to) ? [l.from, l.to].filter(Boolean).join(' → ') : '',
l.confirmation_number]
.filter(Boolean).join(' · '))
.filter(Boolean)
} else {
@@ -296,10 +305,12 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca
else if (r.type === 'train') {
const legs = getTrainLegs(r)
if (legs.length > 1) {
// Multi-leg: one line per leg so every train number + segment route shows.
// Multi-leg: one line per leg so every train number + segment route shows,
// with the segment's own booking code where it has one (#1943).
subtitleLines = legs.map(l =>
[l.train_number, l.platform ? `Gl. ${l.platform}` : '',
(l.from || l.to) ? [l.from, l.to].filter(Boolean).join(' → ') : '']
(l.from || l.to) ? [l.from, l.to].filter(Boolean).join(' → ') : '',
l.confirmation_number]
.filter(Boolean).join(' · '))
.filter(Boolean)
} else {
@@ -2286,6 +2286,41 @@ describe('DayPlanSidebar', () => {
expect(await screen.findByText('2 km')).toBeInTheDocument()
})
it('FE-PLANNER-DAYPLAN-203: leg distance survives a parking on its middle days (#1937)', async () => {
const user = userEvent.setup()
const { calculateRouteWithLegs } = await import('../Map/RouteCalculator')
vi.mocked(calculateRouteWithLegs as any).mockImplementation((wp: any) => Promise.resolve({
distanceText: '2 km', durationText: '10 min',
legs: Array.from({ length: Math.max(0, (wp?.length ?? 0) - 1) }, () => ({
distanceText: '2 km', durationText: '10 min', drivingText: '10 min', walkingText: '25 min',
})),
}))
// Same trap as the car rental in -106: the parking row is hidden on day 11, so the
// through-leg between the places around it must stay keyed to the place.
const parking = {
...buildReservation({ id: 323, type: 'parking', title: 'Airport Parking', day_id: 10 }),
end_day_id: 12,
day_positions: { 11: 0.5 },
}
const days = [
buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }),
buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' }),
buildDay({ id: 12, date: '2025-06-03', title: 'Day 3' }),
]
const assigns = {
'11': [
buildAssignment({ id: 1, day_id: 11, order_index: 0, place: buildPlace({ id: 1, name: 'A', lat: 48.85, lng: 2.35 }) }),
buildAssignment({ id: 2, day_id: 11, order_index: 1, place: buildPlace({ id: 2, name: 'B', lat: 48.86, lng: 2.36 }) }),
],
}
render(<DayPlanSidebar {...makeDefaultProps({
days, places: [], assignments: assigns, reservations: [parking as any],
selectedDayId: null, showRouteToolsWhenExpanded: true,
})} />)
await user.click(screen.getByRole('button', { name: 'Route' }))
expect(await screen.findByText('2 km')).toBeInTheDocument()
})
// ── Persisted / externally driven state ──────────────────────────────────
it('FE-PLANNER-DAYPLAN-110: the saved collapse state wins over expanding every day', () => {
@@ -2443,6 +2478,56 @@ describe('DayPlanSidebar', () => {
expect(screen.getAllByText('Ghost rental')).toHaveLength(1)
})
it('FE-PLANNER-DAYPLAN-200: a multi-day parking shows on the first and last day only (#1937)', () => {
const days = [
buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }),
buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' }),
buildDay({ id: 12, date: '2025-06-03', title: 'Day 3' }),
]
const parking = buildReservation({
id: 320, type: 'parking', title: 'Airport Parking', day_id: 10, end_day_id: 12,
reservation_time: '2025-06-01T05:30:00', reservation_end_time: '2025-06-03T19:00:00',
})
render(<DayPlanSidebar {...makeDefaultProps({ days, reservations: [parking] })} />)
// Drop-off on day 1, pickup on day 3, nothing in between.
expect(screen.getAllByText('Airport Parking')).toHaveLength(2)
// And no smaller tag in the day header either, unlike a rental car.
expect(within(dayHeader('Day 2')).queryByText('Airport Parking')).not.toBeInTheDocument()
})
it('FE-PLANNER-DAYPLAN-201: a multi-day parking labels its drop-off and its pickup day', () => {
const days = [
buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }),
buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' }),
buildDay({ id: 12, date: '2025-06-03', title: 'Day 3' }),
]
const parking = buildReservation({
id: 321, type: 'parking', title: 'Airport Parking', day_id: 10, end_day_id: 12,
reservation_time: '2025-06-01T05:30:00', reservation_end_time: '2025-06-03T19:00:00',
})
render(<DayPlanSidebar {...makeDefaultProps({ days, reservations: [parking] })} />)
expect(screen.getByText('Drop-off')).toBeInTheDocument()
expect(screen.getByText('Pickup')).toBeInTheDocument()
// The generic span wording never appears for a parking.
expect(screen.queryByText('Ongoing')).not.toBeInTheDocument()
})
it('FE-PLANNER-DAYPLAN-202: a day left empty by a spanning parking shows the empty-day hint', () => {
const days = [
buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }),
buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' }),
buildDay({ id: 12, date: '2025-06-03', title: 'Day 3' }),
]
const parking = buildReservation({
id: 322, type: 'parking', title: 'Airport Parking', day_id: 10, end_day_id: 12,
reservation_time: '2025-06-01T05:30:00', reservation_end_time: '2025-06-03T19:00:00',
})
render(<DayPlanSidebar {...makeDefaultProps({ days, reservations: [parking] })} />)
// Days 1 and 3 carry the booking, so only day 2 is empty, and it says so instead
// of leaving a blank gap where the hidden row used to be.
expect(screen.getAllByText('No places planned for this day')).toHaveLength(1)
})
// ── Route legs, hotel bookends, travel modes ─────────────────────────────
it('FE-PLANNER-DAYPLAN-120: a routed day draws the hotel bookends around its stops', async () => {
@@ -28,7 +28,7 @@ import { placeToSaveTarget } from '../Collections/saveTarget'
import { useTranslation } from '../../i18n'
import { isDayInAccommodationRange, getAccommodationAnchors, getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg } from '../../utils/dayOrder'
import {
TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, getDisplayTimeForDay, getTransportRouteEndpoints,
TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, hidesOnMiddleDay, getDisplayTimeForDay, getTransportRouteEndpoints,
getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems,
type MergedItem,
} from '../../utils/dayMerge'
@@ -362,6 +362,9 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
if (phase === 'single') return null
if (r.type === 'flight') return t(`reservations.span.${phase === 'start' ? 'departure' : phase === 'end' ? 'arrival' : 'inTransit'}`)
if (r.type === 'car') return t(`reservations.span.${phase === 'start' ? 'pickup' : phase === 'end' ? 'return' : 'active'}`)
// Parking reuses span.pickup for its end day on purpose: you drop the car off and
// later pick it up again, so the rental car's wording fits without a second key.
if (r.type === 'parking') return t(`reservations.span.${phase === 'start' ? 'dropOff' : phase === 'end' ? 'pickup' : 'ongoing'}`)
return t(`reservations.span.${phase === 'start' ? 'start' : phase === 'end' ? 'end' : 'ongoing'}`)
}
@@ -513,11 +516,12 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
cur = []
curHasPlace = false
if (to) cur.push({ id: r.id, lat: to.lat, lng: to.lng, isPlace: false })
} else if (cur.length > 0 && !(r.type === 'car' && getSpanPhase(r, dayId) === 'middle')) {
} else if (cur.length > 0 && !(r.type === 'car' && getSpanPhase(r, dayId) === 'middle') && !hidesOnMiddleDay(r, dayId)) {
// No location: ignore for routing, but attribute the through-leg to the
// booking so its distance/duration shows under it (purely cosmetic).
// Not for a car rental's middle days though — that row isn't rendered
// in the timeline, so re-keying would drop the leg entirely (#1504).
// Not for a car rental's middle days or a multi-day parking's though:
// those rows aren't rendered in the timeline, so re-keying would drop
// the leg entirely (#1504).
cur[cur.length - 1] = { ...cur[cur.length - 1], id: r.id }
}
}
@@ -1477,6 +1481,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
const merged = mergedItemsMap[day.id] || []
const dayNoteUi = noteUi[day.id]
const placeItems = merged.filter(i => i.type === 'place')
// A row hidden on a middle day still sits in `merged` (the route builder above
// has to see it), so the empty-day hint counts what actually renders. Without
// that, a day whose only entry is a spanning parking would show a blank gap.
const visibleCount = merged.filter(i => !(i.type === 'transport' && hidesOnMiddleDay(i.data, day.id))).length
const dayTint = dayTints[day.id]
// Resolved once per day: the header owns a background that its hover
// handlers reassign imperatively, so both the base and the hover value have
@@ -1735,7 +1743,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
) : connector
})()}
{daySchedule.byPosition[day.id]?.start.map(si => <PluginDayScheduleRow key={`${si.pluginId}:${si.id}`} item={si} />)}
{merged.length === 0 && !dayNoteUi ? (
{visibleCount === 0 && !dayNoteUi ? (
<div
onDragOver={e => { e.preventDefault(); if (dragOverDayId !== day.id) setDragOverDayId(day.id) }}
onDrop={e => handleDropOnDay(e, day.id)}
@@ -2121,6 +2129,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
// Car "active" (middle) days are shown in the day header, skip here
if (res.type === 'car' && spanPhase === 'middle') return null
// A multi-day parking says nothing on the days in between and gets
// no header badge either, only drop-off and pickup (#1937)
if (hidesOnMiddleDay(res, day.id)) return null
const TransportIcon = RES_ICONS[res.type] || Ticket
const color = '#3b82f6'
@@ -1,4 +1,4 @@
// FE-PLANNER-DPTRANSPORT-001 to FE-PLANNER-DPTRANSPORT-018
// FE-PLANNER-DPTRANSPORT-001 to FE-PLANNER-DPTRANSPORT-019
import { render, screen, fireEvent } from '../../../tests/helpers/render'
import userEvent from '@testing-library/user-event'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
@@ -235,6 +235,28 @@ describe('DayPlanSidebarTransportDetailModal', () => {
expect(setTransportDetail).toHaveBeenCalledWith(null)
})
it('FE-PLANNER-DPTRANSPORT-019: a segment with its own booking code gets its own blurred field (#1943)', () => {
seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: true } })
const res = buildReservation({
id: 42, type: 'flight', title: 'BER → CDG → JFK', status: 'confirmed',
reservation_time: '2025-06-15T08:30:00',
confirmation_number: 'XY7Z9Q',
metadata: JSON.stringify({
legs: [
{ from: 'BER', to: 'CDG', confirmation_number: 'ABC123' },
{ from: 'CDG', to: 'JFK' },
],
}),
} as Partial<Reservation>)
render(<DayPlanSidebarTransportDetailModal {...makeProps({ transportDetail: res })} />)
expect(screen.getByText('BER → CDG')).toBeInTheDocument()
expect(screen.getByText('ABC123')).toHaveStyle({ filter: 'blur(5px)' })
// The booking's own reference keeps its own field, a code-less segment adds none.
expect(screen.getByText('XY7Z9Q')).toBeInTheDocument()
expect(screen.queryByText('CDG → JFK')).not.toBeInTheDocument()
})
it('FE-PLANNER-DPTRANSPORT-018: clicking the backdrop closes, clicking the card does not', async () => {
const user = userEvent.setup()
const setTransportDetail = vi.fn()
@@ -57,6 +57,16 @@ export function DayPlanSidebarTransportDetailModal({
if (meta.seat) detailFields.push({ label: t('reservations.meta.seat'), value: meta.seat })
}
if (res.confirmation_number) detailFields.push({ label: t('reservations.confirmationCode'), value: res.confirmation_number, sensitive: true })
// A stopover booking can carry its own reference per segment (#1943); the
// flat fields above only ever describe the first leg. Marked sensitive so
// the blur setting covers them like the booking's own code.
if (Array.isArray(meta.legs) && meta.legs.length > 1) {
for (const leg of meta.legs) {
if (!leg?.confirmation_number) continue
const segment = [leg.from, leg.to].filter(Boolean).join(' → ')
detailFields.push({ label: segment || t('reservations.confirmationCode'), value: leg.confirmation_number, sensitive: true })
}
}
if (res.location) detailFields.push({ label: t('reservations.locationAddress'), value: res.location })
return (
@@ -1,4 +1,4 @@
// FE-COMP-RES-001 to FE-COMP-RES-040, FE-PLANNER-RESP-016 to FE-PLANNER-RESP-072
// FE-COMP-RES-001 to FE-COMP-RES-040, FE-PLANNER-RESP-016 to FE-PLANNER-RESP-078
import { render, screen, fireEvent, waitFor, act } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
@@ -239,6 +239,39 @@ describe('ReservationsPanel', () => {
expect(codeEl.style.filter).toBe('none');
});
const layoverFlight = () => buildReservation({
id: 7, type: 'flight', status: 'confirmed', confirmation_number: 'BOOK1',
metadata: JSON.stringify({
departure_airport: 'FRA', arrival_airport: 'HND',
legs: [
{ from: 'FRA', to: 'BER', confirmation_number: 'ABC123' },
{ from: 'BER', to: 'HND' },
],
}),
});
it('FE-PLANNER-RESP-077: a segment with its own booking code shows it under its route (#1943)', () => {
render(<ReservationsPanel {...defaultProps} reservations={[layoverFlight()]} />);
// The booking's own reference keeps its own cell.
expect(screen.getByText('BOOK1')).toBeInTheDocument();
expect(screen.getByText('FRA → BER')).toBeInTheDocument();
expect(screen.getByText('ABC123')).toBeInTheDocument();
// A segment without its own code adds no cell.
expect(screen.queryByText('BER → HND')).not.toBeInTheDocument();
});
it('FE-PLANNER-RESP-078: a segment code obeys blur_booking_codes and reveals with the card', async () => {
const user = userEvent.setup();
seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: true, temperature_unit: 'celsius', language: 'en', dark_mode: false, default_currency: 'USD', map_tile_url: '', show_place_description: false } });
render(<ReservationsPanel {...defaultProps} reservations={[layoverFlight()]} />);
const legCode = screen.getByText('ABC123');
expect(legCode.style.filter).toContain('blur');
// One reveal per card, not per segment: the booking's own cell uncovers too.
await user.hover(legCode);
expect(legCode.style.filter).toBe('none');
expect(screen.getByText('BOOK1').style.filter).toBe('none');
});
it('FE-PLANNER-RESP-024: reservation notes are shown', () => {
const res = buildReservation({ notes: 'Window seat requested', status: 'pending' });
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
@@ -23,6 +23,7 @@ import { usePluginViewContributions, PluginCardFooter } from '../Plugins/PluginC
import { usePluginStore, type ActivePlugin } from '../../store/pluginStore'
import PluginFrame from '../Plugins/PluginFrame'
import { splitReservationDateTime, formatTime } from '../../utils/formatters'
import { getFlightLegs, getTrainLegs } from '../../utils/flightLegs'
import EmptyState from '../shared/EmptyState'
import { TravelerAvatarRow, TravelerFilterAvatars } from './TravelerPicker'
import type { TripMember } from '../Budget/BudgetPanelMemberChips'
@@ -329,6 +330,41 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
)
})()}
{/* Per-segment booking codes (#1943). A stopover booking can carry its own
reference per leg, and the cell above only ever shows the booking's own.
The reveal is shared with that cell on purpose: uncovering the codes is
one gesture per card, not one per segment. */}
{(() => {
if (r.type !== 'flight' && r.type !== 'train') return null
const legs = r.type === 'flight' ? getFlightLegs(r) : getTrainLegs(r)
if (legs.length < 2) return null
const coded = legs.filter(l => l.confirmation_number)
if (coded.length === 0) return null
return (
<div style={{ display: 'grid', gap: 10, gridTemplateColumns: coded.length > 1 ? `repeat(${Math.min(coded.length, 3)}, 1fr)` : '1fr' }}>
{coded.map((l, i) => (
<div key={i}>
<div className={fieldLabelClass}>{[l.from, l.to].filter(Boolean).join(' → ') || t('reservations.confirmationCode')}</div>
<div
onMouseEnter={() => blurCodes && setCodeRevealed(true)}
onMouseLeave={() => blurCodes && setCodeRevealed(false)}
onClick={() => blurCodes && setCodeRevealed(v => !v)}
className={`${fieldValueClass} text-center`}
style={{
fontFamily: '"SF Mono", "JetBrains Mono", Menlo, monospace', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))',
filter: blurCodes && !codeRevealed ? 'blur(5px)' : 'none',
cursor: blurCodes ? 'pointer' : 'default',
transition: 'filter 0.2s',
}}
>
{l.confirmation_number}
</div>
</div>
))}
</div>
)
})()}
{/* Type-specific metadata */}
{(() => {
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
@@ -1,4 +1,4 @@
// FE-PLANNER-TRANSMODAL-001 to FE-PLANNER-TRANSMODAL-053
// FE-PLANNER-TRANSMODAL-001 to FE-PLANNER-TRANSMODAL-061
import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
@@ -650,6 +650,105 @@ describe('TransportModal', () => {
expect(payload.metadata.legs).toBeUndefined();
});
// ── Per-segment booking references (#1943) ─────────────────────────────────
function multiLegFlight(): Reservation {
const res = buildReservation({ id: 31, title: 'BRU → HEL → JFK', type: 'flight' });
return Object.assign(res, {
day_id: 10,
end_day_id: 12,
reservation_time: '2026-08-01T08:00',
reservation_end_time: '2026-08-03T15:00',
confirmation_number: 'BOOK1',
metadata: {
airline: 'Brussels Airlines', flight_number: 'SN 1234', departure_airport: 'BRU', arrival_airport: 'JFK',
legs: [
{ from: 'BRU', to: 'HEL', airline: 'Brussels Airlines', flight_number: 'SN 1234', confirmation_number: 'ABC123', dep_day_id: 10, dep_time: '08:00', arr_day_id: 10, arr_time: '12:30' },
{ from: 'HEL', to: 'JFK', airline: 'Finnair', flight_number: 'AY 15', confirmation_number: 'XYZ789', dep_day_id: 11, dep_time: '14:00', arr_day_id: 12, arr_time: '15:00' },
],
},
endpoints: [
{ id: 1, reservation_id: 31, role: 'from', sequence: 0, name: 'Brussels (BRU)', code: 'BRU', lat: 50.9, lng: 4.48, timezone: 'Europe/Brussels', local_date: '2026-08-01', local_time: '08:00' },
{ id: 2, reservation_id: 31, role: 'stop', sequence: 1, name: 'Helsinki (HEL)', code: 'HEL', lat: 60.31, lng: 24.96, timezone: 'Europe/Helsinki', local_date: '2026-08-02', local_time: '14:00' },
{ id: 3, reservation_id: 31, role: 'to', sequence: 2, name: 'New York (JFK)', code: 'JFK', lat: 40.64, lng: -73.78, timezone: 'America/New_York', local_date: '2026-08-03', local_time: '15:00' },
],
}) as unknown as Reservation;
}
it('FE-PLANNER-TRANSMODAL-058: each segment of a stopover flight saves its own booking code', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} onSave={onSave} />);
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'BRU → HEL → JFK');
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
const airports = screen.getAllByTestId('airport-select');
fireEvent.change(airports[0], { target: { value: 'BRU' } });
fireEvent.change(airports[1], { target: { value: 'HEL' } });
fireEvent.change(airports[2], { target: { value: 'JFK' } });
// Two departing waypoints get a code field, the booking's own field stays last.
const codes = screen.getAllByPlaceholderText('e.g. ABC12345');
expect(codes).toHaveLength(3);
fireEvent.change(codes[0], { target: { value: 'ABC123' } });
fireEvent.change(codes[1], { target: { value: 'XYZ789' } });
fireEvent.change(codes[2], { target: { value: 'BOOK1' } });
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
const payload = onSave.mock.calls[0][0];
expect(payload.metadata.legs.map((l: { confirmation_number?: string }) => l.confirmation_number)).toEqual(['ABC123', 'XYZ789']);
// The booking's own reference stays where it was and is never mirrored.
expect(payload.confirmation_number).toBe('BOOK1');
expect(payload.metadata.confirmation_number).toBeUndefined();
});
it('FE-PLANNER-TRANSMODAL-059: no per-segment field while the route writes no legs', async () => {
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} />);
fireEvent.change(screen.getAllByTestId('airport-select')[0], { target: { value: 'BRU' } });
fireEvent.change(screen.getAllByTestId('airport-select')[1], { target: { value: 'JFK' } });
// Only the booking's own field: a direct flight stores no legs.
expect(screen.getAllByPlaceholderText('e.g. ABC12345')).toHaveLength(1);
// A third waypoint whose airport is never picked drops out of the route, so
// the legs are still not written and the field must stay away. Otherwise the
// typed code would vanish on save.
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
expect(screen.getAllByTestId('airport-select')).toHaveLength(3);
expect(screen.getAllByPlaceholderText('e.g. ABC12345')).toHaveLength(1);
});
it('FE-PLANNER-TRANSMODAL-060: editing a stopover flight pre-fills every segment code and a re-save keeps them', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<TransportModal {...defaultProps} days={routeDays} reservation={multiLegFlight()} onSave={onSave} />);
const codes = screen.getAllByPlaceholderText('e.g. ABC12345') as HTMLInputElement[];
expect(codes.map(i => i.value)).toEqual(['ABC123', 'XYZ789', 'BOOK1']);
await userEvent.click(screen.getByRole('button', { name: /^Update$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
const payload = onSave.mock.calls[0][0];
expect(payload.metadata.legs.map((l: { confirmation_number?: string }) => l.confirmation_number)).toEqual(['ABC123', 'XYZ789']);
expect(payload.confirmation_number).toBe('BOOK1');
});
it('FE-PLANNER-TRANSMODAL-061: dropping the stop drops the segment codes with the legs, never the booking code', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<TransportModal {...defaultProps} days={routeDays} reservation={multiLegFlight()} onSave={onSave} />);
// Removing the only stop collapses the route to a direct flight, so there are
// no legs left to hold a per-segment code. Documented behaviour, not a bug:
// the field disappears with them and the booking keeps its own reference.
await userEvent.click(screen.getByRole('button', { name: /^Delete$/i }));
expect(screen.getAllByPlaceholderText('e.g. ABC12345')).toHaveLength(1);
await userEvent.click(screen.getByRole('button', { name: /^Update$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
const payload = onSave.mock.calls[0][0];
expect(payload.metadata.legs).toBeUndefined();
expect(payload.confirmation_number).toBe('BOOK1');
});
it('FE-PLANNER-TRANSMODAL-034: the departure-date picker moves the whole flight to another day', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} onSave={onSave} />);
@@ -85,9 +85,12 @@ interface WaypointForm {
airline: string
flight_number: string
seat: string
// Booking reference of the leg leaving this waypoint: airlines often issue one
// per flight rather than one per booking (#1943). Empty means the booking's own.
confirmation_number: string
}
function emptyWaypoint(dayId: string | number = ''): WaypointForm {
return { airport: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', airline: '', flight_number: '', seat: '' }
return { airport: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', airline: '', flight_number: '', seat: '', confirmation_number: '' }
}
// ── Multi-leg train stations ───────────────────────────────────────────────
@@ -103,9 +106,10 @@ interface StationWaypointForm {
train_number: string
platform: string
seat: string
confirmation_number: string
}
function emptyStationWaypoint(dayId: string | number = ''): StationWaypointForm {
return { location: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', train_number: '', platform: '', seat: '' }
return { location: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', train_number: '', platform: '', seat: '', confirmation_number: '' }
}
const TYPE_OPTIONS = [
@@ -264,6 +268,10 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
airline: legOut?.airline ?? (isFirst ? (meta.airline ?? '') : ''),
flight_number: legOut?.flight_number ?? (isFirst ? (meta.flight_number ?? '') : ''),
seat: legOut?.seat ?? (isFirst ? (meta.seat ?? '') : ''),
// No fallback to src.confirmation_number: that one belongs to the
// whole booking and stays in the form's own field, otherwise a
// plain re-save would copy it onto the first leg.
confirmation_number: legOut?.confirmation_number ?? '',
}
})
} else {
@@ -305,6 +313,8 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
train_number: legOut?.train_number ?? (isFirst ? (meta.train_number ?? '') : ''),
platform: legOut?.platform ?? (isFirst ? (meta.platform ?? '') : ''),
seat: legOut?.seat ?? (isFirst ? (meta.seat ?? '') : ''),
// See the flight branch: the booking's own reference stays out of the legs.
confirmation_number: legOut?.confirmation_number ?? '',
}
})
} else {
@@ -399,6 +409,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
...(w.airline ? { airline: w.airline } : {}),
...(w.flight_number ? { flight_number: w.flight_number } : {}),
...(w.seat ? { seat: w.seat } : {}),
...(w.confirmation_number ? { confirmation_number: w.confirmation_number } : {}),
dep_day_id: w.depDayId ? Number(w.depDayId) : null,
dep_time: w.depTime || null,
arr_day_id: next.arrDayId ? Number(next.arrDayId) : null,
@@ -426,6 +437,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
...(w.train_number ? { train_number: w.train_number } : {}),
...(w.platform ? { platform: w.platform } : {}),
...(w.seat ? { seat: w.seat } : {}),
...(w.confirmation_number ? { confirmation_number: w.confirmation_number } : {}),
dep_day_id: w.depDayId ? Number(w.depDayId) : null,
dep_time: w.depTime || null,
arr_day_id: next.arrDayId ? Number(next.arrDayId) : null,
@@ -630,6 +642,12 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
}),
]
// The per-leg booking code is only offered where handleSubmit actually writes
// metadata.legs: the SAME condition, over the waypoints that become endpoints,
// not over the raw rows. Anywhere else the value would vanish on save (#1943).
const writesFlightLegs = waypoints.filter(w => w.airport).length > 2
const writesTrainLegs = trainWaypoints.filter(w => w.location).length > 2
return (
<Modal
isOpen={isOpen}
@@ -792,7 +810,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
</div>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className={`grid grid-cols-1 ${writesFlightLegs ? 'sm:grid-cols-4' : 'sm:grid-cols-3'} gap-3`}>
<div>
<label className={labelClass}>{t('reservations.meta.airline')}</label>
<input type="text" value={wp.airline} onChange={e => updateWp({ airline: e.target.value })} placeholder="Lufthansa" className={inputClass} />
@@ -805,6 +823,13 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
<label className={labelClass}>{t('reservations.meta.seat')}</label>
<input type="text" value={wp.seat} onChange={e => updateWp({ seat: e.target.value })} placeholder="12A" className={inputClass} />
</div>
{writesFlightLegs && (
<div>
<label className={labelClass}>{t('reservations.confirmationCode')}</label>
<input type="text" value={wp.confirmation_number} onChange={e => updateWp({ confirmation_number: e.target.value })}
placeholder={t('reservations.confirmationPlaceholder')} className={inputClass} />
</div>
)}
</div>
</>
)}
@@ -866,7 +891,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
<CustomTimePicker value={wp.depTime} onChange={v => updateWp({ depTime: v })} />
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className={`grid grid-cols-1 ${writesTrainLegs ? 'sm:grid-cols-4' : 'sm:grid-cols-3'} gap-3`}>
<div>
<label className={labelClass}>{t('reservations.meta.trainNumber')}</label>
<input type="text" value={wp.train_number} onChange={e => updateWp({ train_number: e.target.value })} placeholder="ICE 123" className={inputClass} />
@@ -879,6 +904,13 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
<label className={labelClass}>{t('reservations.meta.seat')}</label>
<input type="text" value={wp.seat} onChange={e => updateWp({ seat: e.target.value })} placeholder="42A" className={inputClass} />
</div>
{writesTrainLegs && (
<div>
<label className={labelClass}>{t('reservations.confirmationCode')}</label>
<input type="text" value={wp.confirmation_number} onChange={e => updateWp({ confirmation_number: e.target.value })}
placeholder={t('reservations.confirmationPlaceholder')} className={inputClass} />
</div>
)}
</div>
</>
)}
@@ -585,6 +585,20 @@ export default function MJourneyEntrySheet({
</div>
)}
{/* Quick capture keeps a note field: the ask was "photos *or* quick notes"
unterwegs, and a moment without a picture is still worth catching. Two
rows rather than three — it is a note, not the story, which is written
later in the full sheet. */}
{captureOnly && !readOnly && (
<textarea
rows={2}
value={story}
onChange={e => setStory(e.target.value)}
placeholder={t('journey.editor.writeStory')}
className={`mt-[10px] w-full resize-none px-[14px] py-3 font-geist text-[0.8125rem] leading-[1.5] text-m-ink outline-none placeholder:text-m-faint ${fieldShell} rounded-[14px]`}
/>
)}
{!captureOnly && <>
{readOnly ? (
story && (
+20 -2
View File
@@ -225,6 +225,15 @@ export default function MTripShell({
if (trTab !== 'plan') planner.handleTabChange('plan')
}
// Map focus on the selected day. Drives the same declutter the desktop sidebar's
// collapse chevron drives (#216) rather than adding a second filter — the phone
// lost that affordance when this shell replaced DayPlanSidebar, so a day's pins
// could not be told apart from the rest of the trip at all (#1962). Unplanned
// places stay visible either way, which is what the collapse path has always done.
const focusMapOnDay = (dayId: number | null) => {
planner.setExpandedDayIds(dayId == null ? null : new Set([dayId]))
}
const toggleView = () => {
const next = view === 'plan' ? 'map' : 'plan'
setView(next)
@@ -234,6 +243,11 @@ export default function MTripShell({
if (next === 'map') {
planner.setRouteShown(true)
if (planner.selectedDayId != null) planner.handleSelectDay(planner.selectedDayId, false)
focusMapOnDay(planner.selectedDayId)
} else {
// Leaving the map: nothing is filtered while the timeline is up, so the next
// visit starts from the whole trip unless a day is picked again.
focusMapOnDay(null)
}
}
@@ -267,8 +281,12 @@ export default function MTripShell({
if (dayId === planner.selectedDayId) openSheet('day', { dayId })
else {
planner.handleSelectDay(dayId, view !== 'map')
// In map mode a day tap fits to that day's places AND draws its route.
if (view === 'map') planner.setRouteShown(true)
// In map mode a day tap fits to that day's places, draws its route and drops
// the other days' pins, so the day it framed is the day it shows.
if (view === 'map') {
planner.setRouteShown(true)
focusMapOnDay(dayId)
}
}
}
@@ -1,6 +1,6 @@
import { Cloud, CloudDrizzle, CloudLightning, CloudRain, CloudSnow, Sun, Wind } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { getDisplayTimeForDay, getSpanPhase, parseTimeToMinutes } from '../../../../utils/dayMerge'
import { getDisplayTimeForDay, getSpanPhase, hidesOnMiddleDay, parseTimeToMinutes } from '../../../../utils/dayMerge'
import { getDayBookendHotels, isDayInAccommodationRange } from '../../../../utils/dayOrder'
import type { MergedItem } from '../../../../utils/dayMerge'
import type { TransitLegDisplay } from '../../../../components/Planner/transitDisplay'
@@ -107,8 +107,10 @@ export function buildPlanRows(opts: {
base.push({ key: `note-${note.id}`, kind: 'note', item, note })
} else {
const res = item.data as TransportEntry
// A car rental's middle days live in the day header, not the timeline.
// A car rental's middle days live in the day header, not the timeline; a
// multi-day parking drops out of both on those days (#1937).
if (res.type === 'car' && getSpanPhase(res, dayId) === 'middle') continue
if (hidesOnMiddleDay(res, dayId)) continue
const transit = getTransitMeta(res)
const key = `tr-${res.id}${res.__leg ? `-leg${res.__leg.index}` : ''}`
if (transit) base.push({ key, kind: 'transit', item, res, transit })
@@ -74,9 +74,12 @@ interface WaypointForm {
airline: string
flight_number: string
seat: string
// Booking reference of the leg leaving this waypoint (#1943); empty means the
// booking's own reference covers it.
confirmation_number: string
}
function emptyWaypoint(dayId: string | number = ''): WaypointForm {
return { airport: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', airline: '', flight_number: '', seat: '' }
return { airport: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', airline: '', flight_number: '', seat: '', confirmation_number: '' }
}
// A train mirrors the flight route model, but its waypoints are STATIONS
@@ -90,9 +93,10 @@ interface StationWaypointForm {
train_number: string
platform: string
seat: string
confirmation_number: string
}
function emptyStationWaypoint(dayId: string | number = ''): StationWaypointForm {
return { location: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', train_number: '', platform: '', seat: '' }
return { location: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', train_number: '', platform: '', seat: '', confirmation_number: '' }
}
// Traveler picker row — same surface as the cost-split rows (bg on --m-ic).
@@ -220,6 +224,9 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
airline: legOut?.airline ?? (isFirst ? (meta.airline ?? '') : ''),
flight_number: legOut?.flight_number ?? (isFirst ? (meta.flight_number ?? '') : ''),
seat: legOut?.seat ?? (isFirst ? (meta.seat ?? '') : ''),
// The booking's own reference stays in the form's field, never on a
// leg, so a plain re-save cannot duplicate it onto the first segment.
confirmation_number: legOut?.confirmation_number ?? '',
}
})
} else {
@@ -258,6 +265,8 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
train_number: legOut?.train_number ?? (isFirst ? (meta.train_number ?? '') : ''),
platform: legOut?.platform ?? (isFirst ? (meta.platform ?? '') : ''),
seat: legOut?.seat ?? (isFirst ? (meta.seat ?? '') : ''),
// See the flight branch: the booking's own reference stays out of the legs.
confirmation_number: legOut?.confirmation_number ?? '',
}
})
} else {
@@ -326,6 +335,12 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
}),
]
// Same condition handleSubmit uses to write metadata.legs, over the waypoints
// that actually become endpoints: below it there are no legs to hold a
// per-segment booking code and the value would be dropped on save (#1943).
const writesFlightLegs = waypoints.filter(w => w.airport).length > 2
const writesTrainLegs = trainWaypoints.filter(w => w.location).length > 2
const handleClose = () => {
if (importReviewActive) { advanceImportReview(); return }
setShowTransportModal(false)
@@ -396,6 +411,7 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
...(w.airline ? { airline: w.airline } : {}),
...(w.flight_number ? { flight_number: w.flight_number } : {}),
...(w.seat ? { seat: w.seat } : {}),
...(w.confirmation_number ? { confirmation_number: w.confirmation_number } : {}),
dep_day_id: w.depDayId ? Number(w.depDayId) : null,
dep_time: w.depTime || null,
arr_day_id: next.arrDayId ? Number(next.arrDayId) : null,
@@ -418,6 +434,7 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
...(w.train_number ? { train_number: w.train_number } : {}),
...(w.platform ? { platform: w.platform } : {}),
...(w.seat ? { seat: w.seat } : {}),
...(w.confirmation_number ? { confirmation_number: w.confirmation_number } : {}),
dep_day_id: w.depDayId ? Number(w.depDayId) : null,
dep_time: w.depTime || null,
arr_day_id: next.arrDayId ? Number(next.arrDayId) : null,
@@ -739,6 +756,18 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
<input type="text" value={wp.seat} onChange={e => updateWp({ seat: e.target.value })} placeholder="12A" className={FIELD_CLS} />
</div>
</div>
{writesFlightLegs && (
<div className="mt-2">
<Eyebrow className="mb-[5px] uppercase">{t('reservations.confirmationCode')}</Eyebrow>
<input
type="text"
value={wp.confirmation_number}
onChange={e => updateWp({ confirmation_number: e.target.value })}
placeholder={t('reservations.confirmationPlaceholder')}
className={FIELD_CLS}
/>
</div>
)}
</>
)}
</div>
@@ -817,6 +846,18 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
<input type="text" value={wp.seat} onChange={e => updateWp({ seat: e.target.value })} placeholder="42A" className={FIELD_CLS} />
</div>
</div>
{writesTrainLegs && (
<div className="mt-2">
<Eyebrow className="mb-[5px] uppercase">{t('reservations.confirmationCode')}</Eyebrow>
<input
type="text"
value={wp.confirmation_number}
onChange={e => updateWp({ confirmation_number: e.target.value })}
placeholder={t('reservations.confirmationPlaceholder')}
className={FIELD_CLS}
/>
</div>
)}
</>
)}
</div>
@@ -6,6 +6,7 @@ import { useTranslation } from '../../../../i18n'
import { useSettingsStore } from '../../../../store/settingsStore'
import { RES_ICONS } from '../../../../components/Planner/DayPlanSidebar.constants'
import { splitReservationDateTime } from '../../../../utils/formatters'
import { getFlightLegs, getTrainLegs } from '../../../../utils/flightLegs'
import { openFile } from '../../../../utils/fileDownload'
import type { Reservation } from '../../../../types'
import { Eyebrow, INNER_CLS, StatBox, TileHeader, displayTime } from './MTripSheetUi'
@@ -95,6 +96,11 @@ export default function MTransportSheet({ planner, shell }: MTripSheetsProps) {
const platform = meta.platform
const transitLegs: TransitLeg[] = Array.isArray(meta.transit?.legs) ? meta.transit.legs : []
// Per-segment booking codes (#1943), only on a real stopover booking: the
// single-leg fallback would just echo the booking's own code shown below.
const routeLegs = res.type === 'flight' ? getFlightLegs(res) : res.type === 'train' ? getTrainLegs(res) : []
const legCodes = routeLegs.length > 1 ? routeLegs.filter(l => l.confirmation_number) : []
const resFiles = (planner.files || []).filter(f =>
!f.deleted_at && (f.reservation_id === res.id || (f.linked_reservation_ids || []).includes(res.id)),
)
@@ -221,6 +227,25 @@ export default function MTransportSheet({ planner, shell }: MTripSheetsProps) {
)}
</div>
{/* ── Per-segment booking codes ── */}
{legCodes.length > 0 && (
<div className={`mt-3 flex flex-col gap-[7px] rounded-[13px] px-3 py-[9px] ${INNER_CLS}`}>
{legCodes.map((leg, i) => (
<div key={i} className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate text-[0.71875rem] font-medium">
{[leg.from, leg.to].filter(Boolean).join(' → ') || t('reservations.confirmationCode')}
</span>
<span
onClick={() => { if (blurCodes) setCodeRevealed(v => !v) }}
className={`flex-none font-geist text-[0.71875rem] tabular-nums text-m-muted ${codeBlurred ? 'blur-[4px] select-none' : ''}`}
>
#{leg.confirmation_number}
</span>
</div>
))}
</div>
)}
{/* ── Notes ── */}
{res.notes && (
<>
@@ -536,4 +536,48 @@ describe('JourneyPublicPage', () => {
expect(images.length).toBeGreaterThan(0);
});
});
// #1962 — the marker number used to be the stop's position within its day while
// the day heading showed the day number, so the same digit appeared repeatedly in
// different colours with no key. Both now count stops across the whole journey.
it('FE-PAGE-PUBLICJOURNEY-021: numbers each stop once, and the timeline carries the same number', async () => {
setupSuccess();
render(<JourneyPublicPage />);
await waitFor(() => expect(screen.getByText('Shibuya Crossing')).toBeInTheDocument());
// Two geocoded entries on two different days: stops 1 and 2, not 1 and 1.
const shibuya = document.querySelector('[data-entry-id="10"]') as HTMLElement;
const sensoji = document.querySelector('[data-entry-id="11"]') as HTMLElement;
expect(shibuya).toBeTruthy();
expect(sensoji).toBeTruthy();
const badge = (el: HTMLElement) =>
Array.from(el.querySelectorAll('span')).find(s => /^\d+$/.test(s.textContent || ''))?.textContent;
expect(badge(shibuya)).toBe('1');
expect(badge(sensoji)).toBe('2');
});
// #1614 — a journey shared mid-trip reads like a blog. Only the reading order
// flips; the stop numbers stay chronological so the map still matches.
it('FE-PAGE-PUBLICJOURNEY-022: the reader can flip the order without renumbering the trip', async () => {
setupSuccess();
render(<JourneyPublicPage />);
await waitFor(() => expect(screen.getByText('Shibuya Crossing')).toBeInTheDocument());
const order = () =>
Array.from(document.querySelectorAll('[data-entry-id]')).map(el => el.getAttribute('data-entry-id'));
expect(order()).toEqual(['10', '11']);
fireEvent.click(screen.getByRole('button', { name: /first/i }));
await waitFor(() => expect(order()).toEqual(['11', '10']));
// Numbering is unchanged: entry 10 is still stop 1 even though it is shown last.
const shibuya = document.querySelector('[data-entry-id="10"]') as HTMLElement;
const badge = Array.from(shibuya.querySelectorAll('span')).find(s => /^\d+$/.test(s.textContent || ''));
expect(badge?.textContent).toBe('1');
});
});
+54 -2
View File
@@ -5,6 +5,7 @@ import {
Cloud,
CloudLightning,
CloudRain,
ArrowUpDown,
CloudSun,
Frown,
Grid,
@@ -110,8 +111,13 @@ export default function JourneyPublicPage() {
timelineEntries,
groupedEntries,
sortedDates,
displayDates,
newestFirst,
setNewestFirst,
sidebarMapItems,
allPhotos,
stopNumberById,
mapPhotos,
desktopTwoColumn,
} = useJourneyPublic();
@@ -143,15 +149,44 @@ export default function JourneyPublicPage() {
perms.share_map && { id: 'map' as const, icon: MapPin, label: t('journey.share.map') },
].filter(Boolean) as { id: 'timeline' | 'gallery' | 'map'; icon: any; label: string }[];
// The hook cannot build these: the thumbnail URL needs the share token, which is
// the credential for the byte proxy.
const photoLayer = token
? mapPhotos.map((p: { id: string; lat: number; lng: number; photoId: number }) => ({
id: p.id,
lat: p.lat,
lng: p.lng,
thumbUrl: photoUrl({ photo_id: p.photoId }, token, 'thumbnail'),
}))
: [];
// Shared timeline renderer used in both layout modes
const renderTimeline = () => (
<div className="flex flex-col gap-6">
{sortedDates.length === 0 && (
<EmptyState scene="journey" title={t('journey.detail.noEntries')} />
)}
{sortedDates.map((date, dayIdx) => {
{/* The owner publishes a reading order; the reader may flip it. Only the order
changes — the day colours and the stop numbers stay chronological. */}
{sortedDates.length > 1 && (
<div className="flex justify-end">
<button
type="button"
onClick={() => setNewestFirst(!newestFirst)}
aria-pressed={newestFirst}
className="inline-flex items-center gap-1.5 rounded-full border border-zinc-200 px-3 py-1.5 text-[11px] font-semibold text-zinc-600 hover:border-zinc-400 dark:border-zinc-700 dark:text-zinc-300 dark:hover:border-zinc-500"
>
<ArrowUpDown size={12} />
{newestFirst ? t('memories.newest') : t('memories.oldest')}
</button>
</div>
)}
{displayDates.map((date) => {
const dayEntries = groupedEntries.get(date)!;
const fd = formatDate(date, locale);
// Day index stays chronological even when the feed reads newest-first, so a
// day keeps its colour and its number whichever way round it is shown.
const dayIdx = sortedDates.indexOf(date);
const dayColor = DAY_COLORS[dayIdx % DAY_COLORS.length];
return (
<div key={date}>
@@ -304,6 +339,22 @@ export default function JourneyPublicPage() {
</h3>
)}
{/* The number the map marker carries, so the timeline is the key
to the map rather than a second, unrelated numbering (#1962).
Outside the photo-count gate below: a single-photo entry puts
its title and location in the image overlay, but the stop
number has to be readable either way. */}
{stopNumberById.has(String(entry.id)) && (
<div className="mb-2">
<span
className="inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full px-1.5 text-[10px] font-bold text-white"
style={{ background: dayColor }}
>
{stopNumberById.get(String(entry.id))}
</span>
</div>
)}
{/* Location + time badges */}
{(entry.location_name || entry.entry_time) && photos.length !== 1 && (
<div className="mb-2 flex flex-wrap items-center gap-2">
@@ -731,6 +782,7 @@ export default function JourneyPublicPage() {
ref={mapRef}
checkins={[]}
entries={sidebarMapItems as any}
photos={photoLayer}
height={9999}
fullScreen
activeMarkerId={activeEntryId ?? undefined}
@@ -791,7 +843,7 @@ export default function JourneyPublicPage() {
{/* Map (standalone tab — only in single-column mode) */}
{view === 'map' && perms.share_map && (
<div className="overflow-hidden rounded-2xl border border-zinc-200 dark:border-zinc-700">
<JourneyMap checkins={[]} entries={sidebarMapItems as any} height={500} />
<JourneyMap checkins={[]} entries={sidebarMapItems as any} photos={photoLayer} height={500} />
</div>
)}
</div>
@@ -82,6 +82,11 @@ function buildHook(over: Record<string, unknown> = {}): Record<string, unknown>
groupedEntries: grouped,
sortedDates: [...grouped.keys()].sort(),
sidebarMapItems: [],
stopNumberById: new Map<string, number>(),
mapPhotos: [] as { id: string; lat: number; lng: number; photoId: number }[],
displayDates: [...grouped.keys()].sort(),
newestFirst: false,
setNewestFirst: vi.fn(),
allPhotos: [] as PublicGalleryPhoto[],
desktopTwoColumn: true,
...over,
+194 -4
View File
@@ -7,6 +7,7 @@ import { resetAllStores, seedStore } from '../../tests/helpers/store';
import { buildSettings } from '../../tests/helpers/factories';
import { useSettingsStore } from '../store/settingsStore';
import SharedTripPage from './SharedTripPage';
import L from 'leaflet';
// Mock react-leaflet (SharedTripPage renders a map)
vi.mock('react-leaflet', () => ({
@@ -15,6 +16,7 @@ vi.mock('react-leaflet', () => ({
),
TileLayer: () => null,
Marker: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
Polyline: () => <div data-testid="route-line" />,
Tooltip: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
useMap: () => ({
fitBounds: vi.fn(),
@@ -500,8 +502,11 @@ describe('SharedTripPage', () => {
);
renderSharedTrip('test-token');
// The untitled day shows the German label "Tag 1", proving the hardcoded English
// "Day 1" was replaced by the i18n key t('dayplan.dayN').
await waitFor(() => expect(screen.getByText('Tag 1')).toBeInTheDocument());
// "Day 1" was replaced by the i18n key t('dayplan.dayN'). It appears twice since
// the day picker above the map was added (#1962): once as a chip, once on the card.
await waitFor(() => expect(screen.getAllByText('Tag 1')).toHaveLength(2));
const labels = screen.getAllByText('Tag 1');
expect(labels.filter((el) => el.closest('button'))).toHaveLength(1);
});
});
@@ -568,7 +573,7 @@ describe('SharedTripPage', () => {
});
});
// FE-PAGE-SHARED-021 to FE-PAGE-SHARED-036 drive the remaining render branches of
// FE-PAGE-SHARED-021 to FE-PAGE-SHARED-037 drive the remaining render branches of
// the page: header variants, the permission-driven tab strip, and every item kind
// the day timeline, bookings, packing, costs and chat sections can produce.
@@ -701,7 +706,10 @@ describe('SharedTripPage', () => {
accommodations: [{ id: 3, place_name: 'Hotel Lutetia', start_day_id: 5, end_day_id: 5 }],
}));
expect(screen.getByText('Day 1')).toBeInTheDocument();
// Twice since the day picker landed (#1962): the chip above the map and the card.
const dayLabels = screen.getAllByText('Day 1');
expect(dayLabels).toHaveLength(2);
expect(dayLabels.filter((el) => el.closest('button'))).toHaveLength(1);
expect(screen.getByText('Hotel Lutetia')).toBeInTheDocument();
expect(screen.getByText('0 places')).toBeInTheDocument();
// No date row for an undated day.
@@ -785,6 +793,158 @@ describe('SharedTripPage', () => {
});
});
// ── Day order, route line and the day picker at the map (#1962) ────────────
describe('FE-PAGE-SHARED-038: day markers carry their order and a connecting line', () => {
const day = { id: 7, trip_id: 1, day_number: 1, date: '2026-07-02', title: 'Day One' };
const louvre = { id: 201, name: 'Louvre', lat: 48.86, lng: 2.33, category: { id: 1, name: 'Sight', color: '#ff0000', icon: 'landmark' } };
const orsay = { id: 202, name: 'Orsay', lat: 48.85, lng: 2.32, category: null };
const notre = { id: 203, name: 'Notre-Dame', lat: 48.853, lng: 2.35, category: null };
// Every divIcon call the last render produced, as raw html strings.
const iconHtml = () => (L.divIcon as unknown as ReturnType<typeof vi.fn>).mock.calls.map((c: any[]) => String(c[0].html));
beforeEach(() => (L.divIcon as unknown as ReturnType<typeof vi.fn>).mockClear());
it('numbers the stops by order_index, not by payload order', async () => {
await open('order-map-token', payload({
days: [day],
places: [louvre, orsay, notre],
assignments: {
'7': [
{ id: 302, day_id: 7, place_id: 202, order_index: 1, place: orsay },
{ id: 303, day_id: 7, place_id: 203, order_index: 2, place: notre },
{ id: 301, day_id: 7, place_id: 201, order_index: 0, place: louvre },
],
},
}));
fireEvent.click(screen.getByRole('button', { name: 'Day 1' }));
await waitFor(() => expect(iconHtml().some((h: string) => h.includes('>1<'))).toBe(true));
const html = iconHtml();
// Louvre is order_index 0 and therefore 1, regardless of where it sat in the payload.
expect(html.filter((h: string) => h.includes('>1<'))).toHaveLength(1);
expect(html.filter((h: string) => h.includes('>2<'))).toHaveLength(1);
expect(html.filter((h: string) => h.includes('>3<'))).toHaveLength(1);
});
it('shows both positions once for a stop the day visits twice, and renders it once', async () => {
await open('twice-token', payload({
days: [day],
places: [louvre, orsay],
assignments: {
'7': [
{ id: 301, day_id: 7, place_id: 201, order_index: 0, place: louvre },
{ id: 302, day_id: 7, place_id: 202, order_index: 1, place: orsay },
{ id: 303, day_id: 7, place_id: 201, order_index: 2, place: louvre },
],
},
}));
fireEvent.click(screen.getByRole('button', { name: 'Day 1' }));
await waitFor(() => expect(iconHtml().some((h: string) => h.includes('1 \u00b7 3'))).toBe(true));
// One marker for the repeated place, not two with the same React key.
expect(iconHtml().filter((h: string) => h.includes('1 \u00b7 3'))).toHaveLength(1);
});
it('leaves the trip-wide pool unnumbered, since it has no order to show', async () => {
await open('nonum-token', payload({
days: [day],
places: [louvre, orsay],
assignments: { '7': [{ id: 301, day_id: 7, place_id: 201, order_index: 0, place: louvre }] },
}));
await waitFor(() => expect(iconHtml().length).toBeGreaterThan(0));
expect(iconHtml().some((h: string) => h.includes('border-radius:8px'))).toBe(false);
});
it('draws the connecting line only for a day with more than one stop', async () => {
await open('line-token', payload({
days: [day],
places: [louvre, orsay],
assignments: {
'7': [
{ id: 301, day_id: 7, place_id: 201, order_index: 0, place: louvre },
{ id: 302, day_id: 7, place_id: 202, order_index: 1, place: orsay },
],
},
}));
// No day selected: no line, even though the trip has two geocoded places.
expect(screen.queryByTestId('route-line')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Day 1' }));
await waitFor(() => expect(screen.getByTestId('route-line')).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: 'All' }));
await waitFor(() => expect(screen.queryByTestId('route-line')).toBeNull());
});
it('does not draw a line for a day with a single stop', async () => {
await open('single-token', payload({
days: [day],
places: [louvre],
assignments: { '7': [{ id: 301, day_id: 7, place_id: 201, order_index: 0, place: louvre }] },
}));
fireEvent.click(screen.getByRole('button', { name: 'Day 1' }));
await waitFor(() => expect(screen.getAllByText('Louvre').length).toBeGreaterThan(1));
expect(screen.queryByTestId('route-line')).toBeNull();
});
});
describe('FE-PAGE-SHARED-039: the day picker sits at the map and agrees with the day card', () => {
const day = { id: 7, trip_id: 1, day_number: 1, date: '2026-07-02', title: 'Day One' };
const louvre = { id: 201, name: 'Louvre', lat: 48.86, lng: 2.33, category: null };
const orsay = { id: 202, name: 'Orsay', lat: 48.85, lng: 2.32, category: null };
it('narrows the markers to one day and back again', async () => {
await open('picker-token', payload({
days: [day],
places: [louvre, orsay],
assignments: { '7': [{ id: 301, day_id: 7, place_id: 201, order_index: 0, place: louvre }] },
}));
// All: both geocoded places are on the map, so Orsay's tooltip is present.
expect(screen.getByText('Orsay')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Day 1' }));
await waitFor(() => expect(screen.queryByText('Orsay')).toBeNull());
fireEvent.click(screen.getByRole('button', { name: 'All' }));
await waitFor(() => expect(screen.getByText('Orsay')).toBeInTheDocument());
});
it('marks the active chip for assistive tech', async () => {
await open('pressed-token', payload({ days: [day], places: [louvre], assignments: {} }));
expect(screen.getByRole('button', { name: 'All' })).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(screen.getByRole('button', { name: 'Day 1' }));
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Day 1' })).toHaveAttribute('aria-pressed', 'true'),
);
expect(screen.getByRole('button', { name: 'All' })).toHaveAttribute('aria-pressed', 'false');
});
});
describe('FE-PAGE-SHARED-040: trip-wide markers keep their category colour', () => {
// The payload nests the category on a day's assignments but sends it flat on the
// trip-wide pool, so reading only the nested shape painted every marker indigo.
it('reads the flat category_color the trip pool sends', async () => {
(L.divIcon as unknown as ReturnType<typeof vi.fn>).mockClear();
await open('flatcat-token', payload({
days: [],
places: [{ id: 201, name: 'Louvre', lat: 48.86, lng: 2.33, category_color: '#ff8800', category_icon: 'landmark' }],
assignments: {},
}));
await waitFor(() => expect((L.divIcon as unknown as ReturnType<typeof vi.fn>).mock.calls.length).toBeGreaterThan(0));
const html = (L.divIcon as unknown as ReturnType<typeof vi.fn>).mock.calls.map((c: any[]) => String(c[0].html));
expect(html.some((h: string) => h.includes('#ff8800'))).toBe(true);
expect(html.some((h: string) => h.includes('#6366f1'))).toBe(false);
});
});
describe('FE-PAGE-SHARED-026: expanded day renders notes and single-leg transports', () => {
const day = { id: 9, trip_id: 1, day_number: 1, date: '2026-07-02', title: 'Day One' };
@@ -1134,4 +1294,34 @@ describe('SharedTripPage', () => {
expect(option.style.background).toBe('none');
});
});
describe('FE-PAGE-SHARED-037: a multi-day parking only shows on its drop-off and pickup day (#1937)', () => {
const days = [
{ id: 9, trip_id: 1, day_number: 1, date: '2026-07-01', title: 'Day One' },
{ id: 10, trip_id: 1, day_number: 2, date: '2026-07-02', title: 'Day Two' },
{ id: 11, trip_id: 1, day_number: 3, date: '2026-07-03', title: 'Day Three' },
];
it('skips the days in between and leaves that day collapsed', async () => {
await open('parking-token', payload({
days,
reservations: [
{
id: 81, title: 'Airport Parking', type: 'parking', status: 'confirmed', day_id: 9, end_day_id: 11,
reservation_time: '2026-07-01T05:30:00', reservation_end_time: '2026-07-03T19:00:00', metadata: null,
},
],
}));
// Only one day is expanded at a time, so each day is checked on its own.
fireEvent.click(screen.getByText('Day One'));
await waitFor(() => expect(screen.getByText(/Airport Parking/)).toBeInTheDocument());
fireEvent.click(screen.getByText('Day Two'));
await waitFor(() => expect(screen.queryByText(/Airport Parking/)).toBeNull());
fireEvent.click(screen.getByText('Day Three'));
await waitFor(() => expect(screen.getByText(/Airport Parking/)).toBeInTheDocument());
});
});
});
+130 -20
View File
@@ -17,14 +17,14 @@ import {
} from 'lucide-react';
import { createElement, useEffect, useRef } from 'react';
import { renderIconMarkup } from '../utils/iconMarkup';
import { MapContainer, Marker, TileLayer, Tooltip, useMap } from 'react-leaflet';
import { MapContainer, Marker, Polyline, TileLayer, Tooltip, useMap } from 'react-leaflet';
import { getCategoryIcon } from '../components/shared/categoryIcons';
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../constants/mapDefaults';
import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n';
import { useSettingsStore } from '../store/settingsStore';
import { avatarSrc } from '../utils/avatarSrc';
import { safeHexColor } from '../utils/safeColor';
import { getMergedItems, getTransportForDay } from '../utils/dayMerge';
import { getMergedItems, getTransportForDay, hidesOnMiddleDay } from '../utils/dayMerge';
import { isDayInAccommodationRange } from '../utils/dayOrder';
import { getFlightLegs, getTrainLegs } from '../utils/flightLegs';
import { splitReservationDateTime } from '../utils/formatters';
@@ -33,24 +33,41 @@ import { useSharedTrip } from './sharedTrip/useSharedTrip';
const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship };
function createMarkerIcon(place: any) {
// Injected into Leaflet's marker HTML, where CSS variables cannot reach - the same
// reason MapView.tsx is exempt from theme:lint outright.
const ORDER_BADGE_STYLE = 'position:absolute;bottom:-4px;right:-4px;min-width:16px;height:16px;border-radius:8px;padding:0 3px;background:rgba(255,255,255,0.94);border:1.5px solid rgba(0,0,0,0.15);box-shadow:0 1px 4px rgba(0,0,0,0.18);display:flex;align-items:center;justify-content:center;font-weight:800;color:#111827;line-height:1;box-sizing:border-box;white-space:nowrap;'; // theme-lint-disable
function createMarkerIcon(place: any, orderNumbers?: number[] | null) {
const cat = place.category;
// This page answers without a guard, so an unescaped colour here reaches
// people who have no account on the instance at all.
const color = safeHexColor(cat?.color, '#6366f1');
const CatIcon = getCategoryIcon(cat?.icon);
// The payload carries the category in two shapes: nested on a day's assignments,
// flat on the trip-wide pool. Reading only the nested one dropped every marker
// outside a day selection to the placeholder colour.
const color = safeHexColor(cat?.color ?? place.category_color, '#6366f1');
const CatIcon = getCategoryIcon(cat?.icon ?? place.category_icon);
const iconSvg = renderIconMarkup(createElement(CatIcon, { size: 14, strokeWidth: 2, color: 'white' }));
// Position in the day's order, same contract as the planner: a stop the day
// visits twice shows both, e.g. "1 . 3". The values are array indices, never payload.
const badge = orderNumbers?.length
? `<span style="${ORDER_BADGE_STYLE}font-size:${orderNumbers.length > 1 ? 7.5 : 9}px;">${orderNumbers.join(' \u00b7 ')}</span>`
: '';
return L.divIcon({
className: '',
iconSize: [28, 28],
iconAnchor: [14, 14],
html: `<div style="width:28px;height:28px;border-radius:50%;background:${color};display:flex;align-items:center;justify-content:center;box-shadow:0 2px 6px rgba(0,0,0,0.3);border:2px solid white;">${iconSvg}</div>`,
html: `<div style="position:relative;width:28px;height:28px;border-radius:50%;background:${color};display:flex;align-items:center;justify-content:center;box-shadow:0 2px 6px rgba(0,0,0,0.3);border:2px solid white;">${iconSvg}${badge}</div>`,
});
}
function FitBoundsToPlaces({ places, framedOnMount }: { places: any[]; framedOnMount: boolean }) {
const map = useMap();
const fitRan = useRef(false);
// The page rebuilds this array on every render (a Page body may not memoise), so
// keying the effect on the coordinates rather than the array identity is what stops
// an unrelated re-render - the language picker, a late FX response - from throwing
// the viewer's pan and zoom away.
const fitKey = places.map((p) => `${p.lat},${p.lng}`).join('|');
useEffect(() => {
if (places.length === 0) return;
// The map already opened framed on these places; fitting again would only re-do it.
@@ -62,7 +79,7 @@ function FitBoundsToPlaces({ places, framedOnMount }: { places: any[]; framedOnM
fitRan.current = true;
const bounds = L.latLngBounds(places.map((p) => [p.lat, p.lng]));
map.fitBounds(bounds, { padding: [40, 40], maxZoom: 14 });
}, [places, map]); // eslint-disable-line react-hooks/exhaustive-deps
}, [fitKey, map]); // eslint-disable-line react-hooks/exhaustive-deps
return null;
}
@@ -136,10 +153,27 @@ export default function SharedTripPage() {
} = data;
const sortedDays = [...(days || [])].sort((a: any, b: any) => a.day_number - b.day_number);
// Map places
const mapPlaces = selectedDay
? (assignments[String(selectedDay)] || []).map((a: any) => a.place).filter((p: any) => p?.lat && p?.lng)
: (places || []).filter((p: any) => p?.lat && p?.lng);
// Map places. In day mode each stop carries its position in the day's order; the
// trip-wide pool has none (it arrives by created_at). The index runs over the full
// sorted assignment list, like the planner does, so a stop without coordinates still
// consumes a number and the app and the share link agree on what "3" means.
const dayAssignments = selectedDay
? [...(assignments[String(selectedDay)] || [])].sort((a: any, b: any) => a.order_index - b.order_index)
: [];
const dayOrderMap: Record<number, number[]> = {};
dayAssignments.forEach((a: any, i: number) => {
if (!a.place?.id) return;
(dayOrderMap[a.place.id] ||= []).push(i + 1);
});
const dayPlaces: any[] = [];
const seenPlaceIds = new Set<number>();
for (const a of dayAssignments as any[]) {
const p = a.place;
if (!p?.lat || !p?.lng || seenPlaceIds.has(p.id)) continue;
seenPlaceIds.add(p.id);
dayPlaces.push(p);
}
const mapPlaces = selectedDay ? dayPlaces : (places || []).filter((p: any) => p?.lat && p?.lng);
// Open framed on the trip's places instead of on Paris. MapContainer only reads center/zoom
// at mount, so recomputing this per render is free — and the fit below takes over from there.
@@ -396,6 +430,41 @@ export default function SharedTripPage() {
{/* Map */}
{activeTab === 'plan' && (
<>
{/* Day picker at the map. Same setter as the day card below, so the map and
the expanded day can never disagree. Without it the only way to narrow the
map down was a control 300px further down the page. */}
{sortedDays.length > 0 && (
<div style={{ display: 'flex', gap: 6, overflowX: 'auto', marginBottom: 10, padding: '2px 0' }}>
{[null, ...sortedDays.map((d: any) => d.id)].map((id: number | null, i: number) => {
const active = selectedDay === id;
return (
<button
key={id ?? 'all'}
type="button"
onClick={() => setSelectedDay(id)}
aria-pressed={active}
// Same literals as the day-number circle below. This page pins itself
// to the light neutral look (applyAppearance skips /shared/*), so a
// token here would not be value-equal to the rest of the card.
className={active ? 'bg-[#111827] text-white' : 'bg-[#f3f4f6] text-[#6b7280]'} // theme-lint-disable
style={{
padding: '5px 12px',
borderRadius: 999,
border: 'none',
cursor: 'pointer',
whiteSpace: 'nowrap',
fontWeight: 600,
fontFamily: 'inherit',
flexShrink: 0,
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
}}
>
{id === null ? t('day.allDays') : t('dayplan.dayN', { n: sortedDays[i - 1].day_number })}
</button>
);
})}
</div>
)}
<div
style={{
borderRadius: 16,
@@ -416,8 +485,19 @@ export default function SharedTripPage() {
referrerPolicy="strict-origin-when-cross-origin"
/>
<FitBoundsToPlaces places={mapPlaces} framedOnMount={framed !== null} />
{selectedDay && mapPlaces.length > 1 && (
<Polyline
positions={mapPlaces.map((p: any) => [p.lat, p.lng])}
// Dashed and straight on purpose: it shows the order of the day's stops,
// not the roads between them. A real route would mean sending the
// itinerary to a third party for every anonymous visitor of a shared
// link, with no way for the trip's owner to opt out.
pathOptions={{ color: '#0a84ff', weight: 3, opacity: 0.8, dashArray: '6 8', lineCap: 'round' }} // theme-lint-disable
interactive={false}
/>
)}
{mapPlaces.map((p: any) => (
<Marker key={p.id} position={[p.lat, p.lng]} icon={createMarkerIcon(p)}>
<Marker key={p.id} position={[p.lat, p.lng]} icon={createMarkerIcon(p, dayOrderMap[p.id] ?? null)}>
<Tooltip>{p.name}</Tooltip>
</Marker>
))}
@@ -443,12 +523,16 @@ export default function SharedTripPage() {
isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, sortedDays)
);
// The shared link has to say what the app says: a multi-day parking only
// shows up on its drop-off and pickup day (#1937). Filtered here rather
// than skipped in the loop below so the day body isn't gated open on a
// row that never renders.
const merged = getMergedItems({
dayAssignments: da,
dayNotes: notes,
dayTransports: dayTransport,
dayId: day.id,
});
}).filter(item => !(item.type === 'transport' && hidesOnMiddleDay(item.data, day.id)));
return (
<div
@@ -482,17 +566,34 @@ export default function SharedTripPage() {
>
{di + 1}
</div>
<div style={{ flex: 1 }}>
{/* `flex: 1` alone gives this block a base size of 0, so the moment an
accommodation chip pushes the row over the available width it freezes
at its min-content width — a ~37px column with one word, or one CJK
character, per line (#1955). Base auto plus minWidth 0 lets it shrink
proportionally and truncate instead. */}
<div style={{ flex: '1 1 auto', minWidth: 0 }}>
<div
className="text-[#111827]"
style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600 }}
style={{
fontSize: 'calc(14px * var(--fs-scale-body, 1))',
fontWeight: 600,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{day.title || t('dayplan.dayN', { n: day.day_number })}
</div>
{day.date && (
<div
className="text-[#9ca3af]"
style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', marginTop: 1 }}
style={{
fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
marginTop: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{new Date(day.date + 'T00:00:00Z').toLocaleDateString(locale, {
weekday: 'short',
@@ -508,18 +609,27 @@ export default function SharedTripPage() {
key={acc.id}
className="bg-[#f3f4f6] text-[#6b7280]"
style={{
fontSize: 'calc(9px * var(--fs-scale-caption, 1))',
fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))',
padding: '2px 6px',
borderRadius: 4,
display: 'flex',
alignItems: 'center',
gap: 3,
gap: 4,
minWidth: 0,
}}
>
<Hotel size={8} /> {acc.place_name}
<Hotel size={11} style={{ flexShrink: 0 }} />
{/* Own span: text-overflow needs a block container, and the chip
itself is a flex container. */}
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{acc.place_name}
</span>
</span>
))}
<span className="text-[#9ca3af]" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }}>
<span
className="text-[#9ca3af]"
style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', flexShrink: 0, whiteSpace: 'nowrap' }}
>
{dayPlaceCount} {t('shared.places')}
</span>
</div>
+28
View File
@@ -6,10 +6,12 @@ import {
isBucketDuplicateError,
isCountryVisible,
normalizeRegionName,
regionCacheEvictions,
withCountryMarkedVisited,
wishlistA3Codes,
countryColor,
COUNTRY_COLORS,
REGION_CACHE_MAX,
type AtlasData,
type BucketItem,
} from './atlasModel';
@@ -260,6 +262,32 @@ describe('findBucketDuplicate (#1898)', () => {
});
});
// The region cache used to grow for the whole session (#1950). The eviction order is
// pure arithmetic, so it is decided here rather than inside the Leaflet effect.
describe('regionCacheEvictions (#1950)', () => {
it('drops nothing while the cache is within its cap', () => {
expect(regionCacheEvictions(['FR', 'IT'], new Set(), REGION_CACHE_MAX)).toEqual([]);
expect(regionCacheEvictions(['FR', 'IT'], new Set(), 2)).toEqual([]);
});
it('drops the least recently viewed countries first, and only as many as needed', () => {
expect(regionCacheEvictions(['FR', 'IT', 'ES', 'PT'], new Set(), 2)).toEqual(['FR', 'IT']);
expect(regionCacheEvictions(['FR', 'IT', 'ES', 'PT'], new Set(), 3)).toEqual(['FR']);
});
it('never drops a country that has to stay', () => {
// FR is the oldest entry but still on screen, so the next-oldest ones go instead.
expect(regionCacheEvictions(['FR', 'IT', 'ES', 'PT'], new Set(['FR']), 2)).toEqual(['IT', 'ES']);
});
it('stays over the cap rather than dropping what is in view', () => {
// Three countries on screen with room for two: evicting one would only make the
// next pan fetch it straight back.
expect(regionCacheEvictions(['FR', 'IT', 'ES'], new Set(['FR', 'IT', 'ES']), 2)).toEqual([]);
expect(regionCacheEvictions(['FR', 'IT', 'ES', 'PT'], new Set(['IT', 'ES', 'PT']), 2)).toEqual(['FR']);
});
});
describe('isBucketDuplicateError (#1898)', () => {
it('recognises the 409 and nothing else', () => {
expect(isBucketDuplicateError({ response: { status: 409 } })).toBe(true);
+27
View File
@@ -184,6 +184,33 @@ export function countryColor(a3: string): string {
return COUNTRY_COLORS[hash % COUNTRY_COLORS.length]
}
// How many countries' worth of admin-1 geometry the region layer keeps around.
// Panning back to a country you just left has to stay free, but a session that
// wanders across a continent used to hold on to every country it ever touched,
// a few hundred polygons each (#1950). A dozen covers the countries around any
// one view, and /regions/geo is served with a day of cache headroom, so the
// countries that do fall out come back without hitting the database.
export const REGION_CACHE_MAX = 12
/**
* Which cached countries to drop, given the cache order (least recently viewed
* first) and the codes that have to stay. Never returns a code from `keep`: when
* the countries in view alone exceed the cap it returns a shorter list instead of
* dropping geometry that is on screen, which is what stops panning across a big
* country turning into fetch / evict / refetch.
*/
export function regionCacheEvictions(order: string[], keep: Set<string>, max: number): string[] {
const drop: string[] = []
let remaining = order.length
for (const code of order) {
if (remaining <= max) break
if (keep.has(code)) continue
drop.push(code)
remaining -= 1
}
return drop
}
// Convert country code to flag emoji
export function countryCodeToFlag(code: string): string {
if (!code || code.length !== 2) return ''
+222 -9
View File
@@ -6,6 +6,7 @@ import { server } from '../../../tests/helpers/msw/server';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildSettings } from '../../../tests/helpers/factories';
import { useSettingsStore } from '../../store/settingsStore';
import L from 'leaflet';
import { A2_TO_A3 } from './atlasModel';
import { useAtlas } from './useAtlas';
@@ -16,6 +17,8 @@ interface MockGeoJson {
options: Record<string, unknown>;
entries: { feature: Record<string, unknown>; layer: MockLayer }[];
styles: Record<string, unknown>[];
/** Whether this layer is on the map right now: built is not the same as shown. */
attached: boolean;
}
interface MockLayer {
@@ -32,6 +35,9 @@ const lf = vi.hoisted(() => ({
panes: {} as Record<string, { style: Record<string, string> }>,
zoom: 3,
intersects: true,
// The country codes the viewport is over, when a test needs to be selective about it.
// null falls back to the blanket `intersects` answer.
intersectsOnly: null as string[] | null,
hasLayer: true,
boundsThrows: false,
mapsCreated: 0,
@@ -44,6 +50,7 @@ const lf = vi.hoisted(() => ({
this.panes = { overlayPane: { style: {} } };
this.zoom = 3;
this.intersects = true;
this.intersectsOnly = null;
this.hasLayer = true;
this.boundsThrows = false;
this.mapsCreated = 0;
@@ -53,7 +60,10 @@ const lf = vi.hoisted(() => ({
}));
vi.mock('leaflet', () => {
const makeLayer = () => {
// The bounds a country layer reports carry its own code, so the map's bounds can
// answer `intersects` per country instead of all-or-nothing.
const makeLayer = (feature?: { properties?: Record<string, unknown> }) => {
const code = typeof feature?.properties?.ISO_A2 === 'string' ? feature.properties.ISO_A2 : null;
const handlers: Record<string, (e: unknown) => void> = {};
const layer = {
handlers,
@@ -62,7 +72,7 @@ vi.mock('leaflet', () => {
setStyle: vi.fn(),
getBounds: vi.fn(() => {
if (lf.boundsThrows) throw new Error('no bounds');
return { isValid: () => true };
return { isValid: () => true, code };
}),
};
return layer;
@@ -75,10 +85,16 @@ vi.mock('leaflet', () => {
remove: vi.fn(() => { lf.mapsRemoved += 1; }),
fitBounds: vi.fn(),
addLayer: vi.fn(),
removeLayer: vi.fn(),
removeLayer: vi.fn((layer?: { record?: { attached: boolean } }) => {
if (layer?.record) layer.record.attached = false;
}),
getZoom: vi.fn(() => lf.zoom),
getCenter: vi.fn(() => ({ lat: 25, lng: 0 })),
getBounds: vi.fn(() => ({ intersects: () => lf.intersects })),
getBounds: vi.fn(() => ({
intersects: (other?: { code?: string | null }) => (
lf.intersectsOnly ? !!other?.code && lf.intersectsOnly.includes(other.code) : lf.intersects
),
})),
hasLayer: vi.fn(() => lf.hasLayer),
createPane: vi.fn((name: string) => { lf.panes[name] = { style: {} }; }),
getPane: vi.fn((name: string) => lf.panes[name]),
@@ -100,22 +116,23 @@ vi.mock('leaflet', () => {
}),
layerGroup: vi.fn(() => ({ addTo: vi.fn(() => ({})) })),
geoJSON: vi.fn((data: { features?: Record<string, unknown>[] }, options: Record<string, unknown>) => {
const record = { data, options, entries: [] as unknown[], styles: [] as unknown[] };
const record = { data, options, entries: [] as unknown[], styles: [] as unknown[], attached: false };
const style = options?.style as ((f: unknown) => unknown) | undefined;
const onEachFeature = options?.onEachFeature as ((f: unknown, l: unknown) => void) | undefined;
for (const feature of data?.features ?? []) {
if (style) record.styles.push(style(feature));
if (onEachFeature) {
const layer = makeLayer();
const layer = makeLayer(feature as { properties?: Record<string, unknown> });
onEachFeature(feature, layer);
record.entries.push({ feature, layer });
}
}
lf.geoJson.push(record);
const result = {
addTo: vi.fn(() => result),
removeFrom: vi.fn(),
remove: vi.fn(),
record,
addTo: vi.fn(() => { record.attached = true; return result; }),
removeFrom: vi.fn(() => { record.attached = false; }),
remove: vi.fn(() => { record.attached = false; }),
clearLayers: vi.fn(),
resetStyle: vi.fn(),
};
@@ -196,6 +213,20 @@ function countryLayers(): MockGeoJson[] {
return (lf.geoJson as MockGeoJson[]).filter((g) => g.options.pane === undefined);
}
/** Every region layer ever built, oldest first: one record per rebuild. */
function regionLayers(): MockGeoJson[] {
return (lf.geoJson as MockGeoJson[]).filter((g) => g.options.pane === 'regionPane');
}
function newestRegionLayer(): MockGeoJson | undefined {
const layers = regionLayers();
return layers[layers.length - 1];
}
function regionCodesOf(layer: MockGeoJson | undefined): string[] {
return (layer?.data.features ?? []).map((f) => (f.properties as Record<string, string>).iso_3166_2);
}
async function mountAtlas(over: Partial<Record<string, unknown>> = {}, props: { withPanel?: boolean } = {}) {
useAtlasHandlers(over);
const view = render(<Harness {...props} />);
@@ -808,6 +839,188 @@ describe('useAtlas', () => {
expect((lf.geoJson as MockGeoJson[]).some((g) => g.options.pane === 'regionPane')).toBe(false);
});
// ── The region layer used to keep every country it had ever seen and rebuild all of
// it, with a fresh renderer each time (#1950) ────────────────────────────────────
const twoRegions = {
type: 'FeatureCollection',
features: [
feature({ iso_a2: 'fr', iso_3166_2: 'FR-BRE', name: 'Bretagne', name_en: 'Brittany', admin: 'France' }),
feature({ iso_a2: 'it', iso_3166_2: 'IT-62', name: 'Lazio', name_en: 'Latium', admin: 'Italy' }),
],
};
/** Zoom in far enough for regions, over the given countries. */
const zoomTo = async (...codes: string[]) => {
lf.zoom = 6;
lf.intersectsOnly = codes;
await act(async () => { lf.mapHandlers.zoomend?.forEach((cb) => cb()); });
};
const panTo = async (...codes: string[]) => {
lf.intersectsOnly = codes;
await act(async () => { lf.mapHandlers.moveend?.forEach((cb) => cb()); });
};
it('FE-HOOK-ATLAS-038: a map builds one canvas and one svg renderer for its whole life (#1950)', async () => {
// Leaflet keeps a renderer as a layer of its own and leaves its container in the
// pane when the GeoJSON layer goes, so one per redraw piles up orphaned canvases
// that keep updating on every pan.
const canvasBefore = vi.mocked(L.canvas).mock.calls.length;
const svgBefore = vi.mocked(L.svg).mock.calls.length;
await mountAtlas({ geo: geoCountries, regionGeo: twoRegions });
await waitFor(() => expect(lf.mapHandlers.zoomend?.length).toBeGreaterThan(0));
await zoomTo('FR');
await waitFor(() => expect(regionLayers().length).toBe(1));
// A second country lands, then the planned toggle repaints both layers.
await panTo('FR', 'IT');
await waitFor(() => expect(regionLayers().length).toBe(2));
act(() => atlas.togglePlanned());
await waitFor(() => expect(regionLayers().length).toBe(3));
expect(lf.mapsCreated).toBe(1);
expect(vi.mocked(L.canvas).mock.calls.length).toBe(canvasBefore + 1);
expect(vi.mocked(L.svg).mock.calls.length).toBe(svgBefore + 1);
});
it('FE-HOOK-ATLAS-039: only the countries in view contribute features (#1950)', async () => {
await mountAtlas({ geo: geoCountries, regionGeo: twoRegions });
await waitFor(() => expect(lf.mapHandlers.zoomend?.length).toBeGreaterThan(0));
await zoomTo('FR', 'IT');
await waitFor(() => expect(regionCodesOf(newestRegionLayer())).toEqual(['FR-BRE', 'IT-62']));
// Panning off Italy leaves it in the cache but takes it out of the layer.
await panTo('FR');
expect(regionCodesOf(newestRegionLayer())).toEqual(['FR-BRE']);
// Coming back draws it again straight from the cache.
await panTo('FR', 'IT');
expect(regionCodesOf(newestRegionLayer())).toEqual(['FR-BRE', 'IT-62']);
});
it('FE-HOOK-ATLAS-040: panning inside the same countries rebuilds nothing (#1950)', async () => {
await mountAtlas({ geo: geoCountries, regionGeo: twoRegions });
await waitFor(() => expect(lf.mapHandlers.zoomend?.length).toBeGreaterThan(0));
await zoomTo('FR', 'IT');
await waitFor(() => expect(regionLayers().length).toBe(1));
await panTo('FR', 'IT');
expect(regionLayers().length).toBe(1);
// Losing France from the view is a change, so that one does redraw.
await panTo('IT');
expect(regionLayers().length).toBe(2);
expect(regionCodesOf(newestRegionLayer())).toEqual(['IT-62']);
});
it('FE-HOOK-ATLAS-041: the cache stops at a dozen countries and reloads what fell out (#1950)', async () => {
// Fourteen countries seen one at a time: the oldest two are gone by the end, so
// coming back to the first one has to ask the server again.
const seen = ['FR', 'IT', 'ES', 'PT', 'NL', 'BE', 'AT', 'CH', 'PL', 'CZ', 'SE', 'NO', 'DK', 'FI'];
const geo = {
type: 'FeatureCollection',
// Deliberately the other way round from the visiting order: with both the same,
// dropping the country from the front of the dataset and dropping the one seen
// longest ago pick the same victim, and nothing below tells them apart.
features: [...seen].reverse().map((a2) => feature({ ISO_A2: a2, ADM0_A3: A2_TO_A3[a2], ISO_A3: A2_TO_A3[a2], NAME: a2, ADMIN: a2 })),
};
const regionGeo = {
type: 'FeatureCollection',
features: seen.map((a2) => feature({ iso_a2: a2.toLowerCase(), iso_3166_2: `${a2}-01`, name: `${a2} region`, admin: a2 })),
};
await mountAtlas({ geo, regionGeo });
await waitFor(() => expect(atlas.atlas_country_options.length).toBe(seen.length));
const requested: string[] = [];
server.use(http.get('/api/addons/atlas/regions/geo', ({ request }) => {
requested.push(new URL(request.url).searchParams.get('countries') ?? '');
return HttpResponse.json(regionGeo);
}));
lf.zoom = 6;
for (let i = 0; i < seen.length; i++) {
await panTo(seen[i]);
await waitFor(() => expect(requested).toHaveLength(i + 1));
}
expect(requested).toEqual(seen);
await panTo('FR');
await waitFor(() => expect(requested).toHaveLength(seen.length + 1));
expect(requested[requested.length - 1]).toBe('FR');
// The country the view sat on longest is still cached, so it costs nothing.
await panTo('FI');
expect(regionCodesOf(newestRegionLayer())).toEqual(['FI-01']);
expect(requested).toHaveLength(seen.length + 1);
// And the two the view left just before that are still there as well. They sit at
// the front of the dataset, which is the whole difference between dropping by
// position and dropping by how long ago the country was last on screen.
await panTo('DK');
await panTo('NO');
expect(requested).toHaveLength(seen.length + 1);
});
it('FE-HOOK-ATLAS-042: a theme change and the planned toggle still restyle the regions (#1950)', async () => {
await mountAtlas({ geo: geoCountries, regionGeo: twoRegions });
await waitFor(() => expect(lf.mapHandlers.zoomend?.length).toBeGreaterThan(0));
await zoomTo('FR');
await waitFor(() => expect(regionLayers().length).toBe(1));
const unvisitedFill = (layer: MockGeoJson) =>
(layer.options.style as (f: unknown) => { fillColor: string })(twoRegions.features[0]).fillColor;
expect(unvisitedFill(newestRegionLayer()!)).toBe('#000000');
act(() => atlas.togglePlanned());
await waitFor(() => expect(regionLayers().length).toBe(2));
// Dark mode rebuilds the map itself, so the layer has to come back dark-styled
// even though the countries in view never changed.
act(() => { seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: true }) }); });
await waitFor(() => expect(regionLayers().length).toBe(3));
expect(unvisitedFill(newestRegionLayer()!)).toBe('#ffffff');
});
it('FE-HOOK-ATLAS-043: flying to a country from the search draws its regions (#1950)', async () => {
await mountAtlas({ geo: geoCountries, regionGeo: twoRegions });
await waitFor(() => expect(atlas.atlas_country_options.length).toBe(3));
// fitBounds puts the view on Italy alone; the zoomend that follows must not cull it.
await act(async () => { atlas.select_country_from_search('IT'); });
await zoomTo('IT');
await waitFor(() => expect(regionCodesOf(newestRegionLayer())).toEqual(['IT-62']));
});
it('FE-HOOK-ATLAS-044: zooming out to 5 keeps the drawn regions on the map (#1950)', async () => {
await mountAtlas({ geo: geoCountries, regionGeo: twoRegions });
await waitFor(() => expect(lf.mapHandlers.zoomend?.length).toBeGreaterThan(0));
// Both countries land in the cache, then the view narrows to France alone.
await zoomTo('FR', 'IT');
await waitFor(() => expect(regionLayers().length).toBe(1));
await panTo('FR');
await waitFor(() => expect(regionCodesOf(newestRegionLayer())).toEqual(['FR-BRE']));
const shown = newestRegionLayer()!;
expect(shown.attached).toBe(true);
// One step out. Regions still show at zoom 5 and the country layer stops taking
// clicks there, so the region layer is the only way to mark anything. The wider view
// pulls Italy back in, which is exactly the change that asks for a rebuild, and the
// map only accepts a rebuilt layer from zoom 6, so it must not happen here.
lf.zoom = 5;
lf.intersectsOnly = ['FR', 'IT'];
await act(async () => { lf.mapHandlers.zoomend?.forEach((cb) => cb()); });
await act(async () => { lf.mapHandlers.moveend?.forEach((cb) => cb()); });
expect(lf.panes.overlayPane.style.pointerEvents).toBe('none');
expect(shown.attached).toBe(true);
expect(newestRegionLayer()).toBe(shown);
});
});
describe('planned countries (#1048)', () => {
+135 -18
View File
@@ -5,7 +5,7 @@ import { useSettingsStore } from '../../store/settingsStore'
import apiClient, { mapsApi, pluginsApi, type PluginAtlasLayer } from '../../api/client'
import L from 'leaflet'
import type { GeoJsonFeatureCollection } from '../../types'
import { A2_TO_A3, countryStatus, findBucketDuplicate, isBucketDuplicateError, isCountryVisible, normalizeRegionName, withCountryMarkedVisited, wishlistA3Codes, countryColor, type AtlasData, type AtlasPlaceHit, type CountryDetail, type BucketItem } from './atlasModel'
import { A2_TO_A3, countryStatus, findBucketDuplicate, isBucketDuplicateError, isCountryVisible, normalizeRegionName, regionCacheEvictions, withCountryMarkedVisited, wishlistA3Codes, countryColor, REGION_CACHE_MAX, type AtlasData, type AtlasPlaceHit, type CountryDetail, type BucketItem } from './atlasModel'
import { continentForCountry, escapeHtml, type VisitStatus } from '@trek/shared'
import { useToast } from '../../components/shared/Toast'
import { getApiErrorMessage } from '../../types'
@@ -82,6 +82,12 @@ export function useAtlas() {
const mapRef = useRef<HTMLDivElement>(null)
const mapInstance = useRef<L.Map | null>(null)
const geoLayerRef = useRef<L.GeoJSON | null>(null)
// One renderer per map, not one per redraw: Leaflet registers a renderer as a layer
// of its own and leaves its container in the pane when the GeoJSON layer is removed,
// so building a fresh one on every rebuild left an orphaned canvas/svg behind that
// kept redrawing itself on every pan for the rest of the session (#1950).
const countryRendererRef = useRef<L.Canvas | null>(null)
const regionRendererRef = useRef<L.SVG | null>(null)
const glareRef = useRef<HTMLDivElement>(null)
const borderGlareRef = useRef<HTMLDivElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
@@ -117,6 +123,16 @@ export function useAtlas() {
const pluginLayerRef = useRef<L.GeoJSON | null>(null)
const regionLayerRef = useRef<L.GeoJSON | null>(null)
const regionGeoCache = useRef<Record<string, GeoJsonFeatureCollection>>({})
// Cached countries, least recently in view first, capped at REGION_CACHE_MAX.
const regionCacheOrder = useRef<string[]>([])
// Countries whose /regions/geo answer is still on the way. They must not be asked
// for twice while a pan fires moveend over and over, and must not be evicted before
// their own response lands.
const pendingRegionCodes = useRef<Set<string>>(new Set())
// Which countries the drawn layer was built from, so panning inside the same set of
// countries costs nothing.
const renderedRegionSigRef = useRef<string>('')
const rebuildRegionLayerRef = useRef<(force?: boolean) => void>(() => {})
const [showRegions, setShowRegions] = useState(false)
// Countries you only plan to visit stay off the map until asked for — the atlas is a
// record of where you have been, not of where you booked a flight to (#1048).
@@ -242,6 +258,52 @@ export function useAtlas() {
.catch(() => setPluginLayers([]))
}, [])
/** The view the map currently shows, or null while there is no map to ask. */
const viewportBounds = (): L.LatLngBounds | null => {
try {
return mapInstance.current?.getBounds() ?? null
} catch {
return null
}
}
/** Does this country's outline touch the given view? Fail open: a country we hold no
* outline for, or one whose bounds throw, counts as in view, because dropping it
* would blank regions the user can see. */
const countryInView = (code: string, bounds: L.LatLngBounds | null): boolean => {
if (!bounds) return true
const layer = country_layer_by_a2_ref.current[code]
if (!layer) return true
try {
return bounds.intersects(layer.getBounds())
} catch {
return true
}
}
const touchRegionCode = (code: string): void => {
const order = regionCacheOrder.current
const at = order.indexOf(code)
if (at !== -1) order.splice(at, 1)
order.push(code)
}
/** Drop the countries nobody is looking at once the cache outgrows its cap. A country
* still in view, or one still waiting for its response, is never dropped, or panning
* across a wide country would fetch it, evict it and fetch it again. */
const evictRegionCache = (): void => {
const bounds = viewportBounds()
const keep = new Set<string>(pendingRegionCodes.current)
for (const code of regionCacheOrder.current) {
if (!keep.has(code) && countryInView(code, bounds)) keep.add(code)
}
for (const code of regionCacheEvictions(regionCacheOrder.current, keep, REGION_CACHE_MAX)) {
delete regionGeoCache.current[code]
const at = regionCacheOrder.current.indexOf(code)
if (at !== -1) regionCacheOrder.current.splice(at, 1)
}
}
// Load admin-1 GeoJSON for countries visible in the current viewport
const loadRegionsForViewportRef = useRef<() => void>(() => {})
const loadRegionsForViewport = (): void => {
@@ -249,12 +311,21 @@ export function useAtlas() {
const bounds = mapInstance.current.getBounds()
const toLoad: string[] = []
for (const [code, layer] of Object.entries(country_layer_by_a2_ref.current)) {
if (regionGeoCache.current[code]) continue
if (regionGeoCache.current[code]) {
// Recency means recency of being on screen. Touching every cached country wrote
// the order back into GeoJSON feature order on every moveend, so the eviction
// picked its victim by position in the dataset instead of by what the view had
// just left, and panning back to that country paid for a refetch.
if (countryInView(code, bounds)) touchRegionCode(code)
continue
}
if (pendingRegionCodes.current.has(code)) continue
try {
if (bounds.intersects((layer as any).getBounds())) toLoad.push(code)
} catch {}
}
if (!toLoad.length) return
for (const code of toLoad) pendingRegionCodes.current.add(code)
apiClient.get(`/addons/atlas/regions/geo?countries=${toLoad.join(',')}`)
.then(geoRes => {
const geo = geoRes.data
@@ -262,11 +333,12 @@ export function useAtlas() {
let added = false
for (const c of toLoad) {
const features = geo.features.filter((f: any) => f.properties?.iso_a2?.toUpperCase() === c)
if (features.length > 0) { regionGeoCache.current[c] = { type: 'FeatureCollection', features }; added = true }
if (features.length > 0) { regionGeoCache.current[c] = { type: 'FeatureCollection', features }; touchRegionCode(c); added = true }
}
if (added) setRegionGeoLoaded(v => v + 1)
if (added) { evictRegionCache(); setRegionGeoLoaded(v => v + 1) }
})
.catch(() => {})
.finally(() => { for (const code of toLoad) pendingRegionCodes.current.delete(code) })
}
loadRegionsForViewportRef.current = loadRegionsForViewport
@@ -320,6 +392,10 @@ export function useAtlas() {
map.getPane('regionPane')!.style.zIndex = '401'
mapInstance.current = map
// Both renderers live as long as the map does (see the note on the refs); map.remove()
// disposes them along with every other layer it holds.
countryRendererRef.current = L.canvas({ padding: 0.5, tolerance: 5 })
regionRendererRef.current = L.svg({ pane: 'regionPane' })
// Zoom-based region switching
map.on('zoomend', () => {
@@ -337,6 +413,7 @@ export function useAtlas() {
regionLayerRef.current.addTo(map)
}
loadRegionsForViewportRef.current()
rebuildRegionLayerRef.current()
} else {
// Physically remove region layer so its SVG paths can't intercept events
if (regionTooltipRef.current) regionTooltipRef.current.style.display = 'none'
@@ -348,15 +425,29 @@ export function useAtlas() {
})
map.on('moveend', () => {
if (map.getZoom() >= 6) loadRegionsForViewportRef.current()
if (map.getZoom() < 6) return
loadRegionsForViewportRef.current()
// A pan that stays over the same countries changes nothing, and the rebuild
// notices that for itself, cheaper than working it out here.
rebuildRegionLayerRef.current()
})
return () => { map.remove(); mapInstance.current = null }
return () => {
map.remove()
mapInstance.current = null
countryRendererRef.current = null
regionRendererRef.current = null
// The layer belongs to the map that just went away. Without this the next map's
// zoomend would re-attach it, and Leaflet would revive the old renderer by
// appending a second container to the pane.
regionLayerRef.current = null
renderedRegionSigRef.current = ''
}
}, [dark, loading])
// Render GeoJSON countries
useEffect(() => {
if (!mapInstance.current || !geoData || !data) return
if (!mapInstance.current || !geoData || !data || !countryRendererRef.current) return
const visitedA3 = new Set(visibleCountries.map(c => A2_TO_A3[c.code]).filter(Boolean))
const plannedA3 = new Set(visibleCountries.filter(c => countryStatus(c) !== 'visited').map(c => A2_TO_A3[c.code]).filter(Boolean))
@@ -378,7 +469,7 @@ export function useAtlas() {
const colorForCode = countryColor
const wishlistPatternCache = new Map<string, CanvasPattern | null>()
const canvasRenderer = L.canvas({ padding: 0.5, tolerance: 5 })
const canvasRenderer = countryRendererRef.current
geoLayerRef.current = L.geoJSON(geoData, {
renderer: canvasRenderer,
@@ -541,17 +632,35 @@ export function useAtlas() {
// layers fetched before that would otherwise never get drawn.
}, [geoData, pluginLayers, dark, loading])
// Render sub-national region layer (zoom >= 5)
useEffect(() => {
if (!mapInstance.current) return
// Render sub-national region layer (zoom >= 5). `force` is for the changes that alter
// how the regions look (theme, visits, the planned toggle); the map's own zoom/pan
// handlers pass nothing and get a rebuild only when the countries in view changed.
const rebuildRegionLayer = (force = false): void => {
if (!mapInstance.current || !regionRendererRef.current) return
// Below zoom 6 a rebuild can only do harm: it drops the layer that is on the map and
// the add at the end of this function starts at 6, so the regions would go and stay
// gone. At zoom 5 they are the only clickable layer left (the country layer is dimmed
// and pointer-events off), so the layer built further in has to survive the zoom out.
if (!force && mapInstance.current.getZoom() < 6) return
const regionRenderer = regionRendererRef.current
// Draw only the countries that are actually on screen. The cache deliberately
// outlives the viewport so panning back is free, but merging all of it into one
// layer meant a continent's worth of admin-1 polygons stayed in the DOM, and every
// newly loaded country tore the whole thing down and built it again (#1950).
const bounds = viewportBounds()
const inViewCodes = Object.keys(regionGeoCache.current).filter(code => countryInView(code, bounds)).sort()
const sig = inViewCodes.join('|')
if (!force && sig === renderedRegionSigRef.current) return
// Remove existing region layer
if (regionLayerRef.current) {
mapInstance.current.removeLayer(regionLayerRef.current)
regionLayerRef.current = null
}
renderedRegionSigRef.current = sig
if (Object.keys(regionGeoCache.current).length === 0) return
if (inViewCodes.length === 0) return
// Build set of visited region codes and per-country name sets. Regions follow their
// country's status, so zooming into a planned country can't reveal "visited" regions.
@@ -598,10 +707,11 @@ export function useAtlas() {
const isVisitedFeature = (f: any) => matchesRegions(f, visitedRegionCodes, visitedRegionNamesByCountry)
const isPlannedFeature = (f: any) => matchesRegions(f, plannedRegionCodes, plannedRegionNamesByCountry)
// Include ALL region features — visited ones get colored fill, unvisited get outline only
// Include every region feature of the countries in view: visited ones get colored
// fill, unvisited get outline only (clicking one is how a region gets marked)
const allFeatures: any[] = []
for (const geo of Object.values(regionGeoCache.current)) {
for (const f of geo.features) {
for (const code of inViewCodes) {
for (const f of regionGeoCache.current[code].features) {
allFeatures.push(f)
}
}
@@ -614,10 +724,8 @@ export function useAtlas() {
const mergedGeo = { type: 'FeatureCollection', features: allFeatures }
const svgRenderer = L.svg({ pane: 'regionPane' })
regionLayerRef.current = L.geoJSON(mergedGeo as any, {
renderer: svgRenderer,
renderer: regionRenderer,
interactive: true,
pane: 'regionPane',
style: (feature) => {
@@ -706,8 +814,17 @@ export function useAtlas() {
if (mapInstance.current.getZoom() >= 6) {
regionLayerRef.current.addTo(mapInstance.current)
}
}
// Reassigned every render so the map handlers always call a closure that sees the
// current visits, theme and toggle state, same pattern as loadRegionsForViewport.
rebuildRegionLayerRef.current = rebuildRegionLayer
useEffect(() => {
// Anything in the deps changes how the regions look rather than which are on screen,
// so it has to redraw even when the countries in view are the same ones.
// visitedCountries belongs here: the region colours are derived from it, and without
// the dep this effect kept painting regions from a stale country list.
rebuildRegionLayerRef.current(true)
}, [regionGeoLoaded, visitedRegions, dark, t, visitedCountries, showPlanned])
const handleMarkCountry = (code: string, name: string): void => {
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { distanceBetweenGeoPoints, sortProviderPhotos } from './JourneyDetailPage.helpers';
import { distanceBetweenGeoPoints, groupPhotosByDate, sortProviderPhotos } from './JourneyDetailPage.helpers';
describe('Journey provider photo ranking', () => {
it('places GPS photos nearest the selected Journey location and keeps photos without GPS', () => {
@@ -25,3 +25,31 @@ describe('Journey provider photo ranking', () => {
expect(distanceBetweenGeoPoints({ lat: 41.9, lng: 12.5 }, { lat: 41.9, lng: 12.5 })).toBe(0);
});
});
describe('Journey provider photo grouping', () => {
it('orders the date headings newest first, whatever order the photos arrive in', () => {
const photos = [
{ id: 'b', takenAt: '2026-03-14T09:00:00Z' },
{ id: 'a', takenAt: '2026-03-16T09:00:00Z' },
{ id: 'c', takenAt: '2026-03-15T09:00:00Z' },
];
expect(groupPhotosByDate(photos).map((g) => g.date)).toEqual(['2026-03-16', '2026-03-15', '2026-03-14']);
});
it('keeps the distance sort inside a day but not across days', () => {
// What sortProviderPhotos hands over: nearest first, so the 14th leads the list.
const sorted = [
{ id: 'near', takenAt: '2026-03-14T10:00:00Z' },
{ id: 'mid', takenAt: '2026-03-16T10:00:00Z' },
{ id: 'far', takenAt: '2026-03-14T11:00:00Z' },
];
const groups = groupPhotosByDate(sorted);
expect(groups.map((g) => g.date)).toEqual(['2026-03-16', '2026-03-14']);
expect(groups[1].assets.map((a: { id: string }) => a.id)).toEqual(['near', 'far']);
});
it('sorts photos without a date to the end', () => {
const photos = [{ id: 'x' }, { id: 'y', takenAt: '2026-03-16T09:00:00Z' }];
expect(groupPhotosByDate(photos).map((g) => g.date)).toEqual(['2026-03-16', '__unknown__']);
});
});
@@ -66,7 +66,17 @@ export function groupPhotosByDate(photos: any[]): { date: string; label: string;
if (!map.has(key)) map.set(key, [])
map.get(key)!.push(asset)
}
return [...map.entries()].map(([date, assets]) => ({
// Group order must not depend on the order of `photos`. Once the caller sorts by
// distance to the entry, first-seen order would put the day holding the nearest
// photo first and the date headings would stop reading chronologically. Order
// within a day is left as handed in, so the distance sort still applies there.
return [...map.entries()]
.sort((a, b) => {
if (a[0] === '__unknown__') return 1
if (b[0] === '__unknown__') return -1
return b[0].localeCompare(a[0])
})
.map(([date, assets]) => ({
date,
label: date === '__unknown__'
? 'Unknown date'
@@ -39,6 +39,11 @@ export interface PublicGalleryPhoto {
owner_id?: number | null
file_path?: string | null
caption?: string | null
/** Where and when the picture was taken (#1614). Absent for most photos, and the
* coordinates are withheld entirely unless the owner shared the map. */
taken_at?: string | null
lat?: number | null
lng?: number | null
}
export function groupByDate(entries: PublicEntry[]): Map<string, PublicEntry[]> {
@@ -53,6 +53,8 @@ export function useJourneyPublic() {
const timelineEntries = useMemo(() => entries, [entries])
const groupedEntries = useMemo(() => groupByDate(timelineEntries), [timelineEntries])
// Chronological throughout: this is what the day colours and the stop numbers are
// derived from, so flipping the reading order must not renumber the trip.
const sortedDates = useMemo(() => [...groupedEntries.keys()].sort(), [groupedEntries])
const mapEntries = useMemo(
() => timelineEntries.filter(e => e.location_lat && e.location_lng),
@@ -63,12 +65,16 @@ export function useJourneyPublic() {
// Map entries with day color/label for colored markers.
// dayIdx is derived from sortedDates (ALL timeline dates) so marker colors
// stay in sync with the timeline day headers even when some days have no locations.
// The marker number used to be the stop's position *within* its day, while the
// timeline heading showed the day number — two different numbers in the same
// colour, and on mobile the heading is not rendered at all, so the key was
// missing entirely (#1962). It is now the stop's place in the whole journey, so
// every number appears exactly once and the colour still groups the day.
const sidebarMapItems = useMemo(() => {
const counters = new Map<string, number>()
let stop = 0
return mapEntries.map(e => {
const dayIdx = sortedDates.indexOf(e.entry_date)
const dayLabel = (counters.get(e.entry_date) ?? 0) + 1
counters.set(e.entry_date, dayLabel)
const dayLabel = ++stop
return {
id: String(e.id),
lat: e.location_lat!,
@@ -83,6 +89,33 @@ export function useJourneyPublic() {
})
}, [mapEntries, sortedDates])
// The same number the marker carries, so the timeline is the key to the map
// rather than a second, unrelated numbering.
const stopNumberById = useMemo(() => {
const m = new Map<string, number>()
sidebarMapItems.forEach(i => m.set(i.id, i.dayLabel))
return m
}, [sidebarMapItems])
// Photos that know where they were taken. Only meaningful when the map is shared —
// the server nulls the coordinates otherwise, so this comes out empty by itself.
const mapPhotos = useMemo(
() => gallery
.filter(p => typeof p.lat === 'number' && typeof p.lng === 'number')
.map(p => ({ id: String(p.id), lat: p.lat!, lng: p.lng!, photoId: p.photo_id })),
[gallery],
)
// A journey shared while the trip is still running reads like a blog, so the owner
// can publish it newest-first (#1614). The reader may flip it either way; only the
// display order changes, never the numbering.
const [newestFirst, setNewestFirst] = useState<boolean | null>(null)
const effectiveNewestFirst = newestFirst ?? !!perms.newest_first
const displayDates = useMemo(
() => (effectiveNewestFirst ? [...sortedDates].reverse() : sortedDates),
[sortedDates, effectiveNewestFirst],
)
// Two-column desktop layout: timeline feed left + sticky map right
const desktopTwoColumn = !isMobile && perms.share_timeline && perms.share_map
@@ -102,7 +135,8 @@ export function useJourneyPublic() {
view, setView, lightbox, setLightbox, showLangPicker, setShowLangPicker,
mapRef, activeEntryId, setActiveEntryId, viewingEntry, setViewingEntry, handleMarkerClick,
perms, journey, stats,
timelineEntries, groupedEntries, sortedDates, sidebarMapItems, allPhotos,
timelineEntries, groupedEntries, sortedDates, displayDates, sidebarMapItems, allPhotos, stopNumberById, mapPhotos,
newestFirst: effectiveNewestFirst, setNewestFirst,
desktopTwoColumn,
}
}
+44 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { parseTimeToMinutes, getSpanPhase, getTransportRouteEndpoints, getDisplayTimeForDay, getTransportForDay, getMergedItems } from './dayMerge'
import { parseTimeToMinutes, getSpanPhase, hidesOnMiddleDay, getTransportRouteEndpoints, getDisplayTimeForDay, getTransportForDay, getMergedItems } from './dayMerge'
describe('parseTimeToMinutes', () => {
it('parses HH:MM string', () => {
@@ -34,6 +34,49 @@ describe('getSpanPhase', () => {
})
})
describe('hidesOnMiddleDay', () => {
it('keeps a one-day parking on its only day', () => {
expect(hidesOnMiddleDay({ type: 'parking', day_id: 1, end_day_id: 1 }, 1)).toBe(false)
})
it('keeps a two-day parking on both days (no day in between exists)', () => {
const parking = { type: 'parking', day_id: 1, end_day_id: 2 }
expect(hidesOnMiddleDay(parking, 1)).toBe(false)
expect(hidesOnMiddleDay(parking, 2)).toBe(false)
})
it('hides a three-day parking only on the day in between', () => {
const parking = { type: 'parking', day_id: 1, end_day_id: 3 }
expect(hidesOnMiddleDay(parking, 1)).toBe(false)
expect(hidesOnMiddleDay(parking, 2)).toBe(true)
expect(hidesOnMiddleDay(parking, 3)).toBe(false)
})
it('hides every day in between of a longer parking span (#1937)', () => {
const parking = { type: 'parking', day_id: 1, end_day_id: 5 }
expect([1, 2, 3, 4, 5].map(d => hidesOnMiddleDay(parking, d)))
.toEqual([false, true, true, true, false])
})
it('leaves a car rental visible, since its middle days move to the day header', () => {
expect(hidesOnMiddleDay({ type: 'car', day_id: 1, end_day_id: 3 }, 2)).toBe(false)
})
it('leaves every other booking type alone', () => {
for (const type of ['train', 'cruise', 'event', 'hotel', 'other']) {
expect(hidesOnMiddleDay({ type, day_id: 1, end_day_id: 3 }, 2)).toBe(false)
}
})
it('keeps a parking whose end day is not part of the trip', () => {
expect(hidesOnMiddleDay({ type: 'parking', day_id: 1, end_day_id: 999 }, 1)).toBe(false)
})
it('keeps an unscheduled parking', () => {
expect(hidesOnMiddleDay({ type: 'parking', day_id: null, end_day_id: null }, 2)).toBe(false)
})
})
describe('getTransportRouteEndpoints', () => {
const pickup = { role: 'from', lat: 48.1, lng: 11.5 }
const dropoff = { role: 'to', lat: 52.5, lng: 13.4 }
+20
View File
@@ -29,6 +29,26 @@ export function getSpanPhase(
return 'middle'
}
/**
* Booking types that carry no information on the days between their start and end and
* therefore drop out of the day timeline there. A parked car just stands in the garage:
* you only need to know when you hand it over and when you collect it, so the days in
* between show nothing at all, not even a badge in the day header (#1937).
*
* A car rental is deliberately NOT in this set: its middle days are not dropped but
* moved into the day header, which is what people expect from a vehicle they are
* driving around. Kept as a set so another such type can join without editing every
* render site again.
*/
const MIDDLE_DAY_HIDDEN_TYPES = new Set(['parking'])
export function hidesOnMiddleDay(
r: { type?: string | null; day_id?: number | null; end_day_id?: number | null },
dayId: number
): boolean {
return !!r.type && MIDDLE_DAY_HIDDEN_TYPES.has(r.type) && getSpanPhase(r, dayId) === 'middle'
}
/**
* The route waypoints a transport contributes on a given day, respecting multi-day spans.
* A car rental (or any reservation whose span covers several days) is only routed to on its
+46
View File
@@ -50,3 +50,49 @@ describe('getTrainLegs (#1150)', () => {
expect(legs[0]).toMatchObject({ from: 'FRA', to: 'JFK', airline: 'LH', flight_number: 'LH 400' })
})
})
describe('per-segment booking references (#1943)', () => {
it('reads each segment its own code, and leaves a code-less segment undefined', () => {
const flight = res({
type: 'flight',
confirmation_number: 'BOOK1',
metadata: JSON.stringify({
legs: [
{ from: 'FRA', to: 'BER', confirmation_number: 'ABC123' },
{ from: 'BER', to: 'HND' },
],
}),
})
const legs = getFlightLegs(flight)
expect(legs[0].confirmation_number).toBe('ABC123')
// No fallback to the booking's own code: a segment either has one or it does
// not, and the booking's reference is shown separately.
expect(legs[1].confirmation_number).toBeUndefined()
})
it('reads a train segment code the same way', () => {
const train = res({
metadata: JSON.stringify({
legs: [
{ from: 'Berlin Hbf', to: 'Frankfurt Hbf', confirmation_number: 'DB-1' },
{ from: 'Frankfurt Hbf', to: 'München Hbf', confirmation_number: 'DB-2' },
],
}),
})
expect(getTrainLegs(train).map(l => l.confirmation_number)).toEqual(['DB-1', 'DB-2'])
})
it('gives the derived single leg of a legacy booking the booking code', () => {
const flight = res({
type: 'flight', confirmation_number: 'BOOK1', day_id: 3,
metadata: JSON.stringify({ departure_airport: 'FRA', arrival_airport: 'JFK' }),
})
expect(getFlightLegs(flight)[0].confirmation_number).toBe('BOOK1')
const train = res({
confirmation_number: 'BOOK2',
endpoints: [ep('from', 0, 'Köln Hbf'), ep('to', 1, 'Aachen Hbf')],
metadata: '{}',
})
expect(getTrainLegs(train)[0].confirmation_number).toBe('BOOK2')
})
})
+10
View File
@@ -19,6 +19,8 @@ export interface FlightLeg {
to: string | null
airline?: string
flight_number?: string
/** This segment's own booking reference, when it was booked separately (#1943). */
confirmation_number?: string
dep_day_id?: number | null
dep_time?: string | null // 'HH:mm'
arr_day_id?: number | null
@@ -59,6 +61,7 @@ export function getFlightLegs(r: Reservation): FlightLeg[] {
to: l.to ?? null,
airline: l.airline || undefined,
flight_number: l.flight_number || undefined,
confirmation_number: l.confirmation_number || undefined,
dep_day_id: l.dep_day_id ?? null,
dep_time: l.dep_time ?? null,
arr_day_id: l.arr_day_id ?? null,
@@ -77,6 +80,8 @@ export function getFlightLegs(r: Reservation): FlightLeg[] {
to: toCode,
airline: meta.airline || undefined,
flight_number: meta.flight_number || undefined,
// The single segment IS the booking, so its reference is the booking's.
confirmation_number: r.confirmation_number || undefined,
dep_day_id: r.day_id ?? null,
dep_time: first?.local_time ?? null,
arr_day_id: r.end_day_id ?? r.day_id ?? null,
@@ -95,6 +100,8 @@ export interface TrainLeg {
train_number?: string
platform?: string
seat?: string
/** This segment's own booking reference, when it was booked separately (#1943). */
confirmation_number?: string
dep_day_id?: number | null
dep_time?: string | null
arr_day_id?: number | null
@@ -115,6 +122,7 @@ export function getTrainLegs(r: Reservation): TrainLeg[] {
train_number: l.train_number || undefined,
platform: l.platform || undefined,
seat: l.seat || undefined,
confirmation_number: l.confirmation_number || undefined,
dep_day_id: l.dep_day_id ?? null,
dep_time: l.dep_time ?? null,
arr_day_id: l.arr_day_id ?? null,
@@ -133,6 +141,8 @@ export function getTrainLegs(r: Reservation): TrainLeg[] {
train_number: meta.train_number || undefined,
platform: meta.platform || undefined,
seat: meta.seat || undefined,
// The single segment IS the booking, so its reference is the booking's.
confirmation_number: r.confirmation_number || undefined,
dep_day_id: r.day_id ?? null,
dep_time: first?.local_time ?? null,
arr_day_id: r.end_day_id ?? r.day_id ?? null,
@@ -48,6 +48,24 @@ describe('MJourneyEntrySheet quick capture', () => {
});
});
// #1614 — the ask was "photos *or* quick notes" while travelling, and a moment
// without a picture is still worth catching.
it('takes a note in capture mode and saves it', async () => {
vi.spyOn(mapsApi, 'reverse').mockResolvedValue({ name: 'Singapore', address: 'Singapore' });
const { props } = setup();
const note = await screen.findByPlaceholderText('Write your story...');
fireEvent.change(note, { target: { value: 'Ferry was late, worth it' } });
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() =>
expect(props.onSave).toHaveBeenCalledWith(
expect.objectContaining({ story: 'Ferry was late, worth it' }),
undefined,
),
);
});
it('captures GPS, reverse-geocodes it, detects an editable weather category, and saves', async () => {
vi.spyOn(mapsApi, 'reverse').mockResolvedValue({ name: 'Singapore', address: 'Singapore' });
vi.spyOn(weatherApi, 'getCurrent').mockResolvedValue({
@@ -8,7 +8,7 @@ import { buildPlanner } from '../../../helpers/mobileTrip'
import { resetAllStores, seedStore } from '../../../helpers/store'
import { act, fireEvent, render, screen } from '../../../helpers/render'
// FE-MOB-TRFRM-001 to FE-MOB-TRFRM-046
// FE-MOB-TRFRM-001 to FE-MOB-TRFRM-048
//
// The sheet's own pickers (airport/location search, day select, time picker) and
// the embedded transit panel are replaced by minimal controlled stand-ins so the
@@ -632,6 +632,62 @@ describe('MTransportFormSheet', () => {
expect(legs[1]).not.toHaveProperty('day_positions')
})
it('FE-MOB-TRFRM-047: each segment of a layover saves its own booking code (#1943)', async () => {
const handleSaveTransport = makeSave()
renderSheet(makePlanner({ handleSaveTransport }))
typeTitle('FRA-IST-HND')
// A direct flight writes no legs, so only the booking's own field is offered.
expect(screen.getAllByPlaceholderText('reservations.confirmationPlaceholder')).toHaveLength(1)
fireEvent.click(screen.getByRole('button', { name: 'reservations.layover.addStop' }))
setValue(airportInputs()[0], 'FRA')
setValue(airportInputs()[1], 'IST')
setValue(airportInputs()[2], 'HND')
const codes = screen.getAllByPlaceholderText('reservations.confirmationPlaceholder')
expect(codes).toHaveLength(3)
setValue(codes[0], 'ABC123')
setValue(codes[1], 'XYZ789')
setValue(codes[2], 'BOOK1')
await submit()
const payload = handleSaveTransport.mock.calls[0][0]
const legs = (payload.metadata as { legs: Record<string, unknown>[] }).legs
expect(legs.map(l => l.confirmation_number)).toEqual(['ABC123', 'XYZ789'])
// The booking's own reference stays on its column, never on a segment.
expect(payload.confirmation_number).toBe('BOOK1')
expect(payload.metadata).not.toHaveProperty('confirmation_number')
})
it('FE-MOB-TRFRM-048: a re-save on the phone keeps the stored segment codes (#1943)', async () => {
const handleSaveTransport = makeSave()
const editingTransport = {
id: 45, trip_id: 1, type: 'flight', title: 'FRA-IST-HND', status: 'pending',
day_id: 11, end_day_id: 12, confirmation_number: 'BOOK1',
metadata: {
legs: [
{ from: 'FRA', to: 'IST', confirmation_number: 'ABC123', dep_day_id: 11, dep_time: '10:20', arr_day_id: 11, arr_time: '14:05' },
{ from: 'IST', to: 'HND', confirmation_number: 'XYZ789', dep_day_id: 11, dep_time: '16:30', arr_day_id: 12, arr_time: '09:00' },
],
},
endpoints: [
{ id: 1, reservation_id: 45, role: 'from', sequence: 0, name: 'Frankfurt (FRA)', code: 'FRA', lat: 50.03, lng: 8.57, timezone: 'Europe/Berlin', local_date: null, local_time: null },
{ id: 2, reservation_id: 45, role: 'stop', sequence: 1, name: 'Istanbul (IST)', code: 'IST', lat: 41.27, lng: 28.75, timezone: 'Europe/Istanbul', local_date: null, local_time: null },
{ id: 3, reservation_id: 45, role: 'to', sequence: 2, name: 'Tokyo (HND)', code: 'HND', lat: 35.55, lng: 139.78, timezone: 'Asia/Tokyo', local_date: null, local_time: null },
],
} as unknown as Reservation
renderSheet(makePlanner({ editingTransport, handleSaveTransport }))
const codes = screen.getAllByPlaceholderText('reservations.confirmationPlaceholder') as HTMLInputElement[]
expect(codes.map(i => i.value)).toEqual(['ABC123', 'XYZ789', 'BOOK1'])
await submit('common.update')
const legs = (handleSaveTransport.mock.calls[0][0].metadata as { legs: Record<string, unknown>[] }).legs
expect(legs.map(l => l.confirmation_number)).toEqual(['ABC123', 'XYZ789'])
})
it('FE-MOB-TRFRM-023: an edited flight without endpoints falls back to the reservation times', () => {
const editingTransport = {
id: 44, trip_id: 1, type: 'flight', title: 'Legacy flight', status: 'pending',
@@ -7,7 +7,7 @@ import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import { resetAllStores, seedStore } from '../../../helpers/store'
import { fireEvent, render, screen } from '../../../helpers/render'
// FE-MOB-TRSH-001 to FE-MOB-TRSH-020
// FE-MOB-TRSH-001 to FE-MOB-TRSH-024
vi.mock('../../../../src/utils/fileDownload', () => ({ openFile: vi.fn() }))
@@ -265,6 +265,36 @@ describe('MTransportSheet', () => {
expect(screen.getByText('FRA → HND')).toBeInTheDocument()
})
const layover = (): Reservation => ({
...FLIGHT,
metadata: JSON.stringify({
airline: 'Lufthansa', flight_number: 'LH 716',
legs: [
{ from: 'FRA', to: 'BER', airline: 'Lufthansa', flight_number: 'LH 716', confirmation_number: 'ABC123' },
{ from: 'BER', to: 'HND', airline: 'ANA', flight_number: 'NH 204' },
],
}),
} as unknown as Reservation)
it('FE-MOB-TRSH-023: lists the booking code of every segment that has its own (#1943)', () => {
render(<MTransportSheet planner={makePlanner({ reservations: [layover()] })} shell={makeShell()} />)
// The booking's own reference keeps its row above.
expect(screen.getByText('#QW7788')).toBeInTheDocument()
expect(screen.getByText('FRA → BER')).toBeInTheDocument()
expect(screen.getByText('#ABC123')).toBeInTheDocument()
// A segment without one contributes no row.
expect(screen.queryByText('BER → HND')).not.toBeInTheDocument()
})
it('FE-MOB-TRSH-024: a segment code is blurred until it is tapped', () => {
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, blur_booking_codes: true } })
render(<MTransportSheet planner={makePlanner({ reservations: [layover()] })} shell={makeShell()} />)
const code = screen.getByText('#ABC123')
expect(code.className).toContain('blur-[4px]')
fireEvent.click(code)
expect(screen.getByText('#ABC123').className).not.toContain('blur-[4px]')
})
it('FE-MOB-TRSH-022: closes from the header button', () => {
const shell = makeShell()
render(<MTransportSheet planner={makePlanner()} shell={shell} />)
@@ -257,6 +257,36 @@ describe('MTripShell', () => {
expect(planner.setRouteShown).toHaveBeenCalledWith(true)
})
// #1962 — the phone lost the desktop sidebar's collapse chevron, so the map showed
// every day's pins at once with no way to narrow it down.
it('FE-MOB-SHELL-040: entering the map focuses it on the selected day', () => {
const { planner } = renderShell()
fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.mapView' }))
expect(planner.setExpandedDayIds).toHaveBeenCalledWith(new Set([11]))
})
it('FE-MOB-SHELL-041: a day tap in map mode moves the focus with it', () => {
const { planner } = renderShell()
fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.mapView' }))
vi.mocked(planner.setExpandedDayIds).mockClear()
fireEvent.click(screen.getByRole('button', { name: 'Sun 3' }))
expect(planner.setExpandedDayIds).toHaveBeenCalledWith(new Set([12]))
})
it('FE-MOB-SHELL-042: a day tap in list mode leaves the map unfiltered', () => {
const { planner } = renderShell()
fireEvent.click(screen.getByRole('button', { name: 'Sun 3' }))
expect(planner.setExpandedDayIds).not.toHaveBeenCalled()
})
it('FE-MOB-SHELL-043: leaving the map clears the focus again', () => {
const { planner } = renderShell()
fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.mapView' }))
vi.mocked(planner.setExpandedDayIds).mockClear()
fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.listView' }))
expect(planner.setExpandedDayIds).toHaveBeenCalledWith(null)
})
it('FE-MOB-SHELL-023: the dock only shows enabled tabs and marks the active one', () => {
const { planner } = renderShell({
TRIP_TABS: [
@@ -11,7 +11,7 @@ import type {
Accommodation, Assignment, Day, DayNote, Reservation, RouteSegment, TranslationFn,
} from '../../../../src/types'
// FE-MOB-PTLM-001 to FE-MOB-PTLM-042
// FE-MOB-PTLM-001 to FE-MOB-PTLM-043
const DAYS = [
{ id: 1, trip_id: 1, day_number: 1, date: '2026-05-01', title: null },
@@ -192,6 +192,13 @@ describe('planTimelineModel — buildPlanRows', () => {
expect(buildPlanRows({ merged: [transportItem(car)], reservations: [], routeSegments: [], dayId: 1 })).toHaveLength(1)
})
it('FE-MOB-PTLM-043: hides a parking on the days between drop-off and pickup (#1937)', () => {
const parking = buildReservation({ id: 63, type: 'parking', day_id: 1, end_day_id: 3 })
expect(buildPlanRows({ merged: [transportItem(parking)], reservations: [], routeSegments: [], dayId: 2 })).toEqual([])
expect(buildPlanRows({ merged: [transportItem(parking)], reservations: [], routeSegments: [], dayId: 1 })).toHaveLength(1)
expect(buildPlanRows({ merged: [transportItem(parking)], reservations: [], routeSegments: [], dayId: 3 })).toHaveLength(1)
})
it('FE-MOB-PTLM-017: slots a connector between two located places and tags its origin', () => {
const leg = seg([48.1, 16.1], [48.2, 16.2])
const rows = buildPlanRows({
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.6 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 583 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 276 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 33 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 33 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 32 KiB

+74 -44
View File
@@ -2686,16 +2686,16 @@
}
},
"node_modules/@eslint/config-array/node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
"node": "20 || >=22"
}
},
"node_modules/@eslint/config-array/node_modules/minimatch": {
@@ -2782,9 +2782,9 @@
}
},
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
"integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5433,20 +5433,20 @@
}
},
"node_modules/@nestjs/swagger": {
"version": "11.4.5",
"resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.4.5.tgz",
"integrity": "sha512-lvndlJmWBVDOUT0uEtLi6sSpW1syK2/nbAlHBhiELBORMpJGe9+EiWAT9qHtB10jW91L2Jmlwkr0/lttsYZrig==",
"version": "11.4.7",
"resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.4.7.tgz",
"integrity": "sha512-QyDYnmfP4IRucgmtQxMqzgRBdWtjFoDp8eFvvgf92+3wdLCL+Q0xOFO1948j/ntW/Wi7qT2dyck6ka8ADzPWQQ==",
"license": "MIT",
"dependencies": {
"@microsoft/tsdoc": "0.16.0",
"@nestjs/mapped-types": "2.1.1",
"js-yaml": "4.3.0",
"js-yaml": "5.3.0",
"lodash": "4.18.1",
"path-to-regexp": "8.4.2",
"swagger-ui-dist": "5.32.8"
"swagger-ui-dist": "5.32.13"
},
"peerDependencies": {
"@fastify/static": "^8.0.0 || ^9.0.0",
"@fastify/static": "^8.0.0 || ^9.0.0 || ^10.0.0",
"@nestjs/common": "^11.0.1",
"@nestjs/core": "^11.0.1",
"class-transformer": "*",
@@ -5465,6 +5465,28 @@
}
}
},
"node_modules/@nestjs/swagger/node_modules/js-yaml": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.3.0.tgz",
"integrity": "sha512-muutsYr+e2+d3rTgUGslq5rxbBlUy3cJ61IsHag2QNDQV+7zXWjkUpmALIajhrlLlrgRUiymj6U3zUr/TMK84Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.mjs"
}
},
"node_modules/@nestjs/testing": {
"version": "11.1.27",
"resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.1.27.tgz",
@@ -7816,16 +7838,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
"node": "20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
@@ -8931,9 +8953,9 @@
}
},
"node_modules/brace-expansion": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
"integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
@@ -10722,16 +10744,16 @@
}
},
"node_modules/eslint/node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
"node": "20 || >=22"
}
},
"node_modules/eslint/node_modules/json-schema-traverse": {
@@ -10898,6 +10920,12 @@
"resolved": "https://registry.npmjs.org/exif-parser/-/exif-parser-0.1.12.tgz",
"integrity": "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="
},
"node_modules/exifr": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/exifr/-/exifr-7.1.3.tgz",
"integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==",
"license": "MIT"
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
@@ -11105,9 +11133,9 @@
}
},
"node_modules/fast-uri": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
"integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
"funding": [
{
"type": "github",
@@ -12218,9 +12246,9 @@
}
},
"node_modules/ip-address": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz",
"integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==",
"license": "MIT",
"engines": {
"node": ">= 12"
@@ -13062,6 +13090,7 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"dev": true,
"funding": [
{
"type": "github",
@@ -15331,16 +15360,16 @@
}
},
"node_modules/nodemon/node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
"node": "20 || >=22"
}
},
"node_modules/nodemon/node_modules/has-flag": {
@@ -18624,9 +18653,9 @@
}
},
"node_modules/swagger-ui-dist": {
"version": "5.32.8",
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.8.tgz",
"integrity": "sha512-dgMdWXIgnI4zX4OPhKEdWnlDODbgm8W3AX0Ivn/BBqcUh6xZsBxhZMnvk6DJyRz1BTrj8dPxtarmEGgkz30oyA==",
"version": "5.32.13",
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.13.tgz",
"integrity": "sha512-qQobzb3DeC2LeK0j3E8812Ef4aIq1y9flJxvZkimkqUC/w4u7wS+yCc+VakqGJLweUUBrI24effhwo8OsAvNAw==",
"license": "Apache-2.0",
"dependencies": {
"@scarf/scarf": "=1.4.0"
@@ -20731,16 +20760,16 @@
}
},
"node_modules/workbox-build/node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
"node": "20 || >=22"
}
},
"node_modules/workbox-build/node_modules/fs-extra": {
@@ -21207,6 +21236,7 @@
"cors": "^2.8.5",
"cron": "^4.4.0",
"dotenv": "^16.4.1",
"exifr": "7.1.3",
"express": "^4.18.3",
"fast-xml-parser": "^5.5.10",
"helmet": "^8.1.0",
@@ -21367,9 +21397,9 @@
}
},
"server/node_modules/brace-expansion": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
"integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dev": true,
"license": "MIT",
"dependencies": {
+1
View File
@@ -45,6 +45,7 @@
"cors": "^2.8.5",
"cron": "^4.4.0",
"dotenv": "^16.4.1",
"exifr": "7.1.3",
"express": "^4.18.3",
"fast-xml-parser": "^5.5.10",
"helmet": "^8.1.0",
+22 -3
View File
@@ -273,8 +273,24 @@ async function main() {
}
db.transaction(() => {
// --- app_settings: oidc_client_secret, smtp_pass, admin_webhook_url, admin_ntfy_token ---
for (const key of ['oidc_client_secret', 'smtp_pass', 'admin_webhook_url', 'admin_ntfy_token']) {
// --- app_settings: oidc_client_secret, smtp_pass, admin_webhook_url, admin_ntfy_token,
// plus the instance-wide provider keys (#1939) ---
//
// The last two mirror INSTANCE_API_KEY_NAMES in
// src/nest/settings/instance-api-keys.ts. Spelled out rather than imported,
// because that module pulls in DatabaseService and this script deliberately
// stays independent of src/ (see the crypto note above). Miss one and a
// rotation leaves it encrypted under the old key, which reads back as "no
// key" and silently drops the install to OpenStreetMap, so the copy is
// pinned by tests/unit/db/migrate-encryption-parity.test.ts.
for (const key of [
'oidc_client_secret',
'smtp_pass',
'admin_webhook_url',
'admin_ntfy_token',
'maps_api_key',
'unsplash_api_key',
]) {
const row = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined;
if (!row?.value) continue;
const newVal = migrateApiKeyValue(row.value, `app_settings.${key}`);
@@ -284,7 +300,10 @@ async function main() {
}
// --- users: api key columns + synology credentials ---
const apiKeyColumns = ['maps_api_key', 'openweather_api_key', 'immich_api_key', 'synology_password', 'synology_sid', 'synology_did'];
// unsplash_api_key was missing here for as long as the column has existed. It
// matters now that the resolver reads it as the per-user fallback (#1939): left
// out of a rotation it stays encrypted under the old key and reads back as unset.
const apiKeyColumns = ['maps_api_key', 'unsplash_api_key', 'openweather_api_key', 'immich_api_key', 'synology_password', 'synology_sid', 'synology_did'];
const users = db.prepare('SELECT id FROM users').all() as { id: number }[];
for (const user of users) {
+78 -2
View File
@@ -3909,13 +3909,89 @@ function runMigrations(db: Database.Database): void {
)
`);
},
// #1939 — the Google Places and Unsplash keys are instance configuration and
// now resolve out of app_settings (nest/settings/instance-api-keys.ts). This
// carries the value the old resolver would have handed out — the lowest-id
// admin who has one — into that row, so an install that upgrades keeps the
// key it was searching with instead of falling back to OpenStreetMap.
// Only where that key already was the whole install's, though. The resolver
// reads the instance row before the caller's own column, so promoting a
// personal key while a second row still holds one would move that member
// onto a stranger's key and a stranger's bill without anyone saying so. As
// soon as another row has a value for the same column nothing is written:
// everybody keeps resolving what they resolved before, and a member with no
// key of their own searches via OpenStreetMap until an admin saves the
// instance key once in the panel. Handing a key to the whole instance is the
// admin's call, not a migration's.
// The users columns are left alone: they are still the per-user fallback,
// and nothing here can lose a value. Stored blobs are copied verbatim; both
// sides use the same apiKeyCrypto format, legacy plaintext included.
// Appended LAST — the array is index-addressed against schema_version.
() => {
const upsert = db.prepare(
`INSERT INTO app_settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`
);
for (const column of ['maps_api_key', 'unsplash_api_key'] as const) {
const existing = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(column) as
| { value: string | null }
| undefined;
if (existing?.value) continue;
const row = db
.prepare(
`SELECT id, ${column} AS value FROM users
WHERE role = 'admin' AND ${column} IS NOT NULL AND ${column} != ''
ORDER BY id ASC LIMIT 1`
)
.get() as { id: number; value: string } | undefined;
if (!row?.value) continue;
const otherHolder = db
.prepare(
`SELECT 1 FROM users
WHERE id != ? AND ${column} IS NOT NULL AND ${column} != '' LIMIT 1`
)
.get(row.id);
if (otherHolder) continue;
upsert.run(column, row.value);
}
},
// Capture metadata on the photo itself (#1614): when it was taken and where.
// Both are needed before photos can sit on the Journey map by their own
// coordinates, and before a gallery can be ordered by when a picture was
// taken rather than when it happened to be added. Nullable throughout —
// most providers answer with neither, and a photo without them is normal.
// Guarded so re-running the migration tail is a no-op.
() => {
const cols = db.prepare("SELECT name FROM pragma_table_info('trek_photos')").all() as Array<{ name: string }>;
const has = (name: string) => cols.some(c => c.name === name);
if (!has('taken_at')) db.exec('ALTER TABLE trek_photos ADD COLUMN taken_at TEXT');
if (!has('lat')) db.exec('ALTER TABLE trek_photos ADD COLUMN lat REAL');
if (!has('lng')) db.exec('ALTER TABLE trek_photos ADD COLUMN lng REAL');
// Answering "which photos of this journey have coordinates" without a scan.
db.exec('CREATE INDEX IF NOT EXISTS idx_trek_photos_geo ON trek_photos(lat, lng) WHERE lat IS NOT NULL AND lng IS NOT NULL');
},
// A journey shared while the trip is still running reads like a blog, and a
// blog puts the newest entry first (#1614). The owner decides per share link,
// because it is a property of how the link is meant to be read, not of the
// journey. Off by default: an already-published link must not reorder itself
// under its readers.
() => {
const cols = db.prepare("SELECT name FROM pragma_table_info('journey_share_tokens')").all() as Array<{ name: string }>;
if (!cols.some(c => c.name === 'newest_first')) {
db.exec('ALTER TABLE journey_share_tokens ADD COLUMN newest_first INTEGER NOT NULL DEFAULT 0');
}
},
// Storage slice 2 — collab note attachments historically stored 'files/<name>'
// in trip_files.filename while the file manager stored bare names in the same
// column; the storage layer addresses objects as category + bare name, so
// normalize the legacy rows. substr is 1-indexed: 7 drops the six chars of
// 'files/'. The LIKE guard makes it a no-op on already-bare rows.
// Appended LAST: the array is index-addressed against schema_version, so a
// slot inserted above this line never runs on an existing database.
// Appended LAST (again): this slot moved below the three dev slots above in
// the dev merge — the array is index-addressed against schema_version, and
// the dev slots keep the indices they shipped with. A database that already
// ran this migration at its pre-merge index replays it harmlessly (the LIKE
// guard) but SKIPS the #1939 key promotion — acceptable only because this
// branch was never published; do not repeat this with a released slot.
() => {
db.exec("UPDATE trip_files SET filename = substr(filename, 7) WHERE filename LIKE 'files/%'");
},
+26
View File
@@ -27,6 +27,19 @@ function resetDemoUser(): void {
console.error('[Demo Reset] Failed to read admin data:', e instanceof Error ? e.message : e);
}
// The Places/Unsplash keys the searches actually use live in app_settings
// since #1939, so they have to survive the restore alongside the columns —
// otherwise the demo instance loses map search on every reset.
interface InstanceKeyRow { key: string; value: string | null }
let instanceKeys: InstanceKeyRow[] = [];
try {
instanceKeys = db.prepare(
"SELECT key, value FROM app_settings WHERE key IN ('maps_api_key', 'unsplash_api_key')"
).all() as InstanceKeyRow[];
} catch (e: unknown) {
console.error('[Demo Reset] Failed to read instance API keys:', e instanceof Error ? e.message : e);
}
// Flush WAL to main DB file
try { db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {}
@@ -67,6 +80,19 @@ function resetDemoUser(): void {
}
}
if (instanceKeys.length) {
try {
const { db: freshDb } = require('../db/database');
const upsert = freshDb.prepare(
`INSERT INTO app_settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`
);
for (const row of instanceKeys) upsert.run(row.key, row.value);
} catch (e: unknown) {
console.error('[Demo Reset] Failed to restore instance API keys:', e instanceof Error ? e.message : e);
}
}
console.log('[Demo Reset] Database restored from baseline');
}
@@ -115,6 +115,35 @@ export class AssignmentsMcp {
return ok({ assignment });
}
@Tool({
name: 'set_leg_transport_mode',
description: 'Set the travel mode of a route leg for a place assignment. Use direction "outgoing" (default) for the common case: the leg leaving this stop toward the next. Use direction "incoming" ONLY when this stop\'s arriving leg originates from something that is not itself a place assignment (e.g. a flight/train booking arrival, or a morning hotel departure) – setting "incoming" on an ordinary place-to-place leg is stored but has no effect on route rendering, because it targets a column that is only read for non-place origins. transport_mode is a route profile key: "driving", "walking", "cycling", or a plugin profile written as "plugin:<pluginId>/<profileId>". Any other value is stored but drawn as a driving route. null clears it so the leg inherits the day default.',
inputSchema: {
tripId: z.number().int().positive(),
assignmentId: z.number().int().positive(),
transport_mode: z.string().nullable().optional().describe('Route profile key (e.g. "driving"), or null to inherit the day default'),
direction: z.enum(['outgoing', 'incoming']).default('outgoing').describe('Which leg to set: "outgoing" (leaving this stop, default) or "incoming" (arriving at it)'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
access: { group: 'places', mode: 'write' },
})
async setLegTransportMode(
{ tripId, assignmentId, transport_mode, direction }: {
tripId: number; assignmentId: number; transport_mode?: string | null; direction?: 'outgoing' | 'incoming';
},
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
if (!this.assignments.getAssignmentForTrip(assignmentId, tripId)) return errorResult('Assignment not found.');
const assignment = direction === 'incoming'
? this.assignments.setIncomingLegTransportMode(assignmentId, transport_mode ?? null)
: this.assignments.setLegTransportMode(assignmentId, transport_mode ?? null);
this.guards.safeBroadcast(tripId, 'assignment:updated', { assignment });
return ok({ assignment });
}
@Tool({
name: 'move_assignment',
description: 'Move a place assignment to a different day.',
+52 -9
View File
@@ -14,8 +14,19 @@ import {
} from './atlas-geo';
import { KNOWN_COUNTRIES } from './known-countries';
import { cityFromAddress } from './city-from-address';
import { transferEndpointIds } from './transfer-endpoints';
import type { FlightEndpointRow } from './transfer-endpoints';
import { haversineKm } from '../common/geo';
/**
* A reservation endpoint plus the two booking columns that decide whether it may
* take part in layover pairing at all (see transfer-endpoints.ts).
*/
type EndpointRow = FlightEndpointRow & {
reservation_type: string | null;
reservation_status: string | null;
};
export type CreateBucketData = {
name: string;
lat?: number | null;
@@ -314,19 +325,33 @@ export class AtlasService {
// via resolvePlaceCountries above and would otherwise be missed (#1366).
// Only 'from'/'to' legs count as actually reached — a 'stop' is an intermediate
// connection/layover (e.g. a plane change) the traveler never really visited.
const endpoints = this.db
const endpointRows = this.db
.prepare(
`
SELECT DISTINCT r.trip_id, e.lat, e.lng
SELECT DISTINCT e.id, e.reservation_id, r.trip_id, e.role, e.code, e.lat, e.lng, e.local_date, e.local_time,
r.type AS reservation_type, r.status AS reservation_status,
CASE e.role
WHEN 'to' THEN COALESCE(r.reservation_end_time, r.reservation_time)
ELSE COALESCE(r.reservation_time, r.reservation_end_time)
END AS fallback_time
FROM reservation_endpoints e
JOIN reservations r ON e.reservation_id = r.id
WHERE r.trip_id IN (${tripIds.map(() => '?').join(',')}) AND e.role IN ('from', 'to')
`,
)
.all(...tripIds) as { trip_id: number; lat: number; lng: number }[];
.all(...tripIds) as EndpointRow[];
// Selecting trip_id makes the DISTINCT per trip, so the same airport comes back once
// per booking. Collapse to one entry per coordinate before resolving countries —
// A layover the traveler never left is only marked 'stop' while both its legs sit in
// one booking. Split over two bookings it is a plain 'to' plus 'from' at the same
// airport, so it has to be recognised from the times instead (#1535). Only flights
// pair: a rental car picked up where a flight landed has its own from/to there and
// must keep contributing its country (#1366).
const transfers = transferEndpointIds(
endpointRows.filter((e) => e.reservation_type === 'flight' && e.reservation_status !== 'cancelled'),
);
const endpoints = endpointRows.filter((e) => !transfers.has(e.id));
// Collapse to one entry per coordinate before resolving countries —
// getCountryFromCoords is a point-in-polygon scan and used to run once per point.
const endpointStatus = new Map<string, { lat: number; lng: number; status: VisitStatus }>();
for (const e of endpoints) {
@@ -962,9 +987,16 @@ export class AtlasService {
// planned place there) was never counted as visited (#1366). Resolve each endpoint
// coordinate to a country and fold it in too.
// Only 'from'/'to' legs count as actually reached — a 'stop' is an intermediate
// connection/layover (e.g. a plane change) the traveler never really visited (#1486).
const endpoints = this.db.all<{ lat: number; lng: number }>(`
SELECT DISTINCT e.lat, e.lng
// connection/layover (e.g. a plane change) the traveler never really visited (#1486),
// and the same layover split over two bookings is recognised from its ground time
// instead, because there it is stored as a legitimate 'to'/'from' pair (#1535).
const endpointRows = this.db.all<EndpointRow>(`
SELECT DISTINCT e.id, e.reservation_id, r.trip_id, e.role, e.code, e.lat, e.lng, e.local_date, e.local_time,
r.type AS reservation_type, r.status AS reservation_status,
CASE e.role
WHEN 'to' THEN COALESCE(r.reservation_end_time, r.reservation_time)
ELSE COALESCE(r.reservation_time, r.reservation_end_time)
END AS fallback_time
FROM reservation_endpoints e
JOIN reservations r ON e.reservation_id = r.id
JOIN trips t ON r.trip_id = t.id
@@ -973,7 +1005,18 @@ export class AtlasService {
AND COALESCE(t.start_date, t.end_date) IS NOT NULL
AND COALESCE(t.start_date, t.end_date) <= date('now')
`, userId, userId);
for (const e of endpoints) {
const transfers = transferEndpointIds(
endpointRows.filter(e => e.reservation_type === 'flight' && e.reservation_status !== 'cancelled')
);
// The DISTINCT no longer collapses per coordinate now that the endpoint id has to be
// in the projection, so collapse here instead: getCountryFromCoords is a
// point-in-polygon scan and must stay at one run per coordinate.
const endpointCoords = new Map<string, { lat: number; lng: number }>();
for (const e of endpointRows) {
if (transfers.has(e.id)) continue;
endpointCoords.set(`${e.lat},${e.lng}`, { lat: e.lat, lng: e.lng });
}
for (const e of endpointCoords.values()) {
const code = getCountryFromCoords(e.lat, e.lng);
if (code) countryCodes.add(code.toUpperCase());
}
+305
View File
@@ -0,0 +1,305 @@
/**
* Layover detection for the two country-deriving Atlas queries.
*
* A plane change is not a visit, so #1486 taught both queries to skip
* reservation_endpoints rows with role='stop'. That only reaches a layover
* stored INSIDE one booking. When the legs of one journey land in the database as
* two separate reservations (an AirTrail import whose flights never chained, or
* legs imported one by one before the join feature existed), the hub is the
* legitimate 'to' of the first booking and the legitimate 'from' of the second,
* and no role filter can tell that apart from a real destination (#1535).
*
* What does tell it apart is the time on the ground: leaving the same airport
* again within 24 h is a connection, not a stay. The window is deliberately the
* one both importers already apply when they merge the legs of one journey
* (orderConnectionChain in airtrail-import.service.ts, kitinerary-mapper.ts), so
* Atlas and the importers agree on what counts as one connection.
*
* Read-side only, hence no migration: both queries derive their countries live on
* every request, so a database that already holds the split bookings heals as
* soon as this ships.
*/
import { haversineKm } from '../common/geo';
/** Ground time up to which an onward departure is a connection rather than a stay. */
export const MAX_LAYOVER_MS = 24 * 3600 * 1000;
/**
* Without clocks only calendar dates are left. Leaving the day after arriving is
* still an overnight connection; a wider gap can't be told apart from a genuine
* stopover in the stored data, so it keeps counting as visited.
*/
export const MAX_CLOCKLESS_DAY_GAP = 1;
/**
* How far from where the arriving leg started the departing leg may land and
* still count as the flight home rather than the next hop. A return is regularly
* booked into the sibling airport of the same city (Charleroi instead of
* Zaventem, Gatwick instead of Heathrow), and that day was spent at the
* destination, not in transit.
*
* A radius is the right shape for that question: the country of a coordinate
* would be both too coarse (Belfast to Dublin to London would read as a return
* and start counting Ireland as visited) and too expensive, since
* getCountryFromCoords scans admin polygons and this runs inside the pairing
* loop. 100 km covers every sibling pair worth naming (CRL/BRU 45 km, LGW/LHR
* 40 km, EWR/JFK 34 km, HND/NRT 60 km) and stays below the distance between two
* cities that each own their traffic, so Amsterdam to Frankfurt to Brussels
* (170 km) keeps pairing as the connection it is.
*/
const RETURN_RADIUS_KM = 100;
/** One reservation_endpoints row, joined to the columns its booking contributes. */
export interface FlightEndpointRow {
id: number;
reservation_id: number;
trip_id: number;
role: string;
code: string | null;
lat: number;
lng: number;
local_date: string | null;
local_time: string | null;
/** The booking's own time column for this side of the leg, used for the date only. */
fallback_time: string | null;
}
/** Local calendar date plus, when the leg carried an instant, the local clock. */
interface Stamp {
date: string | null;
time: string | null;
}
/** Where one end of a booking actually is, for the airports the importer geocoded. */
interface Coords {
lat: number;
lng: number;
}
/** The match keys and positions of one booking's own two ends, shared by all of its endpoints. */
interface BookingKeys {
from: Set<string>;
to: Set<string>;
fromCoords: Coords[];
toCoords: Coords[];
}
interface Endpoint {
row: FlightEndpointRow;
booking: BookingKeys;
/** The buckets this endpoint belongs to, already scoped to its trip. */
keys: string[];
/** Midnight of the leg's local date. */
dayMs: number;
/** The leg's exact instant, null when it carried no clock. */
exactMs: number | null;
/** What a bucket of departures is ordered by: the instant if there is one, else the day. */
sortMs: number;
}
const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
const DATE_PREFIX = /^(\d{4}-\d{2}-\d{2})/;
const CLOCK = /^\d{2}:\d{2}/;
const DAY_MS = 24 * 3600 * 1000;
/**
* How far past the arrival's midnight the sorted departures still have to be
* walked. Two days, because each half of a pair is anchored on its own date: a
* clockless arrival still pairs with a departure at 23:45 the next day, and an
* arrival at 23:50 still pairs with a date-only departure the day after. This
* bounds the scan only, isConnection stays the one place that decides.
*/
const SCAN_WINDOW_MS = 2 * DAY_MS;
/**
* Every key an endpoint can be matched on. Two rows for one airport need not
* agree on their shape: an imported leg carries the IATA/ICAO code, a booking
* typed in by hand may only have been geocoded. Indexing both and pairing on
* either match covers the mixed case, and the coordinate key is rounded because
* two geocodes of the same airport differ in the last decimals.
*/
function endpointKeys(e: FlightEndpointRow): string[] {
const keys: string[] = [];
const code = e.code?.trim();
if (code) keys.push(`c:${code.toUpperCase()}`);
if (Number.isFinite(e.lat) && Number.isFinite(e.lng)) keys.push(`p:${e.lat.toFixed(2)},${e.lng.toFixed(2)}`);
return keys;
}
/**
* An endpoint only has local_date/local_time when its leg carried a usable
* instant; localParts() returns nulls otherwise, which is exactly the date-only
* AirTrail flight that fails to chain in the first place. So fall back to the
* booking's own time column, but take its DATE half only: for an arrival that
* column can hold the departure airport's local clock (airtrail.mapper.ts), which
* says nothing about the time on the ground here.
*/
function stampOf(e: FlightEndpointRow): Stamp {
if (e.local_date && DATE_ONLY.test(e.local_date)) {
const time = e.local_time && CLOCK.test(e.local_time) ? e.local_time.slice(0, 5) : null;
return { date: e.local_date, time };
}
const fallback = e.fallback_time?.match(DATE_PREFIX);
return { date: fallback ? fallback[1] : null, time: null };
}
function utcMs(date: string, time: string | null): number {
return Date.parse(`${date}T${time ?? '00:00'}:00Z`);
}
/**
* The instants the pairing compares, read once per endpoint instead of once per
* pair. null means the leg can never be either half of a pair: without a date
* that parses, neither branch below has anything to measure.
*
* A clock that got past the shape check can still be nonsense, and the resulting
* NaN is kept rather than downgraded to "no clock": a pair where both sides claim
* an instant has always been judged on the instant alone.
*/
function instantsOf(stamp: Stamp): { dayMs: number; exactMs: number | null; sortMs: number } | null {
if (!stamp.date) return null;
const dayMs = utcMs(stamp.date, null);
if (!Number.isFinite(dayMs)) return null;
const exactMs = stamp.time === null ? null : utcMs(stamp.date, stamp.time);
return { dayMs, exactMs, sortMs: exactMs !== null && Number.isFinite(exactMs) ? exactMs : dayMs };
}
/**
* Is the departure the onward leg of the arrival? Both endpoints sit at the same
* airport and therefore in the same timezone, so the naive local stamps compare
* as real instants and their difference is the true time on the ground. That is
* the only reason this is sound, so never reuse it across two airports.
*/
function isConnection(arrival: Endpoint, departure: Endpoint): boolean {
if (arrival.exactMs !== null && departure.exactMs !== null) {
const gap = departure.exactMs - arrival.exactMs;
return Number.isFinite(gap) && gap >= 0 && gap <= MAX_LAYOVER_MS;
}
const days = (departure.dayMs - arrival.dayMs) / DAY_MS;
return days >= 0 && days <= MAX_CLOCKLESS_DAY_GAP;
}
function intersects(a: Set<string>, b: Set<string>): boolean {
for (const key of a) {
if (b.has(key)) return true;
}
return false;
}
/**
* Is the departing booking heading back to where the arriving one started? Then
* the traveler spent the time in between at the destination, and the same call is
* the one orderConnectionChain makes before it joins two legs.
*
* The airport keys answer that for a return into the exact same airport; the
* distance covers the sibling airport of the same city, which the keys can never
* match. Legs the importer had no coordinates for fall back to the key match.
*/
function returnsToOrigin(departure: Endpoint, arrival: Endpoint): boolean {
if (intersects(departure.booking.to, arrival.booking.from)) return true;
for (const landed of departure.booking.toCoords) {
for (const started of arrival.booking.fromCoords) {
if (haversineKm(landed.lat, landed.lng, started.lat, started.lng) <= RETURN_RADIUS_KM) return true;
}
}
return false;
}
/** First entry of a bucket sorted by sortMs that is not earlier than `from`. */
function firstFrom(bucket: Endpoint[], from: number): number {
let low = 0;
let high = bucket.length;
while (low < high) {
const mid = (low + high) >>> 1;
if (bucket[mid].sortMs < from) low = mid + 1;
else high = mid;
}
return low;
}
/**
* The endpoint ids that are only a connection airport, spread over two bookings.
*
* Callers pass flight endpoints of non-cancelled bookings only: a car rental
* picked up right where a flight landed has its own from/to at that airport and
* would otherwise pair with the arrival and swallow the destination country
* (#1366).
*/
export function transferEndpointIds(rows: FlightEndpointRow[]): Set<number> {
const transfers = new Set<number>();
if (rows.length < 2) return transfers;
const landings: Endpoint[] = [];
const departures = new Map<string, Endpoint[]>();
const bookings = new Map<number, BookingKeys>();
for (const row of rows) {
if (row.role !== 'from' && row.role !== 'to') continue;
const keys = endpointKeys(row);
if (keys.length === 0) continue;
let booking = bookings.get(row.reservation_id);
if (!booking) {
booking = { from: new Set<string>(), to: new Set<string>(), fromCoords: [], toCoords: [] };
bookings.set(row.reservation_id, booking);
}
const arriving = row.role === 'to';
const ownKeys = arriving ? booking.to : booking.from;
for (const key of keys) ownKeys.add(key);
if (Number.isFinite(row.lat) && Number.isFinite(row.lng)) {
(arriving ? booking.toCoords : booking.fromCoords).push({ lat: row.lat, lng: row.lng });
}
// A leg without a readable date pairs with nothing, but its keys and its
// position had to be recorded above: they describe the booking either way.
const instants = instantsOf(stampOf(row));
if (!instants) continue;
// Two trips that happen to touch the same airport are two journeys: an
// arrival home from one and a departure on the next is not a layover. The
// trip belongs in the bucket key rather than in a per-pair check, so the
// journeys of an imported flight log are never compared against each other.
const endpoint: Endpoint = { row, booking, keys: keys.map((key) => `${row.trip_id}:${key}`), ...instants };
if (arriving) {
landings.push(endpoint);
continue;
}
for (const key of endpoint.keys) {
const bucket = departures.get(key);
if (bucket) bucket.push(endpoint);
else departures.set(key, [endpoint]);
}
}
// In time order, so an arrival can stop looking once the ground time is out of
// reach. Ten years of flight log imported into one trip put hundreds of
// departures on a home airport, and comparing every arrival against all of them
// cost more than a second on both request paths that call this.
for (const bucket of departures.values()) bucket.sort((a, b) => a.sortMs - b.sortMs);
for (const arrival of landings) {
const until = arrival.dayMs + SCAN_WINDOW_MS;
// An endpoint carrying both a code and a coordinate sits in two buckets, so
// without this the same pair would be judged twice.
const seen = arrival.keys.length > 1 ? new Set<Endpoint>() : null;
for (const key of arrival.keys) {
const left = departures.get(key);
if (!left) continue;
for (let i = firstFrom(left, arrival.dayMs); i < left.length; i++) {
const departure = left[i];
if (departure.sortMs > until) break;
if (seen) {
if (seen.has(departure)) continue;
seen.add(departure);
}
if (departure.row.reservation_id === arrival.row.reservation_id) continue;
if (returnsToOrigin(departure, arrival)) continue;
if (!isConnection(arrival, departure)) continue;
transfers.add(arrival.row.id);
transfers.add(departure.row.id);
}
}
}
return transfers;
}
+7
View File
@@ -11,6 +11,7 @@ const ACTION_LABELS: Record<string, string> = {
'user.mfa_enable': 'enabled MFA',
'user.mfa_disable': 'disabled MFA',
'settings.app_update': 'updated settings',
'settings.api_keys_update': 'updated API keys',
'trip.create': 'created trip',
'trip.delete': 'deleted trip',
'admin.user_role_change': 'changed user role',
@@ -38,6 +39,12 @@ function buildInfoSummary(action: string, details?: Record<string, unknown>): st
if (details.require_mfa !== undefined) parts.push(`mfa=${details.require_mfa}`);
return parts.length ? ` (${parts.join(', ')})` : '';
}
if (action === 'settings.api_keys_update') {
// The names, read straight out of details — the writer puts nothing else in
// there, and a key value must never reach a log line.
const changed = Array.isArray(details.changed) ? details.changed : [];
return changed.length ? ` (${changed.join(', ')})` : '';
}
if (action === 'immich.private_ip_configured') {
return details.resolved_ip ? ` url=${details.immich_url} ip=${details.resolved_ip}` : '';
}
+34 -5
View File
@@ -117,22 +117,51 @@ export class AuthController {
return { success: true };
}
/**
* One row per save that actually changed a key, on all three routes that can
* write these columns (#1939 asked for it: the API keys were the only admin
* setting whose change left no trace, while `settings.app_update` next door
* has always been logged).
*
* Names only. `details` is rendered as raw JSON in the admin audit panel and
* mirrored into the debug log, so a value here would leak the key twice over.
* Nothing is written when nothing changed — the panel saves before every test
* click, and each click would otherwise cost a log line.
*/
private auditApiKeys(userId: number, changed: string[], req: Request): void {
if (!changed.length) return;
this.audit.writeAudit({
userId,
action: 'settings.api_keys_update',
resource: 'api_keys',
ip: getClientIp(req),
details: { changed },
});
}
@Put('me/maps-key')
mapsKey(@CurrentUser() user: User, @Body() body: MapsKeyUpdateDto) {
return this.profile.updateMapsKey(user.id, body.maps_api_key);
mapsKey(@CurrentUser() user: User, @Body() body: MapsKeyUpdateDto, @Req() req: Request) {
// changedKeys is for the audit line, not for the client: destructured off so
// the response body stays what it always was.
const { changedKeys = [], ...result } = this.profile.updateMapsKey(user.id, body.maps_api_key);
this.auditApiKeys(user.id, changedKeys, req);
return result;
}
@Put('me/api-keys')
apiKeys(@CurrentUser() user: User, @Body() body: ApiKeysUpdateDto) {
return this.profile.updateApiKeys(user.id, body);
apiKeys(@CurrentUser() user: User, @Body() body: ApiKeysUpdateDto, @Req() req: Request) {
const { changedKeys = [], ...result } = this.profile.updateApiKeys(user.id, body);
this.auditApiKeys(user.id, changedKeys, req);
return result;
}
@Put('me/settings')
updateSettings(@CurrentUser() user: User, @Body() body: SettingsUpdateDto) {
updateSettings(@CurrentUser() user: User, @Body() body: SettingsUpdateDto, @Req() req: Request) {
const result = this.profile.updateSettings(user.id, body);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
this.auditApiKeys(user.id, result.changedKeys ?? [], req);
return { success: result.success, user: result.user };
}
+7 -7
View File
@@ -14,6 +14,7 @@ import { PermissionsService } from '../permissions/permissions.service';
import { validatePassword } from '../common/passwordPolicy';
import { encryptMfaSecret, decryptMfaSecret } from '../common/crypto/mfaCrypto';
import { decrypt_api_key, maybe_encrypt_api_key, encrypt_api_key } from '../common/crypto/apiKeyCrypto';
import { resolveApiKey } from '../settings/instance-api-keys';
import { EphemeralTokenService } from './ephemeral-token.service';
// Import from sessionManager directly, NOT the ../../mcp barrel: the barrel pulls
// the whole tools fan-out (and via the domain bridges, the Nest services) into
@@ -236,13 +237,12 @@ export class AuthService {
// One directory deeper than the legacy src/services location — the extra
// '../' keeps resolving to the workspace package.json.
const version: string = readEnv().app.appVersion ?? require('../../../package.json').version;
// The operator's key counts too. getMapsKey prefers it over anything in the
// users table, so asking the table alone would report "no key" on an install
// where every lookup works, and the client would hide the Google features it
// is actually able to use.
const hasGoogleKey =
!!readEnv().maps.placesApiKey ||
!!this.db.get("SELECT maps_api_key FROM users WHERE role = 'admin' AND maps_api_key IS NOT NULL AND maps_api_key != '' LIMIT 1");
// Asked through the same resolver the search itself uses, so the client can
// never show Google features that rest on a key this caller does not get —
// nor hide them from a member who does have one (#1939). Unauthenticated the
// question is only about the instance, which is the first two steps of the
// chain; id 0 matches no row.
const hasGoogleKey = !!resolveApiKey(this.db, 'maps_api_key', authenticatedUser?.id ?? 0, readEnv().maps.placesApiKey).key;
const oidcDisplayName = readEnv().oidc.displayName ||
this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'oidc_display_name'")?.value || null;
const oidcConfigured = !!(
+127 -23
View File
@@ -6,6 +6,14 @@ import { decrypt_api_key, maybe_encrypt_api_key } from '../common/crypto/apiKeyC
import { avatarUrl } from '../common/avatarUrl';
import { EMAIL_REGEX, mask_stored_api_key } from './auth.helpers';
import { splitManagedKeys, MANAGED_LOCKED_PROFILE_KEYS } from '../common/managed';
import {
INSTANCE_API_KEY_NAMES,
readInstanceApiKey,
resolveApiKey,
writeInstanceApiKey,
type InstanceApiKeyName,
} from '../settings/instance-api-keys';
import { SEARCH_TEXT_FIELD_MASK } from '../maps/maps.helpers';
import { User } from '../../types';
/**
@@ -43,33 +51,109 @@ export class UserProfileService {
return readEnv().managed.enabled;
}
/** The three key columns plus the role that decides where a save lands. */
private currentKeys(userId: number) {
return this.db.get<Pick<User, 'role' | 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key'>>(
'SELECT role, maps_api_key, openweather_api_key, unsplash_api_key FROM users WHERE id = ?',
userId
);
}
/**
* The value a save is measured against, in cleartext.
*
* For an admin and one of the two instance-wide names that is the instance
* value, because that is what the panel shows them and what the search uses;
* their own column only speaks when no instance value has been set yet.
*/
private storedKeyPlaintext(
name: 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key',
current: Pick<User, 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key'> | undefined,
isAdmin: boolean,
): string {
if (isAdmin && (INSTANCE_API_KEY_NAMES as readonly string[]).includes(name)) {
const instance = readInstanceApiKey(this.db, name as InstanceApiKeyName);
if (instance !== null) return instance;
}
return decrypt_api_key(current?.[name]) ?? '';
}
/**
* Which key names this body actually changes.
*
* Compared on cleartext, never on what is stored: the IV is random, so the
* same key encrypted twice differs every time and a ciphertext comparison
* would report a change on every save. The panel saves before each test click,
* so that difference is the one between an audit trail and a full log.
*
* `skipped` are the names a managed install refuses to write — auditing them
* would claim a change that never happened.
*/
private changedKeyNames(
body: Record<string, unknown>,
current: Pick<User, 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key'> | undefined,
isAdmin: boolean,
skipped: string[] = [],
): string[] {
const norm = (v: unknown) => String(v ?? '').trim();
return (['maps_api_key', 'openweather_api_key', 'unsplash_api_key'] as const).filter(
(name) => body[name] !== undefined && !skipped.includes(name) && norm(body[name]) !== norm(this.storedKeyPlaintext(name, current, isAdmin))
);
}
/**
* Mirror the two instance-wide names into app_settings when an admin saves.
*
* Only an admin: `PUT /me/api-keys` is not admin-gated (the class carries
* JwtAuthGuard alone), so letting any caller write here would hand every
* member the instance credential. A non-admin keeps writing their own column,
* which is still the last step of the resolver.
*/
private mirrorInstanceKeys(body: Record<string, unknown>, isAdmin: boolean): void {
if (!isAdmin) return;
for (const name of INSTANCE_API_KEY_NAMES) {
if (body[name] !== undefined) writeInstanceApiKey(this.db, name, body[name]);
}
}
updateMapsKey(userId: number, key: unknown) {
const maps_api_key = key as string | null | undefined;
if (this.managed) {
return { success: true, maps_api_key: null, managed_keys: ['maps_api_key'] };
return { success: true, maps_api_key: null, managed_keys: ['maps_api_key'], changedKeys: [] };
}
this.db.run(
'UPDATE users SET maps_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
maybe_encrypt_api_key(maps_api_key), userId
);
return { success: true, maps_api_key: mask_stored_api_key(maps_api_key) };
const current = this.currentKeys(userId);
const isAdmin = current?.role === 'admin';
const changedKeys = this.changedKeyNames({ maps_api_key }, current, isAdmin);
this.db.transaction(() => {
this.db.run(
'UPDATE users SET maps_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
maybe_encrypt_api_key(maps_api_key), userId
);
this.mirrorInstanceKeys({ maps_api_key }, isAdmin);
});
return { success: true, maps_api_key: mask_stored_api_key(maps_api_key), changedKeys };
}
updateApiKeys(userId: number, rawBody: unknown) {
const body = rawBody as { maps_api_key?: string; openweather_api_key?: string; unsplash_api_key?: string };
const { blocked } = splitManagedKeys(body, this.managed);
for (const key of blocked) delete body[key as keyof typeof body];
const current = this.db.get<Pick<User, 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key'>>('SELECT maps_api_key, openweather_api_key, unsplash_api_key FROM users WHERE id = ?', userId);
const current = this.currentKeys(userId);
const isAdmin = current?.role === 'admin';
const changedKeys = this.changedKeyNames(body, current, isAdmin, blocked);
// `?? null` instead of the former non-null assertions: a user row deleted
// mid-request must degrade to a 0-row UPDATE, not a TypeError/500.
this.db.run(
'UPDATE users SET maps_api_key = ?, openweather_api_key = ?, unsplash_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
body.maps_api_key !== undefined ? maybe_encrypt_api_key(body.maps_api_key) : current?.maps_api_key ?? null,
body.openweather_api_key !== undefined ? maybe_encrypt_api_key(body.openweather_api_key) : current?.openweather_api_key ?? null,
body.unsplash_api_key !== undefined ? maybe_encrypt_api_key(body.unsplash_api_key) : current?.unsplash_api_key ?? null,
userId
);
this.db.transaction(() => {
// `?? null` instead of the former non-null assertions: a user row deleted
// mid-request must degrade to a 0-row UPDATE, not a TypeError/500.
this.db.run(
'UPDATE users SET maps_api_key = ?, openweather_api_key = ?, unsplash_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
body.maps_api_key !== undefined ? maybe_encrypt_api_key(body.maps_api_key) : current?.maps_api_key ?? null,
body.openweather_api_key !== undefined ? maybe_encrypt_api_key(body.openweather_api_key) : current?.openweather_api_key ?? null,
body.unsplash_api_key !== undefined ? maybe_encrypt_api_key(body.unsplash_api_key) : current?.unsplash_api_key ?? null,
userId
);
this.mirrorInstanceKeys(body, isAdmin);
});
const updated = this.db.get<Pick<User, 'id' | 'username' | 'email' | 'role' | 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key' | 'avatar' | 'mfa_enabled'>>(
'SELECT id, username, email, role, maps_api_key, openweather_api_key, unsplash_api_key, avatar, mfa_enabled FROM users WHERE id = ?',
@@ -81,13 +165,14 @@ export class UserProfileService {
success: true,
...(blocked.length ? { managed_keys: blocked } : {}),
user: { ...u, maps_api_key: mask_stored_api_key(u?.maps_api_key), openweather_api_key: mask_stored_api_key(u?.openweather_api_key), unsplash_api_key: mask_stored_api_key(u?.unsplash_api_key), avatar_url: avatarUrl(updated || {}) },
changedKeys,
};
}
updateSettings(
userId: number,
rawBody: unknown
): { error?: string; status?: number; success?: boolean; user?: Record<string, unknown> } {
): { error?: string; status?: number; success?: boolean; user?: Record<string, unknown>; changedKeys?: string[] } {
const body = rawBody as { maps_api_key?: string; openweather_api_key?: string; unsplash_api_key?: string; username?: string; email?: string };
const { maps_api_key, openweather_api_key, unsplash_api_key, username, email } = body;
@@ -126,10 +211,19 @@ export class UserProfileService {
if (username !== undefined) { updates.push('username = ?'); params.push(username.trim()); }
if (email !== undefined) { updates.push('email = ?'); params.push(email.trim()); }
// Read before the write, so the comparison sees the old value; the role in
// the same row decides whether the two instance-wide names travel with it.
const current = this.currentKeys(userId);
const isAdmin = current?.role === 'admin';
const changedKeys = keyLocked ? [] : this.changedKeyNames(body, current, isAdmin, blocked);
if (updates.length > 0) {
updates.push('updated_at = CURRENT_TIMESTAMP');
params.push(userId);
this.db.run(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`, ...params);
this.db.transaction(() => {
this.db.run(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`, ...params);
if (!keyLocked) this.mirrorInstanceKeys(body, isAdmin);
});
}
const updated = this.db.get<Pick<User, 'id' | 'username' | 'email' | 'role' | 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key' | 'avatar' | 'mfa_enabled'>>(
@@ -142,6 +236,7 @@ export class UserProfileService {
success: true,
...(blocked.length ? { managed_keys: blocked } : {}),
user: { ...u, maps_api_key: mask_stored_api_key(u?.maps_api_key), openweather_api_key: mask_stored_api_key(u?.openweather_api_key), unsplash_api_key: mask_stored_api_key(u?.unsplash_api_key), avatar_url: avatarUrl(updated || {}) },
changedKeys,
};
}
@@ -168,11 +263,15 @@ export class UserProfileService {
};
}
// Maps and Unsplash are read where the search reads them: instance-wide
// first. Showing the admin their own column while every request used another
// value is the confusion #1939 reported. Their column still answers while no
// instance value exists — on that install it is what the resolver picks too.
return {
settings: {
maps_api_key: decrypt_api_key(user.maps_api_key),
maps_api_key: readInstanceApiKey(this.db, 'maps_api_key') ?? decrypt_api_key(user.maps_api_key),
openweather_api_key: decrypt_api_key(user.openweather_api_key),
unsplash_api_key: decrypt_api_key(user.unsplash_api_key),
unsplash_api_key: readInstanceApiKey(this.db, 'unsplash_api_key') ?? decrypt_api_key(user.unsplash_api_key),
},
};
}
@@ -227,7 +326,7 @@ export class UserProfileService {
// -------------------------------------------------------------------------
async validateKeys(userId: number): Promise<{ error?: string; status?: number; maps: boolean; weather: boolean; maps_details: null | { ok: boolean; status: number | null; status_text: string | null; error_message: string | null; error_status: string | null; error_raw: string | null } }> {
const user = this.db.get<Pick<User, 'role' | 'maps_api_key' | 'openweather_api_key'>>('SELECT role, maps_api_key, openweather_api_key FROM users WHERE id = ?', userId);
const user = this.db.get<Pick<User, 'role' | 'openweather_api_key'>>('SELECT role, openweather_api_key FROM users WHERE id = ?', userId);
if (user?.role !== 'admin') return { error: 'Admin access required', status: 403, maps: false, weather: false, maps_details: null };
const result: {
@@ -243,7 +342,10 @@ export class UserProfileService {
};
} = { maps: false, weather: false, maps_details: null };
const maps_api_key = decrypt_api_key(user.maps_api_key);
// The key a search would actually use, not the one in this admin's column:
// testing a value nothing resolves to is how "the panel says the key is
// fine" and "every search 403s" coexisted (#1939).
const { key: maps_api_key } = resolveApiKey(this.db, 'maps_api_key', userId, readEnv().maps.placesApiKey);
if (maps_api_key) {
try {
// Same Referer as maps.service googleFetch — without it, keys with an
@@ -257,7 +359,9 @@ export class UserProfileService {
...(referer ? { Referer: referer } : {}),
'Content-Type': 'application/json',
'X-Goog-Api-Key': maps_api_key,
'X-Goog-FieldMask': 'places.displayName',
// The mask the real search sends. A narrower probe passes on keys
// that are restricted to fewer Places SKUs than TREK asks for.
'X-Goog-FieldMask': SEARCH_TEXT_FIELD_MASK,
},
body: JSON.stringify({ textQuery: 'test' }),
}
@@ -412,6 +412,14 @@ export class CalendarService {
// Multi-leg flight: show the whole route (FRA → BER → HND) on one event.
const stops = [meta.legs[0]?.from, ...meta.legs.map((l: { to?: string }) => l.to)].filter(Boolean);
if (stops.length) desc += `\nRoute: ${stops.join(' → ')}`;
// A segment booked under its own reference gets its own line (#1943): at
// the gate it is that code the airline asks for, not the booking's. No
// leg reference, no extra line, so an existing feed stays byte-identical.
for (const leg of meta.legs as { from?: string; to?: string; confirmation_number?: string }[]) {
if (!leg.confirmation_number) continue;
const segment = [leg.from, leg.to].filter(Boolean).join('-');
desc += `\nConfirmation${segment ? ` ${segment}` : ''}: ${leg.confirmation_number}`;
}
} else if (meta.departure_airport || meta.arrival_airport) {
if (meta.departure_airport) desc += `\nFrom: ${meta.departure_airport}`;
if (meta.arrival_airport) desc += `\nTo: ${meta.arrival_airport}`;
+24
View File
@@ -104,6 +104,30 @@ export class DaysMcp {
return ok({ success: true });
}
@Tool({
name: 'set_day_default_transport_mode',
description: 'Set the whole-day default travel mode for a day. transport_mode is a route profile key: "driving", "walking", "cycling", or a plugin profile written as "plugin:<pluginId>/<profileId>". Any other value is stored but drawn as a driving route. null clears the default. Per-segment leg modes still override this.',
inputSchema: {
tripId: z.number().int().positive(),
dayId: z.number().int().positive(),
transport_mode: z.string().nullable().optional().describe('Route profile key (e.g. "driving"), or null to clear the day default'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
access: { group: 'trips', mode: 'write' },
})
async setDayDefaultTransportMode(
{ tripId, dayId, transport_mode }: { tripId: number; dayId: number; transport_mode?: string | null },
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.days.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
if (!this.days.getDay(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
const day = this.days.setDefaultTransportMode(dayId, transport_mode ?? null);
this.guards.safeBroadcast(tripId, 'day:updated', { day });
return ok({ day });
}
@ResourceTemplate({
name: 'trip-days',
uriTemplate: 'trek://trips/{tripId}/days',
@@ -14,7 +14,7 @@ import { TrekPhotosRepository } from '../photos/trek-photos.repository';
const JP_SELECT = `
gp.id, jep.entry_id, gp.photo_id, gp.caption, jep.sort_order, gp.shared, gp.created_at,
tp.provider, tp.asset_id, tp.owner_id, tp.file_path, tp.thumbnail_path, tp.width, tp.height,
tp.media_type, tp.duration_ms
tp.media_type, tp.duration_ms, tp.taken_at, tp.lat, tp.lng
`;
const JP_JOIN = `journey_entry_photos jep
@@ -24,7 +24,7 @@ const JP_JOIN = `journey_entry_photos jep
const GALLERY_SELECT = `
gp.id, gp.journey_id, gp.photo_id, gp.caption, gp.shared, gp.sort_order, gp.created_at,
tp.provider, tp.asset_id, tp.owner_id, tp.file_path, tp.thumbnail_path, tp.width, tp.height,
tp.media_type, tp.duration_ms
tp.media_type, tp.duration_ms, tp.taken_at, tp.lat, tp.lng
`;
const GALLERY_JOIN = 'journey_photos gp JOIN trek_photos tp ON tp.id = gp.photo_id';
@@ -342,6 +342,10 @@ export class JourneyDomainService {
// (cross-tenant leak). Mirrors the trip-access gate every other trip-scoped
// path enforces.
if (!this.db.canAccessTrip(tripId, userId)) return false;
// And a journey the caller can actually reach. Without this, any logged-in user
// could link a trip of theirs into a stranger's journey and seed entries and
// photos there — the MCP tool has always checked this, the REST route never did.
if (!this.canAccessJourney(journeyId, userId)) return false;
const now = this.ts();
try {
this.db.prepare('INSERT OR IGNORE INTO journey_trips (journey_id, trip_id, added_at) VALUES (?, ?, ?)').run(
@@ -355,8 +359,12 @@ export class JourneyDomainService {
// sync skeleton entries for all places in this trip
this.syncTripPlaces(journeyId, tripId, userId);
// import existing trip photos (Immich/Synology) with sharing settings
this.syncTripPhotos(journeyId, tripId);
// Trip photos are deliberately NOT pulled in any more (#1614). Photos live in
// journeys now: the trip-photo surface lost its UI in 3.1.0, nothing writes to
// it on an install newer than that, and copying rows between the two was what
// let a photo one member had chosen not to share reach a journey at all. The
// table and its (unreferenced) routes stay for one more release rather than
// being dropped in an append-only migration.
this.broadcastJourneyEvent(journeyId, 'journey:trip:synced', { tripId });
return true;
}
@@ -441,29 +449,6 @@ export class JourneyDomainService {
}
}
// import trip_photos into journey gallery when a trip is linked
private syncTripPhotos(journeyId: number, tripId: number) {
const tripPhotos = this.db
.prepare('SELECT tp.photo_id, tp.shared FROM trip_photos tp WHERE tp.trip_id = ?')
.all(tripId) as { photo_id: number; shared: number }[];
if (!tripPhotos.length) return;
const now = this.ts();
const maxOrderRow = this.db
.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE journey_id = ?')
.get(journeyId) as { m: number | null };
let nextOrder = (maxOrderRow?.m ?? -1) + 1;
for (const tp of tripPhotos) {
this.db.prepare(
`
INSERT OR IGNORE INTO journey_photos (journey_id, photo_id, shared, sort_order, created_at)
VALUES (?, ?, ?, ?, ?)
`,
).run(journeyId, tp.photo_id, tp.shared, nextOrder++, now);
}
}
// called when a trip place is created
onPlaceCreated(tripId: number, placeId: number) {
const links = this.db.prepare('SELECT journey_id FROM journey_trips WHERE trip_id = ?').all(tripId) as {
@@ -7,6 +7,8 @@ interface JourneySharePermissions {
share_timeline?: boolean;
share_gallery?: boolean;
share_map?: boolean;
/** Read the journey newest-first, like a blog, rather than in trip order. */
newest_first?: boolean;
}
interface JourneyShareTokenInfo {
@@ -15,6 +17,7 @@ interface JourneyShareTokenInfo {
share_timeline: boolean;
share_gallery: boolean;
share_map: boolean;
newest_first: boolean;
}
/**
@@ -45,18 +48,19 @@ export class JourneyShareService {
share_timeline = true,
share_gallery = true,
share_map = true,
newest_first = false,
} = permissions;
const existing = this.db.prepare('SELECT token FROM journey_share_tokens WHERE journey_id = ?').get(journeyId) as { token: string } | undefined;
if (existing) {
this.db.prepare('UPDATE journey_share_tokens SET share_timeline = ?, share_gallery = ?, share_map = ? WHERE journey_id = ?')
.run(share_timeline ? 1 : 0, share_gallery ? 1 : 0, share_map ? 1 : 0, journeyId);
this.db.prepare('UPDATE journey_share_tokens SET share_timeline = ?, share_gallery = ?, share_map = ?, newest_first = ? WHERE journey_id = ?')
.run(share_timeline ? 1 : 0, share_gallery ? 1 : 0, share_map ? 1 : 0, newest_first ? 1 : 0, journeyId);
return { token: existing.token, created: false };
}
const token = crypto.randomBytes(24).toString('base64url');
this.db.prepare('INSERT INTO journey_share_tokens (journey_id, token, created_by, share_timeline, share_gallery, share_map) VALUES (?, ?, ?, ?, ?, ?)')
.run(journeyId, token, createdBy, share_timeline ? 1 : 0, share_gallery ? 1 : 0, share_map ? 1 : 0);
this.db.prepare('INSERT INTO journey_share_tokens (journey_id, token, created_by, share_timeline, share_gallery, share_map, newest_first) VALUES (?, ?, ?, ?, ?, ?, ?)')
.run(journeyId, token, createdBy, share_timeline ? 1 : 0, share_gallery ? 1 : 0, share_map ? 1 : 0, newest_first ? 1 : 0);
return { token, created: true };
}
@@ -69,6 +73,7 @@ export class JourneyShareService {
share_timeline: !!row.share_timeline,
share_gallery: !!row.share_gallery,
share_map: !!row.share_map,
newest_first: !!row.newest_first,
};
}
@@ -130,7 +135,7 @@ export class JourneyShareService {
const photos = this.db.prepare(`
SELECT gp.id, jep.entry_id, gp.photo_id, gp.caption, jep.sort_order, gp.shared, gp.created_at,
tkp.provider, tkp.asset_id, tkp.owner_id, tkp.file_path, tkp.thumbnail_path, tkp.width, tkp.height,
tkp.media_type, tkp.duration_ms
tkp.media_type, tkp.duration_ms, tkp.taken_at, tkp.lat, tkp.lng
FROM journey_entry_photos jep
JOIN journey_photos gp ON gp.id = jep.journey_photo_id
JOIN trek_photos tkp ON tkp.id = gp.photo_id
@@ -146,7 +151,7 @@ export class JourneyShareService {
const gallery = this.db.prepare(`
SELECT gp.id, gp.journey_id, gp.photo_id, gp.caption, gp.shared, gp.sort_order, gp.created_at,
tkp.provider, tkp.asset_id, tkp.owner_id, tkp.file_path, tkp.thumbnail_path, tkp.width, tkp.height,
tkp.media_type, tkp.duration_ms
tkp.media_type, tkp.duration_ms, tkp.taken_at, tkp.lat, tkp.lng
FROM journey_photos gp
JOIN trek_photos tkp ON tkp.id = gp.photo_id
WHERE gp.journey_id = ?
@@ -183,6 +188,7 @@ export class JourneyShareService {
const projected: Record<string, unknown> = { ...e };
if (!shareMap) { projected.location_lat = null; projected.location_lng = null; }
if (!shareGallery) projected.photos = [];
else if (!shareMap) projected.photos = stripPhotoGps(e.photos);
return projected;
});
} else if (shareMap) {
@@ -208,13 +214,23 @@ export class JourneyShareService {
status: journey.status,
},
entries: publicEntries,
gallery: shareGallery ? gallery : [],
// A photo now carries the coordinates it was taken at, which is a location the
// owner never typed and may not expect to publish. It follows share_map, the
// same switch the entry coordinates follow — otherwise a gallery-only share
// would hand out places the map was deliberately turned off for.
gallery: shareGallery ? (shareMap ? gallery : stripPhotoGps(gallery)) : [],
stats,
permissions: {
share_timeline: shareTimeline,
share_gallery: shareGallery,
share_map: shareMap,
newest_first: !!row.newest_first,
},
};
}
}
/** Drop capture coordinates from a photo list, keeping everything else. */
function stripPhotoGps<T>(photos: T[] | undefined | null): T[] {
return (photos ?? []).map(p => ({ ...(p as Record<string, unknown>), lat: null, lng: null })) as T[];
}
@@ -25,6 +25,7 @@ import type { User } from '../../types';
import { StorageService } from '../storage/storage.service';
import { journeyThumbName } from '../memories/thumbnail.service';
import { JourneyService } from './journey.service';
import { PhotoCaptureBackfillService } from '../memories/photo-capture-backfill.service';
import { AddonGuard } from '../addons/addon.guard';
import { RequireAddon } from '../addons/require-addon.decorator';
import { ADDON_IDS } from '../../addons';
@@ -126,6 +127,7 @@ export class JourneyController {
constructor(
private readonly journey: JourneyService,
private readonly storage: StorageService,
private readonly captureBackfill: PhotoCaptureBackfillService,
) {}
/**
@@ -147,6 +149,16 @@ export class JourneyController {
}
}
// The add call carries only an asset id, so when and where the picture was taken
// are fetched from the provider afterwards rather than trusted from the client
// (#1614). Detached: a slow or unreachable provider must not hold up the add.
private backfillCapture(photos: unknown[], userId: number): void {
const ids = photos
.map(p => (p as { photo_id?: number } | null)?.photo_id)
.filter((id): id is number => typeof id === 'number');
this.captureBackfill.schedule(ids, userId);
}
// ── Static prefix routes (before /:id) ──────────────────────────────────
@Get()
list(@CurrentUser() user: User) {
@@ -226,6 +238,7 @@ export class JourneyController {
if (!results.length) {
throw new HttpException({ error: 'Not allowed' }, 403);
}
this.backfillCapture(results, user.id);
return { photos: results };
}
@@ -239,6 +252,7 @@ export class JourneyController {
const photo = this.journey.addProviderPhoto(Number(entryId), user.id, String(body.provider), String(id), body.caption as string | undefined, pp, mt);
if (photo) added.push(photo);
});
this.backfillCapture(added, user.id);
return { photos: added, added: added.length };
}
if (!body.provider || !body.asset_id) {
@@ -248,6 +262,7 @@ export class JourneyController {
if (!photo) {
throw new HttpException({ error: 'Not allowed or duplicate' }, 403);
}
this.backfillCapture([photo], user.id);
return photo;
}
@@ -332,6 +347,9 @@ export class JourneyController {
if (!photos.length) {
throw new HttpException({ error: 'Not allowed' }, 403);
}
// An uploaded file carries its own EXIF; reading it is what puts the photo on
// the map later. Detached, like the provider branch.
this.backfillCapture(photos, user.id);
return { photos };
}
@@ -390,6 +408,7 @@ export class JourneyController {
const photo = this.journey.addProviderPhotoToGallery(Number(id), user.id, String(body.provider), String(aid), undefined, pp, mt);
if (photo) added.push(photo);
});
this.backfillCapture(added, user.id);
return { photos: added, added: added.length };
}
if (!body.provider || !body.asset_id) {
@@ -399,6 +418,7 @@ export class JourneyController {
if (!photo) {
throw new HttpException({ error: 'Not allowed or duplicate' }, 403);
}
this.backfillCapture([photo], user.id);
return photo;
}
+29 -6
View File
@@ -22,6 +22,17 @@ export function buildUserAgent(instanceUrl: string | undefined): string {
// Computed once at load — getAppUrl() reads only env vars, which don't change at runtime.
export const UA = buildUserAgent(getAppUrl());
/**
* The fields places:searchText is asked for.
*
* Here rather than beside the call because the admin panel's key test has to
* send the same mask: a key restricted to a narrower set of Places SKUs answers
* a one-field probe with 200 and the real search with 403, which is the second
* way to reach the #1939 report ("test button green, searching fails").
*/
export const SEARCH_TEXT_FIELD_MASK =
'places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.websiteUri,places.nationalPhoneNumber,places.types,places.googleMapsUri,places.businessStatus';
// TREK's internal language codes mostly coincide with valid BCP-47 codes, but a
// couple don't: 'br' is Brazilian Portuguese here (BCP-47 'pt-BR'; bare 'br' is
// Breton) and 'gr' is Greek (BCP-47 'el'). Outbound geo APIs (Google Places,
@@ -118,17 +129,29 @@ export function namesOverlap(a: string, b: string): boolean {
}
const GOOGLE_FTID_RE = /^0x[0-9a-f]+:0x[0-9a-f]+$/i;
// The same id inside the `data=` pathname blob, where a resolved share link keeps it:
// /maps/place/<name>/data=!4m6!3m5!1s0x47e66e1f06e2b70f:0x40b82c3688c9460!8m2!3d48.85!4d2.29
const GOOGLE_FTID_DATA_RE = /!1s(0x[0-9a-f]{1,20}:0x[0-9a-f]{1,20})/i;
// Extracts a Google Maps feature id (ftid, 0x..:0x..) from a URL's ?ftid= param.
// Extracts a Google Maps feature id (ftid, 0x..:0x..) from a URL.
// Two shapes carry it. The `?ftid=` query parameter is the old one. A share link that
// resolveGoogleMapsUrl has followed to its final hop carries it instead in the `data=`
// pathname blob, in the same `!1s…!3d…!4d…` run the coordinates come from (#1954) — so
// reading only the query parameter dropped the id for every pasted maps.app.goo.gl link.
// The Places API (New) googleMapsUri is usually a cid-style URL (https://maps.google.com/?cid=NNN)
// with no ftid, so this returns null for most API responses — the precise query_place_id link is
// used instead. It does recover an ftid from a /place/?...&ftid= URL, e.g. a pasted share link
// resolved by resolveGoogleMapsUrl or a Google MyMaps list import.
// with neither, so this still returns null for most API responses — the precise
// query_place_id link is used instead.
export function googleFtidFromMapsUrl(url?: string | null): string | null {
if (!url) return null;
try {
const ftid = new URL(url).searchParams.get('ftid')?.trim();
return ftid && GOOGLE_FTID_RE.test(ftid) ? ftid.toLowerCase() : null;
const parsed = new URL(url);
const ftid = parsed.searchParams.get('ftid')?.trim();
if (ftid && GOOGLE_FTID_RE.test(ftid)) return ftid.toLowerCase();
// Only for a place URL. A /maps/dir/ route carries one !1s per waypoint and the
// first one is the origin, not the place the link is about.
if (!parsed.pathname.includes('/place/')) return null;
const fromPath = GOOGLE_FTID_DATA_RE.exec(parsed.pathname)?.[1];
return fromPath ? fromPath.toLowerCase() : null;
} catch {
return null;
}
+39 -24
View File
@@ -9,13 +9,14 @@ import type {
} from '@trek/shared';
import { readEnv, getAppUrl } from '../../app-config';
import { safeFetchFollow, SsrfBlockedError } from '../../utils/ssrfGuard';
import { decrypt_api_key } from '../common/crypto/apiKeyCrypto';
import { resolveApiKey, type ApiKeySource } from '../settings/instance-api-keys';
// ── Photo cache (disk-backed) ────────────────────────────────────────────────
import { PlacePhotoCacheService } from '../place-photos/place-photo-cache.service';
import { DatabaseService } from '../database/database.service';
import { nominatimFetch, type GeoLane } from '../geo/nominatim.client';
import {
UA,
SEARCH_TEXT_FIELD_MASK,
toApiLang,
googleFtidFromMapsUrl,
buildOsmDetails,
@@ -59,6 +60,18 @@ function placesEndpoint(endpoint: string): string {
return base.replace(/\/+$/, '') + endpoint.slice(PLACES_UPSTREAM.length);
}
/**
* Says which of the three credentials Google rejected, never which value.
*
* The response body Google sends ("The caller does not have permission") is
* identical whichever key was used, so without this line a report of "works for
* the admin, fails for everyone else" cannot be told apart from a genuinely
* broken key.
*/
function logKeyFailure(label: string, status: number, userId: number, source: ApiKeySource | null): void {
console.error(`[Maps] ${label} failed with ${status} userId=${userId} keySource=${source}`);
}
function googleFetch(rawEndpoint: string, label: string, init?: RequestInit): Promise<Response> {
const endpoint = placesEndpoint(rawEndpoint);
googleApiCallCount++;
@@ -548,25 +561,25 @@ export class MapsService {
// ── API key retrieval ──────────────────────────────────────────────────────
getMapsKey(userId: number): string | null {
// When the operator supplies the credential, the database is not asked at
// all. Two reasons, and the second is the load-bearing one: a per-user key
// would route around whatever the operator's endpoint counts, and the key
// columns are readable by any admin of this instance, so a shared key stored
// there would be a key handed out. Unset, this branch never runs.
const operatorKey = readEnv().maps.placesApiKey;
if (operatorKey) return operatorKey;
/**
* The Places credential for this request, and where it came from.
*
* Operator env first: a per-user key would route around whatever the
* operator's endpoint counts, and unset, that branch never runs. Then the
* instance-wide value the admin panel writes, then the caller's own row.
*
* What is deliberately gone is the old third step, "any admin's key" (#1939):
* it read a stranger's credential, which server/CLAUDE.md forbids, and made
* the answer depend on who was asking — the saving admin got their own key,
* everybody else got the lowest-id admin's and a 403 from Google. The source
* is returned so a provider error can say which of the three was used.
*/
resolveMapsKey(userId: number): { key: string | null; source: ApiKeySource | null } {
return resolveApiKey(this.database, 'maps_api_key', userId, readEnv().maps.placesApiKey);
}
const user = this.database.get<{ maps_api_key: string | null }>(
'SELECT maps_api_key FROM users WHERE id = ?',
userId,
);
const user_key = decrypt_api_key(user?.maps_api_key);
if (user_key) return user_key;
const admin = this.database.get<{ maps_api_key: string }>(
"SELECT maps_api_key FROM users WHERE role = 'admin' AND maps_api_key IS NOT NULL AND maps_api_key != '' LIMIT 1",
);
return decrypt_api_key(admin?.maps_api_key) || null;
getMapsKey(userId: number): string | null {
return this.resolveMapsKey(userId).key;
}
// ── Nominatim search ───────────────────────────────────────────────────────
@@ -1417,7 +1430,7 @@ export class MapsService {
lang?: string,
locationBias?: { lat: number; lng: number; radius?: number },
): Promise<{ places: Record<string, unknown>[]; source: string }> {
const apiKey = this.getMapsKey(userId);
const { key: apiKey, source: keySource } = this.resolveMapsKey(userId);
if (!apiKey) {
const places = await this.searchNominatim(query, lang);
@@ -1441,8 +1454,7 @@ export class MapsService {
headers: {
'Content-Type': 'application/json',
'X-Goog-Api-Key': apiKey,
'X-Goog-FieldMask':
'places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.websiteUri,places.nationalPhoneNumber,places.types,places.googleMapsUri,places.businessStatus',
'X-Goog-FieldMask': SEARCH_TEXT_FIELD_MASK,
},
body: JSON.stringify(searchBody),
});
@@ -1450,6 +1462,7 @@ export class MapsService {
const data = (await response.json()) as { places?: GooglePlaceResult[]; error?: { message?: string } };
if (!response.ok) {
logKeyFailure('searchText', response.status, userId, keySource);
const err = new Error(data.error?.message || 'Google Places API error') as Error & { status: number };
err.status = response.status;
throw err;
@@ -1488,7 +1501,7 @@ export class MapsService {
locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } },
sessionToken?: string,
): Promise<{ suggestions: { placeId: string; mainText: string; secondaryText: string }[]; source: string }> {
const apiKey = this.getMapsKey(userId);
const { key: apiKey, source: keySource } = this.resolveMapsKey(userId);
if (!apiKey) {
return this.autocompleteNominatim(input, lang);
@@ -1526,6 +1539,7 @@ export class MapsService {
};
if (!response.ok) {
logKeyFailure('autocomplete', response.status, userId, keySource);
const err = new Error(data.error?.message || 'Google Places Autocomplete error') as Error & { status: number };
err.status = response.status;
throw err;
@@ -1960,6 +1974,7 @@ export class MapsService {
lat: string,
lng: string,
lang?: string,
opts?: { lane?: GeoLane; timeoutMs?: number },
): Promise<{ name: string | null; address: string | null }> {
const params = new URLSearchParams({
lat,
@@ -1969,7 +1984,7 @@ export class MapsService {
zoom: '18',
'accept-language': toApiLang(lang),
});
const response = await nominatimFetch('reverse', params);
const response = await nominatimFetch('reverse', params, opts);
if (!response.ok) return { name: null, address: null };
const data = (await response.json()) as { name?: string; display_name?: string; address?: Record<string, string> };
const addr = data.address || {};
@@ -522,6 +522,11 @@ export class ImmichService {
takenAt: a.fileCreatedAt || a.createdAt,
city: a.exifInfo?.city || null,
country: a.exifInfo?.country || null,
// The search path has always carried these; dropping them here meant a
// photo picked out of an album could never be placed on a map, and the
// distance sort silently had nothing to sort by (#1614).
lat: typeof a.exifInfo?.latitude === 'number' ? a.exifInfo.latitude : null,
lng: typeof a.exifInfo?.longitude === 'number' ? a.exifInfo.longitude : null,
mediaType: a.type === 'VIDEO' ? 'video' : 'image',
}));
return { assets };
+3 -1
View File
@@ -5,6 +5,7 @@ import { ImmichService } from './immich.service';
import { SynologyService } from './synology.service';
import { UnifiedMemoriesService } from './unified-memories.service';
import { PhotoResolverService } from './photo-resolver.service';
import { PhotoCaptureBackfillService } from './photo-capture-backfill.service';
import { ThumbnailService } from './thumbnail.service';
import { TrekPhotoCacheService } from './trek-photo-cache.service';
import { TrekPhotoCacheJob } from './trek-photo-cache.job';
@@ -54,6 +55,7 @@ import { StorageModule } from '../storage/storage.module';
SynologyService,
UnifiedMemoriesService,
PhotoResolverService,
PhotoCaptureBackfillService,
ThumbnailService,
TrekPhotoCacheService,
TrekPhotoCacheJob,
@@ -67,6 +69,6 @@ import { StorageModule } from '../storage/storage.module';
inject: [ImmichPhotoProvider, SynologyPhotoProvider],
},
],
exports: [MemoriesAccessService, PhotoResolverService, ImmichService, SynologyService, PhotoProviderRegistry],
exports: [MemoriesAccessService, PhotoResolverService, PhotoCaptureBackfillService, ImmichService, SynologyService, PhotoProviderRegistry],
})
export class MemoriesModule {}
@@ -0,0 +1,105 @@
import { Injectable } from '@nestjs/common';
import exifr from 'exifr';
import { PhotoResolverService } from './photo-resolver.service';
import { StorageService } from '../storage/storage.service';
import { TrekPhotosRepository } from '../photos/trek-photos.repository';
/**
* Ask the provider when and where a photo was taken, and record it (#1614).
*
* The picker already sees these values, but the add call carries only the asset
* id — widening that contract would have meant trusting the client for something
* the provider can be asked for directly, and would have left the MCP path, the
* album sync and every already-imported photo without them.
*
* Runs detached and never throws: a provider that is slow, unreachable or simply
* does not know must not fail the add the user is waiting on. A photo without
* capture metadata is the normal case, not an error — it just will not appear on
* the map.
*/
@Injectable()
export class PhotoCaptureBackfillService {
constructor(
private readonly resolver: PhotoResolverService,
private readonly photos: TrekPhotosRepository,
private readonly storage: StorageService,
) {}
/** Fire-and-forget for a batch that was just added. */
schedule(trekPhotoIds: number[], userId: number): void {
if (!trekPhotoIds.length) return;
void this.run(trekPhotoIds, userId);
}
/** Awaitable form, so tests do not have to chase a floating promise. */
async run(trekPhotoIds: number[], userId: number): Promise<void> {
for (const id of trekPhotoIds) {
try {
const photo = this.photos.resolve(id);
// A row that already knows both has nothing to gain, and a provider call
// per photo is the expensive part of an album import.
if (!photo || (photo.taken_at && photo.lat != null && photo.lng != null)) continue;
// A local file has no provider to ask — the answer is in its own EXIF, and
// getPhotoInfo would only hand back what the DB row already says.
if (photo.provider === 'local') {
const meta = await this.readLocalExif(photo.file_path);
if (meta) this.photos.recordCaptureMetadata(id, meta);
continue;
}
const info = await this.resolver.getPhotoInfo(id, userId);
if (!info.success) continue;
this.photos.recordCaptureMetadata(id, {
takenAt: info.data.takenAt ?? null,
lat: info.data.lat ?? null,
lng: info.data.lng ?? null,
});
} catch (err) {
console.error(`[Photos] capture backfill failed for ${id}:`, err instanceof Error ? err.message : err);
}
}
}
/**
* EXIF of an uploaded file.
*
* Note for anyone chasing "my phone photo has no location": the client converts
* HEIC before upload and that conversion drops GPS, so an iPhone photo arrives
* here already stripped. Nothing to read is the expected outcome far more often
* than not — hence no logging on the empty case.
*/
private async readLocalExif(
filePath?: string | null,
): Promise<{ takenAt: string | null; lat: number | null; lng: number | null } | null> {
if (!filePath) return null;
// photos.file_path is uploads-relative 'journey/<name>' by every writer;
// anything else reads as a miss (same rule as photo-resolver). Central key
// validation (storage-keys.ts) rejects a name that still carries a path,
// so a stored path can never climb out of the journey category.
if (!filePath.startsWith('journey/')) return null;
const name = filePath.slice('journey/'.length);
type Exif = { DateTimeOriginal?: Date; CreateDate?: Date; latitude?: number; longitude?: number };
let parsed: Exif | null;
try {
parsed = await this.storage.withLocalFile('journey', name, async abs =>
(await exifr.parse(abs, {
pick: ['DateTimeOriginal', 'CreateDate', 'latitude', 'longitude'],
})) as Exif | null,
);
} catch {
// A vanished object, an invalid key, not an image, a truncated upload,
// a video — none of it is an error here.
return null;
}
if (!parsed) return null;
const taken = parsed.DateTimeOriginal ?? parsed.CreateDate ?? null;
const takenAt = taken instanceof Date && !Number.isNaN(taken.getTime()) ? taken.toISOString() : null;
const lat = typeof parsed.latitude === 'number' ? parsed.latitude : null;
const lng = typeof parsed.longitude === 'number' ? parsed.longitude : null;
return takenAt || lat != null ? { takenAt, lat, lng } : null;
}
}
+6 -2
View File
@@ -507,8 +507,10 @@ export class SynologyService {
while (true) {
const params: ApiCallParams = passphrase
? { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, passphrase, offset, limit: pageSize, additional: ['thumbnail'] }
: { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, album_id: Number(albumId), offset, limit: pageSize, additional: ['thumbnail'] };
// 'gps' costs nothing extra on the same call and is the only way a
// photo picked from an album can ever reach the map (#1614).
? { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, passphrase, offset, limit: pageSize, additional: ['thumbnail', 'gps'] }
: { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, album_id: Number(albumId), offset, limit: pageSize, additional: ['thumbnail', 'gps'] };
const result = await this._requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, params);
if (!result.success) return result as ServiceResult<AssetsList>;
const items = result.data.list || [];
@@ -520,6 +522,8 @@ export class SynologyService {
const assets = allItems.map(item => ({
id: String(item.additional?.thumbnail?.cache_key || item.id || ''),
takenAt: item.time ? new Date(item.time * 1000).toISOString() : '',
lat: typeof item.additional?.gps?.latitude === 'number' ? item.additional.gps.latitude : null,
lng: typeof item.additional?.gps?.longitude === 'number' ? item.additional.gps.longitude : null,
})).filter(a => a.id);
return success({ assets, total: assets.length, hasMore: false });
@@ -89,6 +89,36 @@ export class TrekPhotosRepository {
);
}
/**
* Where and when the picture was taken (#1614).
*
* COALESCE throughout, for the same reason recordLocalThumbnail does it: the
* three sources disagree in how much they know. A provider search answers with
* coordinates, the same provider's album listing does not, and a local file
* only gives them up once its EXIF has been read. Whichever arrives first wins,
* and a later, emptier answer must not blank what is already there.
*
* A photo with neither is the normal case, not a failure.
*/
recordCaptureMetadata(
photoId: number,
meta: { takenAt?: string | null; lat?: number | null; lng?: number | null },
): void {
const { takenAt = null, lat = null, lng = null } = meta;
if (takenAt == null && lat == null && lng == null) return;
// Coordinates are stored as a pair or not at all — a lone latitude is not a
// place, and half a pair would put the photo on the null island.
const hasPair = Number.isFinite(lat) && Number.isFinite(lng);
this.db.run(
`UPDATE trek_photos
SET taken_at = COALESCE(taken_at, ?),
lat = COALESCE(lat, ?),
lng = COALESCE(lng, ?)
WHERE id = ?`,
takenAt, hasPair ? lat : null, hasPair ? lng : null, photoId,
);
}
/**
* Drop the row once no trip and no journey references it. Local photos are
* kept: their bytes are ours, and the file would outlive the row.
+6
View File
@@ -254,6 +254,12 @@ export const SEARCH_BIAS_RADIUS_METERS = 2000;
/** Concurrent enrichment lookups — small, to stay friendly to the Maps quota. */
export const ENRICH_CONCURRENCY = 3;
// The free address backfill is one Nominatim request per place on the throttled
// background lane (~1/s), so a normal list costs seconds. Past this it stops being
// a backfill and starts being bulk geocoding, which Nominatim's usage policy asks
// people not to do — so it stops rather than queueing for an hour.
export const ADDRESS_BACKFILL_MAX_PLACES = 250;
/**
* Pick the search result that is the same place as the import: it must be a
+62 -4
View File
@@ -30,6 +30,7 @@ import { StorageService } from '../storage/storage.service';
import {
COORD_DEDUP_TOLERANCE,
ENRICH_CONCURRENCY,
ADDRESS_BACKFILL_MAX_PLACES,
escapeLikePattern,
MAX_LIST_RESPONSE_BYTES,
googleMapsFeatureIdFromItem,
@@ -1007,8 +1008,8 @@ export class PlacesService {
}
});
if (opts?.enrich && opts.userId && created.length) {
void this.enrichImportedPlaces(tripId, opts.userId, created as EnrichablePlace[], opts.lang);
if (created.length) {
void this.enrichImportedList(tripId, created as EnrichablePlace[], opts);
}
return { places: created, listName, skipped };
@@ -1150,8 +1151,8 @@ export class PlacesService {
}
});
if (opts?.enrich && opts.userId && created.length) {
void this.enrichImportedPlaces(tripId, opts.userId, created as EnrichablePlace[], opts.lang);
if (created.length) {
void this.enrichImportedList(tripId, created as EnrichablePlace[], opts);
}
return { places: created, listName, skipped };
@@ -1250,6 +1251,63 @@ export class PlacesService {
}
}
/**
* Everything a just-imported list gets in the background, in one place so the
* Google and Naver call sites cannot drift apart.
*
* The Google pass stays exactly as opt-in and key-gated as before. The address
* backfill behind it needs neither — it is the same free Nominatim reverse
* lookup a single pasted link has always had, which is what made a list import
* come out thinner than the same place added one at a time (#1954).
*/
private async enrichImportedList(tripId: string, places: EnrichablePlace[], opts?: ListImportOptions): Promise<void> {
if (opts?.enrich && opts.userId) {
await this.enrichImportedPlaces(tripId, opts.userId, places, opts.lang);
}
await this.backfillMissingAddresses(tripId, places, opts?.lang);
}
/**
* Fill in the address of imported places that have none, from Nominatim.
*
* Runs on the background lane so it never queues in front of a user's own
* search, writes through COALESCE so it can only fill an empty column — the
* Google pass above may already have written a better one — and pushes each
* row over the websocket so the sidebar fills in without a reload. Never
* throws: one bad lookup must not take down a detached task.
*/
async backfillMissingAddresses(tripId: string, places: EnrichablePlace[], lang?: string): Promise<void> {
try {
const pending = places.filter(p => !p.address && p.lat != null && p.lng != null);
if (!pending.length) return;
if (pending.length > ADDRESS_BACKFILL_MAX_PLACES) {
console.warn(`[Places] address backfill skipped for trip ${tripId}: ${pending.length} places exceeds the ${ADDRESS_BACKFILL_MAX_PLACES} cap`);
return;
}
// Serial on purpose: the background lane throttles to roughly one request a
// second anyway, so concurrency would only build a queue.
for (const place of pending) {
try {
const { address } = await this.maps.reverseGeocode(String(place.lat), String(place.lng), lang, {
lane: 'background',
timeoutMs: 10000,
});
if (!address) continue;
this.dbs.run(
'UPDATE places SET address = COALESCE(address, ?), updated_at = CURRENT_TIMESTAMP WHERE id = ? AND trip_id = ?',
address, place.id, tripId,
);
const updated = this.dbs.getPlaceWithTags(place.id);
if (updated) this.realtime.broadcast(tripId, 'place:updated', { place: updated }, undefined);
} catch (err) {
console.error(`[Places] address backfill failed for place ${place.id}:`, err instanceof Error ? err.message : err);
}
}
} catch (err) {
console.error('[Places] address backfill pass failed:', err instanceof Error ? err.message : err);
}
}
// -------------------------------------------------------------------------
// Search place image (Unsplash)
// -------------------------------------------------------------------------
@@ -90,6 +90,7 @@ function parseId(value: string | string[]): number | null {
const legsSchema = transportLegsInputSchema.optional().describe(
'Per-segment detail of a stopover flight or train: one entry per segment, in route order, exactly ONE FEWER than endpoints[]. '
+ 'Each leg carries its own departure and arrival day + local time (dep_day_id/dep_time, arr_day_id/arr_time) plus airline/flight_number (flights) or train_number/platform (trains). '
+ 'A leg may also carry its own confirmation_number when that segment was issued a separate booking reference; leave it out and the booking-level confirmation_number covers the segment. '
+ "Required for a booking with stopovers: a stop's endpoint stores only the onward departure, so without legs that one time is shown as both the arrival and the departure. "
+ 'Omit it for a direct booking. Blank endpoint times/dates, day_id/end_day_id and reservation_time are derived from the legs; a value that contradicts them is rejected.'
);
@@ -207,7 +208,7 @@ function applyLegs(plan: LegPlan): LegOutcome | { error: string } {
from: leg.from ?? depEp.code ?? depEp.name,
to: leg.to ?? arrEp.code ?? arrEp.name,
};
for (const key of ['airline', 'flight_number', 'train_number', 'platform', 'seat'] as const) {
for (const key of ['airline', 'flight_number', 'train_number', 'platform', 'seat', 'confirmation_number'] as const) {
const value = leg[key];
if (value) entry[key] = value;
}
@@ -570,7 +571,7 @@ export class ReservationsMcp {
@Tool({
name: 'create_transport',
description: 'Create a transport booking (flight, train, car, or cruise) for a trip. Use endpoints[] to record origin/destination and intermediate stops — for flights, set code to the IATA airport code (use search_airports first). For a booking WITH STOPOVERS also pass legs[] (one entry per segment, one fewer than endpoints[]), otherwise every segment inherits the stop time as both its arrival and its departure. Created as pending — confirm with update_transport. Set price to record the cost; it will appear on the booking and in the Budget tab.',
description: 'Create a transport booking (flight, train, car, or cruise) for a trip. Use endpoints[] to record origin/destination and intermediate stops — for flights, set code to the IATA airport code (use search_airports first). For a booking WITH STOPOVERS also pass legs[] (one entry per segment, one fewer than endpoints[]), otherwise every segment inherits the stop time as both its arrival and its departure. The top-level confirmation_number is the booking reference; when a single segment was booked under its own reference, put that one on the leg instead. Created as pending — confirm with update_transport. Set price to record the cost; it will appear on the booking and in the Budget tab.',
inputSchema: {
tripId: z.number().int().positive(),
type: z.enum(['flight', 'train', 'car', 'cruise']),
@@ -678,7 +679,7 @@ export class ReservationsMcp {
@Tool({
name: 'update_transport',
description: 'Update an existing transport booking. Pass endpoints[] to replace the full list of stops (origin, destination, intermediates), and legs[] to write the per-segment times of a stopover booking. Sending legs[] without metadata keeps the stored metadata (departure_airport, airtrail_ids, transit) and only replaces the segments. Use status "confirmed" to confirm.',
description: 'Update an existing transport booking. Pass endpoints[] to replace the full list of stops (origin, destination, intermediates), and legs[] to write the per-segment times of a stopover booking. Sending legs[] without metadata keeps the stored metadata (departure_airport, airtrail_ids, transit) and only replaces the segments. A per-segment booking reference lives on the leg (legs[].confirmation_number); the top-level one stays the booking reference. Use status "confirmed" to confirm.',
inputSchema: {
tripId: z.number().int().positive(),
reservationId: z.number().int().positive(),
@@ -0,0 +1,89 @@
import { DatabaseService } from '../database/database.service';
import { decrypt_api_key, maybe_encrypt_api_key } from '../common/crypto/apiKeyCrypto';
/**
* The third-party keys that belong to the instance rather than to a person.
*
* Google Places and Unsplash bill the install, not the account that pasted the
* key in, so the value is instance configuration — the same argument #1772 made
* for the LLM endpoint (see llm-parse/llm-config.ts: instance-wide, admin-set,
* wins; per-user as the fallback). Both keys used to be resolved out of the
* `users` columns alone, which meant "the key of the admin with the lowest id"
* for everyone else and produced #1939: the admin searched with the key they
* had just saved, every other member searched with a stranger's and got a 403
* from Google.
*
* Stored as an encrypted `app_settings` row under the name below, reusing
* apiKeyCrypto, so the format matches what the users columns already hold and
* a legacy plaintext value still reads back.
*/
export type InstanceApiKeyName = 'maps_api_key' | 'unsplash_api_key';
/** Instance names whose per-user column is still honoured as a last resort. */
export const INSTANCE_API_KEY_NAMES: readonly InstanceApiKeyName[] = ['maps_api_key', 'unsplash_api_key'];
/**
* Where a resolved key came from. Logged beside a provider error so "works for
* the admin, 403 for everyone else" is one line in the log rather than a
* guessing game; the key itself is never logged.
*/
export type ApiKeySource = 'operator-env' | 'instance' | 'user-row';
// Full statements rather than an interpolated column: the name doubles as the
// users column AND the app_settings key, and identifiers only ever come from a
// literal allow-list.
const USER_ROW_SQL: Record<InstanceApiKeyName, string> = {
maps_api_key: 'SELECT maps_api_key FROM users WHERE id = ?',
unsplash_api_key: 'SELECT unsplash_api_key FROM users WHERE id = ?',
};
/** The instance-wide value in cleartext, or null when unset/cleared. */
export function readInstanceApiKey(db: DatabaseService, name: InstanceApiKeyName): string | null {
const row = db.get<{ value: string | null }>('SELECT value FROM app_settings WHERE key = ?', name);
if (!row?.value) return null;
return decrypt_api_key(row.value) || null;
}
/**
* Store the instance-wide value, encrypted. A blank value stores '' rather than
* deleting the row: an admin who clears the field means "this instance has no
* key", and a missing row would let the resolver fall through to whatever old
* value still sits in their own users column.
*/
export function writeInstanceApiKey(db: DatabaseService, name: InstanceApiKeyName, value: unknown): void {
db.run(
`INSERT INTO app_settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
name, maybe_encrypt_api_key(value) ?? ''
);
}
/**
* Resolve a key for one request: operator env → instance-wide → the caller's
* own row.
*
* The caller's own row is last, not second, and no other user's row is ever
* read — falling back to a stranger's key is what server/CLAUDE.md forbids and
* what #1939 reported. It stays as a fallback because `PUT /me/api-keys` has
* always accepted a personal key and dropping it would break the installs where
* each member pays for their own.
*
* `userId` 0 means "nobody is asking" (the unauthenticated app-config read):
* there is no personal key to find, so the chain ends at the instance.
*/
export function resolveApiKey(
db: DatabaseService,
name: InstanceApiKeyName,
userId: number,
operatorKey: string | undefined,
): { key: string | null; source: ApiKeySource | null } {
if (operatorKey) return { key: operatorKey, source: 'operator-env' };
const instance = readInstanceApiKey(db, name);
if (instance) return { key: instance, source: 'instance' };
if (!userId) return { key: null, source: null };
const row = db.get<Record<string, string | null>>(USER_ROW_SQL[name], userId);
const own = decrypt_api_key(row?.[name]) || null;
return own ? { key: own, source: 'user-row' } : { key: null, source: null };
}
+8 -12
View File
@@ -2,7 +2,7 @@ import { Readable } from 'node:stream';
import { v4 as uuidv4 } from 'uuid';
import { Injectable } from '@nestjs/common';
import { safeFetch } from '../../utils/ssrfGuard';
import { decrypt_api_key } from '../common/crypto/apiKeyCrypto';
import { resolveApiKey } from '../settings/instance-api-keys';
import { DatabaseService } from '../database/database.service';
import { RuntimeEnvService } from '../app-config/runtime-env.service';
import { StorageService } from '../storage/storage.service';
@@ -42,7 +42,7 @@ const COVER_EXT_BY_TYPE: Record<string, string> = {
* Unsplash search and cover download.
*
* Its own module rather than a method on trips or places: both call it, and the
* key resolution reads the env AND the users table, so it needs the injected
* key resolution reads the env AND the database, so it needs the injected
* connection either way.
*/
@Injectable()
@@ -56,18 +56,14 @@ export class UnsplashService {
/**
* Resolve an Unsplash access key for a request, in precedence order:
* the `UNSPLASH_ACCESS_KEY` env var (instance-wide override, matching the
* SMTP/OIDC env-over-DB convention) → the caller's own stored key → any
* admin's stored key. Mirrors `getMapsKey`. Returns null when none is set,
* in which case the search falls back to the unauthenticated endpoint.
* SMTP/OIDC env-over-DB convention) → the instance-wide value the admin panel
* saves → the caller's own stored key. Mirrors `resolveMapsKey`, including what
* it dropped: "any admin's stored key" read a stranger's credential and made
* the answer depend on who asked (#1939). Returns null when none is set, in
* which case the search falls back to the unauthenticated endpoint.
*/
getUnsplashKey(userId: number): string | null {
const env_key = this.env.env().integrations.unsplashAccessKey;
if (env_key) return env_key;
const user = this.db.get<{ unsplash_api_key: string | null }>('SELECT unsplash_api_key FROM users WHERE id = ?', userId);
const user_key = decrypt_api_key(user?.unsplash_api_key);
if (user_key) return user_key;
const admin = this.db.get<{ unsplash_api_key: string }>("SELECT unsplash_api_key FROM users WHERE role = 'admin' AND unsplash_api_key IS NOT NULL AND unsplash_api_key != '' LIMIT 1");
return decrypt_api_key(admin?.unsplash_api_key) || null;
return resolveApiKey(this.db, 'unsplash_api_key', userId, this.env.env().integrations.unsplashAccessKey).key;
}
async searchUnsplashPhotos(query: string, perPage = 9, accessKey?: string | null) {
@@ -27,12 +27,14 @@ export class WeatherController {
@Query('lng') lng?: string,
@Query('date') date?: string,
@Query('lang') lang?: string,
/** HH:MM — lets a past date answer for that hour rather than the day (#1614). */
@Query('time') time?: string,
): Promise<WeatherResult> {
if (!lat || !lng) {
throw new HttpException({ error: 'Latitude and longitude are required' }, 400);
}
try {
return await this.weather.get(lat, lng, date, lang ?? 'de');
return await this.weather.get(lat, lng, date, lang ?? 'de', time);
} catch (err: unknown) {
throw toHttp(err, 'Weather error:', 'Error fetching weather data');
}
+55 -10
View File
@@ -1,4 +1,24 @@
// Open-Meteo sits on the request path as a third party: without a deadline a hung
// connection holds the caller until the socket gives up on its own. Every other
// outbound client in the codebase carries one.
const WEATHER_TIMEOUT_MS = 8000;
/**
* Which slot of Open-Meteo's hourly series a HH:MM belongs to.
*
* The archive answers with one entry per hour of the requested day in the local
* timezone, so the hour is the index. Anything unparseable means "no time given",
* which falls back to the daily figures rather than guessing midnight.
*/
function hourIndexFromTime(time?: string): number | null {
if (!time) return null;
const m = /^(\d{1,2}):(\d{2})/.exec(time.trim());
if (!m) return null;
const hour = Number(m[1]);
return Number.isInteger(hour) && hour >= 0 && hour <= 23 ? hour : null;
}
// ── Interfaces ──────────────────────────────────────────────────────────
export interface WeatherResult {
@@ -177,8 +197,9 @@ async function _getWeatherImpl(
lng: string,
date: string | undefined,
lang: string,
time?: string,
): Promise<WeatherResult> {
const ck = cacheKey(lat, lng, date);
const ck = cacheKey(lat, lng, date ? `${date}T${time ?? ''}` : date);
if (date) {
const cached = getCached(ck);
@@ -191,7 +212,7 @@ async function _getWeatherImpl(
// Forecast range (-1 .. +16 days)
if (diffDays >= -1 && diffDays <= 16) {
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}&daily=temperature_2m_max,temperature_2m_min,weathercode&timezone=auto&forecast_days=16`;
const response = await fetch(url);
const response = await fetch(url, { signal: AbortSignal.timeout(WEATHER_TIMEOUT_MS) });
const data = await response.json() as OpenMeteoForecast;
if (!response.ok || data.error) {
@@ -222,14 +243,36 @@ async function _getWeatherImpl(
// Past date: use archive API for the actual date
if (diffDays < -1) {
const dateStr = targetDate.toISOString().slice(0, 10);
const url = `https://archive-api.open-meteo.com/v1/archive?latitude=${lat}&longitude=${lng}&start_date=${dateStr}&end_date=${dateStr}&daily=temperature_2m_max,temperature_2m_min,weathercode,precipitation_sum&timezone=auto`;
const response = await fetch(url);
// With a time in hand, ask for the hourly series too: a journal entry made at
// 14:30 wants the weather of that hour, not the day's high and low averaged
// together, which is what a rainy morning and a bright afternoon collapse to.
const hourParam = hourIndexFromTime(time) != null
? '&hourly=temperature_2m,weathercode'
: '';
const url = `https://archive-api.open-meteo.com/v1/archive?latitude=${lat}&longitude=${lng}&start_date=${dateStr}&end_date=${dateStr}&daily=temperature_2m_max,temperature_2m_min,weathercode,precipitation_sum${hourParam}&timezone=auto`;
const response = await fetch(url, { signal: AbortSignal.timeout(WEATHER_TIMEOUT_MS) });
const data = await response.json() as OpenMeteoForecast;
if (!response.ok || data.error) {
throw new ApiError(response.status || 500, data.reason || 'Open-Meteo Archive API error');
}
const hourIdx = hourIndexFromTime(time);
const hourly = (data as { hourly?: { temperature_2m?: (number | null)[]; weathercode?: (number | null)[] } }).hourly;
if (hourIdx != null && hourly?.temperature_2m?.[hourIdx] != null) {
const temp = hourly.temperature_2m[hourIdx]!;
const code = hourly.weathercode?.[hourIdx] ?? undefined;
const descriptions = lang === 'de' ? WMO_DESCRIPTION_DE : WMO_DESCRIPTION_EN;
const result: WeatherResult = {
temp: Math.round(temp),
main: WMO_MAP[code!] || estimateCondition(temp, 0),
description: descriptions[code!] || '',
type: 'forecast',
};
setCache(ck, result, TTL_CLIMATE_MS);
return result;
}
const daily = data.daily;
if (daily && daily.time && daily.time.length > 0 && daily.temperature_2m_max[0] != null) {
const code = daily.weathercode?.[0];
@@ -264,7 +307,7 @@ async function _getWeatherImpl(
const endStr = endDate.toISOString().slice(0, 10);
const url = `https://archive-api.open-meteo.com/v1/archive?latitude=${lat}&longitude=${lng}&start_date=${startStr}&end_date=${endStr}&daily=temperature_2m_max,temperature_2m_min,precipitation_sum&timezone=auto`;
const response = await fetch(url);
const response = await fetch(url, { signal: AbortSignal.timeout(WEATHER_TIMEOUT_MS) });
const data = await response.json() as OpenMeteoForecast;
if (!response.ok || data.error) {
@@ -317,7 +360,7 @@ async function _getWeatherImpl(
if (cached) return cached;
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}&current=temperature_2m,weathercode&timezone=auto`;
const response = await fetch(url);
const response = await fetch(url, { signal: AbortSignal.timeout(WEATHER_TIMEOUT_MS) });
const data = await response.json() as OpenMeteoForecast;
if (!response.ok || data.error) {
@@ -343,15 +386,17 @@ export async function getWeather(
lng: string,
date: string | undefined,
lang: string,
/** HH:MM of the moment being asked about. Only the archive path can use it. */
time?: string,
): Promise<WeatherResult> {
const ck = cacheKey(lat, lng, date);
const ck = cacheKey(lat, lng, date ? `${date}T${time ?? ''}` : date);
const cached = getCached(ck);
if (cached) return cached;
const inFlightKey = `${ck}:${lang}`;
const existing = inFlight.get(inFlightKey);
if (existing) return existing;
const promise = _getWeatherImpl(lat, lng, date, lang);
const promise = _getWeatherImpl(lat, lng, date, lang, time);
inFlight.set(inFlightKey, promise);
try { return await promise; } finally { inFlight.delete(inFlightKey); }
}
@@ -388,7 +433,7 @@ async function _getDetailedWeatherImpl(
+ `&hourly=temperature_2m,precipitation,weathercode,windspeed_10m,relativehumidity_2m`
+ `&daily=temperature_2m_max,temperature_2m_min,weathercode,precipitation_sum,windspeed_10m_max,sunrise,sunset`
+ `&timezone=auto`;
const response = await fetch(url);
const response = await fetch(url, { signal: AbortSignal.timeout(WEATHER_TIMEOUT_MS) });
const data = await response.json() as OpenMeteoForecast;
if (!response.ok || data.error) {
@@ -451,7 +496,7 @@ async function _getDetailedWeatherImpl(
+ `&daily=temperature_2m_max,temperature_2m_min,weathercode,sunrise,sunset,precipitation_probability_max,precipitation_sum,windspeed_10m_max`
+ `&timezone=auto&start_date=${dateStr}&end_date=${dateStr}`;
const response = await fetch(url);
const response = await fetch(url, { signal: AbortSignal.timeout(WEATHER_TIMEOUT_MS) });
const data = await response.json() as OpenMeteoForecast;
if (!response.ok || data.error) {
+2 -2
View File
@@ -25,8 +25,8 @@ export class WeatherService implements OnModuleInit, OnModuleDestroy {
stopCacheCleanup();
}
get(lat: string, lng: string, date: string | undefined, lang: string): Promise<WeatherResult> {
return getWeather(lat, lng, date, lang) as Promise<WeatherResult>;
get(lat: string, lng: string, date: string | undefined, lang: string, time?: string): Promise<WeatherResult> {
return getWeather(lat, lng, date, lang, time) as Promise<WeatherResult>;
}
getDetailed(lat: string, lng: string, date: string, lang: string): Promise<WeatherResult> {
+5
View File
@@ -398,6 +398,11 @@ export interface TrekPhoto {
media_type?: string | null;
/** Optional video duration in milliseconds. */
duration_ms?: number | null;
/** When the picture was taken, as the provider or its EXIF reported it (#1614). */
taken_at?: string | null;
/** Capture coordinates. Stored as a pair or not at all. */
lat?: number | null;
lng?: number | null;
created_at: string;
}
+33
View File
@@ -184,6 +184,39 @@ describe('Atlas e2e (real auth guard + real service + temp SQLite)', () => {
expect(jp.status).toBe(200);
expect(jp.body.status).toBe('planned');
});
// #1535: the same journey booked as two flights, which is what an AirTrail import
// whose legs never chained leaves behind. The hub is a legitimate 'to'/'from' pair, so
// only the ground time between them says it was a plane change. Own user again.
it('200 stats leaves out a layover country whose two legs are separate bookings', async () => {
const flyerId = createUser(db as never, { username: 'atlas-flyer', email: 'atlas-flyer@test.example' }).user.id;
const iso = (days: number) => new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 10);
const trip = createTrip(db as never, flyerId, {
title: 'New York via Helsinki',
start_date: iso(-20),
end_date: iso(-10),
});
const day = iso(-15);
const insertFlight = db.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, ?, 'flight')");
const insertEndpoint = db.prepare(
'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, lat, lng, code, local_date, local_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
);
const inbound = insertFlight.run(trip.id, 'BRU-HEL').lastInsertRowid;
insertEndpoint.run(inbound, 'from', 0, 'Brussels', 50.9014, 4.4844, 'BRU', day, '07:00');
insertEndpoint.run(inbound, 'to', 1, 'Helsinki', 60.3172, 24.9633, 'HEL', day, '09:30');
const onward = insertFlight.run(trip.id, 'HEL-JFK').lastInsertRowid;
insertEndpoint.run(onward, 'from', 0, 'Helsinki', 60.3172, 24.9633, 'HEL', day, '11:00');
insertEndpoint.run(onward, 'to', 1, 'New York', 40.6413, -73.7781, 'JFK', day, '15:00');
const res = await request(server).get('/api/addons/atlas/stats').set('Cookie', sessionCookie(flyerId));
expect(res.status).toBe(200);
const codes = (res.body.countries as { code: string }[]).map((c) => c.code);
expect(codes).toContain('BE');
expect(codes).toContain('US');
expect(codes).not.toContain('FI');
expect(res.body.stats.totalCountries).toBe(2);
});
// ── /locate (#1115) ────────────────────────────────────────────────────────
describe('GET locate', () => {
it('resolves a coordinate to the country and the region the map can highlight', async () => {
+75
View File
@@ -45,6 +45,7 @@ import { MailerService } from '../../src/nest/notifications/mailer/mailer.servic
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { createUser } from '../helpers/factories';
import { encrypt_api_key } from '../../src/nest/common/crypto/apiKeyCrypto';
import { resetRateLimits } from '../helpers/test-db';
import { AuthModule } from '../../src/nest/auth/auth.module';
import { AuthService } from '../../src/nest/auth/auth.service';
@@ -251,6 +252,80 @@ describe('Auth e2e (real auth guard + real service + real cookie service + temp
expect(res.body.error).toMatch(/password/i);
});
it('PUT /me/api-keys stores the key instance-wide and audits it once (#1939)', async () => {
const admin = createUser(db as never, { username: 'keys-admin', email: 'keys-admin@example.test', role: 'admin' });
const before = auditRows('settings.api_keys_update');
const res = await request(server)
.put('/api/auth/me/api-keys')
.set('Cookie', sessionCookie(admin.user.id))
.send({ maps_api_key: 'e2e-google-key' });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
// Masked in the response and never accompanied by the audit bookkeeping.
expect(res.body.user.maps_api_key).not.toBe('e2e-google-key');
expect(res.body).not.toHaveProperty('changedKeys');
// What every member's search will now resolve to, encrypted at rest.
const stored = db.prepare("SELECT value FROM app_settings WHERE key = 'maps_api_key'").get() as { value: string };
expect(stored.value).toMatch(/^enc:v1:/);
// And the admin panel reads back what the search uses.
const settings = await request(server).get('/api/auth/me/settings').set('Cookie', sessionCookie(admin.user.id));
expect(settings.body.settings.maps_api_key).toBe('e2e-google-key');
expect(auditRows('settings.api_keys_update')).toBe(before + 1);
const row = db
.prepare("SELECT resource, details FROM audit_log WHERE action = 'settings.api_keys_update' ORDER BY id DESC LIMIT 1")
.get() as { resource: string; details: string };
expect(row.resource).toBe('api_keys');
expect(row.details).toContain('maps_api_key');
expect(row.details).not.toContain('e2e-google-key');
// Saving the same value again is not a change, so the log does not grow.
await request(server)
.put('/api/auth/me/api-keys')
.set('Cookie', sessionCookie(admin.user.id))
.send({ maps_api_key: 'e2e-google-key' });
expect(auditRows('settings.api_keys_update')).toBe(before + 1);
}, 10000);
it('GET /app-config answers has_maps_key from the instance row, never from an admin column (#1939)', async () => {
const member = createUser(db as never, { username: 'keys-member', email: 'keys-member@example.test' });
const admin = createUser(db as never, { username: 'keys-cfg-admin', email: 'keys-cfg-admin@example.test', role: 'admin' });
// Seeded here instead of riding on the save above, so running this case on
// its own asserts the same thing.
const setInstanceKey = (value: string | null) => {
if (value === null) {
db.prepare("DELETE FROM app_settings WHERE key = 'maps_api_key'").run();
return;
}
db.prepare(
`INSERT INTO app_settings (key, value) VALUES ('maps_api_key', ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`
).run(encrypt_api_key(value));
};
const hasMapsKey = async (userId: number) => {
const res = await request(server).get('/api/auth/app-config').set('Cookie', sessionCookie(userId));
expect(res.status).toBe(200);
return res.body.has_maps_key;
};
setInstanceKey('instance-configured-key');
db.prepare('UPDATE users SET maps_api_key = NULL WHERE id = ?').run(admin.user.id);
// Instance key, no column anywhere: the member searches with it, so the
// client is told the feature is there.
expect(await hasMapsKey(member.user.id)).toBe(true);
// The reported half of #1939: the key exists only in one admin's column.
// It is not the member's to spend, so they are told there is none instead
// of being offered a Google search that answers 403.
setInstanceKey(null);
db.prepare('UPDATE users SET maps_api_key = ? WHERE id = ?').run(encrypt_api_key('admins-own-key'), admin.user.id);
expect(await hasMapsKey(member.user.id)).toBe(false);
// That same column still counts for the admin themselves.
expect(await hasMapsKey(admin.user.id)).toBe(true);
});
it('POST /logout clears the session cookie', async () => {
const res = await request(server).post('/api/auth/logout');
expect(res.status).toBe(200);
+5 -1
View File
@@ -99,7 +99,11 @@ export function createTrip(
const end = new Date(overrides.end_date);
const tripId = result.lastInsertRowid as number;
let dayNumber = 1;
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
// Step in UTC. A date-only ISO string parses as UTC midnight, but setDate()
// advances local wall-clock time — 23h or 25h across a DST transition — so the
// instant drifts off midnight and toISOString() then reports the wrong day:
// one is dropped in autumn, one repeated in spring. CI runs UTC and never sees it.
for (let d = new Date(start); d <= end; d.setUTCDate(d.getUTCDate() + 1)) {
const dateStr = d.toISOString().slice(0, 10);
db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)').run(tripId, dayNumber++, dateStr);
}
@@ -43,10 +43,17 @@ describe('incoming_leg_transport_mode migration', () => {
//
// >>> Appending a migration? Re-point the undo below at whatever yours
// >>> does. That is the whole maintenance cost of this guard.
// Trailing migrations today (a dev/feat-storage-driver merge landed two
// in one release window, so the rewind spans both):
// version-2 — reservation_day_positions cross-trip cleanup: undone by
// Trailing migrations today (a dev/feat-storage-driver merge landed five
// in one release window, so the rewind spans all of them):
// version-5 — reservation_day_positions cross-trip cleanup: undone by
// re-inserting a mismatched reservation/day pair.
// version-4 — #1939 instance API key promotion: nothing to undo — the
// seeded users are not admins and hold no keys, so the
// replay is a no-op by its own guards.
// version-3 — trek_photos capture metadata (#1614): column-guarded, so
// the replay is a no-op; the from-scratch case above covers it.
// version-2 — journey_share_tokens.newest_first: undone by dropping the
// column and letting the replay put it back.
// version-1 — storage slice-2 trip_files 'files/' prefix strip: undone
// by re-inserting a prefixed row.
const upgraded = new Database(':memory:');
@@ -68,7 +75,15 @@ describe('incoming_leg_transport_mode migration', () => {
insertPosition.run(reservationOnA, dayOnA, 2); // a legitimate one it must leave alone
upgraded.prepare("INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, 'files/aaa.pdf', 'a.pdf')").run(tripA);
upgraded.prepare('UPDATE schema_version SET version = ?').run(version - 2);
const hasNewestFirst = () =>
(upgraded.prepare("SELECT name FROM pragma_table_info('journey_share_tokens')").all() as { name: string }[])
.some(c => c.name === 'newest_first');
expect(hasNewestFirst()).toBe(true);
upgraded.exec('ALTER TABLE journey_share_tokens DROP COLUMN newest_first');
expect(hasNewestFirst()).toBe(false);
upgraded.prepare('UPDATE schema_version SET version = ?').run(version - 5);
runMigrations(upgraded);
@@ -76,6 +91,7 @@ describe('incoming_leg_transport_mode migration', () => {
expect(positions).toEqual([{ day_id: Number(dayOnA) }]);
const files = upgraded.prepare('SELECT filename FROM trip_files').all() as { filename: string }[];
expect(files.map(r => r.filename)).toEqual(['aaa.pdf']);
expect(hasNewestFirst()).toBe(true);
expect(upgraded.prepare('SELECT version FROM schema_version').get()).toEqual({ version });
upgraded.close();
});

Some files were not shown because too many files have changed in this diff Show More