# Daily Notification: Why Extra Notifications With Fallback / "Starred Projects" Still Fire **Date:** 2026-03-02 **Context:** After previous fixes (see `DAILY_NOTIFICATION_BUG_DIAGNOSIS.md` and `plugin-feedback-android-rollover-double-fire-and-user-content.md`), duplicate notifications and fallback/"starred projects" text still occur. This doc explains root causes and where fixes must happen. --- ## Summary of What’s Happening 1. **Extra notification(s)** fire at a different time (e.g. ~3 min early) or at the same time as the user-set one. 2. **Wrong text** appears: either generic fallback ("Daily Update" / "Good morning! Ready to make today amazing?") or the app’s placeholder ("TimeSafari Update" / "Check your starred projects for updates!"). 3. The **correct** notification (user-set time and message) can still fire as well, so the user sees both correct and wrong notifications. --- ## Root Causes ### 1. Second alarm from prefetch (UUID / fallback) **Mechanism** - The plugin has two scheduling paths: - **NotifyReceiver** (AlarmManager): used for the app’s single daily reminder; uses `scheduleId` (e.g. `daily_timesafari_reminder`) and carries title/body in the Intent. - **DailyNotificationScheduler** (legacy): used by **DailyNotificationFetchWorker** when prefetch runs and then calls `scheduleNotificationIfNeeded(fallbackContent)`. That creates a **second** alarm with `notification_id` = **UUID** (from `createEmergencyFallbackContent()` or from fetcher placeholder). - **ScheduleHelper** correctly **does not** enqueue prefetch for static reminders (see comment in `DailyNotificationPlugin.kt` ~2686: "Do not enqueue prefetch for static reminders"). So **new** schedules from the app no longer create a prefetch job. - However: - **Existing** WorkManager prefetch jobs (tag `daily_notification_fetch`) that were enqueued **before** that fix (or by an older build) are still pending. When they run, fetch fails or returns placeholder → `useFallbackContent()` → `scheduleNotificationIfNeeded(fallbackContent)` → **second alarm with UUID**. - That UUID alarm is **not** stored in the Schedule table. So when the user later calls `scheduleDailyNotification`, **cleanupExistingNotificationSchedules** only cancels alarms for schedule IDs that exist in the DB (e.g. `daily_timesafari_reminder`, `daily_rollover_*`). The **UUID alarm is never cancelled**. - **Result:** You can have two alarms: one for `daily_timesafari_reminder` (correct) and one for a UUID (fallback text). If the UUID alarm was set for a slightly different time (e.g. from an old rollover), you get two notifications at two times. **Where the fallback text comes from (plugin)** - **DailyNotificationFetchWorker** (in both app’s `node_modules` plugin and the standalone repo): - On failed fetch after max retries: `useFallbackContent(scheduledTime)` → `createEmergencyFallbackContent(scheduledTime)` → title "Daily Update", body "🌅 Good morning! Ready to make today amazing?". - That content is saved and then **scheduled** via `scheduleNotificationIfNeeded(fallbackContent)`, which uses **DailyNotificationScheduler** (legacy) and assigns a **new UUID** to the content. So the second alarm fires with that UUID and shows that fallback text. ### 2. Prefetch WorkManager jobs not cancelled when user reschedules - **scheduleDailyNotification** (plugin) calls: - `ScheduleHelper.cleanupExistingNotificationSchedules(...)` → cancels **alarms** for all DB schedules (except current `scheduleId`). - `ScheduleHelper.scheduleDailyNotification(...)` → cancels alarm for current `scheduleId`, schedules NotifyReceiver alarm, **does not** enqueue prefetch. - It does **not** cancel **WorkManager** jobs. So any already-enqueued prefetch work (tag `daily_notification_fetch`) remains. When that work runs, it creates the second (UUID) alarm as above. - **ScheduleHelper** has `cancelAllWorkManagerJobs(context)` (cancels tags `prefetch`, `daily_notification_fetch`, etc.), but **nothing calls it** in the schedule path. So pending prefetch jobs are left in place. **Fix (plugin):** When the app calls `scheduleDailyNotification`, **cancel all fetch-related WorkManager work** (e.g. call `ScheduleHelper.cancelAllWorkManagerJobs(context)` or a helper that only cancels `daily_notification_fetch` and `prefetch`) **before** or **right after** `cleanupExistingNotificationSchedules`. That prevents any pending prefetch from running and creating a UUID alarm later. ### 3. "Starred projects" message from the app’s native fetcher - **TimeSafariNativeFetcher** (`android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java`) is still a **placeholder**: it always returns: - Title: `"TimeSafari Update"` - Body: `"Check your starred projects for updates!"` - That text is used whenever the plugin **fetches** content and then displays it: - **DailyNotificationFetchWorker**: on “successful” fetch it saves and schedules the fetcher’s result; for your app that result is the placeholder, so any notification created from that path shows “starred projects”. - **DailyNotificationWorker** (JIT path): when `is_static_reminder` is false and content is loaded from Room by `notification_id`, if the worker then does a JIT refresh (e.g. content stale), it calls `DailyNotificationFetcher.fetchContentImmediately()` which can use the app’s native fetcher and **overwrite** title/body with the placeholder. - So “starred projects” appears on any notification that goes through a **fetch** path (prefetch success or JIT) instead of the **static reminder** path (Intent title/body or Room by canonical `schedule_id`). **Fix (app):** For a static-reminder-only flow, the plugin should not run prefetch (already done) and should not overwrite with fetcher in JIT for static reminders. Reducing duplicate/out-of-schedule alarms (fixes above) ensures the main run is the static one. Optionally, implement **TimeSafariNativeFetcher** to return real content if you ever want “fetch-based” notifications; until then, the only path that should show user text is the NotifyReceiver alarm with `daily_timesafari_reminder` and title/body from Intent or from Room by `schedule_id`. ### 4. Rollover / Room content keyed by run-specific id - When an alarm fires with `notification_id` = **UUID** or **notify_<timestamp>** (and no or missing title/body in the Intent), the Worker treats it as **non-static**. It loads content from Room by that `notification_id`. The entity for `daily_timesafari_reminder` (user title/body) is stored under a **different** id, so the Worker either finds nothing or finds content written by prefetch/fallback for that run → wrong text. - When the alarm is the **correct** one (`daily_timesafari_reminder`) and Intent has title/body (or `schedule_id`), the Worker uses static reminder or resolves by `schedule_id` and shows user text. So the main fix is to **avoid creating the UUID/notify_* run in the first place** (cancel prefetch work; no second alarm). Rollover for the static reminder already passes `scheduleId` and title/body in the Intent (NotifyReceiver puts them in the PendingIntent), so once there’s only one alarm, rollover should keep user text. --- ## Where Fixes Must Happen ### Plugin (daily-notification-plugin) **1. Cancel prefetch (and related) WorkManager jobs when scheduling** - **File:** `DailyNotificationPlugin.kt` (or wherever `scheduleDailyNotification` is implemented). - **Change:** When handling `scheduleDailyNotification`, after `cleanupExistingNotificationSchedules` and before (or after) `ScheduleHelper.scheduleDailyNotification`, call a method that cancels all WorkManager work that can create a second alarm. Prefer reusing **ScheduleHelper.cancelAllWorkManagerJobs(context)** or adding a small helper that cancels only fetch-related tags (e.g. `daily_notification_fetch`, `prefetch`) so you don’t cancel display/dismiss work unnecessarily. - **Effect:** Pending prefetch jobs from older builds or previous flows will not run, so no new UUID alarm is created and no extra notification with fallback text. **2. (Already done) Do not enqueue prefetch for static reminders** - **ScheduleHelper.scheduleDailyNotification** already does **not** enqueue FetchWorker for static reminders. No change needed here; just ensure no other code path enqueues prefetch for the app’s single daily reminder. **3. (Optional) DailyNotificationFetchWorker: skip scheduling second alarm for static-reminder schedules** - If you ever enqueue prefetch with an explicit “static reminder” flag, in **DailyNotificationFetchWorker** inside `useFallbackContent` / `scheduleNotificationIfNeeded`, skip calling `scheduleNotificationIfNeeded` when that flag is set. For your current setup (no prefetch for static), this is redundant but makes the contract clear and future-proof. **4. Receiver: no DB on main thread** - Your **DailyNotificationReceiver** in the app’s plugin only reads Intent extras and enqueues work; it does not read Room on the main thread. If you still see `db_fallback_failed` in logcat, the failing DB access is elsewhere (e.g. another receiver or an old build). Ensure no BroadcastReceiver does Room/DB access on the main thread; resolve title/body in the Worker from `schedule_id` if Intent lacks them. ### App (crowd-funder-for-time-pwa) **Scope: static reminders only.** For fixing static reminders, **no app code changes are required.** Real fetch-based content can be added later. **1. TimeSafariNativeFetcher** - **File:** `android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java` - **Current behavior:** Placeholder that returns `"TimeSafari Update"` / `"Check your starred projects for updates!"` (expected). - **For static reminders now:** Leave as-is. The plugin fix (cancel prefetch work when scheduling) ensures the only notification path is the static one; the fetcher is never used for display in that flow. No change needed. - **Later (optional):** When you implement real-world content fetching, replace the placeholder here so any future fetch-driven notifications show real content. **2. Build and dependency** - After plugin changes, ensure the app uses the updated plugin (point `package.json` at the fixed repo or publish and bump version), then **clean build** Android (`./gradlew clean`, rebuild, reinstall). Confirming the APK contains the plugin version that cancels prefetch work and does not enqueue prefetch for static reminders avoids stale behavior from old builds. --- ## Verification After Fixes 1. **Single notification, user text** - Set daily reminder with a **distinct** title/body and a time 2–3 minutes ahead. Wait until that time. - **Expect:** Exactly **one** notification at that time with your text. No second notification (no UUID, no “Daily Update” or “starred projects”). 2. **No out-of-schedule notification** - Change reminder time (e.g. from 21:53 to 21:56) and save. Wait past 21:53 and until 21:56. - **Expect:** No notification at 21:53; one at 21:56 with your text. 3. **Rollover** - Let the correct notification fire once so rollover runs. Next day (or next occurrence) you should see **one** notification with the same user text. 4. **Logcat** - No `display=` at the same time as `static_reminder id=daily_timesafari_reminder`. - After scheduling (e.g. edit and save), you should see prefetch/fetch work being cancelled if you add a log in the cancel path. --- ## Short Summary | Issue | Cause | Fix location | |-------|--------|--------------| | Extra notification at same or different time | Prefetch WorkManager job still runs and creates second (UUID) alarm via legacy scheduler; that alarm is never cancelled on reschedule | **Plugin:** Cancel fetch-related WorkManager jobs when `scheduleDailyNotification` is called | | Fallback text ("Daily Update" / "Good morning!") | FetchWorker’s `useFallbackContent` → `scheduleNotificationIfNeeded` creates alarm with that content | **Plugin:** Same as above (no prefetch run → no fallback alarm); optionally FetchWorker skips scheduling when static-reminder flag set | | "Starred projects" text | TimeSafariNativeFetcher placeholder used when a fetch path runs | **Plugin:** Same as above (no prefetch → no fetch path). **App:** No change for static reminders; leave fetcher as placeholder until real fetch is implemented. | | Wrong content on rollover | Rollover run keyed by UUID or notify_* and no title/body in Intent → Worker loads from Room by that id → wrong/empty content | **Plugin:** Avoid creating UUID/notify_* run (cancel prefetch). Static rollover already passes schedule_id and title/body. | The critical missing step is **cancelling prefetch (and fetch) WorkManager work when the user schedules or reschedules** the daily notification. That prevents any pending prefetch from running and creating the second alarm with fallback or “starred projects” text. --- ## For Cursor (plugin repo) — actionable handoff Use this section when applying the fix in the **daily-notification-plugin** repo (e.g. with Cursor). Paste or @-mention this doc as context. **Goal:** For static reminders, only one notification at the user's chosen time with user-set title/body. No extra notification from pending prefetch (UUID alarm with fallback or "starred projects" text). **Root cause:** `scheduleDailyNotification` cleans up DB schedules and alarms but **does not cancel WorkManager prefetch jobs**. Any previously enqueued job (tag `daily_notification_fetch`) still runs, then creates a second alarm via `DailyNotificationScheduler` (UUID). That alarm is never cancelled on reschedule. Fix: cancel fetch-related WorkManager work when the user schedules. **Change (required):** 1. **Cancel fetch-related WorkManager jobs when handling `scheduleDailyNotification`** - **File:** `android/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.kt` - **Where:** In `scheduleDailyNotification(call)`, inside the `CoroutineScope(Dispatchers.IO).launch { ... }` block, **after** `ScheduleHelper.cleanupExistingNotificationSchedules(...)` and **before** `ScheduleHelper.scheduleDailyNotification(...)`. - **What:** Call a method that cancels WorkManager work that can create a second alarm. Reuse **ScheduleHelper.cancelAllWorkManagerJobs(context)** (it already cancels `prefetch`, `daily_notification_fetch`, etc.). If you prefer not to cancel display/dismiss work, add a helper that only cancels `daily_notification_fetch` and `prefetch` and call that instead. - **Example (using existing helper):** ```kotlin ScheduleHelper.cancelAllWorkManagerJobs(context) ``` (If `cancelAllWorkManagerJobs` is suspend, call it with `runBlocking { }` or from the same coroutine scope.) **No other plugin changes needed for this fix:** ScheduleHelper already does not enqueue prefetch for static reminders; the only missing step is cancelling **pending** prefetch work when the user schedules or reschedules. **Files to look at (plugin Android):** - `DailyNotificationPlugin.kt` — `scheduleDailyNotification(call)` (add cancel call after cleanup, before ScheduleHelper.scheduleDailyNotification). - `ScheduleHelper` (in same file or separate) — `cancelAllWorkManagerJobs(context)` (already exists; ensure it cancels at least `daily_notification_fetch` and `prefetch`).