#Stores, location and lead time
A store is an attribute of a staple, never a list of its own. Items on a list
group by shop with one section open and the rest collapsed, so pinned items are
visible without cluttering the trip. Which section is open is a per-device
preference in localStorage, not household data.
Nothing is filed by hand. A shop reaches an item by two routes, and only one of them is stored:
storeOf(item) = staple.store when overriddenBy.store === 'user'
?? learnedStore(staple) derived, every render
?? anywhere
staple.store means pinned by a person — it is written only from the Shop
panel on a staple's screen, and overriddenBy.store rather than the value is
what marks it, so that pinning something to anywhere on purpose ("I buy this
wherever, stop filing it") is distinguishable from never having pinned it at
all. Everything else is derived. That asymmetry is the whole design: a pin
cannot be trodden on by inference because inference writes nothing, and a
re-attributed trip changes what the app believes immediately, with no sweep to
run and nothing to migrate.
#A shop is a place, not a label
A store carries the identity of a specific storefront: Google's placeId, that
place's own coordinates, and an address. The id is what makes two branches of
one chain two shops — names collide, and coordinates drift with whatever fix
created the record — so it is also the dedupe key: findByPlaceId means the
same physical shop cannot be saved twice and have its visit history split.
A shop named by hand carries a name and wherever the phone was. That is a
working shop — it groups, it is assignable, it is learned from — that simply
cannot be recognised by walking into it until a place is bound. attachPlace
does that the first time you shop there with a key configured, and leaves the
name alone: "Kroger on the way home" is what the household calls it, and
replacing that with the sign over the door is the app arguing with you.
#Shops come into existence by shopping
There is no setup step. The first check-off of a trip opens a session and takes
a fix, without blocking the tap. inferStore reads the fix against shops we
already hold, and attributionFor (src/lib/sessions/attribution.ts, pure)
turns that plus what the Places lookup found into one of three verdicts:
inferStore | Lookup | Verdict |
|---|---|---|
match | skipped, unless the matched shop has no placeId | attribute; bind the legacy shop once |
| any | a placeId we already hold | attribute to it — exact, and it outranks any guess |
ambiguous | ran | ask, tied shops first |
nearest | found a shop here | attribute: a bad indoor fix at a shop we know |
nearest | ran, found nothing | leave — not a shopping trip |
none | found places | ask, offering them to save |
none | ran, found nothing | leave |
nearest / none | no key, so never ran | attribute the guess / ask — exactly the pre-lookup behaviour |
The lookup runs only where proximity is not confident, so a clean match costs
nothing: roughly one Places call per new shop plus one per genuinely uncertain
trip, not one per trip.
The two leave rows are what stops the app inventing trips. Proximity cannot tell a bad fix inside Kroger from the kitchen table with Kroger three streets away — both put you a few hundred metres from a shop you know — and attributing the second to the first records shopping that never happened, which then teaches the app that this is where you buy things. "Is there a shop where I am standing" is exactly what a nearby lookup answers, and a house is not a shop. Recording nothing is the honest outcome; the trip stays assignable from the header chip or from Recent trips.
Without a key every lookup returns nothing and lookupRan is false, which the
table treats as "no evidence either way" and falls back to the behaviour that
shipped before the lookup existed. A household with no key is not worse off.
The picker is never a modal. It renders inline on the list the way the seasonal question does, and an unanswered question just means the trip has no shop attached. A check-off never waits on it. It offers three ways to answer, in the order they are usually right: a shop already saved, tied ones first; a real place found around you, which saves a storefront rather than a name; and a typed name, which is the way through with no signal, no key, or a shop Google has never heard of.
#One write re-attributes a trip, however long ago it was
The trip's shop lives in the list header beside "Finish trip", and tapping it
opens the picker over every known shop. Correcting it is a single
setSessionStore: purchases carry sessionId and the session carries the shop,
so every item ticked off on that trip is re-attributed at once and no purchase
is ever rewritten.
That holds for a trip that has already ended, which is the ordinary case — you
get home, unpack, and only then tick things off, by which point the session has
expired. Recent trips (/settings/trips) lists the trips that bought
something, newest first, with the day, the count and the shop. Opening a trip
shows what it bought — listedTrips hands back each trip's items, not only
their count — and setting the shop is the same single write. The picker is
offered outright only on a trip with no shop; a placed trip keeps it behind
"Change shop", because a shop the app got right is read often and corrected
rarely. The Settings row above it says how many still have no shop, from
the same listedTrips in layer 1 that builds the list — counting one set and
listing another is how you get "2 without a shop" and then find one. Nothing to reconcile: a trip from three weeks
ago is as correctable as this afternoon's, and everything bought on it moves
with it. The list reaches back as far as the household's trips look-back
(ninety days unless Settings says otherwise) — what each trip bought is
counted from the checked-item listener, which carries that window of
check-offs (below, Screens) — and the screen says so. The recent-sessions
listener itself stays the newest fifty, unwindowed: learnedStores resolves a
staple's newest five purchases to shops through it, and a day window would
blind shop learning for anything bought less often than monthly.
Adding a shop by hand lives at /settings/shops. It searches Places by name
or address — "kroger southlake", "1305 E Southlake Blvd" — biased toward where
the household already shops, so a shop added from an armchair is tied to a
specific storefront exactly as one saved by walking into it. That search is the
one part of the feature that does nothing without an API key, and says so rather
than presenting a dead box.
#What an item learns
learnedStore (src/lib/stores/learn.ts, layer 1) reads a staple's cached
purchases, resolves each one's trip through subscribeRecentSessions, and
answers with the dominant shop of the newest five that have one — requiring at
least three, and a strict majority. Buy milk at Kroger three times running and
it files itself; buy it wherever and it stays under Anywhere; one odd trip does
not unfile it, and a 2-2 split answers nothing rather than picking whichever the
iteration reached first, because two devices holding the same data must not
disagree. The window counts purchases that can say something, so a run of
receipt imports with no trip cannot push the real evidence out of view.
Deleting a shop unpins whatever was filed there by hand, or those staples would keep an override pointing at a document that no longer exists: never grouped there again, and never free to learn anywhere else either.
#Lead time
Visits are recorded when a session closes, backdated to when the trip actually
ended, and feed the trip cadence that lead time is derived from: an item due
before your next expected visit goes on the list now, one due after it waits.
Medium confidence doubles the window — a shakier prediction is offered earlier,
never held back, because the premature item is the cheap failure and the empty
shelf the expensive one. Fewer than three visits, or a staple stored at
anywhere, falls back to a fixed two-day lead, doubled the same way at medium
confidence.