Files
daily-notification-plugin/doc/COMPLETION-PLAN-SCHEDULE-DUAL-NOTIFICATION.md
Jose Olarte III 21ab05d63b docs(completion-plan): add app-side implementation blurb for Cursor
Add "For app-side implementation" paragraph so the completion plan can
be used in the app repo: focus §2/§3, plugin v2.1.0+, link/build check,
Edit flow with updateDualScheduleConfig, and key app file paths.
2026-03-19 14:33:50 +08:00

19 KiB
Raw Permalink Blame History

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() ~15041520), 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_<id> 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 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 767771) with real cron parsing.
  • Reference: Android's calculateNextRunTime(schedule: String) in DailyNotificationPlugin.kt (lines 23362378): 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 alarms “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 21952199).
  • 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_<id> 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.
  • 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-<timestamp>).
  • 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 (~15041520).
  • 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 ~350379, scheduleBackgroundFetch/scheduleUserNotification ~731770, calculateNextRunTime ~767771, method list ~21952199), ios/Plugin/DailyNotificationScheduleHelper.swift (~98106), src/definitions.ts (DualScheduleConfiguration, cancelDualSchedule).
  • Android reference: android/.../DailyNotificationPlugin.kt (scheduleDualNotification ~13691420, calculateNextRunTime ~23362378).
  • Consuming app: doc/plugin-feedback-ios-scheduleDualNotification.md, src/views/AccountViewView.vue (~12371245, ~12591300, ~15011548 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).