Skip to content
All posts

Building Stocky: predicting what your kitchen runs out of — on the device

How Stocky turns a barcode scan and a bit of usage history into a shopping list that shows up before you run out — with all the math, and all the data, staying on your phone.

MFKAPPS 4 min read

Most pantry apps are glorified checklists. You add milk, you tick milk off, you add milk again. The app never learns anything. Stocky started from a different question: what if the app noticed how fast you actually go through things, and rebuilt your shopping list before you ran dry?

That one idea — a small, honest prediction — shaped most of the engineering. Here’s how it works, and why it all runs locally.

A scan is a timestamped event, not a row

The naive model is a table of items with a quantity column you increment and decrement. It’s simple, and it forgets everything the moment you edit it.

Stocky stores events instead. Every scan, every “used some”, every restock is an append-only record:

@Entity(tableName = "pantry_events")
data class PantryEvent(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    val productId: String,     // barcode / Open Food Facts id
    val type: EventType,       // ADDED, CONSUMED, DISCARDED
    val amount: Double,        // in the product's base unit (ml, g, pcs)
    val at: Long,              // epoch millis
)

The current quantity is just a fold over events. That sounds wasteful, but it’s the whole trick: because I keep the history, I can ask questions a running total can’t answer — like “how many millilitres of this does the household get through per day?”

The prediction is deliberately boring

There is no neural network here. A household’s consumption of olive oil is not a problem that needs a transformer — it needs an average that doesn’t overreact to one big weekend.

For each product I compute a consumption rate over a trailing window, weighting recent events a little more:

fun dailyRate(events: List<PantryEvent>, now: Long): Double {
    val consumed = events.filter { it.type == EventType.CONSUMED }
    if (consumed.size < 2) return 0.0

    val windowStart = now - 30.days.inWholeMilliseconds
    val recent = consumed.filter { it.at >= windowStart }
    if (recent.isEmpty()) return 0.0

    val total = recent.sumOf { it.amount }
    val spanDays = ((now - recent.first().at) / 1.days.inWholeMilliseconds)
        .coerceAtLeast(1)
    return total / spanDays
}

daysLeft = currentQuantity / dailyRate. When that dips under a threshold, the item quietly lands on your shopping list. No rate yet? No prediction — Stocky says nothing rather than guessing. A calm app would rather be silent than wrong.

Partial use, because kitchens are analog

The feature that took the most fiddling wasn’t the maths — it was letting you say “I drank about a third of this.” People don’t decrement in integers. A carton of juice gets used 200 ml at a time, and if the app forces you to either keep it “full” or mark it “gone”, the prediction is garbage.

So the consume sheet speaks in real units and remembers the product’s package size, so “a glass” or “half” maps to something the rate function can use. It’s a tiny bit of UI that quietly makes the whole model trustworthy.

Why none of this leaves the phone

Every number above is derived from your events, and your events describe your life in uncomfortable detail — when you eat, how much, how often you shop. That’s exactly the data I don’t want to hold.

So there’s no account and no server that stores your pantry. Everything lives in a local Room database. Product lookups hit Open Food Facts (and a community set of Turkish products), but that request only contains a barcode — never your history. The prediction runs on-device because that’s where the data already is, and where it should stay.

Local-first isn’t a marketing checkbox here. It’s the reason the honest version of this app was even possible: I can’t lose, sell, or leak a database I never collected.

What I’d do differently

If I rebuilt the rate model today I’d add a gentle seasonal term — some things spike in summer — and a per-category prior so a brand-new item borrows a sensible starting rate instead of staying silent for a week. Both are small. Neither needs the cloud.

Stocky is live on Google Play. If you try it, the shopping list that shows up on its own is the part I’m proudest of — and the part you’ll hopefully forget is even clever.