mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
4eee08e45c72cc4042db92e07d98477b402ded8b
1011 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
44ddfa510e |
fix(client): only give a scheme to something that looks like a host
safeExternalHref prefixed https:// to anything without a scheme, which turns a note that reads "call the hotel" into a link to a host that does not exist. A dot and no whitespace is what separates www.hotel.com from free text. |
||
|
|
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. |
||
|
|
e72338bbbc |
i18n: the English left in Costs, Studio and the mobile timeline
Three surfaces still had English written straight into JSX in a codebase that gates on key parity: the reworked expense modal, the Studio quickbar and rotation handles, and the leg-mode menu on the mobile day plan. Keyed, and translated into all 23 locales rather than pasted. Also reworded the plugin geolocation permission: it told the user their browser would ask, and it never does - TREK answers the plugin from the page's own location permission. |
||
|
|
27be764164 |
test(client): the coverage that goes with the previous commits
Left out of the commits they belong to by hand, not by intent: the mobile screen suites, the tripRepo cache-first read behind the startup-destination fix, and the MSW handlers the stricter unhandled-request setting needs. |
||
|
|
c75e958fc0 |
fix(client,sdk): the smaller silent failures, and a dead SDK test file
The SDK's parity suite had an unescaped apostrophe in an it() title, so the file did not parse and vitest reported it as "no tests". Both guards in it were dead - including the byte-identical egress-policy check the package's own notes call the worst possible bug in that module to lose. No CI job runs the SDK suite, so it landed quietly; the next npm publish would have failed on it, since prepublishOnly runs the tests. Also in the SDK: the dev preview answered trek:geolocation without checking the geolocation:read grant, so a plugin whose manifest is missing the permission works perfectly in dev and is dead once installed. That is the lenient direction, which is the one that ships broken plugins. Client side: the background-task poll never dismissed a 404'd import job and spun forever, "Update All" collapsed its busy flag after the first plugin settled, the lightbox had no cancellation so a slow signed URL rendered the wrong image, the plugin geolocation watch was torn down by any unrelated settings write, manual date entry accepted 31 February, cover previews were never revoked, and check-all issued one request per packing item. |
||
|
|
b3d8bf08f1 |
fix(mobile): local dates, revoked object URLs, and writes that said nothing
Four screens derived "today" from toISOString(), which is the UTC calendar day: an expense filed at 17:00 in Los Angeles was dated tomorrow, a to-do went overdue eight hours early in Tokyo, and a new journal entry got the wrong day either side of midnight. All four take the viewer's own calendar day now, through the helper the planner already had. Three sheets built preview object URLs in render and never revoked them, so scrolling a picker leaked one blob per pass. Photo reorder fired its PATCHes from inside a setState updater, which StrictMode double-invokes. And several writes failed in silence: the notification toggle, the collab feature switch rolling back to a stale snapshot, the appearance change whose debounced write was dropped on unmount so it reverted on the next reload. The backup-code print page skipped the escaping its desktop twin does, the clipboard copy threw on a non-HTTPS install, and reservation metadata was JSON.parsed unguarded in render, which takes the whole route down. |
||
|
|
19745b7b0d |
fix(studio): keep "Keep mine" from throwing the document away
After a save conflict, Keep mine set the version and unblocked writes but never re-queued the local document, and write() had already cleared the queue before the request. So if the user picked Keep mine and then simply closed the editor without typing anything else, saveNow() found nothing pending and did nothing: their afternoon of edits existed only in memory, the indicator said idle, and the server kept the other version. The exact outcome the conflict dialog promises to prevent. Ctrl+Z was intercepted while typing in a text field too. Studio commits text on blur, so mid-edit the typing is not in the undo history yet: the browser's own text undo was suppressed and a previous layout change was undone underneath the open editor instead. Reads as random data loss. Also: delete no longer removes locked elements, the auto-layout no longer freezes an empty GPS path when tracks resolve after the one-shot build, the back-cover tally cannot print "0 places", the map fetch aborts when Studio closes, and a double Enter in the list-name field stops creating two lists. The page-pattern gate had quietly stopped covering anything: the Desktop-shim refactor moved every real container out of the default export the checker reads, and its regex missed useRef<T>(null) anyway. Widened on both counts, and the state it immediately found moved into the hooks. |
||
|
|
7959470802 |
fix(collab): scope the realtime handlers to the trip, and stop losing writes quietly
The notes and polls WebSocket handlers applied every event they saw without checking which trip it belonged to, so an event for another trip the user is a member of landed in the open one. The poll load effect had no cancellation either, so switching trips fast let the old response win. What's Next compared trip-local calendar dates against a UTC one while building the time from the local clock. West of UTC that means the rest of today disappears from the widget every evening and tomorrow's items are labelled Today; east of UTC it happens after local midnight instead. A note website was rendered as a raw href with no scheme check, the same stored-javascript: shape the planner had. Category rename walked the notes in a loop with no handling for a partial failure. And the school-holiday region labels resolved German first for every locale. |
||
|
|
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. |
||
|
|
47a4276e84 |
fix(client): make the offline core survive a logout and stop clobbering itself
logout() unregistered every sync trigger, and the only place that registers them is App's mount effect - which never runs again, because logout navigates through the router rather than reloading. Log out, log back in, go offline, edit something: it queues and nothing ever flushes it. No online listener, no visibilitychange, no 30s heartbeat, no WS reconnect hook. Register on auth success instead; registerSyncTriggers is already idempotent. The background sync loops only checked the auth gate on entry. Logout swaps the Dexie handle to the shared anonymous database, so a trip loop or a blob download still in flight afterwards wrote the logged-out account's itinerary and documents into it - the database that survives logout and greets the next person on a shared device. Check the gate per iteration. And the remote place:updated applier replaced the whole embedded place, while the local reducer right next to it preserves the assignment's own times. Same event, two answers, and the remote one wipes a scheduled 09:00-10:30 off the itinerary when someone else renames the place. One merge helper now, used by both, so they cannot drift again. The test that should have caught it seeded null times and asserted only the name. Also: moving a day note deleted before it created, and restored the note only locally when the create failed; t() interpolation was corrupted by a '$' in a parameter value; a 429 upload was treated as permanently failed; and the geolocation watch was started inside a state updater. |
||
|
|
39c8a22352 |
fix(offline): replay queued writes an interrupted flush left behind
A flush marks each row 'syncing' before it sends, and only ever picks up rows that are 'pending'. Kill the tab mid-request - the OS evicting a PWA does exactly this - and the row stays 'syncing' forever: it keeps counting towards the pending badge, but nothing ever retries it, fails it or shows it as a conflict. Anything queued behind it that depends on it later fails with 'unresolved temp id'. Stamp the row when it goes in flight and reclaim it on the next flush. The axios instance times out at 8s, so a minute is well past anything that could still be running, and the mutation id doubles as the idempotency key, so replaying a write the server already applied is answered from its cache. |
||
|
|
e91ad69c83 | fix(atlas): visual empty stats state | ||
|
|
bb7c484ca4 |
feat(notices): add the 4.0.0 release modal
The thank-you modal was one column of markdown for every notice. A release needs more room than that: what shipped, and why it took what it took. Adds a `release` block to the notice contract. A notice carrying it renders through ReleaseNoticeModal instead of the generic body — the release on the left, the note from the maintainer on the right, both halves driven entirely by translation keys so the next release only edits the registry entry. Sizing lives on one CSS variable (--rn-s), so the panel scales as a piece and the design's proportions hold at every breakpoint. The thank-you notice gets maxVersion 4.0.0: from there on the release modal carries the same thank-you and the same two support links, and installs still on 3.x keep the old one. All 23 locales. |
||
|
|
305b113d5f |
Feat/storage driver (#1949)
* feat(storage): storage key validation, driver contract suite, LocalDriver * feat(storage): MirrorDriver composite backend * feat(server): add TREK_UPLOADS_DIR env var for the uploads root * feat(storage): settings-backed registry, StorageService facade, module wiring * test(storage): pin src/nest/storage coverage thresholds * feat(storage): multer upload options factory * refactor(files): spool trip-file uploads through storage * refactor(collab): spool note attachments through storage; normalize files/ prefix * refactor(journey): spool journey media uploads through storage * refactor(trips): spool trip cover upload through storage * refactor(places): spool place-image and collection uploads through storage * refactor(auth): spool avatar upload through storage * fix(server): consolidate allowed_file_types onto AllowedFileTypesService * fix(storage): default defParamCharset to utf8 in the upload factory * test(db): re-point the trailing-migration guard at the trip_files prefix strip * fix(storage): age-gate the boot spool sweep * test(files): key FILE-P02 residue checks on upload size * test(storage): raise src/nest/storage coverage pin * refactor(platform): serve /uploads routes through storage * refactor(files): serve trip-file downloads through storage * refactor(memories): serve photo bytes through storage * refactor(maps): stream place-photo bytes through storage * refactor(share): stream shared place-photo bytes through storage * refactor(journey): serve public journey assets through storage * fix(share): answer place-photo cache miss with 204 like the maps proxy * test(server): close serving-swap gaps surfaced by the full tier * test(storage): raise src/nest/storage branches pin * refactor(place-photos): route place-photo cache through storage * refactor(places): reclaim place images and collection covers through storage * refactor(trips): trip covers through storage * refactor(auth): avatar reclaim through storage * refactor(collab): note-file deletion through storage * refactor(files): trash deletion through storage * refactor(memories): trek photo cache internals through storage * refactor(memories): thumbnail service category-addressed * refactor(memories): immich upload reads through storage; drop uploads-root * refactor(journey): photo deletion through storage * fix(memories): reclaim orphaned trek photo cache objects in the ttl sweep * feat(journey): journey-thumbs orphan sweep and delete-site derived-artifact reclaim * test(server): close slice-4 gaps surfaced by the full tier * test(coverage): raise slice-4 domain pins * refactor(backup): list, existence and delete through storage * refactor(backup): download through storage sendToResponse * refactor(backup): create backup through per-category storage enumeration * refactor(backup): restore and upload-restore through storage * refactor(backup): auto-backup retention through storage * refactor(server): boot dir-creation to driver init * test(server): close slice-5 gaps surfaced by the coverage tier * test(coverage): raise slice-5 backup pin * test(server): resolve async backupFileExists mocks for the tests typecheck * test(server): pin ranged-read stat and zero-byte roundtrip in the storage driver contract * feat(server): TREK_S3_* env wiring — schema, s3 namespace, all-or-nothing boot rule * feat(server): S3 driver core — aws-lite client seam, stat/delete, driver-owned deadlines * refactor(server): type the aws-lite S3 client via s3-types reference * feat(server): S3 driver getStream and list — ranged full-object stat, iterator pagination * feat(server): S3 driver put — bounded-peek routing, Upload inactivity deadline * fix(server): S3 driver Upload monitoring — Transform interposition, stream cleanup * feat(server): env-declared s3-main storage backend; settings-declared s3 rejected * test(server): env-gated S3 driver contract suite against MinIO * fix(server): S3 driver zero-byte put — work around aws4 duplicate Content-Length signing defect * fix(server): scope the zero-byte put workaround to buffered bodies; bind test MinIO to localhost * ci(server): MinIO-backed S3 driver contract job * ci(server): align s3-contract shim comment and MinIO bind with references * docs: S3 storage backend configuration and off-box backup mirroring * docs: restore README formatting, keep S3 additions only * ci(server): make live-MinIO contract runs manual-only (AIStor licensing) MinIO's community line is discontinued entirely; the maintained successor, AIStor, requires a license key that can't safely live in a shared CI runner. The s3-contract CI job is removed; live-provider contract runs against MinIO/AIStor are now a manual, license-gated procedure documented in docker-compose.minio-test.yml's header. * chore(server): final-review polish — stale CI refs, dockerignore key, boundary pins, zero-byte tempfile contract case * expose admin ui * feat(shared): storage backend type registry, wire schemas, category relocation * feat(server): accept settings-declared s3 backends — shared-schema options, decrypt-at-build, registry preview() * feat(server): remove TREK_S3_*/TREK_UPLOADS_DIR — tombstone boot rule, built-in override as the relocation path * feat(server): deriveSecurity.encryptionKeySet and storage secret plumbing (gate, encrypt, unmask, mask, redact) * chore(server): drop storage-secrets import that only Task 8's probe helper will use * feat(server): seed-once storage-config.json boot import — fail closed on any invalid seed * feat(server): registry snapshot() — effective storage config with provenance * feat(server): api/admin/storage GET/PUT — masked secrets, preview-gated saves, wired reload, audits * feat(server): api/admin/storage/test — per-target ephemeral probes, mirror targets probed individually * test(server): e2e coverage for api/admin/storage — guards, managed refusal, envelope, probes * test(server): give e2e harness DBs the app_settings table the storage registry reads at boot * test(coverage): raise pins after the storage admin-config slice * fix(server): final-review polish — encryption-gate wording, all-field sentinel guard, README/e2e notes * test(server): storage slice-1 review follow-ups — mirror s3-replica probe decrypt, all-encrypted seed without key, test-route 401 * feat(i18n): storage admin domain + tab label in every locale * feat(client): adminApi storage endpoints — getStorage, updateStorage, testStorageBackend * feat(client): storage admin draft-document helpers + relative-time util * feat(client): schema-driven storage BackendForm — fields by kind, mask echo, encryption banner * feat(client): admin storage panel — backends list, category map, health strip, one-PUT save * feat(client): storage tab on the admin page * feat(client): storage admin panel and section on the phone shell * fix(client): block renaming a storage backend onto an existing name * feat(server): allow mirrors on any storage category — lift the backups-only rule * feat(i18n): replicas-on-primary mirror strings in every locale * feat(client): mirror fold/synthesize model helpers for replicas-on-primary * feat(client): BackendForm mirror-targets composer; raw mirror type hidden * feat(client): desktop storage panel folds mirrors into their primaries * feat(client): phone storage panel folds mirrors into their primaries * fix(client): strip removed backends from remaining mirror replicas — no phantom refs * feat(storage): retire photos as a configurable category — served-only via the ServedCategory seam * feat(i18n): descriptive storage category names and descriptions in every locale * feat(client): descriptive category names, descriptions and id badges in the storage panel * docs(storage): fix stale nine-category comments after the eight-category shrink * docs: storage configuration via the admin UI and seed file — README + compose * docs(wiki): Admin Storage page and storage cross-links * docs(server): storage domain blueprint entry; TREK_PLACE_PHOTO_DIR tombstone clarifier * refactor(server): drop the storage env-var tombstones — the variables never shipped * docs: storage config is simply new in v4 — drop the removed-variables story * feat(shared): backfill-status and usage wire schemas on the storage admin state * feat(server): MirrorDriver.backfill — prefix-scoped, size-compared, cancellable replica catch-up * feat(server): one-at-a-time storage backfill jobs + the storage events bridge * feat(server): storage usage scan — nightly cron, on-demand service, persisted with computedAt * feat(server): replica_failure admin notifications — events bridge, hourly debounce, channel registration * feat(i18n): replica-failure notification text in every locale * feat(server): storage backfill and stats-refresh admin endpoints * feat(client): adminApi backfill and stats-refresh endpoints * feat(i18n): storage sync and usage strings in every locale * feat(client): desktop storage panel — sync now, usage display, backfill polling * feat(client): phone storage panel — sync now, usage display, backfill polling * fix(client): per-category usage line on the phone storage panel * docs: sync now, usage stats and replica-failure notifications * test(server): admin scope now includes replica_failure, not just version_available Commit |
||
|
|
942e1932c9 |
fix(budget): name the category parking and file parking bookings in it
The key was 'parkings', which is not a word and does not match the 'parking' the reservation type uses everywhere else. The shared trip page prints the stored category verbatim, so a share link would have shown "parkings" in every language. The category also had no relationship to the booking type of the same name: RESERVATION_TYPE_TO_COST_CATEGORY still sent a parking booking's auto-expense to Transport, so the new bucket sat empty next to the costs that belong in it while a manually typed "parking" resolved into it. Both paths agree now. |
||
|
|
999236f944 |
feat(budget): add "Fuel" and "Parking" expense categories
- Adds two new fixed budget categories: fuel ("Fuel") and parkings
("Parking"), alongside the existing set (Accommodation, Food & drink,
Groceries, Transport, Flights, Activities, Sightseeing, Shopping,
Fees & tickets, Health, Tips, Other).
- Icon/color metadata added for both (Fuel and ParkingCircle from
lucide-react), plus legacy free-text aliases (gas/petrol->fuel,
parking->parkings) for existing/imported expense rows.
- Label strings added for all 23 supported locales.
- Adds test coverage for the new categories and legacy alias mapping.
|
||
|
|
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 |
||
|
|
a23a81f35b |
fix(studio): the spread import points inward too
Missed in the sweep: "Import spread" opens a JSON file picker and wore the outgoing arrow like the rest of them. Refs #2006 |
||
|
|
331954c1b0 |
feat(mobile): drag to reorder the day plan, and reach the day sheet from any day
Reordering a day on the phone meant two 15px arrows, while the same list on the web takes a drag. It takes one here too now, via the long-press bridge tablets already use (#1616): it watches only draggable rows inside an opted-in container, waits out a press before claiming the gesture — so a swipe stays a scroll — and replays real drag events, which is why the rows only needed the receiving half. Armed in edit mode and on coarse pointers alone, so go-mode taps and map pans are untouched and the polyfill retired in #1432 stays retired. The arrows stay: they are the accessible path. Both paths go through one mover, so the chronology guard and the undo entry are the same either way. The card header was also the only route into the day sheet, and it rendered only when the day happened to have an accommodation or a weather reading — with the accommodation chip as the sole tap target. A day with neither had no way in. A day pill now leads that row unconditionally, the weather reading is a target too, and the active day chip carries a chevron so the second tap that opens the sheet is visible in map view as well. Closes #1997 Closes #2004 |
||
|
|
2d6c8c5157 |
fix(mobile): open the stay when you tap the stay
Tapping the accommodation in the day sheet opened the place it is pinned to, so check-in, check-out and the confirmation code had no way in at all. The sheet that edits them has taken an accId since the mobile rewrite; nothing ever passed one. The row is a real button now and opens that editor, with the place kept one tap to its right for anyone who wants it. Viewers without day_edit keep the place as their target, since the editor is no use to them. The linked hotel booking below opens too — the day's booking list filters hotels out, so that strip was the only place it showed and it did nothing. Closes #2001 |
||
|
|
da92581a16 |
fix(planner): let a route choice survive the map, and keep new items on their day
Two things the mobile planner forgot. The day sheet's route toggle only ever lived in component state, and entering the map — or tapping a day while in it — forced it back on. Turning the route off therefore did nothing you could see and had reset itself by the time you looked again. The choice is now remembered per trip; the map still switches it on by default, but only while the user has never said otherwise. Adding a place or a booking from inside a day did not connect it to that day. The place dropped into the unplanned pool, which on mobile is a different screen — it looks like the save was lost — and the booking opened with no date at all. Both entry points now carry the day they were opened from, including a long-press on the mobile map, where the chip rail names a day at all times. The desktop map has no such context and passes nothing, so its pool behaviour stands. While here: the import button on the bookings and transports headers only appeared when kitinerary was installed, though the server also accepts an import when the LLM parser is configured (#2007). Closes #2003 Closes #1998 |
||
|
|
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 |
||
|
|
9094f05604 |
fix(planner): close the accommodation loop when no times are recorded
With "optimize route from accommodation" on, the drawn route still started at the first activity on any arrival day and stopped at the last one on any departure day. The two bookend rules ask whether you can be proven to have been at the hotel already — but they read a missing check-in time as proof against, and the hotel picker leaves that field blank by default. So the loop hung open at one end on every boundary day, and at both ends for a stay booked on a single day. With no time recorded there is no bar to clear, so the leg is drawn. Where a time *is* set the existing rules are untouched: a stop before check-in still draws nothing (#1465), and a transport arrival is still not a hotel departure (#1321). Closes #2009 |
||
|
|
5e78069223 |
fix(journey): saving a suggestion turns it into an entry
A trip visit shows up in the journey as a suggestion, and opening one puts you in the entry editor. Pressing Save there only promoted it when a story, a photo or a linked photo had been added — a title, a mood, the weather or a set of tags all left it a suggestion, and so did saving it untouched. From the outside that looks like the save did nothing. An explicit Save is the user saying this is their entry now. handleSave only ever runs from the Save button, so there is no autosave to misread as intent. Closes #2008 |
||
|
|
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 |
||
|
|
ec3017f9cc |
fix(ui): keep dropdown panels anchored to their trigger
The three fixed-position popovers computed top/left inline while rendering, which reads the trigger's rect exactly once. Scroll a sheet afterwards and the trigger walks off while the panel stays nailed to the viewport; on iOS the keyboard slides up over a panel that was placed before it existed. useAnchoredPosition measures in a layout effect and re-measures on scroll (capture phase, so the sheets' own scroll containers count), on resize and on visualViewport changes. It also reports how much room is actually left, so a list opened next to the keyboard shrinks instead of hiding underneath it. CustomDateTimePicker clamps a fixed 268x360 calendar horizontally, which no generic box can express, so it takes the bare re-measure signal instead. Closes #1999 Closes #2000 |
||
|
|
9e13791239 |
fix(booking-import): geocode transport endpoints on the path clients use
An imported train, bus or rental car showed its pick-up and drop-off in the review step and then had them gone from the saved booking: no pins, no route, no error. reservation_endpoints.lat and lng are NOT NULL, so saveEndpoints drops an endpoint without coordinates, and kitinerary and the LLM name stations, stops, terminals and rental desks without geo-locating them. Only airports arrive with coordinates, from the mapper's own table. The lookup that was meant to fill them in existed — in confirm(), which no client calls. Both the desktop planner and mobile go preview, review form, ordinary save, so it never ran. It runs in preview() now, through a helper confirm() shares, on the throttled background lane whose own docstring names booking-import as its intended caller. The query ladder matches the venue lookup beside it: the endpoint plus the booking's location first, then the address, then the bare name — name-only is exactly what fails for a desk label like "Curbside Pickup Counter 7", so it goes last. Answers are cached per preview, since a multi-leg train repeats its stations and the lane allows about one request a second. An endpoint that still cannot be placed stays on the item rather than being filtered, so the review form can show and edit it, and the preview says which name it could not locate. That warning needed the widget to change too: it rendered warnings only when a job found nothing at all, which is precisely the case this is not. Reported with the full chain, file by file. Closes #1969 |
||
|
|
f574e75dbe |
fix(pdf): print a day in the order the plan shows it
The export ordered each day by itself, on day_plan_position — one global number per booking, seeded automatically from the place count of whichever day happened to render first. On any other day of a span it therefore pointed at the wrong slot, and where it was still null the export fell back to the order the API returned rows in, which is reservation_time ASC with nulls first. That is why the same trip printed correctly some of the time and swapped two bookings the rest of it, and why the reporter's workaround worked: adding a place and removing it again reseeded the position from the day they were looking at. The plan has never used that column. It picks transports per day, gives each one the time that day shows, inserts them among the places and re-sorts the whole day by the clock — and none of that was reachable from here, because this file had its own copy of the ordering with subtly different rules. So the copy is gone and the export goes through utils/dayMerge, the same functions the plan uses. Two differences fall out of that, both of them the plan's existing behaviour: a booking attached to a place is no longer printed twice, and a multi-leg journey prints one row per leg at each leg's own time rather than one row carrying every leg as a subtitle. The per-segment booking code #1943 added is read back from the booking, since the leg object does not carry it. Closes #1978 |
||
|
|
f76ee87336 |
fix(nav): stop the tab pill from running under the logo and the user menu
The centred tab menu was absolutely positioned, so it had no relationship to what sat beside it. Its width grows with every enabled addon and every page plugin, and once it outgrew the free space in the middle of the bar it simply ran underneath the logo on one side and the bell and user menu on the other. The only adaptation was a hard-coded 1024px breakpoint that drops the tab labels — a constant chosen when two or three addons was the whole story, and one that cannot know a plugin has added anything. So the bar is three columns in the flow now: the brand block and the action cluster carry equal weight either side, and the pill sits between them. That keeps it centred on the bar as before, and makes overlap something that cannot happen rather than something a breakpoint has to predict. With min-w-0 the pill gives way and scrolls when even it runs out of room, instead of pushing the actions off the edge. Measuring the pill against the free space and folding the overflow into a menu was the other option. It buys a tidier look at a narrow width and costs a ResizeObserver on every desktop page, a cache to keep the measurement from oscillating against the mode it chose, and a new string in 23 locales. Not for a fix to overlapping. Closes #1983 |
||
|
|
bb0d65bb19 |
fix(map): read the last fitBounds call without Array.at
The client's tsconfig targets a lib without Array.prototype.at, so the two new assertions typechecked locally against the test runner but not against tsc. Index arithmetic instead. |
||
|
|
7aead0b713 |
fix(map): stop the day fit from framing everything and then drifting off it
Selecting a day compensated for the day panel twice: the fit reserves the panel's height in its bottom padding, and then 300ms later the camera was nudged again with panBy([0, 150]). Both came from the same change, and together they moved the view 150px further than intended. Wide days never showed it. A day that runs north to south does: the fit is height-bound there, so the northernmost stop sits exactly on the top padding line, and the nudge then pushes it off the canvas. What the user sees is the map framing the whole day correctly and then sliding upwards, leaving the first marker outside the view. The padding alone is right, and it is what MapViewGL has always done with the same numbers, so this also brings the two renderers back into agreement. Closes #1982 |
||
|
|
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 |
||
|
|
68bf5316ad |
fix(planner): stop an empty reservation event from breaking a trip for good
reservation:created and reservation:updated come in two shapes. Normally they carry the reservation; the accommodation cascade sends them empty, as a "something changed, go and look" ping that the event contract allows for. The applier read the id off both. With no reservations loaded yet the existence check never ran its callback, so the cast slipped through and the store became [undefined]. From there every render that walked the list threw "Cannot read properties of undefined (reading 'endpoints')" into the route error boundary, and since the store is rebuilt from the same events, the trip stayed unreachable in that browser. The reporter could only get out of it by clearing localStorage. Both appliers now decline an event with nothing on it, and the Dexie writer does too rather than leaning on a swallowed catch. The predicate that walks the list is total as well: it guarded the property but not the element, and it is handed straight to Array.filter over a list it does not own. Declining the empty ping would have left a hotel booked by one member invisible to the others until they reloaded, so the ping is honoured the way day:reordered already honours its own — fetch the authoritative list. The server keeps sending what it sends. Making the cascade carry the full reservation would mean moving the REST, MCP and RPC paths together with their pinned tests, and would still leave the client fragile against the next emitter that does this. Closes #1979 |
||
|
|
8f171453d3 |
fix(packing): keep a private item off the other members' screens over MCP
A restricted packing item, changed through an MCP tool, went to every socket in the trip room. Ticking something off your own list through an assistant pushed that row to every other member. The REST and RPC surfaces have scoped these events since #858 — both go through emitToViewers, which delivers to the owner and the recipients and nobody else. The MCP tools called safeBroadcast, which took three arguments and had no way to say who may see the thing that changed. So safeBroadcast learned that fourth argument, and the packing tools pass viewersOf(item), which is the same answer the REST route asks for. A shared item still answers null and still goes to the room in a three-argument call, so nothing about it changes — including what the hundred-odd broadcast mocks in the suite record. The client stops trusting the wire on this too. It used to `put` whatever arrived, and the offline read hands back every cached row for the trip with no privacy filter, so a single stray event lodged another member's item in that browser permanently. It now drops a restricted item that is neither mine nor shared with me — deleting rather than skipping, so a list that was already leaked into heals itself the next time an event about that item comes past. Closes #1976 |
||
|
|
eae8480728 |
feat(studio): follow-ups from using Studio on a real journey (#1984)
* feat(studio): widen the document, and stop a save from losing it The schema gains what the elements coming after it need: an icon element, a badge that can be restyled rather than only placed, a date and a pair of coordinates on a text binding, and the map's route, pin and road settings. Widening it turned up two ways a book could fail to come back. The mood badge writes its code into the document, and the schema said that code was exactly two characters, from a first draft where it was a country. The real codes are words, so every save was rejected and the read that followed parsed to an empty document: seventeen spreads in, nothing out. Widened to what the badge actually writes, and normalizeBookDocument now drops the elements it cannot read rather than the whole book, so one bad element costs a picture frame instead of a year of work. The other was the body limit. The parser allowed 100kb for everything, and a book with a routed map measures past that, so the save came back 413 and the editor sat on "not saved" with no way forward. Book writes get their own parser at 8mb; everything else keeps the limit it had. * feat(studio): a date, a pair of coordinates and an icon library Three things that had to be drawn by hand before. A journal entry knows when it happened and where, and neither could be put on a page: you read the date off the entry and typed it into a text box, where it then stayed while the entry moved. Both are chips now, next to the title and the story, and both follow their entry the way the others do. The formatting lives in one place so the chip, the template and the resolver cannot drift by a character — coordinates in degrees or decimals, the date in the reader's language. The icons are the whole Lucide set, searchable, placed as a real element with its own colour and stroke rather than as a character in a font. The stroke is deliberately not called strokeWidth: three separate places rescale a field of that name by page width, and an icon whose line thins as the book grows is not what anyone asked for. And the badges finally have their settings. A mood or weather badge could be placed and then only moved: the icon's colour was fixed, the icon could not be turned off, and there was no way to keep the icon and drop the words. All three are switches now, with the colour following the badge unless you say otherwise. * fix(studio): let a placed entry field follow its journal entry again The inspector says a bound field follows the journal entry and that editing it here breaks the link. The first half was not true: nothing ever re-read the entry, so renaming a title in Journey left the old words on the page for good, and the same went for every other bound field. The resolver runs where the journey is loaded and re-reads the fields that still point at an entry. What it will not do is as important as what it does. A field somebody has typed over stays typed over, an entry that is gone leaves its element alone rather than blanking it, and an entry that answers with nothing never wipes what is already on the page. Formatted fields — the date, the coordinates — are only re-read when the format itself changed, which is what keeps this from quietly rewriting books that already exist: an entry whose date was placed as a heading and then set in capitals keeps its capitals. Checked against the real database before it shipped, where the first version overwrote three of them. * feat(studio): start a new book empty, with six layouts to choose from A new journey opened onto a book that had already been laid out, which is the wrong first move: it decides the shape of somebody's book before they have seen it, and undoing it means deleting pages one at a time. A new book starts empty now, and the auto layout runs when it is asked to. The layouts themselves went from two to six, so choosing one is a real choice: what was there covered a photo-heavy spread and a text-heavy one and nothing in between. * fix(studio): hyphenate the export the way the editor does, and set the bleed Two things the press sees and the editor did not. A paragraph broke in different places in the PDF than on screen. The print document is written from scratch and never carried a language, so the browser hyphenated the editor's German and the export's nothing-in- particular. It takes the page's own language now. The wait for the sheet to be ready also covers the images inside SVG, with a ceiling, so a map that is slow to draw does not print half-finished. And the bleed and the safe margin are typeable. Both were baked into every page preset at what a photo-book vendor usually asks for, 3mm and 5mm, with no control anywhere. That is fine until a printer asks for 5 and 10, at which point the book cannot be made here at all: the export carries the wrong bleed and the crop marks sit in the wrong place, which nobody finds until the guillotine has run. * feat(studio): rebuild the inspector The panel on the right had grown by accretion: every element type had added its own controls in whatever shape suited it that day, so the same idea — a colour, a size, a toggle — looked different depending on what you had selected, and long panels were a wall of unlabelled fields. It is one set of controls now, used by every element: foldable sections, a line that holds a label and its control, radio choices, checkboxes, the app's own toggle, and a number field that holds a draft so typing "500" is not read as "5", clamped and written back under the cursor. Duplicated controls were merged rather than deleted; nothing an element could do before it cannot do now. The head carries the element's identity and its actions, so what you have selected is readable without counting fields, and the sections have room to breathe: what was there was correct and unreadable. * feat(studio): a route map worth printing The map in a book was a straight grey line between grey dots on a tile that was chosen for a screen. At print size those tiles are read at roughly four times the resolution they were fetched for, which is why it looked soft next to everything around it. The imagery comes from sources that hold up on paper: NASA's shaded relief and EOX's cloudless Sentinel-2, both licensed for it, picked at a zoom that matches the printer rather than the screen. Tiles are fetched at twice the density where the source offers it, and the seam between them scales with the tile instead of sitting at a fixed fraction of a millimetre, which under editor zoom fell below one device pixel and showed as white threads. The route is drawn rather than plotted. A leg that was never recorded bows, by an amount that grows with its length and turns away from the pole, and it dashes, because an inferred line should not claim to be a track — a recorded track never bows. Both strokes are laid down in two passes, casing then core, so the line stays legible over dark sea and bright desert alike. Legs can follow roads instead, routed leg by leg with the geometry thinned before it is stored, which is the setting a road trip wants and the one a flight cannot have. Stops carry their own first photograph, cut to a circle by a stencil that exists — the first one was written into a defs block that only appears on a map clipped to a coastline, so on an ordinary map it clipped nothing and the photographs sat square on the page. A stop with no photograph of its own is numbered rather than handed one from elsewhere in the journey: a marker at one place showing a picture taken at another is a caption that lies. Whatever the frame, the whole route is inside it. The window takes the frame's aspect, so the fit and the imagery agree instead of one containing what the other covers, and a map dragged narrow no longer cuts off its end. Each linked trip can carry its own map, and a map notices a photograph added to a stop afterwards rather than having to be placed again. * i18n(journey): the new Studio strings, in all 23 locales * test(journey): pin the coverage the map work brought with it The journey domain measures higher than its floor after this round, so the floor follows it up. Only journey moved; the generator's suggestion would have taken memories' functions down a point, and a ratchet that can fall is not a ratchet. * test(studio): cover the thinning that keeps a road route saveable The one path in roadRoute nothing exercised, and the one that matters most: a router answers a long drive with thousands of points at a precision no press can resolve, and all of it goes into the document. Keeping a leg whole is what pushed a real book past the body limit. Both ends have to survive so the line still meets its stops, and the spacing has to stay even, because cutting a run out of the middle would straighten exactly the bends somebody wanted to see. |
||
|
|
736599a89b |
feat(journey): TREK Studio — a photo book designer for Journey (#1973)
* fix(photos): serve local photos root-relative so a dotted install path works
res.sendFile(absolutePath) resolves against the rewritten req.url under the Nest
ExpressAdapter and answers a spurious 404 even though the file is there — the
same trap files-download.controller.ts:60 and plugin-frame.controller.ts:74
already work around with an explicit { root } + basename. The photo resolver
never got that treatment, so every thumbnail and original 404s on any install
whose uploads path contains a dot segment (~/.local/share/trek and friends).
The public journey asset route had it too, which took shared journeys with it.
The guards above each call already confine the path to the uploads tree, so the
root form is no weaker than what it replaces.
* feat(shared): add the book document contract for TREK Studio
Millimetres throughout, not pixels: a pixel means nothing to a print shop and
changes meaning with the device pixel ratio. CSS maps 1in onto 96px, 25.4mm and
72pt with a fixed ratio, so the editor can render the document at a scale and the
print renderer at 1:1 and land on the same page.
Paint order is the array order rather than a zIndex field that can disagree with
itself, text elements may stay bound to the journal entry they came from, and a
spread can park content a layout has no room for instead of dropping it.
* feat(journey): open TREK Studio over the journey
Studio is a child route of /journey/:id and portals itself above it, so the
journey stays mounted and shows through the panel's 16px margin — it reads as
something opened on top of the journey rather than a place you navigated to. The
URL exists for reload, the back button and shared links; the user never sees it.
Every rule in studio.css is scoped under .trek-dash on purpose: dashboard.css:95
resets button font, colour, background and padding at a specificity a bare .st-x
loses to, so an unscoped rule silently does not apply to a button.
Escape clears the selection and only leaves on a second press. In a dialog
Escape means go away; in an editor that reflex would cost you the page.
* feat(journey): build, edit and arrange the book in Studio
The auto mode turns a journey into a book once and then gets out of the way: what
it produces is an ordinary document, every element as editable as one placed by
hand. Templates work the same way — applying one pours the spread's existing
photos and text into the new arrangement and parks whatever will not fit, so
trying a text-only layout does not cost you the pictures.
Moving and resizing are hand-rolled on pointer events. The drag-and-drop
libraries in this space move items between lists and have no notion of a free
frame, a handle or a magnetic edge; HTML5 drag hands you a ghost image you cannot
control and no position until the drop. It is used for exactly the one shape it
suits — dragging a photo out of the content panel onto a frame.
Selection chrome carries two rings, dark inside light. A single line vanishes
against a photograph of its own value, and over a picture that is bright on one
side and dark on the other no measurement of the background can be right.
The sheet renders in DOM rather than on a canvas so the same component tree can
be what the print renderer runs — one renderer, nothing to drift against. Edit
affordances live in a layer above it and can never reach the page.
* i18n(journey): studio strings across all 23 locales
The empty-frame prompt breaks per language rather than copying the English line
break onto everything — forced onto Japanese or Arabic it strands a fragment on
the second line.
* refactor(journey): keep SpreadView to components only
FONT_STACKS and photoSrc move to their own module: a file that exports both
components and constants breaks Fast Refresh, and this is a file you edit
constantly while designing a page. Types come from the journey store instead of
any, and the count beside the content tabs is a badge rather than competing with
the label for the same weight.
* feat(journey): more shapes, decoration and a content search in Studio
Six shapes now instead of three — rounded rectangle, triangle and an outline-only
box join the rest, plus a quotation mark and an outlined circle under decoration.
A triangle is a clip path rather than the old CSS border trick: that hack cannot
take a fill gradient or a stroke and its size does not follow the frame the way
everything else does. A stroke sits inside the frame, or the drawn shape would be
wider than the box the editor snapped and the print would not match it.
The content panel gets a search over photos and entries. A photo matches on its
own caption and on the entry it hangs from — most photos carry no words at all,
so matching captions alone would look broken on exactly the journeys that need
it. Plain filtering over what is already in memory; a journey holds tens or
hundreds of items, not thousands.
Dates in the panel and in the book now follow the app's language through the
house formatDate rather than the browser's locale, and the entry's date and place
are two badges instead of one dot-joined string the eye has to parse.
* @
feat(journey): a shape library, picture frames and photo grids for Studio
Studio had six shapes, two frames and no grids. This is the library that
makes it usable for a book rather than for a diagram.
Shapes are SVG paths now, not clip-paths. That was forced rather than
chosen: a clipped div cannot take a stroke — the clip cuts the border in
half lengthwise — and a heart is not a box with a radius. Rectangles and
ellipses keep their old div rendering, since a box genuinely is better as
a box and every document written so far contains those two.
Two decisions in shapes.ts are worth stating.
The path *points* are scaled, not the coordinate system. Fitting a path
with preserveAspectRatio="none" scales the stroke with it, so a 0.5mm
outline on a frame twice as wide as it is tall comes out 0.7mm on the
sides and 0.35mm top and bottom. Printed next to a straight rule at
300dpi, you see it. Hence the deliberately boring path subset: absolute
M/L/C/Q/Z, numbers alternating x and y, no arcs.
And every shape fills its 0..100 box exactly, enforced by normalise() at
module load rather than asked for in a comment. The editor draws its
selection outline, its resize handles and every snap target from the
element frame, so a shape occupying 86% of its own frame cannot be
aligned against anything you can see. It costs a regularity nobody can
spot — a hexagon in a square frame still reads as a hexagon.
Frames are the same list: a picture frame is a shape with a photograph
inside it, and keeping two would mean a heart that exists as one but not
the other. Photos gain a mask and a decoration, kept independent, plus
the Polaroid, film and tape treatments that are not expressible as either.
Grids take their gutter out of the cells rather than adding it between
them, so a grid asked to fill the safe area fills it instead of
overhanging by one gutter.
@
* @
feat(journey): derive what a journey adds up to
GET /api/journeys/:id/stats — distance, days, steps, photos, places, the
furthest point, the countries in visit order, and the route itself. Read
only and derived: nothing here is stored, it is what the trips added to
the journey come to.
The route comes from the journey entries where they carry coordinates,
and falls back to the places on the linked trips where they do not —
which is the common case for a journey assembled from trips nobody has
written up yet. The two are never mixed: an entry written about a place
that is also on the trip would be two stops at one location, and the
distance would count that leg twice.
Countries read place_regions first, which Atlas already fills, and fall
through to getCountryFromCoords for the rest. That import is a plain
function from a module built without DI or Nest edges, so it costs the
journey domain no coupling to Atlas.
Distance is great-circle metres, not driving distance: there is no
routing engine here, consecutive stops are not necessarily joined by a
road, and a book that prints a road distance it never measured is worse
than one that says "as the crow flies". Metres because kilometres and
miles are a display choice.
A hotel across three nights is one place with three day assignments, so
the place query aggregates back down to one row at its earliest day —
otherwise the route would visit the hotel three times.
Two test fixes came out of writing this. journey.controller.test.ts still
asserted an absolute path from res.sendFile, which the photo fix in this
branch had already changed to the basename-plus-root pair; and
linkTripToJourney in the factories wrote to linked_at, a column that does
not exist — it had no callers, so it had never once run.
@
* @
feat(journey): travel elements — the route, the figures, the countries
A photo book of a trip is not an album: the trip carries facts, and those
facts make pages photographs cannot. Four new elements — a vector route
map, a figures panel, a country page and a set of small marks — plus the
mood, weather and pros/cons an entry already records.
Every one of them carries its own values, resolved when the element is
placed and written into the document. The print renderer is headless
Chromium loading a page; one that needs an authenticated fetch to know
what it says will print blank the day a token expires. It also makes a
book a record of a trip as it was, rather than something that quietly
rewrites last year distance when a stop is added. travelRefresh compares
what is stored against the journey and offers an update, so bringing an
element up to date is a choice.
The map is vector, from the coordinates, over silhouettes simplified
offline from the admin-0 boundaries the server already bundles for Atlas
— no licence question, nothing to fetch at render time, sharp at any
print size. It paints no background: a map is geography sitting on the
paper, not a window cut through it, so it can be laid over a photograph.
Flags are drawn rather than typed. A regional-indicator pair is the
obvious answer and works everywhere except Chrome on Windows, which has
no flag glyphs and renders the two letters instead — "DE" in a printed
book is not a flag. Thirty-three flags are exact constructions; anything
with an emblem or unequal bands is deliberately absent and falls back to
the country outline, because a wrong flag is worse than no flag.
Panel tiles render the real element small, through the page own
renderer. These differ from each other by what they say rather than by
their shape: four map styles are four maps, and no icon can tell you
which is which.
@
* @
feat(journey): manage spreads from the pages rail
Add, insert between two spreads, duplicate, reorder and delete. None of
it existed — a book was whatever the auto layout produced.
The cover and the back cover are fixed points: a book has exactly one of
each, they are single pages, and no sequence of clicks should be able to
produce a book with two covers or none. Every operation asks one
predicate whether a spread is inner, rather than each caller testing role
for itself, and a spread inserted after the back cover lands before it.
Duplicating re-ids every element it copies. Two elements sharing an id
would make selection ambiguous and every lookup that follows it wrong,
delete included.
Also: a locked element can be selected again. Locking used to switch its
hit target off entirely, which was a dead end — it could not be picked,
so the inspector never showed it, so the unlock button was unreachable
and the only way back was undo. Locked now means what it says: it cannot
be moved, and its resize handles are hidden so the editor does not offer
a drag it will refuse.
@
* @
feat(journey): let the auto layout use the travel elements
The book now opens on a summary spread — the route drawn on the left, the
figures on the right — and, for a journey that crossed a border, a country
page after it. Both are omitted rather than left empty when there is
nothing to draw: a spread holding a blank frame over the words "0 KM" is
worse than a shorter book, and every page in a printed one costs money.
Only the figures the journey actually has make it onto the page. A zero
is not a fact worth a tile, it is a hole in a composition.
Each entry gains a short accent rule above its heading — one drawn
element, and the cheapest thing here that makes a spread read as designed
rather than as text placed on a page. An entry with no photographs, which
is the one spread with a blank left page, sets its date as a numeral
there: a gap in the book becomes a pause in it.
The back cover closed with `${places} Orte` — German, hardcoded, in a
book that follows the app language everywhere else. It is a figures
element now, so it takes its labels from the same translations as the
rest of Studio.
The layout waits for the figures rather than racing them. Laying out
first would give a journey a summary spread or not depending on which
promise resolved first, and Studio already shows its loading state until
the document exists, so the wait costs nothing.
@
* @
feat(journey): the Studio wordmark replaces the book-name field
The field asked for a second name for something already named after the
journey — a box whose only real use was being left alone. The bar says
what this is instead: the lockup, inline so it takes the theme ink
through currentColor rather than needing a light-on-dark copy, and a beta
mark beside it.
Along with the strings for everything in this branch, across all 23
locales, and the two keys the name field used, removed rather than left
as orphans nobody can tell from live ones.
journey.studio.frames was "Picture frames" — too long for a tab sharing a
row with two others, and untranslated in 21 of the 23 locales, which
showed the English through. It is one word now, in every language.
@
* @
feat(journey): a free trim size, page numbers, and auto layout as a button
Three from the list the PR left open.
**Custom trim.** The five presets are what vendors actually bind, which
covers almost everyone — "almost" being the problem. A local printer with
a 24x18 press, or a book meant for a particular shelf, could not be made
here at all. Two fields under the format list, and typing in them *is*
picking custom: a book that is 210 by 240 is not square-21 with a typo,
so the preset follows rather than needing to be chosen first. Switching
to custom keeps the page it is on — it adjusts the format you have.
**Page numbers.** Set on the document and drawn by the renderer, which is
the only place that knows what number a page is. Stamping them with
pdf-lib afterwards would print numbers the editor never showed, and a book
you cannot proofread on screen is not much of an editor. They are also the
one thing on a page that is not an element: an element holding "14" would
still say 14 after its spread was moved. `startAt` exists because the
first inner spread is rarely page 1 once bound, and outer/inner is a real
distinction on a spread — getting it backwards puts both numbers in the
fold.
The controls live in the inspector empty state, which was one line of
hint text. With nothing selected, that panel is where the book own
settings belong.
**Auto layout as a button.** It ran once, on open, which made it the one
thing in Studio you could not ask for; a page that came out wrong had to
be rebuilt by hand. Now it does one spread or the whole book, both as
ordinary commits — the first thing anyone does with a button like this is
press it to see, and the second is want their page back. A single spread
keeps its id so the rail does not scroll, and refuses on anything not
built from an entry rather than replacing it with a blank.
@
* @
feat(journey): cover layouts, and five more for the spreads
The cover had none. The panel said covers are designed by hand, which was
true and unhelpful — it is the page people care most about and the one
they had no starting point for.
A cover is a different problem from a spread and gets its own set rather
than a filter over the existing one: it is a single page, the type is the
subject rather than a caption to a photograph, and a spread layout applied
to it lands half its frames past the edge. Five of them, from full bleed
to type-only, and the panel serves the right set for whichever spread is
open. The cards scale as one page there, and drop the fold line, since
there is no fold.
Five more spread layouts as well — a picture with a narrow column of
story, nine up, a mosaic around one large frame, two pictures with the
words between them, and four staggered so a row of squares stops reading
as a contact sheet.
Applying a layout also stopped mangling a single text. The old rule took
the longest text as the story and looked for a heading among the rest,
which reads a three-text spread correctly and gets a one-text cover
exactly wrong: the 30pt title *is* the longest text, so it was the story,
so a cover layout with a heading slot and no body slot had nowhere to put
it and parked the title. Size decides the heading now; length only sorts
what is left.
@
* @
feat(journey): seven self-hosted book faces, and a picker that shows them
The serif slot was Georgia. Georgia is a system font — on most desktops,
on no Linux container — so the print renderer would have substituted
something else and a book proofread in one typeface would have printed in
another. The existing Journey PDF export has exactly this bug today,
asking for Inter while only Poppins is bundled.
Every family is self-hosted through @fontsource now, all OFL, all loaded
with Studio own chunk so a face cannot arrive after the text has been
laid out and reflow the line endings someone was setting. Two sans, three
serifs from bookish to high-contrast, two display faces for a cover.
Every stack ends in a generic rather than in another named font nobody
shipped.
The three original ids are kept by name, so documents written so far
still parse — they point at bundled faces rather than at whatever the
machine happened to have.
The picker sets each family in itself. A row of chips reading
"Poppins / Lora / Bebas" in the interface font tells you their names and
nothing about the only thing anyone is choosing between.
Weights a family does not ship are disabled rather than offered: asking
for bold on a single-weight display face gets a synthesised one, which
prints as a smeared regular. Picking such a family moves the weight to
the nearest it really has.
@
* @
feat(journey): real map imagery, from the providers TREK already has
The map element drew country outlines and nothing else. That is still the
default and still the right one — nothing fetched, nothing licensed,
sharp at any print size. But an outline of Iceland cannot show which road
you took, and some books want that.
So two more sources, both reading the tile template and Mapbox token an
admin already configured for the planner and Atlas. A second place to
paste a token is a second place for it to be wrong, and a source with
nothing behind it is not offered at all — showing "Mapbox" to someone
without a token produces a grey rectangle and no explanation anywhere.
Both costs are stated rather than hidden: tiles are fetched when the page
renders, and they are raster, cut for one zoom, so printing much larger
than they were fetched goes soft.
Attribution is neither optional nor a setting. The licence requires
credit and a book is a published work — nobody can add a footnote after
it is bound. The credit is matched on the host, so an instance running its
own OSM mirror still credits OpenStreetMap, and an unrecognised source
gets none rather than an invented one. It is written into the document
when the element is placed, so the renderer cannot lose it.
The route is projected onto the tile grid when there is one. The vector
map fits itself to country outlines, and a line placed by that fit sits
next to the road rather than on it — the sort of error that only shows up
once it is printed. Outlines are dropped under imagery, where they would
be a second coastline over the one in the picture.
@
* @
fix(journey): make the Studio type-check again
The map element used tileView and projectOntoTiles without importing them,
useMapSources declared a stricter frame type than any real frame satisfies,
and the mood icon read a colour through an `in` check that types as unknown.
The page fixtures in the studio tests predate page numbers, so they build a
BookPageSetup that the contract no longer accepts — now parsed from the
schema, which cannot drift from it.
@
* @
feat(journey): the book is saved
Studio laid a book out on every open and kept it in the tab. Close the tab
and the afternoon went with it, and the auto layout was not a starting point
but the only point.
The document now lives in journey_books, one row per journey, and autosaves:
quiet for a moment after the last change, or at the latest fifteen seconds
in, so a drag across a spread is one request rather than four hundred. A
stored book wins over a fresh layout on open, and closing the editor writes
whatever is still queued.
Everyone on a journey can edit its book, so the write carries the version it
was made against and the update matches on it in a single statement. A save
that has been overtaken is refused with the current record in the body,
which turns "your work vanished" into a choice between two versions. The
server broadcasts journey:book:saved to the other editors — the version, not
the document — and a client with nothing outstanding takes the new one. A
client with unsaved edits deliberately takes nothing and conflicts on its
next save instead.
@
* @
feat(journey): export the book
The one thing Studio could not do. The button was there and disabled.
A book now goes out as sheets: every inner spread cut down the middle into
leaves in reading order, bleed carried out past the trim on all four sides,
crop marks outside that saying where to cut. The cut is a window rather than
a re-layout, so a photograph crossing the gutter still meets itself once both
halves are printed. Spreads stay whole in the other mode, which is what you
send someone to read.
The browser makes the PDF, from the editor components at 1:1. Considered and
rejected: headless Chromium on the server, and a PDF library drawing the
pages a second time. TREK is self-hosted, and neither Chromium in the image
nor a second renderer to proofread against is worth carrying when the browser
already produces exactly what the editor shows — it is also how the other
exports here work. What the browser will not write is PDF metadata and the
TrimBox entries; the marks carry that instead, as they have since long before
the format existed.
Two things had to give. `SpreadView` clipped at the trim, which is right on
screen and leaves a white hairline down every edge of a bound book, so print
mode lets the page run out and the sheet does the clipping. And the print
document is its own document: it carries the app stylesheets across so the
seven bundled faces are the ones that print, and waits for images and fonts
before it offers to save.
@
* @
fix(journey): make the Studio popovers cover what they cover
The auto-layout menu sat on the glass tokens, which carry 42-58% opacity when
the user has transparency on. That reads as depth over the workbench, where
the rails and the bar use it, and as a mess over the inspector, where this
menu opens: two layers of text through each other, neither readable.
The export dialog was worse — it named --panel and --card, which are not
tokens here, so its background resolved to nothing at all and the whole thing
was see-through.
Both are opaque now. The other glass surfaces are left alone: they float over
the workbench, and that is what the material is for.
The print preview also fits the pane. A spread sheet is over 400 mm across,
so at 1:1 the book ran off the side and had to be read by scrolling sideways.
Screen only — the print stays at full size.
@
* fix(journey): a cover keeps its own size, and a trim size can be typed
Three things the export and the page setup got wrong.
Spread mode made every sheet as wide as the widest so that one @page rule
would cover them all. That put a cover — one page — in the middle of a
spread-sized sheet with white either side, which is not a cover. Each sheet
is now sized to what is on it, and the print CSS carries a named page rule
for the single-page ones.
The trim fields clamped on every keystroke, so typing 500 meant the field saw
5, clamped it to the minimum and wrote that back under the cursor: the number
could not be typed at all, only nudged with the arrows. The field now keeps
what is being typed and tells the document about values it can use. The
arrows are unchanged, and leaving the field still clamps — someone who typed
5 and clicked away meant the smallest book.
And the autosave could loop. The save effect depended on the whole store
object, which is new on every render, so after a conflict — where the local
document is by definition not the saved one — it was a save attempt per
render: a 409, a re-render, another attempt. It depends on the callbacks now,
and a conflict holds the autosave until somebody answers it.
* feat(journey): page numbers pick their own colour
A book has dark pages and light ones, often on the same spread, so one folio
colour is wrong on half of them: the warm grey that reads on paper vanishes
into a full-bleed night photograph, and white vanishes the moment the page is
white. The way out used to be setting it per spread, which the document has
no place for.
The renderer asks what is under the number instead. It knows every element
and where it sits, so it walks them from the top down and takes the first one
that covers the spot: a shape's fill is arithmetic, a photograph gets white
with a hairline of shadow — unknowable without decoding it, and dark more
often than not once printed — and bare page takes the page colour.
A heuristic, deliberately: the alternative is sampling pixels from an image
that may not have loaded, in a renderer that also has to run for print.
Anyone it gets wrong can still pick a colour, and picking one turns it off.
* feat(journey): see who else is in the book, and where they are
Two people editing the same document without seeing each other is how you get
two people editing the same spread. So: a badge in the bar saying who has the
book open, and an arrow with a name on it for each of them.
The coordinates are millimetres on the spread, not pixels on a screen — the
two of you are at different zoom levels on different monitors, and a pixel
means nothing to the other one. They carry a spread index too, so a pointer
on page 40 is not drawn on page 2.
Ephemeral throughout. Nothing is written down, the room is the authorisation
for the pointer path, and a socket that dies takes its arrow with it rather
than leaving it on everyone else's page. Presence is keyed by socket because
two tabs are two pointers; the badge folds them back into one person, because
two tabs are not two people.
The rate limit needed room. Pointers move about ten times a second, three
times what the per-socket limit allows, and that limit is what stops a client
flooding the process — so it is now two: an outer ceiling every frame counts
against before parsing, and the original tight limit on everything a pointer
is not. Malformed JSON still counts against the tight one, which is what the
guard was for.
Throttled to ten frames a second going out and interpolated coming in: sixty
would be five times the traffic for something the eye cannot use, and drawing
ten as they land reads as a stutter rather than a hand.
* fix(journey): keep the zoom bar on screen at every zoom level
Zoomed far enough in, the bar slid off the bottom of the window — and it is
the only way back out, so the editor was stuck at 300%.
The workbench sat in a grid track sized `auto`, which grows to whatever is in
it; `overflow: auto` on the child does not stop that, because a grid item's
minimum size is its content unless told otherwise. So at 300% the box grew to
2400px inside a 660px window and took its absolutely positioned bottom edge
with it. The track is bound now, and the workbench scrolls instead of growing.
The bar also has a z-index, for the same reason it has to stay in view: it
must never be the thing something else covers.
* feat(journey): Studio replaces the old book export
The journey PDF and Studio were two ways to print the same book that disagreed
about what it looks like — and only one of them can be edited. The old export
goes: the two buttons on the desktop already sat next to the Studio one, and
the renderer they called is deleted rather than left as a second answer.
Nothing takes its place on the phone. The book is a Studio document now and
Studio is a desktop editor; a phone-sized PDF of something laid out for print
was the old export's answer to a question nobody was asking.
* feat(journey): lay the book out the way a travel book is laid out
The auto layout built every spread the same way: a picture on the left, words
on the right, and nothing else. It came out looking like a template because it
was one — and on a journey with little in it, like a stack of blank paper.
Five layouts now, chosen by what an entry has and alternating as the book goes
on. A place page for a stop nobody wrote about, with the country pale behind
the name and the coordinates under it. A story page with a small map of the
stop and the picture bleeding off three edges. A panorama across the fold. A
gallery with the hero on one page and a ruled strip on the other. And every
fourth picture spread goes full bleed with the words set over it, because a
book of loud pages has no loud pages.
The marks are what actually make these pages: a flag beside the country, a
date numeral, a chip saying which day this is, a rule along the foot with a
dot showing how far in you are. Each is trivial and together they are the
difference between a page that was designed and one that was filled in. All of
them are elements the panels can make, so what comes out can be taken apart.
The summary spread gained a heading and the furthest point drawn as two dots
and a line — that figure means nothing until it is a distance between two
names. The countries page went dark, because the printed one is: pale outlines
on near-black is the single most recognisable page in these books.
Two things the old layout got wrong are fixed on the way. Entries with nothing
on them share one page instead of taking a spread each, in the order they
happened rather than gathered at the end. And an entry whose photographs have
not arrived gets the frames they will go in, empty, rather than blank paper —
the layout is finished and the pictures are the only thing missing, which is
the right way round.
* feat(journey): build the spreads out of areas, and set the elements in ink
Two things, both about what a page is made of.
The layouts were photographs with captions. A printed travel book is built out
of areas: a page laid solid in ink with pale type on it, a band down the outer
edge, a picture that overruns the panel it sits against, a box ruled around a
block of text. Two more layouts do that now — one with a whole page in ink and
a second photograph crossing the fold onto it, one with an edge band and the
words in a ruled box, pictures at two sizes and one overlapping the hero. Six
layouts on a rotation, so the book stops reading as a template.
None of it needed a new feature. The document paints in array order, so a panel
pushed first is a panel everything else lands on; that is all layering is, and
it needed using rather than adding.
And the travel elements are ink by default rather than the app's orange. A book
is printed and a printed page is ink on paper; a colour that arrives without
being chosen turns every element into the same accent whether the book wants
one or not. Picking one is a click. The dark map is the exception — the route
goes pale there, because ink on a near-black country is a line nobody can see,
and only the default is overridden: a colour somebody chose stays theirs.
* fix(journey): three things the editor got wrong
An empty photo frame rendered on its own, ignoring frameStyle entirely — so a
Polaroid placeholder was a plain dashed rectangle until a photograph landed in
it and the chin appeared from nowhere. The two branches are folded together
now: the surround is drawn once and only what goes inside it differs, so a
frame shows what it will look like before it is filled.
Adding a page to a book with no inner pages yet put it in front of the cover,
where it could not be moved back from either — a move only ever swaps with
another inner spread, and the cover is not one. An empty book reports its last
inner spread as -1, and -1 + 1 is 0. The insert is bounded at both ends now.
And snapping only knew the outer safe lines. A spread draws that dashed rule
inset from each of its four vertical edges, so dragging inwards caught the fold
and nothing else — the line you could see just before it was not a target. All
four are, along with the centre of each page and the quarters within it: on a
spread the halfway mark is the fold, so a picture centred on its own page had
nothing to find.
* feat(journey): take the auto layout's spreads from templates drawn by hand
The layouts written as code are what a function can reason its way to: a
picture here, words there, margins that add up. What they are not is designed —
a panel bled off one edge, a shape running off a corner, a strip of pictures at
two sizes with the coordinates set beneath them. Those decisions come from
looking at a page, and the editor is where looking at a page happens.
So a spread built in Studio can now be the layout. `extract-templates.cjs`
reads a journey's book and writes its spreads out as data, with every
measurement divided by the page it was drawn on — a template built on a 210mm
square lays out on an A4 landscape book without being redrawn. `applyTemplate`
does the other half: it fills what the entry can answer for and copies
everything else as drawn.
That division is the whole contract. Text carrying a binding takes the title or
the story; empty frames take the photographs in order; the day, coordinate,
date and country marks take what the stop knows. Panels, rules and decorative
shapes are the design and are left exactly as they were put.
A template is one *for an entry* when it uses one — which has to come out of
the drawing rather than a label somebody remembers to set. A summary spread of
figures and country outlines reads perfectly well on its own, and dropping a
day's photographs into it would be nonsense.
Two templates ship with this, drawn by the person who will use them. The
built-in layouts stay as the fallback for entries no template suits.
* feat(journey): turn elements freely, and pass a spread on as a file
Rotation by the corner, the way it works everywhere else: a handle sits
diagonally outside each of the four corners of the selection, and dragging one
turns the element by the change in angle rather than to the angle under the
pointer, so it can be picked up anywhere without jumping. Held to 15 degree
steps unless shift is down. Four handles rather than one because a single
handle off the top right sits under the inspector for anything in the right
page's upper half.
And a spread can now leave the book it was made in. The zoom bar downloads the
one on screen as a file; Import, beside Add spread in the pages rail, brings one
back. Measurements travel as fractions of the page, so a spread drawn on a 210mm
square lays out correctly in an A4 landscape book, the same way the auto
layout's templates do.
What deliberately does not travel:
- Photographs. The file carries the design, and the frames arrive empty.
- The map source. A static map freezes the URL it was rendered from, and on a
Mapbox instance that URL has the access token in it, so a file meant to be
posted in a forum would carry a credential. Coming the other way, a tile
template from a stranger is a URL every reader's browser would fetch tile by
tile: an IP address handed to whoever wrote the file.
An imported file is treated as what it is, somebody else's JSON. It is read in
the tab that chose it and never uploaded, parsed and never evaluated, and every
element goes through the document contract, which drops unknown fields and
range-checks the rest. Colours in particular are #rrggbb or nothing. The size of
the file and the number of elements are both capped, because the damage a
hostile file can do here is not code execution, it is handing the editor more
than the server will accept and leaving a document that will not save.
Also fixes the rotation handle being drawn but not grabbable: the chrome layer
and the selection box both pass pointers through, and a handle has to opt back
in with pointer-events.
* docs(wiki): add two Studio screenshots for the addon page
The editor with a stats spread open, and the frames panel. Both are what the
Journey addon page will want once Studio ships, and both are wanted now to show
the feature off while it is still in review.
* test(realtime): cover the book room's refusals and its dead sockets
The realtime coverage ratchet caught what the book room work left behind: the
three handlers each have an early return for a message that is missing its
journey id or comes from a socket with no user, and none of them was exercised.
Every one of those is a silent failure by construction. A handler that did
something with a missing journey id would not throw, it would put a socket
somewhere it does not belong, and nobody would notice until two strangers were
looking at the same page.
The other half is sockets that are in a room but cannot be used: a connection
that dropped before its close handler ran, or one put in a room without ever
being registered. The peer list has to walk past both rather than showing a
ghost, and the broadcast has to skip them rather than throwing on a path that
runs ten times a second per editor.
Both files are back to full branch coverage, so the domain's pins hold as they
are rather than being lowered to fit.
* feat(journey): make the route map a map of the journey
Four things were wrong with it, and they compounded.
**It showed the wrong place.** The view was fitted to the country outlines as
well as the stops, so a trip that stayed inside Berlin was drawn as the whole of
Germany with two dots in the middle of it. It fits what the journey did now, and
the outlines run off the edge of the frame the way a coastline does on any real
map. Fitting to the country is still there as a deliberate choice, for the page
every travel book has, and how much air to leave around either is a setting
rather than a constant.
**It drew a line nobody travelled.** Stops joined in order is a claim about the
journey that is almost always false: it crosses mountains, water and borders the
trip went around. TREK already stores routed geometry per place, from the
planner and from imported GPX, so the map now follows the road where the trip
knows it and falls back to joining the stops where it does not. Segments stay
separate, so a trip with a flight in the middle does not grow a road across the
sea. The geometry is thinned when it is frozen into the element: a printed line
gains nothing from a point every fifteen metres, and a book carrying every GPS
fix of a three-week trip is a document nobody can save.
**The tiled and static sources drew nothing at all.** The tile grid was
computed, the route was projected onto it, and then no picture was ever
rendered — the map came out as a frame with a line across it. Three separate
faults behind that: the imagery layer did not exist, `{s}` was left unsubstituted
so every tile URL named a host that does not resolve, and the grid was cut from
the tiles the route happened to sit on rather than from what the frame shows, so
it stopped short of the edge. The frame decides the grid now, and it covers by
construction. Tiles overlap by a tenth of a millimetre, which closes the hairline
of white paper that rounding left between them.
Mapbox needed one more thing: `mapbox/standard` is a 3D GL style and the Static
Images API refuses it outright, so an instance configured for the planner's map
was configured with a style this cannot use. It falls back to the classic
equivalent, and a URL frozen into a document before that existed is repaired on
the way out rather than left broken for good.
**A map was always a rectangle.** It can be cut to the outline of the countries
now, so the picture *is* the country: no frame, no box, the page showing through
around the coastline. That is the version worth laying next to a photograph, and
the one thing a rectangle can never be. The tile grid and the vector fit are
projected from one extent so the picture and the shape it is cut to line up —
two fits meant the country was full of the wrong towns.
Also here: the credit line comes off the map itself. It is still resolved and
still stored, for the colophon, which is where a book has always credited its
sources.
* feat(journey): split the country list, and trim the map cards
The country list and the country grid were compositions somebody else made:
they decided the order, the spacing and the type size, and the only way to
change any of it was to not use them. They are gone, and what is left is the
same entry — the name over its silhouette, set the same way — as an element
placed one country at a time. Three down a page is the list, with the spacing
yours; one at 80mm is a chapter opener, which the list could never be. The name
is editable, so numbering them "1. Germany", "2. Netherlands" is typing rather
than a feature. The compact mark, outline beside the name at the size of a line
of type, stays for the places that want a mark rather than a heading.
The map section offers the four vector styles and nothing else. Tiles, Mapbox
and the cut-out are one click away in the inspector, where the rest of the map's
settings already live, and four cards that are the same map four times were
crowding out everything under them.
A map is fitted to the country by default now, which is the picture a travel
book usually wants. Fitting to the stops is still there and still matters the
moment the trip is smaller than the country it happened in.
Also: no navigation fallback from the dev service worker. In production it is
right — offline, a deep link has to be answered by the cached shell. In
development it answers a reload with the index.html it cached earlier, whose
script tags point at module URLs from before the last restart, so the browser
re-runs the previous version of the app while the editor shows the new one.
Every explanation for that is wrong until someone thinks of the service worker.
* fix(journey): drop what the trimmed map section left behind
Three bindings that only fed the cards removed in the commit before: the map
sources hook, the country codes the cut-out card needed, and the frame the
preview sized itself from. Lint had them as warnings rather than errors, so
they would have ridden along quietly.
|
||
|
|
2c333cb643 |
perf(journey): render the gallery in pages instead of all at once
A long trip's gallery is hundreds of tiles and every one of them was in the DOM from the first paint, which is what made scrolling it a chore (#1614). It now renders 60 and grows as the sentinel comes into view — the same mechanism the provider picker already uses, rather than a second one invented for this. Deliberately not pagination over the wire: the gallery arrives inside the one getJourneyFull payload that the client, the MCP tool, the MCP resource and the PDF export all read, so cutting it there would change the answer for four consumers at once and would silently shorten the book. The reported symptom was the scrolling, and that is client-side. The reported 500-photo limit is not in the code — no query limit, no upload cap, no client slice — so nothing was raised. |
||
|
|
06b7f3e690 |
feat(weather): answer for the hour when a past entry carries one
A journal entry made at 14:30 wants the weather of that hour. The archive path only ever asked for daily figures, so it answered with a rainy morning and a bright afternoon averaged into one number (#1614). With a time in hand it now asks for the hourly series as well and reads that hour, falling back to the daily figures when the hour has no reading — an archive gap must not turn into no answer at all. An unparseable time is treated as no time rather than as midnight, which would have been a confident wrong answer. The cache key carries the hour, so the two do not shadow each other. Nothing changes for callers that pass no time, which is every existing one. |
||
|
|
97f86dcf46 |
feat(journey): let quick capture take a note, not just a photo
The ask was "photos or quick notes" while travelling, and the capture sheet only took photos (#1614) — so a moment worth catching without a picture had no way in short of opening the full editor, which is exactly what capture mode exists to avoid. Two rows rather than the full sheet's three: this is a note, not the story. The story is written later, when the entry is finished. |
||
|
|
56b4509b03 |
feat(journey): put photos on the map where they were taken
The visible half of the capture-metadata work (#1614). Photos that know their coordinates are drawn as thumbnails on the Journey map, in the shape the report asked for: a picture rather than an abstract pin, and a count when several collapse into one. Clustering is a screen-space grid rather than a library. The Journey maps have never had clustering, and the one the planner uses hangs off react-leaflet while this map drives Leaflet directly; bucketing by rounded pixel position is a few lines, is deterministic, and does the job — photos of one place collapse into one thumbnail with a count, and pulling the map apart separates them again. The layer sits below the entry pins, because the itinerary is the point of the map and the photos are context. Its own effect, so photos arriving do not tear down and rebuild the map the way the entry effect does, and redrawn on zoom and pan because the buckets are in screen space. The thumbnail URL is built in the page rather than the hook: it needs the share token, which is the credential the byte proxy checks. Also re-points the trailing-migration guard at the new last slot, which is what its own comment asks of anyone appending one. |
||
|
|
11686be264 |
feat(journey): let a shared journey read newest-first
A journey shared while the trip is still running is read like a blog: the family following along wants the latest entry at the top, not to scroll past three weeks to find it (#1614). The owner sets it per share link, because it is a property of how the link is meant to be read rather than of the journey, and the reader can flip it either way. Off by default, so an already-published link does not reorder itself under its readers. Only the reading order changes. Day colours and stop numbers stay chronological, so the timeline still matches the map when the feed is upside down — which is why the day index is looked up rather than taken from the render loop. Reuses memories.newest/oldest, already translated in all 23 locales. |
||
|
|
e809808572 |
fix(journey): give the public map one numbering instead of two
The marker showed the stop's position within its day; the day heading showed the day number. Same colour, different meaning, and on mobile the heading is not rendered at all, so the number had no key — which is exactly what the report described: the same digits over and over in different colours (#1962). Both now count stops across the whole journey, so every number appears once, and the timeline carries the same number next to each geocoded entry. The day colour still groups the day. The badge sits outside the photo-count gate on purpose: an entry with a single photo puts its title and location in the image overlay, but the stop number has to be readable either way. |
||
|
|
85794a5a59 |
fix(journey): keep the picker's date headings chronological, and name any provider
Since the provider picker started sorting by distance to the entry, the date groups were built from the already-sorted list, so first-seen order put the day holding the nearest photo at the top and the headings stopped reading in order. Grouping now sorts its own output; the order within a day is untouched, so the distance sort still does its job where it belongs. The picker also called every non-Immich provider "Synology Photos". It now falls back to the provider's own id, the way the gallery view already did. |
||
|
|
edb487fbef |
feat(journey): let the desktop editor set the entry time, and reach the camera
The time was already stored, sent to the server and shown in the timeline, on the map, in the PDF and on the public share — the desktop editor just never rendered an input for it. So a draft carried the wall-clock time it was created at, it showed up everywhere, and there was no way to correct it (#1614). The column holds HH:MM:SS and a time input wants HH:MM, so both the initial value and the dirty check now trim. The camera gets its own hidden input rather than a capture attribute on the existing picker: putting it there would take the photo library away on a phone, which is the more common way in. The phone sheet already works this way. |
||
|
|
b267771772 |
fix(mobile): let the map show one day again
Collapsing a day card has hidden that day's places from the map since #216, and it was the only way to reduce a busy map to one day. The phone shell does not render DayPlanSidebar, so when it replaced the desktop page on phones that control disappeared with it: expandedDayIds stayed null and every day's pins sat on top of each other with nothing to do about it (#1962). A day chip in map view now drives the same declutter the desktop chevron drives, rather than introducing a second filter. Entering the map focuses the selected day, tapping another chip moves the focus with it, and leaving the map clears it so the timeline is never looking at a filtered state. Unplanned places stay visible, exactly as the collapse path has always left them. |
||
|
|
a3d1fc7808 |
feat(share): number the day's stops on the map and connect them in order
The public share page drew every geocoded place of the trip as an identical pin with no sequence and no line, so an itinerary could not be read off the map at all (#1962). It does filter to a day, but the only control for that sat below a 300px map, which is why the reporter never found it on a phone. Markers of the selected day now carry their position in that day's order, with the same contract the planner uses: a stop the day visits twice shows both, e.g. "1 · 3". A dashed line connects them in order. It is a straight connector on purpose, not a road route — drawing a real one would mean sending the itinerary to a third-party routing service for every anonymous visitor of a shared link, with no way for the trip's owner to opt out. A day picker now sits directly above the map, driving the same state as the day cards below, so the two can never disagree. Two things nobody reported, both found while in here. Outside a day selection every marker fell back to the placeholder indigo, because the payload nests the category on a day's assignments but sends it flat on the trip-wide pool and only the nested shape was read. And the map refit itself whenever the page re-rendered for an unrelated reason — the language picker, a late FX response — because the effect keyed on the array identity of a list the page rebuilds every render, throwing away the viewer's pan and zoom. It keys on the coordinates now. |
||
|
|
f4ee06e931 |
fix(share): stop the day header collapsing the title into a one-word column
The day card header on /shared/ is a single nowrap flex row, and its title block carried `flex: 1`. That is a flex base size of 0, so the moment an accommodation chip pushes the row past the available width the block freezes at its min-content width while the chip and the place count stay at full content width — the ~37px column with one word, or one CJK character, per line from #1955. It is content-driven, not viewport-driven, which is why it also showed up on a 1149px-wide desktop window: one long hotel name, or a day carrying both a check-out and a check-in, is enough. A breakpoint would have been the wrong fix. The title block now shrinks proportionally and truncates, the chip can shrink and truncates too, and the place count is pinned so it never wraps or gets pushed out of the card. The chip also moves off its hard-coded 9px onto the 10.5px the day sidebar uses in the app, with an icon to match — at 9px it was barely readable. |