← Forky AI Blog

Tracking shelf life with AI — why the 'expires in 3 days' badge isn't lying anymore

Most fridge-tracker apps store a static integer for expiry. Open the app a week later, the badge still says 3 days. Forky AI's expires_at refactor — and why timezone-aware countdowns matter — explained.

By Elie de Rougemont, Founder of Forky AI · 7 min read · published

Storing a relative duration is the wrong default. The moment the document hits disk, the integer is stale, and there's no signal that ticks it down each day. The fix is to store the absolute date. Obvious in hindsight; not obvious before someone reports the bug.

The frozen-integer bug

Forky AI's first version of the fridge feature stored each item with a days_until_expiry: 5 field. The badge in the UI read this integer and rendered 5d. Tomorrow it would still read 5d. Next week it would still read 5d. There was no cron job that decremented the integer, and the user's device clock couldn't be the authority because it might be wrong — or, more often, the device wasn't running at midnight to tick the value down.

We had stored a duration. We should have stored a moment.

Store the date, compute the days

The right shape is:

{
  "name": "chicken breast",
  "purchased_at": "2026-05-12",
  "expires_at":   "2026-05-15"   <-- absolute date
}

And on every read, the server recomputes days_until_expiry as expires_at - today. Two helper functions handle everything:

def _compute_expires_at(expires_at, days_until_expiry, tz_name):
    """Canonicalise to an absolute date. Client may send either."""
    if expires_at: return expires_at
    if days_until_expiry is None: return None
    return (_user_today(tz_name) + timedelta(days=int(days_until_expiry))).isoformat()

def _days_remaining(expires_at, tz_name):
    """Live countdown against the user's local today."""
    if not expires_at: return None
    exp = datetime.fromisoformat(str(expires_at).split("T")[0]).date()
    return (exp - _user_today(tz_name)).days

The hydrate path on every list endpoint overwrites the stored days_until_expiry (a stale value, possibly) with the live one computed from the absolute date. The client doesn't need to know about the absolute date if it doesn't want to — the integer it reads is always fresh.

Why the timezone bug matters more than you think

Once you have an absolute date, the next question is: whose "today" is it? Our first version anchored on UTC midnight. A user in Paris saving an item at 23:30 local time (which is 22:30 UTC in summer, 21:30 in winter) was getting the expiry one calendar day later than expected, because "today" in UTC was already the next day for them.

Worse, the "expires today / EXPIRED" thresholds in the coach prompt flipped a day early or late depending on the user's timezone. An Apple reviewer in California would have seen "expires today" when the user genuinely had another full day left.

The fix is to thread the user's IANA timezone (e.g. "Europe/Paris") into every date computation:

def _user_today(tz_name):
    if tz_name:
        try: return datetime.now(ZoneInfo(tz_name)).date()
        except (ZoneInfoNotFoundError, ValueError): pass
    return datetime.now(timezone.utc).date()

On the frontend, the AuthProvider sends the device's IANA TZ to the backend on first launch:

try {
  const deviceTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
  if (deviceTz && user.timezone !== deviceTz) {
    void AuthAPI.updateProfile({ timezone: deviceTz });
  }
} catch {
  // Intl unavailable on very old runtimes — silently skip.
}

Once stored on the user doc, every fridge read hydrates against the user's local "today", not the server's UTC "today". Same code, no special cases.

The legacy migration

We had a few hundred fridge items written with the old days_until_expiry-only shape. We didn't run a batch migration; we run it lazily, per user, on first /fridge call after the refactor:

async def _backfill_fridge_expires_at(user_id):
    cursor = db.fridge.find({
        "user_id": user_id,
        "expires_at": {"$exists": False},
        "days_until_expiry": {"$ne": None},
    })
    async for row in cursor:
        anchor = parse(row["created_at"] or row["last_seen_at"]).date()
        target = anchor + timedelta(days=int(row["days_until_expiry"]))
        await db.fridge.update_one(
            {"id": row["id"], "user_id": user_id},
            {"$set": {"expires_at": target.isoformat()}},
        )

Idempotent — only touches rows missing expires_at. A user who never opens their fridge tab is fine; the migration runs the moment they do.

The validation layer that should have shipped on day one

While we were in there, we added Pydantic validation on the date inputs. The original endpoint accepted expires_at: "not-a-date" without complaint — the string would land in Mongo and _days_remaining would silently return None forever. Now:

def _validate_iso_date(v):
    if v is None or v == "": return None
    try:
        d = datetime.fromisoformat(str(v).split("T")[0]).date()
    except (TypeError, ValueError):
        raise ValueError("must be ISO date (YYYY-MM-DD)")
    today = datetime.now(timezone.utc).date()
    if d.year < today.year - 5 or d.year > today.year + 10:
        raise ValueError("date is outside the reasonable range")
    return d.isoformat()

Plus bounded integer ranges on days_until_expiry (ge=-3650, le=3650) so an adversarial client can't pass 10⁹ and crash the server on a date + timedelta overflow. Belt and braces.

The lesson, cleanly stated

"Any countdown field must be stored as an absolute moment. The relative delta is stale the moment the document goes to disk. A helper that converts at write time + a helper that derives at read time = one source of truth, no cron job needed."

We added this to our internal lessons log the day the bug shipped. Two months later we hit the same shape of problem with a recipe-photo cache that was poisoning every user with one hallucinated image. Different feature, same anti-pattern: storing a derived value without a source-of-truth backing it. We caught it faster the second time.