Skip to work
Back home
Product / UX / Engineering case study

Frostly

Your smart freezer companion

At a glance

A progressive web app that remembers what is in your freezer, tracks expiry dates using USDA science, and suggests meals from whatever is expiring soonest. No server, no account, no internet connection. One week from idea to shipped.

Frostly product banner: the logo and tagline your freezer, finally organised, three phone screens showing meal ideas, the freezer inventory, and the add item sheet, plus food waste statistics
Role
Solo: product thinking, idea filtering, design system, interface design, frontend engineering, release
Timeline
April 2026. One week from idea to shipped, installable progressive web app
Stack
  • Vanilla HTML
  • CSS
  • JavaScript
  • PWA
  • localStorage
  • Open Food Facts API
  • Vercel
  • GitHub
01

The question that drove everything

The brief was deliberately open: build something people will actually use. The first filter I applied was unusually specific.

What can an app do that a chatbot cannot?

A chatbot resets every conversation. An app remembers.

That single insight killed four ideas and started one good one. Eight product ideas were sketched and each was run through the same filter. Most failed immediately.

  • Receipt scanner app

    Killed by privacy concerns. People will not photograph their receipts for a new app.

    Rejected
  • Niche job board

    A chatbot can replicate the whole value in one prompt. No reason to come back.

    Rejected
  • Flashcard generator

    No persistent value. General assistants already do this natively.

    Rejected
  • Landing page teardown service

    A one-shot utility. Useful once, then forgotten.

    Rejected
  • Freezer tracker with expiry and meal ideas

    Needs persistent memory, structured UI, and time-aware logic. A chatbot cannot hold this alone.

    Selected
02

Why freezer tracking

The problem is universal and well documented. People freeze food, forget about it, and throw it away. The existing solutions - a notes app, a spreadsheet, pen and paper - all demand manual effort and give nothing back.

A tracker that remembers, calculates, and suggests is exactly the kind of thing an app does better than a conversation.

30%
Of frozen food ends up wasted
73%
Of wasted UK food was still edible
£470
Average annual household food waste cost in the UK
£17bn
Total UK edible food wasted in 2022, WRAP

Source: WRAP, Household Food and Drink Waste in the UK, 2022.

03

Every decision had a reason

The product had one constraint above all others: ship in days, not months, with nothing spent on infrastructure. Every technical and product decision flows from that.

  • Platform

    PWA

    over iOS and Android native

    £0 to launch against a yearly developer fee, no store review, installable on both platforms, shippable in hours. For a solo MVP it was the only rational choice.

  • Tech stack

    Vanilla HTML, CSS, JS

    over React or Next.js

    No build step, no dependencies, no bundle weight. The whole app is three files, so it loads instantly on mobile and stays easy to debug and deploy.

  • Data and privacy

    localStorage only

    over A hosted backend

    “Your data never leaves your phone” is a feature, not a limitation. No GDPR exposure, no auth system, no server cost, and it works fully offline.

  • Recipe intelligence

    Local scoring engine

    over An AI API call

    API keys in client-side code are a security risk, and every call costs money and needs a connection. A local keyword-scoring engine over 80+ recipes is faster, free, and consistent.

  • Expiry data

    USDA FoodKeeper

    over Category defaults

    Category defaults are wrong for most items. Bacon frozen lasts 30 days, chicken breast 270. Trust is earned through accuracy, item by item.

  • Growth

    Open Food Facts

    over A paid barcode database

    Community maintained, free, global coverage, and it returns category tags that auto-fill the add-item form. No licensing cost, no API key.

04

The build roadmap

  • Phase 0 - now

    Core loop working, all bugs fixed

    Add item, see expiry, get a meal suggestion. Every control works on Android and iOS. Light and dark mode follow the system. USDA expiry data and 80+ local recipes.

  • Phase 1 - week 2

    Retention hooks

    Onboarding for first-time users, push notifications tied to what is expiring, and an add-to-home-screen prompt triggered after the first item is added.

  • Phase 2 - week 3 to 4

    Monetisation

    Free tier at 15 items. Barcode scan unlocked with a one-time payment. The paywall is designed to feel helpful, not hostile: you only meet it after the product has proven itself.

  • Phase 3 - month 2

    Growth surface

    Landing page with how it works and a privacy policy, a browser extension as an entry point, and a launch into the communities that already care about food waste.

05

Looking through a freezer door at night

The design brief was one sentence, written before a single line of CSS.

Frosted glass, deep navy, ice blue glow. Dark first. Think looking through a freezer door at night.

