feat(notifications): add dev/test 10-minute rollover toggle and plugin spec

- Add dev-only "Use 10-minute rollover (testing)" toggle in Reminder
  Notifications (Account view). Visible only when not on prod API server
  (isNotProdServer). Toggle persists and reschedules reminder with
  rolloverIntervalMinutes when changed.
- Extend daily notification flow to pass optional rolloverIntervalMinutes
  to the plugin: NotificationService/NativeNotificationService options,
  PushNotificationPermission dialog options, first-time and edit flows.
- Add settings: reminderFastRolloverForTesting (Settings, AccountSettings,
  PlatformServiceMixin boolean mapping, migration 007).
- Centralize isNotProdServer(apiServer) in constants/app.ts; use in
  AccountViewView (toggle visibility), ImportAccountView, and TestView.
- Add docs/plugin-spec-rollover-interval-minutes.md for the plugin repo
  (configurable rollover interval and persistence after reboot).

Note: Daily notification plugin dependency is currently pointed at the
"rollover-interval" branch for testing this feature.
This commit is contained in:
Jose Olarte III
2026-03-03 21:31:07 +08:00
parent 96ae89bcfa
commit af63ab70e7
14 changed files with 302 additions and 11 deletions

View File

@@ -0,0 +1,148 @@
# Plugin spec: Configurable rollover interval (e.g. 10 minutes for testing)
**Date:** 2026-03-03
**Target repo:** daily-notification-plugin
**Consuming app:** crowd-funder-for-time-pwa (TimeSafari)
**Platforms:** iOS and Android
## Summary
The consuming app needs to support **rapid testing** of daily notification rollover on device. Today, after a notification fires, the plugin always schedules the next occurrence **24 hours** later. We need an **optional** parameter so the app can request a different interval (e.g. **10 minutes**) for dev/testing. When that parameter is present, the plugin must:
1. Use the given interval (in minutes) when scheduling the **next** occurrence after a notification fires (rollover).
2. **Persist** that interval with the schedule so that it survives **device reboot** and is used again when:
- Boot recovery reschedules alarms from stored data, and
- Any subsequent rollover runs (after the next notification fires).
If the interval is not persisted, then after a device restart the plugin would no longer know to use 10 minutes and would fall back to 24 hours; rapid testing after reboot would break. So persistence is a **required** part of this feature.
---
## API contract (app → plugin)
### Method: `scheduleDailyNotification` (or equivalent used for the apps daily reminder)
**Add an optional parameter:**
- **Name:** `rolloverIntervalMinutes` (or equivalent, e.g. `repeatIntervalMinutes`).
- **Type:** `number` (integer), optional.
- **Meaning:** When the scheduled notification fires, schedule the **next** occurrence this many **minutes** after the current trigger time (instead of 24 hours). When **absent** or not provided, behavior is unchanged: next occurrence is **24 hours** later (current behavior).
**Example (pseudocode):**
- App calls: `scheduleDailyNotification({ id, time, title, body, ..., rolloverIntervalMinutes: 10 })`.
- Plugin stores the schedule **including** `rolloverIntervalMinutes: 10`.
- When the notification fires, plugin computes next trigger = current trigger + 10 minutes (instead of + 24 hours), and schedules that.
- When the device reboots, boot recovery loads the schedule, sees `rolloverIntervalMinutes: 10`, and uses it when (a) computing the next run time for reschedule and (b) any future rollover after the next fire.
**Example (normal production, no param):**
- App calls: `scheduleDailyNotification({ id, time, title, body })` (no `rolloverIntervalMinutes`).
- Plugin stores the schedule with no interval (or default 24h).
- Rollover and boot recovery behave as today: next occurrence 24 hours later.
---
## Persistence requirement (critical for device restart)
The rollover interval must be **stored with the schedule** in the plugins persistent storage (e.g. Room on Android, UserDefaults/DB on iOS), not only kept in memory. Concretely:
1. **When the app calls `scheduleDailyNotification` with `rolloverIntervalMinutes`:**
- Persist that value in the same place you persist the rest of the schedule (e.g. Schedule entity, or equivalent table/row that is read on boot and on rollover).
2. **When computing the next occurrence (rollover path, after a notification fires):**
- Read the stored `rolloverIntervalMinutes` for that schedule.
- If present and > 0: next trigger = current trigger + `rolloverIntervalMinutes` minutes.
- If absent or 0: next trigger = current trigger + 24 hours (existing behavior).
3. **When boot recovery runs (after device restart):**
- Load schedules from persistent storage (including the stored `rolloverIntervalMinutes`).
- When rescheduling each alarm, use the stored interval to compute the next run time (same logic as rollover: if interval is set, use it; otherwise 24 hours).
- When the next notification fires after reboot, the rollover path will again read the same stored value, so the 10-minute (or whatever) interval continues to apply.
4. **When the app calls `scheduleDailyNotification` without `rolloverIntervalMinutes` (or to turn off fast rollover):**
- Overwrite the stored schedule so that the interval is cleared or set to default (24h). Subsequent rollovers and boot recovery then use 24 hours again.
**Why this matters:** Without persisting the interval, a device restart would lose the “10 minutes” setting; rollover and boot recovery would have no way to know to use 10 minutes and would default to 24 hours. Rapid testing after reboot would not work.
---
## Platform-specific notes
### Android
- **Storage:** Add `rollover_interval_minutes` (or equivalent) to the Schedule entity (or wherever the apps reminder schedule is stored) and persist it when handling `scheduleDailyNotification`. Use it in:
- Rollover path (e.g. when scheduling next alarm after notification fires).
- Boot recovery path (when rebuilding alarms from DB after `BOOT_COMPLETED`).
- **Next trigger:** Current trigger time + `rolloverIntervalMinutes` minutes (using `Calendar` or equivalent so DST/timezone is handled correctly; same care as for 24h rollover).
### iOS
- **Storage:** Add the same field to whatever persistent structure holds the schedule (e.g. the same place that stores time, title, body, id). Persist it when the app calls the schedule method.
- **Rollover:** In `scheduleNextNotification()` (or equivalent), read the stored interval; if set, use `Calendar.date(byAdding: .minute, value: rolloverIntervalMinutes, to: currentDate)` (or equivalent) instead of adding 24 hours.
- **App launch / recovery:** If the plugin has any path that restores or reschedules after app launch or system events, use the stored interval there as well so behavior is consistent.
---
## Edge cases and defaults
- **Parameter absent:** Do not change current behavior. Next occurrence = 24 hours later.
- **Parameter = 0 or negative:** Treat as “use default”; same as absent (24 hours).
- **Parameter > 0 (e.g. 10):** Next occurrence = current trigger + that many minutes.
- **Existing schedules (created before this feature):** No stored interval → treat as 24 hours. No migration required beyond “missing field = default”.
---
## App-side behavior (for context)
- The app will only pass `rolloverIntervalMinutes` when a **dev-only** setting is enabled (e.g. “Use 10-minute rollover for testing” in the Reminder Notifications section). Production users will not set it.
- The app will pass it on every `scheduleDailyNotification` call when the user has that setting on (first-time enable and edit). When the user turns the setting off, the app will call `scheduleDailyNotification` without the parameter (so the plugin can persist “no interval” / 24h).
---
## Verification (plugin repo)
1. **Rollover with interval:** Schedule with `rolloverIntervalMinutes: 10`. Trigger the notification (or wait). Confirm the **next** scheduled time is ~10 minutes after the current trigger (not 24 hours). Let it fire again; confirm the following occurrence is again ~10 minutes later.
2. **Persistence:** Schedule with `rolloverIntervalMinutes: 10`, then **restart the device** (do not open the app). After boot, confirm (via logs or next fire) that the rescheduled alarm uses the 10-minute interval (e.g. next fire is 10 minutes after the last stored trigger, not 24 hours). After that notification fires, confirm the **next** rollover is still 10 minutes later.
3. **Default:** Schedule without `rolloverIntervalMinutes`. Confirm next occurrence is 24 hours later. Reboot; confirm boot recovery still uses 24 hours.
4. **Turn off:** Schedule with 10 minutes, then have the app call `scheduleDailyNotification` again with the same id/time but **no** `rolloverIntervalMinutes`. Confirm stored interval is cleared and next rollover is 24 hours.
---
## Short summary for plugin maintainers
- **New optional parameter:** `rolloverIntervalMinutes?: number` on the schedule method used for the apps daily reminder.
- **When set (e.g. 10):** After a notification fires, schedule the next occurrence in that many **minutes** instead of 24 hours.
- **Must persist:** Store the value with the schedule in the plugins DB/storage. Use it in **rollover** and in **boot recovery** so that after a device restart the same interval is used. Without persistence, the feature would not work after reboot.
- **When absent:** Behavior unchanged (24-hour rollover). No migration needed for existing schedules.
---
## For Cursor (plugin repo) — actionable handoff
Use this section when implementing this feature in the **daily-notification-plugin** repo (e.g. with Cursor). You can paste or @-mention this doc as context.
**Goal:** Support an optional `rolloverIntervalMinutes` (or equivalent) on the daily reminder schedule API. When provided (e.g. `10`), schedule the next occurrence that many minutes after the current trigger instead of 24 hours. **Persist this value** with the schedule so that rollover and boot recovery both use it; after a device restart, the same interval must still apply.
**Concrete tasks:**
1. **API:** In the plugin interface used by the app (e.g. `scheduleDailyNotification`), add an optional parameter `rolloverIntervalMinutes?: number`. Document that when absent, next occurrence is 24 hours (current behavior).
2. **Storage (Android):** In the Schedule entity (or equivalent), add a column/field for the rollover interval (e.g. `rollover_interval_minutes` nullable Int). When handling `scheduleDailyNotification`, persist the value if present; if absent, store null or 0 to mean “24 hours”.
3. **Storage (iOS):** Add the same field to the persistent structure that holds the reminder schedule. Persist it when the app calls the schedule method.
4. **Rollover (both platforms):** In the code that runs when a scheduled notification fires and schedules the next occurrence:
- Read the stored `rolloverIntervalMinutes` for that schedule.
- If present and > 0: next trigger = current trigger + that many minutes (using Calendar/date APIs that respect timezone/DST).
- Else: next trigger = current trigger + 24 hours (existing behavior).
- Persist the same interval on the new schedule record so the next rollover still uses it.
5. **Boot recovery (both platforms):** In the path that runs after device reboot and reschedules from stored data:
- Load the stored `rolloverIntervalMinutes` with each schedule.
- When computing “next run time” for reschedule, use the same logic: if interval set, current trigger + that many minutes; else + 24 hours.
- Do not rely on in-memory state; always read from persisted storage so behavior is correct after restart.
6. **Clearing the interval:** When the app calls `scheduleDailyNotification` without `rolloverIntervalMinutes` (e.g. user turned off “fast rollover” in the app), overwrite the stored schedule so the interval field is null/0. Subsequent rollovers and boot recovery then use 24 hours.
7. **Tests:** Add or extend tests for: (a) rollover with 10 minutes, (b) boot recovery with stored 10-minute interval, (c) default 24h when parameter absent or cleared.

