Skip to content

Follow the walk: watchPosition, gated so the list cannot re-sort under a thumb - #97

Merged
EnesYilmazcode merged 2 commits into
mainfrom
claude/open-issues-eymdb2-87
Sep 2, 2026
Merged

Follow the walk: watchPosition, gated so the list cannot re-sort under a thumb#97
EnesYilmazcode merged 2 commits into
mainfrom
claude/open-issues-eymdb2-87

Conversation

@EnesYilmazcode

@EnesYilmazcode EnesYilmazcode commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Closes #87.

locate() asked the phone where it was exactly once, at boot, and every answer for the rest of the session was ranked from that point. The product is one number — usable = window - walk - packup — and the walk was measured from a place the student leaves the moment they set off toward the room.

This is #87 option 1: watchPosition, throttled, feeding the existing useOriginrefresh() path, with the repaint gated so the list cannot re-sort under a thumb.

The suite was not evidence, and that was the reason to hold this

The first version of this PR reported "17 mutations, all caught". That was true of the seventeen chosen and it was the wrong conclusion. followFix and followAction are pure and genuinely mutation-checked, but the lifecycle around them was not: it lived in js/app.js, which touches the DOM at import and cannot be loaded under node, so follow.test.mjs asserted regexes over the source text. That kills a mutation which rewrites a quoted string and nothing else.

Reviewed with 26 mutations. Five survived a green suite, and grep -rn "watchId" scripts/test/ returned nothing:

mutation what it would ship
clearWatch(0) instead of clearWatch(watchId) the watch never actually stops
drop the watchId != null guard two live watches, doubled battery
drop watchId = null in stopWatch watch permanently off after one background cycle
drop either half of the throttle every fix re-ranks

The handle is a state machine in js/state.js now (createWatch), with the geolocation object and the visibility predicate injected, so all five are unit-testable. Re-run against the new tests, every one fails:

clearWatch(0)                     1 failing
no double-start guard             1 failing
handle not nulled                 2 failing
opens a watch on a hidden page    1 failing
picked origin followed anyway     2 failing

It is spelled geolocation rather than geo because install.test.mjs forbids the string geo: anywhere in js/geo: URIs are inert on iOS — and a property named geo trips that guard for nothing.

Two false sentences on the privacy page

Both were introduced by the first version of this branch, and both are now true of the code:

  • "never starts at all if you picked a building by hand" — false for a pick made mid-session. pickBuilding wrote the origin and left the watch open until the next position arrived to be judged, which with maximumAge: 0, no timeout and a student who has just walked indoors can be minutes away or never. It calls stopWatch() now.
  • "stops the moment you switch away" — false if the page was hidden before the boot fix landed. follow() called startWatch() with no visibility check, after visibilitychange had already fired, so the watch ran until the user foregrounded and backgrounded again. createWatch refuses a hidden page.

Two more in the same paragraph were older than this branch and plainly wrong. The position is put in a URL: mapsHref puts the gps lat,lon into saddr/origin and hands it to Apple or Google on the Directions tap. And there are twelve JavaScript files, not four. The page describes the handoff now, says it happens only on a tap and never on its own, and says the old claim was wrong. A test holds all three to the code.

Two more from the review

  • repaintRoom killed the compass and leaked its listeners. It rewrites #room's innerHTML, detaching the needle, its arrow and its label, while orientationOff still closed over the old nodes and its two window listeners stayed bound. The needle silently vanished every FOLLOW_M of the walk it exists for, and a second tap orphaned the first pair permanently. It releases the compass first.
  • Nothing cleared state.selected on the way back from a room. followAction returned 'hold' forever, under 'hold' refresh() never ran, so answer() — the only other thing that nulls it — never ran either. Open the app, tap a room, press back, walk: the list held boot values for the rest of the session, which is exactly the staleness Your location is read once at boot and never again, so the walk times are wrong the moment you start walking #87 exists to remove. showList clears it.

The gates

refresh() carries the rule this change had to not break — a list that re-sorts under a thumb loses the row somebody was reaching for. followAction() is the whole gate, pure, and the order of its tests is the rule (screen === 'room' before selected, because opening a room always selects its row).

gate behaviour
room open walk minutes and "yours for" redrawn in place, ranking untouched
row selected dot and line move, order stands
finger on the sheet holds, on every screen
list scrolled below the top holds — "idle and untouched"
idle, untouched list re-ranks
origin picked by hand the watch closes, the pick is never overwritten
page hidden watch stopped, and never opened on an already-hidden page
off-campus check runs on the boot fix and every accepted position

FOLLOW_M = 40 is 0.51 minutes at the engine's WALK_MPM of 78, under the COARSE_M of 75. FOLLOW_MS = 15000 is a guess and the comment says so — it is the number a battery run would move.

Tests

731 tests, 727 pass, 0 fail, 4 skipped. check-dead-code --strict: 84 files, no dead code. Shell gzip re-measured through the command the header documents: 98,246 bytes.

main had moved five lanes ahead; this merges it. showRoom's frames.wake() from #94 is kept alongside the wireRoom() extraction.

What is still NOT done

