React and TypeScript on Vite, with Capacitor packaging to Android. The APK writes to the system calendar and schedules local notifications: the notice goes out with the application closed and the event lands in the diary the person already checks. On the web there is no system calendar to touch, so the same button downloads a .ics file with the sessions that have not been cancelled.
The phone reminders are not pushed by the server: the application reads the same rules from the API and projects the whole cycle into the future, one notification per moment, skipping sessions already started or cancelled. The identifier of each one comes from hashing group · session · rule · moment, so resyncing the same group does not generate duplicates. And there is a ceiling of two hundred scheduled alarms, because Android allows them and a group with many sessions eats up the system queue.
Two details that only get written after reading the Android documentation: you have to request the exact alarm permission separately from the notification permission (otherwise the system groups the notices and they arrive late), and when a rule changes you have to cancel the pending ones for that group before rescheduling, filtering by the group identifier that the app itself stored in the extra payload of each notice.
The server does not find out about either thing. The API is identical for the browser and for the phone, so the app is just one more client and not a second source of truth to keep in sync.
In development, the SMTP points at Mailpit on port 1025: the messages are built and received, but none of them leaves for the internet. A whole reminder flow can be repeated without bombarding real inboxes, and that convenience is what keeps the mail under test.