mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 15:06:32 +00:00
41b18f970c320d9d82bffdde41fbcba4d4c8e5ba
141 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
41b18f970c | feat(vacay): add manual school holiday calendars | ||
|
|
2c06c9904b |
feat(collections): a list as a file, so it can leave the instance (#2198)
Sharing a list works between people on the same TREK. This is the other case: giving one to somebody who runs their own, or keeping a copy. Export sits beside Edit and Share in the list header and downloads the open list; Import sits beside New list in the rail, reads a file, shows what is in it and makes a new list of it. Desktop only, which the route already decides. What travels is what somebody wrote down: the list's name, colour and description, its labels, and each place with its address, coordinates, notes, status, contact details, category name and labels. A file is dull JSON on purpose, so it can be read and edited by a person. What does not travel is everything that only means something on the instance it came from. Ids of every kind, since the receiver issues its own and a category crosses over by name. Star ratings and members, because a rating is an opinion somebody gave the people on that list. Provenance into trips, which points at rows the receiver cannot see. And an image path under /uploads, which resolves on the sender's server: only an absolute https URL survives. A file is a stranger's JSON and is read as one. It is parsed in the browser that chose it and never uploaded; the parsed list goes through the same contract on both sides; and a link that is not http(s) arrives as nothing rather than as an anchor. Strict where it must refuse, forgiving where it can drop: a file that does not say what it is never reaches the importer, while one unreadable place inside it is skipped and counted, and one bad field on a good place costs that field alone. An import is always a new list. Importing into an existing one would mean deciding what happens to a place that is already there, and every answer to that is a merge nobody asked for. |
||
|
|
a9907e2f10 |
feat(plugins): let a plugin answer place searches from its own index
TREK searches its own place index and OpenStreetMap. Neither carries a rating, so "the best rated hotel around here" has never been answerable, and an operator with a better index for their region had no way to reach the search box at all. The searchProvider hook is that way in: a plugin is handed the query, a limit, the language and the coordinate the search is biased toward, and answers with places in the same shape the core search returns, plus a rating. It sits beside the core search rather than inside it. The plugin runtime already imports PlacesModule, which imports MapsModule, so asking the providers from the maps service would close a module cycle — the same reason the other fourteen contribution controllers stand next to what they contribute to. The client asks both at once and merges, at the single point every caller already goes through, so all nine search surfaces get plugin hits without knowing the hook exists. A plugin index may never hold up the list: the client stops waiting at two and a half seconds and the host gives a provider two, which is also why the budget is the shortest of any hook here. With no such plugin installed the route answers without reaching the runtime at all. Every hit is normalized host-side and shared by the REST route and the MCP tool, so a cap that holds for the browser holds for the assistant: coordinates are range-checked, strings capped, the rating clamped to 0..5, a website must be http or https, and ids are namespaced so they can never collide with an OSM one. search_places_via_plugins is the MCP half, because what the user can see the assistant has to be able to see; search_place now points at it. |
||
|
|
3f0eb210d8 |
feat(roadtrip): move a stop reached after midnight onto the day it is reached
A day that runs past midnight used to keep every one of its stops on its own card, so day one ended at 03:12 with three places nobody reaches on day one and day two began with a drive that had already happened. The rail now lays the stops out by the date they are actually reached: when the clock goes backwards between two arrivals the ones after it move to the following day, into a block that says where they came from, and the night drive and its kilometres go with them. It is an arrangement, not a write — the day planner beside it is untouched, nothing reaches the database, and a shorter stay the evening before puts everything back on its own. The map follows the same arrangement, because a boundary drawn in the rail and not on the map is two answers to one question. Day one now ends where its last reachable stop is and the line, the legs and the totals are cut to match. Two switches in the driving settings, off by default. "Connect the days" routes the gap between one day's last stop and the next day's first, which is real driving the rail has never drawn, and counts it. "A colour per day" then puts the days back into a line that has become continuous, and tints each day header to match. Collapsing a day from its header takes its route and its places off the map as well as out of the rail, and the categories in the corridor panel fold the same way. The refuel band is one full-width red band with the pump as its button, the results are chips, and the driving dialog is two columns of panels with the figures on one right edge. Also fixes a via that would not adjust the route once dragged. Five separate causes, all measured rather than guessed: leg threading used the card index instead of the owning day's, the anchor lookup returned chain indices, it filtered by the card rather than by the anchor's owner, seams were routed without their vias, and a seam once routed was never re-fetched. |
||
|
|
96ab748f34 |
feat(roadtrip): let one stop say how full it fills
The fill level was one figure for the whole trip, and a trip does not have one. A motorway rapid charger is worth about 80 % because the last fifth costs as long again; the charger at the hotel is worth all of it because the car stands there all night. Between those two the difference is a whole leg, and a single setting has to be wrong about one of them. So the badge on a refuelling stop is now the way in: it shows what that stop will actually fill to, its own figure or the traveller's, and opens a panel of the six answers people actually give. Under them it says what the choice buys in kilometres, because 80 % is an abstraction and "about 290 km from here" is the thing being decided. Picking the figure a stop already has hands it back to the setting, the same gesture the kind picker uses to un-pick a kind. The value lives in places.fill_percent, beside stop_type, because it is a property of the stop rather than of the person. Null means "follow the setting", which is every stop that predates the column and every stop nobody has an opinion about, so nothing changes for a trip that never touches it. Two pieces of shared machinery came out of it rather than being copied. The popover chrome — measure before showing, flip when the window is short, close on an outside click, Escape or a scroll — is one component now, used by the kind picker and this one. So is the assembly of the range from seven settings, which the routes hook and the rail both need and which would have drifted the first time a field was added. |
||
|
|
aaf6559d5d |
feat(roadtrip): state a range as the car it comes from
A range could only be typed as one number, and that is the number fewest travellers actually know. A tank size is on the filler flap, a consumption is in the manual, a battery capacity and a kWh per hundred are on the data sheet. All of them divide into the same figure, so the dialog now takes either and does the division itself. What the parts say wins over what was typed, because it is the more specific answer, and the range row goes read-only when they do so there is never a second answer to the same question. Of ABRP's list only battery wear is here. Plug type, reference speed, relative speed and drive style shape a consumption PREDICTION; this addon does not predict one, it divides a capacity by a consumption the traveller states, so those fields would add typing without moving a kilometre. Wear is different in kind: it shrinks the capacity itself, and on an older car by enough to decide whether the last leg of a day arrives. Storage stays metric throughout, the same rule the range itself follows. An imperial traveller types gallons, miles per gallon and kWh per 100 miles and gets them back unchanged; mpg is the inverse of litres per hundred rather than a scale factor, which a straight multiplication would have stored as a car four times as thirsty. The gauge under the picker is the argument that these two ways of stating a range are the same thing, so it answers while the figures are still being typed rather than after the field is left. It draws the tank the day it left the factory: the filled part is what a stop actually puts back in, the hatched tail is what age has taken off, and both are things the bare number cannot say. Also here, from the same round: a fill-up that stops short of full. Nobody charges to 100 % on the road, and counting every stop as a full tank overstated everything after it by that last fifth. Stops that refuel now carry a badge saying how far they fill. |
||
|
|
5b147af0b6 |
feat(roadtrip): tell petrol from electric, and shape the refuel band like a drive band
Four things that only showed up once it was on screen. The range budget treated a petrol station and a charger as the same thing, so an electric car pausing for coffee at a filling station had its battery refilled on paper: the warnings went quiet for the rest of the day and the driver was told nothing. A vehicle can now be named, and then only what actually refills it counts — and the search asks for that kind alone instead of offering pumps to a car that cannot use them. Not saying keeps the old behaviour, which is what everybody who never opens the dialog is on. The band was its own shape in a rail made of drive bands. It is one now: the same rail, the same height, the same type size, a warning tint instead of a ring, and the figures where a drive band keeps them. The buttons are icons of the same 18px square the shuffle mark uses, so the band stays one line beside its neighbours. The old range warning stood down where the band shows. Both at once said the same thing twice in the wrong order — the answer above the problem — because the warning marks the stop somebody finds out at while the band sits on the leg the fuel ends on. Two measured corrections to the search itself. The server turns a box into a circle of half its diagonal and silently narrows anything past 20 km, so a box of ±25 asked for 35 and came back flagged as narrowed EVERY time, which read on screen as "we could not check"; ±14 asks for 19.8 and is answered in full. And the circle is centred 12 km before the dry point rather than on it, because only road already driven is any use — centred on the point, half the circle was spent on road nobody can reach. An answer that finds nothing now leaves the button rather than only a close. The place search is a shared public service that does time out, and "it did not answer" with no way to ask again reads as broken rather than as busy. |
||
|
|
95d1752a3d |
feat(roadtrip): suggest where to fill up, not just warn that you cannot
The range budget has always been able to say "you have driven 245 km on this tank" and never "and here is where to do something about it". The review put it plainly: the value is in the suggestion, not in the warning. The warning sits on the stop the car ARRIVES at, which is where somebody finds out. The fuel runs out earlier, often most of a leg earlier, and a filling station offered at the warning is one the car cannot reach. So the dry point is computed where it actually is, a search runs around it on request, and what comes back is filtered to what can be reached. Three filters, each one there because its absence gives a wrong answer rather than an untidy one. Only what lies before the tank empties, because a pump past it is on the far side of an empty tank. The detour counts double, because 8 km off the road is 16 km of driving. And a reserve is kept back, because the range figure is a round number from a manual, not a measurement. Sorted by the detour rather than by how far it gets. Measured on a real day between Hamburg and Berlin: the station that got furthest was 9.2 km off the road for one kilometre more of range, while one 0.9 km off sat almost as late. Deduplicated by name and half a kilometre, which also came out of that measurement. OSM maps a charging site as one node per socket: the first three offers were the same "autostrom plus GmbH" three times over. The four nodes sat in two pairs 6 m and 7 m apart with 450 m between the pairs, while genuinely different branches of one chain were 18 to 36 km apart — so anything from 500 m upwards separates the two cases. An empty answer says which kind of empty it is. "Nothing on the reachable stretch" after a request that failed or was cut short states something that was never checked, and the server flags both cases itself. Offline it does not offer at all: the place write queues offline but the day assignment does not, so accepting one would leave an orphan. Nothing is searched until it is asked for, and one press is one request against a small circle rather than the sixteen boxes a corridor run costs. Accepting hands the choice to the same popup a corridor hit goes through, so the stop kind, the dwell time, the position, the via re-anchoring and the re-route are the paths that already work. The dry point counts DRIVING metres only. The day's drawn line concatenates every run, ferries and walks included, while the budget skips those, so walking that line to the same figure would overshoot by the length of any crossing and put the marker out at sea. |
||
|
|
77162aa8d5 |
feat(roadtrip): avoid tolls, motorways and ferries, through a second engine
The switch a road tripper keeps asking for has never worked. OSRM's `exclude` is the only
avoidance TREK has, and the two public hosts it ships with answer it HTTP 400 on every
leg: their car profile is built without excludable classes, and their own Lua says why
("no memory for classes on demo server"). So "drive this without the tolls" produced
nothing at all on a default install, and the fallback inside the alternatives bar quietly
gave up after one refusal per host.
FOSSGIS runs a public Valhalla on the whole planet next to that OSRM, keyless, and there
avoidance is a runtime weighting rather than a profile that has to be rebuilt. This adds
it as a second engine asked only the questions the first cannot answer, and puts three
switches under the road trip settings that change the road itself rather than offering an
alternative beside it. Paris to Lyon measured 465.8 km / 297 min with tolls against
492.0 km / 454 min without; Hamburg to Copenhagen comes back ferry-free at 469.8 km,
which OSRM cannot produce at all — where a ferry IS the crossing it answers NoRoute
rather than driving round.
Four things the docs would have got wrong, all measured against the live instance:
- `format=osrm` does not carry `has_toll`. That flag is the entire reason for doing this,
and it exists only in the native answer — which is also 5.7x smaller, because the OSRM
shape drags every intersection along. So the adapter translates instead of borrowing a
familiar envelope.
- Without a `radius` Valhalla refuses a point it cannot bind to a road, where OSRM still
snaps one. Vienna's Stephansdom, in a pedestrian zone, answers error 442 until a search
radius is allowed. TREK is full of such places, so every location carries one.
- Ten locations is a hard cap and a road trip day is bundled into one request. A day over
it is split into runs overlapping by one point, which is exact rather than approximate:
a measured six-stop day came back within two metres and under a second of the same day
asked whole.
- Its drive times run 5.4 % above OSRM's over ten European legs and 36 % above on one, and
those times feed the arrival clocks of a day. So a day is answered by one engine or by
neither, never by both, and a piece that will not answer loses the whole day rather than
half of it — the schedule chains leg times blindly and would otherwise print a complete,
plausible, silently wrong timetable.
Because the weighting can fail, the honest surface is the answer rather than the request.
`use_tolls: 0` still returns a tolled road where there is no untolled crossing, so what
the road actually turned out to be is read back per leg, and a day that could not avoid
what it was asked to avoid says so on its own header instead of wearing a label the road
disproves. The switches disable themselves, with a reason, on an instance pointed at its
own OSRM: that operator chose against public hosts, and a second one they never asked for
would undo the choice.
Two things that would have broken quietly and did not:
- The route cache keys on `profile:unit:coords` and is shared with the day planner, the
studio and the booking geometry. Without the avoidance in the key, flipping a switch
returns the old road out of cache in under a millisecond — and worse, an avoiding answer
stored under the plain key hands those other callers a road nobody drives.
- `snapped` is OSRM-only and carries the dashed kerb-to-door spur from #2273. Valhalla
echoes the coordinates it was given and never says where it bound them, but the ends of
a leg's line are exactly those points: measured against Füssen, its first vertex matches
OSRM's snapped location to the digit.
The alternatives bar gets the same engine, which finally makes its "no motorway" and "no
tolls" offers appear on a default install, plus a ferry-free one. `sameRoad` now compares
geometry rather than numbers there, because two engines' distances for the same road sit
kilometres apart and the old 200 m tolerance called that a discovery.
Desktop only, like the whole road trip mode: nothing under client/src/mobile renders it.
|
||
|
|
756e0fb908 |
fix(places): let the pool follow the open day, and say that it does
The map has narrowed to the selected day on the Planned filter since #2024. The pool beside it did not, so a trip with 55 planned places read 55 in the tab and drew five pins on the map, with nothing anywhere to say the two were answering different questions. Reported twice in the same thread as the map being broken: you pick a day, open Places, tap Planned, see everything you ever planned, and the map shows a handful of it. The list and the tab count now use the same set the map does. Unplanned deliberately stays trip-wide: a place assigned to some other day is planned, whichever day happens to be open. A note under the tabs says so and closes the day when dismissed. It sits there rather than over the map because a chip on the canvas is out of reach on a phone, which is where this was reported from. |
||
|
|
7e3b9fd039 |
fix(collab): keep every panel on screen, and stop leaking spool and links
The desktop layout rendered notes and links as a pair and dropped whichever of the two was on its own, so turning the new Links feature off on an otherwise default install made the Notes panel disappear. Both rows are now split by kind and rendered only when they hold something, and one function decides what a panel id renders, because the branch that lost a panel did so by not listing it. The message and its attachment rows are written in one transaction. The caller has already committed the image bytes by then, so a message that landed without them left those bytes with nothing pointing at them and nothing to sweep them. Deleting one now drops the blobs after the rows are gone rather than before. A body the Zod pipe rejects used to leave up to four spooled images on disk: pipes run after interceptors, so multer had already written them and the handler's own cleanup was never reached. An interceptor removes them on any failure before the handler, which also covers every other schema mismatch, and the length check that could never fire is gone. Adding, pinning and deleting a link now says something when it fails instead of raising an unhandled rejection, the list has real types, and the rejected-image toast is translated in all 23 locales rather than shipping English everywhere. The picking, previewing and clearing of chat images moved into one hook both shells use: it was copied into two, and both rebuilt every preview on each change and leaked an object URL per image per pick. Tests: the four link routes end to end with the validation pipe the harness was missing, the image filter against the disguised-extension case, and the layout combinations that drop a panel. |
||
|
|
9543fdc3f2 |
feat(collab): add shared links, chat images, and plugin fullscreen
Adds a Collab Links panel (add/pin/delete) with translations in every locale so tab and button labels are not raw i18n keys, chat image attachments (up to 4), and native fullscreen on plugin iframes. Includes Zod request DTOs and CollabPanel coverage for the Links tab. |
||
|
|
6a3d4b1281 |
fix(budget): stop receipts deleting files, and share the upload path
Attaching a receipt no longer lets the budget domain delete one. Trashing the file was reachable from the REST route, the MCP tool and the plugin RPC, none of which check file_delete, and a receipt id is any file on the trip, so anyone who could edit an expense could trash a document they were not allowed to touch. A concurrent edit could do it by accident: the client sends the full set of receipt ids from a snapshot taken when the form opened, so saving an expense someone else had just attached a file to moved that file to the trash. Removing a receipt now removes the link and nothing else. The link row is treated as shared, because it is. One row can tie a file to a place and to an expense at once, so the sync clears only the receipt half and leaves the rest, the foreign key is SET NULL rather than CASCADE, and a receipt already in the trash keeps its link so restoring it comes back attached. The upload and its rollback moved into one module both shells call. It was copied into six places, and all six rolled back with the permanent-delete route, which only accepts a file that is already in the trash and therefore answered 404 every time: a failed save left every uploaded file on the trip, attached to the expense, and the next attempt uploaded another copy. It now trashes first, keeps the pending files for the retry, and says so when a file could not be removed instead of swallowing it. Escape in the receipt viewer no longer closes the expense form behind it, the download fallback uses a real accent class rather than a utility that does not exist, and the upload indicator is actually switched on. Tests: the receipt link behaviour on both service paths, the four new branches in the files service, the upload module, and the viewer. |
||
|
|
06ecc5135c | feat(budget): attach receipts and invoices to expenses | ||
|
|
f5d6af9c77 |
fix(planner): steady satellite pill, responsive places rail, day-scoped add
The satellite toggle sat on an offset that included --day-panel-h, so it hopped every time the day panel was collapsed or expanded. On the desktop map that panel is a centred card between the sidebars and never reaches the pill at the left edge, so the lift only applies on a phone now. In the places rail the two add buttons split the width evenly once a day is open, and every button drops to its icon when the rail is dragged narrow. The filter tabs span the rail and keep their own text widths. The count is a badge across the rail; the search field got the outline the controls around it use. An empty day offers a button that opens the place form pointed at that day instead of only stating that nothing is planned. The day is cleared again when the form is opened to edit, which it was not before. The trip tab bar no longer carries a native title repeating its own label; the full name moved to aria-label for the tabs whose label is abbreviated. Three maps tests drove the index switch through app_settings, which no longer holds it, and now move TREK_PLACES_ENABLED instead. |
||
|
|
9fa0ceaf49 |
feat(places): add a place straight into the day that is open
The places pool sits one click away from the plan, and a place you already know belongs to today had to go through the day picker afterwards to get there. With a day open, the add button splits: the left half still opens an empty form, the right half opens one that assigns the place to that day on save. The plumbing was already there. placeFormDayId has assigned a freshly created place to a day since #1998; nothing in the places rail ever set it. The main label shortens to "New place" while the split is out, so both buttons fit side by side in the rail without either one truncating. The second button stays mounted and collapsed rather than unmounted, so it has something to animate out of: width, padding and opacity over 180ms, and nothing at all under prefers-reduced-motion. Collapsed it is also out of the tab order and hidden from screen readers, because a zero-width button is still focusable otherwise. Both columns of the place form are 320px now. Two panels of different widths flanking one form read as a mistake rather than as a hierarchy, and neither of them is the more important one. |
||
|
|
900e4806ad |
fix(places): centre the empty states, and give the details column one too
Five states, one shape. EmptyState grows a `fill` option: the stack already centres what it holds, but with no height of its own there was nothing to centre inside, so every one of them sat pinned under the header of its column. Optically centred rather than geometrically. The extra room goes underneath, which puts the mascot slightly above the middle: in a column you can scroll, dead centre reads as having fallen to the bottom the moment the list is short. The details column's own placeholder joins them, with the idle look rather than the sad one, because nothing being picked yet is a state and not a fault. Its line is shortened to fit on one: "Pick a result to see more", in all 23 languages. The corridor panel, the saved-places list and the place details all dropped `compact`, so the five now read at the same size as the road trip rail. |
||
|
|
4a88dbcbcf |
fix(places): say where a result came from, and stop asking questions in the form
Six changes to the place picker and the card behind it, all of them about a panel saying less and meaning more. The TREK place index has no switch in the admin panel any more. It decides whether a search leaves the instance at all, which is a property of the deployment rather than a taste: an operator pins TREK_PLACES_ENABLED=false in their compose file, and it cannot be turned off from a browser by whoever holds an admin account that day. The switch never shipped, so nothing to migrate. The standing "Using OpenStreetMap" notice above the search box is gone. It was a sentence about configuration in the middle of a form about a place, and it answered a question nobody asked while leaving the real one open. Each result now carries a small mark instead: TREK, OpenStreetMap or Google. It sits on the row rather than above the list, because a list can be two indexes interleaved and a heading could only name one of them. The saved-places filters open across the full width of the row. Two of them sit side by side, only one can be open at a time, and list names were being cut at about ten characters for no reason. Three empty states use the mascot the rest of TREK uses. The place details panel no longer tells the reader to go and ask an administrator for a Google key, the saved-places list and the road trip rail no longer explain themselves in two lines of grey text. That needed one new mood, `sad`: `sleepy` reads as falling asleep, which is a different thing from finding nothing. The Kind of stop field is out of the place form. It is a road trip concept, and the road trip view already sets it from the stop's own number, where the choice is next to what it changes. |
||
|
|
5ac6ce40be | fix(i18n): add translations for new notification event labels | ||
|
|
b2d9b25bb6 |
fix(roadtrip): say which filter emptied the list, and stop typing a limit racing itself
Four small things, and one that was not this branch's fault but is now its neighbour. The driving limits saved on every keystroke. Typing "180" sent three settings writes carrying 1, 18 and 180, unordered and concurrent over HTTP/2, so whichever the server committed last won: the number that decides every over-budget warning could quietly end up a tenth of what was typed, and only show it after the next reload. Typed locally now, committed on blur or Enter, and a value that did not actually change writes nothing. The empty-result message always blamed the name box. Four controls narrow that list and only one of them is the name, so picking a section with no hits produced `Nothing on the way matches ""` — empty quotes naming a filter the reader never set. The count line above it was gated the same way, so the section case lost its "x of y" as well. Both follow whatever actually narrowed the list. The check-out dropdown printed a bare ISO date, the only place in the planner that did; it goes through the same helper as the rail beside it. Switching an addon off left open MCP sessions holding its write tools. A `when:` gate runs once per session, at attach time, so the tools stay registered and callable while the REST half answers 404 for the same user in the same moment — and the list that decides whether to tear those sessions down had drifted: airtrail and collections gate tools and were missing from it, which this addon would have inherited. The list now sits beside ADDON_IDS and a parity test reads the gates out of the source and holds it there. Read out of the source rather than out of a runtime registry on purpose: a runtime set only holds the gates whose modules the caller happened to import, so it would pass in a suite that imports none — which is the shape of test that hid this in the first place. RoadtripLimitsCard gets the test it never had, including the one case that matters: three keystrokes are not three writes. |
||
|
|
676c82a308 |
feat(roadtrip): remember which track a day follows
The vias already made the day follow the road; nothing said which road. After a reload the badge could only say "shaped by hand", and re-fitting after the stops changed meant finding the track again by eye. One row per day, written in the same transaction as the chain it describes: a day claiming to follow a road whose vias never landed would be worse than a day claiming nothing. Three states rather than two, which is why the field is an object and not an id — absent leaves whatever the day followed alone, null says it follows nothing, an object records the road it now takes. A bare nullable id could not tell "do not touch this" apart from "forget it", and a hand-dragged via on the same day must not wipe the label. The foreign key is the point: delete the imported track and the label cascades with it, so no day is left claiming to follow a line nobody can see. The vias stay, because they are the drive. Both write paths check that the place is a track of this trip before it becomes a label — permission alone would let another trip's place id, or an ordinary place, become a name that can never be drawn. Also clears three reliability findings the scanner raised on this branch: two default sorts given an explicit comparator, and a trailing-slash regex the scanner is right to call backtracking, walked instead. |
||
|
|
6fca238440 |
feat(places): import the stops of a shared Google Maps route
Pasting a /maps/dir/ link into the list-import box answered "could not extract list ID", which is the whole of the complaint: people share a drive far more often than they share a list, and it is the same gesture — somebody pressed Share in Google Maps. Which screen they were on is the URL's business, so the box takes both and the controller decides. No API key and no call to Google for the link itself: the stops are in the URL. Three shapes arrive — the path form with its data blob, the documented ?api=1&origin= form, and a short link that is one of those after a redirect — and a stop the link spells out in coordinates needs no geocoder at all. One that is only a name is geocoded, keeping the name from the link rather than the one the geocoder answers with: somebody who typed a nickname into Google should not find a street address on their trip. A name nobody can place is left out and counted rather than failing the import, because a route of six stops with five findable is five stops more than the traveller had. Coordinates are read as 1d/2d, longitude before latitude, which is the reverse of a place link and the one way to put Berlin in Somalia. A blob that does not line up with the stops is ignored entirely: half a link read confidently is worse than one read as names. |
||
|
|
761ec4e66f |
feat(roadtrip): let a day drive along an imported track
An imported GPX track was a line drawn beside the day: the map showed the scenic route while the day kept driving the motorway, so the distance, the drive time and every warning belonged to a road nobody was taking. Norway publishes its scenic routes that way, and so does every touring club worth importing from. A track becomes a route the day takes by refinement, not by conversion. Route through what we have, find the point of the track the road misses by the most, make that point a via, go again. Each round roughly halves the worst miss, because the new via lands in the middle of what the last one left unfollowed, so a scenic route usually fits inside ten vias — and the loop stops when the road is close enough rather than after a fixed count. Every point of the track would route exactly and take a hundred requests; two would route in seconds and follow nothing. The day is routed whole per round, not leg by leg: it is a single request already, and per-leg would multiply every round by the number of legs against a host that answers one request a second. The track is oriented to the day first, because half of Norway's routes are published southbound, and the legs it covers are cleared before the new chain lands, so applying a track twice does not stack. Offered from the day's own badges, and only once something has been imported. Tinted while the day carries vias: the rail draws none of them, so that badge is the only place a drive shaped by hand differs from one the router picked. |
||
|
|
36ac5de84c |
feat(roadtrip): narrow the corridor to one stretch of the drive
A seven-hundred-kilometre day turns up seventy hits spread over the whole of it, and the traveller wanting a break near Berlin has to read all seventy and work out from "245 km along the route" which ones are anywhere near. That is the wrong way round: you decide roughly where to stop, and then look at what is there. Picking a point narrows the list to what lies within 25, 50 or 100 km of it. This is a subtraction rather than a second search, because the stops and the hits are already measured along the same thinned line. The midpoints of the legs are offered alongside the stops, and they are the point of the feature: a break on a five-hour drive belongs in the middle of the drive, not at either end. Asking only about stops would answer "what is near Berlin" when the question is "what is near the halfway mark". The chosen point is stored as which anchor it is rather than as a distance. alongKm is measured against a line thinned with a tolerance derived from the corridor width, so a kilometre figure kept here would quietly drift when the width goes from 5 to 10; resolved through the stops it lands on the same place again. It also names its day, so switching days cannot apply yesterday's break to today's drive. |
||
|
|
061259b3d9 |
feat(maps): pass through what OSM knows about a charging station
The corridor query has always ended in `out center tags`, and the projection has always kept six keys out of the whole tag set. Everything an electric car needs to know was in the answer and went in the bin: which plug families the station has, how many of each, at what power, how many vehicles fit, and whether it charges. Reading them costs no extra request, no key and no second query. The filters treat "not stated" as its own answer, which is the only honest way to build them: OSM states a socket type on roughly a third of charging stations and a power on fewer still, so a power filter keeps every station that says nothing rather than reading silence as too slow. The plug dropdown offers only the families this search actually turned up, because CHAdeMO in a list for a corridor with three Type 2 posts on it is a filter that can only return nothing. Socket names stay as they are printed on the plug. "Type 2" is not a string anyone translates, and the lowercase tag key is not what is written on the charger. The tool description follows, as parity requires, including the coverage caveat — a model that reads a missing power as "no fast charging here" would be wrong about most of the map. |
||
|
|
fc74991a15 |
feat(roadtrip): turn a place into a stop on the way from its own number
An ordinary place could not become a fuel stop without going through the full form, and the number in the rail is exactly what changes when it does: a service stop has none. So the number is the control. Click the 3, pick the pump, and the 3 becomes an orange disc while everything below renumbers itself. The disc of a service stop opens the same picker the other way, with a row to make it an ordinary destination again. Six coloured discs rather than six lines of text: at that size the colour and the shape are the label, and anyone who has seen one petrol stop on the map knows the orange pump before reading anything. Centred on the column rather than on the number, which sits hard against the left edge and would hang the panel half over the map. The click stops at the disc, so picking a kind never also selects the place underneath. Also in this change, from the same round: the corridor search offers hotels, a hit somebody could sleep at opens the popup in an overnight mode with a check-out day instead of a dwell, and booking one writes the place, its day and the row in day_accommodations together. A hotel keeps its number, because sleeping there ends the day rather than interrupting the drive, which is why the category list and the stop-kind list are now two lists instead of one. |
||
|
|
49f97af3fb |
fix(roadtrip): the range figure keeps counting until something actually refuels
Reported off a real day: Berlin said 245 km since the last fill-up and Sanssouci said 121, as if the tank had been filled in between. It had not. The budget was being zeroed at every warning, so the first figure was right and every one after it was the length of one leg rather than the distance on the tank. A warning is not a fill-up. Only a fuel or charging stop resets it now, which is what makes putting one in the answer rather than a formality: the same day reads 125, 370, 519 without one, and collapses to a single mark with one. Nothing is flagged on a fuel stop either. Reaching a petrol station on an empty tank is the plan working, and a warning there would sit on the one stop that answers it. Also: the stop number sits level with the middle of its row rather than at the top, the way the corridor list already places its badges. A number pinned to the first line drifts off as soon as a stop carries a stay and a walk under its name. |
||
|
|
ea9de65550 |
feat(places): make the kind of stop editable, and enforce it on the way in
Three holes, one seam. Leaving the corridor popup by "more details" dropped the kind and the dwell it had just worked out, so a fuel stop silently became a numbered destination that counted in every total, in the badge and in the PDF. Once set, the kind could never be changed back through the web UI at all. And the REST route did not validate the field: both request schemas are open objects, so stop_type: 'banana' was written straight to the column, where isServiceStopType read it as false and the stop counted as a destination again. The form now carries the field, so the popup's answer survives the trip through it, an ordinary place can become a service stop, and a wrong kind can be taken off. Editing keeps stop_type and deliberately leaves duration_minutes out: how long a stay takes belongs to the rail's own dialog, and sending it from here would overwrite what was set there. The schemas gain the one field rather than closing: roughly 190 call sites post whatever their form holds, and a closed object would reject all of them at once. The doc comment claiming the list was already enforced is corrected — it was describing an intention. The two driving findings move into the stop's badge row in warning colours, beside the stay and the walk from the road. All three say what this stop costs, and one row reads as one answer rather than as notes stacked underneath. |
||
|
|
e79a2591a1 |
refactor(roadtrip): put the driving limits behind a button, and the walk beside the stay
Two things the column was getting wrong. The limits sat open as a card, three rows of a narrow sidebar for something set once on the first day of planning and left alone after; they are behind a proper full-width button now, with what is currently set riding on the button so the form does not have to be open to read it. And the walk from the road was its own line under the stay, where it looked like an unrelated note; it wears the stay's two-part badge and sits beside it, because both answer the same question about a stop — one in time, one in a walk. |
||
|
|
dfb038d90f |
feat(roadtrip): say when a drive is longer than you drive
Roel and gfRally asked for three things: how far the car goes on a tank, how long you are willing to sit behind the wheel in one go, and how much driving belongs in a day. They are one feature. All three are a threshold, a walk over the legs, and a finding. Two things they deliberately are not. They are durations and distances rather than times of day, because with no stop pinned to a clock the cascade produces no times at all, for any stop, and a limit on an hour would simply never fire. And the day limit is called driving time, not travel time, because the figure it is measured against is exactly that: the badge beside it, which counts no time spent at stops and no leg to a hotel. The card says so out loud rather than leaving it to be discovered. The range budget carries across midnight, since a tank does not empty overnight, and resets after a finding: without that, 1800 km on a 600 km range flags every leg after the first overrun. Only fuel and charging reset it, never the other four service kinds — a two-hour lunch fills nothing, and nobody here knows whether the restaurant has a charger in its car park. Kilometres in storage, the traveller's own unit on screen. Skipping that round trip means an imperial user types 400 meaning miles, 400 km is stored, and the warnings arrive a third too early from then on. No stop is suggested and no vehicle is modelled. OSM does not know what car this is and neither do we, so the range is a number you type. Furkot does the same, for the same reason. |
||
|
|
56266138c6 |
feat(roadtrip): show the halts a routing plugin puts on the drive
A plugin may already route a day in road trip mode, and the charging stops it answered with were thrown away: the map was handed an empty list and the rail never looked. That made "build it as a plugin" — the answer given to charging prices, to ABRP and to the AFIR data — untrue in the one mode where it matters. Each halt is measured along the run once and filed on the leg it is actually driven on, using the leg lengths the router reported rather than by projecting the stops too: leg distances are exact by construction, while two stops close together project onto each other's stretch and would put a charge the wrong side of a town. The rail row is deliberately the poorest of the four. No number, no editable stay, no arrival time, a hollow disc: the halt belongs to the provider, not to the traveller, and everything filled on that rail is something they put there. Nor is it ever written back — a plugin halt stored as a via would go out as a waypoint on the next run, and the plugin would plan around its own charging stop. No time on the row for a second reason: the SDK contract folds the stop into the leg duration, so the arrivals already account for it. Adding dwellSeconds to the schedule would push every arrival after a charge back by the charging time. |
||
|
|
11f4c740e1 |
feat(roadtrip): draw the last bit to a place the road does not reach
A router snaps every waypoint to the nearest road before it starts, and OSRM does it with no distance limit at all. A viewpoint above a valley, a farmhouse down a track, a marina at the end of a pier: the drive to it really begins somewhere else, and the line said nothing about that. It either stopped short for no visible reason or appeared to cut across open ground. OSRM reports the snap position and its distance in every single answer, and this client has thrown both away since the first route was drawn. Reading them costs no request and no key. The gap is drawn as a dashed spur in the route's own blue, over the route rather than under it, because it is the one piece of the line that is not driving. Thirty metres is the floor, which is about a dual carriageway and its verge, so a place pinned off an aerial photo does not earn a dash. From a quarter of a kilometre the rail prints the distance too, because that is where "the car park is over there" turns into luggage, a gate, or a track a hire car should not be on. Vias are deliberately left out: a via is a shape handle, not a destination, and a dash hanging off one reads as a fault. |
||
|
|
e11ae6f056 |
feat(roadtrip): shape the drive — vias, alternatives and stops along the way
The road-trip addon's second half: the parts that let a planned drive be argued with rather than only read. Reshaping. A via records which stop it follows by position, and that position is resolved fresh every time the day is routed — so inserting, reordering or removing a stop moved the ground under every via after it, and the drive silently snapped back to the road it had been steered away from while the handle still sat on the map where it was left. The correction is computed against the day as it stands and written with the same change, in one transaction: a half-applied re-anchoring mixes two numbering schemes in one day. The one judgement that needs geometry — which half of a split leg a via belongs to when a stop lands in the middle of it — is measured on the road actually driven, so a fuel stop added to a bent leg keeps the bend on the side it belongs to. Alternatives. OSRM answers most long legs with exactly one route, which read as "there is no other way" when a perfectly good slower road existed. When it offers nothing, the same drive is asked for again without the motorway, and failing that without the tolls — one question at a time, stopping as soon as one lands. The offer says what it is rather than how much slower. Stops along the way. Everything the corridor search can find is a pause, not a destination: fuel, charging, rest areas, campsites, food and sights all sit inside the leg they were found on, carry their own colour and icon, and are left out of every stop count. That colour is defined once and read by the rail, the corridor list, the map pin and the picker — and it replaced the chains' logos, which turned a corridor full of petrol stations into a row of advertisements and left the brands with no logo on file looking like something else entirely. How long a stop takes was readable in the rail and writable nowhere; new places were given an invented hour that nobody had entered. It is now set from the rail, cleared as a zero because the update statement folds a null into "leave it alone", and new places start with nothing. Both columns are rebuilt on one type scale, anchored to the day plan's own sizes so switching tabs does not change the size of the text beside it. The rail reads as trip totals, day cards and a chain of stops with the drives between them; the corridor column as what to look for, how the search is getting on, and what it found. Six category pills over three lines became one dropdown, which is the space the results wanted. Also: the offered routes are framed on the map when the picker opens (`fitKey` carries no coordinates and each renderer reads it as "the selected day"), road trip mode does not cluster at all so the whole drive stays readable zoomed out, and the drive-time labels finally render in the app's own typeface instead of Leaflet's Helvetica. All of it in both renderers. |
||
|
|
74258bd5d9 |
feat(roadtrip): give the corridor hits their own icons, and say when a stretch went unsearched
A petrol station and a campsite both came back as the same nameless grey dot, because the road categories only existed on the server: the map falls back to grey for a category it does not know. They now sit in the client's category table too — with the pump, the bolt, the parking sign and the tent, in their own colours — which both map renderers and the popup read from the same place. They stay out of the explore pill's own list on purpose: that pill offers what a city visit needs, and while road trip mode is on it is not on screen anyway. The search also stayed quiet when only some boxes failed, which reads as "no fuel on this stretch" when the truth is that nobody looked. A partial failure now says how many stretches went unanswered. |
||
|
|
552bd61d11 |
fix(roadtrip): search along the roads driven, not the lines between stops
The corridor was built from the day's stops, so it followed straight lines across country the car never drives. Between two cities that line runs through open fields while the motorway swings wide of it, which is why every hit came back sitting on a stop rather than on the way. The days now carry the routed geometry, and the corridor is cut from that. Same trip, same search: 192 hits spread over the whole 482 km — first fifty, middle, and the last stretch — where before all of them reported kilometre zero. Following real roads takes more boxes to cover the same drive, so they go out three at a time instead of strictly one after another, and the budget rises from ten to sixteen. The panel head loses the sentence explaining what the sections below already say, and the day's distance and driving time, which the day list has been showing all along. |
||
|
|
647e972c8b |
feat(roadtrip): draw corridor hits on the map and build the day in one request
Three things the first cut got wrong. Switching into road trip mode took its time: every leg was its own routing request, paced a second apart so the shared hosts would answer at all. The router already returns a leg per consecutive pair of the waypoints it is handed, so a day is now one request — split only where the travel mode changes, because one request carries one mode. Answers that come from the cache are no longer paced at all, which is what made returning to the view feel broken. A list of petrol stations is half an answer: which one is on your side of the road is the other half. The corridor search therefore moved out of the panel into the planner, and its hits are handed to the map like any other place suggestion — grey, because they are not part of the trip until you add one. The explore pill stands down while the mode is on: it draws into the same channel and answers the same question for a drive. The panel itself was a stack of controls without an order. It now reads as header, search, results: what is being searched (with the day's distance and driving time), what is being looked for, how far off the route, then the hits grouped by kind with a count — and a progress bar while the boxes are being worked through. |
||
|
|
07a4447bbd |
feat(roadtrip): find fuel, rest areas and sights along the drive
The road trip mode could show the route but not what sits beside it, which is half of what planning a drive is about (#1797). The right column becomes a corridor search: pick what you need, pick how far off the route you will go for it, and the day's drive is searched box by box. The boxes come from the route itself rather than the viewport, so the answer is "on the way", not "on screen"; each hit reports how far off the route it lies and how far into the drive it comes, and adding one opens the normal place form so it lands on the day like any other stop. Overpass is asked in plain bounding boxes rather than with a polyline filter, because that is what the existing /api/maps/pois route accepts and what the mirrors can answer; the geometry work is pure and tested. Long routes stop at ten boxes and say so instead of quietly searching half the drive. Fuel, charging, rest areas and campsites join the server's POI categories, so the explore pill and the MCP search_pois tool gain them too. A car booking's stops now also reach the PDF, which printed pick-up and return with everything in between missing. |
||
|
|
3902e77991 |
feat(roadtrip): add the road trip addon with a drive-first plan view
The trip planner could show one day's route at a time and nothing across days, so a road trip had to be pieced together in Google Maps (#1797, #435). The addon adds a mode to the plan view rather than a tab of its own: the map stays the workspace and gains the route over the whole trip, while the left rail becomes one chain of stops with the driving distance and time between them and arrival times that follow from them. A time pinned on a stop is kept and restarts the chain, so a ferry or a museum ticket stays where the user put it. Car bookings can finally carry the stops the schema has always allowed: the endpoints table has held role/sequence for every transport type since #384, but only flights and trains had an editor for them. The MCP tools already accepted them, so this closes the gap the other way round. Legs are fetched one at a time and spaced out — the public routing hosts answer a whole trip's worth of requests with 429 — and the distance cap now looks at the single leg instead of the sum, which is what dropped long drives entirely. |
||
|
|
48f0f29273 |
feat(admin): let an operator switch the place index off
trek_places_enabled was read by six call paths — search, autocomplete, the POI pills, booking-import geocoding, the offline area and details resolution — and written by nothing. No admin route, no MCP tool, no switch anywhere in the UI, and the key is not in the settings allow-lists either, so the only way to set it was an INSERT against app_settings by hand on the server. That matters more than the four switches beside it, which trade away quota. This one decides whether every search and every keystroke in the place box leaves the instance at all. The flag reads fail-open, so an install that upgrades without visiting the admin panel starts sending them, which is the right default and exactly why the operator needs somewhere to say no. Adds the pair symmetrically to place-shadow from the same branch: getTrekPlaces /updateTrekPlaces on AddonsService, GET/PUT /api/admin/trek-places with an audit row, and a real switch on the card. The read stays fail-open so it keeps agreeing with MapsService.trekPlacesEnabled(), and a test pins that agreement across every stored value — if those two ever diverge the panel shows "off" while the queries keep leaving, which is worse than either default. The card said this itself: `available` was a display-only prop whose comment read "False until the client is wired to the service; the card then says so instead of offering a switch that would do nothing." It is wired now, so the prop is a switch. Its two badge strings had been shipped to all 23 locales and rendered nowhere; they make way for the switch label and a line naming what answers while the index is off. |
||
|
|
b3d69aa760 |
feat(places): record which search result was picked, on this instance only
The corpus behind "is our own index good enough to replace Nominatim". When somebody searches and then picks a result, the client posts the query, the rank and the pick. Nothing else: no user, no trip, no session, and coordinates only to three decimals. Deliberately decoupled from the search endpoint. Correlating a response with a later pick would mean handing out a ticket on POST /api/maps/search and threading it through all fifteen call sites; the client already holds everything the row needs at the moment of the pick. Evaluation happens offline afterwards: export the corpus, run it against a candidate index, count how often the pick came back in the top five. That is why collection can start before such an index exists, which is the whole point, because the corpus needs weeks of real use and the index needs a server, and those two waits should not be serial. Off unless an admin switches it on, and it reads === 'true' rather than !== 'false'. The three older places switches fail open because a migration backfilled a row for installs already using them; nothing uses this one, so an instance that upgrades and never visits the admin panel logs nothing. Reading is admin only, retention is 180 days, and the switch state is on the unauthenticated app-config endpoint on purpose: whether an instance records what its users search for is something a visitor may know before logging in. |
||
|
|
0a2fa32fc1 |
feat(admin): rebuild the API keys card around the TREK index
The card was a flat list: two key fields, four Google switches and a large weather panel, all at the same level. That gave a switch you set once the same weight as the key it depends on, and gave the loudest treatment on the page to the one thing with nothing to configure. Now three comparable provider blocks. The TREK index carries the accent and the recommendation, because a key should be the exception rather than the expected setup. Google carries no border badge, so it does not stand out, and says plainly what it costs beyond money. Weather has left the card: it needs no key and had nothing to set. The "recommended" badge moved from Google to the index, which is the whole point of the change. Also here, and both are their own thing: - The settings tab is two columns from xl up. Nearly every card is "label left, switch right", so the middle stayed empty while the page scrolled for ages. Explicit columns rather than a grid over the flat list, because a grid pairs cards row by row and leaves a hole under the shorter one, and these differ by hundreds of pixels. - Pictures: the nearby fallback drops from 300m to 60m and is skipped entirely for shops and gastronomy. Measured across 600 places in six cities, 2 percent of ordinary businesses have a picture on Wikimedia against 70 percent of churches, so for a cafe the curated rungs never fire and the fallback always does. That is how the town hall ended up over the doner shop. It fails open on an unknown category, so landmarks keep the fallback that works for them. vite.config reads the dev port and the API target from the environment, defaulting to what they were. Several worktrees could not run side by side. |
||
|
|
2a0ceb2687 |
feat(transit): let an admin route transit search through Google
Transitous has no GTFS coverage for much of Asia, so a search between two Osaka subway stops finds nothing — the stops are not in the feed at all. Add Google as an optional backend behind an admin setting, reusing the install's existing Places key, and fall back to Transitous whenever no key resolves. Uses the Routes API rather than the legacy Directions endpoint the request named: every other Google call here already speaks the v1/X-Goog-Api-Key family, and Google closed the legacy endpoint to Cloud projects created after March 2025. Google returns walking as turn-by-turn navigation steps where MOTIS returns one leg per walking segment, so consecutive walk steps are coalesced into a single leg — otherwise a finely-sliced walk pushed a real journey past the 20-leg cap and the whole itinerary was dropped. Both responses now name the backend that answered. The fallback is silent by design, which left an empty result ambiguous between "no data here" and "the provider you picked never ran", and the admin panel warns when Google is selected but no key resolves — or when only the admin's own key is set, which serves them and nobody else. Cost shaped the implementation, since Google bills where Transitous does not: both field masks are cut to the cheapest SKU tier that still answers the question, cache TTLs are longer than the Transitous path's, and route alternatives ride along inside the already-billed request. Addresses #1699 |
||
|
|
04b31ec2e5 |
fix(mobile): give the trip map a way back to every day
The phone map filters down to one day and had no way back out. The chip rail can only swap one day for another, the second tap on the active chip is the day sheet, and the seed guard deliberately does not re-seed — so a day picked on the map stayed picked until you left the trip and came back (#2257). Desktop has had the deselect since the day header got it. Its own round control beside the rail, map view only: the plan timeline is single-day, so there is nothing to widen there, and the day sheet keeps the one route into it that also exists on the map. It toggles — the first press parks the day it came from and frames the whole trip, the second puts that day back with its route, or falls back to the focus day if a remote delete retired it in between. Leaving the map restores it too, since an empty timeline would be the worse answer. |
||
|
|
55f14c6a70 |
fix(offline): the PWA starts offline again and the download does what it says
Four separate defects sat behind "offline mode is broken". The app shell. On the first online launch after a version bump, App.tsx deleted EVERY Cache Storage bucket and unregistered EVERY service worker, then reloaded. That is exactly wrong for a Workbox autoUpdate build, which swaps its precache atomically on install and never leaves an empty one. Between the purge and the completion of a fresh 22 MB precache the origin had no shell at all; anyone who closed the app or lost signal inside that window could no longer start the PWA offline. It also threw away the map tiles and documents the user had downloaded on purpose. It now asks the worker to fetch the new build and reloads once it has taken over. The version marker also moves inside the guarded path, because it was written after the purge and outside its try: a throwing setItem left the caches gone, the marker unwritten and the reload unreached, repeating on every launch. The reported version is validated before it reaches this device's storage and before it is compared against the stored marker, so a value that is not a release tag can neither be persisted nor start an update on every launch. The session. loadUser's offline branch kept the persisted session but never called onAuthSuccess, the only caller of setAuthed(true), and the mutation queue's flush, syncAll and prepareForOffline are all gated on it. A session that launched without network could therefore never sync again for the rest of its life, even after the signal returned: the offline settings buttons spun and returned instantly having done nothing, and queued edits never uploaded. It also left reads pointed at the anonymous database instead of the user's own. The date rule. A trip is only cached when it has not ended, and one that ended over a week ago is actively evicted. The per-trip switch was an opt-out list, so ticking a finished trip changed nothing at all. For anyone whose trips are all in the past, "Download for offline use" stored none of them, reported success, and the cache stayed at zero. Switching a trip on now pins it, which overrides both the skip and the eviction, and the switch shows the honest position rather than promising storage that never happens. What the screen says. A sync that never started (already running, offline, signed out) was indistinguishable from one that stored everything: the progress bar painted full and a green "Ready for offline use" appeared over a run of nothing. Both syncs now report why they stopped and the screen says so. A rejected Dexie read no longer renders as "No trips cached yet", and the clear button stays reachable when the database holds data that no bookkeeping row points at. A run that rejects outright says so too: the trip list it starts from is a plain fetch, so a network blip took the whole thing down and left the last progress frame on screen looking like a finished download. The 401-with-HTML branch in the API client now confirms a reachable proxy before tearing the worker down, since several of TREK's own routes answer 401 with a text/html body. Both settings shells move onto one shared hook rather than growing the same logic twice, and the tile prefetch caps its depth when the browser refused persistent storage. Opaque tiles are billed at about 7 MB each, and an origin that is not exempt from eviction loses its whole bucket, precached shell included, which Workbox then never refills because sw.js has not changed. Closes #2228 |
||
|
|
6d54ac629e |
fix(oauth): accept private-use redirect schemes and loopback ports
POST /oauth/register refused every single-label private-use scheme, so an
MCP client registering workbuddy:// could not connect at all, while the
error it got back advertised "a private custom scheme" as supported.
The cause was two validators that had drifted apart, both of which run on
registration. The provider required a dot in the scheme name, which RFC
8252 section 7.1 only recommends and which protected nothing
(javascript.evil:// passed it). The service behind it had no
custom-scheme branch whatsoever and no [::1], which is what actually
answered the reporter's second symptom, and its localhost exemption was
not gated on http:. So javascript://localhost/%0aalert(1), blob:, about:,
ftp: and wss: were all accepted through the settings UI, and the consent
flow assigns the stored URI to window.location.href. Only the CSP stood
between that and a navigation. Both now decide through one policy in
oauth.helpers.ts.
Widening the scheme set means the deny-list has to carry its own weight:
it grows by the browser, extension and OS-handler schemes that were
previously blocked only as a side effect of the dot rule, including
ms-msdt (Follina) and the ms- namespace by prefix. intent: is denied,
because Chrome follows its embedded fallback URL, which turns it into an
open redirect the authorization server cannot inspect.
Fixing registration alone would have moved the reporter's [::1]:{port}
failure one step later: TREK matched redirect URIs byte for byte while
RFC 8252 section 7.3 frees the port for loopback, so a native client on
an OS-assigned port got a 302 to consent and then invalid_redirect_uri.
The matcher now relaxes the port, and only the port, for http loopback.
Stored URIs are never canonicalised, so the %3A round-trips untouched.
Note this tightens POST /api/oauth/clients for inputs it accepts today.
That is the security half of the change and applies at create time only,
so no already-registered client changes behaviour.
Closes #2227
|
||
|
|
bc10fba389 |
feat(plugins): TREK_PLUGINS_IGNORE_TREK_RANGE turns the version gate into a warning (#2226)
* feat(plugins): TREK_PLUGINS_IGNORE_TREK_RANGE turns the version gate into a warning A plugin whose author has not updated its `trek` range for the running TREK can now be installed, updated and activated when the operator sets TREK_PLUGINS_IGNORE_TREK_RANGE to a truthy value. Every gate (registry picker, post-extraction re-check, sideload, dev-link, activation) warns instead of refusing; a missing range is tolerated the same way; "install latest" takes the newest published version. The plugin-API version gate is not lifted and the Discover compatibility verdict stays truthful. The admin is warned at every step: each bypass is logged, install/upload/link/ update responses carry a `trekRangeBypassed` marker, and both admin panels show a "Version checks off" pill, an "Install anyway" confirm dialog before a registry install, the same notice after a path that could not ask first, and a persistent chip on the row while the plugin runs outside its range. Nine new i18n keys in every locale; wiki pages updated (Environment-Variables, Admin-Plugins, Plugin-Development, Security-Hardening). * refactor(plugins): one useRangeBypass hook behind both admin shells SonarQube flagged PR 2226 at 10.8% duplicated new code: the range-bypass logic had been added to the desktop and phone plugin panels as identical copies. The marker/warning types, the row chip, the "Install anyway" offer, the dialog copy and the confirm-or-notice state now live in useRangeBypass (next to useInstanceSettings, the existing shared-logic precedent); each panel keeps only its own dialog/sheet markup. * docs(claude): record the SonarQube gate rules and the no-English-placeholder i18n rule The CI SonarCloud gate (Sonar way, on new code: >=80% coverage, <=3% duplication, A ratings, hotspots reviewed) was only discoverable by waiting 20-25 minutes for the workflow. The root CLAUDE.md now states the conditions, the desktop/mobile shell-mirror duplication trap and its shared-hook fix, and how to check a PR with the sonarqube MCP first; client and server CLAUDE.md point at it. shared/CLAUDE.md says every locale needs a real translation — parity cannot tell an English placeholder from a translation. * docs(claude): keep CLAUDE.md to commands, invariants, gates and philosophy Verified every concrete claim in the five CLAUDE.md files against the code and fixed the stale ones: the websocket server is the Nest realtime gateway, MCP tools live in <domain>.mcp.ts files, the client "native shell" section described code that was never written, offline maps also prefetch vector basemaps, plus several renamed files and wrong table names. Then trimmed everything that drifts on its own: dates, migration history, counts, and inventories of stores, slices, domains, sync files and CLI commands. What stays is the commands, the invariants with the file that enforces each one, what CI actually runs, the reference implementations to copy, and the rules for new code. Each file now opens with that rule. |
||
|
|
6cbefd23e8 |
feat(studio): filter photos by entry, upload from Studio, and switch stops off
The content browser gets a filter over the journey's pictures: all of them, the ones no entry holds, or one entry at a time, with the day and the place under each name. An entry card in the Entries tab reaches its own pictures the same way. Pictures can now come in without leaving Studio, into the entry the filter names or into the gallery, from the panel or dropped straight onto the page, and a photo element can fill its page or the whole spread in one press. Stops can be taken out of the arithmetic. A journal entry carries a stats_excluded flag; an entry that has it is off the route, out of the distance, out of the countries and not a step, while staying in the journal. Studio lists every stop in the travel panel with a switch, the entry editor carries the same switch on desktop and phone, and the entry card says when a day is off the route. |
||
|
|
b82962f95b |
Feat/plugin instance actions (#2209)
* feat(shared): scope plugin action descriptors * feat(plugin-sdk): instance-scoped settings actions * feat(plugins): run instance-scoped actions from the admin settings dialog * feat(admin): instance-scoped plugin actions in the settings dialog * fix(plugins): surface instance actions without instance fields and harden the action gate * docs(plugin-sdk): note that hosts before 4.2.0 ignore action scope * fix(admin): open the dialog for instance actions, explain inactive 404s, guard the save window * docs(wiki): plugin pages for instance actions and the admin panel changes |
||
|
|
7c857173cf |
fix(costs): three-decimal currencies stay typable and refunds work on iOS (#2175, #2176)
The split and receipt fields rejected the third decimal a KWD or BHD amount seeds, on desktop and in the mobile sheet, which share the currency-aware guard now. Signed amounts get a sign toggle on the numeric input because the iOS decimal pad has no minus key, and the under/over hint no longer swaps direction for a negative total. |
||
|
|
1e6ab069cd |
Fix/sdk default (#2199)
* feat(plugin-sdk): settings-field default + unknown-attribute warning
* feat(shared): default on the plugin settings-field contract
* feat(server): persist and serve plugin settings-field defaults
* feat(client): pre-fill plugin settings defaults in the admin and user forms
* feat(server): enforce required plugin settings on both save paths
* feat(client): block plugin-settings save on an empty required field
* feat(server): resolve plugin settings defaults at runtime
A settings-field `default` was stored and served to the form but never
reached the plugin: the child's init envelope, `ctx.settings.get()`, the
channel dispatch's readAll and the OAuth provider config all read the
stored blob only. Fold `stored ?? default` in on every path that hands
config to a plugin (settings-defaults.ts), and let a default satisfy
`required` on both save gates — refusing a save the runtime would have
accepted was a contradiction.
Also: one `isFilled` rule shared by the save gate and `hasRequired`
(checkbox exempt, whitespace empty); the install parser drops a
non-boolean checkbox default or a select default outside its options,
matching what the SDK already refuses; export SETTING_FIELD_KEYS so the
SDK's mirror can be parity-tested.
* feat(client): share the plugin settings form rules across all three forms
The mobile user settings screen was a third consumer of the fields/config
contract that never got `default` pre-fill or the required check, and
the seed/patch loops lived in five hand copies. Extract
seedSettingsValues / findMissingRequired / settingsPatch into one module
and use it from the admin instance form, the desktop user tab and the
mobile screen. The user forms now surface a 4xx `{ error }` from the
server (the required-field refusal) instead of the generic toast.
* feat(plugin-sdk): seed dev fixtures from manifest defaults
`trek-plugin dev` handed the plugin only the fixture values, so a plugin
relying on its manifest defaults read undefined in dev and only in dev.
Seed ctx.config / ctx.settings from the declared defaults (a fixture
value still wins) via a new exported settingDefaults(manifest, scope).
SETTING_FIELD_KEYS is now parity-tested against the host's list.
* docs(plugins): document settings defaults as runtime fallbacks
A `default` is the field's effective value wherever nobody set one, not
just a form pre-fill; `required` is enforced on both ends; unknown
attributes are dropped at install and warned on by validate. Add a
Settings section with the attribute table to the SDK README and update
the ctx type comments, the shared schema, and the wiki guide.
* add missing exports
|