Friday Grocery Cart

Friday Grocery Cart turns a normal shared Notion checklist into a weekly Kroger cart without converting the document into a database.

Every Friday at 9:00 p.m. Eastern, a Val Town interval reads the Notion page, finds unchecked non-empty to-do blocks under the Kroger heading, resolves their text to pickup-eligible products at a configured store, adds confirmed matches to the authenticated customer's Kroger cart, and checks off each Notion block only after Kroger reports success. The customer reviews the cart and completes checkout in Kroger manually.

The Notion API exposes blocks, text, headings, checkbox state, and block IDs. The application still owns the business rules that give those blocks meaning.

Current state

The first end-to-end integration test passed on August 29, 2026:

  1. Val Town read the live shared Notion page.
  2. It found one unchecked item under the Kroger heading: Lettuce.
  3. The first dry run refused to guess among several Kroger results.
  4. The user selected Kroger® Green Leaf Lettuce, UPC 0001111018167.
  5. That preference was saved as the meaning of the exact text Lettuce.
  6. Kroger accepted one item into the real customer cart.
  7. Only after Kroger returned success, the Val checked off the exact Notion block.
  8. A second Notion read returned zero eligible unchecked Kroger items.
  9. The customer independently confirmed that the product appeared in the Kroger cart.

A production mapping run completed on September 3, 2026:

  • The user reviewed live pickup candidates and selected exact products for the current list.
  • Thirteen products were added and committed to Notion on the first pass.
  • A generic-search pagination issue initially hid the chosen six-count pepper UPC.
  • Matching was updated to fall back to an exact UPC lookup for remembered choices.
  • The pepper was added successfully on a second pass, for 14 products total.
  • There were no failed or uncertain cart writes.
  • Strawberries were initially held behind a sale-price condition. That condition has since been removed; the remembered 1 lb strawberry selection is now unconditional.
  • Wraps were intentionally excluded.

Configured production context:

  • Notion page: Grocery List
  • Page ID: 303c869b2f04804bbf24f393fd074bd1
  • Store: Kroger Marketplace – Holland Road
  • Address: 3901 Holland Rd, Virginia Beach, VA 23452
  • Kroger location ID: 02900540
  • Schedule: Friday at 9:00 p.m. America/New_York
  • UTC mechanism: hourly interval plus an Eastern-time execution guard
  • Checkout: always manual

Architecture

Notion shared page ↓ read blocks Val Town interval and business rules ↓ search pickup products Kroger Products API ↓ add remembered/confirmed matches Kroger Cart API ↓ successful response Notion Update Block API ↓ Customer reviews cart and checks out manually

Files:

  • main.ts — protected admin page, OAuth routes, diagnostics, and manual-run endpoint
  • lib.ts — Notion parser, Kroger clients, matching, OAuth refresh, cart writes, and state transitions
  • cron.ts — scheduled Friday entrypoint
  • dry-run.ts — read-only parser diagnostic
  • README.md — setup and operational documentation

Persistent state uses Val-scoped std/blob storage:

  • kroger:oauth — Kroger access and refresh tokens
  • oauth:state:* — short-lived OAuth anti-forgery state
  • match:<normalized text> — remembered checklist-text-to-UPC mappings
  • run:<date>:<notion block id> — per-item execution ledger
  • run:latest — latest run report

Credentials belong in Val Town environment variables, never blob storage or source code.

Notion document contract

The page remains a normal shared document. No Notion database is required.

Example:

## 09/04/26 - next shop ### Kroger - [ ] Lettuce - [ ] 2x whole milk - [x] Coffee ### Costco - [ ] Paper towels

Current parsing rules:

  1. Recursively retrieve all blocks and handle Notion pagination.
  2. Treat headings as the current section name.
  3. Process only to_do blocks under a heading exactly equal to Kroger, ignoring case.
  4. Ignore checked boxes.
  5. Ignore empty boxes.
  6. Parse a leading integer such as 2x, , or 2 as quantity; otherwise use quantity 1.
  7. Preserve the remaining text as the product query and preference key.
  8. Do not alter headings, prose, other stores, checked items, or empty boxes.

Environment variables

Open the Val's Environment Variables page and create the following values:

VariableSecret?Purpose
NOTION_TOKENYesNotion internal connection token with access to the grocery page
KROGER_CLIENT_IDYesClient ID from the Kroger Developer Portal
KROGER_CLIENT_SECRETYesClient secret from the Kroger Developer Portal
KROGER_REDIRECT_URINoExact deployed OAuth callback URL
KROGER_LOCATION_IDNoKroger location used for product and pickup filtering
SETUP_KEYYesProtects admin, diagnostics, connect, and manual-run routes
LIVE_CARTNoMaster write switch; must equal the string true for cart and Notion mutations

Prefilled environment-variable pages:

👉 Add NOTION_TOKEN here: https://www.val.town/x/pchinjr/friday-grocery-cart/environment-variables?key=NOTION_TOKEN

👉 Add KROGER_CLIENT_ID here: https://www.val.town/x/pchinjr/friday-grocery-cart/environment-variables?key=KROGER_CLIENT_ID

