Reminders
Musubi answers “which events should remind me?” with a rule, not two hundred toggles. That matters most in a shared calendar: joining one with forty birthdays in it should take one decision, not forty.
The chain
Section titled “The chain”Three levels. The first one that has an answer wins.
- An override on the event —
event_reminders, one row per person per event. A row is always a deliberate choice, “never” included. - Your rule for a calendar —
calendar_members.reminder. An event can be in several calendars, so the rules are read in the order of your ownsettings.calendarOrder; the first calendar with a rule decides. A rule that says “never” wins there like any other, because saying nothing and saying no are different answers. - Your global default —
user_settings.default_reminder. Always a concrete rule, since there is nothing above it to fall back on.
Inheriting is the absence of a rule — a missing row, a null column — never a rule value. Encoding “inherit” as a value would allow a rule that inherits from itself, and every reader would need a cycle check it has no way to resolve.
Nothing reminds, whatever the rule says, for an event you have declined
(event_users.status), an event marked cancelled, or an occurrence whose
reminder time has already passed.
What a rule says
Section titled “What a rule says”type ReminderRule = { // Timed events: minutes before the start. null = do not remind. minutesBefore: number | null; // All-day events: a wall-clock time instead, because "15 minutes before // Christmas" is 23:45 on the 24th. null = do not remind. allDay: { daysBefore: number; atMinute: number } | null;};Two branches because the two kinds of event ask different questions. All-day events are stored as UTC midnight of a timezone-invariant date, so their reminder is built from that calendar date in your zone. Timed events need no zone: they are instants, and an offset from an instant is another instant.
user_settings.timezone is what the server dispatcher resolves all-day
reminders against — it cannot ask the device what time it is there. Clients
resolving for themselves use their own live zone instead. The mobile app reports
its zone on a settings refresh it was making anyway. The web reports it when
somebody turns on push, and again if they have since travelled — not on page
load, which would be a request and a settings revision for every visit.
Who rings
Section titled “Who rings”The server stores rules and nothing else. Clients ask
resolveReminders({ context, events, from, to }) from @musubi/calendar what
falls due in a window, and that one function is what keeps the phone, the
browser, and any future server-side dispatcher from disagreeing about a declined
birthday.
| Where | How it rings | Ceiling |
|---|---|---|
| Mobile | expo-notifications, scheduled locally from the rules |
A device that has not synced for a while can ring on stale data |
| Web, tab open | setTimeout plus the Notification API |
Stops when the tab does |
| Web, tab closed | Web Push from the server, when the install has VAPID keys | Nothing without keys; the tab-open path still works |
A browser never does both. Once it is on the server’s push list the in-tab scheduler stands down, because two notifications raised from two places for one occurrence may not coalesce on their tag.
Permission is asked contextually, where somebody is switching a reminder on — never at launch, and never the first time one happens to come due. A prompt with no context is the one people dismiss forever.
The dispatcher
Section titled “The dispatcher”apps/api/src/reminder_dispatch.ts, on a one-minute tick beside the external
sync. A minute because a reminder is a promise about a specific minute; a
five-minute tick would make “10 minutes before” mean somewhere between five and
ten.
It keeps one cursor for the whole server in dispatch_cursors and dispatches
the window (cursor, now], so consecutive passes abut and nothing falls between
them. After an outage the window is clamped to the last 15 minutes and the drop
is logged: announcing a stack of meetings that already happened is worse than
silence. Delivery is at-least-once — the cursor advances after the sends — and
the payload carries the occurrence id as the notification tag, so a repeat
replaces the banner rather than stacking a second one.
A push service answering 404 or 410 means that endpoint is finished (permission
revoked, storage cleared, browser reinstalled) and the subscription is deleted.
That is counted as gone, not failed: it is the system working.
The service worker is served from /app/sw.js, not the origin root — the
gateway gives / to the marketing site, so a worker at the root would 404 on
the hosted install and silently disable push.
Changing a rule
Section titled “Changing a rule”- Globally — Settings → Reminders, on the phone and on the web.
- Per calendar — Settings → Reminders by calendar, on both. The phone offers
whole-rule presets (“Evening before”), the web the same set; the option lists
live in
packages/types/src/reminder_options.tsso neither client can offer a choice the other cannot display. - Per event — the event’s details, under “Remind me”. Choosing “Use the calendar’s setting” clears the override.
Surfaces write an override only where the choice differs from what the event would inherit. Storing one every time would make every event an exception, and a later change to a calendar rule would then reach none of them.
An older server
Section titled “An older server”A self-hosted server updates when its admin gets round to it, so a phone newer than the API it talks to is an ordinary state rather than an edge case. Two things make that survivable:
SettingsPatchSchemastrips fields it does not know instead of refusing the patch that carries them. Under.strict()a phone that had learned abouttimezonelost its theme change too — the whole write refused over one field the server could have ignored.GET /api/v1/remindersanswering 404 is read as “this server has no rules”, not as an error. The phone falls back to a document built fromnotificationsOnByDefault, which every version sends, so reminders keep working at the global default until the server catches up. The stand-in is never cached — a real document, including a cached one, always wins.
Upgrading
Section titled “Upgrading”Per-event reminders that existing phones hold in local SQLite are not carried up: the old table only stored an offset, the migration drops it, and those events fall back to their calendar or the global rule like any other. A deliberate skip, not an oversight.
The migration that introduces default_reminder sets it from
notificationsOnByDefault: accounts with notifications off get a silent rule,
so nobody is woken by an upgrade they did not ask for. Accounts with them on get
the standard rule — ten minutes before a timed event, the evening before an
all-day one — which now applies to every event rather than only the ones
that had been toggled on individually. That is the point of the change, and one
click on a calendar quiets it again.