Reorder buttons appeared broken because the server ORDER BY put entry_time
before sort_order, so entries synced from trip places with differing times
would always sort by time regardless of sort_order writes. The client store
mirrored the same comparator, making even the optimistic update invisible.
- Change ORDER BY to (entry_date, sort_order, id) in getJourneyFull and listEntries
- Fix syncTripPlaces and onPlaceCreated to assign MAX+1 sort_order per day instead of day_number/0
- Update client store comparator to match
- Add DB migration to backfill sort_order using old effective key (entry_time, id) so existing journeys retain their visual order
- Add tests: JOURNEY-SVC-089–093, FE-STORE-JOURNEY-018–019
Closes#846
window.open with noreferrer returns null, which triggered the popup-blocked download fallback in addition to the new-tab open. Use a target=_blank anchor click instead.
OIDC: when OIDC_DISCOVERY_URL is explicitly set, trust the discovery
doc's issuer for id_token comparison instead of rejecting a path
mismatch as an error. Authentik (and similar realm-path providers)
return a canonical issuer like /application/o/<slug>/ that differs
from the operator's base OIDC_ISSUER. Strict equality blocked login
in 3.x despite working in v2. Default discovery (no custom URL) keeps
the strict check. Adds OIDC-SVC-037/038/039.
UI: ConfirmDialog and CopyTripDialog lacked the --bottom-nav-h
paddingBottom offset that other overlays already use. On mobile portrait
the action buttons were hidden behind the sticky bottom nav bar.
Closes#843Closes#844
Re-adds the share_map permission toggle to the journey share settings UI so
owners can control whether the map is visible on the public share page.
Fixes horizontal scrollbar on the public journey page caused by decorative
hero circles with negative offsets overflowing the viewport.
Map permission is always enabled on new links (share always includes map).
Removed the toggle from the share settings UI since the map is now always
part of the combined timeline+map view with no standalone value in toggling it.
Desktop entry cards on the public share page now open MobileEntryView on click,
matching the mobile behaviour added in #826.
Replaces the old model where journey_photos was keyed per-entry with a
per-journey gallery table (one row per unique photo per journey) and a new
junction table journey_entry_photos that links gallery photos to entries.
Key changes:
- Migration 121: renames old journey_photos to journey_photos_old, creates the
new gallery table + junction table, backfills both from existing data, drops
the backup, removes synthetic 'Gallery' / '[Trip Photos]' wrapper entries
- journeyService: rewrites photo helpers (JP_SELECT/JOIN now joins via
journey_entry_photos → journey_photos → trek_photos); adds uploadGalleryPhotos,
addProviderPhotoToGallery, unlinkPhotoFromEntry, deleteGalleryPhoto; simplifies
deletePhoto and linkPhotoToEntry against the new schema; syncTripPhotos inserts
directly into the gallery instead of a wrapper entry
- journeyShareService: updates public photo and asset validation queries to join
through the gallery table instead of entry_id; getPublicJourney now returns a
dedicated gallery array alongside per-entry photos
- journey routes: adds gallery upload, provider-photo, and delete endpoints
(POST/DELETE /:id/gallery/*); adds unlink-from-entry route
(DELETE /entries/:entryId/photos/:journeyPhotoId); updates link-photo to
accept journey_photo_id with a backwards-compat photo_id alias
- types: adds GalleryPhoto interface
- client api: adds uploadGalleryPhotos, addProviderPhotosToGallery, unlinkPhoto,
deleteGalleryPhoto; updates linkPhoto param name to journeyPhotoId
- journeyStore: adds GalleryPhoto type, gallery field on JourneyDetail,
uploadGalleryPhotos / unlinkPhoto / deleteGalleryPhoto store actions
- JourneyDetailPage + tests: updated to work with the new gallery model
- #828: exclude 'map' from availableViews on mobile; MobileMapTimeline already
shows combined map+timeline so the standalone map tab is redundant
- #827: cap timeline feed column at xl:max-w-[50%] on ≥1280px viewports so the
map aside is not dwarfed on wide monitors; applies to both desktop two-column
layouts (JourneyPublicPage)
- #826: wire MobileMapTimeline onEntryClick to setViewingEntry; render
MobileEntryView with readOnly + public photo URL builder so photos load via
the share token endpoint; add publicPhotoUrl prop to MobileEntryView so
photo URLs are routable for both authenticated and public-share contexts
Fix#802: ProviderPicker modal now uses dvh-based max-height, items-end
on mobile (bottom-sheet), flex-shrink-0 on all fixed sections, min-h-0
on the scrollable grid, and env(safe-area-inset-bottom) padding so the
Add button is always reachable above the iOS home indicator.
Fix#819: Gallery view now deduplicates photos by photo_id (underlying
trek_photos.id) so a photo linked from Gallery into an activity no longer
appears twice. Gallery delete cascades to all copies. EntryEditor From
Gallery grid and photo count also deduplicated. Server photo_count uses
COUNT(DISTINCT photo_id). Preserves #729 guarantee (removing from an
activity does not delete the Gallery copy).
- 'Remove share link' → 'Delete link' (now uses share.deleteLink i18n key)
- FE-PAGE-PUBLICJOURNEY-009/012: map tab no longer exists in desktop two-column
layout; map is always rendered in the sidebar — tests updated to verify the
journey-map testid is present without requiring a tab click
- Defer activeIndex updates until scrolling settles (150ms debounce)
instead of updating every RAF — mid-swipe card resize (240→320px)
caused layout reflow on every frame, which is the main stutter source
- Switch scrollSnapType from 'proximity' to 'mandatory' for reliable
browser-native snapping without needing a JS re-center pass
- Remove scroll-smooth CSS class (conflicts with mandatory snap)
- Remove the post-settle scrollIntoView call (mandatory snap handles it)
- Drop the now-unused activeIndexRef
Closes#818
- Track dirty state (title/subtitle changed from original)
- Intercept X button, backdrop click, and Cancel with handleClose
- Show ConfirmDialog when dirty; proceed with onClose only on confirm
- Add common.discardChanges and common.discard keys to all 15 locales
- sidebarMapItems now derives dayIdx from all timeline dates (not just
located-entry dates), so markers stay color-aligned with day headers
even when some days have no location
- scroll-sync no longer calls highlightMarker for unlocated entries,
preventing the map from clearing or misfiring when the scroll winner
has no corresponding marker
- same dayIdx fix applied to JourneyPublicPage desktop two-column view
The top bar still blocked the trip planner's top nav on mobile even
after #808's padding trick — nav layouts that position their own
sticky headers were ignoring the --offline-banner-h offset, and the
bar looked alarming for what is usually a 2s blip.
Redesign as a small floating pill anchored bottom-center, hovering
above the mobile bottom nav (calc(var(--bottom-nav-h) + 16px)). No
layout shift anywhere, nothing ever covers the nav, and the pill
looks like a passing status chip rather than an error banner.
Reverts the body padding-top / navbar top offset introduced in #808
since they're no longer needed with the pill positioning.
The dark-mode toggle kicked off a 360ms setTimeout that removed a
CSS class via 'document.documentElement'. In vitest the document
was torn down before the timer fired, triggering an unhandled
ReferenceError that flipped the whole run to a non-zero exit even
though every test passed.
Track the handle in a ref and clearTimeout on unmount (and before
scheduling a new one).
formatDate() in both JourneyDetailPage and JourneyPublicPage passed
undefined/'en' as the locale to toLocaleDateString, so weekday/month
names always followed the browser's language instead of the app's
selected UI language. Thread the selected locale through from
useTranslation() in both pages.
Public view still falls back to 'en' when no settings locale is
available (shared links can be opened by anyone).
OfflineBanner was fixed at top:0 but the rest of the page had no
idea it was visible, so on mobile (and the desktop nav on wider
screens) the banner sat on top of the header content.
When the banner is visible it now sets --offline-banner-h on <html>;
body reserves that space via padding-top, and the desktop fixed
Navbar shifts its top by the same amount. When back online the var
is removed and everything snaps back.
Mixed 'peilingen' (titles/tabs) with 'poll/polls' (everywhere else).
Normalised to 'polls' per reporter's preference — more common in
modern Dutch usage anyway.
PhotoProvidersSection:
- Replace raw <input type=checkbox> with TREK's ToggleSwitch so the
'spiegeln zu Immich'-style options match the rest of the app.
- Wrap action row in flex-wrap so the connected/disconnected badge
drops to its own line on mobile instead of clipping.
- Add a short 'Test' translation (memories.testShort) shown on mobile
in place of 'Test connection' — 14 languages kept in sync.
ToggleSwitch:
- Explicit type='button' (never a form submitter), minWidth + flex-
shrink:0 so the toggle doesn't get squished next to long labels,
padding:0 so no inherited UA margin warps the inner circle.
MapSettingsTab:
- 'Mapbox' instead of 'Mapbox GL' on narrow screens — the provider
card is too cramped on mobile for the full name.
- Drop the 'Experimental' badge on mobile entirely; it overlapped
the title at that width. Still shown on >=sm.
DisplaySettingsTab:
- Time format buttons show just '24h' / '12h' on mobile; the '(14:30)'
/ '(2:30 PM)' hint stays on >=sm. Test updated to match the role
query since the label is now split across nodes.
After moving Save/Cancel into the Modal's sticky footer prop, the
button no longer lives inside the <form> element, so walking up via
closest('form') returns null. Query the form directly via
document.querySelector('form') — same semantics, just doesn't assume
the button is a descendant of the form.
When a user adds a new packing category, the first item is seeded
with name '...' because the server rejects empty names. That string
was rendered as a real value in the input, forcing users to delete
the dots before typing. Now we detect the sentinel, show it as a
faint placeholder in the display span, and start the edit input
empty (with '...' as the HTML placeholder).
Status and category chips collided with the reservation title on
narrow viewports because the header was a single-line flex with
inline chips of natural width. flexWrap on the outer row plus the
inner chip group lets the title+actions drop to a second row when
content overflows, so the chips and the title never overlap.
TransportModal + ReservationModal: move Save/Cancel into the Modal's
footer slot so they stay visible on long forms (same fix as
PlaceFormModal in this PR).
DayDetailPanel: the floating day info panel was anchored at a fixed
bottom: 96px which didn't account for safe-area-inset-bottom, causing
it to overlap the bottom nav on devices with a home indicator. Use
calc(var(--bottom-nav-h) + 20px) so it always floats above the tab
bar with a safe gap.
Two fixes in Modal.tsx:
- Replace 100vh with 100dvh so iOS Safari PWA respects the actual
visible viewport. Explicitly subtract --bottom-nav-h on mobile so
the modal never extends behind the tab bar.
- overflow-hidden on the container so the footer's bottom corners
inherit rounded-2xl.
- flex-shrink-0 on header and footer + min-h-0 on the body so the
body shrinks and scrolls while the footer stays put.
One fix in PlaceFormModal.tsx:
- Save/cancel were rendered inside the scrollable body. Moved them
into the Modal's footer slot.
Introduces CopyTripDialog — a two-section modal that appears before the
copy action and lists what is carried over (days, places, budget items,
packing lists, TODOs, notes) and what is intentionally skipped
(collaborators, collab data, files, share tokens). Addresses the UX gap
raised in #786.
With 30+ bucket list entries the panel expanded to near-full viewport
width, elongating the Stats tab, hiding overflow entries, and covering
the Leaflet zoom controls. Measure the stats content width via
ResizeObserver and use it as maxWidth on the horizontal bucket row so
scroll activates exactly when entries exceed the stats panel width.
Also fixes the ResizeObserver test mock to use a class (matching the
IntersectionObserver pattern) so the instance methods are accessible.
Closes#787
When hotel_place_id is cleared in the modal, also clear the location
field that was auto-filled from the place. Location is hidden for hotel
type so users had no way to remove the stale address after unlinking.
When a reservation was saved, only setShowReservationModal(false) was
called. The modal's useEffect watches [reservation, isOpen, ...], so
flipping isOpen to false re-ran the effect with the stale editingReservation
(old assignment_id), resetting the form to the pre-edit state during the
closing animation. Users perceived this as the value reverting after save.
Calling setEditingReservation(null) immediately after the close mirrors
the existing onClose handler and prevents the stale-prop form reset.
Hotel reservations store their date range in day_accommodations rather
than on reservation_time, so the card date block never rendered. Pull
accommodation_start_day_id / accommodation_end_day_id from the SQL join
and surface them on the card.
Also apply Maurice's badge-pill pattern (day name + localized date pill)
to the day-range display, consistent with the modal day selectors.
Without these args, the Wikimedia fallback (used when no Google Maps key
is configured) silently skips the fetch because lat/lng are NaN. The plan
view (PlaceAvatar/photoService) already passes all three; this aligns the
PDF path with the same behaviour.
fetchPlacePhotos only checked google_place_id, skipping places that
only have osm_id (e.g. those added via MCP). Mirror PlaceAvatar logic
by falling back to osm_id in both the filter and the photo fetch call.
If a trip has no dates set but a day has a custom title, the
dropdown showed only the title with no context. Fall back to
'Day N' as the badge so users can still tell which day it is.
Day selectors in the Transport, Reservation and Hotel-Day-Range
modals only showed the renamed day title once a day had a custom
name — hiding the actual date. Added an optional badge prop to
CustomSelect, rendered as a pill next to the label, and wired the
date badge onto all affected dropdowns. FileManager day section
headers got the same pill for consistency.
Also translated transport.addTransport and transport.modalTitle.*
in all 13 non-English language files; the keys existed but still
carried the English source string.
MobileMapTimeline received the raw entries array, bypassing the
synthetic-container filter applied to timelineEntries. On screens
below the lg breakpoint (<1024px) the [Trip Photos] sync container
leaked back into the combined map+timeline view.
Fixes#773: isValidBackupFilename regex anchored to ^backup- rejected all
auto-backup-* filenames, causing 400 on download/restore/delete. Broadened
to ^(?:auto-)?backup-.
Fixes#774: three regressions in the trip Files tab —
- openFile import shadowed by a local function of the same name inside
FileManager; PDF preview modal was calling the local with a URL string,
corrupting state and crashing on the second click (mime_type read on
undefined). Fixed by aliasing the import as openFileUrl.
- GET /:id/download used a bespoke authenticateDownload that checked only
Bearer header and ?token= query param, ignoring the trek_session cookie.
After the JWT-to-cookie migration the client sends cookies only, so every
download silently 401-ed. Extended authenticateDownload to accept req and
check cookie → Bearer → query token in priority order.
- files.download and files.openError translation keys were missing from all
15 locale files; t() was returning the raw key as a truthy string,
defeating the || 'Download' fallback.
Migration 107 only rewrote image_url rows matching /places/%/photos/%; URLs using
the /place-photos/ or /places/<opaque> paths survived the upgrade and were passed
verbatim to the Places API, producing a malformed request whose empty/HTML response
body threw SyntaxError before detailsRes.ok was checked. The resulting rejection was
leaked by placePhotoCache.setInFlight via an unhandled .finally() chain, triggering
Node 22's default unhandledRejection=throw and terminating the process.
- placePhotoCache: add .catch() after .finally() to prevent unhandled rejection crash
- mapsService: reject URL-shaped placeIds early; read response as text before JSON.parse
- migrations: add migration to rewrite remaining googleusercontent/places.googleapis URLs
- MapView/MapViewGL: prefer stable proxy URL form of image_url before google_place_id
Fixes#770
- Remove NOT NULL constraint on day_accommodations.place_id (migration)
and change ON DELETE CASCADE → SET NULL so deleting a place no longer
cascades to the accommodation row
- Switch listAccommodations / getAccommodationWithPlace to LEFT JOIN so
accommodations without a linked place are visible to the modal
- Relax create/update guards in reservationService to only require
start_day_id + end_day_id, not place_id; place_id remains optional
- Client save guard now sends create_accommodation whenever FROM/TO days
are set, regardless of whether a hotel place was selected
- Add re-hydration useEffect in ReservationModal to back-fill hotel
fields from the accommodations prop when it arrives after modal opens
(race between isOpen and the tripAccommodations fetch)
- Fix demo-seed TDZ crash: move db Proxy declaration before DEMO_MODE
block so circular require in demo-reset resolves correctly
- Sidebar accommodation badge falls back to reservation title when
place_name is null; click/cursor disabled for placeless accommodations
- listAccommodations now joins reservations to expose reservation_title
ReservationOverlay was Leaflet-only: react-leaflet components, L.divIcon,
panes, useMap/useMapEvents. When the user switched the planner map to
Mapbox GL, the entire feature disappeared — no polylines, no endpoint
badges, no clickable IATA labels.
Add a matching overlay for the Mapbox renderer:
- New reservationsMapbox.ts with an imperative `ReservationMapboxOverlay`
class — mapbox-gl is imperative, so a React component wrapper would
fight its own lifecycle every render. The manager owns one GeoJSON
source + line layer for the arcs, one HTML `mapboxgl.Marker` per
endpoint badge, and one per flight stats label. It cleans itself up
when the map is rebuilt (style/token/3d toggle) or unmounted.
- Geometry helpers (great-circle arc, antimeridian split, haversine,
tz-aware duration math, label formatting) are copied from the Leaflet
overlay so both renderers produce the same lines. Great-circle is
useful even on the Mapbox globe because the mercator projection mode
still draws the short-way line, and the antimeridian split prevents
a NYC↔Tokyo flight from wrapping halfway around the planet.
- Flights / cruises get geodesic arcs; trains / cars get straight
lines. All four types get clickable endpoint badges with the
matching lucide icon; only flights render the rotating mid-arc stats
label (IATA → IATA · distance · duration) — same rule as the Leaflet
overlay.
- The stats label's rotation is recomputed on every `render` event by
projecting two points straddling the arc midpoint, which keeps it
parallel to the arc as the camera rotates/zooms on the globe.
- Visibility thresholds mirror the Leaflet overlay (per-type min pixel
distance before a line / endpoint label is worth drawing).
- MapViewGL now accepts the `reservations`, `visibleConnectionIds`,
`showReservationStats`, `onReservationClick` props that the Leaflet
MapView already took. `visibleConnectionIds` is honoured the same way
— the per-booking toggle in DayPlanSidebar controls which routes
appear, so switching the renderer doesn't lose that UX.
- Added a `mapReady` gate so the overlay can only add its source/layer
once the map's `load` handler has attached the other trip sources;
the gate resets on every style rebuild.