Planet4.Me docs
Open the app

#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:

inferStoreLookupVerdict
matchskipped, unless the matched shop has no placeIdattribute; bind the legacy shop once
anya placeId we already holdattribute to it — exact, and it outranks any guess
ambiguousranask, tied shops first
nearestfound a shop hereattribute: a bad indoor fix at a shop we know
nearestran, found nothingleave — not a shopping trip
nonefound placesask, offering them to save
noneran, found nothingleave
nearest / noneno key, so never ranattribute 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.