Case study · macOS app

CPAP Tracker

CPAP equipment has to be replaced on a schedule — filters monthly, cushions every few weeks, tubing and headgear a few times a year. Miss it and therapy quietly gets worse. This is a menu-bar app that keeps the schedule, with all of the data on your own machine and none of it anywhere else.

  •   offline only
  • SwiftUI · macOS 14+
  • shipped: 2026-08
The Supply Board: a summary strip showing the next date supplies are due, how many items fall on it, and how many are overdue, above a months-long timeline; below that, one card per batch of components sharing a due date, each with a single mark-all-replaced action.
The Supply Board. Components that come due together are grouped into one batch with one primary action; per-row replacement stays deliberately quiet. Light and dark ship together — this figure follows your system setting.

Dates are the hard part

"Every month" sounds trivial until the month is February. If you replace a filter on 31 January, the next one is due 28 February — and the one after that should be 31 March, not 28 March. Naïve date arithmetic loses that day permanently, and each short month walks the whole schedule earlier.

Every component keeps an anchor day, so clamping into a short month is temporary rather than a permanent drift. All arithmetic runs on a civil-date type — year, month, day, no time zone — so "overdue" flips at local midnight and can't shift because somebody's clock is UTC-relative. It's the kind of correctness that is invisible when it works and maddening when it doesn't, which is why the scheduling rules are the most heavily tested part of the codebase.

Being late is the normal case

A tracker that assumes you replace things on time is a tracker that is wrong most of the time. When you mark something replaced after its due date, the app asks what you meant, with real dates on every option: start the next interval from today, keep the original cadence, or line this component back up with the batch it belongs to. You can also enter the actual date if you replaced it last Tuesday and are only now saying so.

Whichever you pick is remembered as that component's default, and recorded on the replacement event itself — so a year later the history can explain why a date moved, instead of presenting an unexplained jump.

Not losing data

  • Writes are atomic, so an interrupted save can't leave a half-written file.
  • The store is versioned. An older file migrates non-destructively; a file from a newer version refuses to open rather than risk silently discarding fields it doesn't understand.
  • Deleting a component keeps its replacement history. Retiring is the ordinary way to hide something you no longer use.
  • Components with genuinely unknown history are stored as unknown. The app never invents a replacement date to make the UI look complete.

Decisions worth naming

The due date is stored, not computed
It would be tidier to derive the next due date from the last replacement plus the interval. But then any correction to history silently rewrites the future. The due date is its own stored fact; when it disagrees with the arithmetic, the app points that out and lets you decide, rather than quietly fixing itself behind your back.
Batches, because that's how supplies arrive
Components that come due together are grouped, with a single "mark all replaced" as the primary action and per-row replacement kept deliberately quiet. When items drift apart, the app suggests the alignment date that moves the fewest of them.
Urgency in weight, not colour
The one batch that needs action wears a solid field; everything calm stays a line drawing. Nothing depends on a red-versus-green distinction to be legible.
Logic split from the interface
Models, the civil-date type, the scheduler, and the store live in a core module that imports no UI at all, which is what makes the scheduling rules exhaustively testable without launching an app.

Stack

Swift · SwiftUI · Codable · Swift Package Manager · XCTest · SMAppService (launch at login) · UserNotifications

Runs as a menu-bar extra with optional local notifications, configurable lead times, and rate-limited overdue reminders. Notification permission is requested at the first meaningful interaction rather than on a cold start, and a denial degrades to an in-app banner instead of a dead feature.