👉 Add KROGER_CLIENT_SECRET here: https://www.val.town/x/pchinjr/friday-grocery-cart/environment-variables?key=KROGER_CLIENT_SECRET

👉 Add KROGER_REDIRECT_URI here: https://www.val.town/x/pchinjr/friday-grocery-cart/environment-variables?key=KROGER_REDIRECT_URI

👉 Add KROGER_LOCATION_ID here: https://www.val.town/x/pchinjr/friday-grocery-cart/environment-variables?key=KROGER_LOCATION_ID

👉 Add SETUP_KEY here: https://www.val.town/x/pchinjr/friday-grocery-cart/environment-variables?key=SETUP_KEY

👉 Add LIVE_CART here: https://www.val.town/x/pchinjr/friday-grocery-cart/environment-variables?key=LIVE_CART

For this deployment:

KROGER_REDIRECT_URI=https://pchinjr--373b716ca36411f1a6f51607ee4eb77e.web.val.run/auth/kroger/callback KROGER_LOCATION_ID=02900540 LIVE_CART=false

Generate SETUP_KEY as a long random value. Do not reuse a password. The HTTP Val must remain public so Kroger can reach its OAuth callback, so every administrative route is separately protected by this key.

Keep LIVE_CART=false during setup and every dry run. Turn it on only after Notion parsing, store selection, OAuth, and product matching have been verified.

Notion setup

You must be able to create an internal connection in the target Notion workspace.

  1. In Notion, open Settings → Connections.
  2. Enable Developer Mode if prompted.
  3. Open Personal access tokens or Internal connections.
  4. Choose New connection.
  5. Name it, for example, PrefilledGroceryCart.
  6. Select the workspace containing the grocery page.
  7. Grant Read content and Update content capabilities.
  8. Create the connection.
  9. Copy its installation access token into the Val's NOTION_TOKEN environment variable.
  10. Open the Grocery List page.
  11. Use the page's upper-right ••• menu.
  12. Choose Connections or Add connections.
  13. Select the new connection and confirm.

The token alone is insufficient. If the page was not shared with the connection, Notion returns 404 object_not_found even when the page ID is correct.

Only the Grocery List page needs to be connected. It does not need to be published, converted into a database, or exposed to the web.

Kroger developer application setup

  1. Sign in to the Kroger Developer Portal.
  2. Create a developer application.
  3. Enable the public APIs required by this project:
    • Authorization
    • Locations
    • Products
    • Cart
  4. Copy the generated client ID to KROGER_CLIENT_ID.
  5. Copy the generated client secret to KROGER_CLIENT_SECRET.
  6. In the application's redirect/callback configuration, register the exact deployed callback URL from KROGER_REDIRECT_URI.
  7. Match the URL byte-for-byte, including protocol, host, path, and trailing slash behavior.

The application uses two OAuth grant types:

  • Client credentials with product.compact for stores and product search.
  • Authorization code with cart.basic:write for acting on behalf of the customer's cart.

The Val never receives or stores the customer's Kroger password or payment information.

Authorizing a Kroger customer account

After setting the Kroger credentials and callback:

  1. Open the protected admin route: /admin?key=<SETUP_KEY>
  2. Select Connect Kroger.
  3. The Val creates a random, ten-minute OAuth state record.
  4. The browser redirects to Kroger.
  5. Sign in directly on Kroger's site.
  6. Approve cart access.
  7. Kroger redirects to /auth/kroger/callback with a temporary code.
  8. The callback verifies state.
  9. The Val exchanges the code for access and refresh tokens.
  10. Tokens are stored in Val-scoped blob storage.
  11. Future runs refresh access automatically without requiring weekly login.

The requested scope can add products to the cart. It does not grant checkout or payment permission.

Store and pickup configuration

Use the protected endpoint below to look up stores:

GET /api/stores?zip=<ZIP>&key=<SETUP_KEY>

Copy the desired locationId into KROGER_LOCATION_ID.

Product searches include:

filter.locationId=<KROGER_LOCATION_ID> filter.fulfillment=csp

The csp filter restricts candidates to products Kroger reports as eligible for curbside pickup at that location.

Important limitation: the public Cart API adds items but does not choose a fulfillment mode, reserve a pickup window, or place an order. The customer confirms pickup, selects a time, reviews substitutions and prices, and checks out in Kroger.

Product matching and preferences

A broad human description can return several valid products. For example, Lettuce returned iceberg, romaine, green leaf, shredded, hearts, and organic products.

Current policy:

  1. Look for a remembered mapping under match:<normalized text>.
  2. Search pickup-eligible products at the configured store and confirm the remembered UPC is still available.
  3. If the UPC is not among the first generic search results, retry using the exact UPC.
  4. Without a remembered mapping, automatically accept only a case-insensitive exact Kroger description match.
  5. Leave ambiguous or unavailable items unchecked and place them in review.
  6. Once a human chooses a candidate, save its UPC as the unconditional meaning of that exact checklist text.

Remembered mappings do not contain price conditions. The selected product is added whenever Kroger reports that exact UPC as pickup-eligible. This deliberately prioritizes a correct cart over a full cart.

