Scheduled Reminders
A 4-phase scheduled-reminders system layered on top of the centralized notification stack, plus a sibling evening-checklist nudge that reuses the same pg_cron-SQL-fn pattern without touching the reminder table. Lets the user fire a notification to themselves at a future time — ad-hoc one-offs (P1), recurring rules (P2), automatic “up next” reminders for day-plan blocks (P3), and snooze-a-notification (P4). The whole scheduler is a pure-SQL pg_cron job; reminders fire by inserting a notification row directly. The evening-checklist reminder (shipped 2026-05-11) is a second, independent pg_cron SQL function built on the same primitives — see Evening-Checklist Reminder.
For Agents
Builds on push-notifications (the centralized notification system) and mobile-notifications-ui (the in-app notification surfaces). Reminders deliberately bypass
emit_notification— see the warning callout below; this is the single most surprising design choice in the feature. All four phases are shipped and applied to the remote DB. The canonical design doc isdocs/superpowers/specs/2026-05-10-scheduled-reminders-design.md(its Phase 3 section was rewritten to the realday_plan_blockschema after the original spec got it wrong — though even the rewrite mis-typedstart_timeastime; it’s actuallytimestamptz, see Gotchas #4); plan isdocs/superpowers/plans/2026-05-10-scheduled-reminders.md. The evening-checklist reminder is a separate feature in the same family — specdocs/superpowers/specs/2026-05-11-evening-checklist-reminder-design.md, plandocs/superpowers/plans/2026-05-11-evening-checklist-reminder.md; it does not use theremindertable orprocess_due_reminders— see its own section below.
All migrations applied — Phase 3 is live
20260512000400_reminder_p2_5_hardening.sql(P2.5 backend hardening),20260513000100_day_plan_block_reminder_trigger.sql(P3 trigger) and20260513000200_reminder_update_guard.sql(thereminderBEFORE UPDATE guard) are committed tomasterand applied to the remote DB. Phase 3 is live and smoke-tested. (Historical note: those migrations sat un-applied for part of the session because the Supabase MCP token expired mid-work — see Gotchas #1 for whysupabase db pushis the wrong way to ship them when the MCP is down.)
The four phases at a glance
| Phase | What | Key objects | Status |
|---|---|---|---|
| P1 | Ad-hoc one-off reminders | public.reminder table, create_reminder RPC, process_due_reminders() + pg_cron job, /reminders page, MobileReminders, ReminderCreateDialog, CUSTOM_REMINDER notification type | Shipped |
| P1.5 | Post-review fixes | recipient_self SECDEF→INVOKER, ownership validation, FOR UPDATE SKIP LOCKED, send-push EF v6 pref-fallback + HTTP 500 on errors, i18n, toast [object Object] fix | Shipped |
| P2 | Recurring reminders | compute_first_fire_at / compute_next_fire_at SQL helpers, scheduler advances fire_at, 6-tab dialog (Once/Daily/Weekdays/Weekly/Monthly/Hourly) | Shipped |
| P2.5 | Post-review fixes | STABLE not IMMUTABLE, SET search_path='', monthly day-of-month clamping, RAISE LOG on terminated recurrence, dialog Radix-nesting fix, max-h-[90dvh] scroll, string-state number inputs | Shipped (migration 20260512000400 applied) |
| P3 | Day-plan-block reminders | sync_day_plan_block_reminder() trigger split into trg_dpb_reminder_iud + trg_dpb_reminder_upd on day_plan_block | Shipped & smoke-tested (migration 20260513000100 applied) |
| P4 | Snooze on notifications | SnoozePopover, useSnoozeNotification, long-press 500ms (mobile) / hover-chip (desktop), process_due_reminders re-emits (forwards original entity_type/entity_id) | Shipped |
| P3+P4 review | 4-perspective review (DB/security/frontend/ops) of P3 and P4; all critical+important findings fixed | Shipped (incl. migration 20260513000200 — reminder BEFORE UPDATE guard) |
Architecture
Reminder lifecycle
graph TD subgraph Create UI1["ReminderCreateDialog<br/>(/reminders, MobileReminders)"] --> RPC["create_reminder RPC<br/>(SECURITY DEFINER)"] UI2["SnoozePopover<br/>(notification long-press/chip)"] --> RPC TRG["day_plan_block trigger<br/>sync_day_plan_block_reminder()"] -.upsert.-> ROW RPC --> ROW["public.reminder row<br/>fire_at, recur_rule?, source, source_notification_id?"] end subgraph Scheduler CRON["pg_cron: '* * * * *'<br/>SELECT process_due_reminders()"] --> SCAN["scan reminder WHERE<br/>fire_at <= now() AND fired_at IS NULL<br/>FOR UPDATE SKIP LOCKED"] SCAN --> INS["INSERT INTO notification<br/>origin='reminder', type='CUSTOM_REMINDER'<br/>data->>'source_id' dedup key"] INS --> REC{"recur_rule<br/>set?"} REC -->|yes| ADV["fire_at = compute_next_fire_at(r)<br/>(or RAISE LOG + terminate if NULL)"] REC -->|no| MARK["fired_at = now()"] end subgraph Deliver INS --> TRG2["send_push_on_notification trigger<br/>(silences origin='event_fanout')"] TRG2 -->|origin='reminder' → NOT silenced| EF["send-push EF v6<br/>(verify_jwt: false)"] EF --> DEV["push to devices<br/>(falls back to notification_type_def.default_push)"] INS --> FEED["in-app notification feed<br/>(NotificationBell / MobileNotificationsSheet)"] end style RPC fill:#264653,stroke:#2a9d8f,color:#fff style CRON fill:#264653,stroke:#2a9d8f,color:#fff style INS fill:#2d2d2d,stroke:#888,color:#fff style TRG2 fill:#3d2020,stroke:#a44,color:#fff
Why reminders bypass emit_notification (the load-bearing decision)
Reminders direct-INSERT into
notificationon purpose
emit_notificationhardcodesorigin = 'event_fanout'. Thesend_push_on_notificationtrigger silencesorigin = 'event_fanout'during the shadow-mode rollout of the centralized notification system. So if reminders went throughemit_notification, no push would ever fire. Insteadprocess_due_remindersinserts anotificationrow directly withdata->>'origin' = 'reminder', which the send-push trigger does NOT silence.If the shadow-mode rollout ever completes and the
event_fanoutsilence is removed, this can be revisited — but until then, do not “clean this up” by routing reminders throughemit_notification. It will silently kill push delivery.
The scheduler is pure SQL — no Edge Function in the loop
cron.schedule('process-due-reminders', '* * * * *', 'SELECT public.process_due_reminders();')No Edge Function is invoked by the scheduler itself. (Push delivery downstream goes through the send-push EF, but that’s triggered by the notification INSERT, not by the cron job.) The cron.schedule call is idempotent — a DO block unschedules first, then re-schedules — so re-running the migration doesn’t duplicate the job.
CUSTOM_REMINDER notification type
Registered in notification_type_def with default_push = true. It also has a placeholder recipient_self resolver fn (entity_type = 'reminder', match_event_type = 'CREATED'). The resolver never actually matches — there’s no audit trigger on the reminder table — so it’s vestigial; recipient resolution happens implicitly because process_due_reminders writes the recipient column on the notification row directly.
The public.reminder table
| Concern | Detail |
|---|---|
| Owner column | scoped to actor_person_id() for RLS SELECT |
| No INSERT policy | Rows are created only via the create_reminder SECURITY DEFINER RPC. PostgREST cannot insert directly. |
reminder_update_own | Permissive UPDATE policy — lets a user update their own row via PostgREST. Flagged across reviews as too loose. Now backstopped by the reminder_update_guard BEFORE UPDATE trigger (migration 20260513000200): when the current role is authenticated, the trigger rejects any direct UPDATE that changes a column other than status — so the PostgREST path can only flip status, while process_due_reminders (running as the cron/owner role) is unaffected. A full RLS rewrite is still a deferred follow-up but the direct-PostgREST hole is closed. |
fire_at | timestamptz — when the reminder should fire |
fired_at | timestamptz nullable — set when a one-off fires; recurring rows keep this NULL and advance fire_at instead |
recur_rule | jsonb nullable — discriminated union (see below) |
source | 'manual' / 'snooze' / 'day_plan_block' |
source_notification_id | uuid nullable — set for snoozes; FK to the notification being snoozed; create_reminder validates the caller owns it |
source_entity_id | for source='day_plan_block' this is the block’s id; keyed by partial unique index reminder_dpb_uidx (source_entity_id) WHERE source = 'day_plan_block' |
recur_rule — discriminated-union jsonb
Stored verbatim by the RPC. The scheduler’s date math fails safe via its exception handler if the jsonb is malformed.
| Preset | Shape |
|---|---|
| Daily | {kind:'daily', at:'HH:MM'} |
| Weekdays (Mon-Fri) | {kind:'weekdays', at:'HH:MM'} |
| Weekly (day-set) | {kind:'weekly', days:['MON','WED',...], at:'HH:MM'} |
| Monthly (day-of-month) | {kind:'monthly', day_of_month:N, at:'HH:MM'} |
| Hourly (every-N) | {kind:'hourly', every_n_hours:N} |
source_id dedup key
In the notification row’s data:
data->>'source_id' = reminder.id::text || ':' || extract(epoch from reminder.fire_at)::bigint::text
So a recurring reminder’s each occurrence dedups independently (different fire_at → different key), while a same-tick double-fire of the same occurrence is prevented via:
ON CONFLICT (recipient, type, (data->>'source_id')) WHERE data ? 'source_id'SQL helpers (P2 recurrence math)
| Function | Purpose | Volatility | search_path |
|---|---|---|---|
compute_first_fire_at(recur_rule, tz) | Given a rule + tz, when does the first occurrence land? Used by create_reminder when p_fire_at is null. | STABLE (calls now()) | SET search_path = '' |
compute_next_fire_at(reminder) | Given a reminder row, when’s the next occurrence? Used by process_due_reminders to advance recurring rows. | STABLE | SET search_path = '' |
These are
STABLE, notIMMUTABLE— on purposeThey call
now().IMMUTABLEon a function that callsnow()is a correctness lie — the planner is entitled to constant-fold it at plan time. Behaviorally identical inside a plpgsql body, but bites if ever used in an index expression / generated column / CHECK / matview. Originally declaredIMMUTABLE; fixed in P2.5.
Monthly recurrence clamps
day_of_monthto the actual month length
LEAST(dom, EXTRACT(DAY FROM <end-of-month>)). Before the P2.5 fix, aday_of_month = 31rule in February (or 30-day months) overflowed into the next month and then drifted permanently (each subsequent computation pushed it further). Now it clamps to e.g. Feb 28/29.
Scheduler cursor must be
%ROWTYPE, notrecordThe
FOR ... LOOPcursor variable inprocess_due_remindersmust be declaredpublic.reminder%ROWTYPE. If it’srecord,compute_next_fire_at(r)fails withcannot cast type record to reminder(SQLSTATE 42846) —recordwon’t implicitly cast to a named composite type.
Phase 1 — ad-hoc one-off reminders
public.remindertable (above).create_reminderRPC (SECURITY DEFINER) — the only insert path. Validatesp_source_notification_idownership. Whenp_fire_atis null and ap_recur_ruleis given, derivesfire_atfromcompute_first_fire_at.process_due_reminders()— run every minute by pg_cron. Scans due rows withFOR UPDATE SKIP LOCKED, inserts thenotification, advances or marks each. Exception handlerRAISE WARNINGs instead of dying on a bad row.RAISE LOGs when a row has arecur_rulebutcompute_next_fire_atreturns NULL (terminating the recurrence) — was silent before P2.5.- Frontend:
/remindersdesktop page +MobileRemindersscreen +ReminderCreateDialog+ sidebar / More-menu entries.
Phase 1.5 — post-review fixes
Backend:
recipient_selfresolver downgraded SECURITY DEFINER → SECURITY INVOKER.create_remindervalidatesp_source_notification_idownership.process_due_remindersgotFOR UPDATE SKIP LOCKED, aRAISE WARNINGin the exception handler, and an idempotentcron.schedule(DO block: unschedule then schedule).send-pushEF v6 fallback bug fix — it used to treat a missingnotification_preferencerow as “push disabled”, so push only fired for types the user had explicitly toggled on in settings. Now falls back tonotification_type_def.default_pushwhen there’s no explicit preference row. (See push-notifications for the centralized prefs model —emit_notificationalready had the equivalentdefault_in_appfallback for in-app delivery.)send-pushEF v6 also: returns HTTP 500 on real errors (was returning 200, which masked failures — the error lived only innet._http_response); push tag falls back tonotification:<id>when no better tag is available.
Frontend:
- i18n (EN + HU) on all reminder strings; loading / error UI states.
- Smarter mobile back button:
navigate(-1)with/moreas the fallback. - Toast
[object Object]bug fix — Supabase error objects are plain objects, notErrorinstances, soString(err)rendered[object Object]. Now pulls.message/.details/.hint/.codeexplicitly. (Recorded in debugging-log-crm.)
Phase 2 — recurring reminders
compute_first_fire_at/compute_next_fire_atSQL helpers (above) covering presetsdaily/weekdays/weekly(day-set) /monthly(day-of-month) /hourly(every-N).process_due_remindersadvancesfire_atviacompute_next_fire_atfor recurring rows instead of marking them fired.create_reminderderivesfire_atfromcompute_first_fire_atwhenp_fire_atis null.- Dialog rebuilt with 6 tabs — Once / Daily / Weekdays / Weekly / Monthly / Hourly:
- Select-based HH:MM
TimePicker(two<select>s, not a native time input). - Day-of-week chips for Weekly.
- Native
<input type="date">+ Today / Tomorrow / Next-week chips — replaced the iOS-clunkydatetime-localinput.
- Select-based HH:MM
Phase 2.5 — post-review fixes
Backend (migration 20260512000400 — committed and applied to remote):
compute_*_fire_atdeclaredSTABLEnotIMMUTABLE.- Both got
SET search_path = ''. - Monthly recurrence clamps
day_of_monthto days-in-month viaLEAST(dom, EXTRACT(DAY FROM <end-of-month>)). process_due_remindersRAISE LOGs when arecur_rulerow gets a NULL next-fire (terminating the recurrence).
Frontend (shipped):
- The recurrence dialog’s
<Tabs>was wrongly wrapping the always-visible title / body inputs — invalid Radix nesting, broke keyboard nav +autoFocus. Restructured so<Tabs>wraps onlyTabsList+TabsContent. DialogContentgotmax-h-[90dvh] overflow-y-auto— the 6-tab layout was clipping Save / Cancel off-screen on small phones.- Day-of-month / every-N-hours number inputs switched to string state — clearing the field to retype a 2-digit value was snapping it back to
1. TimePickergotrole="group"+ per-SelectTriggeraria-labels.
Phase 3 — day-plan-block reminders
Live & smoke-tested — migrations
20260513000100(trigger) +20260513000200(reminder update guard) applied.
sync_day_plan_block_reminder() — a SECURITY DEFINER trigger function on public.day_plan_block. After the P3 review it’s wired up as two triggers (see C-finding #2 below) rather than one AFTER INSERT OR UPDATE OR DELETE:
| Trigger | Fires | Why |
|---|---|---|
trg_dpb_reminder_iud | AFTER INSERT OR DELETE, always | inserts always create / deletes always remove the paired reminder |
trg_dpb_reminder_upd | AFTER UPDATE WHEN (start_time / status / title changed) | the day-planner does lots of UPDATEs that touch none of these (drag-reorder position, notes edits, …); the WHEN clause stops those no-op-for-reminders updates from re-running the trigger body |
Trigger body:
- When a block is
status = 'PLANNED'with a non-nullstart_time→ upsert a reminder atstart_time - interval '5 minutes', title'Up next: <block title>',source = 'day_plan_block',person_id = NEW.person, keyed on thereminder_dpb_uidxpartial unique index(source_entity_id) WHERE source = 'day_plan_block'.start_timeis atimestamp with time zone— an absolute instant. Nostandup.datejoin, noAT TIME ZONEmath: the fire time is literallystart_time - interval '5 minutes'.- The reminder’s owner comes from
NEW.person— the block’s own column, which RLS already pins toauth.uid(). The trigger does not do a SECURITY-DEFINER-amplified lookup of the standup owner, so the cross-person-injection concern the P3 review raised is moot (see C-finding below). ON CONFLICT DO UPDATEonly resetsstatus = 'pending'/fired_at = NULLwhenfire_atactually changed AND the newfire_at > now(). So re-saving a block (or editing its title) doesn’t re-arm a reminder that already fired, and moving a block into the past doesn’t resurrect a stale reminder.
- When the block is
statusDONE / CANCELLED / SKIPPED, has a NULLstart_time, or is deleted → cancel the reminder.
day_plan_block.start_timeistimestamptz, nottime— and an earlier review assumedtimeSchema reality for the columns this trigger touches:
- The owner column is
person— notperson_id. (RLS pins it toauth.uid().)start_timeis atimestamp with time zone— an absolute instant, NOT atime. An earlier review ofday_plan_block(and an early spec draft) assumedstart_timewas atimevalue living alongside the standup’s date. A trigger that didstandup.date + start_timewould have been adate + timestamptzruntime type error — anddatabase.types.tstypesstart_timeasstringeither way, so nothing static would have caught it; only a liveINSERTduring smoke-testing surfaced it.- Because
start_timeis already absolute, there is no timezone math in the trigger and nostandupjoin — the original “hardcodedEurope/Budapest” shortcut is gone too.day_plan_block.statusvalues:PLANNED,DONE,CANCELLED,SKIPPED. See day-planner for the day-plan architecture.
P3 review — critical / important findings & fixes
A 4-perspective review (DB / security / frontend / ops) of the Phase 3 work; all critical + important findings were fixed and shipped before P3 went live.
- C1 — re-arming an already-fired reminder. The original
ON CONFLICT DO UPDATEunconditionally resetstatus='pending'/fired_at=NULL, so any later UPDATE of the block (even one that didn’t move it) re-armed the reminder. Fixed: the conflict branch only resets those columns whenfire_atgenuinely changed and the newfire_at > now(). - C2 — single trigger churned on every block UPDATE. One
AFTER INSERT OR UPDATE OR DELETEtrigger meant the day-planner’s frequent reminder-irrelevant UPDATEs (position reorders, notes edits) re-ran the trigger body for nothing. Fixed: split intotrg_dpb_reminder_iud(INSERT/DELETE, always) +trg_dpb_reminder_upd(UPDATE,WHENstart_time/status/titlechanged). - Cross-person injection (raised, then resolved by design change). A SECURITY-DEFINER trigger that looked up the standup’s owner to set the reminder’s
person_idcould in principle write a reminder for someone else’s account. Resolved by usingNEW.person(the block’s own RLS-pinned column) instead of a standup-owner lookup — no amplified read, nothing to inject. - See also P4 review — important findings & fixes and the new
reminder_update_guardBEFORE UPDATE trigger (migration20260513000200), which independently restricts theauthenticatedrole’s direct UPDATEs onremindertostatusonly.
Phase 4 — snooze on notifications
- Mobile: long-press a notification (500ms timer, post-review — was 350ms) →
SnoozePopover. Long-press now fires a haptic tick and shows press feedback so the gesture is discoverable. - Desktop: hover a notification → a Snooze chip appears → click it →
SnoozePopover. SnoozePopover— 4 presets: 1h / 3h / Tomorrow 9am / Next week. The fire time is computed client-side in the browser’s timezone.- →
useSnoozeNotification→create_reminderwithsource = 'snooze',source_notification_id = <the notification's id>. The hook also forwards the original notification’sentity_type/entity_idso the re-fired notification keeps its click-through target (P4 review fix — see below). - When the snooze reminder fires,
process_due_remindersre-emits it as a freshCUSTOM_REMINDERnotification — title / body andentity_type/entity_idcopied from the original. SnoozePopoveris shared by the desktopNotificationPopoverand the mobileMobileNotificationsSheet(both renderNotificationItem). See mobile-notifications-ui.
P4 review — important findings & fixes
A 4-perspective review (DB / security / frontend / ops) of the Phase 4 work; fixes shipped before P4 was finalized.
- Snooze lost the click-through target. The re-emitted notification used to copy only title / body, not
entity_type/entity_id— so a snoozedTASK_ASSIGNEDre-fired with no destination to click into. Fixed:useSnoozeNotificationforwardsentity_type/entity_idintocreate_reminder, andprocess_due_reminderswrites them onto the re-emittednotificationrow. - Long-press too twitchy / undiscoverable. Bumped the mobile long-press from 350ms → 500ms, added a haptic tick and visible press feedback on press-start.
reminderUPDATE policy too permissive (shared with P3 review). Added thereminder_update_guardBEFORE UPDATE trigger (migration20260513000200) — when the role isauthenticated, direct UPDATEs are restricted to changingstatusonly;process_due_reminders(cron/owner role) is unaffected. See thereminder_update_ownrow in [[scheduled-reminders#the-publicreminder-table|Thepublic.remindertable]].
Evening-Checklist Reminder
Shipped 2026-05-11 — separate from Phases 1-4
A timed nudge to finish the daily Evening Checklist. Same pattern as
process_due_reminders(self-contained pg_cron SQL fn, direct-insert withorigin='reminder', dedup viadata->>'source_id', per-personBEGIN/EXCEPTION) but it does not touch theremindertable,process_due_reminders, or the day-plan-block trigger — zero impact on those. Spec:docs/superpowers/specs/2026-05-11-evening-checklist-reminder-design.md(revised after a 3-perspective DB/security/ops review — the review added the in-app-pref gate, thecount(DISTINCT), theRAISE LOGs, and the'standup'click-through). Plan:docs/superpowers/plans/2026-05-11-evening-checklist-reminder.md. Commits onmaster:cf6e947(migration),ed5a34d(send-pushEF v7),8fbfda1(click handlers +NotificationPopover.test.tsx).
process_evening_checklist_reminders() — the function
A SECURITY DEFINER SQL function, called by a new pg_cron job process-evening-checklist-reminders on schedule '0 * * * *' — top of every hour, UTC. The function self-gates: it only does work when extract(hour from now() AT TIME ZONE 'Europe/Budapest') is 21 or 22. So it’s DST-proof (the gate is on Budapest wall-clock, not on a UTC cron offset), and 22 of the 24 hourly ticks are a one-statement RETURN.
Why a UTC
'0 * * * *'cron + in-function Budapest gate instead of'0 20,21 * * *'Pinning the cron schedule to UTC hours 20 & 21 would be the right Budapest times only in winter (CET = UTC+1); in summer (CEST = UTC+2) those would fire at 22:00 & 23:00 local. Running every hour and gating on
now() AT TIME ZONE 'Europe/Budapest'inside the function makes the nudge land at 21:00 / 22:00 local year-round at the cost of 22 cheap no-op ticks/day. Same trick is reusable for any “fire at a local wall-clock hour” job.
When the gate passes, per tick:
-
X = count of active
standup_datapoint_configrows withphase = 'EVENING_CHECKLIST'(currently 4).standup_datapoint_configis global — nopersoncolumn — so X is the same for everyone. -
Loop over people who actually do the ritual:
SELECT DISTINCT person FROM standup WHERE date >= today - 30. (Scoping to recent-standup people keeps the loop tiny and avoids nudging someone who’s never used the feature.) -
Per person, N =
count(DISTINCT config)of that person’sstandup_datapointrows attached to today’s standup, where the row’s config is an activeEVENING_CHECKLISTconfig andvalueis non-empty. -
If
N >= X→ done, skip (RAISE LOG ... -> skip(done)). -
Else check the
(person, CUSTOM_REMINDER, in_app)notification_preference(falling back tonotification_type_def.default_in_appwhen there’s no explicit row — same fallback semantics asprocess_due_reminders/emit_notification). If in-app is disabled → skip (RAISE LOG ... -> skip(in_app disabled)) — a disabled-in-app user gets neither the in-app row nor a push, exactly likeprocess_due_reminders. -
Else direct-
INSERTanotification:Column Value type'CUSTOM_REMINDER'title'Evening checklist'body'N/X done — wrap up your day'entity_type'standup'entity_idtoday’s standup id, or NULLif no standup row exists yetdata{ source:'evening_checklist', source_id:'evening_checklist:<YYYY-MM-DD>:<hour>', origin:'reminder', n, x }…with
ON CONFLICT (recipient, type, (data->>'source_id')) WHERE data ? 'source_id' DO NOTHING— the existingnotification_dedup_uidx. ThenRAISE LOG ... -> insert. Each person’s iteration is wrapped inBEGIN ... EXCEPTION WHEN OTHERS THEN RAISE WARNING ...so one bad person doesn’t abort the rest of the loop.
source_idincludes the hour → 21:00 and 22:00 are distinct rows
source_id = 'evening_checklist:' || <today's date> || ':' || <Budapest hour>. So the 21:00 nudge and the 22:00 nudge dedup independently — the 22:00 tick naturally re-nudges only if still incomplete (it re-checksN < X); if the checklist got finished between 21 and 22, the 22:00 tick hits theskip(done)branch. Two differentsource_ids also means the OS doesn’t collapse the two pushes into one notification (see the tag note below).
Delivery & click-through
- Push: the
notificationINSERT fires the existingsend_push_on_notificationAFTER INSERT trigger → pg_net →send-pushEF.data->>'origin' = 'reminder'is what escapes the shadow-mode push silence (the trigger only silencesorigin = 'event_fanout') — same mechanism theremindertable relies on. Push tag falls back tonotification:<id>when no better tag is derivable, so the 21:00 and 22:00 pushes don’t collapse into a single OS notification. - Click target:
send-push’sderiveUrlgainedcase 'standup': return '/standup'(EF v7;verify_jwtstays false — see Gotcha #6). In-app:handleClickin bothNotificationPopover.tsx(desktop) andMobileNotificationsSheet.tsx(mobile) got anelse if (notification.entity_type === 'standup') navigate('/standup')branch (+ a newNotificationPopover.test.tsxunit test). So tapping the nudge — in-app or push — opens/standup, which auto-creates today’s standup row if it doesn’t exist yet.
What’s hardcoded / out of scope
- Nudge hours 21:00 / 22:00 Europe/Budapest are hardcoded. A user-configurable hour is out of scope — it would need a settings row.
- “Done” is judged by filled-in answers (
N >= X, i.e. allEVENING_CHECKLISTdatapointvalues non-empty), notstandup.evening_completed_at. This is deliberate: the checklist/reflection fields autosave on input (see mobile-autosave) and there’s no separate “complete” button, soevening_completed_atisn’t a reliable “the user is done” signal. If a real “complete” affordance is ever added, revisit this.
Evening-checklist-reminder gotchas / notes
- “Done” =
N >= X, notstandup.evening_completed_at IS NOT NULL— see the “out of scope” note above. The evening checklist has no “complete” button; fields autosave. Revisit if that changes. standup_datapoint_configis global (nopersoncolumn) → X is the same for every person. If it ever gains apersoncolumn, X has to become per-person.- Latent multi-tenant note:
process_evening_checklist_remindersisSECURITY DEFINERandstandup’s RLS isWITH CHECK (true), so in a future multi-user world user A couldINSERTastanduprow attributed to B → B gets a (harmless, fixed-content) nudge. Single-user CRM today, not exploitable; tracked for a future multi-tenant pass (tightenstandupRLS toperson = auth.uid()). Same family of concern as theday_plan_block/standupRLS follow-up below. - Snooze quirk: because the nudge is
type = 'CUSTOM_REMINDER', the Phase-4 long-press snooze works on it. A snoozed copy re-fires next morning copying the literal “3/4 done” body against tomorrow’s fresh 0/4 standup. Harmless; not special-cased. - It does not write to or read the
remindertable, does not callprocess_due_reminders, and does not interact with the day-plan-block trigger. Brand-new pg_cron job (process-evening-checklist-reminders), brand-new function. The only shared surface is thenotificationtable + thesend_push_on_notificationtrigger + thesend-pushEF + thenotification_dedup_uidxindex.
Gotchas
These were learned during this work. The Postgres ones have cross-project value and are also captured in debugging-log-crm.
- Supabase MCP
apply_migrationassigns its OWN timestamp version (YYYYMMDDHH24MISSat apply time), NOT the version in your migration filename. So a project where migrations were applied via the MCP shows a permanent remote-vs-local mismatch insupabase migration list, andsupabase db pushagainst it tries to re-run all the local migration files (their versions don’t match remote rows) — which fails onCREATE TABLE(non-IF NOT EXISTS) statements. The migration files are the source of truth for intended schema; remote history is just an audit log with different version numbers. Don’t chase parity. If you must apply a migration while the MCP is down: write the file, commit it, and apply viapsql -fagainst the connection string —supabase db pushis dangerous here. IMMUTABLEon a function that callsnow()is a correctness lie. The planner can constant-fold it at plan time. UseSTABLE. Behaviorally identical inside a plpgsql body, but bites in index expressions / generated columns / CHECKs / matviews.- PL/pgSQL cursor variables must be
%ROWTYPE, notrecord, if you pass them to a function expecting that exact row type.recordwon’t implicitly cast —cannot cast type record to reminder, SQLSTATE 42846. day + timein Postgres yieldstimestamp WITHOUT time zone(wall-clock). To get atimestamptzyou mustAT TIME ZONE '<zone>'. Casting a wall-clocktimestampdirectly totimestamptzuses the server’s tz (UTC on Supabase) — almost never what you want for user-facing times.- Supabase error objects are NOT
Errorinstances —String(err)renders[object Object]. Pullerr.message/.details/.hint/.codeexplicitly when surfacing in a toast. send-pushEF must keepverify_jwt: false. Thesend_push_on_notificationtrigger calls it via pg_net withAuthorization: Bearer <SEND_PUSH_WEBHOOK_SECRET>, not a JWT. Re-deploying withverify_jwt: true(the MCPdeploy_edge_functiondefault) silently 401s every push. Always passverify_jwt: falseexplicitly and verify it stuck vialist_edge_functions.- Notification preference “no row” fallback semantics —
emit_notification(in-app) falls back tonotification_type_def.default_in_app;send-push(push) now falls back tonotification_type_def.default_push. Before the P1.5 fix,send-pushtreated a missing row as disabled — so push only worked for types the user had explicitly toggled. - An earlier review of
day_plan_blockassumedstart_timewas atime; it’s actuallytimestamptz. The early Phase 3 plan was going to compute the fire time asstandup.date + start_time— which would have been adate + timestamptzruntime type error (operator does not exist: date + timestamp with time zone).database.types.tstypesstart_timeasstringregardless of whether it’stimeortimestamptz, so nothing static catches the difference — it only surfaced on a liveINSERTduring smoke-testing. Lesson: when a trigger or RPC depends on a column’s exact SQL type (not just “is it a string in TS”), verify it in the actual schema (\d table/information_schema.columns), not just the generated TS types or a spec draft. - A
WHEN (...)clause on anAFTER UPDATEtrigger is the cheap fix for “this trigger churns on UPDATEs that don’t matter to it.” The day-planner UPDATEsday_plan_blockconstantly for things the reminder trigger doesn’t care about (position, notes). Splitting the trigger into INSERT/DELETE-always + UPDATE-WHEN-relevant-columns-changed avoids re-running the body for nothing — without that, every drag-reorder re-evaluated the reminder upsert. - For a “fire at a local wall-clock hour” pg_cron job, schedule it
'0 * * * *'(every hour, UTC) and gate inside the function onextract(hour from now() AT TIME ZONE '<zone>'). pg_cron schedules are UTC; pinning the cron to fixed UTC hours is only correct in one half of the DST year. Hourly + an in-function local-hour gate is DST-proof; the cost is N−k cheap no-op ticks/day (a one-statementRETURN). Used byprocess_evening_checklist_reminders(gate = hour ∈ {21,22} Europe/Budapest). count(DISTINCT config)notcount(*)when “progress” is “how many distinct items have an answer.” A person could have multiplestandup_datapointrows pointing at the same config (history / re-saves);count(*)would over-count and could spuriously cross theN >= X“done” threshold.count(DISTINCT config) FILTER (WHERE value <> '')is the honest count. (A 3-perspective review caught this in the evening-checklist reminder.)- A self-nudging cron job should re-check completion every tick, and put a discriminator in the dedup
source_idso successive ticks are distinct rows.process_evening_checklist_remindersruns at both 21:00 and 22:00 Budapest;source_idembeds the hour, so the 22:00 tick is a separate (deduped-independently) notification that only materializes if the work is still incomplete at 22:00. Without the hour in the key, the 22:00 re-nudge would be swallowed by the 21:00 row’s dedup.
Open follow-ups (post-Phase 4 + P3/P4 review)
- Multi-tenant
day_plan_block/standupRLS tightening (the P3 review’s C2) — much less urgent now that the trigger derives the reminder’s owner fromNEW.personrather than a standup-owner lookup, so there’s no SECURITY-DEFINER-amplified cross-person write path. Still worth doing if/when the CRM goes multi-tenant. - Full
reminder_update_ownRLS rewrite — partially addressed: the newreminder_update_guardBEFORE UPDATE trigger (migration20260513000200) already blocks the direct-PostgREST path from changing anything butstatus. Replacing the permissiveUSING (true)policy with a column-scoped one is still nice-to-have but no longer load-bearing. - No retention / cleanup job for
reminder(fired rows accumulate),net._http_response, orcron.job_run_details. useReminders/ recurrence / snooze / theday_plan_blocktrigger have thin test coverage.- Per-block / per-person configurable lead time for day-plan-block reminders (currently hardcoded 5 min).
- Custom snooze time picker (currently 4 presets only).
Spec & Plan
- Spec —
docs/superpowers/specs/2026-05-10-scheduled-reminders-design.md(canonical design; Phase 3 section rewritten to the realday_plan_blockschema — but note even that rewrite mis-typedstart_timeastime; it’stimestamptz) - Plan —
docs/superpowers/plans/2026-05-10-scheduled-reminders.md - Evening-checklist reminder spec —
docs/superpowers/specs/2026-05-11-evening-checklist-reminder-design.md(revised after a 3-perspective DB/security/ops review) - Evening-checklist reminder plan —
docs/superpowers/plans/2026-05-11-evening-checklist-reminder.md
Commits (master)
| Phase | Commits |
|---|---|
| P1 | ~5de4cbc..a390815 + 5018344 + 2130a06 + c60d2b8 |
| P2 | 5d4bd47, 4e413eb, 6e87c08 |
| P2.5 | 6757811 (migration 20260512000400 — applied), fa1ea6b (frontend) |
| P3 | cd67841 (migration 20260513000100 — applied) |
| P4 | 664a63f |
| P3+P4 review fixes | trigger split / ON CONFLICT guard / snooze entity forwarding / long-press 500ms / migration 20260513000200 reminder_update_guard (commits interspersed) |
| Evening-checklist reminder | cf6e947 (migration — process_evening_checklist_reminders + process-evening-checklist-reminders cron job), ed5a34d (send-push EF v7 — 'standup' URL case), 8fbfda1 (NotificationPopover + MobileNotificationsSheet standup click branch + NotificationPopover.test.tsx), plus the spec/plan doc commits |
Plus build / deploy / push commits interspersed.
Related
- push-notifications — Centralized notification system this builds on (
notificationtable,notification_type_def,send-pushEF, theevent_fanoutsilencing) - mobile-notifications-ui — In-app notification surfaces (
NotificationItem,MobileNotificationsSheet,NotificationPopover) that snooze + the evening-checklist click-through hook into - evening-checklist — The Evening Checklist ritual the evening-checklist reminder nudges (
EVENING_CHECKLISTphase,standup_datapoint_config/standup_datapoint) - mobile-autosave — Why “done” for the evening checklist is
N >= X, notevening_completed_at(fields autosave, no complete button) - day-planner — Day-plan /
day_plan_blockarchitecture (Phase 3 trigger source); also hosts the desktop standup page/standupopens to - debugging-log-crm — The Postgres gotchas + the
actor_person_id/ send-push / toast bugs - security — Auth model (
actor_person_id(), RLS, CF Access JWT) - tech-debt — Where the open follow-ups land
- levandor-crm — Project overview