Everything followed from it: colour, typography, the glass surfaces, the glow. The aesthetic had to feel cold, premium, and slightly magical while staying approachable enough for a daily kitchen habit.

Colour palette
  • Ice blue#4FC3F7
  • Ice dark#0288D1
  • Urgent#FF7043
  • Warning#FFB300
  • Good#66BB6A
  • Background#0a1628
  • Surface#0f2035

Coral is reserved exclusively for urgency, amber for a warning, green for safe. The system is intuitive enough that people read status at a glance without reading a label.

  • Display and headings

    Phudu 900 and 700

    Compressed at large sizes, which gives the product the editorial confidence of a premium brand.

  • Body and UI

    DM Sans 300

    Warm and readable. At light weight it softens the display face, so the pairing reads smart and approachable rather than cold and corporate.

  • Code and data

    JetBrains Mono

    Reserved for expiry dates, quantities, and barcodes, where alignment matters more than personality.

06

Light and dark as a system-level decision

The first build was dark only, with a hardcoded colour scheme. On Android in light mode, Chrome overrode the background colours with a flat system blue and the frosted glass aesthetic broke completely.

The fix needed more than a media query. Every colour token had to be redesigned for light mode from scratch, not inverted but rethought. Ice blue reads as a highlight on dark; on light it has to drop to the darker shade to hold contrast. The modal surface moves from deep navy to a pale sky. The atmospheric gradients shift from subtle to more saturated so they still register against a bright canvas.

theme-tokens.css - light and dark switching
/* Dark theme - the default, what we designed first */
:root {
  --bg:    #0a1628;  /* deep navy */
  --ice:   #4FC3F7;  /* ice blue highlight */
  --tx:    #e8f4f8;  /* near-white text */
  --modal: #0f2035;  /* slightly lighter surface */
}

/* Light theme - NOT just inverted, redesigned */
@media (prefers-color-scheme: light) {
  :root {
    --bg:    #d8eef9;  /* pale sky, keeps the cold feel */
    --ice:   #0288D1;  /* darker blue for contrast on light */
    --tx:    #0d1f2d;  /* near-black text */
    --modal: #eaf5fd;  /* light frost surface */
  }
}
07

Three bugs, three different root causes

The app shipped with working desktop functionality and three critical mobile failures. Diagnosing each one meant understanding how different browsers handle CSS rendering, event propagation, and layout context.

Bug 1

No buttons work on Android or iOS

Symptom

Every tap on mobile did nothing. Buttons, tabs, the add icon, all silent. Desktop worked perfectly.

Cause

The desktop layout used display: contents on the phone frame wrapper so it would collapse in mobile view. That property has a documented Android Chrome bug: it breaks event propagation, so every tap was consumed by an invisible wrapper.

Fix

A nuclear reset on the wrapper divs in mobile, forcing them into true pass-throughs with zero influence on layout or events.

mobile-fix.css - nuclear wrapper reset
@media (max-width: 599px) {
  /* .dl and .ds are the desktop layout wrappers.
     On mobile they must be invisible: zero layout influence,
     zero event interception, zero overflow clipping. */
  .dl, .ds {
    all: initial !important;   /* wipes every CSS property */
    display: block !important;
    width: 100% !important;
    height: 100% !important;
  }

  .di { display: none !important; }  /* desktop info panel */
}
Bug 2

Add to Freezer does nothing

Symptom

Every form field worked. The submit button was visible and clickable. Pressing it produced no item and no feedback.

Cause

Closed modals were hidden with opacity: 0 and pointer-events: none. An invisible modal still sitting at z-index 100 intercepted the click in some Chrome versions, so the tap never reached the submit handler.

Fix

Swapped the opacity trick for display: none and display: flex. Elements that are absent from the rendering tree cannot intercept anything, ever.

modal-system.css - display none instead of the opacity trick
/* OLD - opacity:0 modals still exist and can intercept events */
.modal-overlay { opacity: 0; pointer-events: none; }
.modal-overlay.open { opacity: 1; pointer-events: all; }

/* NEW - removed from the rendering tree entirely */
.overlay {
  display: none;      /* completely absent, cannot block anything */
  position: fixed;
  inset: 0;
  align-items: flex-end;
}
.overlay.open { display: flex; }  /* animate via the sheet child */
Bug 3

A background layer blocks touch on some Android builds

Symptom

Intermittent touch failures on Android. Some taps landed, others did not, with no consistent pattern.

Cause

