DEV Community

Cover image for What your receipts know: price history, "last paid", trends, savings
Ibukun Demehin
Ibukun Demehin

Posted on

What your receipts know: price history, "last paid", trends, savings

Part 15 established an uncomfortable fact: there is no free, legitimate API for supermarket prices. Scraping is off the table; the paid aggregators price by the request. For a solo side project, that looked like a dead end for anything "deals"-shaped.

But something changed while I wasn't looking. After weeks of scanning receipts, the user now owns a price dataset nobody sells: every price they actually paid, at the shops they actually use. This post builds three screens on it — per-product price history, spend trends, and a savings screen — and spends most of its words on the part that mattered: the gates that stop those screens from lying.

TL;DR — Your users' own history beats no data and beats scraped data: it's free, legal, and true for them. The design work is honesty: freshness windows before a cheaper past price counts as actionable, per-store totals only, a two-distinct-prices minimum before anything claims a saving, foreign receipts excluded and counted on screen — and every one of those gates lives in the selector, not the UI, so no screen can accidentally bypass them. Also: chart libraries mutate their own copy of your data; read tooltips from your series, not theirs.

(Part 16 of Building CannyCart, a voice-first shopping app I'm building in public. Self-contained — no earlier context needed.)

The dataset you accidentally built

Every scanned receipt stores a frozen lineItems snapshot and a purchaseDate — the timezone-proof, date-only field from Part 14 that records when you actually shopped, not when you scanned. Nothing in this post adds a model or a Lambda. All three screens are pure selectors over receipts already in the cache, which also means all three work offline. The interesting engineering is entirely in what the selectors refuse to claim.

Start small: "you last paid £2.40"

The gentlest feature first: when you price an item by hand, the amount sheet now suggests what you last paid. The lookup matches by barcode when the item has one — exact, thanks to Part 15 — and falls back to normalised name otherwise, which also covers everything added by voice or typing. A tiny feature, but it set the matching precedent the rest of the post keeps renegotiating.

Price history: the chart that zooms, honestly

Tap any product and you get its unit-price history as a line chart. The interesting decision is the y-axis: it zooms to the range of prices paid rather than starting at zero. Against a zero baseline, a price that moved from £2.20 to £2.40 is a flat line pinned to the top rule — the chart would hide exactly the thing it exists to show.

Zoomed axes are how charts famously lie, so this one buys its legitimacy explicitly: no y-axis numbers are drawn at all, and every point is tappable, showing the real price in a readout line. You can't misread an axis that isn't there; you can only tap and see the truth.

Then the library trap. react-native-gifted-charts implements the zoom via yAxisOffset — by subtracting the offset from its own copy of your data. Wire the tap-readout to the chart's item and it happily reports £0.20 for a £2.40 price. The fix is in a comment in the real component:

// Tap → readout line above the chart (index into OUR series: the
// chart's copy has the zoom offset subtracted, so its values are
// not the prices paid). No floating tooltip, nothing to clip.
focusEnabled
showStripOnFocus
Enter fullscreen mode Exit fullscreen mode

The chart renders geometry; your data stays the source of truth. Anything a user reads as a number comes from your series, by index.

Spend trends: deltas need equal windows

/expenses/trends buckets receipts by purchaseDate over a 3-, 6- or 12-month range: a headline total, monthly bars, and ranked lists of stores, aisles, and most-bought products. Two rules keep the numbers honest:

  • The delta compares equal windows. "Down 12%" means this 3 months vs the previous 3 months — the prior window is loaded solely to feed that comparison.
  • Foreign receipts are excluded — and counted, on screen. The app supports many currencies but converts nothing (a later post). A euro receipt in a GBP total would be nonsense; silently dropping it would be worse. So the total excludes it and the screen says so: "2 receipts in other currencies not included." Never let a number quietly mean less than it appears to.

The Trends screen: monthly spend bars with the delta vs the previous window, and ranked stores and aisles below

Charts on a phone: practical scars

  • The library is pure JS — chosen partly because Part 14 taught me what native modules cost (a full dev-client rebuild). Charts arrived with zero build-system involvement.
  • Size charts from useWindowDimensions, not constants. The library's fixed default geometry overflows the card at exactly 12 bars — which a 12-month range produces.
  • The compact money format (£1.2k) exists solely for chart labels, which live in boxes a few characters wide. Everywhere else gets full amounts.
  • Thousands separators are grouped by hand, not Intl — Hermes ships a real Intl on some platforms and a stub on others, and a silently ungrouped total is exactly the kind of bug you only meet in production screenshots.

Savings: coral's screen, honestly earned

/expenses/savings is the one screen where coral — the design system's money accent, hoarded for fifteen posts — is the color. Which is why its numbers had to be the most defensible in the app. With no price API, a "deal" is redefined as something provable from the user's own receipts, and the module docstring is the spec:

/**
 * Deals and savings, derived entirely from the user's own receipts — there is no
 * legitimate free UK supermarket price API, so a "deal" here means *you have paid
 * less for this before, at this shop, recently*.
 *
 * Three gates keep that claim honest, and none of them are optional:
 *
 * 1. FRESH_DAYS — a cheaper price from nine months ago is not an offer. Stale
 *    ones are still listed, with their date, but never counted in a headline.
 * 2. Per-store totals only. Summing savings across five shops assumes a trip to
 *    all five; the headline is always "£x at one named shop".
 * 3. Two purchases at two distinct prices before an item can claim a saving —
 *    something bought once is merely its own only price.
 */
export const FRESH_DAYS = 90;
/** Under 5p is noise, not a deal. */
const MIN_SAVE = 0.05;
Enter fullscreen mode Exit fullscreen mode

Gate 3 is the one that bites early users: the thing usually missing isn't a second shop, it's a second price — and that's fine, because one shop's own price moving over time is a real, actionable difference. (And the bonus micro-gate: savings under 5p don't count. Noise isn't a deal.)

The structural decision: all three gates live in the selector, not the UI. No screen decides what counts as a saving; screens only render what useSavings already vouches for. A future widget, Home card, or notification reusing the selector inherits the honesty for free — the same pattern as Part 13's budget selector, now carrying real editorial weight.

The Savings screen: per-store deals from your own receipts — the one screen where coral is the accent

The keying asymmetry

Here's the subtle one. The savings selector keys products by normalised name. The price-history lookup keys barcode-first. Same dataset, opposite choices — both deliberate.

Only receipts logged after barcode scanning shipped carry barcodes. Key savings by barcode and every product splits into a pre-barcode era and a post-barcode era — half the history on each side, neither half clearing the two-purchases gate, savings understated across the board. Name-keying heals the split. But when you tap one specific product to see its history, exactness wins — barcode when present, name as fallback.

The lesson generalizes: key by the question, not by the schema. "How has what I pay for milk moved?" and "show me this product" are different questions and deserve different joins.

Risers, demoted on purpose

The screen also lists price rises — with two deliberate suppressions. Rises compare latest vs the time before, never vs cheapest-ever: measure against the all-time low and one long-gone promotion marks an item "rising" forever. And risers live in a collapsed footer, below the savings: a rise is the one thing on this screen you can't act on. Information you can't act on doesn't get to shout.

What I took away

  • Your users' own history is a dataset nobody sells — free, legal, and true for them in a way no scraped price ever is.
  • Honesty gates belong in selectors. Freshness windows, minimum evidence, per-store scoping — enforce them where every consumer inherits them.
  • Derived claims need expiry dates. A cheaper price from March isn't an offer in August; show it, dated, but never count it.
  • Chart libraries lie about their own data. Zoomed axes mutate copies; read every user-visible number from your series.
  • Key by the question, not the schema — and expect the right key to differ per screen.
  • Demote the unactionable. Collapsed footers are a design feature, not a failure state.

Next up

Every number in this post wore a £ — and for the app's first weeks that symbol was hardcoded in a template string. Part 17 is the audit that fixed it: currency as a first-class user preference, from first-run country detection to the module-mirror trick that keeps pure formatters working outside React.

What does your product derive from data users already gave you — and what gates keep those derived numbers honest? I'd genuinely like to steal your rules.

Top comments (0)