# Completion Plan: scheduleDualNotification (Plugin + Consuming App) **Purpose:** Checklist of what needs to be done to complete the dual-schedule (New Activity) implementation in the plugin and in the consuming app. For review before making changes. **Status:** Cron parsing, stable dual ID, cancelDualSchedule, and updateDualScheduleConfig are implemented on iOS and Android. The **relationship** (contentTimeout / fallbackBehavior) is implemented: dual config is persisted; on iOS the pending dual notification is updated when the fetch completes; on Android the Worker resolves config + cache at fire time and shows the resolved title/body. **Related:** Consuming app feedback doc `plugin-feedback-ios-scheduleDualNotification.md`; plugin `src/definitions.ts` (`DualScheduleConfiguration`, `scheduleDualNotification`, `cancelDualSchedule`). **For app-side implementation (crowd-funder-for-time-pwa):** All plugin work below is **done**. Use this doc in the app repo to implement app changes. Require plugin **v2.1.0+** (or current local plugin with dual schedule + relationship). Focus on **§2** and the **Consuming app** rows in **§3**. Key tasks: (1) Verify plugin is linked and built so `scheduleDualNotification` is not UNIMPLEMENTED (§2.1); (2) In Edit-time flow, call `updateDualScheduleConfig(buildDualScheduleConfig({ notifyTime }))` when user saves new time, with fallback to `scheduleDualNotification` (§2.4). App paths: `src/views/AccountViewView.vue` (e.g. `editNewActivityNotification()` ~1504–1520), `src/services/notifications/dualScheduleConfig.ts`, `src/services/notifications/NativeNotificationService.ts`. --- ## 0. Context: Two notification types in the consuming app The consuming app (crowd-funder-for-time-pwa) has **two independent notification types**, both using a user-set time from the app DB and firing once a day at that time: | Type | Content source | Plugin API | Cancel API | |------|----------------|------------|------------| | **Daily Reminder** | User-set text from app DB | `scheduleDailyReminder(id, title, body, time, …)` | `cancelDailyReminder({ reminderId })` | | **New Activity** | API-fetched content (native fetcher) | `scheduleDualNotification({ config })` | `cancelDualSchedule()` | - Both can be **on at the same time**; the app turns each on/off and sets its time independently. - **Isolation requirement:** Cancelling one must not affect the other. So: - `cancelDualSchedule()` must cancel **only** the dual schedule (content fetch + user notification for New Activity). It must **not** remove Daily Reminder notifications (iOS uses `reminder_` e.g. `reminder_daily_timesafari_reminder`). - `cancelDailyReminder({ reminderId })` must cancel **only** that reminder; it must **not** cancel the dual content-fetch task or New Activity user notification. The completion plan below assumes this separation. Any new plugin code (e.g. iOS `cancelDualSchedule`, or dual user-notification identifiers) must preserve it. --- ## 1. Plugin (daily-notification-plugin) — iOS ### 1.1 Fix UNIMPLEMENTED (bridge / integration) - **Ensure the native method is actually invoked.** Capacitor returns `UNIMPLEMENTED` when the bridge doesn't call the native handler. In the **consuming app**: - Confirm the app depends on this plugin (or an up-to-date fork) and that `npx cap sync ios` / build includes the plugin's native code. - If they use Capacitor 6, check the [Capacitor 6 plugin registration / UNIMPLEMENTED issues](https://github.com/ionic-team/capacitor-docs/issues/325) and apply any required registration or build fixes so `scheduleDualNotification` is exposed and called. - No code changes are required in the plugin for this; the handler and registration already exist. ### 1.2 Cron parsing (align with Android) - **Replace the stub `calculateNextRunTime(from:)`** in `ios/Plugin/DailyNotificationPlugin.swift` (lines 767–771) with real cron parsing. - **Reference:** Android's `calculateNextRunTime(schedule: String)` in `DailyNotificationPlugin.kt` (lines 2336–2378): supports `"minute hour * * *"`, uses device timezone, returns next occurrence (today or tomorrow). - **Behavior:** For a given cron string (e.g. `"25 18 * * *"`), compute the next run as `Date`/`TimeInterval` and use that for: - `BGAppRefreshTaskRequest.earliestBeginDate` - `UNCalendarNotificationTrigger` (or equivalent) for the user notification so it fires at the correct local time daily. - Right now the implementation ignores the cron and always uses 86400 seconds, so schedules are wrong. ### 1.3 Use `relationship` (contentTimeout + fallbackBehavior) — implemented - **Intent:** When the user notification fires at `userNotification.schedule`, show **API-derived content** if the fetch completed and is within `relationship.contentTimeout`; otherwise show `userNotification.title` / `userNotification.body` (per `fallbackBehavior: "show_default"`). - **Implemented:** Dual config (userNotification + relationship) is persisted when scheduling/updating. On **iOS**, after the content fetch completes in `handleBackgroundFetch`, the plugin replaces the pending dual notification with resolved title/body (from cache if within contentTimeout, else default). On **Android**, when the Worker runs for a `dual_notify_*` schedule, it loads the persisted config and content cache and resolves title/body at fire time, then displays one notification with that content. See **§1.3a** for implementation details (retained for reference). ### 1.3a Implementation plan: relationship (contentTimeout / fallbackBehavior) Implement when ready so the New Activity notification can show API content when the fetch succeeds in time, or default text otherwise. **Prerequisite: persist dual config (both platforms)** When `scheduleDualNotification` or `updateDualScheduleConfig` runs, persist enough of the config for later use: - **userNotification:** `schedule` (cron), `title`, `body` (and any other fields needed to build the notification). - **relationship:** `contentTimeout`, `fallbackBehavior`. So when we later resolve content (after fetch or at fire time), we have the default text and the rules. No new API surface; store what we already receive. - **iOS:** e.g. a single key in UserDefaults (or alongside `native_fetcher_config`), e.g. `dual_schedule_config`, with this structure (e.g. JSON). - **Android:** e.g. SharedPreferences or a keyed config; the code that runs at notification time (or after fetch) must be able to read it. **iOS: update the pending notification when the fetch completes** - When the content fetch runs (e.g. in `handleBackgroundFetch`), we already store the result. After a successful fetch: 1. **Read the persisted dual config.** If none (no dual schedule or legacy flow), skip. 2. **Resolve content:** Load the content just stored (or latest from cache) and its timestamp. If content exists and `(now - contentTimestamp) <= relationship.contentTimeout`, use that title/body; else use `userNotification.title` / `userNotification.body`. 3. **Replace the pending dual notification:** Remove the pending request with identifier `dualNotificationRequestIdentifier`, then add a new `UNNotificationRequest` with the same identifier, the same trigger (recompute from `userNotification.schedule` in stored config), and the resolved title/body. - **Edge cases:** If the fetch completes after the notification time (next trigger already in the past), do not replace. If the fetch fails, leave the existing pending notification as-is (it already has default title/body). **Android: resolve content when the notification is about to fire** - On Android the “notification” is an alarm that fires at notify time and then runs code (e.g. `NotifyReceiver` / `DailyNotificationReceiver`) to display the notification. We cannot change the alarm’s “content” after the fact the same way as on iOS; we decide what to show when the alarm fires. - **Persist dual config** when scheduling (same as above), keyed so the receiver can find it (e.g. by schedule id or a single “current dual config” key). - **When the receiver runs** for a dual schedule (e.g. for `dual_notify_*` or the known dual schedule id): load the persisted dual config, load the latest content from the content cache and its timestamp, apply relationship (use cache if within `contentTimeout`, else default), then show one notification with that resolved title/body. - The receiver must be dual-aware: for dual schedules it resolves title/body from config + cache + relationship instead of using fixed payload from the alarm. **Summary** | Step | iOS | Android | |------|-----|---------| | 1. Persist dual config | Store `userNotification` + `relationship` when scheduling/updating dual (e.g. UserDefaults). | Same; store when scheduling dual (e.g. SharedPreferences), keyed for the receiver. | | 2. Where relationship is applied | In **handleBackgroundFetch** after storing content: resolve cache vs default, then **replace** the pending dual notification (same id, same trigger, new title/body). | In the **receiver** at notify time: load config + cache, resolve cache vs default, then **show** the notification with that title/body. | | 3. Edge cases | Do not replace if next trigger is in the past; if fetch fails, leave existing default notification. | Receiver runs at fire time; “too old” handled by contentTimeout; if no config, fall back to alarm payload. | ### 1.4 Implement and register `cancelDualSchedule()` on iOS - **Current state:** `cancelDualSchedule` is in `definitions.ts` and the web implementation, but there is **no** `@objc func cancelDualSchedule(_ call: CAPPluginCall)` in the iOS plugin and **no** `CAPPluginMethod(name: "cancelDualSchedule", ...)` in the plugin's method list (around 2195–2199). - **Required:** - Add `cancelDualSchedule(_ call: CAPPluginCall)` that: - Cancels the BGAppRefreshTaskRequest for the dual content fetch (e.g. cancel the task with `fetchTaskIdentifier` or the identifier used for the dual schedule). - Cancels **only** the pending user notification(s) created for the dual (New Activity) schedule — e.g. by a **dedicated request identifier** (see below). Must **not** remove Daily Reminder notifications (those use identifier `reminder_` e.g. `reminder_daily_timesafari_reminder`). - When implementing or refactoring the dual user notification in `scheduleUserNotification(config:)` (or the path used by `scheduleDualNotification`), use a **stable, dedicated identifier** for the dual notification (e.g. `"dual_daily_notification"` or `"new_activity"`) so `cancelDualSchedule` can remove only that request. Currently the code uses `"daily-notification-\(Date().timeIntervalSince1970)"`, which is unique per call and not suitable for targeted cancellation. - Append `CAPPluginMethod(name: "cancelDualSchedule", returnType: CAPPluginReturnPromise)` in the same method list. - **Result:** Turning off "New Activity" in the app and calling `DailyNotification.cancelDualSchedule()` will no longer get `UNIMPLEMENTED` and will clear only the dual schedule, leaving Daily Reminder untouched. ### 1.5 Implement `updateDualScheduleConfig` for Edit time (recommended) - **Use case:** The consuming app has an **Edit** button for New Activity that lets the user **change the time** of the notification. That flow is exactly what `updateDualScheduleConfig(config: DualScheduleConfiguration)` is for: "update the existing dual schedule with new config" (same config shape as `scheduleDualNotification`). - **Current app behavior:** Edit is implemented by calling `scheduleNewActivityDualNotification(timeText)` again (i.e. `scheduleDualNotification({ config })` with the new time). That can create duplicate pending notifications if the plugin does not replace the existing dual schedule (e.g. iOS currently uses a unique identifier per call: `daily-notification-`). - **Recommendation:** Implement **`updateDualScheduleConfig`** on iOS (and Android) for clear semantics: "change time" → call `updateDualScheduleConfig(newConfig)`. Implementation can be cancel existing dual schedule then schedule with new config (same as cancel + scheduleDualNotification under the hood). The consuming app should then call `updateDualScheduleConfig(buildDualScheduleConfig({ notifyTime }))` when the user saves a new time from the Edit dialog, instead of (or with fallback to) `scheduleDualNotification`. - **Replace semantics:** Whether or not the app uses `updateDualScheduleConfig`, the plugin must ensure that when a dual schedule already exists and the app calls `scheduleDualNotification` again (e.g. on Edit), the result is **replace** not **add** — no duplicate content-fetch tasks or user notifications. Using a stable dual notification identifier (see 1.4) and replacing the existing request when scheduling achieves this. - **Other optional methods:** `pauseDualSchedule` and `resumeDualSchedule` remain optional; they are in `definitions.ts` but not required for the current app flow. ### 1.6 Android parity - **cancelDualSchedule / updateDualScheduleConfig:** Implemented; Android now exposes both methods and uses `FetchWorker.WORK_NAME_DUAL` so only dual fetch work is cancelled. For **relationship** (contentTimeout / fallbackBehavior), see §1.3a (resolve at fire time in receiver). --- ## 2. Consuming app (crowd-funder-for-time-pwa) ### 2.1 Ensure plugin is linked and built (fix UNIMPLEMENTED) - **Verify** the app's iOS project is using the plugin from this repo (or a release that includes the iOS implementation): - `package.json` dependency points to the right plugin (path, git, or npm). - Run `npx cap sync ios` and confirm `DailyNotificationPlugin` (and its Swift files) are in the app's `ios/App/` or Pods. - Clean build and run on device/simulator; confirm in Xcode that the plugin's `scheduleDualNotification` is registered and that a breakpoint in the Swift handler is hit when turning on New Activity. - If the app is on **Capacitor 6**, follow any documented steps for plugin registration so native methods are not reported as unimplemented. - No change to `buildDualScheduleConfig` or call order is needed; the config shape and sequence (configureNativeFetcher → updateStarredPlans → scheduleDualNotification) already match the plugin's expectations. ### 2.2 Error handling (optional but useful) - **Current:** `AccountViewView.vue` treats `code === "UNIMPLEMENTED"` with a "not yet available on this device" message and any other error as "Could not schedule… try again." - **Improvement:** Once the plugin implements and registers `cancelDualSchedule` on iOS, the app can: - Keep handling `UNIMPLEMENTED` for older builds or platforms where the method is still missing. - Optionally surface more specific errors (e.g. `code === "SCHEDULING_FAILED"` or message strings from the plugin) so the user gets clearer feedback when scheduling fails for a reason other than "not implemented." - No change is strictly required for completion; the current flow is valid. ### 2.3 Turn-off flow - The app already calls `DailyNotification.cancelDualSchedule()` when the user turns off New Activity (with a guard for `DailyNotification?.cancelDualSchedule`). Once the plugin implements and registers `cancelDualSchedule` on iOS (and optionally on Android), this will work without any app code change. ### 2.4 Edit time flow (New Activity) - **Current:** When the user taps **Edit** and picks a new time, the app calls `scheduleNewActivityDualNotification(timeText)` (i.e. `scheduleDualNotification({ config })` again). See `editNewActivityNotification()` in `AccountViewView.vue` (~1504–1520). - **Recommended:** Once the plugin implements `updateDualScheduleConfig` (see 1.5), the app should call **`updateDualScheduleConfig({ config })`** when the user saves a new time from the Edit dialog, with `config = buildDualScheduleConfig({ notifyTime: timeText })`. That makes the intent explicit ("update existing schedule") and avoids relying on replace semantics inside `scheduleDualNotification`. The app can keep a fallback to `scheduleDualNotification` when `updateDualScheduleConfig` is not available (e.g. older plugin version). --- ## 3. Summary table | Where | What | Status / action | |-------|------|------------------| | **Plugin iOS** | `scheduleDualNotification` handler + registration | Done; fix bridge/build in app if still UNIMPLEMENTED. | | **Plugin iOS** | Cron parsing in `calculateNextRunTime(from:)` | Done; real cron parsing (match Android semantics). | | **Plugin iOS** | Use `relationship.contentTimeout` and `fallbackBehavior` | **Done;** persist dual config; in handleBackgroundFetch replace pending notification with resolved content. | | **Plugin iOS** | `cancelDualSchedule()` implementation + registration | Done; cancel BG task + dual user notification only; stable identifier. | | **Plugin iOS** | `updateDualScheduleConfig(config)` | Done; cancel then schedule with new config. | | **Plugin Android** | `cancelDualSchedule()` | Done; cancel dual schedules + WorkManager WORK_NAME_DUAL only. | | **Plugin Android** | `updateDualScheduleConfig(config)` | Done; cancel then schedule with new config. | | **Plugin Android** | Use `relationship` (contentTimeout / fallbackBehavior) | **Done;** persist dual config; in Worker at fire time (dual_notify_*) resolve config + cache and show resolved title/body. | | **Plugin both** | Replace semantics for dual schedule | Done; stable dual identifier, replace before add. | | **Plugin both** | Isolation of Daily Reminder vs New Activity | Done; cancelDualSchedule does not touch reminder_*. | | **Consuming app** | Plugin linked and built for iOS | Verify dependency, `cap sync`, and build so native `scheduleDualNotification` is called. | | **Consuming app** | Edit time: use `updateDualScheduleConfig` | In `editNewActivityNotification()`, call `updateDualScheduleConfig(buildDualScheduleConfig({ notifyTime }))` when user saves new time; fallback to `scheduleDualNotification` if unavailable. | | **Consuming app** | Error handling / UX | Optional: refine messages once plugin returns specific error codes. | --- ## 4. References - **Plugin:** `ios/Plugin/DailyNotificationPlugin.swift` (scheduleDualNotification ~350–379, scheduleBackgroundFetch/scheduleUserNotification ~731–770, calculateNextRunTime ~767–771, method list ~2195–2199), `ios/Plugin/DailyNotificationScheduleHelper.swift` (~98–106), `src/definitions.ts` (DualScheduleConfiguration, cancelDualSchedule). - **Android reference:** `android/.../DailyNotificationPlugin.kt` (scheduleDualNotification ~1369–1420, calculateNextRunTime ~2336–2378). - **Consuming app:** `doc/plugin-feedback-ios-scheduleDualNotification.md`, `src/views/AccountViewView.vue` (~1237–1245, ~1259–1300, ~1501–1548 `editNewActivityNotification`), `src/services/notifications/dualScheduleConfig.ts`, `src/services/notifications/reminderIds.ts` (Daily Reminder vs New Activity IDs), `src/services/notifications/NativeNotificationService.ts` (Daily Reminder uses `scheduleDailyReminder` / `cancelDailyReminder`).