Half-Life: Pharmacokinetics on a Watch Face
One decay model, six surfaces, and no cached copies of the answer.
Half-Life came out of a small daily annoyance: I dose caffeine on instinct, not on data. I knew it had a half-life, I knew it stacked, and I had no real sense of how much was still working on me at 9pm. So I built the thing I wanted. It answers one question at a glance, on the phone and on the watch — how wired am I right now — using the same pharmacokinetics a clinician would.
The model is about ten lines
Caffeine clears on first-order exponential decay, roughly five hours in a healthy adult, with plenty of individual variance. Each dose becomes a decay curve anchored at its intake time, and your concentration at any instant is the superposition of every curve still in flight. That's the whole model. Everything after it is keeping six different surfaces from disagreeing about the answer.
// One source of truth. Concentration as a pure function of time.
func level(at t: Date, doses: [Dose]) -> Double {
doses.reduce(0) { sum, dose in
guard t >= dose.time else { return sum }
let hours = t.timeIntervalSince(dose.time) / 3600
return sum + dose.mg * pow(0.5, hours / dose.halfLifeHours)
}
}One engine, many renderers
The decision that made the rest easy was refusing to store current caffeine anywhere. The engine exposes concentration as a pure function of time, and nothing caches a level. That happens to line up exactly with how WidgetKit works: a widget doesn't tick, it asks your provider for a timeline of future entries and renders them on its own schedule so the system can update it cheaply. Since my level is already a function of time, building that timeline is just sampling the function wherever WidgetKit asks.
- Home screen widgets in three sizes — each a sampled slice of the same curve, plus a small sketch.
- Lock screen widgets — one number for the second you glance at the phone.
- Watch complications — the same function sampled for a watch face, with no phone in the loop.
- The full app — interactive graph, intake log, and planning against a target level.
Adding a surface never touches the model. It's a rendering problem: a new way to sample a function that already exists and is already correct.
Planning backward from bedtime
The feature I actually use runs the model in reverse. You set a ceiling you want to be under by bedtime, and the app solves for the latest time and dose that still lands you under it, given everything already in your system. Same superposition, inverted. Instead of what is my level now, it answers what input keeps me under a target at a future time. That inversion is the difference between a chart that's interesting and a tool that changes what I do at 4pm. Most afternoons the answer is no more coffee, which is not what I built it hoping to hear.
Six surfaces, one function, no cache. Everything else was rendering.
On device, or not at all
Caffeine intake is health data, and I wasn't going to build a product that ships it to a server. The intake log, the decay engine, every widget timeline — it all computes and stays on the phone. No account, no backend, nothing recording when you drink coffee. That's a privacy stance first and a much smaller engineering surface second. There's no sync layer to get wrong, because there's nothing to sync.
← Back to all posts