forked from trent_larson/crowd-funder-for-time-pwa
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.
1873 lines
66 KiB
Markdown
1873 lines
66 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
|
||
|
||
**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, '&')
|
||
.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 } 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`.*
|