From e84e75d854d8e6f39c1785ff37f24bef06251714 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Sat, 30 May 2026 14:03:53 +0200 Subject: [PATCH] fix(notifications): cap pre-scheduled recurring reminders (#7850) iOS caps pending local notifications at 64 globally across every reminder effect and silently drops the overflow. Bound the recurring pre-scheduler to the soonest-firing REPEAT_MAX_SCHEDULED occurrences (matching iOS's own keep-soonest behaviour) and log any truncation rather than letting the OS drop an arbitrary subset. Add a regression test covering the cap. --- .../store/mobile-notification.effects.spec.ts | 27 +++++++++++++++++++ .../store/mobile-notification.effects.ts | 22 +++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/src/app/features/mobile/store/mobile-notification.effects.spec.ts b/src/app/features/mobile/store/mobile-notification.effects.spec.ts index 45f7a2cb17..5ab5eb9af1 100644 --- a/src/app/features/mobile/store/mobile-notification.effects.spec.ts +++ b/src/app/features/mobile/store/mobile-notification.effects.spec.ts @@ -745,5 +745,32 @@ describe('MobileNotificationEffects', () => { generateNotificationId(tomorrowId), ); })); + + it('caps pre-scheduled reminders, keeping the soonest-firing (iOS 64-pending limit)', fakeAsync(() => { + // More configs than the cap (REPEAT_MAX_SCHEDULED = 32 in the effect), + // each due tomorrow at a distinct clock time 30 min apart (00:00 → 19:30). + const cfgs = Array.from({ length: 40 }, (_, i) => { + const h = String(Math.floor(i / 2)).padStart(2, '0'); + const m = i % 2 === 0 ? '00' : '30'; + return mkDailyCfg({ id: `cfg${i}`, startTime: `${h}:${m}` }); + }); + store.overrideSelector(selectActiveTaskRepeatCfgs, cfgs); + subscribeRepeatReminders(); + + tick(REPEAT_SETTLE_MS + 1); + + // Bounded to the cap, not the full 40. + expect(reminderServiceSpy.scheduleReminder).toHaveBeenCalledTimes(32); + // Soonest kept, last-within-cap kept, first-over-cap dropped. + expect(reminderServiceSpy.scheduleReminder).toHaveBeenCalledWith( + jasmine.objectContaining({ triggerAtMs: triggerFor(1, '00:00') }), + ); + expect(reminderServiceSpy.scheduleReminder).toHaveBeenCalledWith( + jasmine.objectContaining({ triggerAtMs: triggerFor(1, '15:30') }), + ); + expect(reminderServiceSpy.scheduleReminder).not.toHaveBeenCalledWith( + jasmine.objectContaining({ triggerAtMs: triggerFor(1, '16:00') }), + ); + })); }); }); diff --git a/src/app/features/mobile/store/mobile-notification.effects.ts b/src/app/features/mobile/store/mobile-notification.effects.ts index c76716c43c..b24a9fd168 100644 --- a/src/app/features/mobile/store/mobile-notification.effects.ts +++ b/src/app/features/mobile/store/mobile-notification.effects.ts @@ -37,6 +37,16 @@ const DELAY_SCHEDULE = 5000; // this window. See #7850. const REPEAT_LOOKAHEAD_DAYS = 14; +// Upper bound on how many recurring reminders we pre-schedule in one pass. +// iOS caps pending local notifications at 64 GLOBALLY (across every reminder +// effect), silently dropping the rest, and it keeps the soonest-firing ones. +// We pre-schedule at most one occurrence per config, so this only bites a user +// with an unusually large number of timed recurring tasks — but when it does we +// want to deterministically keep the most imminent ones (matching iOS's own +// keep-soonest behaviour) and leave headroom for the live/due-date effects, +// rather than letting the OS choose for us. Truncation is logged, never silent. +const REPEAT_MAX_SCHEDULED = 32; + // Settle window for the recurring-reminder scheduler. Instance creation // dispatches addTask → updateTaskRepeatCfg → scheduleTaskWithTime back-to-back, // which the store surfaces as several intermediate emissions. Debouncing @@ -563,6 +573,18 @@ export class MobileNotificationEffects { } } + // Keep the soonest-firing occurrences when over the cap (see + // REPEAT_MAX_SCHEDULED). The day loop already yields roughly ascending + // order, but sort explicitly so the cap is deterministic. + result.sort((a, b) => a.triggerAtMs - b.triggerAtMs); + if (result.length > REPEAT_MAX_SCHEDULED) { + Log.log('MobileEffects: capping pre-scheduled repeat reminders', { + total: result.length, + cap: REPEAT_MAX_SCHEDULED, + }); + return result.slice(0, REPEAT_MAX_SCHEDULED); + } + return result; }