- JavaScript 92.8%
- CSS 7%
Desi found main red. Both failures were waiting for today's date rather than for anyone's change — the trip began this morning, so Home's `during` branch rendered for the first time since it was written. THE DUPLICATE (her find, her recommendation, and she is right). The status card says "Today / Arrival & settle in". The primary action said "Open today / Arrival & settle in" directly underneath. Repeating it added nothing the second time and made two elements look like two different choices to weigh up, which is the opposite of the one-primary-action hierarchy the test is named for. The button now carries the day's AREA — "Centraal → Hotel". That answers the question the title leaves open, which is where today actually goes, and it is already on every day so it needs no new data. Dropping the sub-label entirely was the other option; the area is the same amount of screen for information that is not already three lines above it. THE SECOND HALF OF THE SAME TEST WAS ALSO WRONG. It asserted "Open the itinerary" and `#/days` unconditionally, while branching on phase for everything above. During the trip the primary action is "Open today" pointing at the day — so that assertion was dead code until this morning and wrong afterwards. It would have failed today even with the duplicate fixed. Now branches. The root cause is that this test reads the real clock, so exactly one phase was ever exercised and the other two rotted unseen. Four new tests render Home at pinned dates — before, during, after — so every phase runs on every run. Reverting the fix fails exactly the two duplicate tests and nothing else. ALSO FIXED: a flake, not a break. DayEdit's "keeps the sensory tags that were ticked" failed in the full run and passed 30/30 alone. Same trap already documented twenty lines above it in the same file: adding an activity is a write plus a full re-read, and findBy polls for ONE second by default, out of step with the 40s testTimeout this file needs for exactly these operations. Same fix, same reasoning. 50 files, 742 tests, lint and build clean, verified on a clean export of origin/main rather than on my working tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|---|---|---|
| documents | ||
| public | ||
| scripts | ||
| src | ||
| .dockerignore | ||
| .gitignore | ||
| .oxlintrc.json | ||
| Amsterdam_Itinerary_1623_Sept_2026_2.pdf | ||
| DEPLOY.md | ||
| DESIGN-CONSTRAINTS.md | ||
| Dockerfile | ||
| index.html | ||
| nginx.conf | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| vite.config.js | ||
Amsterdam trip app
Day-by-day itinerary and checklist for 16–23 September 2026, digitised from
Amsterdam_Itinerary_1623_Sept_2026_2.pdf. That PDF is the source of truth — if the
two disagree, the PDF wins.
npm install
npm run dev # http://localhost:5173
npm run build
npm run lint
npm run test
Design constraints
Changing how this looks? Read
DESIGN-CONSTRAINTS.mdfirst. It separates the hard accessibility requirements — no pure white or black, a contrast ceiling as well as a floor, colour never alone, no motion, visible navigation — from the current design choices you are free to change, and lists the five things that have actually bitten in this project.
This is built for AuDHD use, and for Irlen Syndrome (visual stress). These are requirements, not polish:
- One thing per screen. Landing screen → day list → one day → one activity's details. Activity details stay collapsed until asked for.
- The landing screen answers one question: "what now?" A countdown (or today's now/next during the trip), then a single visually dominant action. Everything else on it is deliberately quieter. Resist turning it into a dashboard.
- Predictable navigation. Three fixed destinations — Itinerary, Documents, Checklist — always in the same place at the bottom of the screen. Hash routing so the back button works. The landing screen, day list, a day and the week overview are all within Itinerary; views of the itinerary hang off it rather than becoming their own tabs. If this needs to grow, it stays a visible bar — never a hamburger drawer. A hidden menu is worse for predictability, not better.
- Two ways to read a day, not five. Timetable (the PDF's layout: clock down a
left gutter, whole day in one scroll) or Detail (cards with costs and links).
The choice persists in
localStorage, because a view that silently resets to something you didn't pick is exactly the unpredictability this app avoids. - Now/next is never buried. During the trip the top of the itinerary says what is happening now and what is next, computed from the clock.
- No motion.
src/index.cssglobally disables animation and transitions for everyone, not only forprefers-reduced-motion. - 48px minimum touch targets (
--tap). - Display type is ALL CAPS and stays on short headings. "New Amsterdam"
(headings) draws every letter at cap height, so "Amsterdam, our way" renders
as "AMSTERDAM, OUR WAY". That was chosen knowingly; the cost is that capitals
lose word-shape, which is what the eye uses to recognise a word without
reading it letter by letter. So it is used only at 20px and above and only
on short strings: the masthead, the hero title, day titles, the countdown and
the day-list banner. Do not extend it to
.section-title,.day-card-title,.card-titleor.tt-title— those are 18–19px, dense, or scanned eight at a time, and two of them contain→, which the face lacks. Set atfont-weight: 400so the browser can't synthesise a faux-bold. - Colour never carries meaning alone. Every sensory tag has a text label — "loud & flashing", "step-free", "stairs" — so the colour is reinforcement, not the signal. Same rule for links: each one spells out its kind ("ACCESSIBILITY", "TICKETS", "MAP") rather than relying on the icon or border colour. Do not remove the labels to tidy up the layout. Greyscale the whole app and nothing should become ambiguous.
- Colour coding is load-bearing, so it has to be separable. Each sensory category gets its own hue, and each of the eight days has its own accent used on the day card, the day header and the stripe under the masthead. All pairings are dark-on-light and clear 4.5:1.
Data
-
src/data/itinerary.js— the eight days, transcribed from the PDF. -
src/data/checklists.js— two kinds of list, and the difference is load-bearing:source: 'pdf'— the PDF's own booking and admin checklist.source: 'derived'— not in the PDF. Assembled from its weather, cash, proof and venue notes. The UI labels these sections explicitly so nobody mistakes them for something the trip planner already signed off.
-
src/data/activityLinks.js— per-activity links: official site, tickets, accessibility page, Google Maps, transport. -
src/data/weekOverview.js— PDF page 1 (the shape of the week, the three warnings) and page 25 (the day-by-day totals table). The totals table rounds the companion savings where the day cards don't — Sunday reads −€30 here and €29.50 on the day card. That mismatch is in the source PDF and is preserved deliberately; a test asserts the core figures match the day cards, so real drift still fails.
Ticks are stored in localStorage under ams.itinerary.done and ams.checklist.done.
Documents
Two kinds, with different guarantees — see documents/README.md for the full
convention. Short version: files committed to documents/<day-id>/ are bundled
into the build and shared across both phones; files added through the app live in
IndexedDB and are device-local, so they are never served publicly and never
reach the other phone. Sensitive things (booking confirmations, tickets) should
go through upload for exactly that reason.
scripts/build-documents.mjs runs on prebuild/predev and fails the build
on a bad day folder or a links.json entry pointing at a file or activity that
does not exist — a document silently attached to the wrong activity is worse than
a broken build.
Graphics on the landing screen
The canal-house skyline in src/components/Home.jsx is drawn as inline SVG
rather than downloaded: no asset dependency, no network request, nothing extra
to cache offline, and it reuses the eight day accent colours so the palette
stays consistent. It is purely decorative and aria-hidden — a test enforces
that. If it ever starts carrying meaning, it needs a text equivalent.
To use a photographic hero instead, put the file in public/images/ and set
HERO_IMAGE (and a real HERO_ALT) at the top of Home.jsx. Image assets must
live inside this project folder — public/ for files served as-is. Anything
in a sibling folder such as ../amsterdam-assets/ is outside both the Vite root
and the Docker build context, so it will neither bundle nor deploy.
Fonts
Both are self-hosted in public/fonts/, not loaded from the Google Fonts CDN.
That is deliberate: the app has to work offline on a foreign SIM, and a CDN font
is a network dependency that fails exactly when it matters. Together they are
41KB, and they get precached with everything else.
- Space Grotesk (body) — the variable font, so one 21KB file covers weights
300–700. Only the
latinsubset is shipped;latin-extwas checked and isn't needed, because every letter in the app's content is within Latin-1. - New Amsterdam (display) — converted from the supplied TTF (74KB → 19KB as
woff2, with all 377 codepoints compared before and after rather than assumed).
Source:
amsterdam-assets/fonts/NewAmsterdam-Regular.ttf. Replaced Hello Amsterdam on 13 Sept; that file was also removed frompublic/fonts/, where it was still being precached despite nothing using it.
Neither face contains → (U+2192), the non-breaking hyphen (U+2011) or ✓
(U+2713). Those fall through the stack to system-ui, which is why the chain
ends there rather than at a bare sans-serif — the fallback is chosen, not
accidental.
Picking day illustrations
All eight day images come from one clipart set
(amsterdam-assets/Amsterdam-Canal-House-Clipart-Set-64280293). That is the
whole basis of the visual consistency: same flat-illustration technique, same
warm terracotta/teal/navy palette as the hero. Each was chosen for a subject
that echoes the day and a dominant colour that sits with that day's accent.
Do not mix sets. Other bundles in the assets folder were rejected on style, not quality — the landmark set is a fixed orange/blue linocut that fights the per-day accents, and the watercolour sets are softer and less saturated, which works against the brief for more vibrant colour. If more images are needed, take them from the same set, or restyle all eight together.
Images are alpha-trimmed cutouts on transparency, resized to 440px and saved as WebP. Total image budget is currently 579KB (hero 136KB + eight days). That is a deliberate ceiling to watch when the service worker starts precaching — these have to download once over a foreign SIM.
src/data/dayImages.js holds the mapping and the alt text. The day screen shows
one image and describes it; the day list shows eight and marks them decorative
(alt=""), because eight near-identical descriptions down one screen is noise.
Tests enforce both.
Where the links come from
Every URL is lifted from the PDF's own link annotations. pdftotext renders the
visible label ("Google Maps", "Accessibility") and throws the href away, so they
were extracted from the /Annots dictionaries with pypdf. Nothing is
invented — if a venue has no link in the PDF, it has none here. Keep it that
way: a plausible-looking guessed URL is worse than no link when someone is
standing outside a museum trying to check step-free access.
activityLinks.js is keyed by day id + activity index and carries the expected
activity title alongside. A test asserts every title still matches, so reordering
an activity fails the build instead of silently attaching the Anne Frank
accessibility link to the Body Worlds card.
Known constraints on this machine
npm run dev needs the polling watcher. The project lives on an SMB share
(N: → \\BAZINGA-NAS\bazinga-network). Node's native fs.watch fails there with
UNKNOWN: unknown error, watch (errno: -4094) and kills the dev server on startup.
vite.config.js sets server.watch.usePolling. Do not remove it while the project
lives on the share.
npm run test does not run from the share. Vitest's worker start timeout is a
hard-coded 60 s (START_TIMEOUT in vitest/dist/chunks/), and workers cannot boot
over SMB inside that budget — every run fails with
[vitest-pool-runner]: Timeout waiting for worker to respond before a single test
executes. There is no config option for it. To run the suite, copy the project to
local disk and npm install there. All 298 tests pass that way; they have not
been made to pass from N:.
Offline / PWA
The app installs and works with no network at all. Verified, not assumed — see below.
public/manifest.webmanifest+ icons inpublic/icons/(192, 512, maskable 512, apple-touch). Installable to a home screen,display: standalone.scripts/build-sw.mjsruns onpostbuild. It globsdist/, writes the real hashed filenames intodist/sw.js, and names the cache after a hash of that list — so any asset change produces a new cache and the old one is deleted on activate. There is no version number to remember to bump.- Registration is production-only (
src/lib/registerSW.js). A service worker in dev serves stale assets over HMR and makes every edit look like it failed.
Precache is currently ~2.5MB across 22 files — app shell, fonts, day
illustrations, and bundled documents. cache.addAll is atomic, so a partial
download never leaves a cache that claims to be complete.
The ceiling to watch: bundled documents are precached, which is what makes
them guaranteed-available offline. That is right while there are a handful. Once
documents/ grows past roughly 5MB, switch them to runtime caching with an
explicit "save for offline" control per document, because a large precache can
fail on a flaky connection and the install is all-or-nothing.
Testing offline properly
CDP's Network.emulateNetworkConditions does not work for this. It applies
to the page target; a service worker fetches on its own target and sails past
it. An "offline" emulation test will happily report success while the SW is
quietly using the real network.
C:\temp\ams-verify\offline2.mjs does it honestly: serve dist from a local
throwaway server, let the SW install, shut the server down, then reload.
Nothing can be fetched from anywhere. Current result — service worker active,
22 entries precached, server confirmed gone, and the home screen, a day deep
link, the Documents tab, the bundled PDF and the day images all still resolve.
For the deploy
/sw.js must be served with Cache-Control: no-store. If a stale service
worker gets cached, clients pin themselves to an old build and no amount of
deploying fixes them. This is already noted in DEPLOY.md.
Information
#/information — the practical front matter from PDF pages 2–5: the sensory
key, budget vs €1,500, the passes verdict, hotel↔everywhere routes, practical
notes, how companion tickets actually work, the Anne Frank / Van Gogh position,
and the FEBO vending walls. Eight sections, collapsed by default.
Deliberately not duplicated here: the booking checklist (already in
checklists.js) and the link lists (already in tripResources.js). This
section is the prose and the tables.
Two things worth knowing:
- The sensory key renders real tag chips, not descriptions of them, so the legend always matches what's actually on an activity. Tests assert the key and the itinerary agree in both directions — a tag in use but missing from the key fails, and so does a key entry nothing uses.
- The hotel↔everywhere table was cross-checked, not just transcribed. The PDF renders it as multi-column and the text extraction interleaved the rows, so each route was verified against the matching day in the itinerary before being written down. A test pins two of them.
Resources
Every link and number in one place, at #/resources. Built by merging
data/tripResources.js (trip-level links from the PDF's front matter) with all
the per-activity links and deduping by URL — 9292 appears on several travel
legs and FEBO across four days, so one row each, with a note saying how many
activities it came from.
Three deliberate choices:
- The phone numbers are not collapsed and not filtered by the search box. That block is what you need when something has gone wrong and you are not in a state to go hunting. Emergency services is first and visually distinct.
- Link groups are collapsed by default and open automatically while searching. 75 links in one scroll is a wall; this is the same one-section-at-a-time pattern the Checklist already uses.
- Maps links are deliberately excluded from the listed groups. There are 64 of them, all reading "Google Maps", and a map link belongs beside the activity it points at. A test asserts they stay out.
The nav is five fixed tabs. It may grow again — but it stays a visible bar. Never a hamburger drawer, never an overflow menu. The test asserts that shape rather than a count, so it doesn't need editing every time a section lands.
Keep the tab labels short. Flex items default to min-width: auto, so at
five tabs the labels can't shrink and the last one clips off the right edge —
which is exactly what happened with "Documents" at 430px. Hence "Docs" and
"Info" on the bar while the screens keep their full headings. Verified at 360px
as well as 430px.
Calendar export
.ics is the primary route, not a Google Calendar link. It is a file format, so
it works with Google, Apple and Outlook alike, works with no signal, and can
actually be validated — which a third-party URL scheme cannot. The Google
"add event" link is a convenience on top and is labelled in the UI as needing a
connection. Three scopes: one activity, one whole day, the whole trip.
Details that matter if you touch src/lib/icalendar.js:
- Times are emitted in UTC. The whole trip sits inside CEST (EU summer time runs to 25 Oct 2026), so Amsterdam local is UTC+2 throughout. UTC keeps events correct whichever timezone the phone is in — they are travelling.
- Line folding counts octets, not characters. An
éis two bytes, so "Café Kalkhoven" would push a naive 75-character fold over the limit. - Durations take the UPPER end of a range — "60–75 min" becomes 75. Booking short creates a clash with the next activity that isn't real; booking long leaves a harmless gap.
- Two activities have no clock time ("Tonight", "Train −90 min"). They become all-day events on the right date rather than being given an invented time.
Validated against the third-party Python icalendar library, not just against
its own tests: 71 events, 69 timed + 2 all-day, unique UIDs, no missing DTEND.
Not built yet
Memories, and editing activities in the app. Full-text search inside uploaded PDFs is also outstanding — bundled documents could use the extraction tooling already proven on the itinerary, but uploads would need pdf.js client-side.
Google Wallet was dropped, not deferred: passes must be signed with a Google Cloud service account key, which cannot happen in a static client-side app.