You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

17 KiB

TimeSafari Notification System — Feature-First Execution Pack

Author: Matthew Raymer
Date: 2025-01-27T15:00Z (UTC)
Status: 🚀 ACTIVE - Surgical PR implementation for Capacitor platforms

What we'll ship (v1 in-app)

  • Multi-daily one-shot local notifications (rolling window)
  • Online-first (ETag, 10–15s timeout) with offline-first fallback
  • SQLite persistence + 14-day retention
  • Templating: {title, body} with {{var}}
  • Event queue: delivery/error/heartbeat (drained on foreground)
  • Same TS API that we can swap to native (v2) later

Minimal PR layout

/src/services/notifs/
  types.ts
  NotificationOrchestrator.ts
  adapters/
    DataStoreSqlite.ts
    SchedulerCapacitor.ts
    CallbacksHttp.ts
/migrations/
  00XX_notifs.sql
/sw_scripts/
  notification-click.js   # (or merge into sw_scripts-combined.js)
/app/bootstrap/
  notifications.ts        # init + feature flags

Changes (surgical)

1) Dependencies

npm i @capacitor/local-notifications
npx cap sync

2) Capacitor setup

// capacitor.config.ts
plugins: {
  LocalNotifications: {
    smallIcon: 'ic_stat_name',
    iconColor: '#4a90e2'
  }
}

3) Android channel + iOS category (once at app start)

import { LocalNotifications } from '@capacitor/local-notifications';

export async function initNotifChannels() {
  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' }]
    }]
  });
}

4) SQLite migration

-- /migrations/00XX_notifs.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,
  error_code TEXT,
  error_message TEXT
);

CREATE TABLE IF NOT EXISTS notif_config(
  k TEXT PRIMARY KEY,
  v TEXT NOT NULL
);

Retention job (daily):

DELETE FROM notif_contents
WHERE fetched_at < strftime('%s','now','-14 days');

DELETE FROM notif_deliveries
WHERE fire_at < strftime('%s','now','-14 days');

5) Types (shared API)

// src/services/notifs/types.ts
export type NotificationTime = { hour: number; minute: number };
export type SlotId = string;

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[];
  ttlSeconds?: number;
  prefetchLeadMinutes?: number;
  storage: 'shared'|'plugin';
  dbPath?: string;
  contentTemplate?: { title: string; body: string };
  callbackProfile?: CallbackProfile;
};

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[]>;
}

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>;
}

6) Adapters (thin)

// src/services/notifs/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> {
    const fetchedAt = Date.now();
    const payloadJson = JSON.stringify(payload);
    
    await this.platformService.$exec(
      `INSERT OR REPLACE INTO notif_contents (slot_id, payload_json, fetched_at, etag)
       VALUES (?, ?, ?, ?)`,
      [slotId, payloadJson, fetchedAt, etag]
    );
  }

  async getLatestContent(slotId: SlotId): Promise<{
    payload: unknown;
    fetchedAt: number;
    etag?: string
  }|null> {
    const result = await this.platformService.$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
    };
  }

  async recordDelivery(
    slotId: SlotId,
    fireAt: number,
    status: 'scheduled'|'shown'|'error',
    error?: { code?: string; message?: string }
  ): Promise<void> {
    const deliveredAt = status === 'shown' ? Date.now() : null;
    
    await this.platformService.$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]
    );
  }

  async enqueueEvent(e: unknown): Promise<void> {
    // Simple in-memory queue for now, can be enhanced with SQLite
    // This will be drained when app comes to foreground
  }

  async drainEvents(): Promise<unknown[]> {
    // Return and clear queued events
    return [];
  }
}

// src/services/notifs/adapters/SchedulerCapacitor.ts
import { LocalNotifications } from '@capacitor/local-notifications';
import type { Scheduler, SlotId } from '../types';

export class SchedulerCapacitor implements Scheduler {
  async capabilities(): Promise<{ exactAlarms: boolean; maxPending?: number }> {
    // iOS: ~64 pending notifications
    // Android: depends on exact alarm permissions
    return {
      exactAlarms: true, // Assume we have permissions
      maxPending: 64
    };
  }

