mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
ac42cc941b684f9b7bec3c0583dfd095c9372da0
918 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
62bc7ff8fd |
Jump to today, map-app links, drag to day, PDF landing time (#1891)
* Open a running trip on today Asked for in discussion #1567: during a trip, opening the planner starts at day 1 and you scroll to find out what is planned for today. The mobile shell has done this since the plan tab was built - it seeds the active day with today when the trip is running. The desktop day plan never did, so the same trip behaved differently depending on the screen you opened it on. It now selects today's day, expands it and scrolls it into view, once per mount and only while nothing is selected yet, so a deep link or a day picked before switching tabs still wins. The date comparison moved into one helper both sides use. It is deliberately local rather than toISOString(): 08:00 in Tokyo is still yesterday in UTC, and a trip day is a calendar day where the traveller is standing. Also fixes a note losing its colour when dragged to another day. A move is a delete plus a create, and the create was still sending the three fields notes had before colours existed. * Open a collection place in a map app Asked for in discussion #1455: a saved place is somewhere you intend to go, but the only way to get directions to one was to add it to a trip first, where the button has been for a while. The collection detail gets the same picker off the same helper: Google Maps, Waze, Apple Maps and OpenStreetMap, filtered by what the place can actually be located with. A place with coordinates offers all of them; a place typed in by hand with just a name and an address still offers the two that take a search string, and a place with neither shows no button at all. It sits on the meta line beside the address, quieter than the link chips below it, because it is an action on the place rather than a piece of it. * Drag a place off the map onto a day Asked for in discussion #891: assigning a place means finding it again in the sidebar, even though it is right there under the cursor on the map. The drop half already existed and did not need touching. Every day, every row and every gap between rows in the day plan reads a placeId and calls onAssignToDay with the position it was dropped at, complete with the toast and the undo entry. What was missing was a second place to pick a place UP from - the only one was the row in the places sidebar. Markers become that second source. Both renderers build their markers as real DOM nodes, so one helper covers Leaflet and the GL variants alike, and it sets the same two channels the sidebar row does: dataTransfer for the drop, and window.__dragData because browsers hide dataTransfer while the pointer is merely hovering. The day plan cannot tell the two apart. Not offered on touch: HTML5 drag is a pointer feature, and on a phone the day plan is not on screen next to the map anyway. * Show the landing time in the PDF Asked for in discussion #1310: the export prints a flight's departure and nothing else, so a reader cannot tell how much of the day is left for anything after it. The day plan has drawn both times as "14:30 - 16:45" for a while; the export never got the rule. Now it does, for any transport that begins and ends on the same day - a train and a ferry read the same way a flight does. Across a span it stays as it was: the arrival belongs to the arrival day, which already prints it as its own time, and repeating it on the departure day would put tomorrow's clock next to today's departure. |
||
|
|
ce37e8e97c |
Rich formatting and colours for notes (#1889)
* Rich formatting and colours for notes Asked for in discussion #1629: notes should carry formatting, links and colour, so a warning reads differently from a recommendation. Notes have rendered Markdown for a long time. The problem was that the only way to write it was a bare textarea, so unless you already knew the syntax there was nothing to tell you it existed. Both note fields get a formatting bar - bold, italic, strikethrough, code, link, lists, quote - which edits Markdown rather than replacing it with a rich-text model, so the stored value stays plain text that the PDF, the plugin API and every existing note keep reading unchanged. Links now open in their own tab. A bare anchor navigates the planner away, which loses the open day and the map, and without rel=noopener the opened page can navigate this one - notes are content other trip members wrote. Day notes get a colour from a short shared palette. It tints the card, the border and the icon circle, and it reaches the PDF as the spine and rule rather than a fill. The dialog is two columns now, with a live preview of the card beside the picker, because a row of swatches says nothing about what a tinted note looks like next to a place. On the phone the thirty-two icon grid was most of the screen, so it is folded behind the chosen icon, which doubles as the colour preview. The detail field there got the same formatting bar and the same 2000 character ceiling as the desktop one - they write the same column. * Fix the tests that pinned the old note caps, and rework the note actions The e2e suite builds its own day_notes table by hand rather than going through schema.ts, so it never got the new column and every note insert came back 500. Three more tests wrote down the 250 character body cap and the old create() arity. While in there: the edit and delete buttons on a note card were a 10px icon in a 14px box, side by side, and they took width away from the text on every note whether you were hovering or not. They are stacked in a capsule now, 30px targets with their own hover states, floating over the right edge of the card only while the pointer is on it. The text keeps the full width the rest of the time. * Centre the day badge when there is no weather to stack under it The badge is pinned to the top of the row because with weather it is a tall stack that has to start there. Without a location there is no weather block, so all that is left is a 26px circle floating above the day title. * Render the place subtitle in the places list, and drop the form hint The row in the places sidebar printed the description raw while the same line in the day plan renders it, so a place written with the formatting bar read as _underscores_ in one list and italics in the other. Still clamped to one line - it is a list entry, not the inspector. The "Markdown supported" line under the notes field goes: the bar above it already says so. |
||
|
|
435b782c85 |
Add a note field to expenses (#1888)
Requested in discussion #1658: an expense carries an amount and a category, and everything else about it ends up crammed into the title. The column was already there. It just was not available, because the costs UI stores an itemized receipt in it as TICKETJSON:{...}, so an expense split by receipt could never carry a note - and saving one any other way wrote note=null, which quietly wiped whatever had been typed into the budget table. The receipt gets its own column and note goes back to being text. Old clients that still send the blob as a note are routed to the right column instead of clobbering the note. On desktop the row now has fixed columns, so the amount does not drift left when one row has a note and the next does not. The category moved to the card edge as a tab like the mobile card does, which is what frees the line the note sits on. Collapsed it is one line, and the same element grows in place when opened. The dialog is two columns now: what the expense was on the left, how it is shared on the right, grouped into panels the way the trip dialog does it. On the phone the eleven category pills became a dropdown, which is where the room for the note field came from, and a note on a card unfolds when you tap it. |
||
|
|
6fe89588c2 |
feat(plugins): give plugins the surface, insets and mobile palette they need (#1886)
* Tell a plugin where it is mounted A plugin sits in one of six host surfaces and cannot see which: a trip tab and the plugin page fill their container and scroll themselves, a dashboard widget and the settings frame report their height, a detail slot is a narrow column in someone else's panel. The contracts differ, and on a filling surface the height report is discarded, so a plugin cannot even discover the difference by trying. trek:context now carries viewport: the surface by name, phone or desktop, the fill flag, and the insets the host already keeps clear. The insets describe space that is held free, not padding to add — the mobile trip tab is the only surface with chrome floating over it, and the numbers come from the same variables the native tabs use so the two cannot drift. * Seat the plugin tab like a native one The mobile plugin tab kept clearance at the bottom and none at the top, so the plugin's first row sat under the status bar and behind the floating back button. The bottom value was a copied 84 that ignored the safe area on the devices that have one, and the padding sat on the container rather than in the scroller. Both clearances now come from the variables TabScroller uses, so the tab cannot drift away from the native ones. The frame also states its surface, and pins color-scheme for the reason both settings mounts already pin it: Chromium paints a white canvas behind a transparent frame otherwise. The tab also had no name anywhere on screen. It is not in the dock, so nothing lit up there either, and the header had a branch for every other tab. It gets the same treatment, built from the tab entry the planner already assembles. * Keep the widget card intact on the phone The card is built from the dashboard's glass vocabulary, which is scoped to .trek-dash. The phone dashboard is a different route and mounts these widgets outside that subtree, so background, border, shadow and blur all resolved empty and the widget stood on the gradient as a bare title over an iframe. Each value now falls back to the mobile token the surrounding cards are made of. * Give the design kit a mobile layer The kit was written for the desktop and stayed there: a filled card with a drop shadow where the mobile design is translucent and border-led, inputs at 13px which make iOS Safari zoom on focus and never zoom back, feedback on :hover which never fires on a touchscreen, and one media query in the whole stylesheet. It reads the new viewport field now and sets data-form-factor, data-surface and data-fill on the document, plus the insets as variables. Everything mobile hangs off data-form-factor="phone", so nothing changes on the desktop: the card loses its shadow and takes the mobile surface tokens, targets grow to 44px, inputs to 16px, and pressing something scales it instead of waiting for a hover that will not come. A filling surface anchors the body and scrolls an inner element, because its height report is discarded by the host. Plus the pieces a v4 screen is actually made of, so an author does not have to reverse-engineer them: eyebrow, count pill, section header, status dot, round icon button, field, segmented control. Each falls back to a global token where the mobile family is absent, so they work on both. * Document what a plugin needs on a phone The guide had nothing on the subject: no mention of the second token family, of who scrolls, of what sizes to expect, or that the kit brings almost nothing responsive. It also promised more than it delivered, claiming the palette was complete and that applying it matches the host "exactly" — fonts are named, not shipped, so that was never true. The new section covers the six surfaces and which of them fill versus report a height, what insets mean (space already held free, not padding to add), the mobile palette with the branch-on-absence pattern, and the four rules that decide whether it feels native. It also says plainly that the dev preview cannot show a phone width yet. |
||
|
|
d1e3d8a75a |
feat(places): offer Waze and Apple Maps beside Google Maps (#1885)
* Offer Waze and Apple Maps beside Google Maps A place could only be opened in Google Maps, and the request in #919 was for the apps people actually drive with. Google keeps the precise link it always had: ftid, then place id, then the details URL, and coordinates only as a last resort, so it still lands on the entry inside a mall rather than on the roof. Waze and Apple Maps take coordinates and nothing else, which is why they are absent for a place with no position, and why they open with navigation armed instead of on a place view. No setting. Every app that can resolve the place is offered, except Apple Maps, which is dead weight on Android and Windows and therefore only appears on Apple platforms. iPadOS reports itself as a Mac; both sides of that ambiguity have the app, so the check does not need to tell them apart. The picker is a new component rather than the context menu, which has no notion of a submenu. It is portalled because both call sites sit in panels that clip their overflow, and it is measured before it is painted: near the bottom of the window it flips above the trigger instead of running off the screen, and it is pulled back inside when it would overflow to the right. With a single app left to offer there is no menu at all, the button opens it directly, exactly as it behaved before. The three surfaces take it the way each has room for. The inspector button collects them behind one entry, the day-plan context menu lists them flat because a menu can grow, and the phone keeps its single circle and opens the picker as a sheet. * Move OpenStreetMap into the navigation menu, and name the destination OSM was still a button of its own beside the one that now collects the map apps, which is the arrangement the menu exists to end. It belongs with the others. The bigger correction is what Waze and Apple Maps are handed. Both document a query parameter next to the position, and the pair is what makes a destination legible: coordinates say which place is meant, the name is what the driver reads instead of a number. Neither gets a name without a position, because there are a lot of places called "Bahnhof". Google is unchanged; it can already do better than a name through its place id. * Stop drawing the section count as a pill inside a pill SectionHeader wraps its count in a CountPill, and all three callers passed one in already, so every section header carried two stacked pills: a lighter rounded background peeking out from behind the real one. Pass the number and let the header do the wrapping. * Give the traveler filter a label and a way back The avatars floated between the toolbar and the first section with nothing to say what they were, and once a filter was on the only way out was remembering which face had been tapped. It is a labelled bar now, closed off by the same divider the rows below use, with an explicit reset that appears while a filter is active. Both texts already exist in all 23 languages. * Line the traveler filter up with the section titles It carried the sheet's usual 18px of horizontal padding while SectionHeader right below it starts at 2, so the two labels sat on different left edges. * Filter the place list by stars instead of sorting by them Sorting put the best-rated place first and left every other one on the list, which does not help when the question is "what does the group actually want to do". It is a floor now, the same one the collections bar uses: All, or 5+ down to 1+, with unrated places dropping out once a floor is set. The button carries the active floor so the list is never quietly shortened. The row it sits in used to render only when the trip had categories, which hid the star control on a fresh trip. That is exactly where someone collects places to rate, so the row is always there now and only the category dropdown depends on there being categories. places.sortByRating is replaced by places.filterByRating in all 23 languages, since the old label described a behaviour that no longer exists. * Keep the collections map when a filter matches nothing Clicking a label with no places behind it made the map disappear and the page reflow to full width. The layout hung off the FILTERED set: no visible places meant nothing mappable meant no split. A filter should empty the map, not tear it out, so the layout now asks whether the list has anything mappable at all, while the markers keep following the filter. * Give the label filters a tray of their own Two changes to the filter row. Select and manage-labels drop their captions and keep icon, title and aria-label, which puts them on the same footing as the add-place button that was already icon-only. The labels themselves were pill-shaped buttons with their own borders sitting loose among square-cornered dropdowns, so a list with a handful of them read as a pile rather than as one control. They share the filter buttons' radius and type now, and sit in a recessed strip led by a tag glyph: the chips carry no border of their own, because a frame inside a frame was half the noise. Manage stays quiet at the end of the strip until hovered. * Put the collections controls on one line The filter row sat at about 31px while the buttons floating over the map are 38, and the icon-only add button was the odd one out even within its own row. Scrolled to the top, where the row and the map's top bar are side by side, that reads as three different heights. One --col-ctl-h for both, fixed rather than derived from padding so a button with an icon and one with a word cannot drift apart, and the row starts at the same offset the map's own bar does. Phones raise the variable instead of patching individual min-heights, which keeps the square buttons square. * Offer Apple Maps on desktops that are not Apple Apple Maps has had a web version since 2024, so the link resolves to a usable map on Windows and Linux rather than to nothing. Hiding it there was a guess about a limitation that no longer exists. Android keeps the exemption: the web version works there too, but nobody navigating from an Android phone reaches for Apple Maps, and the row of choices is short on purpose. * Move the label filter into the map's top bar The filter row was running out of room while the map's own bar had space to spare, so the labels ride up there next to the collapse button, in the same glass as the controls beside them and scrolling sideways rather than wrapping. They fall back into the filter row whenever no map is on screen — a narrow window in list view, or a list with nothing mappable in it — so they can never end up somewhere the user cannot reach. Both places render the same component now instead of two copies drifting apart. * Stop the active select button drawing a dark icon on a dark fill Two rules with the same specificity claimed the same class. On a dropdown `.open` means the panel is down, so the label stays dark; on the select button it meant the mode is on, which fills it with the accent and needs the light text. The dropdown rule sits later in the file, so it won, and the icon disappeared into the fill the moment the button was clicked. The state is `.on` now, which is what the other toggles on this page (col-map-btn, col-iconbtn) already call it. * Open the place in Google Maps, not the coordinates Reported in #1278 and still reproducible: without a Google identifier the link carried nothing but latitude and longitude, so Maps dropped an unlabelled pin instead of opening the entry. The name and the address were both on hand and both thrown away. Google documents query=PLACE_NAME,ADDRESS for exactly this case, so that is what the fallback sends now. A name with no address still falls through to coordinates, because "Central Station" on its own would confidently open the wrong city, and a pin in the right spot beats a link to the wrong place. The precise paths are untouched: an ftid or a place id still wins, and they are what a place searched through Google carries. This only changes what happens for the ones that carry neither, which is every place added from OpenStreetMap, by hand, or before those fields existed. |
||
|
|
8b1a238c51 |
fix(ui): sparkline stacking, wrapping sheet hint, translated phone entry view, Cloudflare docs (#1884)
* Put the atlas sparkline behind the text it was covering The graphic is absolutely positioned and comes after the label, value and delta in the DOM, so with no stacking order of its own the browser painted it on top of them. On the dashboard's atlas row a status card is about 190px wide at 1440px, and the 80px sparkline covered more than half the delta line (#1787). Give it z-index 0 and lift the three text rows to 1. It is decoration, so it stops taking pointer events while we are here. * Let a sentence-length sheet subtitle wrap TileHeader truncates its subtitle to one line. That is right for a filename or a date, and wrong for the reorder hint: between the 40px icon tile and the 34px close button there is not enough width for a sentence, so it was cut off mid-word (#1814), and every translation is longer than the English one. Opt in with subWrap where the subtitle is prose; the file sheets stay single-line. * Translate the phone entry view MobileEntryView carried its own English label tables while the desktop view resolved the same values through i18n, so a German reader got "Amazing" and "Sunny" on a phone and the translated chips on a tablet (#1846). Which view appears is decided by two different breakpoints, which is why it looked arbitrary. The labels are keys now, the edit action, the photo counter and the pros/cons headings go through t(), and the date formats against the chosen locale instead of the browser default. No new keys: all of them already exist in all 23 files. The colour classes stay local. The desktop table carries raw hex values and no dark-mode variants, so sharing one table would change how this looks. * Name Google account linking in the Cloudflare bot section The section claimed the block was specific to ChatGPT, so someone hitting it with Gemini had no reason to read on (#1667). Google exchanges the code from its own servers, which Bot Fight Mode blocks the same way, and the symptom differs enough to be worth spelling out: the browser half succeeds and linking fails afterwards, with a 200 on /api/oauth/authorize and no /oauth/token at all. Also note that Bot Fight Mode ignores IP allowlists, which is the first thing people try. |
||
|
|
8c8cc2a727 |
Require share_manage for the two public credentials (#1883)
Both advisories are the same mistake: a route that hands out a bearer token
treated trip membership as authorisation. Membership lets someone read the trip
while they are signed in. It does not let them mint a URL that reads it without
an account, keeps working after they are removed, and can be rotated out from
under the owner's calendar subscriptions.
The calendar feed token now sits behind TripAccessGuard plus
@RequirePermission('share_manage'), the same shape the rest of the trip-scoped
controllers use, which also replaces a hand-written access check that GET never
called at all. GET is gated with the writes because it returns the credential
itself, not its status. The share link's GET does what its own POST and DELETE
already did, and what the invite link next door already does for exactly this
reason.
Two things had to come with it or the gate would have been decoration. The MCP
tool get_share_link read the same token on membership alone, so it would simply
have become the new way in. And feed_token rides along in every trip payload
because TRIP_SELECT reads t.*: any member could have read the value out of
GET /api/trips/:id, the summary tool or the plugin RPC. It is blanked once in
the select and stripped in the two places that select the row directly.
The three service writes take the acting user and scope their UPDATE to a trip
that user can reach. The guard decides who may manage the credential; this only
makes sure a caller arriving another way cannot write to an id it guessed.
Client side, "Subscribe to calendar" is now behind the same permission on
desktop and mobile. Without that a member would have kept an entry whose enable
button silently does nothing, because IcsSubscribeModal swallows every non-200.
The one-off ICS download stays where it is: that is a file they may already read.
Instances that want members to manage these links can lower share_manage to
trip_member in the admin panel, which the tests cover.
|
||
|
|
073e55cbe4 |
fix(auth): redirect authenticated users away from /login and /register (#1822)
Landing on /login while already signed in, by typing the URL or going back, showed the login form again (#1810). PublicRoute takes a redirectAuthed flag on those two routes and sends the visitor to the start destination instead, capturing the auth state at mount so a fresh login is left alone during the takeoff animation. Only from a bare URL: ?redirect=, ?invite=, ?oidc_code= and ?oidc_error= all have to reach useLogin intact. |
||
|
|
ba7d55f999 |
Finish the contract ratchet, the upload bridge and the client leftovers
BODY_CONTRACT_ALLOW_LIST is empty. The last seventeen handlers type their
body with a createZodDto class, so the fail-closed boot gate has nothing left
to forgive; it still refuses a boot for a new handler that reads @Body()
without one.
Several of those contracts describe rather than constrain, and that is the
whole care in this change. The pipe runs before the handler, so a schema
stricter than the handler moves a user-visible answer:
- TagsController.create answers { error: 'Tag name is required' }, pinned
byte-for-byte in two tests, and the pipe formats as `field: message`. The
DTO leaves name optional and the handler keeps the check that owns the
message. Same shape for the booking-import confirm body, which additionally
gains per-item validation the handler never did.
- PluginsController.activate and install answer 503 when plugins are disabled,
and check that BEFORE looking at the body. A schema that rejected first
would turn that into a 400 for an operator whose server has plugins off.
- setEgressHosts reads a non-array `hosts` as "clear the list", which is the
documented reset. Typing it as string[] would have removed a working admin
action rather than tightened anything.
- PluginRoutesController.route answers { route: null } at 200 for everything
it dislikes, so the client falls back to straight lines. A 400 there would
turn a graceful degradation into a visible error.
files.bridge is gone. The multer options are built by
MulterModule.registerAsync from an AllowedFileTypesService leaf, so the
fileFilter reads the operator's extension list from the container instead of
from a second FilesService built outside it. Nest injects
MULTER_MODULE_OPTIONS with @Optional(), so a token that fails to resolve is
not a boot error: it silently falls back to defaults, with no filter and no
size cap. FILE-020 already proved that for trip files by uploading an .svg
over HTTP and expecting 400; journey uploads had no such case, and now do.
VIDEO_UPLOAD is untouched, including the filename callback that forces .jpg
regardless of what the client sends, which guards the public share proxy.
PluginFrame gets an onError. A frame whose document fails to load left a
blank element at full height, which reads as a hung app rather than as a
plugin that is gone.
typecheck:tests runs in CI now, with the test tree at zero errors. tz-lookup
is left as it is: the build puts it in a lazy helpers chunk, not the entry
chunk, and the remaining split would trade a synchronous render for an async
one to move 172 kB that is already off the critical path.
|
||
|
|
7078ac7a4d |
Keep the mobile FX widget off the network after unmount
Client CI was red on this branch while dev was green, and no client file had changed. The 536 test files all passed; the job failed on three unhandled rejections, all `window is not defined` from MDashWidgets, attributed to whichever case happened to be running when they landed. The widget fetches api.frankfurter.dev directly and sets state from both handlers with no unmount guard. MSW runs with onUnhandledRequest: 'warn', so that request was never intercepted: it left the runner, came back after the environment was gone, and React tried to schedule an update against a torn down tree. Load dependent by construction, which is why dev happened to pass. Two halves, because either alone leaves the other broken. The widget now aborts in flight work on unmount and on refetch, and treats an abort as "no one is waiting" rather than as a failure to report. That matters outside the runner too: this is a third party call on a mobile screen a user can leave immediately. The handler set gains the third party endpoints the app calls directly rather than through /api, so no suite reaches the real host. An empty rate list is the shape the widget already handles. Also clears twenty more test files, each at its root cause. Two more were passing for the wrong reason: oidc.service.test.ts built AuthService with an AtlasService in the membership slot, undefined for userCleanup and mailer, and the mailer stub in the tokens slot, the same shift admin.service.test.ts had. And CAL-010 claims to serialise a flight byte for byte, but createReservation ignores reservation_time, so the row is written with a null time and CalendarService drops it: the recorded snapshot is truthful about the data the case builds and silent about the flight it advertises. |
||
|
|
36cdb36875 | Keep the pictures as a grid, wider tiles instead of a lead image | ||
|
|
46318db5ac | Give the detail column a lead image and pin its one action | ||
|
|
0cd67675c5 | Describe the chain when nothing describes the branch | ||
|
|
e3b220603c | Point at the Google key only where its absence is the reason | ||
|
|
e1a52ba43c | Stop a picked place leaving its website on the next one | ||
|
|
880e258438 | Show a week of opening hours as a week | ||
|
|
8f9af76204 | Ask the sources that know the place before the ones that know the area | ||
|
|
14d6f2ecc9 | Keep the hover card's corners round without its tail | ||
|
|
2a94a5eb8e | Put the add-place dialog on the theme tokens | ||
|
|
e03b6a3080 | Match the keyboard button to the date field beside it | ||
|
|
0d58080fa6 | Lay the trip form out in two columns and give it the dashboard's shapes | ||
|
|
a2a9c3094c | Drop the tail from the map hover card | ||
|
|
f5a483da64 |
Get the pictures right and stop reusing stale answers
Three things you could see on dev1. Pictures were "roughly right" rather than right, because a coordinate search only knows what was photographed within 300 m. Wikidata's P18 is one picture somebody chose to represent that exact object - "Zurletzteninstanz.jpg" for the restaurant, not a statue down the road - so it now leads, then the Commons category, and geosearch is the last resort it should always have been. Small venues carry a wikidata tag far more often than a Commons category, so this is also what reaches the places the other sources miss. Descriptions stayed on Wikipedia after the switch to Wikivoyage because the week-long result cache predates it. It carries a format version now; a release that changes the shape or the sources invalidates its own entries instead of serving last week's for another six days. Ratings and opening hours were being fetched from Google and then dropped on the floor - the fact list only looked at OpenStreetMap tags. Both are shown now, whichever provider supplied them. Also: the column follows a place opened from the map, not only one picked from search. A POI tap arrives as prefillCoords and never touched the selection, so the column kept showing the previously added place. Pictures are a tight three-column grid and only the picture in play is credited in full, on one line. Crediting all of them at two lines each drowned the column, and the licence attaches to the one that gets used - whose credit also stays visible in the inspector after saving. The request carries the place record the dialog already fetched, so the server skips a details lookup it was making in parallel with the client's own. That lookup was measured at 12.8 s for a large OSM relation. |
||
|
|
d98d597581 |
Make the detail column useful for places no encyclopaedia describes
A restaurant got nothing: no wiki article, and a coordinate search around it returns whatever was photographed nearby rather than the place itself. Three changes, all on free sources: OpenStreetMap facts. A tagged restaurant carries its cuisine, its opening hours, a link to its menu, outdoor seating, step-free access and diet tags. Those arrive with the details lookup the dialog already makes, so showing them as chips costs nothing and is the single biggest gain for exactly the places that were empty before. Only positive facts are listed - "no outdoor seating" is noise. Commons categories. Where a place has a wikimedia_commons tag, its category is pictures OF it; geosearch falls back in when the category is empty. Wikivoyage before Wikipedia. Same API, same licence, but it describes a place for someone about to visit rather than opening with area in square kilometres. Two sentences instead of three, because this sits beside a form. The column also follows the section-heading idiom Vacay and the dashboard share (uppercase overline, wide tracking), its header matches the Saved places panel beside it, and a lead picture with a row underneath replaces the two-column grid that left a hole on every odd count. Credits wrap over two lines instead of truncating - a cut-off name credits nobody. Saved places stops after ten with a Show more button; search and filters still run over everything, not just the visible page. Form spacing is one rhythm (12px between groups, 8px inside, 4px label to field) and Notes matches Description in height. The OSM notice fits on one line and no longer claims there are no photos, which stopped being true when the column started fetching them from Wikimedia. The clipboard hint is gone; paste still works. |
||
|
|
1f1ed630fc |
Fetch the enrichment pictures concurrently
The column showed "details could not be loaded" for every place. The endpoint was answering correctly, just after ten seconds, and the shared axios instance gives up at eight. The downloads sat in a loop: five Commons hits meant five round trips and five decodes in sequence, even though the slot limiter already allows five at once. Both provider listings now run together and the bytes are fetched concurrently, which brings a warm request back to about a second. Candidate keys move with it. They now number the provider's list rather than the surviving strip, so a picture that fails to download leaves a gap instead of renumbering the ones behind it - a key has to point at the same file on the next request or the cached result goes stale against the photo cache. The client asks for enrichment with a longer timeout, and the Wikimedia calls carry one of their own: a cold TLS handshake from a fresh container was measured at eight seconds on its own, and no pictures is a better answer than a request that never comes back. |
||
|
|
5d99886b04 |
Keep a picked place image and its credit visible everywhere
Accommodation cards read the place from page-local state, so a new thumbnail only showed up there after a reload. place:updated now asks the planner to reload them, but only when the image actually changed - renaming a place must not trigger a refetch. The inspector shows the stored credit under the avatar. Crediting a CC BY-SA picture only in the moment it is chosen is not enough; the obligation travels with the picture. Also lets a GIF hero through to the PDF. Uploads accept gif, the PDF extension check did not, so one silently fell back to the category icon. |
||
|
|
4843ebc937 |
Show place details beside the search field
Picking a search result fills a column on the left of the add-place dialog - pictures and a description, no extra click. Someone who already knows the place ignores it and saves exactly as before. The column stays mounted with an empty state instead of appearing on the first pick, otherwise the dialog jumps sideways mid-typing. Requests are cached in memory and in sessionStorage and aborted when the selection changes, so clicking back and forth between two results does not pay twice. Every thumbnail carries its author, its licence and a link to the file page. That is not decoration - most of these are CC BY-SA, and reusing one without naming the author does not satisfy the licence. The description is shown but never written into the place on its own; a button copies it across, and it is disabled while the user already has a description of their own. Desktop only. The mobile sheet is untouched. |
||
|
|
f2368f86cf |
Add an admin switch for place enrichment
Reads fail-open, unlike the three older places_* switches. Those read === 'true', which is why migration 185 had to backfill a row on every existing install; there is nothing here that warrants repeating that. The three hand-rolled toggles next to it move to ToggleSwitch while I am in there - they were bare buttons with an inline transform and no aria-pressed or label, so a screen reader could not tell their state. |
||
|
|
7d0e90eb47 |
Make the plugin permission facts live in one place
The hook->permission map existed verbatim twice (the host supervisor and the plugin SDK) and the permission list four times (envelope, SDK manifest, and both admin panels in the client). All of them were in sync when measured - 63 entries each, 15 hook entries each - so this changes no behaviour. It removes a quiet outage: a hook whose permission is added on only one side fails silently in production, with the plugin installing, activating and then simply never being called. protocol/envelope.ts is the single source now. METHOD_PERMISSION was typed Record<KnownMethod, string>, so its value side was unconstrained - 'db:read:trps' compiled and made the method unreachable forever. It is `as const satisfies Record<KnownMethod, KnownPermission>` now, which catches a typo'd value, a missing key and an extra key. The derived KnownPermission and MethodPermission<M> are what the @PluginMethod decorator will check its argument against. Deliberately no explicit annotation on METHOD_PERMISSION: with one, typeof is that Record, MethodPermission<M> widens to the whole union, and the check silently evaporates. server/scripts/gen-plugin-facts.ts imports envelope.ts and writes the SDK and shared copies. It does not scrape. The guard it replaces pulled the block out of plugin-supervisor.ts with a regex that goes null on a reformat, and it ran only from prepublishOnly - drift would have surfaced as a broken npm release. The generated files are checked in because publish-plugin-sdk builds from a standalone checkout with no server present. The SDK's re-exported types stay wide. plugin-sdk compiles under strict, and grantGaps indexes HOOK_PERMISSION with a plain string, so an `as const` there is TS7053 and would break the publish. Strictness belongs on the host side. --check also fails on a permission with no admin.plugins.perm.* translation and on one the CLI has no hint for. Both are silent today. plugin-sdk/** was in no workflow path filter, so nothing in CI ever ran its tests. It is in the filter now, alongside a job that runs the drift check. |
||
|
|
86e2a96eb6 |
fix(client): a deliberate logout should not book a return ticket
Pressing "log out" clears isAuthenticated while the page you were on is still mounted, so ProtectedRoute re-renders for it and sends you to /login?redirect=<that page>. Log out from the dashboard and the next login goes to the dashboard - beating the startup destination every time after the first, which is exactly how it looked: worked once, never again. A session that ended on its own still gets the return ticket, because coming back to where you were is the whole point of that parameter. A deliberate sign-out is a fresh start and now gets none, so the startup destination decides. The Navbar already hinted at this with its unused noRedirect state; the flag lives in the store instead, since all seven call sites of logout() need it, not just that one. |
||
|
|
795cc50dae |
fix(client): stop the startup redirect racing the settings request
RootRedirect waited for whoever else might call loadSettings, which on a cold start meant waiting for a request queued behind everything else a launch fires off. Traced it in the browser: the settings came back as the 388th request, long after the redirect had given up and gone to the dashboard. That is what "it still just opens the dashboard" was. It now asks for the settings itself - the store de-dupes, so this costs nothing when a load is already in flight - and login spends its 2.6s takeoff animation fetching them instead of leaving that time idle, so by the time it hands over they are already there. Also folds the five copies of setShowTakeoff + navigate-after-2600ms into one takeOff(), since all of them needed the same fix. |
||
|
|
e7851e79ff |
fix(client): honour the start page on a device that never mirrored it
The mirror answered "dashboard" for a browser that had simply never been told, and RootRedirect took that as the user's choice - so the setting was ignored on every device except the one that set it, and on that one too after a logout cleared the mirror. The setting itself was stored correctly the whole time, which is what made it look like nothing happened. readStartDestination now says "unknown" rather than guessing, and a device in that state waits for the real settings instead. That costs one wait, once per device; afterwards the mirror answers and the launch is as fast as before. Bounded by a timeout, because loadSettings deliberately leaves isLoaded false on a failed request so it can retry - without the floor, a launch with no backend would sit on the spinner forever. |
||
|
|
6bf2e0708d |
feat(client): add the Startup section to General settings
Desktop and mobile both get it, in the style of the file they live in - the On/Off button pairs on desktop, segments and a picker sheet on mobile. The tab picker only appears once "Active trip" is chosen, since there is nothing for it to mean otherwise. Labels come from the same keys the planner's tab bar uses, so the picker can never name a tab differently from the tab itself. |
||
|
|
44eff7177e |
feat(client): open TREK on the active trip when asked to
Settings -> General -> Startup: start on the dashboard (unchanged default) or straight in the active trip, on a tab of your choice. Three clicks and three loads less for someone entering expenses while actually travelling, which is where the request came from. The redirect sits on '/' because that is what a shortcut, a bookmark and the installed PWA all launch (manifest start_url). /dashboard keeps working as itself - redirecting there too would leave no way back to it. The preference is mirrored into localStorage, the way the language and the theme snapshot already are, so the first paint can decide without waiting on GET /api/settings. That keeps the default launch exactly as fast as before: only someone who asked for the trip pays for the lookup, and if it fails or they have no trip they get the dashboard. Right after login the store already holds the real settings, so those win there. Login stops hardcoding /dashboard as its fallback and hands the decision to the same place, so there is one answer to "where does TREK open". |
||
|
|
688ce0fae2 |
feat(client): let a trip URL open on a given tab
/trips/:id?tab=finanzplan opens the planner straight on that tab. Read in the state initializer, not an effect, so the planner never paints the plan view and swaps a frame later. The param is consumed on arrival; the session memory keeps the choice across a reload. Two things the tab machinery got away with while nothing ever started on a tab other than plan: - the guard evicted anything not in TRIP_TABS, but enabledAddons is an optimistic guess until the addon feed answers, and collab is guessed off - so a requested collab tab was thrown away a render before we knew the addon was on. It now waits for the feed, like it already did for plugins. - the lazy loads hang off handleTabChange, which the tab we *start* on never goes through. Opening on Files showed an empty list, and that was already true for a tab restored from a previous visit. Tab ids move to constants/tripTabs so the settings picker can name them the way the tab bar does instead of keeping a second list. |
||
|
|
1e05501b9d |
refactor(client): finish the React 19 migration (#1826)
Mechanical, and it ends the migration visibly.
45 files imported the whole react-dom default export to reach one function.
They now import createPortal by name, across 54 call sites; six files already
did it this way. main.tsx keeps its react-dom/client import, which is a
different module and already correct.
forwardRef is gone from every component. In React 19 ref is an ordinary prop,
so the wrapper only obscured the signature:
NumericInput — a plain DOM ref, no imperative handle involved
JourneyMap — useImperativeHandle stays, only the wrapping goes
JourneyMapGL — same
JourneyMapAuto — same
Each takes `ref?: Ref<Handle>` in its own Props interface now, so the ref is
part of the declared surface instead of arriving through a generic parameter.
The two journey bindings in glLazy shrink to one line each. They existed as
forwardRef wrappers purely to carry the ref past the lazy boundary; with ref an
ordinary prop it rides through the spread like everything else, which makes all
six bindings in that file identical again.
The five React.FC annotations go. Three were `Record<string, React.FC<{size?:
number}>>` for provider icons in files that never import React at all — those
only typechecked through the UMD global — and are now ComponentType. The two
pages become function declarations.
TranslationContext uses the 19 context shorthand, which leaves its React default
import unused, so that goes too.
Also drops two dead vi.mocks of react-dom/server. Their comment still said "used
in createMarkerIcon", which stopped being true when marker icons moved to the
in-house serializer — nothing under src/ imports react-dom/server any more
except the parity test that exists to compare against it.
|
||
|
|
eb8e777741 |
chore(client): move zustand to 5.0.14 (#1825)
The last React-18 leftover in the dependency tree. v4 declared
use-sync-external-store as a hard dependency and its main entry imported the
shim; v5 declares no dependencies at all and calls React.useSyncExternalStore
directly. Only zustand/traditional still reaches for the shim, and nothing here
imports it, so it no longer reaches the bundle.
The upgrade itself needed one line. What it needed more of was checking, because
the one thing v5 changes about behaviour does not show up as a test failure.
v4 subscribed through useSyncExternalStoreWithSelector, which memoised the
selector's result. v5 hands React a plain getSnapshot of
`() => selector(api.getState())` and compares with Object.is. A selector that
builds a fresh object or array therefore returns a new reference on every call,
and React reads that as "the store changed" — an infinite render loop, or at
best a re-render on every parent render.
All 294 store selectors return a property or a primitive. None builds an object
literal, array literal, filter/map/slice result, `?? []` default, Object.keys or
a new Set/Map, so none of them needed useShallow. The suite confirms it: 533
files, 11,165 tests, and zero "getSnapshot should be cached" or "Maximum update
depth" warnings anywhere in the output.
The one source change is the seedStore test helper, which was typed against a
hand-written `{ setState: (partial, replace?: boolean) => void }` shape. As of
v5 setState is an overload pair — partial with replace?: false, full state with
replace: true — and no single signature is assignable to that. Typing it against
zustand's own StoreApi fixes all 129 resulting errors and is what the helper
meant in the first place.
|
||
|
|
3dfbb3fd02 |
perf(planner): load the PDF export and the transport modal on demand (#1821)
Two more things every trip paid for whether or not anyone used them. downloadTripPDF hung statically off the days sidebar, so opening any trip pulled in the whole PDF builder. Both call sites are already async and already wrap the call in try/catch, so a failed chunk surfaces as the export error the handler had anyway. TransportModal was already rendered conditionally, which means lazy bites immediately. It is worth more than its own 32 kB: it is the only path to TransitSearchPanel, and that chunk carried tz-lookup — about 200 kB of packed zone geometry. TripPlannerPage 339.78 -> 305.70 kB entry chunk 258.24 -> 218.70 kB TransitSearchPanel 198.67 -> 26.51 kB (and now on demand) TripPDF 226.02 -> 27.78 kB (and now on demand) eager path unchanged at 1,483 kB Worth stating plainly: tz-lookup itself did not go away. It sits in PlaceFormModal.helpers now, and PlaceFormModal is still a static import of the planner, so a trip still loads it. Prying that loose means touching placeOpenState, whose consumers assume a synchronous lookup — a separate change with its own care. |
||
|
|
cf62567c48 |
fix(map): stop untrusted values reaching the marker HTML (#1820)
* fix(map): drop the casing layer before its source on teardown
Tearing down the reservation overlay logged an error on every map unmount:
Source "trek-reservations" cannot be removed while layer
"trek-reservations-lines-transit-casing" is using it.
The casing layer arrived with the transit paths in 3.2.0 but was never added to
destroy(), which only removed the main line layer. The try/catch around it gave
no protection either — the engine reports this by firing an error event rather
than throwing — so the source was silently left behind on the style.
The layer id is a named constant now, so the place that creates it and the place
that removes it can no longer drift apart.
The lifecycle test grew the assertion that would have caught it: the fake map
now refuses to remove a source that a layer still references, the same way the
real one does. Verified against the unfixed code — two tests fail without the
change.
* fix(map): stop untrusted values reaching the marker HTML
Both map renderers build their marker HTML as a string and hand it to
L.divIcon / innerHTML. Two of the interpolations carry values a user controls,
and neither was escaped.
place.image_url is the worse of the two, because it is not admin-only: any trip
member with edit rights can set it. placeUpdateRequestSchema is a plain record
of unknown values, and the length guard in places.controller covers name,
description, address and notes but not image_url. The renderers do check the
value, but only with startsWith('/uploads/') — a payload like
/uploads/x" onerror="…" y="
satisfies that and then breaks out of the src="…" attribute.
category.color is the same shape one line up, reachable by an admin. It also
reaches SharedTripPage, which builds its own divIcon and answers without a
guard, so that payload lands in front of people who have no account here.
Colours are allow-listed rather than escaped. Escaping stops the attribute
breakout but leaves a working CSS value behind, and `url(https://…)` in a
background is a tracking pixel. Anything that is not #rgb/#rrggbb falls back.
image_url is a URL, not an enum, so that one is escaped; the prefix check stays.
Server side, the category contract now demands a hex colour, and the endpoints
validate through DTO classes instead of @Body('color') — a property read carries
no metatype, so the global pipe never saw it. That retires two entries from the
body-contract allow list. It replaces the hand-rolled 400 for a missing name
with the pipe's envelope, the same trade the places and trips migrations made.
The response schema deliberately still accepts any string: the write path was
open for a long time, so tightening it would make an instance fail on its own
stored rows. The client allow-list is what covers those.
Verified against the unpatched renderer — the three new cases fail without it.
* perf(client): serialize the map icons without react-dom/server
Six modules imported react-dom/server statically to turn a lucide icon into an
SVG string. That put Fizz — roughly 190 kB raw, 57 kB gzip — into a chunk three
lazy routes share, the Leaflet renderer among them, which is the default. For
output that is always a single <svg> with a flat list of shape children.
mapViewport chunk 189,532 -> 3,710 B
Tooltip chunk 194,333 -> 1,970 + 4,860 B
precache total 17,856 -> 17,675 KiB
utils/iconMarkup.ts serializes React elements directly. It is generic over
elements rather than over lucide's icon tables on purpose: iconNode is closed
over inside createLucideIcon and never exported, so calling the forwardRef
render function is what survives a lucide upgrade.
Byte-compatibility is checked, not asserted. iconMarkup.parity.test.ts diffs the
output against real Fizz for every exported icon in three prop shapes — the test
file is the one place still allowed to import react-dom/server, and it never
reaches a bundle. Two traps it covers: viewBox must not be kebab-cased into
view-box, and Fizz emits <path …></path> rather than a self-closing tag.
TripPDF loses its lazy renderer entirely — ensureRenderer, the module-level
handle and the two "not ready yet" guards that returned an empty string. There
is nothing left to load.
An eslint rule keeps the import from creeping back. Without it the regression is
invisible: the build stays green, the app keeps working, and the chunk quietly
grows by 190 kB again.
|
||
|
|
c089e75f08 |
perf(map): load one GL engine instead of both (#1819)
* perf(map): load one GL engine instead of both MapViewGL, JourneyMapGL and the settings preview each imported mapbox-gl and maplibre-gl statically, so rollup emitted one 2.83 MB chunk carrying both. Every map user downloaded 1.8 MB of mapbox plus 1.03 MB of maplibre and then ran exactly one of them. The 110 kB stylesheet was merged the same way. mapbox users 2,827,647 -> 1,797,952 B (-1.03 MB, CSS 110.6 -> 40.8 kB) maplibre users 2,827,647 -> 1,027,635 B (-1.80 MB, CSS 110.6 -> 69.8 kB) The engine is a prop now rather than a module import, and the pairing happens behind the lazy boundary in glLazy.tsx. No module reaches both SDKs statically any more, which is the whole mechanism — the components themselves stayed engine-agnostic, they already guarded on the provider everywhere the two genuinely differ. Both imports of a pair start together instead of in sequence: the engine is by far the larger half, and fetching the component first would put its round trip in front of the download that actually costs time. check:gl-split guards it. A stray static import anywhere in the map tree brings the merged chunk straight back with a green build and a working app, so the script greps the built chunks for a marker of each SDK and fails when one file holds both. Not on the bare string "maplibre" — the provider value and the maplibre_style setting key appear in app code too, so that matches the shared core chunk as well. Two boundaries were missing and are added here, because the split makes them reachable: the GL boundaries had no resetKeys, so a chunk failure under one provider kept showing Leaflet after switching to the other, and the settings preview had a Suspense but no boundary at all — that is the one place a user flips providers live, so it is the likeliest chunk to fail. Worth being precise about the gain: the service worker precaches every chunk, so an installed PWA still downloads both engines. What this buys is the critical path on a cold cache, and parse/compile/execute always — a megabyte of unused engine ran on every map open. * perf(client): split out the video player and the ISO subdivision data Two heavyweights that shipped with routes that rarely use them. plyr (112 kB raw / 33 kB gzip, plus 32 kB of CSS) hung off three lightboxes and through them off the FileManager, JournalBody and MTripShell chunks. The lightboxes only render the player on their video branch anyway, so the split costs one frame of placeholder — a black rectangle in the target geometry, so the lightbox does not jump when the chunk lands. iso-3166-2 is a single 238 kB data blob with no tree-shaking to be had, and only fetchRegionOptions touches it. It now loads after the hasRegions check, so it is fetched only for countries that get a region picker at all: holidayRegions drops from 257 kB to 14 kB. The import sits inside the existing try — a failed chunk yields the same empty array as a failed request, rather than an unhandled rejection in the two callers that only do .then(setRegions). tz-lookup is deliberately left alone. TransitSearchPanel builds the departure time it sends to MOTIS out of tzAt(), so a not-yet-loaded window there is a wrong query rather than a late pixel — that one needs its own change with its own care. |
||
|
|
49e68fb07c |
build(client): give react and the core libs their own chunks (#1818)
Everything that changes with a release currently drags the entry chunk with it, and 63 of 141 chunks import the entry statically. Measured with a one-token edit to a leaf route module and a rebuild, 70 of 141 chunks rehash — just over 1 MB gzip that every installed PWA re-downloads per deploy, since the service worker registers with autoUpdate. Pulling react and the core libraries out cuts that to 847 kB: rehashed 70/141 -> 60/140 gzip 1,038,497 B -> 847,688 B (-18.4%) entry 580,672 B -> 258,105 B eager JS 1,482,722 B -> 1,482,523 B (unchanged) eager CSS same file, same hash No first paint win, then — the gain is a smaller update delta and a halved entry to parse on weak devices. Three of the five groups the plan called for are left out, and not silently: mapbox-gl/maplibre-gl, leaflet and react-markdown already sit in async chunks whose hashes survive a release, so a group would only rename them. Worse, without tags: ['$initial'] a group also collects modules that hang behind React.lazy today and turns its chunk into a static import of the entry — measured, that put the 2.8 MB GL chunk into the index.html modulepreload and took eager JS to 4.61 MB, undoing the route split for maps. Note for the next person to touch this: vite 8 bundles with rolldown, so rollupOptions is only an alias and both manualChunks and advancedChunks are deprecated in favour of codeSplitting.groups. A config using the old names builds green and has no effect. Also refreshes two comments that still quoted pre-split bundle numbers. |
||
|
|
057d4ba503 |
perf(client): pick the viewport branch at the route (#1817)
Every page imported its mobile screen statically and chose while rendering, so each route chunk carried both trees and the viewport only decided which half stayed dark. Worse, because ten route chunks shared the mobile tree, rollup hoisted it into the entry chunk — so every desktop user downloaded the whole mobile shell before seeing a single page. The branch now decides the chunk. A phone never fetches the desktop planner, a desktop never the mobile shell. entry chunk 1,243 -> 581 kB (gzip 349 -> 177) TripPlannerPage 914 -> 340 kB VacayPage 361 -> 57 kB AdminPage 413 -> 225 kB SettingsPage 259 -> 155 kB Splitting inside each page instead was measured and rejected: it leaves the desktop code in the same module as the switch, so a phone would still download it and then wait for a second, serial request on top — 914 kB and a waterfall against 550 kB in parallel. The pass-through wrapper in each page stays deliberately. check-page-pattern scans from the default export to the next top-level function and rejects hooks there; making the desktop component the default export fails it immediately on dashboard, planner and atlas. RouteFallback gets a mobile face. It renders above MobileShell, so the --m-* tokens do not resolve for it and the desktop spinner on bg-surface would be a foreign white sheet in front of the mobile screen. The five scattered viewport spot checks are replaced by one contract covering all eleven routes, which is where the switch now lives. |
||
|
|
1d94814b57 |
perf(planner): load the tab panels on demand (#1816)
* refactor(costs): move the pure split maths out of the panel module splitEqualShares, calculateTicketShares and TicketItem sit next to the payer maths they belong with. They are pure functions with no module state and no imports from the panel, so this is a move and nothing else. The reason it matters: the mobile cost sheet and its model import these two functions from CostsPanel, and MTripShell is a static import of the trip planner page. Any attempt to load the costs panel on demand would therefore pull the whole 97 kB module straight back in through the mobile branch. * perf(planner): load the tab panels on demand Each tab panel only mounts while its own tab is active, yet all six shipped in the planner chunk. The page was 1,203 kB after route splitting — larger than the entire entry chunk. It is now 914 kB, with the panels in six chunks of their own (files 99, collab 87, packing 71, costs 62, reservations 32, todo 31 kB). The panels also get the error boundaries they never had. fe-error-boundary covered the maps and the plugin frame but not these, so without them a dead panel chunk would throw past to the route boundary and take the whole planner with it. The boundary sits outside the Suspense, since Suspense only owns the pending promise and a rejected one throws straight past it. One boundary per panel rather than one around the content area: a shared one would already be mounted with the visible tab, so switching tabs would swap the entire planner for the placeholder instead of just the part still loading. PluginFrame stays static — DayDetailPanel and PlaceInspector import it from the plan tab anyway, so splitting it here would move nothing. The two import modals stay static for the same reason, via the mobile sheets. The wiring tests now await their panel. That includes the ones still passing: React.lazy caches the resolved module for the whole file, so they were only green because an earlier test had already pulled it in. |
||
|
|
e5962c22d5 |
chore(client): tighten the precache ceiling and correct the offline docs (#1815)
* chore(client): tighten the precache ceiling now the entry chunk is split The 6 MB ceiling was set against the pre-split bundle, where the entry chunk alone was 5.2 MB. It is now 1.2 MB and the largest precached entry is the heic-to chunk at 3.0 MB, so the old limit had nothing left to catch. Worth being explicit about what the limit does: a file above it is dropped from the manifest and the build still succeeds, printing only that the file will not be precached. The PWA is then quietly broken offline for that file, which is why the ceiling has to stay close to the real bundle. Also records why every route chunk is precached rather than pulled in at runtime, since that is the reason route splitting did not shrink the install. * docs(wiki): correct the offline cache table The table still described an api-data cache holding API responses for 24 hours under NetworkFirst. That cache is gone: API requests are NetworkOnly because Workbox keys entries by URL and cannot vary them on the session cookie, so on a shared device one account's data could be served to the next. Documenting a cache that deliberately does not exist is the wrong way round for a privacy decision. Map tiles were listed at 1 000 entries; the actual cap is 12 288 and has to stay in step with MAX_TILES in tilePrefetcher.ts. The two GL tile caches were missing altogether, and the precache line now says that it covers every page rather than just the shell. |
||
|
|
96e4ff5932 |
perf(client): load the page chunks on demand (#1812)
* feat(client): retry a failed page chunk once before giving up A dynamic import fails for two different reasons, and only one of them is worth a second attempt. If the file is gone because a deploy happened while the tab was open, nothing but a reload helps and reloadOnceForChunk already owns that path. If the fetch itself went wrong — hotel wifi, an aborted request, a service worker handing back a truncated entry — one retry fixes it. The retry cannot reuse the original specifier, since Vite rewrites it into a hashed asset URL at build time; the only usable URL is the one the browser names in the failure message, and a fresh query on it misses both the HTTP cache and the Workbox precache. This is also where a healthy load is acknowledged. Clearing the reload marker used to sit in main.tsx right after render(), which runs before React commits and long before any chunk is fetched, so the marker was gone before it was ever needed. * perf(client): load the page chunks on demand Every one of the twenty pages was a static import, so the entry chunk carried the planner, the journal, the atlas and the vacation planner for anyone who only opened the dashboard. They now load through lazyWithRetry; the entry chunk drops from 5,183 kB to 1,243 kB (gzip 1,300 -> 349). LoginPage stays static on purpose: anyone logged out lands there, and every other route redirects there first. The Suspense boundary sits above <Routes> rather than inside ProtectedRoute so it stays mounted across navigations. react-router runs location updates inside a transition, which keeps the current page on screen while the next chunk arrives instead of flashing a spinner on every jump. The precache is unchanged by design: workbox picks up the new chunks, so the install size stays where it was (107 -> 178 entries, +31 KiB) and every route remains available offline. This buys first paint, not install size. |
||
|
|
39b6d9f786 |
feat(client): catch render errors instead of blanking the page (#1805)
* feat(client): catch render errors instead of blanking the page
There were no error boundaries anywhere, so one throw during render unmounted the
whole tree and left the user staring at a white page with no way back.
ErrorBoundary holds the state in a class — getDerivedStateFromError has no hook
equivalent — and renders a function component for the visible half, so the panel
can translate normally. TranslationContext isn't exported, so contextType was not
an option anyway.
Two things it does beyond showing a message. A chunk that 404s after a deploy gets
one reload rather than a retry button: React.lazy caches the rejected promise, so
retrying re-throws immediately. And the reload happens once per session, or a
genuinely missing chunk would spin the tab.
The global handlers in main.tsx cover what no boundary reaches — event handlers
and async code. They filter axios errors: the interceptor already redirects on
401/403 and there are 600+ toast.error call sites, so reporting again would mean
double toasts and, for the redirects, loops.
* feat(client): isolate routes, widgets and the shell chrome
The route boundary goes on the single line where all 16 protected routes render
their content, inside MobileShell rather than around it: the navigation survives,
so a broken page is something the user can walk away from instead of a dead end.
The mobile --m-* tokens live on the shell's .m-root too, so a fallback outside it
would render invisible.
key={location.pathname}, not resetKeys — ProtectedRoute is the same component at
the same position for every protected route, so otherwise a failure could follow
the user across a navigation.
The public routes render outside ProtectedRoute and were therefore uncovered,
including /login (where people land when everything else failed) and the two
anonymous share pages. PublicRoute wraps those seven.
The five global widgets and the shell chrome get fallback={null} each: a broken
widget should disappear quietly, and if BottomNav dies the user loses the very
thing they need to leave the route.
* feat(client): isolate the GL maps and the plugin frame
Both map wrappers put the boundary outside their Suspense, not inside: Suspense
handles a pending promise, a rejected one throws straight past it. Falling back to
the Leaflet renderer keeps a usable map rather than showing an error card — the
same downgrade the Suspense fallback already does while loading.
PluginFrame is third-party code rendering in our tree; a throw there should cost
the plugin, not the page. resetKeys={[pluginId]} so switching plugins clears it.
The locale loader had no .catch (TranslationContext.tsx). After a deploy the chunk
can be gone, which produced an unhandled rejection and left the UI silently on the
previous language. It now keeps the strings it has and says so in the console.
|
||
|
|
6c63a9bb60 |
fix(client): move to react-router 7.18.2 (#1804)
* refactor(client): opt into the v7 router behaviour while still on v6 Turning the behaviour on separately from the version bump keeps the two failure modes apart: red here means a behaviour change, red on the bump means the import rewrite. The suite is unchanged either way — 527 files, 11099 tests. Only the two flags a non-data router understands are set; the rest belong to createBrowserRouter, which this app doesn't use. Both are applied at all six router mounts, so the tests exercise the same behaviour as production rather than validating v6 while prod runs v7. * fix(client): move to react-router 7.18.2 6.30.4 is affected by GHSA-wrjc-x8rr-h8h6 — open redirect through a backslash in Link and useNavigate, which this app uses in 41 places — and the 6.x line has no fix: react-router-dom 6.30.2 to 6.30.4 carries GHSA-jjmj-jmhj-qwj2 with no patched version at all. Staying is not an option, and 7.0 to 7.17 only swaps it for GHSA-chx6-hx7r-mcp5. The package rename comes with it: react-router-dom is a re-export shim in v7, so using it would only defer the same rewrite to the v8 jump. 77 occurrences across 60 files, all of them import specifiers and vi.mock strings. The surface is small — useNavigate, useParams, useSearchParams, useLocation, Routes, Route, Link, Navigate, useMatch, MemoryRouter, BrowserRouter — no data router, no loaders or actions. Nothing else had to change; the future flags from the previous commit are simply how v7 behaves, so they come back out. npm audit still reports GHSA-qwww-vcr4-c8h2 on react-router (RSC mode CSRF, fixed in 8.3.0). It needs RSC mode, which needs a server; this is a static SPA. Closing it means the v8 major, which is its own piece of work. |
||
|
|
2b11990063 |
chore(client): take the GL engines out of the entry chunk (#1801)
* refactor(settings): load the GL map preview on demand MapViewGL and JourneyMapGL are both behind lazy() so a Leaflet-only install never downloads the GL engines. MapboxPreview imported mapbox-gl and maplibre-gl statically, which put them back in the entry chunk and cancelled that out. Wrap it in lazy()/Suspense on both the desktop tab and the mobile screen. The entry chunk drops from 7987 kB to 5157 kB (2057 kB to 1292 kB gzipped); the engines now ship as their own chunk, loaded when a GL provider is picked. The preview no longer resolves synchronously, so the tests that read it right after switching provider await it now. * chore(client): drop unused deps, self-host MuseoModerno, add a bundle baseline @react-pdf/renderer, react-window and @types/react-window have no importers anywhere: TripPDF builds HTML for the browser print window and JourneyBookPDF goes through marked. RegisterPage is dead too, /register renders LoginPage. MuseoModerno was the last font still coming from the Google CDN through a render-blocking <link>, while main.tsx already self-hosts Poppins and Geist for exactly the reason stated in the comment there. Same weights, now bundled. Add rollup-plugin-visualizer behind `npm run build:analyze` so the splitting work has numbers to argue with, pin build.target instead of inheriting Vite's default, and bring the workbox size ceiling down from 10 MB to 6 MB — anything above it is dropped from the precache manifest silently, which only shows up as a broken offline app. |
||
|
|
06f5bc2c86 |
fix(planner): keep the file-upload callback above the place bail-out (#1800)
PlaceInspector returns null when no place is selected, but the useCallback for handleFileUpload sat below that return. Clearing the selection therefore changed the hook count and React threw "Rendered fewer hooks than expected", which takes the whole tree down since nothing catches it. Move the callback above the bail-out and read the id through place?.id. |
||
|
|
ab9ca4220a | fix(atlas): translate unvisited country names via the locale resolver (#1798) |