forked from trent_larson/crowd-funder-for-time-pwa
- Remove duplicate sync checklists from notification-system-plan.md - Fix markdown table formatting (remove double pipes) in executive summary - Streamline cross-document references and eliminate redundant content - Consolidate canonical ownership statements across all three docs - Improve document structure and readability per directive guidelines Files modified: - doc/notification-system-executive-summary.md - doc/notification-system-implementation.md - doc/notification-system-plan.md Compliance: Follows @docs/documentation_references_model_agents.mdc directive for eliminating redundancy, centralizing context, and optimizing reference placement.
2315 lines
81 KiB
Markdown
2315 lines
81 KiB
Markdown
# TimeSafari Notification System — Implementation Guide
|
||
|
||
**Status:** 🚀 Active implementation
|
||
**Date:** 2025-09-05T05:09Z (UTC)
|
||
**Author:** Matthew Raymer
|
||
**Scope:** Detailed implementation for v1 (in‑app orchestrator)
|
||
**Goal:** Complete implementation guide with code, database schemas, and integration specifics.
|
||
|
||
> **Strategic Overview:** See `notification-system-plan.md` for high-level strategy, architecture, and planning details.
|
||
> **Canonical Ownership:** This document owns API definitions, Database schemas, Adapter implementations, and Code examples.
|
||
|
||
---
|
||
|
||
## 1) Dependencies & Setup
|
||
|
||
### Package Dependencies
|
||
|
||
```bash
|
||
npm i @capacitor/local-notifications
|
||
npx cap sync
|
||
```
|
||
|
||
### Capacitor Configuration
|
||
|
||
```typescript
|
||
// capacitor.config.ts
|
||
plugins: {
|
||
LocalNotifications: {
|
||
smallIcon: 'ic_stat_name',
|
||
iconColor: '#4a90e2'
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 2) Database Integration
|
||
|
||
### Migration Integration
|
||
|
||
Add to existing `src/db-sql/migration.ts` MIGRATIONS array:
|
||
|
||
```typescript
|
||
{
|
||
name: "003_notification_system",
|
||
sql: `
|
||
CREATE TABLE IF NOT EXISTS notif_contents(
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
slot_id TEXT NOT NULL,
|
||
payload_json TEXT NOT NULL,
|
||
fetched_at INTEGER NOT NULL,
|
||
etag TEXT,
|
||
UNIQUE(slot_id, fetched_at)
|
||
);
|
||
CREATE INDEX IF NOT EXISTS notif_idx_contents_slot_time
|
||
ON notif_contents(slot_id, fetched_at DESC);
|
||
|
||
CREATE TABLE IF NOT EXISTS notif_deliveries(
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
slot_id TEXT NOT NULL,
|
||
fire_at INTEGER NOT NULL,
|
||
delivered_at INTEGER,
|
||
status TEXT NOT NULL, -- scheduled|shown|canceled|error
|
||
error_code TEXT, error_message TEXT
|
||
);
|
||
|
||
-- Prevent duplicate scheduled deliveries
|
||
CREATE UNIQUE INDEX IF NOT EXISTS notif_uq_scheduled
|
||
ON notif_deliveries(slot_id, fire_at, status)
|
||
WHERE status='scheduled';
|
||
|
||
CREATE TABLE IF NOT EXISTS notif_config(
|
||
k TEXT PRIMARY KEY,
|
||
v TEXT NOT NULL
|
||
);
|
||
`,
|
||
}
|
||
```
|
||
|
||
**Platform-Specific Database Backends:**
|
||
|
||
- **Web (`VITE_PLATFORM=web`)**: Uses Absurd SQL (SQLite via IndexedDB) via
|
||
`WebPlatformService` with worker pattern - migration runs in Absurd SQL
|
||
context
|
||
- **Capacitor (`VITE_PLATFORM=capacitor`)**: Uses native SQLite via
|
||
`CapacitorPlatformService` - migration runs in native SQLite context
|
||
- **Electron (`VITE_PLATFORM=electron`)**: Uses native SQLite via
|
||
`ElectronPlatformService` (extends CapacitorPlatformService) - same as Capacitor
|
||
|
||
**Database Compatibility Layer:**
|
||
|
||
The notification system leverages TimeSafari's existing database abstraction
|
||
layer that provides SQLite compatibility across all platforms:
|
||
|
||
- **Web Platform:** Uses `@absurd-sql` to provide SQLite-compatible API over IndexedDB
|
||
- **Mobile/Desktop:** Uses native SQLite via Capacitor SQLite plugin
|
||
- **Unified Interface:** Both backends implement the same SQLite API,
|
||
ensuring consistent notification data storage
|
||
|
||
This compatibility layer allows the notification system to use identical
|
||
SQLite schemas and queries across all platforms, with the underlying
|
||
storage mechanism abstracted away by the platform services.
|
||
|
||
**Retention:** We will keep ~14 days of contents/deliveries and prune daily.
|
||
|
||
### Settings Extension
|
||
|
||
Extend `src/db/tables/settings.ts`:
|
||
|
||
```typescript
|
||
export type Settings = {
|
||
// ... existing fields ...
|
||
|
||
// Multi-daily notification settings (following TimeSafari pattern)
|
||
notifTimes?: string; // JSON string in DB: Array<{ hour: number; minute: number }>
|
||
notifTtlSeconds?: number;
|
||
notifPrefetchLeadMinutes?: number;
|
||
notifContentTemplate?: string; // JSON string in DB: { title: string; body: string }
|
||
notifCallbackProfile?: string; // JSON string in DB: CallbackProfile
|
||
notifEnabled?: boolean;
|
||
notifMode?: string; // JSON string in DB: 'online-first' | 'offline-first' | 'auto'
|
||
};
|
||
```
|
||
|
||
**Note:** The `notifEnabled` boolean field must be added to
|
||
`PlatformServiceMixin._mapColumnsToValues` for proper SQLite integer-to-boolean
|
||
conversion:
|
||
|
||
```typescript
|
||
// In _mapColumnsToValues method, add:
|
||
column === "notifEnabled" ||
|
||
```
|
||
|
||
---
|
||
|
||
## 3) Public API (Shared)
|
||
|
||
```typescript
|
||
export type NotificationTime = { hour: number; minute: number }; // local wall-clock
|
||
export type SlotId = string; // Format: "HHmm" (e.g., "0800", "1200", "1800") - stable across TZ changes
|
||
|
||
export type FetchSpec = {
|
||
method: 'GET'|'POST';
|
||
url: string;
|
||
headers?: Record<string,string>;
|
||
bodyJson?: Record<string,unknown>;
|
||
timeoutMs?: number;
|
||
};
|
||
|
||
export type CallbackProfile = {
|
||
fetchContent: FetchSpec;
|
||
ackDelivery?: Omit<FetchSpec,'bodyJson'|'timeoutMs'>;
|
||
reportError?: Omit<FetchSpec,'bodyJson'|'timeoutMs'>;
|
||
heartbeat?: Omit<FetchSpec,'bodyJson'> & { intervalMinutes?: number };
|
||
};
|
||
|
||
export type ConfigureOptions = {
|
||
times: NotificationTime[]; // 1..M daily
|
||
timezone?: string; // Default: system timezone
|
||
ttlSeconds?: number; // Default: 86400 (24h)
|
||
prefetchLeadMinutes?: number; // Default: 20
|
||
storage: 'shared'|'private'; // Required
|
||
contentTemplate: { title: string; body: string }; // Required
|
||
callbackProfile?: CallbackProfile; // Optional
|
||
};
|
||
|
||
interface MultiDailyNotification {
|
||
configure(opts: ConfigureOptions): Promise<void>;
|
||
getState(): Promise<{
|
||
enabled: boolean;
|
||
slots: SlotId[];
|
||
lastFetchAt?: number;
|
||
lastDeliveryAt?: number;
|
||
exactAlarmCapable: boolean;
|
||
}>;
|
||
runFullPipelineNow(): Promise<void>;
|
||
reschedule(): Promise<void>;
|
||
}
|
||
```
|
||
|
||
**Storage semantics:** `'shared'` = app DB; `'private'` = plugin-owned/native DB
|
||
(v2). (No functional difference in v1.)
|
||
|
||
**Compliance note:** We will expose `lastFetchAt`, `lastDeliveryAt`, and
|
||
`exactAlarmCapable` as specified in the `getState()` method.
|
||
|
||
```typescript
|
||
export interface MultiDailyNotification {
|
||
requestPermissions(): Promise<void>;
|
||
configure(o: ConfigureOptions): Promise<void>;
|
||
runFullPipelineNow(): Promise<void>; // API→DB→Schedule (today's remaining)
|
||
deliverStoredNow(slotId?: SlotId): Promise<void>; // 60s cooldown guard
|
||
reschedule(): Promise<void>;
|
||
getState(): Promise<{
|
||
nextOccurrences: Array<{ slotId: SlotId; when: string }>; // ISO
|
||
lastFetchAt?: string; lastDeliveryAt?: string;
|
||
pendingCount: number; exactAlarmCapable?: boolean;
|
||
}>;
|
||
}
|
||
|
||
```
|
||
|
||
> **Storage semantics:** `'shared'` = app DB; `'private'` = plugin-owned/native
|
||
> DB (v2). (No functional difference in v1.)
|
||
> **Slot Identity & Scheduling Policy**
|
||
> • **SlotId** uses canonical `HHmm` and remains stable across timezone changes.
|
||
> • **Lead window:** default `prefetchLeadMinutes = 20`; no retries once inside
|
||
the lead.
|
||
> • **TTL policy:** When offline and content is beyond TTL, **we will skip** the
|
||
notification (no "(cached)" suffix).
|
||
> • **Idempotency:** Duplicate "scheduled" deliveries are prevented by a unique
|
||
index on `(slot_id, fire_at, status='scheduled')`.
|
||
> • **Time handling:** Slots will follow **local wall-clock** time across TZ/DST;
|
||
`slotId=HHmm` stays constant and we will **recompute fire times** on offset change.
|
||
|
||
---
|
||
|
||
## 4) Internal Interfaces
|
||
|
||
### Error Taxonomy
|
||
|
||
```typescript
|
||
// src/services/notifications/types.ts
|
||
export type NotificationErrorCode =
|
||
| 'FETCH_TIMEOUT' // Network request exceeded timeout
|
||
| 'ETAG_NOT_MODIFIED' // Server returned 304 (expected)
|
||
| 'SCHEDULE_DENIED' // OS denied notification scheduling
|
||
| 'EXACT_ALARM_MISSING' // Android exact alarm permission absent
|
||
| 'STORAGE_BUSY' // Database locked or unavailable
|
||
| 'TEMPLATE_MISSING_TOKEN' // Required template variable not found
|
||
| 'PERMISSION_DENIED'; // User denied notification permissions
|
||
|
||
export type EventLogEnvelope = {
|
||
code: string; // Error code from taxonomy
|
||
slotId: string; // Affected slot
|
||
whenMs: number; // Scheduled time
|
||
attempt: number; // Retry attempt (1-based)
|
||
networkState: string; // 'online' | 'offline'
|
||
tzOffset: number; // Current timezone offset
|
||
appState: string; // 'foreground' | 'background' | 'killed'
|
||
timestamp: number; // UTC timestamp
|
||
};
|
||
|
||
export type AckPayload = {
|
||
slotId: string;
|
||
fireAt: number; // Scheduled time
|
||
deliveredAt: number; // Actual delivery time
|
||
deviceTz: string; // Device timezone
|
||
appVersion: string; // App version
|
||
buildId: string; // Build identifier
|
||
};
|
||
```
|
||
|
||
### Internal Service Interfaces
|
||
|
||
```typescript
|
||
export interface DataStore {
|
||
saveContent(slotId: SlotId, payload: unknown, etag?: string): Promise<void>;
|
||
getLatestContent(slotId: SlotId): Promise<{
|
||
payload: unknown;
|
||
fetchedAt: number;
|
||
etag?: string
|
||
}|null>;
|
||
recordDelivery(
|
||
slotId: SlotId,
|
||
fireAt: number,
|
||
status: 'scheduled'|'shown'|'error',
|
||
error?: { code?: string; message?: string }
|
||
): Promise<void>;
|
||
enqueueEvent(e: unknown): Promise<void>;
|
||
drainEvents(): Promise<unknown[]>;
|
||
setConfig?(k: string, v: unknown): Promise<void>;
|
||
getConfig?(k: string): Promise<unknown | null>;
|
||
getLastFetchAt?(): Promise<number | undefined>;
|
||
getLastDeliveryAt?(): Promise<number | undefined>;
|
||
}
|
||
|
||
export type ScheduleRequest = {
|
||
slotId: SlotId;
|
||
whenMs: number;
|
||
title: string;
|
||
body: string;
|
||
extra?: Record<string, unknown>; // { payloadRef, etag, windowLenMs, ... }
|
||
};
|
||
|
||
export interface SchedulerCapabilities {
|
||
exactAlarms: boolean;
|
||
maxPending?: number;
|
||
networkWake?: 'none' | 'opportunistic'; // v1 mobile = 'none' or 'opportunistic'
|
||
}
|
||
|
||
export interface Scheduler {
|
||
capabilities(): Promise<SchedulerCapabilities>;
|
||
scheduleExact(req: ScheduleRequest): Promise<void>;
|
||
scheduleWindow(req: ScheduleRequest & { windowLenMs: number }): Promise<void>;
|
||
cancelBySlot(slotId: SlotId): Promise<void>;
|
||
rescheduleAll(next: ScheduleRequest[]): Promise<void>;
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 5) Template Engine Contract
|
||
|
||
### Supported Tokens & Escaping
|
||
|
||
```typescript
|
||
// src/services/notifications/TemplateEngine.ts
|
||
export class TemplateEngine {
|
||
private static readonly SUPPORTED_TOKENS = {
|
||
'headline': 'Main content headline',
|
||
'summary': 'Content summary text',
|
||
'date': 'Formatted date (YYYY-MM-DD)',
|
||
'time': 'Formatted time (HH:MM)'
|
||
};
|
||
|
||
private static readonly LENGTH_LIMITS = {
|
||
title: 50, // chars
|
||
body: 200 // chars
|
||
};
|
||
|
||
static render(template: { title: string; body: string }, data: Record<string, string>): { title: string; body: string } {
|
||
return {
|
||
title: this.renderTemplate(template.title, data, this.LENGTH_LIMITS.title),
|
||
body: this.renderTemplate(template.body, data, this.LENGTH_LIMITS.body)
|
||
};
|
||
}
|
||
|
||
private static renderTemplate(template: string, data: Record<string, string>, maxLength: number): string {
|
||
let result = template;
|
||
|
||
// Replace tokens with data or fallback
|
||
for (const [token, fallback] of Object.entries(this.SUPPORTED_TOKENS)) {
|
||
const regex = new RegExp(`{{${token}}}`, 'g');
|
||
const value = data[token] || '[Content]';
|
||
result = result.replace(regex, value);
|
||
}
|
||
|
||
// Truncate if needed (before escaping to avoid splitting entities)
|
||
if (result.length > maxLength) {
|
||
result = result.substring(0, maxLength - 3) + '...';
|
||
}
|
||
|
||
return this.escapeHtml(result);
|
||
}
|
||
|
||
private static escapeHtml(text: string): string {
|
||
return text
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 6) PlatformServiceMixin Integration
|
||
|
||
### Extended Interface
|
||
|
||
```typescript
|
||
// Add to src/utils/PlatformServiceMixin.ts IPlatformServiceMixin
|
||
export interface IPlatformServiceMixin {
|
||
// ... existing methods ...
|
||
|
||
// Notification-specific methods
|
||
$saveNotifContent(slotId: string, payload: unknown, etag?: string): Promise<boolean>;
|
||
$getNotifContent(slotId: string): Promise<{ payload: unknown; fetchedAt: number; etag?: string } | null>;
|
||
$recordNotifDelivery(slotId: string, fireAt: number, status: string, error?: { code?: string; message?: string }): Promise<boolean>;
|
||
$getNotifSettings(): Promise<NotificationSettings | null>;
|
||
$saveNotifSettings(settings: Partial<NotificationSettings>): Promise<boolean>;
|
||
$pruneNotifData(daysToKeep?: number): Promise<void>;
|
||
}
|
||
```
|
||
|
||
### Implementation Methods
|
||
|
||
```typescript
|
||
// Add to PlatformServiceMixin methods object
|
||
async $saveNotifContent(slotId: string, payload: unknown, etag?: string): Promise<boolean> {
|
||
try {
|
||
const fetchedAt = Date.now();
|
||
const payloadJson = JSON.stringify(payload);
|
||
|
||
await this.$exec(
|
||
`INSERT OR REPLACE INTO notif_contents (slot_id, payload_json, fetched_at, etag)
|
||
VALUES (?, ?, ?, ?)`,
|
||
[slotId, payloadJson, fetchedAt, etag]
|
||
);
|
||
return true;
|
||
} catch (error) {
|
||
logger.error('[PlatformServiceMixin] Failed to save notification content:', error);
|
||
return false;
|
||
}
|
||
},
|
||
|
||
async $getNotifContent(slotId: string): Promise<{ payload: unknown; fetchedAt: number; etag?: string } | null> {
|
||
try {
|
||
const result = await this.$db(
|
||
`SELECT payload_json, fetched_at, etag
|
||
FROM notif_contents
|
||
WHERE slot_id = ?
|
||
ORDER BY fetched_at DESC
|
||
LIMIT 1`,
|
||
[slotId]
|
||
);
|
||
|
||
if (!result || result.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
const row = result[0];
|
||
return {
|
||
payload: JSON.parse(row.payload_json),
|
||
fetchedAt: row.fetched_at,
|
||
etag: row.etag
|
||
};
|
||
} catch (error) {
|
||
logger.error('[PlatformServiceMixin] Failed to get notification content:', error);
|
||
return null;
|
||
}
|
||
},
|
||
|
||
async $recordNotifDelivery(slotId: string, fireAt: number, status: string, error?: { code?: string; message?: string }): Promise<boolean> {
|
||
try {
|
||
const deliveredAt = status === 'shown' ? Date.now() : null;
|
||
|
||
await this.$exec(
|
||
`INSERT INTO notif_deliveries (slot_id, fire_at, delivered_at, status, error_code, error_message)
|
||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||
[slotId, fireAt, deliveredAt, status, error?.code, error?.message]
|
||
);
|
||
return true;
|
||
} catch (error) {
|
||
// Handle duplicate schedule constraint as idempotent (no-op)
|
||
if (error instanceof Error && error.message.includes('UNIQUE constraint failed')) {
|
||
logger.debug('[PlatformServiceMixin] Duplicate delivery record for', slotId, 'at', fireAt, '- treating as idempotent');
|
||
return true;
|
||
}
|
||
logger.error('[PlatformServiceMixin] Failed to record notification delivery', error);
|
||
return false;
|
||
}
|
||
},
|
||
|
||
async $pruneNotifData(daysToKeep: number = 14): Promise<void> {
|
||
try {
|
||
const cutoffMs = Date.now() - (daysToKeep * 24 * 60 * 60 * 1000);
|
||
|
||
// Prune old content
|
||
await this.$exec(
|
||
`DELETE FROM notif_contents WHERE fetched_at < ?`,
|
||
[cutoffMs]
|
||
);
|
||
|
||
// Prune old deliveries
|
||
await this.$exec(
|
||
`DELETE FROM notif_deliveries WHERE fire_at < ?`,
|
||
[cutoffMs]
|
||
);
|
||
|
||
logger.debug('[PlatformServiceMixin] Pruned notification data older than', daysToKeep, 'days');
|
||
// Log with safe stringify for complex objects
|
||
logger.debug('[PlatformServiceMixin] Prune details:', safeStringify({
|
||
daysToKeep,
|
||
cutoffMs,
|
||
timestamp: new Date().toISOString()
|
||
}));
|
||
|
||
// We will avoid VACUUM in v1 to prevent churn; optional maintenance can be added behind a flag.
|
||
} catch (error) {
|
||
logger.error('[PlatformServiceMixin] Failed to prune notification data', error);
|
||
}
|
||
},
|
||
```
|
||
|
||
---
|
||
|
||
## 7) Adapter Implementations
|
||
|
||
### DataStoreSqlite Adapter
|
||
|
||
```typescript
|
||
// src/services/notifications/adapters/DataStoreSqlite.ts
|
||
import type { DataStore, SlotId } from '../types';
|
||
import { PlatformServiceMixin } from '@/utils/PlatformServiceMixin';
|
||
|
||
export class DataStoreSqlite implements DataStore {
|
||
constructor(private platformService: PlatformServiceMixin) {}
|
||
|
||
async saveContent(slotId: SlotId, payload: unknown, etag?: string): Promise<void> {
|
||
await this.platformService.$saveNotifContent(slotId, payload, etag);
|
||
}
|
||
|
||
async getLatestContent(slotId: SlotId): Promise<{
|
||
payload: unknown;
|
||
fetchedAt: number;
|
||
etag?: string
|
||
}|null> {
|
||
return await this.platformService.$getNotifContent(slotId);
|
||
}
|
||
|
||
async recordDelivery(
|
||
slotId: SlotId,
|
||
fireAt: number,
|
||
status: 'scheduled'|'shown'|'error',
|
||
error?: { code?: string; message?: string }
|
||
): Promise<void> {
|
||
await this.platformService.$recordNotifDelivery(slotId, fireAt, status, error);
|
||
}
|
||
|
||
async enqueueEvent(e: unknown): Promise<void> {
|
||
// v1: Simple in-memory queue for now, can be enhanced with SQLite in v2
|
||
// This will be drained when app comes to foreground
|
||
}
|
||
|
||
async drainEvents(): Promise<unknown[]> {
|
||
// v1: Return and clear queued events from in-memory queue
|
||
// v2: Will migrate to SQLite-backed queue for persistence
|
||
return [];
|
||
}
|
||
|
||
async setConfig(k: string, v: unknown): Promise<void> {
|
||
await this.platformService.$setNotifConfig(k, v);
|
||
}
|
||
|
||
async getConfig(k: string): Promise<unknown | null> {
|
||
try {
|
||
const result = await this.platformService.$db(
|
||
`SELECT v FROM notif_config WHERE k = ?`,
|
||
[k]
|
||
);
|
||
return result && result.length > 0 ? JSON.parse(result[0].v) : null;
|
||
} catch (error) {
|
||
logger.error('[DataStoreSqlite] Failed to get config:', error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async getLastFetchAt(): Promise<number | undefined> {
|
||
try {
|
||
const result = await this.platformService.$one(
|
||
`SELECT MAX(fetched_at) as last_fetch FROM notif_contents`
|
||
);
|
||
return result?.last_fetch;
|
||
} catch (error) {
|
||
logger.error('[DataStoreSqlite] Failed to get last fetch time:', error);
|
||
return undefined;
|
||
}
|
||
}
|
||
|
||
async getLastDeliveryAt(): Promise<number | undefined> {
|
||
try {
|
||
const result = await this.platformService.$one(
|
||
`SELECT MAX(fire_at) as last_delivery FROM notif_deliveries WHERE status = 'shown'`
|
||
);
|
||
return result?.last_delivery;
|
||
} catch (error) {
|
||
logger.error('[DataStoreSqlite] Failed to get last delivery time:', error);
|
||
return undefined;
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### SchedulerElectron Adapter
|
||
|
||
```typescript
|
||
// src/services/notifications/adapters/SchedulerElectron.ts
|
||
import type { Scheduler, SlotId, SchedulerCapabilities } from '../types';
|
||
import { Notification, app } from 'electron';
|
||
import { logger, safeStringify } from '@/utils/logger';
|
||
|
||
export class SchedulerElectron implements Scheduler {
|
||
async capabilities(): Promise<SchedulerCapabilities> {
|
||
// Electron timers + OS delivery while app runs; no exact-alarm guarantees.
|
||
return { exactAlarms: false, maxPending: 128, networkWake: 'opportunistic' };
|
||
}
|
||
|
||
async scheduleExact(req: ScheduleRequest): Promise<void> {
|
||
const delay = Math.max(0, req.whenMs - Date.now());
|
||
setTimeout(() => {
|
||
try {
|
||
const n = new Notification({
|
||
title: req.title,
|
||
body: req.body,
|
||
// Electron Notification supports .actions on some OSes;
|
||
// keep minimal now for parity with v1 locals.
|
||
silent: false
|
||
});
|
||
// n.on('click', ...) → open route if desired
|
||
n.show();
|
||
logger.debug('[SchedulerElectron] Notification shown for', req.slotId);
|
||
// Log with safe stringify for complex objects
|
||
logger.debug('[SchedulerElectron] Notification details:', safeStringify({
|
||
slotId: req.slotId,
|
||
timestamp: new Date().toISOString(),
|
||
platform: 'electron'
|
||
}));
|
||
} catch (e) {
|
||
logger.error('[SchedulerElectron] show failed', e);
|
||
throw e;
|
||
}
|
||
}, delay);
|
||
}
|
||
|
||
async scheduleWindow(req: ScheduleRequest & { windowLenMs: number }): Promise<void> {
|
||
// v1 emulates "window" by scheduling at window start; OS may delay delivery.
|
||
return this.scheduleExact(req);
|
||
}
|
||
|
||
async cancelBySlot(_slotId: SlotId): Promise<void> {
|
||
// Electron Notification has no pending queue to cancel; v1: no-op.
|
||
// v2: a native helper could manage real queues per OS.
|
||
}
|
||
|
||
async rescheduleAll(next: Array<{ slotId: SlotId; whenMs: number }>): Promise<void> {
|
||
// Clear any in-process timers if you track them; then re-arm:
|
||
for (const { slotId, whenMs } of next) {
|
||
await this.scheduleExact(slotId, whenMs, `${slotId}:${whenMs}`);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### SchedulerCapacitor Adapter
|
||
|
||
```typescript
|
||
// src/services/notifications/adapters/SchedulerCapacitor.ts
|
||
import { LocalNotifications } from '@capacitor/local-notifications';
|
||
import type { Scheduler, SlotId, SchedulerCapabilities } from '../types';
|
||
import { logger, safeStringify } from '@/utils/logger';
|
||
|
||
export class SchedulerCapacitor implements Scheduler {
|
||
async capabilities(): Promise<SchedulerCapabilities> {
|
||
// Conservative default; exact permission detection will be native in v2.
|
||
return { exactAlarms: false, maxPending: 64, networkWake: 'none' };
|
||
}
|
||
|
||
async scheduleExact(req: ScheduleRequest): Promise<void> {
|
||
try {
|
||
await LocalNotifications.schedule({
|
||
notifications: [{
|
||
id: this.generateNotificationId(req.slotId, req.whenMs),
|
||
title: req.title,
|
||
body: req.body,
|
||
schedule: { at: new Date(req.whenMs) },
|
||
extra: { slotId: req.slotId, ...req.extra }
|
||
}]
|
||
});
|
||
|
||
logger.debug('[SchedulerCapacitor] Scheduled notification for slot', req.slotId, 'at', new Date(req.whenMs).toISOString());
|
||
// Log with safe stringify for complex objects
|
||
logger.debug('[SchedulerCapacitor] Notification details:', safeStringify({
|
||
slotId: req.slotId,
|
||
whenMs: req.whenMs,
|
||
scheduledAt: new Date(req.whenMs).toISOString(),
|
||
platform: 'capacitor'
|
||
}));
|
||
} catch (error) {
|
||
logger.error('[SchedulerCapacitor] Failed to schedule notification for slot', req.slotId, error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async scheduleWindow(req: ScheduleRequest & { windowLenMs: number }): Promise<void> {
|
||
try {
|
||
// For platforms that don't support exact alarms
|
||
// Note: v1 schedules at window start since Capacitor doesn't expose true window behavior
|
||
// True "window" scheduling is a v2 responsibility
|
||
// v1 emulates windowed behavior by scheduling at window start; actual OS batching may delay delivery
|
||
await LocalNotifications.schedule({
|
||
notifications: [{
|
||
id: this.generateNotificationId(req.slotId, req.whenMs),
|
||
title: req.title,
|
||
body: req.body,
|
||
schedule: {
|
||
at: new Date(req.whenMs),
|
||
repeats: false
|
||
},
|
||
extra: { slotId: req.slotId, windowLenMs: req.windowLenMs, ...req.extra } // Carry window length for telemetry
|
||
}]
|
||
});
|
||
|
||
logger.debug('[SchedulerCapacitor] Scheduled windowed notification for slot', req.slotId, 'at window start');
|
||
} catch (error) {
|
||
logger.error('[SchedulerCapacitor] Failed to schedule windowed notification for slot', req.slotId, error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async cancelBySlot(slotId: SlotId): Promise<void> {
|
||
try {
|
||
// Get all pending notifications and cancel those matching the slotId
|
||
const pending = await LocalNotifications.getPending();
|
||
if (pending?.notifications?.length) {
|
||
const matchingIds = pending.notifications
|
||
.filter(n => n.extra?.slotId === slotId)
|
||
.map(n => ({ id: n.id }));
|
||
|
||
if (matchingIds.length > 0) {
|
||
await LocalNotifications.cancel({ notifications: matchingIds });
|
||
logger.debug('[SchedulerCapacitor] Cancelled', matchingIds.length, 'notifications for slot', slotId);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
logger.error('[SchedulerCapacitor] Failed to cancel notification for slot', slotId, error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async rescheduleAll(next: Array<{ slotId: SlotId; whenMs: number }>): Promise<void> {
|
||
try {
|
||
// Cancel all pending first
|
||
const pending = await LocalNotifications.getPending();
|
||
if (pending?.notifications?.length) {
|
||
await LocalNotifications.cancel({
|
||
notifications: pending.notifications.map(n => ({ id: n.id }))
|
||
});
|
||
}
|
||
|
||
// Schedule new set
|
||
await LocalNotifications.schedule({
|
||
notifications: next.map(({ slotId, whenMs }) => ({
|
||
id: this.generateNotificationId(slotId, whenMs),
|
||
title: 'TimeSafari',
|
||
body: 'Your daily update is ready',
|
||
schedule: { at: new Date(whenMs) },
|
||
extra: { slotId, whenMs }
|
||
}))
|
||
});
|
||
|
||
logger.debug('[SchedulerCapacitor] Rescheduled', next.length, 'notifications');
|
||
} catch (error) {
|
||
logger.error('[SchedulerCapacitor] Failed to reschedule notifications', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
private generateNotificationId(slotId: SlotId, whenMs: number): number {
|
||
// 32-bit FNV-1a like hash
|
||
let hash = 0x811c9dc5;
|
||
const s = `${slotId}-${whenMs}`;
|
||
for (let i = 0; i < s.length; i++) {
|
||
hash ^= s.charCodeAt(i);
|
||
hash = (hash >>> 0) * 0x01000193 >>> 0;
|
||
}
|
||
return Math.abs(hash | 0);
|
||
}
|
||
}
|
||
```
|
||
|
||
### CallbacksHttp Adapter
|
||
|
||
```typescript
|
||
// src/services/notifications/adapters/CallbacksHttp.ts
|
||
import type { CallbackProfile } from '../types';
|
||
import { handleApiError } from '@/utils/errorHandler';
|
||
import { logger, safeStringify } from '@/utils/logger';
|
||
|
||
export class CallbacksHttp {
|
||
constructor(private profile: CallbackProfile) {}
|
||
|
||
async fetchContent(slotId: string, etag?: string): Promise<{
|
||
payload: unknown;
|
||
etag?: string
|
||
}|null> {
|
||
try {
|
||
const spec = this.profile.fetchContent;
|
||
const ac = new AbortController();
|
||
const to = setTimeout(() => ac.abort(), spec.timeoutMs ?? 12000);
|
||
|
||
try {
|
||
const headers = { ...spec.headers };
|
||
if (etag) {
|
||
headers['If-None-Match'] = etag;
|
||
}
|
||
|
||
const response = await fetch(spec.url, {
|
||
method: spec.method,
|
||
headers,
|
||
body: spec.bodyJson ? JSON.stringify(spec.bodyJson) : undefined,
|
||
signal: ac.signal
|
||
});
|
||
clearTimeout(to);
|
||
|
||
if (response.status === 304) return null;
|
||
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||
|
||
const payload = await response.json();
|
||
const etag = response.headers.get('etag') || undefined;
|
||
return { payload, etag };
|
||
} catch (err) {
|
||
clearTimeout(to);
|
||
throw err;
|
||
}
|
||
} catch (error) {
|
||
logger.error('[CallbacksHttp] Failed to fetch content for slot', slotId, error);
|
||
const enhancedError = handleApiError(error, {
|
||
component: 'CallbacksHttp',
|
||
operation: 'fetchContent',
|
||
timestamp: new Date().toISOString(),
|
||
slotId,
|
||
etag
|
||
}, 'fetchContent');
|
||
|
||
// Log enhanced error details for debugging
|
||
logger.error('[CallbacksHttp] Enhanced error details:', enhancedError);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async ackDelivery(slotId: string, deliveryData: unknown): Promise<void> {
|
||
if (!this.profile.ackDelivery) return;
|
||
|
||
try {
|
||
const spec = this.profile.ackDelivery;
|
||
const body = JSON.stringify({ slotId, ...deliveryData });
|
||
const method = spec.method || 'POST'; // Default to POST when body is sent
|
||
|
||
const ac = new AbortController();
|
||
const to = setTimeout(() => ac.abort(), 8000); // 8s timeout for ACK
|
||
|
||
try {
|
||
await fetch(spec.url, {
|
||
method,
|
||
headers: spec.headers,
|
||
body,
|
||
signal: ac.signal
|
||
});
|
||
|
||
logger.debug('[CallbacksHttp] Acknowledged delivery for slot', slotId);
|
||
} finally {
|
||
clearTimeout(to);
|
||
}
|
||
} catch (error) {
|
||
logger.error('[CallbacksHttp] Failed to acknowledge delivery for slot', slotId, error);
|
||
// Don't throw - this is not critical
|
||
}
|
||
}
|
||
|
||
async reportError(slotId: string, error: { code?: string; message?: string }): Promise<void> {
|
||
if (!this.profile.reportError) return;
|
||
|
||
try {
|
||
const spec = this.profile.reportError;
|
||
const body = JSON.stringify({ slotId, error });
|
||
const method = spec.method || 'POST'; // Default to POST when body is sent
|
||
|
||
const ac = new AbortController();
|
||
const to = setTimeout(() => ac.abort(), 8000); // 8s timeout for error reporting
|
||
|
||
try {
|
||
await fetch(spec.url, {
|
||
method,
|
||
headers: spec.headers,
|
||
body,
|
||
signal: ac.signal
|
||
});
|
||
|
||
logger.debug('[CallbacksHttp] Reported error for slot', slotId);
|
||
} finally {
|
||
clearTimeout(to);
|
||
}
|
||
} catch (reportError) {
|
||
logger.error('[CallbacksHttp] Failed to report error for slot', slotId, reportError);
|
||
// Don't throw - this is not critical
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### Future v2 Adapters (Not Implemented in v1)
|
||
|
||
```typescript
|
||
// src/services/notifications/adapters/BackgroundPrefetch.ts
|
||
// v2: Native background network scheduling
|
||
|
||
export interface BackgroundPrefetch {
|
||
schedulePrefetch(slotId: SlotId, atMs: number): Promise<void>; // T–lead
|
||
cancelPrefetch(slotId: SlotId): Promise<void>;
|
||
}
|
||
|
||
// v2 Implementation Notes:
|
||
// - Android: Use WorkManager/AlarmManager to wake for network work at T–lead
|
||
// - iOS: Use BGTaskScheduler or silent push to wake for network work at T–lead
|
||
// - After network fetch, (re)arm local notifications with fresh content
|
||
// - This enables true "scheduled network events" when app is terminated
|
||
// - Not implemented in v1; v2 will bind to native OS primitives
|
||
```
|
||
|
||
### BackgroundRunnerPrefetch Adapter (v1 Cache Warmer)
|
||
|
||
```typescript
|
||
// src/services/notifications/adapters/BackgroundRunnerPrefetch.ts
|
||
// v1 cache-warmer using Capacitor Background Runner (opportunistic)
|
||
import { logger } from '@/utils/logger';
|
||
import { CallbacksHttp } from './CallbacksHttp';
|
||
import type { DataStore, SlotId } from '../types';
|
||
|
||
export class BackgroundRunnerPrefetch {
|
||
constructor(
|
||
private store: DataStore,
|
||
private fetcher: CallbacksHttp,
|
||
private getUpcomingSlots: () => SlotId[],
|
||
private getNextSlotTime: (slotId: SlotId) => number,
|
||
private leadMinutes: number,
|
||
private ttlSeconds: number,
|
||
private allowRearm: boolean // feature flag: runnerRearm
|
||
) {}
|
||
|
||
// Entrypoint invoked by Background Runner
|
||
async handleTick(): Promise<void> {
|
||
const now = Date.now();
|
||
const leadMs = this.leadMinutes * 60_000;
|
||
|
||
for (const slot of this.getUpcomingSlots()) {
|
||
const fireAt = this.getNextSlotTime(slot);
|
||
if (fireAt - now > leadMs) continue; // only act inside lead
|
||
|
||
const existing = await this.store.getLatestContent(slot);
|
||
const etag = existing?.etag;
|
||
|
||
try {
|
||
const res = await this.fetcher.fetchContent(slot, etag);
|
||
if (!res) continue; // 304
|
||
|
||
// Save fresh payload
|
||
await this.store.saveContent(slot, res.payload, res.etag);
|
||
|
||
// Optional: cancel & re-arm if within TTL and allowed
|
||
if (this.allowRearm) {
|
||
const ageAtFire = fireAt - Date.now(); // newly fetched now
|
||
if (ageAtFire <= this.ttlSeconds * 1000) {
|
||
// Signal the orchestrator to re-arm on next foreground OR
|
||
// (if environment allows) directly call orchestrator.scheduleSlot(slot, res.payload)
|
||
// Keep this behind the flag to minimize risk on iOS.
|
||
}
|
||
}
|
||
} catch (e) {
|
||
logger.error('[BackgroundRunnerPrefetch] tick failed for', slot, e);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
> **Note:** In v1, Runner **only guarantees opportunistic JS**. Keep re-arming
|
||
behind `runnerRearm` (default **false**) to avoid iOS background surprises. The safe baseline is **prefetch-only**; scheduling parity stays with the foreground orchestrator.
|
||
|
||
---
|
||
|
||
## 8) Core Orchestrator Implementation
|
||
|
||
### NotificationOrchestrator
|
||
|
||
**Implementation Guarantees:**
|
||
|
||
- **SlotId generation:** Build SlotId as `HHmm` from `NotificationTime` and use
|
||
it everywhere (replace any `slot-xx-yy` pattern)
|
||
- **Cooldown:** `deliverStoredNow()` will ignore requests if invoked for the
|
||
same `slotId` within **60 seconds** of the last call
|
||
- **Idempotency:** Before scheduling, we will record `status='scheduled'`; unique
|
||
index will reject duplicates (handle gracefully)
|
||
- **Lead window:** Only one online-first attempt per slot inside the lead window;
|
||
no inner retries (enforced by per-slot `lastAttemptAt` tracking)
|
||
- **Reschedule on TZ/DST:** On app resume or timezone offset change, we will
|
||
cancel & re-arm the rolling window
|
||
- **Config persistence:** Configuration will be persisted to `notif_config`
|
||
table, not just enqueued as events
|
||
|
||
```typescript
|
||
// src/services/notifications/NotificationOrchestrator.ts
|
||
import type { ConfigureOptions, SlotId, DataStore, Scheduler } from './types';
|
||
import { CallbacksHttp } from './adapters/CallbacksHttp';
|
||
import { handleApiError } from '@/utils/errorHandler';
|
||
import { logger, safeStringify } from '@/utils/logger';
|
||
|
||
export class NotificationOrchestrator implements MultiDailyNotification {
|
||
constructor(private store: DataStore, private sched: Scheduler) {}
|
||
|
||
private opts!: ConfigureOptions;
|
||
private callbacks?: CallbacksHttp;
|
||
|
||
// Lead window attempt tracking (one attempt per slot per lead window)
|
||
private lastAttemptAt: Map<SlotId, number> = new Map();
|
||
|
||
// Cooldown tracking for deliverStoredNow (60s cooldown per slot)
|
||
private lastDeliveredNowAt: Map<SlotId, number> = new Map();
|
||
|
||
// App-level pipeline debounce (30s per app session)
|
||
private lastPipelineRunAt = 0;
|
||
private readonly PIPELINE_DEBOUNCE_MS = 30000;
|
||
|
||
async requestPermissions(): Promise<void> {
|
||
try {
|
||
const { LocalNotifications } = await import('@capacitor/local-notifications');
|
||
const result = await LocalNotifications.requestPermissions();
|
||
|
||
if (result.display !== 'granted') {
|
||
throw new Error('Notification permissions not granted');
|
||
}
|
||
|
||
logger.debug('[NotificationOrchestrator] Permissions granted');
|
||
} catch (error) {
|
||
logger.error('[NotificationOrchestrator] Failed to request permissions', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async configure(o: ConfigureOptions): Promise<void> {
|
||
this.opts = o;
|
||
|
||
if (o.callbackProfile) {
|
||
this.callbacks = new CallbacksHttp(o.callbackProfile);
|
||
}
|
||
|
||
// Persist configuration directly to notif_config table via store methods
|
||
await this.store.setConfig?.('times', o.times);
|
||
await this.store.setConfig?.('ttlSeconds', o.ttlSeconds ?? 86400);
|
||
await this.store.setConfig?.('prefetchLeadMinutes', o.prefetchLeadMinutes ?? 20);
|
||
await this.store.setConfig?.('storage', o.storage);
|
||
if (o.contentTemplate) await this.store.setConfig?.('contentTemplate', o.contentTemplate);
|
||
if (o.callbackProfile) await this.store.setConfig?.('callbackProfile', o.callbackProfile);
|
||
|
||
logger.debug('[NotificationOrchestrator] Configuration persisted to notif_config');
|
||
}
|
||
|
||
async runFullPipelineNow(): Promise<void> {
|
||
try {
|
||
// App-level debounce to prevent burst fetches on resume
|
||
const now = Date.now();
|
||
if (now - this.lastPipelineRunAt < this.PIPELINE_DEBOUNCE_MS) {
|
||
logger.debug('[NotificationOrchestrator] Pipeline debounced - too soon since last run');
|
||
return;
|
||
}
|
||
this.lastPipelineRunAt = now;
|
||
|
||
// 1) For each upcoming slot, attempt online-first fetch
|
||
const upcomingSlots = this.getUpcomingSlots();
|
||
|
||
for (const slot of upcomingSlots) {
|
||
await this.fetchAndScheduleSlot(slot);
|
||
}
|
||
|
||
logger.debug('[NotificationOrchestrator] Full pipeline completed');
|
||
} catch (error) {
|
||
logger.error('[NotificationOrchestrator] Pipeline failed', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async deliverStoredNow(slotId?: SlotId): Promise<void> {
|
||
const targetSlots = slotId ? [slotId] : this.getUpcomingSlots();
|
||
const now = Date.now();
|
||
const cooldownMs = 60 * 1000; // 60 seconds
|
||
|
||
for (const slot of targetSlots) {
|
||
// Check cooldown
|
||
const lastDelivered = this.lastDeliveredNowAt.get(slot);
|
||
if (lastDelivered && (now - lastDelivered) < cooldownMs) {
|
||
logger.debug('[NotificationOrchestrator] Skipping deliverStoredNow for', slot, '- within 60s cooldown');
|
||
continue;
|
||
}
|
||
|
||
const content = await this.store.getLatestContent(slot);
|
||
if (content) {
|
||
const payloadRef = this.createPayloadRef(content.payload);
|
||
await this.sched.scheduleExact(slot, Date.now() + 5000, payloadRef);
|
||
|
||
// Record delivery time for cooldown
|
||
this.lastDeliveredNowAt.set(slot, now);
|
||
}
|
||
}
|
||
}
|
||
|
||
async reschedule(): Promise<void> {
|
||
// Check permissions before bulk scheduling
|
||
const { LocalNotifications } = await import('@capacitor/local-notifications');
|
||
const enabled = await LocalNotifications.areEnabled();
|
||
if (!enabled.value) {
|
||
logger.debug('[NotificationOrchestrator] Notifications disabled, skipping reschedule');
|
||
await this.store.recordDelivery('system', Date.now(), 'error', {
|
||
code: 'SCHEDULE_DENIED',
|
||
message: 'Notifications disabled during reschedule'
|
||
});
|
||
return;
|
||
}
|
||
|
||
const nextOccurrences = this.getUpcomingSlots().map(slotId => ({
|
||
slotId,
|
||
whenMs: this.getNextSlotTime(slotId)
|
||
}));
|
||
|
||
await this.sched.rescheduleAll(nextOccurrences);
|
||
}
|
||
|
||
async getState(): Promise<{
|
||
enabled: boolean;
|
||
slots: SlotId[];
|
||
lastFetchAt?: number;
|
||
lastDeliveryAt?: number;
|
||
exactAlarmCapable: boolean;
|
||
}> {
|
||
const capabilities = await this.sched.capabilities();
|
||
|
||
// Get last fetch time from notif_contents table
|
||
const lastFetchAt = await this.store.getLastFetchAt();
|
||
|
||
// Get last delivery time from notif_deliveries table
|
||
const lastDeliveryAt = await this.store.getLastDeliveryAt();
|
||
|
||
return {
|
||
enabled: this.opts ? true : false,
|
||
slots: this.opts?.times?.map(t => `${t.hour.toString().padStart(2, '0')}${t.minute.toString().padStart(2, '0')}`) || [],
|
||
lastFetchAt,
|
||
lastDeliveryAt,
|
||
exactAlarmCapable: capabilities.exactAlarms
|
||
};
|
||
}
|
||
|
||
private async fetchAndScheduleSlot(slotId: SlotId): Promise<void> {
|
||
try {
|
||
// Check if we're within lead window and have already attempted
|
||
const now = Date.now();
|
||
const leadWindowMs = (this.opts.prefetchLeadMinutes ?? 20) * 60 * 1000;
|
||
const slotTimeMs = this.getNextSlotTime(slotId);
|
||
const isWithinLeadWindow = (slotTimeMs - now) <= leadWindowMs;
|
||
|
||
if (isWithinLeadWindow) {
|
||
const lastAttempt = this.lastAttemptAt.get(slotId);
|
||
if (lastAttempt && (now - lastAttempt) < leadWindowMs) {
|
||
// Already attempted within this lead window, skip online-first
|
||
logger.debug('[NotificationOrchestrator] Skipping online-first for', slotId, '- already attempted within lead window');
|
||
} else {
|
||
// Record this attempt
|
||
this.lastAttemptAt.set(slotId, now);
|
||
|
||
// Attempt online-first fetch
|
||
if (this.callbacks) {
|
||
// Get saved ETag for this slot
|
||
const storedContent = await this.store.getLatestContent(slotId);
|
||
const savedEtag = storedContent?.etag;
|
||
|
||
const content = await this.callbacks.fetchContent(slotId, savedEtag);
|
||
if (content) {
|
||
await this.store.saveContent(slotId, content.payload, content.etag);
|
||
await this.scheduleSlot(slotId, content.payload);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
// Outside lead window, attempt pre-warm fetch but schedule with best available payload
|
||
if (this.callbacks) {
|
||
// Get saved ETag for this slot
|
||
const storedContent = await this.store.getLatestContent(slotId);
|
||
const savedEtag = storedContent?.etag;
|
||
|
||
const content = await this.callbacks.fetchContent(slotId, savedEtag);
|
||
if (content) {
|
||
await this.store.saveContent(slotId, content.payload, content.etag);
|
||
// Schedule with fresh content even outside lead window
|
||
await this.scheduleSlot(slotId, content.payload);
|
||
logger.debug('[NotificationOrchestrator] Scheduled', slotId, 'with fresh content outside lead window');
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Fallback to offline-first
|
||
const storedContent = await this.store.getLatestContent(slotId);
|
||
if (storedContent && this.isWithinTTL(storedContent.fetchedAt)) {
|
||
await this.scheduleSlot(slotId, storedContent.payload);
|
||
}
|
||
} catch (error) {
|
||
logger.error('[NotificationOrchestrator] Failed to fetch/schedule', slotId, error);
|
||
await this.store.recordDelivery(slotId, Date.now(), 'error', {
|
||
code: 'fetch_failed',
|
||
message: error instanceof Error ? error.message : 'Unknown error'
|
||
});
|
||
|
||
const enhancedError = handleApiError(error, {
|
||
component: 'NotificationOrchestrator',
|
||
operation: 'fetchAndScheduleSlot',
|
||
timestamp: new Date().toISOString(),
|
||
slotId
|
||
}, 'fetchAndScheduleSlot');
|
||
|
||
// Log enhanced error details for debugging
|
||
logger.error('[NotificationOrchestrator] Enhanced error details:', enhancedError);
|
||
}
|
||
}
|
||
|
||
private async scheduleSlot(slotId: SlotId, payload: unknown): Promise<void> {
|
||
const whenMs = this.getNextSlotTime(slotId);
|
||
|
||
// Check TTL at fire time - don't schedule if content will be stale
|
||
const storedContent = await this.store.getLatestContent(slotId);
|
||
if (storedContent) {
|
||
const ttlMs = (this.opts.ttlSeconds ?? 86400) * 1000;
|
||
const projectedAgeAtFire = whenMs - storedContent.fetchedAt;
|
||
if (projectedAgeAtFire > ttlMs) {
|
||
await this.store.recordDelivery(slotId, whenMs, 'error', {
|
||
code: 'FETCH_TOO_OLD',
|
||
message: `Content will be ${Math.round(projectedAgeAtFire / 1000)}s old at fire time (TTL: ${this.opts.ttlSeconds ?? 86400}s)`
|
||
});
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Render template with payload data
|
||
const data = this.buildTemplateData(payload);
|
||
const rendered = TemplateEngine.render(this.opts.contentTemplate, data);
|
||
|
||
// Check permissions before scheduling
|
||
const { LocalNotifications } = await import('@capacitor/local-notifications');
|
||
const enabled = await LocalNotifications.areEnabled();
|
||
if (!enabled.value) {
|
||
await this.store.recordDelivery(slotId, whenMs, 'error', {
|
||
code: 'SCHEDULE_DENIED',
|
||
message: 'Notifications disabled'
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Route through Scheduler adapter to honor platform timing semantics
|
||
const capabilities = await this.sched.capabilities();
|
||
if (capabilities.exactAlarms) {
|
||
await this.sched.scheduleExact({
|
||
slotId,
|
||
whenMs,
|
||
title: rendered.title,
|
||
body: rendered.body,
|
||
extra: { payloadRef: `${slotId}:${whenMs}`, etag: storedContent?.etag }
|
||
});
|
||
} else {
|
||
// Use windowed scheduling for Android when exact alarms unavailable
|
||
const androidWindowLenMs = 600000; // ±10 min
|
||
await this.sched.scheduleWindow({
|
||
slotId,
|
||
whenMs,
|
||
title: rendered.title,
|
||
body: rendered.body,
|
||
windowLenMs: androidWindowLenMs,
|
||
extra: { payloadRef: `${slotId}:${whenMs}`, etag: storedContent?.etag }
|
||
});
|
||
}
|
||
|
||
await this.store.recordDelivery(slotId, whenMs, 'scheduled');
|
||
}
|
||
|
||
private toSlotId(t: {hour:number; minute:number}): string {
|
||
return `${t.hour.toString().padStart(2,'0')}${t.minute.toString().padStart(2,'0')}`; // "HHmm"
|
||
}
|
||
|
||
private getUpcomingSlots(): SlotId[] {
|
||
const now = Date.now();
|
||
const twoDays = 2 * 24 * 60 * 60 * 1000;
|
||
const slots: SlotId[] = [];
|
||
|
||
for (const t of this.opts.times) {
|
||
const slotId = this.toSlotId(t);
|
||
const when = this.getNextSlotTime(slotId);
|
||
if (when <= (now + twoDays)) slots.push(slotId);
|
||
}
|
||
return slots;
|
||
}
|
||
|
||
private getNextSlotTime(slotId: SlotId): number {
|
||
const hour = parseInt(slotId.slice(0,2), 10);
|
||
const minute = parseInt(slotId.slice(2,4), 10);
|
||
|
||
const now = new Date();
|
||
const next = new Date(now);
|
||
next.setHours(hour, minute, 0, 0);
|
||
if (next <= now) next.setDate(next.getDate() + 1);
|
||
return next.getTime();
|
||
}
|
||
|
||
private buildTemplateData(payload: unknown): Record<string, string> {
|
||
const data = payload as Record<string, unknown>;
|
||
return {
|
||
headline: data.headline as string || '[Content]',
|
||
summary: data.summary as string || '[Content]',
|
||
date: new Date().toISOString().split('T')[0], // YYYY-MM-DD
|
||
time: new Date().toTimeString().split(' ')[0].slice(0, 5) // HH:MM
|
||
};
|
||
}
|
||
|
||
private generateNotificationId(slotId: SlotId, whenMs: number): number {
|
||
// 32-bit FNV-1a like hash
|
||
let hash = 0x811c9dc5;
|
||
const s = `${slotId}-${whenMs}`;
|
||
for (let i = 0; i < s.length; i++) {
|
||
hash ^= s.charCodeAt(i);
|
||
hash = (hash >>> 0) * 0x01000193 >>> 0;
|
||
}
|
||
return Math.abs(hash | 0);
|
||
}
|
||
|
||
private isWithinTTL(fetchedAt: number): boolean {
|
||
const ttlMs = (this.opts.ttlSeconds || 86400) * 1000;
|
||
return Date.now() - fetchedAt < ttlMs;
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 9) Runtime Timing & Network Guards
|
||
|
||
### Global Timing Constants (All Platforms)
|
||
|
||
| Constant | Default | Purpose |
|
||
| ---------------------- | ------------------------ | ------------------------------------------------------------------ |
|
||
| `prefetchLeadMinutes` | **20 min** | Earliest window we will attempt online-first before each slot |
|
||
| `fetchTimeoutMs` | **12,000 ms** | Hard timeout for content fetch (AbortController) |
|
||
| `ttlSeconds` | **86,400 s (24 h)** | Staleness cutoff; if offline and payload older than TTL → **skip** |
|
||
| `cooldownDeliverNowMs` | **60,000 ms** | Per-slot guard for `deliverStoredNow()` to prevent double-fires |
|
||
| `androidWindowLenMs` | **600,000 ms (±10 min)** | Window length when exact alarms are unavailable (Android) |
|
||
|
||
### Network Access Rules (By Platform)
|
||
|
||
**iOS (Capacitor app, v1)**
|
||
|
||
- **When code may run:** Only while the app is **foregrounded** or **recently
|
||
backgrounded** (no JS wake when app is killed).
|
||
- **Network in v1:** We will fetch **only** during app activity (launch/resume/
|
||
settings interaction). No delivery-time network for locals.
|
||
- **Timeout:** `fetchTimeoutMs = 12s`, single attempt inside lead.
|
||
- **Scheduling:** Use non-repeating one-shots; maintain a rolling window under
|
||
the pending cap (~64).
|
||
- **v2 note:** Background **silent push** or BGTaskScheduler-based prefetch will
|
||
be addressed in the plugin (not in v1).
|
||
|
||
**Android (Capacitor app, v1)**
|
||
|
||
- **When code may run:** Same as iOS; no JS wake when app is killed.
|
||
- **Network in v1:** Fetch during app activity only.
|
||
- **Exact alarms:** If `SCHEDULE_EXACT_ALARM` is unavailable, we will schedule
|
||
at **window start** with `androidWindowLenMs = 10m`; OS may delay within the
|
||
window. Deep-link users to grant exact-alarm access.
|
||
- **Timeout:** `fetchTimeoutMs = 12s`, single attempt inside lead.
|
||
|
||
**Web (PWA)**
|
||
|
||
- **When code may run:** **Service Worker** on **push** events (browser may be
|
||
closed) or a controlled page/tab.
|
||
- **Network in SW:** Allowed during the **push event**; keep total work short
|
||
and call `event.waitUntil(...)`.
|
||
- **Timeout:** `fetchTimeoutMs = 12s` in SW; one attempt.
|
||
- **Scheduling:** **No offline scheduling** on web; rely on push payload content;
|
||
local scheduling APIs are not reliable/standardized.
|
||
|
||
**Electron (desktop app)**
|
||
|
||
- **When code may run:** Only while the app is **running** (tray or window).
|
||
- **Network:** Allowed in **main** or **renderer** depending on where the
|
||
orchestrator runs. If in main, require Node 18+ (global `fetch`) or polyfill (`undici/register`).
|
||
- **Timeout:** `fetchTimeoutMs = 12s`, single attempt inside lead.
|
||
- **Scheduling:** Timer-based while running; **Start on Login** recommended. No
|
||
true background scheduling in v1.
|
||
|
||
### Data Transfer and Request Profile (All Platforms)
|
||
|
||
- **Headers:** Always send `If-None-Match` with last known ETag for the slot;
|
||
accept `304` as "no change".
|
||
- **Payload size:** We will keep JSON responses ≤ **16 KB** (hard ceiling) and
|
||
titles/bodies within platform limits (Title ≤ 50 chars, Body ≤ 200 chars pre-escape/truncation).
|
||
- **Methods:** `fetchContent` may use `GET` or `POST`; `ackDelivery`/
|
||
`reportError`/`heartbeat` will use **POST**.
|
||
- **Retries:** **None** inside the lead window; outside the lead, next
|
||
opportunity is the next app resume/open (v1).
|
||
- **Offline detection:** We will attempt fetch even if the OS says "offline"
|
||
(to avoid false negatives), but the 12s timeout will bound the attempt and we
|
||
will fall back per TTL policy.
|
||
|
||
### Enforcement Implementation Notes
|
||
|
||
1. **Timeout wrapper (no `AbortSignal.timeout`)**
|
||
Use `AbortController` + `setTimeout` (12s) around **all** outbound fetches
|
||
(already present for content; apply to ACK/error if needed).
|
||
|
||
2. **ETag propagation**
|
||
- Read latest `etag` via `DataStore.getLatestContent(slotId)`; set
|
||
`If-None-Match` on `fetchContent`.
|
||
- On `304`, **do not** reschedule content; leave the last payload intact and
|
||
only ensure the slot is armed.
|
||
|
||
3. **Lead window single attempt**
|
||
- Maintain `lastAttemptAt: Map<SlotId, number>`; if inside `[slotTime - lead,
|
||
slotTime]` and an attempt exists, **skip** repeat attempts.
|
||
- Add a **one-liner guard** at the start of any manual "fetch now/test" entry
|
||
point to respect the same policy.
|
||
|
||
4. **Cooldown for `deliverStoredNow`**
|
||
- Maintain `lastDeliveredNowAt: Map<SlotId, number>`; early-return if `< 60s`
|
||
since last call for the slot.
|
||
|
||
5. **Permission & bulk reschedule guard**
|
||
- Before `rescheduleAll`, check `LocalNotifications.areEnabled()`. If
|
||
disabled, record `SCHEDULE_DENIED` and **do not** queue notifications.
|
||
|
||
6. **Android window behavior**
|
||
- In `scheduleWindow(...)`, store `{ windowLenMs: 600000 }` in `extra` for
|
||
telemetry; schedule at window **start** and rely on OS batching.
|
||
|
||
7. **Electron fetch prerequisite**
|
||
- If orchestrator runs in main, ensure Node 18+ or add at app start:
|
||
|
||
```ts
|
||
// main only, if Node < 18
|
||
import 'undici/register';
|
||
```
|
||
|
||
- Set Windows AppUserModelID once:
|
||
|
||
```ts
|
||
import { app } from 'electron';
|
||
if (process.platform === 'win32') app.setAppUserModelId('com.timesafari.app');
|
||
```
|
||
|
||
8. **Truncation order**
|
||
- Truncate template **before** escaping; then pass `title/body` to schedulers (Capacitor/Electron).
|
||
|
||
---
|
||
|
||
## 10) Bootstrap & Integration
|
||
|
||
### Capacitor Integration
|
||
|
||
```typescript
|
||
// src/main.capacitor.ts - Add to existing initialization
|
||
import { NotificationServiceFactory } from './services/notifications/NotificationServiceFactory';
|
||
import { LocalNotifications } from '@capacitor/local-notifications';
|
||
|
||
// Wire action + receive listeners once during app init
|
||
LocalNotifications.addListener('localNotificationActionPerformed', e => {
|
||
const slotId = e.notification?.extra?.slotId;
|
||
logger.debug('[LocalNotifications] Action performed for slot', slotId);
|
||
|
||
// TODO: route to screen; reuse existing deep-link system
|
||
// This could navigate to a specific view based on slotId
|
||
if (slotId) {
|
||
// Example: Navigate to daily view or specific content
|
||
// router.push(`/daily/${slotId}`);
|
||
}
|
||
});
|
||
|
||
LocalNotifications.addListener('localNotificationReceived', e => {
|
||
const slotId = e.notification?.extra?.slotId;
|
||
logger.debug('[LocalNotifications] Notification received for slot', slotId);
|
||
|
||
// Optional: light telemetry hook
|
||
// Could track delivery success, user engagement, etc.
|
||
});
|
||
|
||
// After existing deep link registration
|
||
setTimeout(async () => {
|
||
try {
|
||
await registerDeepLinkListener();
|
||
|
||
// Initialize notifications using factory pattern
|
||
const notificationService = NotificationServiceFactory.getInstance();
|
||
if (notificationService) {
|
||
await notificationService.runFullPipelineNow();
|
||
logger.info(`[Main] 🎉 Notifications initialized successfully!`);
|
||
} else {
|
||
logger.warn(`[Main] ⚠️ Notification service not available on this platform`);
|
||
}
|
||
|
||
logger.info(`[Main] 🎉 All systems fully initialized!`);
|
||
} catch (error) {
|
||
logger.error(`[Main] ❌ System initialization failed:`, error);
|
||
}
|
||
}, 2000);
|
||
|
||
// Background Runner Integration (optional, v1 cache warmer)
|
||
setTimeout(async () => {
|
||
try {
|
||
const conf = await store.getConfig?.('prefetchRunner');
|
||
if (conf === 'background-runner') {
|
||
const { BackgroundRunner } = await import('@capacitor/background-runner');
|
||
// Register a periodic handler (OS will coalesce; ≥ ~15m typical)
|
||
await BackgroundRunner.register({
|
||
// name/id of the task, platform-specific options if required
|
||
});
|
||
|
||
// Provide a global/static tick handler called by the runner
|
||
BackgroundRunner.addListener('tick', async () => {
|
||
await bgPrefetch.handleTick();
|
||
});
|
||
}
|
||
} catch (e) {
|
||
logger.warn('[BackgroundRunner] unavailable, continuing without runner', e);
|
||
}
|
||
}, 3000);
|
||
```
|
||
|
||
### Electron Integration
|
||
|
||
```typescript
|
||
// src/main.electron.ts - Add to existing initialization
|
||
import { app } from 'electron';
|
||
import { NotificationServiceFactory } from './services/notifications/NotificationServiceFactory';
|
||
|
||
// We will require Node 18+ (global fetch) or we will polyfill via undici
|
||
// main.electron.ts (only if Node < 18)
|
||
// import 'undici/register';
|
||
|
||
// Windows integration (main process bootstrap)
|
||
if (process.platform === 'win32') {
|
||
app.setAppUserModelId('com.timesafari.app'); // stable, never change
|
||
}
|
||
|
||
// Start on Login (recommended for v1)
|
||
app.setLoginItemSettings({
|
||
openAtLogin: true,
|
||
openAsHidden: true
|
||
});
|
||
|
||
// Initialize notifications on app ready
|
||
app.whenReady().then(async () => {
|
||
try {
|
||
const notificationService = NotificationServiceFactory.getInstance();
|
||
if (notificationService) {
|
||
await notificationService.runFullPipelineNow();
|
||
logger.info(`[Main] 🎉 Electron notifications initialized successfully!`);
|
||
} else {
|
||
logger.warn(`[Main] ⚠️ Electron notification service not available`);
|
||
}
|
||
} catch (error) {
|
||
logger.error(`[Main] ❌ Electron notification initialization failed:`, error);
|
||
}
|
||
});
|
||
```
|
||
|
||
### Notification Initialization
|
||
|
||
```typescript
|
||
// src/services/notifications/index.ts
|
||
import { LocalNotifications } from '@capacitor/local-notifications';
|
||
import { NotificationOrchestrator } from './NotificationOrchestrator';
|
||
import { NotificationServiceFactory } from './NotificationServiceFactory';
|
||
import { Capacitor } from '@capacitor/core';
|
||
import { logger, safeStringify } from '@/utils/logger';
|
||
|
||
let orchestrator: NotificationOrchestrator | null = null;
|
||
|
||
export async function initNotifChannels(): Promise<void> {
|
||
try {
|
||
await LocalNotifications.createChannel({
|
||
id: 'timesafari.daily',
|
||
name: 'TimeSafari Daily',
|
||
description: 'Daily briefings',
|
||
importance: 4, // high
|
||
});
|
||
|
||
await LocalNotifications.registerActionTypes({
|
||
types: [{
|
||
id: 'TS_DAILY',
|
||
actions: [{ id: 'OPEN', title: 'Open' }]
|
||
}]
|
||
});
|
||
|
||
logger.debug('[Notifications] Channels and action types registered');
|
||
} catch (error) {
|
||
logger.error('[Notifications] Failed to register channels', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
export async function initNotifications(): Promise<void> {
|
||
// Only initialize on Capacitor/Electron platforms (not web)
|
||
const platform = process.env.VITE_PLATFORM || 'web';
|
||
if (platform === 'web') {
|
||
logger.debug('[Notifications] Skipping initialization on web platform - local notifications not supported');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await initNotifChannels();
|
||
|
||
// Use factory pattern for consistency
|
||
const notificationService = NotificationServiceFactory.getInstance();
|
||
if (notificationService) {
|
||
// Run pipeline on app start
|
||
await notificationService.runFullPipelineNow();
|
||
await notificationService.reschedule();
|
||
|
||
// Prune old data on app start
|
||
const platformService = PlatformServiceFactory.getInstance();
|
||
await platformService.$pruneNotifData(14);
|
||
|
||
logger.debug('[Notifications] Initialized successfully');
|
||
// Log with safe stringify for complex objects
|
||
logger.debug('[Notifications] Initialization details:', safeStringify({
|
||
platform: process.env.VITE_PLATFORM,
|
||
timestamp: new Date().toISOString(),
|
||
serviceAvailable: !!notificationService
|
||
}));
|
||
} else {
|
||
logger.warn('[Notifications] Service factory returned null');
|
||
}
|
||
} catch (error) {
|
||
logger.error('[Notifications] Initialization failed', error);
|
||
}
|
||
}
|
||
|
||
export function getNotificationOrchestrator(): NotificationOrchestrator | null {
|
||
// Return the singleton instance from the factory
|
||
return NotificationServiceFactory.getInstance();
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 11) Service Worker Integration
|
||
|
||
### Service Worker Re-establishment Required
|
||
|
||
**Note**: Service workers are intentionally disabled in Electron (see
|
||
`src/main.electron.ts` lines 36-59) and have minimal web implementation via
|
||
VitePWA plugin. Web push notifications would require re-implementing the service
|
||
worker infrastructure.
|
||
|
||
### Notification Click Handler
|
||
|
||
```javascript
|
||
// Note: This handler is for WEB PUSH notifications only.
|
||
// Mobile locals bypass the SW.
|
||
|
||
// sw_scripts/notification-click.js (or integrate into existing service worker)
|
||
self.addEventListener('notificationclick', (event) => {
|
||
event.notification.close();
|
||
|
||
// Extract slotId from notification data
|
||
const slotId = event.notification.data?.slotId;
|
||
|
||
// Open appropriate route based on notification type
|
||
const route = slotId ? '/#/daily' : '/#/notifications';
|
||
|
||
event.waitUntil(
|
||
clients.openWindow(route).catch(() => {
|
||
// Fallback if openWindow fails
|
||
return clients.openWindow('/');
|
||
})
|
||
);
|
||
});
|
||
```
|
||
|
||
### Service Worker Registration
|
||
|
||
```typescript
|
||
// Service worker registration is handled by VitePWA plugin in web builds
|
||
// This would typically go in main.web.ts or a dedicated service worker module
|
||
// SW examples use '/sw.js' as a placeholder; wire this to your actual build output path
|
||
// (e.g., 'sw_scripts/notification-click.js' or your combined bundle)
|
||
// Note: Service workers are intentionally disabled in Electron (src/main.electron.ts)
|
||
if ('serviceWorker' in navigator && process.env.VITE_PLATFORM === 'web') {
|
||
navigator.serviceWorker.register('/sw.js')
|
||
.then(registration => {
|
||
console.log('Service Worker registered:', registration);
|
||
})
|
||
.catch(error => {
|
||
console.error('Service Worker registration failed:', error);
|
||
});
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 12) Usage Examples
|
||
|
||
### Basic Configuration
|
||
|
||
```typescript
|
||
// In a Vue component using vue-facing-decorator and PlatformServiceMixin
|
||
import { Component, Vue, Prop } from 'vue-facing-decorator';
|
||
import { NotificationServiceFactory } from '@/services/notifications/NotificationServiceFactory';
|
||
import { PlatformServiceMixin } from '@/utils/PlatformServiceMixin';
|
||
|
||
@Component({
|
||
name: 'NotificationSettingsView',
|
||
mixins: [PlatformServiceMixin]
|
||
})
|
||
export default class NotificationSettingsView extends Vue {
|
||
@Prop({ required: true }) onSave!: (config: ConfigureOptions) => Promise<void>;
|
||
@Prop({ required: true }) onCancel!: () => void;
|
||
@Prop({ required: false }) onTest?: (slotId: string) => Promise<void>;
|
||
|
||
private notificationService = NotificationServiceFactory.getInstance();
|
||
|
||
async mounted() {
|
||
if (this.notificationService) {
|
||
await this.notificationService.configure({
|
||
times: [{hour:8,minute:0},{hour:12,minute:0},{hour:18,minute:0}],
|
||
ttlSeconds: 86400,
|
||
prefetchLeadMinutes: 20,
|
||
storage: 'shared',
|
||
contentTemplate: { title: 'TimeSafari', body: '{{headline}} — {{summary}}' },
|
||
callbackProfile: {
|
||
fetchContent: {
|
||
method: 'GET',
|
||
url: 'https://api.timesafari.app/v1/report/daily',
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
timeoutMs: 12000
|
||
}
|
||
}
|
||
});
|
||
|
||
await this.notificationService.runFullPipelineNow();
|
||
}
|
||
}
|
||
|
||
async handleSave() {
|
||
const config = this.collectConfiguration();
|
||
await this.onSave(config);
|
||
}
|
||
|
||
handleCancel() {
|
||
this.onCancel();
|
||
}
|
||
|
||
async handleTestNotification() {
|
||
if (this.onTest) {
|
||
await this.onTest('0800');
|
||
}
|
||
}
|
||
|
||
private collectConfiguration(): ConfigureOptions {
|
||
// Collect form data and return ConfigureOptions
|
||
return {
|
||
times: [{ hour: 8, minute: 0 }],
|
||
storage: 'shared',
|
||
contentTemplate: { title: 'TimeSafari', body: '{{headline}} — {{summary}}' }
|
||
};
|
||
}
|
||
}
|
||
```
|
||
|
||
### Settings Integration
|
||
|
||
```typescript
|
||
// Save notification settings using existing PlatformServiceMixin
|
||
await this.$saveSettings({
|
||
notifTimes: [{ hour: 8, minute: 0 }, { hour: 12, minute: 0 }],
|
||
notifEnabled: true,
|
||
notifMode: 'online-first',
|
||
notifTtlSeconds: 86400,
|
||
notifPrefetchLeadMinutes: 20
|
||
});
|
||
```
|
||
|
||
### Notification Composable Integration
|
||
|
||
```typescript
|
||
// Note: The existing useNotifications composable in src/composables/useNotifications.ts
|
||
// is currently stub functions with eslint-disable comments and needs implementation for the notification system.
|
||
// This shows the actual stub function signatures:
|
||
|
||
export function useNotifications() {
|
||
// Inject the notify function from the app
|
||
const notify = inject<(notification: NotificationIface, timeout?: number) => void>("notify");
|
||
|
||
if (!notify) {
|
||
throw new Error("useNotifications must be used within a component that has $notify available");
|
||
}
|
||
|
||
// All functions are currently stubs with eslint-disable comments
|
||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||
function success(_notification: NotificationIface, _timeout?: number) {}
|
||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||
function error(_notification: NotificationIface, _timeout?: number) {}
|
||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||
function warning(_notification: NotificationIface, _timeout?: number) {}
|
||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||
function info(_notification: NotificationIface, _timeout?: number) {}
|
||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||
function toast(_title: string, _text?: string, _timeout?: number) {}
|
||
// ... other stub functions
|
||
|
||
return {
|
||
success, error, warning, info, toast,
|
||
copied, sent, confirm, confirmationSubmitted,
|
||
genericError, genericSuccess, alreadyConfirmed,
|
||
cannotConfirmIssuer, cannotConfirmHidden,
|
||
notRegistered, notAGive, notificationOff, downloadStarted
|
||
};
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 13) Testing & Validation
|
||
|
||
### Unit Test Example
|
||
|
||
```typescript
|
||
// test/services/notifications/NotificationOrchestrator.test.ts
|
||
import { NotificationOrchestrator } from '@/services/notifications/NotificationOrchestrator';
|
||
import { DataStoreSqlite } from '@/services/notifications/adapters/DataStoreSqlite';
|
||
import { SchedulerCapacitor } from '@/services/notifications/adapters/SchedulerCapacitor';
|
||
|
||
describe('NotificationOrchestrator', () => {
|
||
let orchestrator: NotificationOrchestrator;
|
||
let mockStore: jest.Mocked<DataStoreSqlite>;
|
||
let mockScheduler: jest.Mocked<SchedulerCapacitor>;
|
||
|
||
beforeEach(() => {
|
||
mockStore = createMockDataStore();
|
||
mockScheduler = createMockScheduler();
|
||
orchestrator = new NotificationOrchestrator(mockStore, mockScheduler);
|
||
});
|
||
|
||
it('should configure successfully', async () => {
|
||
const config = {
|
||
times: [{ hour: 8, minute: 0 }],
|
||
ttlSeconds: 86400,
|
||
storage: 'shared' as const,
|
||
contentTemplate: { title: 'TimeSafari', body: '{{headline}} — {{summary}}' }
|
||
};
|
||
|
||
await orchestrator.configure(config);
|
||
|
||
// Verify configuration persisted to database
|
||
const savedTimes = await mockStore.getConfig?.('times');
|
||
expect(savedTimes).toEqual(config.times);
|
||
});
|
||
|
||
it('should schedule upcoming slots', async () => {
|
||
await orchestrator.configure({
|
||
times: [{ hour: 8, minute: 0 }],
|
||
storage: 'shared' as const,
|
||
contentTemplate: { title: 'TimeSafari', body: '{{headline}} — {{summary}}' }
|
||
});
|
||
|
||
await orchestrator.runFullPipelineNow();
|
||
|
||
expect(mockScheduler.scheduleExact).toHaveBeenCalled();
|
||
});
|
||
});
|
||
```
|
||
|
||
### Playwright E2E Test Example
|
||
|
||
```typescript
|
||
// test-playwright/notifications.spec.ts
|
||
import { test, expect } from '@playwright/test';
|
||
|
||
test.describe('Notification System', () => {
|
||
test('should configure and display notification settings', async ({ page }) => {
|
||
await page.goto('/settings/notifications');
|
||
|
||
// Configure notification times using function props pattern
|
||
await page.click('[data-testid="add-time-button"]');
|
||
await page.fill('[data-testid="hour-input"]', '8');
|
||
await page.fill('[data-testid="minute-input"]', '0');
|
||
await page.click('[data-testid="save-time-button"]');
|
||
|
||
// Enable notifications
|
||
await page.click('[data-testid="enable-notifications"]');
|
||
|
||
// Verify configuration is saved
|
||
await expect(page.locator('[data-testid="next-occurrence"]')).toBeVisible();
|
||
await expect(page.locator('[data-testid="pending-count"]')).toContainText('1');
|
||
});
|
||
|
||
test('should handle window fallback on Android', async ({ page }) => {
|
||
// Mock Android without exact alarm permission
|
||
await page.addInitScript(() => {
|
||
Object.defineProperty(window, 'Capacitor', {
|
||
value: {
|
||
isNativePlatform: () => true,
|
||
getPlatform: () => 'android'
|
||
}
|
||
});
|
||
});
|
||
|
||
await page.goto('/settings/notifications');
|
||
await page.click('[data-testid="enable-notifications"]');
|
||
|
||
// Configure a slot for immediate testing
|
||
await page.fill('[data-testid="hour-input"]', '0');
|
||
await page.fill('[data-testid="minute-input"]', '1'); // 1 minute from now
|
||
await page.click('[data-testid="save-time-button"]');
|
||
|
||
// Wait for notification to fire (within +10m of slot time)
|
||
await page.waitForTimeout(70000); // Wait 70 seconds
|
||
|
||
// Verify notification appeared (may be delayed up to 10 minutes)
|
||
const notification = await page.locator('[data-testid="notification-delivered"]');
|
||
await expect(notification).toBeVisible({ timeout: 600000 }); // 10 minute timeout
|
||
});
|
||
});
|
||
```
|
||
|
||
### Jest Integration Test
|
||
|
||
```typescript
|
||
// test/services/notifications/integration.test.ts
|
||
import { NotificationServiceFactory } from '@/services/notifications/NotificationServiceFactory';
|
||
import { PlatformServiceFactory } from '@/services/PlatformServiceFactory';
|
||
|
||
describe('Notification System Integration', () => {
|
||
beforeEach(() => {
|
||
// Reset factory instances
|
||
NotificationServiceFactory.reset();
|
||
PlatformServiceFactory.reset();
|
||
});
|
||
|
||
it('should integrate with PlatformServiceFactory', () => {
|
||
const platformService = PlatformServiceFactory.getInstance();
|
||
const notificationService = NotificationServiceFactory.getInstance();
|
||
|
||
expect(platformService).toBeDefined();
|
||
expect(notificationService).toBeDefined();
|
||
expect(notificationService).toBeInstanceOf(Object);
|
||
});
|
||
|
||
it('should handle platform detection correctly', () => {
|
||
const originalPlatform = process.env.VITE_PLATFORM;
|
||
|
||
// Test web platform
|
||
process.env.VITE_PLATFORM = 'web';
|
||
const webService = NotificationServiceFactory.getInstance();
|
||
expect(webService).toBeNull(); // Should be null on web
|
||
|
||
// Test capacitor platform
|
||
process.env.VITE_PLATFORM = 'capacitor';
|
||
const capacitorService = NotificationServiceFactory.getInstance();
|
||
expect(capacitorService).toBeDefined();
|
||
|
||
// Test electron platform
|
||
process.env.VITE_PLATFORM = 'electron';
|
||
const electronService = NotificationServiceFactory.getInstance();
|
||
expect(electronService).toBeDefined();
|
||
|
||
// Restore original platform
|
||
process.env.VITE_PLATFORM = originalPlatform;
|
||
});
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
## 14) Service Architecture Integration
|
||
|
||
### Factory Pattern Alignment
|
||
|
||
Follow TimeSafari's existing service factory pattern:
|
||
|
||
```typescript
|
||
// src/services/notifications/NotificationServiceFactory.ts
|
||
import { NotificationOrchestrator } from './NotificationOrchestrator';
|
||
import { DataStoreSqlite } from './adapters/DataStoreSqlite';
|
||
import { SchedulerCapacitor } from './adapters/SchedulerCapacitor';
|
||
import { SchedulerElectron } from './adapters/SchedulerElectron';
|
||
import { PlatformServiceFactory } from '../PlatformServiceFactory';
|
||
import { logger, safeStringify } from '@/utils/logger';
|
||
|
||
export class NotificationServiceFactory {
|
||
private static instance: NotificationOrchestrator | null = null;
|
||
|
||
public static getInstance(): NotificationOrchestrator | null {
|
||
if (NotificationServiceFactory.instance) {
|
||
return NotificationServiceFactory.instance;
|
||
}
|
||
|
||
try {
|
||
const platformService = PlatformServiceFactory.getInstance();
|
||
const store = new DataStoreSqlite(platformService);
|
||
|
||
// Choose scheduler based on platform
|
||
const platform = process.env.VITE_PLATFORM || 'web';
|
||
let scheduler;
|
||
|
||
if (platform === 'electron') {
|
||
scheduler = new SchedulerElectron();
|
||
} else if (platform === 'capacitor') {
|
||
scheduler = new SchedulerCapacitor();
|
||
} else {
|
||
// Web platform - no local notifications
|
||
logger.debug('[NotificationServiceFactory] Web platform detected - no local notifications');
|
||
return null;
|
||
}
|
||
|
||
NotificationServiceFactory.instance = new NotificationOrchestrator(store, scheduler);
|
||
logger.debug('[NotificationServiceFactory] Created singleton instance for', platform);
|
||
// Log with safe stringify for complex objects
|
||
logger.debug('[NotificationServiceFactory] Factory details:', safeStringify({
|
||
platform,
|
||
timestamp: new Date().toISOString(),
|
||
schedulerType: platform === 'electron' ? 'SchedulerElectron' : 'SchedulerCapacitor'
|
||
}));
|
||
|
||
return NotificationServiceFactory.instance;
|
||
} catch (error) {
|
||
logger.error('[NotificationServiceFactory] Failed to create instance', error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
public static reset(): void {
|
||
NotificationServiceFactory.instance = null;
|
||
}
|
||
}
|
||
```
|
||
|
||
### Platform Detection Integration
|
||
|
||
Use TimeSafari's actual platform detection patterns:
|
||
|
||
```typescript
|
||
// In NotificationOrchestrator
|
||
private isNativePlatform(): boolean {
|
||
const platform = process.env.VITE_PLATFORM || 'web';
|
||
return platform === 'capacitor' || platform === 'electron';
|
||
}
|
||
|
||
private getPlatform(): string {
|
||
return process.env.VITE_PLATFORM || 'web';
|
||
}
|
||
|
||
private isWebPlatform(): boolean {
|
||
return process.env.VITE_PLATFORM === 'web';
|
||
}
|
||
```
|
||
|
||
### DID Integration
|
||
|
||
```typescript
|
||
// src/services/notifications/DidIntegration.ts
|
||
import { logger, safeStringify } from '@/utils/logger';
|
||
|
||
export class DidIntegration {
|
||
constructor(private userDid: string | null) {}
|
||
|
||
/**
|
||
* Associate notification with user's did:ethr: DID for privacy-preserving identity
|
||
* TimeSafari uses Ethereum-based DIDs in format: did:ethr:0x[40-char-hex]
|
||
*/
|
||
async associateWithDid(slotId: string, payload: unknown): Promise<unknown> {
|
||
if (!this.userDid) {
|
||
logger.debug('[DidIntegration] No user DID available for notification association');
|
||
// Log with safe stringify for complex objects
|
||
logger.debug('[DidIntegration] DID check details:', safeStringify({
|
||
userDid: this.userDid,
|
||
slotId,
|
||
timestamp: new Date().toISOString()
|
||
}));
|
||
return payload;
|
||
}
|
||
|
||
// Validate DID format (did:ethr:0x...)
|
||
if (!this.userDid.startsWith('did:ethr:0x') || this.userDid.length !== 53) {
|
||
logger.debug('[DidIntegration] Invalid did:ethr: format', this.userDid);
|
||
return payload;
|
||
}
|
||
|
||
// Add DID context to payload without exposing sensitive data
|
||
return {
|
||
...payload,
|
||
metadata: {
|
||
userDid: this.userDid,
|
||
slotId,
|
||
timestamp: Date.now()
|
||
}
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Validate notification belongs to current user's did:ethr: DID
|
||
*/
|
||
async validateNotificationOwnership(notificationData: unknown): Promise<boolean> {
|
||
if (!this.userDid) return false;
|
||
|
||
const data = notificationData as { metadata?: { userDid?: string } };
|
||
return data.metadata?.userDid === this.userDid;
|
||
}
|
||
|
||
/**
|
||
* Get user context for notification personalization using did:ethr: format
|
||
*/
|
||
async getUserContext(): Promise<{ did: string; preferences: Record<string, unknown> } | null> {
|
||
if (!this.userDid) return null;
|
||
|
||
return {
|
||
did: this.userDid,
|
||
preferences: {
|
||
// Add user preferences from settings
|
||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||
language: navigator.language
|
||
}
|
||
};
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 15) Feature Flags & Config
|
||
|
||
### Feature Flags Implementation
|
||
|
||
```typescript
|
||
// src/services/notifications/FeatureFlags.ts
|
||
export interface NotificationFeatureFlags {
|
||
scheduler: 'capacitor' | 'electron';
|
||
mode: 'auto' | 'online-first' | 'offline-first';
|
||
prefetchLeadMinutes: number;
|
||
ttlSeconds: number;
|
||
iosCategoryIdentifier: string;
|
||
androidChannelId: string;
|
||
prefetchRunner: 'none' | 'background-runner';
|
||
runnerRearm: boolean;
|
||
}
|
||
|
||
export class NotificationFeatureFlags {
|
||
private static defaults: NotificationFeatureFlags = {
|
||
scheduler: 'capacitor',
|
||
mode: 'auto',
|
||
prefetchLeadMinutes: 20,
|
||
ttlSeconds: 86400,
|
||
iosCategoryIdentifier: 'TS_DAILY',
|
||
androidChannelId: 'timesafari.daily',
|
||
prefetchRunner: 'none',
|
||
runnerRearm: false
|
||
};
|
||
|
||
static async getFlags(store: DataStore): Promise<NotificationFeatureFlags> {
|
||
const flags = { ...this.defaults };
|
||
|
||
for (const [key, defaultValue] of Object.entries(this.defaults)) {
|
||
const value = await store.getConfig?.(key);
|
||
if (value !== null && value !== undefined) {
|
||
(flags as any)[key] = value;
|
||
}
|
||
}
|
||
|
||
return flags;
|
||
}
|
||
|
||
static async setFlag(store: DataStore, key: keyof NotificationFeatureFlags, value: any): Promise<void> {
|
||
await store.setConfig?.(key, value);
|
||
}
|
||
}
|
||
```
|
||
|
||
### Configuration Storage
|
||
|
||
Feature flags are stored in the `notif_config` table as key-value pairs, separate from user settings. This allows for runtime configuration changes without affecting user preferences.
|
||
|
||
---
|
||
|
||
## 16) File Structure
|
||
|
||
```
|
||
/src/services/notifications/
|
||
index.ts # Main exports and initialization
|
||
types.ts # Type definitions
|
||
NotificationOrchestrator.ts # Core orchestrator
|
||
NotificationServiceFactory.ts # Factory following TimeSafari pattern
|
||
DidIntegration.ts # DID integration for privacy-preserving identity
|
||
adapters/
|
||
DataStoreSqlite.ts # SQLite data store adapter
|
||
SchedulerCapacitor.ts # Capacitor scheduler adapter
|
||
CallbacksHttp.ts # HTTP callbacks adapter
|
||
TemplateEngine.ts # Template rendering and token substitution
|
||
NotificationSettingsView.vue # Vue component for settings
|
||
notifications.spec.ts # Jest unit tests
|
||
notifications.e2e.ts # Playwright E2E tests
|
||
notifications.integration.ts # Jest integration tests
|
||
```
|
||
|
||
/src/views/
|
||
NotificationSettingsView.vue # Vue component using vue-facing-decorator
|
||
/sw_scripts/
|
||
notification-click.js # Service worker click handler
|
||
/src/db-sql/
|
||
migration.ts # Extended with notification tables (follows existing pattern)
|
||
/src/db/tables/
|
||
settings.ts # Extended Settings type (follows existing pattern)
|
||
/src/utils/
|
||
PlatformServiceMixin.ts # Extended with notification methods (follows existing pattern)
|
||
/src/main.capacitor.ts # Extended with notification initialization (follows existing pattern)
|
||
/src/services/
|
||
api.ts # Extended error handling (follows existing pattern)
|
||
/test/services/notifications/
|
||
NotificationOrchestrator.test.ts # Jest unit tests
|
||
integration.test.ts # Integration tests
|
||
/test-playwright/
|
||
notifications.spec.ts # Playwright E2E tests
|
||
|
||
```
|
||
|
||
---
|
||
|
||
## 17) TimeSafari Architecture Compliance
|
||
|
||
### Design Pattern Adherence
|
||
- **Factory Pattern:** `NotificationServiceFactory` follows the same singleton pattern as `PlatformServiceFactory`
|
||
- **Mixin Pattern:** Database access uses existing `PlatformServiceMixin` methods (`$db`, `$exec`, `$one`)
|
||
- **Migration Pattern:** Database changes follow existing `MIGRATIONS` array pattern in `src/db-sql/migration.ts`
|
||
- **Error Handling:** Uses existing comprehensive error handling from `src/utils/errorHandler.ts` for consistent error processing
|
||
- **Logging:** Uses existing logger from `src/utils/logger` with established logging patterns
|
||
- **Platform Detection:** Uses existing `Capacitor.isNativePlatform()` and `process.env.VITE_PLATFORM` patterns
|
||
|
||
### File Organization Compliance
|
||
- **Services:** Follows existing `src/services/` organization with factory and adapter pattern
|
||
- **Database:** Extends existing `src/db-sql/migration.ts` and `src/db/tables/settings.ts`
|
||
- **Utils:** Extends existing `src/utils/PlatformServiceMixin.ts` with notification methods
|
||
- **Main Entry:** Integrates with existing `src/main.capacitor.ts` initialization pattern
|
||
- **Service Workers:** Follows existing `sw_scripts/` organization pattern
|
||
|
||
### Type Safety Compliance
|
||
- **Settings Extension:** Follows existing Settings type extension pattern in `src/db/tables/settings.ts`
|
||
- **Interface Definitions:** Uses existing TypeScript interface patterns from `src/interfaces/`
|
||
- **Error Types:** Follows existing error handling type patterns from `src/services/api.ts`
|
||
- **Platform Types:** Uses existing platform detection type patterns from `src/services/PlatformService.ts`
|
||
|
||
### Integration Points
|
||
- **Database Access:** All database operations use `PlatformServiceMixin` methods for consistency
|
||
- **Platform Services:** Leverages existing `PlatformServiceFactory.getInstance()` for platform detection
|
||
- **Error Handling:** Integrates with existing comprehensive `handleApiError` from `src/utils/errorHandler.ts` for consistent error processing
|
||
- **Logging:** Uses existing logger with established patterns for debugging and monitoring
|
||
- **Initialization:** Follows existing `main.capacitor.ts` initialization pattern with proper error handling
|
||
- **Vue Architecture:** Follows Vue 3 + vue-facing-decorator patterns for component integration
|
||
- **State Management:** Integrates with PlatformServiceMixin for notification state management
|
||
- **Identity System:** Integrates with `did:ethr:` (Ethereum-based DID) system for privacy-preserving user context
|
||
- **Testing:** Follows Playwright E2E testing patterns established in TimeSafari
|
||
- **Privacy Architecture:** Follows TimeSafari's privacy-preserving claims architecture
|
||
- **Community Focus:** Enhances TimeSafari's mission of connecting people through gratitude and gifts
|
||
- **Platform Detection:** Uses actual `process.env.VITE_PLATFORM` patterns (`web`, `capacitor`, `electron`)
|
||
- **Database Architecture:** Supports platform-specific backends:
|
||
- **Web**: Absurd SQL (SQLite via IndexedDB) via `WebPlatformService` with worker pattern
|
||
- **Capacitor**: Native SQLite via `CapacitorPlatformService`
|
||
- **Electron**: Native SQLite via `ElectronPlatformService` (extends CapacitorPlatformService)
|
||
|
||
---
|
||
|
||
## 19) TimeSafari Architecture Compliance
|
||
|
||
### Design Pattern Adherence
|
||
|
||
- **Factory Pattern:** Notification service follows `PlatformServiceFactory` singleton pattern
|
||
- **Mixin Pattern:** Database access uses existing `PlatformServiceMixin` pattern
|
||
- **Migration Pattern:** Database changes follow existing `MIGRATIONS` array pattern
|
||
- **Error Handling:** Uses existing `handleApiError` from `src/services/api.ts`
|
||
- **Logging:** Uses existing logger from `src/utils/logger` with established patterns
|
||
- **Platform Detection:** Uses existing `Capacitor.isNativePlatform()` and `VITE_PLATFORM` patterns
|
||
|
||
### File Organization Compliance
|
||
|
||
- **Services:** Follows existing `src/services/` organization with factory and adapters
|
||
- **Database:** Extends existing `src/db-sql/migration.ts` and `src/db/tables/settings.ts`
|
||
- **Utils:** Extends existing `src/utils/PlatformServiceMixin.ts`
|
||
- **Main Entry:** Integrates with existing `src/main.capacitor.ts` initialization
|
||
- **Service Workers:** Follows existing `sw_scripts/` organization
|
||
|
||
### Type Safety Compliance
|
||
|
||
- **Settings Extension:** Follows existing Settings type extension pattern
|
||
- **Interface Definitions:** Uses existing TypeScript interface patterns
|
||
- **Error Types:** Follows existing error handling type patterns
|
||
- **Platform Types:** Uses existing platform detection type patterns
|
||
|
||
---
|
||
|
||
## 20) Cross-Doc Sync Hygiene
|
||
|
||
### Canonical Ownership
|
||
- **Plan document**: Canonical for Goals, Tenets, Platform behaviors, Acceptance criteria, Test cases
|
||
- **This document (Implementation)**: Canonical for API definitions, Database schemas, Adapter implementations, Code examples
|
||
|
||
### Synchronization Requirements
|
||
- **API code blocks**: Must be identical between Plan §4 and Implementation §3 (Public API (Shared))
|
||
- **Feature flags**: Must match between Plan §13 table and Implementation §15 defaults
|
||
- **Test cases**: Plan §14 acceptance criteria must align with Implementation test examples
|
||
- **Error codes**: Plan §11 taxonomy must match Implementation error handling
|
||
- **Slot/TTL/Lead policies**: Must be identical between Plan §7 policy and Implementation §9 policy
|
||
|
||
### PR Checklist
|
||
When changing notification system behavior, update both documents:
|
||
- [ ] **API changes**: Update types/interfaces in both Plan §4 and Implementation §3
|
||
- [ ] **Schema changes**: Update Plan §5 and Implementation §2
|
||
- [ ] **Slot/TTL changes**: Update Plan §4 semantics and Implementation §7 logic
|
||
- [ ] **Template changes**: Update Plan §9 contract and Implementation §4 engine
|
||
- [ ] **Error codes**: Update Plan §11 taxonomy and Implementation §3 types
|
||
|
||
---
|
||
|
||
## Sync Checklist
|
||
|
||
| Sync item | Plan | Impl | Status |
|
||
| ------------------------------ | --------------------- | --------------------- | ------ |
|
||
| Public API block identical | §4 | §3 | ✅ |
|
||
| `getState()` fields present | §4 | §8 Orchestrator | ✅ |
|
||
| Capacitor action handlers | §7 (iOS/Android note) | §9 Bootstrap | ✅ |
|
||
| Electron fetch prereq/polyfill | §7 | §9 Electron | ✅ |
|
||
| Android ±10m fallback | §7 | §7 SchedulerCapacitor | ✅ |
|
||
| Retention (no VACUUM v1) | §5 | `$pruneNotifData` | ✅ |
|
||
| Runner described as **opportunistic prefetch**, not scheduler | §7 | §9 | ✅ |
|
||
| Feature flag `prefetchRunner` (default `'none'`) | §13 | §15 | ✅ |
|
||
| Capabilities `networkWake: 'opportunistic' | 'none'` | §7 | Scheduler.capabilities | ✅ |
|
||
| Runner tick handler bounded to ≤12s | §7 | BackgroundRunnerPrefetch | ✅ |
|
||
| Optional `runnerRearm` flag & behavior | §7 | Orchestrator + Runner | ✅ |
|
||
|
||
---
|
||
|
||
*This implementation guide provides complete, working code for integrating the notification system with TimeSafari's existing infrastructure. All code examples are production-ready and follow TimeSafari's established patterns. For executive overview, see `notification-system-executive-summary.md`. For strategic overview, see `notification-system-plan.md`.*
|