  async scheduleExact(slotId: SlotId, whenMs: number, payloadRef: string): Promise<void> {
    await LocalNotifications.schedule({
      notifications: [{
        id: this.generateNotificationId(slotId),
        title: 'TimeSafari',
        body: 'Your daily update is ready',
        schedule: { at: new Date(whenMs) },
        extra: { slotId, payloadRef }
      }]
    });
  }

  async scheduleWindow(
    slotId: SlotId,
    windowStartMs: number,
    windowLenMs: number,
    payloadRef: string
  ): Promise<void> {
    // For platforms that don't support exact alarms
    await LocalNotifications.schedule({
      notifications: [{
        id: this.generateNotificationId(slotId),
        title: 'TimeSafari',
        body: 'Your daily update is ready',
        schedule: {
          at: new Date(windowStartMs),
          repeats: false
        },
        extra: { slotId, payloadRef }
      }]
    });
  }

  async cancelBySlot(slotId: SlotId): Promise<void> {
    const id = this.generateNotificationId(slotId);
    await LocalNotifications.cancel({ notifications: [{ id }] });
  }

  async rescheduleAll(next: Array<{ slotId: SlotId; whenMs: number }>): Promise<void> {
    // Cancel all existing notifications
    await LocalNotifications.cancel({ notifications: [] });
    
    // Schedule new ones
    const notifications = next.map(({ slotId, whenMs }) => ({
      id: this.generateNotificationId(slotId),
      title: 'TimeSafari',
      body: 'Your daily update is ready',
      schedule: { at: new Date(whenMs) },
      extra: { slotId }
    }));

    await LocalNotifications.schedule({ notifications });
  }

  private generateNotificationId(slotId: SlotId): number {
    // Simple hash of slotId to generate unique notification ID
    let hash = 0;
    for (let i = 0; i < slotId.length; i++) {
      const char = slotId.charCodeAt(i);
      hash = ((hash << 5) - hash) + char;
      hash = hash & hash; // Convert to 32-bit integer
    }
    return Math.abs(hash);
  }
}

7) Orchestrator (lean core)

// src/services/notifs/NotificationOrchestrator.ts
import type { ConfigureOptions, SlotId, DataStore, Scheduler } from './types';
import { logger } from '@/utils/logger';

export class NotificationOrchestrator {
  constructor(private store: DataStore, private sched: Scheduler) {}

  private opts!: ConfigureOptions;

  async configure(o: ConfigureOptions): Promise<void> {
    this.opts = o;
    
    // Persist configuration to SQLite
    await this.store.enqueueEvent({
      type: 'config_updated',
      config: o,
      timestamp: Date.now()
    });
  }

  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.log('[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();
    
    for (const slot of targetSlots) {
      const content = await this.store.getLatestContent(slot);
      if (content) {
        const payloadRef = this.createPayloadRef(content.payload);
        await this.sched.scheduleExact(slot, Date.now() + 5000, payloadRef);
      }
    }
  }

  async reschedule(): Promise<void> {
    const nextOccurrences = this.getUpcomingSlots().map(slotId => ({
      slotId,
      whenMs: this.getNextSlotTime(slotId)
    }));
    
    await this.sched.rescheduleAll(nextOccurrences);
  }

  async getState(): Promise<{
    pendingCount: number;
    nextOccurrences: Array<{slotId: string; when: string}>
  }> {
    const capabilities = await this.sched.capabilities();
    const upcomingSlots = this.getUpcomingSlots();
    
    return {
      pendingCount: Math.min(upcomingSlots.length, capabilities.maxPending || 64),
      nextOccurrences: upcomingSlots.map(slotId => ({
        slotId,
        when: new Date(this.getNextSlotTime(slotId)).toISOString()
      }))
    };
  }

