Skip to content

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.

Three levels. The first one that has an answer wins.

  1. An override on the eventevent_reminders, one row per person per event. A row is always a deliberate choice, “never” included.
  2. Your rule for a calendarcalendar_members.reminder. An event can be in several calendars, so the rules are read in the order of your own settings.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.
  3. Your global defaultuser_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.

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.

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.

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.

  • 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.ts so 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.

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:

  • SettingsPatchSchema strips fields it does not know instead of refusing the patch that carries them. Under .strict() a phone that had learned about timezone lost its theme change too — the whole write refused over one field the server could have ignored.
  • GET /api/v1/reminders answering 404 is read as “this server has no rules”, not as an error. The phone falls back to a document built from notificationsOnByDefault, 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.

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.