The atmospheric glow used a body::before pseudo-element with position: fixed. Fixed-position pseudo-elements can still take part in hit testing on some Android Chrome builds even with pointer-events: none.

Fix

Replaced the pseudo-element with a real div at z-index -1 and an explicit pointer-events: none. Real elements behave predictably.

08

The recipe matching engine

The first version of “What can I cook?” called a hosted AI model, which meant an API key embedded in client-side JavaScript and a dependency on connectivity. One was a security problem, the other broke the offline promise.

The replacement is a local scoring engine, and for this specific job it is actually more capable. Items expiring within fourteen days earn thirty extra points, so they always rise to the top of suggestions. Instant, offline, free forever.

recipe-engine.js - scoring logic
function matchRecipes() {
  // Enrich each freezer item with an urgency score:
  // expiring soon = higher weight in recipe matching
  const pool = items.map(i => ({
    ...i,
    score: urg(daysLeft(i)) === 'urgent' ? 30   // <= 14 days
         : urg(daysLeft(i)) === 'soon'   ? 12   // <= 45 days
         : 2                                    // good
  }));

  return RECIPES.map(recipe => {
    let pts = 0;
    const used = new Map();

    // Keyword match - highest weight (12 + urgency bonus)
    recipe.keywords.forEach(kw =>
      pool.forEach(item => {
        if (item.name.toLowerCase().includes(kw) && !used.has(item.id)) {
          pts += 12 + item.score;
          used.set(item.id, item);
        }
      })
    );

    // Category match - lower weight (2 + fraction of urgency)
    recipe.categories.forEach(cat =>
      pool.forEach(item => {
        if (item.category === cat && !used.has(item.id)) {
          pts += 2 + item.score * 0.15;
          used.set(item.id, item);
        }
      })
    );

    return { ...recipe, pts, used: [...used.values()] };
  })
  .filter(r => r.pts > 8)          // minimum threshold
  .sort((a, b) => b.pts - a.pts)   // highest score first
  .slice(0, 8);                    // top 8 results
}

The engine carries 80+ recipes, each with a keyword list and a category fallback, so a sparse freezer still returns something cookable.

09

Replacing guesswork with USDA science

The original expiry system was a flat category lookup: all meat, 90 days. It was wrong for most items. Frozen bacon lasts 30 days. Beef steak lasts 365. Broccoli lasts twelve months. Inaccurate data erodes trust the first time someone notices.

The replacement is a two-tier lookup built on USDA FoodKeeper data: an item-specific keyword match first, a category fallback only when nothing matches.

expiry.js - two-tier lookup, keyword first, category fallback
/* Tier 1: item-specific data from USDA FoodKeeper (days frozen) */
const EXP_KW = {
  'chicken breast': 270, 'bacon': 30, 'beef steak': 365,
  'salmon': 90, 'ground beef': 120, 'broccoli': 365,
  'ice cream': 60, 'butter': 270,
  // ... 80+ more entries
};

/* Tier 2: category fallback when no keyword matches */
const EXP_CAT = {
  meat: 180, fish: 180, vegetable: 365,
  dairy: 90, prepared: 90, other: 180
};

function getExpiry(item) {
  const name = item.name.toLowerCase();
  for (const [kw, days] of Object.entries(EXP_KW)) {
    if (name.includes(kw)) return days;  // specific match wins
  }
  return EXP_CAT[item.category] || 180;  // category fallback
}
10

What worked and what I would do differently

  • The chatbot filter

    Testing every idea against what needs persistent state, time awareness, and structured UI removed seven of eight ideas quickly. The survivor was defensible by construction.

  • Constraints as selling points

    “No backend” sounds like a limitation. “Your data never leaves your phone” is a feature. The framing mattered as much as the decision.

  • A one-sentence design brief

    “Looking through a freezer door at night” answered colour, type, glass, and glow before a single line of CSS was written.

  • Authoritative data over estimates

    Someone who knows bacon is genuinely 30 days will trust everything else the app tells them. Accuracy is a trust feature.

Shipping fast is a design skill. Shipping fast twice is a systems skill.

  • 01

    Test on Android from day one. Both blocking bugs were mobile-only and would have surfaced in hours, not after shipping.

  • 02

    Design the dark and light themes together. Building dark-only meant redesigning every colour token twice.

  • 03

    Never put an API key in client code. The pivot to local recipes was the right call, it just should have been the first call.

  • 04

    Validate the paywall threshold with real data. Fifteen free items was a guess where typical freezer size was knowable.