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
@@ -15,6 +15,7 @@
|
||||
*.sh text eol=lf
|
||||
# Binary files — no line ending conversion
|
||||
*.png binary
|
||||
*.webp binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
|
||||
@@ -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">
|
||||
|
||||
· <a href="#docker-compose-production">Docker Compose</a> · <a href="#helm-kubernetes">Helm / Kubernetes</a> · <a href="#install-as-app-pwa">Install as PWA</a> · <a href="#reverse-proxy">Reverse Proxy</a> ·
|
||||
<a href="#docker-compose-production">Docker Compose</a> · <a href="#helm-kubernetes">Helm / Kubernetes</a> · <a href="#install-as-app-pwa">Install as PWA</a> · <a href="#reverse-proxy">Reverse Proxy</a>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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('<img src=x onerror="alert(1)">');
|
||||
});
|
||||
// #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+</);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ''
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
|
||||
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 205 KiB |
|
Before Width: | Height: | Size: 583 KiB |
|
Before Width: | Height: | Size: 306 KiB |
|
Before Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 401 KiB |
|
After Width: | Height: | Size: 413 KiB |
|
After Width: | Height: | Size: 302 KiB |
|
Before Width: | Height: | Size: 2.5 MiB |
|
Before Width: | Height: | Size: 276 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 32 KiB |
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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/%'");
|
||||
},
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}` : '';
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = !!(
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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}¤t=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) {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||