6
package-lock.json generated
View File

@@ -38,7 +38,7 @@
"@pvermeer/dexie-encrypted-addon": "^3.0.0",
"@simplewebauthn/browser": "^10.0.0",
"@simplewebauthn/server": "^10.0.0",
"@timesafari/daily-notification-plugin": "git+https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin.git#master",
"@timesafari/daily-notification-plugin": "git+https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin.git#rollover-interval",
"@tweenjs/tween.js": "^21.1.1",
"@types/qrcode": "^1.5.5",
"@veramo/core": "^5.6.0",
@@ -8651,8 +8651,8 @@
}
},
"node_modules/@timesafari/daily-notification-plugin": {
"version": "1.2.1",
"resolved": "git+https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin.git#aa0eaa5389f67709240b88ad5955b2c89a7abf9e",
"version": "1.3.0",
"resolved": "git+https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin.git#27144800706094d292115737de3095a7959e8090",
"license": "MIT",
"workspaces": [
"packages/*"

View File

@@ -151,7 +151,7 @@
"@capacitor/share": "^6.0.3",
"@capacitor/status-bar": "^6.0.2",
"@capawesome/capacitor-file-picker": "^6.2.0",
"@timesafari/daily-notification-plugin": "git+https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin.git#master",
"@timesafari/daily-notification-plugin": "git+https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin.git#rollover-interval",
"@dicebear/collection": "^5.4.1",
"@dicebear/core": "^5.4.1",
"@ethersproject/hdnode": "^5.7.0",

View File

@@ -158,6 +158,8 @@ export default class PushNotificationPermission extends Vue {
pushType = "";
/** When true, dialog only returns time/message to parent; parent does cancel+schedule (avoids double schedule on edit). */
skipScheduleForOpen = false;
/** When set (e.g. 10), passed to plugin for dev/test fast rollover. */
rolloverIntervalMinutesForSchedule: number | undefined = undefined;
serviceWorkerReady = false;
vapidKey = "";
@@ -171,12 +173,13 @@ export default class PushNotificationPermission extends Vue {
async open(
pushType: string,
callback?: (success: boolean, time: string, message?: string) => void,
options?: { skipSchedule?: boolean },
options?: { skipSchedule?: boolean; rolloverIntervalMinutes?: number },
) {
this.callback = callback || this.callback;
this.isVisible = true;
this.pushType = pushType;
this.skipScheduleForOpen = options?.skipSchedule ?? false;
this.rolloverIntervalMinutesForSchedule = options?.rolloverIntervalMinutes;
// Native platforms: Skip web push initialization
if (this.isNativePlatform) {
@@ -805,6 +808,10 @@ export default class PushNotificationPermission extends Vue {
title,
body,
priority: "normal",
...(this.rolloverIntervalMinutesForSchedule != null &&
this.rolloverIntervalMinutesForSchedule > 0
? { rolloverIntervalMinutes: this.rolloverIntervalMinutesForSchedule }
: {}),
});
if (!success) {

View File

@@ -49,6 +49,14 @@ export const DEFAULT_PUSH_SERVER =
export const IMAGE_TYPE_PROFILE = "profile";
/**
* True when the current API server is not production (test/local build).
* Use this to show dev/test-only UI (e.g. 10-minute rollover toggle, test user mnemonic).
*/
export function isNotProdServer(apiServer: string): boolean {
return apiServer !== AppString.PROD_ENDORSER_API_SERVER;
}
export const PASSKEYS_ENABLED =
!!import.meta.env.VITE_PASSKEYS_ENABLED || false;

View File

@@ -213,6 +213,13 @@ const MIGRATIONS = [
CREATE INDEX idx_contact_labels_did ON contact_labels(did);
`,
},
{
name: "007_add_reminderFastRolloverForTesting_to_settings",
sql: `
-- Dev/test only: 10-minute rollover for daily reminder (plugin rolloverIntervalMinutes)
ALTER TABLE settings ADD COLUMN reminderFastRolloverForTesting BOOLEAN DEFAULT FALSE;
`,
},
];
/**

View File

@@ -52,6 +52,8 @@ export type Settings = {
notifyingNewActivityTime?: string; // set to their chosen time if they have turned on daily check for new activity via the push server
notifyingReminderMessage?: string; // set to their chosen message for a daily reminder
notifyingReminderTime?: string; // set to their chosen time for a daily reminder
/** Dev/test only: use 10-minute rollover interval for daily reminder (plugin rolloverIntervalMinutes) */
reminderFastRolloverForTesting?: boolean;
partnerApiServer?: string; // partner server API URL

View File

@@ -33,6 +33,7 @@ export interface AccountSettings {
notifyingNewActivityTime?: string;
notifyingReminderMessage?: string;
notifyingReminderTime?: string;
reminderFastRolloverForTesting?: boolean;
partnerApiServer?: string;
profileImageUrl?: string;
showContactGivesInline?: boolean;

View File

@@ -360,6 +360,7 @@ export class NativeNotificationService implements NotificationServiceInterface {
sound: boolean;
priority: "low" | "default" | "high";
id: string;
rolloverIntervalMinutes?: number;
} = {
time: options.time,
title: options.title,
@@ -367,6 +368,10 @@ export class NativeNotificationService implements NotificationServiceInterface {
sound: true,
priority: (options.priority || "normal") as "low" | "default" | "high",
id: this.reminderId,
...(options.rolloverIntervalMinutes != null &&
options.rolloverIntervalMinutes > 0
? { rolloverIntervalMinutes: options.rolloverIntervalMinutes }
: {}),
};
logger.debug(
"[NativeNotificationService] Calling scheduleDailyNotification with options:",

View File

@@ -42,6 +42,13 @@ export interface DailyNotificationOptions {
* @default 'normal'
*/
priority?: "low" | "normal" | "high";
/**
* Optional rollover interval in minutes (e.g. 10 for testing). When set, next occurrence
* is scheduled this many minutes after the current trigger instead of 24 hours.
* Plugin must support and persist this for it to take effect after reboot.
*/
rolloverIntervalMinutes?: number;
}
/**

View File

@@ -296,6 +296,7 @@ export const PlatformServiceMixin = {
column === "showShortcutBvc" ||
column === "warnIfProdServer" ||
column === "warnIfTestServer" ||
column === "reminderFastRolloverForTesting" ||
// contacts
column === "iViewContent" ||
column === "registered" ||

View File

@@ -143,6 +143,29 @@
</button>
</div>
</div>
<!-- Dev/test only: 10-minute rollover for rapid testing (Reminder Notifications) -->
<label
v-if="isNotProdServer"
class="flex items-center justify-between cursor-pointer mt-3 py-2"
@click="toggleReminderFastRollover"
>
<span class="text-sm text-slate-600">
Use 10-minute rollover (testing)
</span>
<div class="relative ml-2">
<input
:checked="reminderFastRolloverForTesting"
type="checkbox"
class="sr-only"
readonly
@click.stop.prevent
/>
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
<div
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
></div>
</div>
</label>
<div v-if="false" class="mt-4 flex items-center justify-between">
<!-- label -->
<div>
@@ -780,6 +803,7 @@ import {
DEFAULT_PARTNER_API_SERVER,
DEFAULT_PUSH_SERVER,
IMAGE_TYPE_PROFILE,
isNotProdServer as isNotProdServerUtil,
NotificationIface,
PASSKEYS_ENABLED,
} from "../constants/app";
@@ -824,7 +848,7 @@ interface PushNotificationPermissionRef {
open: (
title: string,
callback: (success: boolean, timeText: string, message?: string) => void,
options?: { skipSchedule?: boolean },
options?: { skipSchedule?: boolean; rolloverIntervalMinutes?: number },
) => void;
hourInput?: string;
minuteInput?: string;
@@ -899,6 +923,8 @@ export default class AccountViewView extends Vue {
notifyingReminderTime: string = "";
/** Guard: only one edit-reminder schedule per user action (avoids double schedule on Android). */
editReminderScheduleInProgress: boolean = false;
/** Dev/test only: use 10-minute rollover for daily reminder (plugin rolloverIntervalMinutes). */
reminderFastRolloverForTesting: boolean = false;
subscription: PushSubscription | null = null;
// UI state properties
@@ -934,6 +960,14 @@ export default class AccountViewView extends Vue {
return Capacitor.isNativePlatform();
}
/**
* True when not on prod API server (test/local), so dev-only UI (e.g. 10-minute rollover toggle) is shown.
* Matches logic used in ImportAccountView and TestView.
*/
private get isNotProdServer(): boolean {
return isNotProdServerUtil(this.apiServer);
}
created() {
this.notify = createNotifyHelpers(this.$notify);
@@ -1081,6 +1115,8 @@ export default class AccountViewView extends Vue {
this.notifyingReminder = !!settings.notifyingReminderTime;
this.notifyingReminderMessage = settings.notifyingReminderMessage || "";
this.notifyingReminderTime = settings.notifyingReminderTime || "";
this.reminderFastRolloverForTesting =
!!settings.reminderFastRolloverForTesting;
this.partnerApiServer = settings.partnerApiServer || this.partnerApiServer;
this.partnerApiServerInput =
settings.partnerApiServer || this.partnerApiServerInput;
@@ -1232,6 +1268,11 @@ export default class AccountViewView extends Vue {
this.notifyingReminderTime = timeText;
}
},
{
rolloverIntervalMinutes: this.reminderFastRolloverForTesting
? 10
: undefined,
},
);
} else {
// On native platforms, handle notification cancellation directly
@@ -1323,6 +1364,9 @@ export default class AccountViewView extends Vue {
title,
body,
priority: "normal",
...(this.reminderFastRolloverForTesting
? { rolloverIntervalMinutes: 10 }
: {}),
});
if (scheduleSuccess) {
@@ -1349,7 +1393,12 @@ export default class AccountViewView extends Vue {
this.editReminderScheduleInProgress = false;
}
},
{ skipSchedule: true },
{
skipSchedule: true,
rolloverIntervalMinutes: this.reminderFastRolloverForTesting
? 10
: undefined,
},
);
// Pre-populate the form with current values after dialog opens
@@ -1388,6 +1437,54 @@ export default class AccountViewView extends Vue {
}, 150);
}
/**
* Toggle dev-only 10-minute rollover for daily reminder. Saves the setting and,
* if reminder is already on, reschedules so the plugin uses the new interval.
*/
async toggleReminderFastRollover(): Promise<void> {
const next = !this.reminderFastRolloverForTesting;
await this.$saveSettings({
reminderFastRolloverForTesting: next,
});
this.reminderFastRolloverForTesting = next;
if (this.notifyingReminder) {
try {
const service = NotificationService.getInstance();
if (Capacitor.getPlatform() !== "android") {
await service.cancelDailyNotification();
}
const time24h = this.parseTimeTo24Hour(this.notifyingReminderTime);
const title = "Daily Reminder";
const body =
this.notifyingReminderMessage ||
"Click to share some gratitude with the world -- even if they're unnamed.";
await service.scheduleDailyNotification({
time: time24h,
title,
body,
priority: "normal",
...(next ? { rolloverIntervalMinutes: 10 } : {}),
});
this.notify.success(
next
? "Reminder will repeat every 10 minutes (testing)."
: "Reminder will repeat daily (24h).",
TIMEOUTS.STANDARD,
);
} catch (err) {
logger.error(
"[AccountViewView] Reschedule after fast-rollover toggle failed:",
err,
);
this.notify.error(
"Failed to update reminder interval. Please try again.",
TIMEOUTS.STANDARD,
);
}
}
}
/**
* Parse time string (e.g., "5:22 PM") to 24-hour format (e.g., "17:22")
*/

View File

@@ -95,7 +95,11 @@
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { AppString, NotificationIface } from "../constants/app";
import {
AppString,
isNotProdServer as isNotProdServerUtil,
NotificationIface,
} from "../constants/app";
import { DEFAULT_ROOT_DERIVATION_PATH } from "../libs/crypto";
import {
retrieveAccountCount,
@@ -194,7 +198,7 @@ export default class ImportAccountView extends Vue {
* @returns True if not on production server (enables test utilities)
*/
public isNotProdServer() {
return this.apiServer !== AppString.PROD_ENDORSER_API_SERVER;
return isNotProdServerUtil(this.apiServer);
}
/**

View File

@@ -320,7 +320,11 @@ import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import QuickNav from "../components/QuickNav.vue";
import { AppString, NotificationIface } from "../constants/app";
import {
AppString,
isNotProdServer as isNotProdServerUtil,
NotificationIface,
} from "../constants/app";
import {
NOTIFY_SQL_ERROR,
createSqlErrorMessage,
@@ -741,7 +745,7 @@ export default class Help extends Vue {
* @returns True if not on production server (enables test utilities)
*/
public isNotProdServer() {
return this.apiServer !== AppString.PROD_ENDORSER_API_SERVER;
return isNotProdServerUtil(this.apiServer);
}
async registerMe() {