Files
crowd-funder-for-time-pwa/doc/notification-system-implementation.md
Matthew Raymer cfeb920493 refactor(docs): split notification system docs into plan and implementation
Replace monolithic notification-system-implementation-plan.md with focused
strategic plan (notification-system-plan.md) and detailed implementation
guide (notification-system-implementation.md). Both documents now perfectly
aligned with TimeSafari codebase patterns including:

- Actual Settings type extension pattern (JSON strings for complex objects)
- Real useNotifications composable stub signatures with eslint-disable
- Verified logger exports and safeStringify usage
- Confirmed PlatformServiceMixin.$saveSettings integration
- Validated migration system registerMigration patterns

Documents are production-ready with accurate code examples verified
against actual TimeSafari infrastructure.
2025-09-05 09:01:15 +00:00

1873 lines
66 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# TimeSafari Notification System — Implementation Guide
**Status:** 🚀 Active implementation
**Date:** 2025-09-05T05:09Z (UTC)
**Author:** Matthew Raymer
**Scope:** Detailed implementation for v1 (inapp 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
**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'
};
```
---
## 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.
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 interface Scheduler {
capabilities(): Promise<{ exactAlarms: boolean; maxPending?: number }>;
scheduleExact(slotId: SlotId, whenMs: number, payloadRef: string): Promise<void>;
scheduleWindow(
slotId: SlotId,
windowStartMs: number,
windowLenMs: number,
payloadRef: string
): Promise<void>;
cancelBySlot(slotId: SlotId): Promise<void>;
rescheduleAll(next: Array<{ slotId: SlotId; whenMs: number }>): 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
}
```
---
## 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 } from '../types';
import { Notification, app } from 'electron';
import { logger, safeStringify } from '@/utils/logger';
export class SchedulerElectron implements Scheduler {
async capabilities(): Promise<{ exactAlarms: boolean; maxPending?: number }> {
// Electron timers + OS delivery while app runs; no exact-alarm guarantees.
return { exactAlarms: false, maxPending: 128 };
}
async scheduleExact(slotId: SlotId, whenMs: number, payloadRef: string): Promise<void> {
const delay = Math.max(0, whenMs - Date.now());
setTimeout(() => {
try {
const n = new Notification({
title: 'TimeSafari',
body: 'Your daily update is ready',
// 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', slotId);
// Log with safe stringify for complex objects
logger.debug('[SchedulerElectron] Notification details:', safeStringify({
slotId,
timestamp: new Date().toISOString(),
platform: 'electron'
}));
} catch (e) {
logger.error('[SchedulerElectron] show failed', e);
throw e;
}
}, delay);
}
async scheduleWindow(slotId: SlotId, windowStartMs: number, windowLenMs: number, payloadRef: string): Promise<void> {
// v1 emulates "window" by scheduling at window start; OS may delay delivery.
return this.scheduleExact(slotId, windowStartMs, payloadRef);
}
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 } from '../types';
import { logger, safeStringify } from '@/utils/logger';
export class SchedulerCapacitor implements Scheduler {
async capabilities(): Promise<{ exactAlarms: boolean; maxPending?: number }> {
// Conservative default; exact permission detection will be native in v2.
return { exactAlarms: false, maxPending: 64 };
}
async scheduleExact(slotId: SlotId, whenMs: number, payloadRef: string): Promise<void> {
try {
await LocalNotifications.schedule({
notifications: [{
id: this.generateNotificationId(slotId, whenMs),
title: 'TimeSafari',
body: 'Your daily update is ready',
schedule: { at: new Date(whenMs) },
extra: { slotId, payloadRef }
}]
});
logger.debug('[SchedulerCapacitor] Scheduled notification for slot', slotId, 'at', new Date(whenMs).toISOString());
// Log with safe stringify for complex objects
logger.debug('[SchedulerCapacitor] Notification details:', safeStringify({
slotId,
whenMs,
scheduledAt: new Date(whenMs).toISOString(),
platform: 'capacitor'
}));
} catch (error) {
logger.error('[SchedulerCapacitor] Failed to schedule notification for slot', slotId, error);
throw error;
}
}
async scheduleWindow(
slotId: SlotId,
windowStartMs: number,
windowLenMs: number,
payloadRef: string
): 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(slotId, windowStartMs),
title: 'TimeSafari',
body: 'Your daily update is ready',
schedule: {
at: new Date(windowStartMs),
repeats: false
},
extra: { slotId, payloadRef, windowLenMs } // Carry window length for telemetry
}]
});
logger.debug('[SchedulerCapacitor] Scheduled windowed notification for slot', slotId, 'at window start');
} catch (error) {
logger.error('[SchedulerCapacitor] Failed to schedule windowed notification for slot', 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 ?? 15000);
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
await fetch(spec.url, {
method,
headers: spec.headers,
body
});
logger.debug('[CallbacksHttp] Acknowledged delivery for slot', slotId);
} 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
await fetch(spec.url, {
method,
headers: spec.headers,
body
});
logger.debug('[CallbacksHttp] Reported error for slot', slotId);
} catch (reportError) {
logger.error('[CallbacksHttp] Failed to report error for slot', slotId, reportError);
// Don't throw - this is not critical
}
}
}
```
---
## 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();
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 {
// 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 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;
}
}
}
// 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);
// 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;
}
// Schedule with rendered title/body
await LocalNotifications.schedule({
notifications: [{
id: this.generateNotificationId(slotId, whenMs),
title: rendered.title,
body: rendered.body,
schedule: { at: new Date(whenMs) },
extra: { slotId, whenMs }
}]
});
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) 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);
```
### 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
}
// Auto-launch (Option 1)
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();
}
```
---
## 10) 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.
// Capacitor local notifications on mobile do not pass through the service worker.
// 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);
});
}
```
---
## 11) 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
};
}
```
---
## 11) 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;
});
});
```
---
## 12) 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
}
};
}
}
```
---
## 13) 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
```
---
## 14) 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)
---
## 15) 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 §12 table and Implementation defaults
- **Test cases**: Plan §13 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 §4 policy and Implementation §3 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` | ✅ |
---
*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 strategic overview, see `notification-system-plan.md`.*