-
-
The sky is computed, not illustrated: real solar position from your coordinates, with no GPS and no network call.
-
Logging a night is one sheet, with your habitual schedule pre-filled — confirm and you're done.
-
Sleep debt, circadian phase and alertness, each from a documented, unit-tested algorithm rather than a guess.
-
Offline-first by architecture: every read is local, and nothing about your sleep ever leaves the device.
-
A circadian coach that tells you when to get light, when to stop caffeine, and when to wind down.
Inspiration
I didn't need another sleep tracker. I had several, and between them they told me — in increasingly beautiful charts — that I slept badly. Not one of them ever told me what to do about it on Tuesday.
The science isn't the bottleneck. Morning light advances your body clock and evening light delays it. Caffeine at 4pm is still measurably in you at midnight. Your alertness through the day is the sum of two well-characterised processes. All of this is decades old, and almost none of it reaches the person holding the phone. The gap isn't measurement. It's prescription.
So Circa skips tracking entirely — no wearable, no account, no network — and spends its effort on the timing instead.
What it does
Circa computes where the sun actually is above you, estimates where your body clock currently sits, and turns both into a short daily protocol you can follow:
- Today — your light window, caffeine cutoff, wind-down time and target bedtime, over a sky that renders the real sun at your coordinates right now.
- Sleep — five-second logging and a seven-day sleep-debt ledger with an honest confidence level, because three nights of data is not five.
- Plan — your melatonin onset, temperature minimum and chronotype, with a protocol chosen to match them.
- Jet Lag Planner — a day-by-day pre-adaptation plan built before you fly, from the light phase-response curve rather than a rule of thumb.
- Forecast — tomorrow's predicted alertness curve, dip and all.
How I built it
Flutter 3.44.7 · Dart 3.12.2 · Riverpod 3.3.2 · GoRouter 17.3.0 · sqflite 2.4.3 · purchases_flutter 9.16.1. Seven packages, every one load-bearing.
The centre of the app is lib/domain/chrono — nine pure Dart modules with zero
package dependencies:
solar_engine— the NOAA solar position algorithm: sunrise, sunset, civil and nautical twilight, solar noon, altitude at any instant.circadian_phase_model— phase estimated from sleep timing via the MCTQ construct, yielding melatonin onset and temperature minimum.two_process_model— Borbély's homeostatic sleep pressure against the circadian process, producing the alertness curve.light_prc— the light phase-response curve: how much a given light exposure moves your clock, and in which direction.caffeine_model,sleep_debt_ledger,chronotype_estimator,jet_lag_planner,protocol_engine.
Every one is unit-tested against published values rather than against itself.
Offline-first is the architecture, not a feature. Solar geometry is pure
maths from latitude, longitude and date, so there is nothing to fetch. Location
resolves from the device time zone through a bundled dataset — 4,461 cities, 361
IANA zones, 218 countries, 247 KB, with prefix search and haversine reverse
geocoding. geolocator was removed deliberately: it merges
ACCESS_FINE_LOCATION into the manifest for a capability the app doesn't use,
which is a Play data-safety problem before it's a code smell. There is no
account, no server and no analytics. Sleep data never leaves the device.
Monetisation is RevenueCat. One entitlement, pro — everything gates on
customerInfo.entitlements.active['pro'], because multi-tier entitlements are
where gating bugs are born. Three packages in the default offering: monthly as
a deliberate decoy anchor, annual with a 7-day trial as the target, and lifetime
for people who hate subscriptions. Annual is the hero because a circadian
intervention takes weeks to show results, and a monthly plan churns before the
user ever feels it work.
Challenges I ran into
A code generator that silently dropped my data. drift_dev 2.34.0 — the
newest release compatible with the analyzer flutter_test pins — omits every
IntegerColumn from its generated code. Not an error; just absent columns.
Nearly every column in this schema is an integer timestamp. I confirmed it with a
minimal probe table and moved the data layer to hand-written SQL on sqflite.
I published a contrast ratio that was wrong. My design doc claimed the
light-theme amber was 4.0:1. I wrote a test that recomputes all 17 palette tokens
from source and checks them against WCAG 2.2 AA — and it failed. The real ratio
was 2.55:1. Amber is now split into solar (fills and icons only, 3.28:1)
and solarInk (text-safe, 5.81:1), and the test asserts that solar stays
below 4.5:1, so nobody can quietly start using it for text later.
Two RevenueCat bugs that only a real store could surface. I drove the paywall end to end against RevenueCat's Test Store before the Play account existed. It found two things that would have shipped:
startListeninglost a race withconfigure.main()deliberately doesn't await configuration — a slow store must never delay the first frame — but the listener ran from a post-frame callback, found_configured == false, marked the store unavailable and returned without attaching. Nothing retried. A paying customer relaunching the app would have sat on the free tier for the whole session.- Bundled marketing copy survived into the live-price path.
copyWithonly overrodedisplayPrice, so the annual tile printed the store's real price beside the hardcoded subtitle "$3.33 / month · save 52%" — wrong arithmetic at any price but the US one, and dollars shown to a shopper whose price directly above reads ₹ or €. The CTA also promised "Start 7 days free" for a product with no free phase, which is a review rejection rather than a cosmetic slip. The subtitle is now rebuilt frompricePerMonthStringagainst the live monthly package, and trial length is read from the store's own free phase.
A model with no afternoon dip. The naive formulation of the two-process model produced a monotonic curve — no post-lunch dip at all, which is the one feature of the day everybody actually recognises. I fitted the parameters numerically against published data until the curve matched lived experience.
Tests that passed on the day I wrote them. Three repository cases inserted
records at a hardcoded date, then read them back through rolling 14-day and
7-day windows measured from DateTime.now(). They went red on a morning nobody
had touched the code. The production behaviour was correct; the tests simply
weren't hermetic. The fix was to inject the clock as a dependency, not to widen
the window — widening it would have hidden the rot instead of removing it.
Accomplishments that I'm proud of
The engine computes; it doesn't render fixtures. Computed sunrise and sunset against published almanac times for the 2026 solstices, locked in as golden tests at ±2 minutes:
| City | Date | Computed | Published | Δ |
|---|---|---|---|---|
| London | 21 Jun | 04:43 / 21:21 | 04:43 / 21:21 | 0 min |
| New York | 21 Dec | 07:16 / 16:31 | 07:16 / 16:32 | ≤1 min |
| Sydney | 21 Jun | 07:00 / 16:53 | 07:00 / 16:54 | ≤1 min |
| Tokyo | 21 Jun | 04:25 / 19:00 | 04:25 / 19:00 | 0 min |
| Delhi | 21 Jun | 05:23 / 19:21 | 05:23 / 19:21 | 0 min |
Polar cases are first-class states rather than errors: Tromsø returns midnight sun in June and polar night in December.
The same build in two places, which is the evidence I trust most — an iPhone simulator on a London profile with five logged nights, against a Pixel emulator in Mumbai at 22:11 on a fresh install. London: a daylight arc with the sun 29% along, "Good morning", sunrise 05:09 against a published 05:12, sleep debt 4h 59m at medium confidence. Mumbai: a star field with a crescent moon, "Good evening", sunrise 06:11 against a published 06:10, and an active "NOW — dim the lights" window. Nothing there is a fixture.
Accessibility is tested, not asserted. All 17 tokens machine-checked against WCAG 2.2 AA on every run, plus minimum tap-target height at every button size, 1.6× text scaling without overflow, screen-reader semantics and reduced-motion behaviour.
186 tests, all passing. flutter analyze: no issues.
What I learned
A store that returns real data is a test environment you cannot simulate. Both paywall bugs above were invisible against mocks and would have reached paying users.
If you publish a number, write the test that checks it. The contrast bug was in my own design documentation, stated with confidence, and it took thirty lines of test code to disprove.
And the schedule constraint that mattered had nothing to do with code: an individual Play account needs 12 testers opted in for 14 consecutive days before production. That is wall-clock time. It cannot be compressed by working weekends.
What's next for Circa
- The notification scheduler — protocol events already emit stable idempotent keys; the reconciler into the OS queue is the remaining piece.
- Trends, with a consistency raster plot over months rather than a week.
- Health Connect import for people who do own a wearable and want it to feed the model rather than replace it.
- An iOS release. The engine is platform-agnostic; only the store work differs.
Log in or sign up for Devpost to join the conversation.