mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 07:26:30 +00:00
49b0ce28d546803cbd006689bbd9c04a1ce3c657
284 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
49b0ce28d5 | fix(planner): no hotel bookend legs against a same-day carrier without stay times (#2157) | ||
|
|
2e0c496a93 | fix(planner): edit details handoff from the transit journey dialog (#2148) | ||
|
|
354a89f25b | fix(todo): stop the press-scale animation from swallowing checkbox clicks (#2158) | ||
|
|
9162f5d7ac |
fix(maps): stop the day route drawing drives to airports you flew from
A flight leaving overnight contributes only its departure airport on the day it leaves, and the evening hotel bookend was attaching to whatever point sat at the day's edge. So the map drew a straight line from the airport you took off from to the hotel you were checking into. The same blind spot on the run builder produced the long rays: a day's order is a bare wall clock, so an inbound long-haul whose departure clock reads late sorts after the day's local stops, and its far departure airport got appended to that run. OSRM answers such a pair with NoRoute and the fallback straightens the whole chunk, taking the genuine local drive with it. Both now ask whether the pair could be driven at all. A flight, train, ferry, cruise or coach carries you out of the day's geography, so its departure point has no drive back to tonight's hotel and its arrival point had no drive from this morning's. A hire car is the opposite and stays out of that set: you keep driving it, so a multi-day rental's pickup and drop-off points keep their hotel legs. Alongside it, any leg touching a booking endpoint has to be within MAX_DRIVE_KM, which catches the mis-sorted edges the role alone cannot see. The sidebar gets both guards so its hotel connectors stay in step with the drawn route; the mobile map and timeline read the same hook. |
||
|
|
adf88e1d61 |
fix(reservations): accept a booking that starts and ends on the same day
A permit valid for one day has no clock on either end. isEndBeforeStart filled
both missing times with midnight and then compared strictly, so start and end
came out equal and the form refused it: inline error, dead save button, toast
on submit.
It also made existing rows uneditable. A booking whose end is a bare date on the
start day is exactly what the booking import writes, and what the mobile sheet
has been allowed to save since
|
||
|
|
910c62ea90 |
fix(transit): show the stop you get off at instead of the walk that follows
Every row of an expanded search result is anchored at its leg's start: the time, the stop name, the platform. A leg's arrival appears as the next row's heading, and the last one gets the closing row. The walking legs broke that and printed their DESTINATION, which is the stop the next row already names. MOTIS brackets every journey in walking legs, so the last leg of a real connection is the walk from the arrival station to wherever you were headed. Its origin is the stop you get off at, and because that row printed the destination instead, the station had no row anywhere in the card and vanished. The same branch blanked the time cell, so the train's arrival time went with it. At the other end the access walk swallowed the origin the user had picked. Rows now read from.name for every mode, and the walk says it is a walk in its badge row instead. The platform tags along, which gives the exit row the arrival platform it was already carrying. The time falls back to scheduledTime the way the save path does, since a schedule-only feed would otherwise leave the row that matters without a clock. Fixes #2106 |
||
|
|
130272cfb0 |
fix(booking-import): stop writing an unreadable type down as a hotel (#2076)
When neither the keyword table nor the model produced a type, the router wrote "hotel" and cast whatever string it got to FlatType without checking it. A hotel is a booking type, so a ferry voucher imported from the Transport tab opened the booking form, which offers six chips and not one transport among them, and "other" was the only honest pick left. An unrecognised string got through the cast too and then vanished silently in nuExtractToKiReservations. The type is validated against FLAT_TYPES now. When it cannot be read the item is still extracted with the lodging shape so it survives mapping, but it says so, and the tie-breaker #2080 added finally has something to fire on: isUnplaceableItem only ever matched a type outside both form's vocabularies, and the server can only emit its own eight, so on a real document it never matched at all. The tab travels with the job instead of sitting in component state. The parse deliberately outlives navigation and reload and the review is triggered by the global widget on any page, so by the time it ran the state had remounted back to its default and the context was gone. |
||
|
|
92575c0d49 |
@
fix(import): stop reading tickets as lodging, and stop scroll escaping a dropdown Two v4.0.0 reports. The file importer classified nearly every transport document as a hotel. Its keyword table tested lodging second, on a group that included "check-in" and "check-out" — words printed on almost every ferry, train and bus ticket there is — so a ferry ticket came back a hotel, and the review then handed the user the booking form, whose six chips contain no transport type at all. That is where "everything ends up as other" comes from: it was the only honest pick left. Transport is tested first now, the lodging pattern asks for a word that names lodging, and the rental pattern asks for a rental rather than for a pick-up time that ferries and shuttles also print. Two more in the same function. The flight path forces its type on everything it returns, and it claimed any document with two letters followed by digits — which is what a German rail number looks like, so an IC ticket was imported as a flight without the keyword table ever running. It now stands down when the document already names another kind of transport. And a type neither form can express opened the booking form regardless; it now follows the tab the import was started from, while a type either form knows still wins over the tab, since one PDF routinely holds a flight and a hotel. An unreadable type reaching the transport form arrives as transport_other rather than as a flight: a wrong flight looks right enough to be saved unnoticed. The other report: a dropdown inside a sheet scrolled the page instead of its own list. The panel is portaled to document.body and positioned fixed, so its scroll chain runs to the viewport rather than to the sheet it looks like it belongs to, and nothing stopped it. Three portaled lists carry overscroll-behavior: contain now — the select, the navigation menu (which reaches the phone through the place sheet, not only the desktop inspector) and the phone day picker. @ |
||
|
|
4d6820a4d4 |
@
fix(planner): route a layover flight per leg, and count linked places as planned Two v4.0.0 reports, both a surface answering a question with less than it had. The map built its own waypoint order. It read the BOOKING position and never expanded metadata.legs, while a multi-leg booking stores its position per leg — so a Frankfurt to Vancouver flight via Reykjavik was dropped from the list and the airport before it was joined to the airport after it in one driving run across the Atlantic. It now takes the order from getMergedItems, the same one the day plan renders, which brings the span filter and the leg expansion with it. getMergedItems in turn had never read day_plan_position, the one slot the map did read, which is how the two could disagree at all. getTransportRouteEndpoints handed back the booking outermost airports for every leg. A stop between two legs was therefore routed from the arrival airport back to the departure one — a drive from Vancouver to Frankfurt with a cafe in Reykjavik on the way. A leg now spans its own pair, when the endpoints line up one per stop with the legs; anything ambiguous keeps the role lookup. Being planned meant one thing: a day_assignments row. A hotel is linked through its stay and a venue through its booking, and neither is ever dragged onto a day, so both sat under Unplanned while the day header printed their names. The three places that asked the question — desktop sidebar, map markers, phone browser — now ask one helper that also counts a stay and a day-anchored booking. A booking that names no day still counts as unplanned, because nothing says when it happens, and the day plan renders nothing for it either. @ |
||
|
|
bbe4d62c90 |
@
fix(export): print the reader clock format, and split window bookings in the feed Three v4.0.0 reports that all come down to a surface printing what is stored instead of what the reader asked for. The PDF (#2066) is assembled as an HTML string outside React, so no hook could reach it and no time format was ever passed in. It printed the column: four clock sites, all 24h whatever the personal setting or the admin default said, and a place time typed by a 12h user came out as "3:00 PM" for a 24h reader. Both export entry points now hand the format over the way they already hand over the locale, and the four sites go through formatClockTime. The journey editors (#2067) used a native <input type="time">, which paints 12h or 24h from the browser locale and cannot be told otherwise by any attribute. Every other time field in TREK has used CustomTimePicker for exactly that reason; the journey editor, its phone sheet and the phone place sheet were never migrated, so the same place form obeyed the setting on desktop and ignored it on a phone. The picker gained a disabled prop so the read-only journey sheet keeps the affordance the native input gave it. In the subscribed calendar (#2068), a booking that is a window rather than an activity read as one block across its whole range: a week of airport parking sat on all seven days. Both hand-overs now stand on their own — an hour each, carrying what the block used to say — and the block goes. Only when the two ends fall on different days: a same-day booking is one sitting, and a rental with only its pickup geocoded keeps the block, which is the only thing holding the return time and its zone at all. Rental cars were missing outright, because the planner writes reservation_time = NULL when its optional time pickers are left blank while still setting day_id, and nothing in the exporter ever read the linked days. It reads them now, as the last resort, for every type — so a hand-typed bus or train shows up as well. @ |
||
|
|
495c5e95ec |
fix(mobile): drop stale up-next cards and open the plan on the next day (#2057)
The UP NEXT card kept pointing at this morning's first stop hours after it had started, and at day one of a trip that ended last month. A header promising what comes next, sitting above something that is over. It now stays away on a day that has passed and once today's timed plan has run out — a day whose stops carry no times at all still shows its first stop, there is nothing stale about that. The timeline card already collapses the reserved space when there is no up-next, so the layout closes cleanly. Seeding the plan day also lands on the next day still ahead when today falls into a gap in the dates, instead of dropping back to day one. A trip that is already over keeps opening on its first day. And a day that was selected before the shell mounted now counts as seeded, so clearing the selection is left alone instead of being re-seeded on the next render — which is what the guard was there for in the first place. Based on #2055. |
||
|
|
0269273d01 |
fix(budget): use Math.trunc for user colour index, complete settlement fixtures
The `| 0` truncation in colorForUserId/colorFor existed to coerce an undefined user_id to 0, but the settlement API always returns user_id — only the two BudgetPanel test fixtures omitted it. Add user_id to the fixtures, switch both helpers to Math.trunc (with a Number.isFinite guard in colorForUserId preserving the old undefined->0 behaviour), and drop nothing else: TravelerPicker only ever receives required numbers. |
||
|
|
1ad2e92142 |
fix: make the app keyboard-reachable, and close four DoS-able patterns
The bulk of what SonarQube still had open on the reliability side, read site by
site rather than swept. Two clusters are worth calling out because they are
defects, not lint.
**Four regexes could be made to hang the process.** The worst is
`/.+@.+\..+/` in the forgot-password path: three unbounded `.+` that all
overlap, unanchored, on an endpoint that needs no login and whose schema is a
bare z.string() under a 100kb body cap. A thousand '@' characters had not
finished after 100 seconds — one request pins the event loop, and Node has one.
EMAIL_REGEX in auth.helpers is the same shape on register and profile update
(310ms for 8kb). The two tag strippers over an uploaded KML description and over
Commons metadata took 0.52s on a 16k run of '<'. Each rewrite was proven
equivalent by brute force against the original — millions of strings for the
coordinate patterns, 671,876 for the tag stripper — because a subtly different
regex on a parsing path is worse than the finding.
`<[^>]+>` could not be fixed in the pattern: `[^>]` matches `<` too, so the only
unambiguous form changes what happens to malformed markup. It is now a scan in
common/stripHtmlTags.ts, quirks included — `<>` is left alone, `<a<b>` is one
tag — with the equivalence pinned by a test that compares against the old regex
over every string up to length six.
**And roughly 500 places could only be reached with a mouse.** Clickable divs
became real buttons where the markup allowed (52 of them), took
role + tabIndex + Enter/Space where they had to stay divs, and modal backdrops
took role="presentation" — the honest role, since a backdrop is a mouse shortcut
for a Cancel control that is already in the tab order, and making it a button
plants a nameless full-screen tab stop around its own contents.
Alongside those: `.sort()` calls that now say the order they always had, `{n &&
<X/>}` guards that rendered a literal "0" for a zero-byte file and for a place at
latitude 0, an optimistic budget write that stored a boolean where the contract
and both transports use 0/1, and the Number statics.
sonar-project.properties documents the six that were deliberately left: two wiki
patterns with no overlapping quantifier pair whose "fix" would stop parsing
`[see [1]](url)`, four tar header fields bounded at 12-155 bytes by the format,
two djb2 hashes where the 32-bit wrap is the algorithm, two `| 0` that are also
coercing undefined to 0 on a path where the API sends no user_id, and the MCP
session maps Sonar reads as empty because they are filled from another module.
|
||
|
|
52d1a0720f |
fix: say out loud what every sort was already doing
Sonar's S2871 flags a bare .sort(), which orders by UTF-16 code unit rather than by value. All 30 remaining sites turned out to sort ASCII strings — ISO dates, currency and country codes, setting keys, route names, OAuth scopes, zip entry names — where that order is already the intended one. Each says so explicitly now, with a comparator that reproduces it exactly rather than a localeCompare that would quietly reorder things. That mattered more than it sounds. Several of these feed something other than a display: useAtlas joins the sorted codes into the signature that decides whether to rebuild the region layer, daysSlice re-pins sorted dates positionally onto reordered days, and three timeline views pick a day's colour by indexOf into the sorted array. Switching any of them to a locale collator would have been a silent regression. Two smaller ones came with it. The .map(placeBtn) calls in FileManagerAssignModal pass the index as a second argument, which placeBtn declares and uses for its React key — correct already, now written out as .map((p, i) => placeBtn(p, i)). And budgetSlice's optimistic paid-toggle stored the raw boolean behind `paid as unknown as number`, while the schema declares z.number() and both the REST response and the WebSocket event carry 0/1. So the optimistic state and the confirmed state disagreed on type until a refresh. It normalises now, and the two tests that asserted `true` assert the contract. Four `| 0` truncations are listed in sonar-project.properties instead of changed. Two are a djb2 string hash where the 32-bit wrap is the algorithm. The other two look like plain truncation of a row id, but `| 0` is also coercing undefined to 0 there — the settlement flows the API returns carry no user_id, so that is the normal path, and Math.trunc returns NaN and crashes the lookup. |
||
|
|
b5c8182410 |
feat(planner): clicking the selected day header again deselects it
Same effect as the day panel's close button: selection cleared and the day detail panel closed. Discussed in #2024. |
||
|
|
2c2d008dc2 |
fix: clear every open Sonar security issue, and half the reliability ones (#2037)
* fix(security): mint idempotency keys properly, and resolve the extractor by path Three findings out of a sweep of every open Sonar security issue. The other 49 turned out to be false positives and are documented in sonar-project.properties rather than changed — each entry scoped to a file, never a whole rule, so a future Math.random() on an auth path still fails. The idempotency key is the one that can lose data. Both producers fell back to Math.random when crypto.randomUUID was missing, and randomUUID needs a secure context — TREK's own quickstart is http://localhost:3000 and LAN installs run on http://192.168.x.x, so on the deployments the README documents the fallback is what actually runs. The axios one was `Math.random().toString(36).slice(2)`, which is not length-stable: 0.5 yields a single character and 0 yields the empty string, which the interceptor reads as "no key" and skips deduplication for entirely. Two writes sharing a key collapse into one — the second gets the first one's cached response and the edit is gone with no error anywhere. Both now come from utils/randomId, which has the getRandomValues rung that works in insecure contexts too. The extractor probe stored the bare binary name, so every extraction re-resolved it through whatever PATH held at that moment. The official image sets KITINERARY_EXTRACTOR_PATH and never reaches that branch, but a bare-metal install without it ran an unqualified name from a PATH it does not control. It now walks the search path itself and keeps the absolute path of the file it verified; execFileSync also drops the /bin/sh hop the old probe needed. PATH comes through app-config because process.env is walled off in src/. * fix: four tests and sorts that were not doing what they claimed Sonar reported 43 high-impact reliability issues. Reading all of them, four are real; the rest are cosmetic or would be made worse by the suggested change. budget.test.ts hand-built `paid: true`, but both emitters send `paid: body.paid ? 1 : 0` and the handler stores the value verbatim — the test asserted a payload the server never sends. The line below it already used a number. admin.controller.test.ts had a `.resolves` assertion with no await. The promise was dropped, so that line could not fail the test. The two to-do category lists sorted with a bare .sort(), which is byte order: every accented name files behind the whole ASCII range, so in a German trip "Übernachtung" lands after "Zelt". TREK ships 23 locales, so this is real data. One side effect worth knowing: katColor picks a category's dot colour by its index, so re-sorting can move which category shows which colour — it already moves whenever a category is added, and the colour carries no meaning. Deliberately not changed: the two `| 0` truncations (Math.trunc is not equivalent — `| 0` wraps at 32 bits and these carry millisecond timestamps), the two .map() callbacks whose second parameter is declared and used, and the sha1 in thumbnail.service (its own comment says a swap would orphan every existing thumbnail). * refactor: give every button a type, and prefer the Number statics Two mechanical sweeps, no behaviour change intended in either. A <button> with no type attribute is type="submit" per the HTML spec. Outside a form that does nothing today, but it turns into a surprise the moment the markup is wrapped in one, which is what Sonar's S9011 is warning about. 870 buttons get an explicit type="button". Two things were left alone on purpose: buttons that take a {...spread}, because the spread may carry a type and the merge order decides which wins, and anything inside a <form> — there the answer is a judgement call, and checking all eleven form files found every real submit button already carrying type="submit". The Number sweep covers only the three globals that are exactly equivalent to their Number twin: parseInt, parseFloat and NaN. isNaN is NOT in it — the global coerces its argument, so isNaN('abc') is true where Number.isNaN('abc') is false, and renaming it would change what the code does. It reaches server and plugin-sdk as well as the client; both copies of egress-policy.ts got the identical edit, so the mirror the SDK's parity test guards stays intact. |
||
|
|
e3c524dd02 |
test: give every test case an assertion of its own
SonarQube's S2699 flagged 196 test cases as assertion-less. Almost all of them end on a bare `await screen.findByText(...)`, which does throw when the text never shows up — the test is real, the analyser just cannot see it. Spelling the check out keeps the behaviour identical and makes it readable. Writing them out turned three tests red, each for a good reason: - FE-COMP-NOTES-008 read the heading out of the loading panel, which renders its own copy and swaps the whole node out once the fetch settles. It now waits for the loaded state. - FE-COMP-PHOTOPROVIDERS-014 matched /connected/i, which also matches the "Not connected" the badge starts as — the test passed whether or not the button did anything. Exact text now. - FE-FILES-002 compared an empty list against the empty list it already was. It seeds a file first, so the 500 has something to leave alone. JOURNEY-SVC-066 asserted nothing at all behind a "should not throw" comment; it now says that out loud and checks no journey entry is left orphaned. |
||
|
|
efcdbd94fb | fix(client): compute "today" from the local calendar, not UTC | ||
|
|
7c6a17c020 |
fix(client): make the new rollbacks roll back only what they wrote
The rollbacks this branch added were snapshot-shaped: capture the whole collection before the write, restore it on any failure. That is wrong in two ways at once. It undoes work the server already accepted when a later step is the one that fails, and it throws away whatever a collaborator's socket event applied while the requests were in flight. The day-plan reorder is the clearest case: five awaits, and a failing day-note write reverted transport positions the server had already stored. It now records only the fields it is about to overwrite, per booking, and drops each one as its request lands - so a note failure reaches the catch with nothing to undo, and a booking that arrived mid-write survives. The notification toggle had the same shape underneath: the rollback was made surgical but the request still shipped the whole matrix built from the pre-toggle render, so two overlapping toggles overwrote each other anyway. It sends the one cell that changed. The test pinned the disagreement as correct; it asserts the payload now. Photo reorder awaits allSettled rather than all, so the UI is not snapped back while the remaining writes are still landing - a partial run really does leave the server holding part of the new order, and only a total refusal justifies restoring the old one. Both the desktop editor and the mobile sheet, which was still swallowing every rejection. Also: the geolocation start guard is a run generation now, because a start issued after a stop re-sets a plain flag while the first run is still parked in the iOS permission prompt; a category rename reports once for the batch and rethrows, so the settings dialog stops closing as if it had worked; and a 404'd import job stops telling the user their file was invalid when it had simply expired. The clipboard fallback for plain-HTTP installs was pasted into three files by this branch. It is one helper now, and the two copy buttons in the members modal that still called navigator.clipboard unguarded use it too. Same for the fourth local-date helper written next to the three that already import the existing one. |
||
|
|
818021908f |
fix(client): correct the second pass over this branch's own client fixes
Four of the fixes already on this branch were wrong, and an adversarial read of the diff is what turned them up. The shared merge helper claimed to implement the server's COALESCE(assignment_time, place_time) and did not: it pinned the embedded times to whatever the assignment already showed, ignoring the override column entirely. Sharing that with the WS applier traded one bug for another - a collaborator moving a place from 08:00 to 10:00 stopped reaching the day card for every assignment WITHOUT an override, which is most of them. It merges the way the server projects now, and the test that was supposed to pin this seeded a fixture the server could never produce (embedded times that differ from the pool row with no override to explain the difference). The per-iteration auth gate went into every loop in tripSyncManager except the one that matters most: the mutation-queue replay, which writes server responses straight into Dexie. After a logout that is the shared anonymous database. The fire-and-forget tags/categories writes two lines below a gate added in the same commit were ungated for the same reason - they resolve later, and the check has to happen then. safeHttpUrl is backed by placeWebsiteSchema: http(s) only, capped at 500 characters. A booking url is deliberately free-form - shared's own contract says so in as many words, because people paste bare hosts and long provider deep links - so guarding it with the stricter schema turned a link somebody saved last year into dead text. safeExternalHref refuses the schemes that execute in this origin and gives a scheme-less value https://, which is what an address bar does with the same input. And the memories timeout was 20s while the server's own budget for a Synology call is 30s, doubled again when the session has expired and it re-authenticates: a slow NAS that answered before this branch would have started reading as disconnected. Those calls had no client timeout at all before; they have none again, and the ones that need to give up early carry an AbortSignal. The notes thumbnail also loses its NUL byte cache separator - it made git treat the whole file as binary, so a security fix landed in it unreviewable - and gains the same cache cap its chat sibling got. |
||
|
|
3e0bfbbb29 |
fix(client): give the desktop halves the same fixes their mobile twins got
Several of the fixes in this branch landed on a mobile screen while the desktop component with the identical bug sat in another package and was left alone, which leaves the two disagreeing about the same handler. So: the appearance tab flushes its debounced write on unmount, the journey share copy guards navigator.clipboard (undefined on a plain-HTTP self-hosted install), the admin collab toggle rolls back from a scoped snapshot instead of a stale one, the reservation modal parses metadata through the guarded helper rather than a bare JSON.parse that takes the route down, and the notification toggle surfaces its failures and stops racing concurrent toggles. The Journey components reached the memories provider-status endpoint with a raw fetch(), past every apiClient interceptor; they go through a small memoriesApi now. And the server half of the OSM menu_url finding: the client renders it through safeHttpUrl, so place-enrichment should not be handing a javascript: value out in the first place. The share section hides itself when the server says the link is not this user's to manage, rather than offering a create button that 403s. |
||
|
|
61d4fd4d64 |
fix(planner): scheme-check the booking URL, and stop the serial routing calls
A booking's url is rendered as a plain href to every other member of the trip and nothing ever checked its scheme, so a collaborator could store javascript: there. The place website next to it has been going through safeHttpUrl for exactly this reason, with a comment saying stored values predate the server check - this is the surface that was left. Both ends now: safeHttpUrl at the render site, and the scheme in the shared contract so nothing new gets stored, additively so existing rows still load. The per-leg routing rewrite turned one multi-waypoint request per run into one serial request per leg. A twelve-stop day is eleven round trips to a public OSRM in sequence before a single connector appears, and toggling km/mi does it all again. Group consecutive legs that resolve to the same mode into one request and fire the groups concurrently, keeping leg order on the join. Transport reordering wrote to the store and persisted with a bare catch, so a failed PUT left the sidebar showing an order the server never got; it reconciles now. Plus the smaller ones: the OSM menu_url rendered unchecked, expand/collapse-all persisting to a different storage than the day list reads, the debounced address search with no ordering guard, the accommodation save chain with no error path, and window.__trek_map holding a destroyed map. |
||
|
|
875f87b885 |
fix(planner): open the booking a place is linked to
The place inspector summarises the booking attached to the day assignment and then went nowhere: the strip is a bare div with no handler, no role and no tabIndex, and PlaceInspector was never handed an editor callback in the first place. Reached from a map marker, that strip is the only route a restaurant or tour booking has — only transports with two positioned endpoints get map pins of their own — so its fields and its attachments were unreachable. The identical lookup in the day sidebar has always carried a pencil that picks the editor by type. The inspector now takes the same onEditTransport / onEditReservation pair and does the same: a ferry, a flight, a train opens the transport form, everything else the reservation modal. That split matters — ReservationModal has no transport entry in its type list, so a ferry sent through it would have its type re-picked on save. Each handler arrives as undefined when the user lacks the matching right, which is what keeps the strip a read-only summary instead of turning it into a button that does nothing. Gating inside the handler would have re-shipped the reported symptom with a label claiming otherwise. The phone place sheet never rendered the strip at all, so the same booking was just as unreachable there, only invisibly. It shows it now and opens the same two editors, closing itself first — it hangs off the place selection rather than the sheet stack, so closeSheet() would leave the editor stacked on top of it. Closes #2012 |
||
|
|
8b88986ca8 |
fix(bookings): make the reservation import findable on the desktop panel
The import is not missing from the desktop — it sits at the far right of the bookings toolbar, next to the accent "Manual Booking", above an empty page with nothing but the mascot on it. On a fresh trip that is exactly where nobody looks, which is how it reads as absent. The empty state now offers the ways in that apply: add manually, import from a file, and AirTrail where it is connected. EmptyState grows an optional action slot for it, so every other caller is untouched. Closes #2007 |
||
|
|
df139a2d96 |
fix(ui): point import controls inward and give Google Maps its own mark
Every import button in the client wore an outgoing arrow, so the control that pulls a booking or a place list in looked like it was about to send something out. The places-import sheet managed to contradict itself inside one screen: Download in the header, Upload on the row below it, Upload again on the file step. Imports now take Download, or FileDown where the button opens a file picker; genuine uploads and the desktop drop zones keep Upload. The mobile places-import entry point wore an ellipsis, which promises a menu. The "open in Google Maps" buttons drew the four-segment Google *search* G, flattened to one colour — a glyph that reads as "run a search". They get a map pin instead, factored into one component so the desktop and mobile copies stop drifting apart. Closes #2005 Closes #2006 |
||
|
|
d0cc4e2f00 |
fix(planner): give the day's optimize button room by dropping its label
Every button in the day's route-tools row hands the day somewhere: show the route, open it in Google Maps, open it in CoMaps, reorder it. Two were already icon-only. Optimize carried a label on flex: 1 with padding '6px 0', which is no horizontal padding at all — survivable until CoMaps added a fifth button, at which point the text sat hard against both edges of its own button. Icon-only now, matching the two beside it. The label stays as the accessible name and the tooltip, so nothing about reaching the action changes, and the row has room for whatever gets added to it next. Closes #1981 |
||
|
|
9522e4ce4a |
fix(budget): keep an uneven split from corrupting the expense total
163.21 across two people is 81.61 and 81.60. Both are exact to the cent — the client splits properly — but the server discarded the total it was sent and re-derived it by adding those two as doubles, which lands on 163.20999999999998. That is the number that went into the row. It looked like a display quirk because everything routed through Intl came out right; only the places that print a number as it stands showed it. The stored value was wrong all along, which is why reopening the expense showed it back, and why the price stamped onto a linked booking carried it too. The domain already had this rule and followed it everywhere else — the settlement maths converts to whole cents before it adds anything. The payer sum was the one arithmetic still done in euros, in both the create and the update path, so it now goes through the same rule. The client side is what makes rows that are already stored wrong stop looking wrong: the expense form and the mobile cost sheet seed their total from a cent-clean value, and the three panels that print a booking's price verbatim tidy it when it parses as a number. Free text an import wrote there passes through untouched rather than becoming NaN. Closes #1964 |
||
|
|
0a7e460253 |
fix(planner): keep a parked car off the days between drop-off and pickup
A parking booking spanning the trip showed up on every day, sometimes in between planned activities. It now appears on its first and last day only, with drop-off and pickup wording, and contributes nothing to the days in between, not even a badge in the day header. Applied to the day plan, the mobile timeline, the PDF export and the shared trip view, so a shared link shows what the app shows. Rental cars stay as they are. The report names the car's smaller tag as the thing it does not want for parking, not as something to change, and a car's middle days move into the day header, which is what people expect from a vehicle they are driving around. The distance connector between legs is keyed off the row that gets hidden, so hiding it naively takes the "2 km" label with it, which is what #1504 was about. There is a test that fails without that guard. Each segment of a stopover flight or train can also carry its own booking reference now, since airlines often issue one per flight rather than one per booking. Legs already live as a JSON array on the reservation, so this is an optional field per entry: no migration, no contract change, and a booking without it renders exactly as before. Readable everywhere it can be entered, desktop and mobile, through the same blur treatment other booking codes get, plus the PDF and the calendar description. create_transport and update_transport preserve it too, otherwise they would accept the field and silently drop it. Closes #1937 Closes #1943 |
||
|
|
c8607ba234 |
fix(places): pin the image and website URL fields on the way in
image_url and website were bare strings in the contract, and the create/update bodies are open records, so the Zod pipe never looked at either. Both leave the database for something that treats them as a URL: the thumbnail is interpolated into hand-built marker HTML, and the homepage is handed to window.open, where a javascript: value would run in this origin rather than opening a page. Both are allow-listed on the way in now, next to hexColorSchema and checked in the handler the way validateRouteColor already is, on all three write surfaces so REST, the plugin RPC and MCP agree on what they accept. The entity schemas stay open: rows written before this still have to round-trip. safeHttpUrl covers the read side for exactly those rows, the same split safeHexColor uses, and the window.open calls it guards pick up noopener,noreferrer while they are being touched. |
||
|
|
05315ef7d9 |
fix(planner): pin the clock in the opening-hours test
FE-PDC-019 asserts that the collapsed row shows one specific line, but the row shows the current day in the place's timezone — and the fixture gives Saturday different hours from the working week. So the test passed Monday to Friday and took the build down every weekend, which reads as a broken feature rather than a test that forgot to say which day it is standing on. Pinned to a Wednesday, with the Saturday case kept as its own test rather than thrown away: it is the one that proves the day lookup happens at all, and it is the exact case that was failing. |
||
|
|
f02a4b5654 |
feat(maps): bill a place search as one session instead of per keystroke
Google charges autocomplete per request unless the requests carry a session token. With one, the keystrokes of a single search and the details lookup that ends it are charged together, which is also how a user experiences it: they looked up one place. TREK sent no token at all, so every debounced keystroke was its own billable request. On a busy trip that is the largest line on the Places bill, and it is paid by whoever owns the key, which for a self-hosted instance is the person running it. A session opens with the first suggestion request and closes when the user picks a result. It has to close on the abandoned paths too: a token carried into the next search would merge two searches into one billed session and scope the suggestions wrongly. So the search box clearing it, the text-search fallback and the error paths all end it. The token is validated on the way in. The autocomplete body goes through the Zod pipe, the details query does not, so the controller shape-checks it there. Anything that does not match Google's URL-safe 36-character form is dropped rather than forwarded, which falls back to per-request billing instead of breaking a lookup. A cached details hit never reaches Google and so never closes its session. That is billing-neutral: an unclosed session is charged as a plain autocomplete session, which is still cheaper than the per-request behaviour it replaces. |
||
|
|
0e7c516279 |
Merge branch 'dev' into feat/comaps-integration
# Conflicts: # client/src/components/Planner/DayPlanSidebar.tsx |
||
|
|
97c725ea14 |
feat(planner): collapse the three export buttons into one dialog
PDF, ICS and GPX each had their own button in the day-plan toolbar, and two of them carried a hover menu of their own. That is five controls' worth of width in a pane the user can drag narrower, so the exports were the first thing pushed off the edge, and a hover menu is not reachable at all once its button is gone. One button opens a dialog instead: the day plan as a PDF, the bookings as a calendar (a one-off .ics or a live subscription) and the map data as GPX in the three scopes a device wants. Every row reports that it is working, which the PDF export needed most: it fetches a 226 kB builder before it renders anything, and until now the toolbar button simply sat there. Two strings the old ICS menu had hardcoded in English, the download and the subscription, come from the locale files the phone export sheet already used. The toolbar no longer needs the icsHover flag it only held to keep its menu open, so that comes out of the sidebar's state as well. |
||
|
|
974c240043 |
fix(planner): give the reservation time its own chip
The status, the time and the flight or train number sat inside one pill strung together on a middle dot, and the dot was baked into the translation itself: planner.resPending ended with " · " in all 23 locales. That made the row read as one run-on sentence, and it meant the separator could never move without a locale change. They are separate chips now, same tint so they still belong together and nowrap so the time cannot break away from the status it qualifies. The separator is out of the locale files, which also retires the regex the transport detail modal needed to strip it back off. |
||
|
|
8065af28ef |
feat(planner): open places and day routes in CoMaps
Add CoMaps, the offline OpenStreetMap navigator, alongside the existing map integrations. A geolocated place gains a CoMaps entry in its navigation menu, and a day's route toolbar gains a button that hands the day's bookended stops to CoMaps in that day's travel mode. CoMaps' route link carries only a start and a destination, so a two-stop day goes as a route and a longer one as named pins rather than silently losing the stops in between. Links use the https form so a device without CoMaps installed lands on comaps.at instead of a dead scheme. The hotel-bookend logic behind the Google Maps export was duplicated between desktop and mobile; both now share dayExportStops so the two links cannot disagree about which hotel legs are real. Addresses discussion #1904 |
||
|
|
0d737c2466 |
fix(dayplan): resolve per-leg transit departure time within its own day
Review of the previous commit surfaced that the departure-time lookup keyed a trip-wide coordinate index, so a POI revisited on another day (a supported pattern) leaked the other day's time into the prefill. Resolve the departure time from the leg's own day instead; the name index stays trip-wide (a name is coordinate-stable). Also from review: wire the per-leg option on the mobile sidebar too (it was desktop-only), drop the now-dead route-leg fallback in the outgoing menu opener, and accept single-digit hours in the time normalisation. Adds a regression test for the revisited-stop case and pins the hotel-bookend fallback time. |
||
|
|
ecea39d912 |
feat(dayplan): offer public transport on any connector leg (#1281 follow-up)
The per-segment travel-mode menu now carries a "Public transit" entry on every editable connector: stop-to-stop legs and the boundary hotel/booking bookends alike. Choosing it opens the existing transit search pre-filled with the leg's origin, destination and the stop's departure time, instead of drawing an OSRM road route. A connector only renders when both endpoints are located, so the option is inherently limited to legs that can actually be searched. A leg's coordinates are resolved back to named, timed points from the trip's places, accommodations and booking endpoints. TransitSearchPanel gains an initialTime prop so the departure time can be seeded. |
||
|
|
3c5e406229 |
feat(places): link an expense to a place, the way bookings already do (#1917)
* feat(places): link an expense to a place, the way bookings already do Recording what a museum ticket or a guided tour cost meant leaving the place, opening the Costs tab and typing it in again — while a booking has had a Costs block in its own form all along. The place form now carries that block. Same component, same flow: with nothing linked it offers Create expense, which saves the place and then opens the Costs editor for a new expense pointing at it; once linked it shows the expense with edit and remove. budget_items gains place_id beside the reservation_id it already had, with the same nullable FK and the same ON DELETE SET NULL. Desktop and phone both, and the mobile editor carries the link through its own save path rather than the desktop one. The expense belongs to the place, not to a day assignment, so a place sitting on five days is not five tickets. Hanging it off the assignment would have meant adding a place to three days quietly creating three expenses and removing a day quietly deleting one. Deleting a place takes its expense with it in the same transaction and announces it, on every path that deletes a place: REST, both MCP tools and the plugin RPC. Closes #1298 * test(places): teach the RPC stubs about linkedExpenseIds The delete paths read the linked expense before the place is gone, so the two hand-built PlacesService stubs threw on the new method instead of reaching the hook, the remove and the broadcast — four failures out of one missing line. |
||
|
|
df3edac7b9 |
feat: six community requests — import dedup, accommodations in the feed, OAuth refresh race, visited-from-trip, journal jumps, phone packing rows (#1915)
* fix(places): match imports on the provider id so renamed places are not duplicated Re-importing a Google Maps list duplicated every place the user had renamed. buildDedupSet only ever collected names and, for unnamed places, coordinates, and isPlaceDuplicate returns on the name before it reaches the coordinate fallback, so a renamed place was findable by neither. The ids were stored all along, and even backfilled onto matched places, they were simply never read. Match on google_place_id / google_ftid / osm_id first, keeping name and coordinates in their old roles below it. Widening the coordinate check instead would have merged the restaurant and the bar in the same building. The collection copy carried a hand-mirrored port of the same predicate; it now uses the shared helpers and gets the fix with them. Closes #1550 * feat(calendar): put accommodations into the subscribed feed as all-day stays A hotel keeps its dates on the linked stay, not on the reservation: the booking form writes reservation_time = NULL for type 'hotel', and day_accommodations carries start day, end day and the check-in/out clock. The feed only ever read the reservation, so a stay showed up once on the arrival day at best, and not at all when it came from the booking form. Join the stay in and emit the reservation as an all-day range over every night, which also keeps it correct when days are reordered later — the dates now come from the day rows rather than from the copy stamped at creation time. Check-in and check-out become their own timed events when the stay records a clock, because an all-day event cannot carry the time you actually need reminding of. The check-in window, when set, becomes that event's DTEND. Closes #1586 * fix(oauth): treat a concurrent refresh as a race, not as a stolen token Refresh rotation is a race by construction. Several MCP clients share one refresh token, both post it within the same second, and the loser was read as a replay: chain revoked, MCP sessions torn down, browser window asking to log in again. That is the daily re-authentication people were seeing, not a short lifetime — access tokens last an hour and refresh tokens roll for thirty days. A revoked refresh token now only counts as a replay once it is outside a 30-second grace window, and only when the rotation that revoked it left a successor still alive. Inside the window the caller gets its own pair branched off the same parent, so both clients walk away with a working token. Explicit revocation leaves no live successor and a chain killed by a real replay has none either, so neither can slip through the window. Closes #1007 * feat(collections): mark a place visited from the trip it belongs to A place saved in several lists had to be opened in each of them to say you had been there, which is the wrong way round: you find out you have been somewhere while looking at the trip, not while browsing the library. The save picker now shows each list's own status and cycles it in place, with one action for "visited, wherever this is saved". The planner's selection bar and the mobile bulk toolbar carry the same thing for a whole selection — select all, one tap, done. Matching happens on the server with the signals findMembership already uses plus the source link a place saved from this trip carries, so a copy renamed inside a list is still found. Lists you may only read are skipped rather than refused; a shared list you cannot edit should not stop you marking your own. Closes #1469 * feat(journey): jump to the top or back to the last entry in a long journal Everything that adds to a journal sits at the top — the entry button, the gallery upload — and where you left off sits at the bottom, so adding one photo to a long journey meant scrolling the whole way twice. Two buttons float over the middle of the feed, clear of the Add Entry column on one side and the reorder arrows on the other, each appearing only when there is more than a screenful to travel in that direction. They live in a zero-height sticky box so they take no layout space and never push the timeline around. While in the header, the status pill also picks up the height of the round buttons beside it. Closes #1088 * fix(packing): give the item name the row back on phones An item name had roughly a third of the row to itself: a spelled-out sharing sentence, an empty weight column and two round action buttons took the rest, so anything longer than a word was cut off. Sharing is now the icon it always was next to a label rather than a sentence in the layout, an unset weight prints nothing instead of a dash, and edit/delete sit behind one overflow button that opens the same inline menu the bag picker already uses. The to-do list needed none of this — it wraps its chips onto a second line already. Closes #1525 * docs(wiki): cover the six community changes - Calendar feeds: accommodations as an all-day range plus check-in/check-out - Collections: setting the visited status from a trip, and how places are matched - Journey: the jump buttons on a long journal - Packing: what the phone row keeps and what moved behind the overflow button - Places: re-importing a list no longer duplicates renamed places - MCP setup: the rotation race no longer ends an OAuth session * test(collections): assert the stored status instead of a private method tests/ sits outside the build tsconfig, so reaching into getPlaceById only showed up in the dedicated typecheck step. Reading the row is the better assertion anyway — it pins what was persisted, not what a getter returns. |
||
|
|
9ce6eed9ad |
feat(places): export a trip as GPX (#1907)
* feat(places): hand a trip back as GPX TREK reads GPX but never wrote it, so a planned trip could not reach the offline map or the handheld it was planned for. The importer already fixes the mapping: a wpt is a place, a rte or trk is a place with route_geometry, so writing is that reversed. Days have no import counterpart and become routes of their stops in order, which is the part that puts a plan on a device. XMLBuilder comes from the same fast-xml-parser the importer parses with, so nothing new is pulled in. * feat(dayplan): a GPX button beside PDF and ICS Scopes are worded by what someone wants on their device rather than by the GPX element: the whole trip, just the places for an offline map, just the days to follow a plan. An empty trip says so instead of reporting a failed download. * feat(mobile): GPX in the export sheet, which is no longer calendar-only The sheet held the PDF export next to the calendar entries and was still called Export Calendar; with GPX beside them the name was simply wrong. * i18n(dayplan): translate the GPX export into all locales * docs(map): document the GPX export * fix(dayplan): drop the GPX tooltip and centre both export menus The tooltip sat under the cursor exactly where the menu opens, so hovering the button produced both at once. The label lives on as an aria-label. Both menus now open centred under their button instead of hanging off its right edge. |
||
|
|
e5c885e768 | client: target the real bookend endpoint from hotel/booking mode menus | ||
|
|
8c3a235c3b | client: editable travel mode on booking and hotel-bookend connectors | ||
|
|
5679ea76d2 | client: resolve boundary-leg modes from either endpoint in the day sidebar | ||
|
|
899316d5f8 | client: add resolveLegMode helper for endpoint-aware leg modes | ||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
36cdb36875 | Keep the pictures as a grid, wider tiles instead of a lead image |