#Sessions
A trip is a burst of check-offs, and the pause after it means you left. Sessions are created implicitly by the first check-off — never by asking "which store are you at" — and closed lazily on read: on mount and on every return to visibility, an abandoned session is closed. No timer and no scheduled function, so it works offline and costs nothing.
A session opens without waiting for a location fix. getCurrentPosition's
timeout covers acquiring a position, not a permission prompt nobody has
answered, so awaiting it could block the very tap that checks an item off. The
fix is requested alongside and lands on the session whenever it arrives, or
never — which is an ordinary outcome, not an error.
It opens without waiting for a read, either. ensureSession is
synchronous: it answers from the open-session listener's last snapshot
(sessionRef), or from the trip this device just opened and the listener has
not echoed yet (opened), and otherwise opens a new one with a
client-generated id. It never performs a getDocs, because on a connection
that is up but not answering — the half-open state a phone in a supermarket
lives in — a read waits on the server for ten seconds or so before falling
back to the cache, and what it was holding up was the row moving. The
check-off's writes now go out in the same tick as the tap, which is what
src/data/items.ts promised all along. The opened ref is also what keeps two
quick taps on one trip: both would otherwise see no open session and start
their own. The listener normally catches up within the same tick, since
Firestore raises local writes to listeners before the network sees them.
closedAt is backdated to the session's last activity rather than set to
whenever somebody reopened the app, because the trip ended when the check-offs
stopped, and a store visit recorded three days late would corrupt the trip
cadence that lead times are derived from.
Items that were on the list and never checked off are recorded on the closed
session as evidence for out-of-stock detection, and leftBehind
(src/lib/sessions/leftBehind.ts, layer 1) reads it: the closed trips to a
regular's own shop — pinned by hand, or learned from its purchases
(stapleShop); a regular under Anywhere has no shop to count trips to —
newest first, and how many in a row from the newest carried the regular in
uncheckedStapleIds. A trip that bought it, or did not list it, ends the
run; a trip elsewhere is not evidence about this shop and neither breaks
nor extends it; an open trip has decided nothing. Two in a row
(LEFT_BEHIND_TRIPS) earns one muted line on the Regulars row and under
the Shop section of the regular's page — "Not found at Kroger on the last
2 trips there." (LeftBehindLine, the one sentence both render) — and
fewer earns silence. It is a derivation over the recent-sessions listener,
so nothing is stored and a re-attributed trip changes the answer at once.
See DECISIONS, "A trip that came home without it is a sentence, not a
verdict".
useCheckOff is the single place a check-off happens, because it is three things
at once: the item leaves the list, a trip opens if none is running, and — if the
item resolves to a staple — a purchase enters the stream. A repeat within six
hours is treated as the same purchase, so two people in one shop record one.