There is no battery measurement. The done-when "measured on a real phone: battery cost over a 15 minute walk with the app open" is not satisfied and stays open. No phone was involved in any part of this and no number was invented. The DECISIONS.md entry lists what has not been observed:

  • what a coarse watch costs over a quarter hour of walking
  • whether an installed iOS PWA holds a watch at all (SPIKE: does geolocation work in an installed PWA on current iOS? #5), and whether it survives backgrounding
  • how often a stationary phone crosses the 40 m line, which is what FOLLOW_MS was guessed against
  • whether repaintRoom's scroll and focus restoration is actually invisible to a reader

This remains the riskiest change of the batch: it alters runtime behaviour that cannot be validated without a device. The difference from the first version is that the suite now covers the parts that can be tested offline, rather than appearing to.

Also not done, on purpose: #88 is not fixed here (clearPickedOrigin is still unreachable; it gained one line so the watch returns if it is ever reached). sw.js is not re-stamped — the stamp is a HEAD sha and belongs to whatever commit merges.


🤖 Generated with Claude Code

https://claude.ai/code/session_017xiX95r6nnUUmhD3KwMnWM

…r a thumb

The app asked the phone where it was exactly once, at launch, and ranked every
answer for the rest of the session from that point. The product is one number,
usable = window - walk - packup, and the walk was measured from a place the
student leaves the moment they set off toward the room. #87 option 1.

watchPosition starts once the boot fix has landed and feeds the existing
useOrigin/refresh() path. Two thresholds throttle it, both in js/state.js:
FOLLOW_M 40, which is 0.51 minutes at the engine's WALK_MPM of 78 and sits under
the 75 m the app already calls a coarse fix, and FOLLOW_MS 15000, which is a
guess and says so.

The repaint is gated by followAction(), because refresh() carries a rule of its
own: a list that re-sorts under a thumb loses the row somebody was reaching for.
A picked origin stops the watch, a finger on the sheet holds the order, a room
open redraws its walk minutes in place and leaves the ranking alone, a selected
row or a scrolled list holds, and only an idle untouched list re-ranks. The dot
and the line move in every case, because the render loop already reads
state.origin every frame.

Also: the watch stops on visibilitychange to hidden and restarts on visible;
maximumAge is 0 on the watch where the boot fix keeps 60 s; the off-campus
circle moved into offCampus() so it runs on every accepted position rather than
only the first; enableHighAccuracy stays false on both.

scripts/test/follow.test.mjs is 25 tests, one per gate, and every gate was
checked by deleting it and watching its test go red. No phone was involved: the
battery measurement #87 asks for has not been taken and the done-when stays
open, which docs/DECISIONS.md says out loud. privacy.html said the app "does not
keep watching" and has been corrected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xiX95r6nnUUmhD3KwMnWM
…hat are not true

Merges main, which had moved five lanes ahead, and fixes what review found.

## The suite was not evidence

followFix and followAction are pure and were mutation-checked. The lifecycle
around them was not: it lived in js/app.js, which touches the DOM at import
and cannot be loaded under node, so follow.test.mjs asserted regexes over
the source text. Reviewed with 26 mutations and five survived a green suite:

  clearWatch(0) instead of clearWatch(watchId)  the watch never stops
  drop the watchId != null guard                two live watches
  drop watchId = null in stopWatch              never restarts
  drop either half of the throttle              every fix re-ranks

`grep -rn watchId scripts/test/` returned nothing. The handle is a state
machine in js/state.js now, with the geolocation object and the visibility
predicate injected, so all five are unit-testable. Re-run against the new
tests, every one of them fails:

  clearWatch(0)                     1 failing
  no double-start guard             1 failing
  handle not nulled                 2 failing
  opens a watch on a hidden page    1 failing
  picked origin followed anyway     2 failing

It is spelled `geolocation` rather than `geo` because install.test.mjs
forbids the string `geo:` anywhere in js/ -- geo: URIs are inert on iOS --
and a property named `geo` trips that guard for nothing.

## Two false sentences on the privacy page

"never starts at all if you picked a building by hand" was false for a pick
made mid-session: pickBuilding wrote the origin and left the watch open
until the next position arrived to be judged, which with maximumAge 0, no
timeout and a student who has walked indoors can be minutes away or never.
It calls stopWatch() now.

"stops the moment you switch away" was false if the page was hidden before
the boot fix landed: follow() called startWatch() with no visibility check,
after visibilitychange had already fired, so the watch ran until the user
foregrounded AND backgrounded again. createWatch refuses a hidden page.

Two more in the same paragraph, both older than this branch and both plainly
wrong: the position IS put in a URL -- mapsHref puts the gps lat,lon into
saddr/origin and hands it to Apple or Google on the Directions tap -- and
there are twelve JavaScript files, not four. The page describes the handoff
now, says it happens only on a tap, and says the old claim was wrong. A test
holds all three to the code.

## Two more from the review

repaintRoom rewrote #room's innerHTML while orientationOff still closed over
the detached compass nodes and its two window listeners stayed bound, so the
needle vanished every FOLLOW_M of the walk it exists for and a second tap
orphaned the first pair for good. It releases the compass first.

Nothing cleared state.selected on the way back from a room, so followAction
returned 'hold' forever, refresh() never ran, and answer() -- the only other
thing that nulls it -- never ran either. Open the app, tap a room, press
back, walk: the list held boot values for the rest of the session, which is
the staleness #87 exists to remove. showList clears it.

## Still not done

No battery measurement. No phone was involved in any of this and no number
was invented. The done-when stays open and the DECISIONS entry says what has
not been observed.

731 tests, 727 pass, 0 fail. check-dead-code --strict: 84 files, no dead
code. Shell gzip re-measured through the documented pipe: 98,246 bytes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xiX95r6nnUUmhD3KwMnWM
@EnesYilmazcode
EnesYilmazcode merged commit e38e12d into main Sep 2, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Your location is read once at boot and never again, so the walk times are wrong the moment you start walking

2 participants