We store no record of which dose you saw, on purpose
The Home card and the notification both need to know which item is today's. One of them runs in a background isolate that cannot open the database at all — so instead of syncing a cursor, we deleted it.
Daily Dose shows one engineering fact, formula or question a day: a card on the Home screen, and one notification in the morning.
Two separate pieces of the app have to agree on which item today is. The card, which runs in the normal UI isolate. And the notification, which is re-armed by a background worker.
The background worker cannot open the database. Not “should not” — cannot, safely.
Why the obvious design breaks
The obvious way to run a rotating daily item is a cursor. Keep a lastShownId, advance it on each read, wrap around at the end. It is four lines and it is what almost every implementation does.
It breaks here, and the reason is worth stating precisely, because it is not about races in the usual sense.
Hive is not safe to open from two isolates at once. The background isolate does not share the UI isolate’s boxes — it gets its own copy of every one of them. Two copies, two writes, and the last writer silently wins. A cursor stored there would drift, and the drift would be invisible: the card and the notification would quietly disagree about what today’s dose is, and nothing would report it.
You could try to synchronise the two. That means a lock, or a merge rule, or a “who wrote last” timestamp — all to protect a single integer that both sides could compute instead.
So instead of syncing the cursor, we removed it
Nothing is stored. “Which dose is today’s” is a pure function of two things: the pool of items, and the calendar day.
That has a consequence worth following through. A pure function of the date gives the same answer in both isolates with no communication at all, because there is nothing to communicate. The background worker does not need to know what the card decided, any more than two calculators need to agree on what 7 × 6 is.
It also means there is nothing to repair. There is no state that can be half-written, because there is no state.
Pure does not mean trivial
The resolution rules have to be deterministic to the byte, and that turned out to have a sharp edge.
The day’s kind rotates through fact, formula and question. Within a kind, the item is picked by walking a fixed shuffle one step per cycle. The natural way to write that shuffle in Dart is Random(seed) with a seeded generator.
We wrote our own 31-bit linear congruential generator instead, with a Fisher–Yates shuffle seeded from the pool size alone. The reason is in the comment: dart:math makes no promise that a given seed produces the same sequence across SDK versions.
That promise is fine to break in a game. It is not fine here. If the sequence changed, the UI isolate and the background isolate — which may be running different builds of the app — would disagree, and the notification you tapped would open onto a different item than the one it advertised. A homegrown generator is fifteen lines and it is guaranteed stable, because we control it.
Items are also ordered by their own sort key and then by id before shuffling, so the result does not depend on the order the database or the local cache happened to return them in.
Getting the pool into the background
The background task still needs the list of items to resolve against. It gets it from a preferences snapshot rather than the database — a small, plain key-value record that the scheduler mirrors at the end of every planning pass, in a finally block so that even a pass which fails half way through leaves the snapshot consistent.
Two other things about that worker were wrong for a long time and are worth admitting:
It ran every fifteen minutes. That burnt battery, and worse, it ran a behaviour-tracking backfill roughly ninety-six times a day for no benefit. It now runs every six hours.
It required an unmetered network. Which meant it never ran for anyone on mobile data — exactly the students it existed to cover. That constraint is gone.
The cost we accepted
A pure function has no memory, so adding or retiring an item changes the pool size, and the pool size is what the shuffle is seeded from. Retag one row and the whole cycle reshuffles. An item can come round again sooner than a full turn of the list.
That is the accepted price of storing no cursor anywhere. The alternative is two isolates writing one counter, and a card that disagrees with the notification that sent you to it.
Branch targeting had to be a filter, not a parameter
Not every dose suits every branch, so items can be tagged. The rule for the tag is that an empty tag list means common — shown to everybody.
That default is deliberate. If empty meant “nobody”, then every existing row and every row an admin inserts without thinking about audience would silently disappear. A column that hides content from everyone until someone retags it is a much worse failure than one that shows a little too much. And first-year maths and physics content genuinely is taken by every branch, so common is not a special case — it is the honest default.
The filter is applied to the pool before resolution, never inside it. That ordering matters: the resolver is a pure function of pool and day, so if the audience were threaded into it, the answer would depend on when the audience became known — and the card and the notification could resolve different items on the day a student changed branch.
Tags are two-letter branch codes, not the four stream names. Resolving a stream to its branch codes is a network lookup, and a tagged row that failed to match offline would hide content on a train. A stream is expressed by listing its member codes, all fifty of them.
The column also has a validation constraint on the way in, because a malformed tag like a lower-case code with a trailing space would never match the normalised token — and the row would vanish from every student’s rotation with nothing to report it.
What the notification is allowed to say
When the day’s item is a question, the notification shows the question and stops. The answer, and the explanation, are withheld — because the explanation is the “why”, and a line like “one weber is one volt-second” gives the answer away just as completely as stating it outright. A notification that answers itself gives the student no reason to open the app, which is the entire point of the feature.
There is also a small piece of translation. Formulas are LaTeX, and LaTeX on a lock screen is unreadable, so notification copy only is reduced to plain text — with symbols matched as whole command names, because otherwise \neq comes out as ≠q. The screen itself still renders real mathematics.
The payload carries the item’s id, not the word “today”. A notification left unread past midnight would otherwise open onto different content than it advertised. And when that id is looked up, it is searched in the unfiltered pool: if the student has since changed their branch, the honest thing is still to open the dose they were told about.
Nothing here is per-student
There are no read receipts and no per-student state, because which dose a student saw is a function of the date. There is nothing to record, so nothing is recorded.
That is also why the whole feature works offline. The pool is cached, the resolver is local arithmetic, and the only thing the network does is update the list.
Keep reading
Related reading
The Daily Quiz has no daily content, and that is deliberate
Daily Quiz seeds nothing by date and generates nothing when the app opens. The quizzes are the output of a durable background queue, and the only daily thing about the feature is which five of them we remind you about.
A flashcard that always turns up, even on a train
Tick twenty topics on a bad connection, close the app, and it still finishes the job. Here is the retry queue that made AI generation durable.
The card you mark as known never comes back
Flashcards has three states and no spaced-repetition scheduler. Marking a card known removes it from revision permanently, and four different screens disagree about which cards are due.