State transition and duplicate protection

For every eligible Notion block:

unchecked → matched → attempting cart write → Kroger success → ledger marked added → Notion checked

The Notion checkbox is the commit step from the user's perspective.

Before a live cart request, the Val writes an attempting ledger record. After Kroger returns success, it writes added, then checks the Notion block.

If a network error occurs after the request begins, the ledger becomes uncertain. Automatic retry is blocked because Kroger's public cart surface cannot reliably read or remove items. A human must inspect the cart before resolving an uncertain item.

Safe testing procedure

1. Parser test

Keep LIVE_CART=false, then call:

GET /api/dry-run?key=<SETUP_KEY>

Expected output contains only unchecked, non-empty Kroger items and mutationAttempted: false.

GET /api/products?q=Lettuce&key=<SETUP_KEY>

Review UPC, description, size, price, and curbside fulfillment.

3. Full dry run

POST /api/run?live=false&key=<SETUP_KEY>

This resolves remembered/exact candidates but does not add to Kroger or update Notion.

4. Controlled integration test

  1. Put exactly one known unchecked item under the Kroger heading.
  2. Confirm its remembered UPC.
  3. Set LIVE_CART=true.
  4. Call POST /api/run?live=true&key=<SETUP_KEY>.
  5. Confirm the report contains one item in added and none in failed.
  6. Confirm the item appears in the Kroger cart.
  7. Rerun the parser diagnostic and confirm the item is no longer returned.
  8. Set LIVE_CART=false while reviewing results.

Schedule and operations

cron.ts calls runShoppingList({ live: true }), but actual writes still require LIVE_CART=true.

The interval uses:

0 * * * *

Val Town cron expressions use UTC and do not adjust for daylight-saving time. The interval therefore wakes at the top of each hour, and cron.ts uses Intl.DateTimeFormat with America/New_York to continue only when the local clock is Friday at 21:00. Every other hourly invocation exits without reading the grocery list or calling Kroger. This keeps execution at exactly 9:00 p.m. Eastern during both EDT and EST.

Use the protected admin page to inspect configuration state and the latest run report. Never publish a run report on an unprotected route because it may reveal private grocery-list contents.

Confirmed behavior

  • Reads a normal shared Notion document.
  • Recurses through child blocks and paginated responses.
  • Scopes processing to the Kroger heading.
  • Ignores checked and blank to-do blocks.
  • Searches the correct Kroger location.
  • Filters for curbside-pickup eligibility.
  • Completes Kroger OAuth with customer consent.
  • Refreshes customer tokens.
  • Remembers human-approved UPC mappings.
  • Adds an item to the authenticated Kroger cart.
  • Checks off the exact Notion block only after Kroger success.
  • Prevents ordinary duplicate processing.
  • Stops before checkout.

Known limitations

  • Product ambiguity currently requires manually writing a preference mapping.
  • No built-in review UI exists yet.
  • The public Kroger API cannot read or remove everything from the customer cart.
  • A lost response after a cart write creates an uncertain outcome that requires manual inspection.
  • Prices and availability can change between Friday morning and checkout.
  • Pickup time and substitutions are finalized in Kroger.
  • The page ID and expected section name are currently defined in source code.
  • Run notifications are not yet implemented.
  • The scheduled production workflow should remain disabled until preference review is mature.

Next steps

  1. Build an admin review screen showing ambiguous items and Kroger candidates.
  2. Let the user choose a product and save the mapping without editing blob storage manually.
  3. Add controls to forget or replace a remembered preference.
  4. Add a preflight page showing Friday's proposed cart before writes.
  5. Send a Friday completion report with added, review, failed, and uncertain items.
  6. Add a direct Kroger cart link to the report.
  7. Decide whether one ambiguous item should block the whole run or only itself.
  8. Add tests for headings, nesting, pagination, quantities, blank items, and malformed content.
  9. Make page ID, section name, schedule, store, and default quantity configurable.
  10. Add explicit recovery tooling for uncertain ledger entries.
  11. Add token-revocation and disconnect controls.
  12. Add structured logs that redact tokens, setup keys, and OAuth codes.
  13. Re-enable LIVE_CART only when the review and notification workflow is ready.

Security notes

  • Never commit environment-variable values.
  • Never place Notion or Kroger tokens in README files, logs, query strings, or screenshots.
  • Treat SETUP_KEY as a password because the HTTP endpoint must be publicly reachable for OAuth.
  • Rotate the setup key if an administrative URL is exposed.
  • Keep OAuth state validation enabled.
  • Request only product.compact and cart.basic:write.
  • Do not add checkout automation.
  • Keep run reports behind the setup-key check.
  • Review uncertain outcomes manually rather than retrying blindly.

Handoff summary

The infrastructure and first real integration are working. The remaining work is primarily product judgment and operational UX, not connectivity. Notion is acting as the collaborative human interface; Val Town supplies the hidden schema, parser, state machine, matching memory, OAuth lifecycle, idempotency rules, and side effects; Kroger remains the final review and checkout surface.