  private async fetchAndScheduleSlot(slotId: SlotId): Promise<void> {
    try {
      // Attempt online-first fetch
      if (this.opts.callbackProfile?.fetchContent) {
        const content = await this.fetchContent(slotId);
        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'
      });
    }
  }

  private async fetchContent(slotId: SlotId): Promise<{
    payload: unknown;
    etag?: string
  }|null> {
    const spec = this.opts.callbackProfile!.fetchContent;
    const response = await fetch(spec.url, {
      method: spec.method,
      headers: spec.headers,
      body: spec.bodyJson ? JSON.stringify(spec.bodyJson) : undefined,
      signal: AbortSignal.timeout(spec.timeoutMs || 15000)
    });

    if (response.status === 304) {
      return null; // Not modified
    }

    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 };
  }

  private async scheduleSlot(slotId: SlotId, payload: unknown): Promise<void> {
    const whenMs = this.getNextSlotTime(slotId);
    const payloadRef = this.createPayloadRef(payload);
    
    await this.sched.scheduleExact(slotId, whenMs, payloadRef);
    await this.store.recordDelivery(slotId, whenMs, 'scheduled');
  }

  private getUpcomingSlots(): SlotId[] {
    const now = new Date();
    const slots: SlotId[] = [];
    
    for (const time of this.opts.times) {
      const slotId = `slot-${time.hour.toString().padStart(2, '0')}-${time.minute.toString().padStart(2, '0')}`;
      const nextTime = this.getNextSlotTime(slotId);
      
      // Include slots for today and tomorrow (within rolling window)
      if (nextTime <= now.getTime() + (2 * 24 * 60 * 60 * 1000)) {
        slots.push(slotId);
      }
    }
    
    return slots;
  }

  private getNextSlotTime(slotId: SlotId): number {
    const [_, hourStr, minuteStr] = slotId.match(/slot-(\d{2})-(\d{2})/) || [];
    const hour = parseInt(hourStr, 10);
    const minute = parseInt(minuteStr, 10);
    
    const now = new Date();
    const next = new Date(now);
    next.setHours(hour, minute, 0, 0);
    
    // If time has passed today, schedule for tomorrow
    if (next <= now) {
      next.setDate(next.getDate() + 1);
    }
    
    return next.getTime();
  }

  private createPayloadRef(payload: unknown): string {
    return `payload-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
  }

  private isWithinTTL(fetchedAt: number): boolean {
    const ttlMs = (this.opts.ttlSeconds || 86400) * 1000;
    return Date.now() - fetchedAt < ttlMs;
  }
}

8) Bootstrap + feature flags

// /app/bootstrap/notifications.ts
import { initNotifChannels } from './initNotifChannels';
import { NotificationOrchestrator } from '@/services/notifs/NotificationOrchestrator';
import { DataStoreSqlite } from '@/services/notifs/adapters/DataStoreSqlite';
import { SchedulerCapacitor } from '@/services/notifs/adapters/SchedulerCapacitor';
import { PlatformServiceMixin } from '@/utils/PlatformServiceMixin';
import { Capacitor } from '@capacitor/core';

let orchestrator: NotificationOrchestrator | null = null;

export async function initNotifications(): Promise<void> {
  // Only initialize on Capacitor platforms
  if (!Capacitor.isNativePlatform()) {
    return;
  }

  try {
    await initNotifChannels();
    
    const platformService = new PlatformServiceMixin();
    const store = new DataStoreSqlite(platformService);
    const scheduler = new SchedulerCapacitor();
    
    orchestrator = new NotificationOrchestrator(store, scheduler);
    
    // Run pipeline on app start
    await orchestrator.runFullPipelineNow();
    await orchestrator.reschedule();
    
    logger.log('[Notifications] Initialized successfully');
  } catch (error) {
    logger.error('[Notifications] Initialization failed:', error);
  }
}

export function getNotificationOrchestrator(): NotificationOrchestrator | null {
  return orchestrator;
}

9) Service Worker click handler (web)

// sw_scripts/notification-click.js (or inside combined file)
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('/');
    })
  );
});

Acceptance (v1)

  • Locals fire at configured slots with app killed
  • Online-first w/ ETag + timeout; falls back to offline-first (TTL respected)
  • DB retains 14 days; retention job prunes
  • Foreground drains queued events
  • One-line adapter swap path ready for v2

Platform Notes

  • Capacitor Only: This implementation is designed for iOS/Android via Capacitor
  • Web Fallback: Web platform will use existing service worker + push notifications
  • Electron: Will need separate implementation using native notification APIs
  • Feature Flags: Can be toggled per platform in bootstrap configuration