SkateSpots/src/lib/reminders.ts
SkateSpots d966bca148 feat(notify): scheduled event reminders (Phase 1b) + v1.5.7
An OS alarm an hour before each upcoming event the user RSVP'd going or
interested to. Because the OS holds the alarm it fires with the app fully
closed — the only closed-app notification achievable without a server.

- src/lib/reminders.ts: schedule/cancel via @capacitor/local-notifications,
  tracking handed-out ids in localStorage so withdrawn RSVPs and passed events
  are cancelled without disturbing one-off notifications. Web is a no-op.
- src/hooks/useLocalReminders.ts: reuses the existing upcoming-events and
  my-RSVPs queries; no new relay destination, no signing.
- Bridge mounts the hook only on native and only when the new 'reminders'
  pref is on; unmount cancels everything (opt-out/logout).
- Skipped the Pro-expiry reminder on purpose: it would drag the heavy
  useSasquatchPro query into a nudge.

Bumps to v1.5.7 (versionCode 10507); APK rebuilt.
2026-09-29 14:12:55 +07:00

119 lines
3.9 KiB
TypeScript

import type { LocalNotificationSchema } from '@capacitor/local-notifications';
import { isNativePlatform } from '@/lib/platform';
import { getNotificationPermission, numericId } from '@/lib/osNotify';
/**
* Scheduled, future OS reminders — the only kind of notification that still
* fires when the app is **fully closed**, because the OS holds the alarm. No
* server, no background process, no network call: the app hands the OS a title,
* a time and an in-app path, and the OS does the rest.
*
* Native (Android/iOS) only — the web has no scheduler. On web every function
* here is a quiet no-op.
*
* We track the ids we handed out in localStorage so a reminder that no longer
* applies (RSVP withdrawn, event passed) can be cancelled without disturbing
* the one-off notifications fired elsewhere.
*/
export interface Reminder {
/** Stable key. Reusing it reschedules in place; dropping it cancels. */
key: string;
title: string;
body: string;
/** In-app path to open when tapped. */
path: string;
/** Fire time, ms since epoch. */
at: number;
}
const REMINDER_IDS_KEY = 'skatespots:notifications:reminders';
type ReminderIdMap = Record<string, number>;
function loadIds(): ReminderIdMap {
try {
const raw = localStorage.getItem(REMINDER_IDS_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw) as unknown;
if (parsed && typeof parsed === 'object') return parsed as ReminderIdMap;
} catch {
// Corrupt/blocked storage — start clean.
}
return {};
}
function saveIds(map: ReminderIdMap): void {
try {
localStorage.setItem(REMINDER_IDS_KEY, JSON.stringify(map));
} catch {
// Ignore — reminders still work for this session.
}
}
async function cancelNative(ids: number[]): Promise<void> {
if (ids.length === 0) return;
const { LocalNotifications } = await import('@capacitor/local-notifications');
await LocalNotifications.cancel({ notifications: ids.map((id) => ({ id })) });
}
/** Cancel every reminder we scheduled (used when turning notifications off). */
export async function cancelAllScheduledReminders(): Promise<void> {
const ids = Object.values(loadIds());
saveIds({});
if (!isNativePlatform() || ids.length === 0) return;
try {
await cancelNative(ids);
} catch (err) {
console.warn('[reminders] cancel failed', err);
}
}
/**
* Make the OS's pending reminders match `reminders` exactly: cancel the ones we
* previously scheduled that are no longer wanted, schedule the new ones, and
* leave already-scheduled ones alone (re-scheduling would only churn).
*/
export async function syncScheduledReminders(reminders: Reminder[]): Promise<void> {
if (!isNativePlatform()) return;
const now = Date.now();
const desired = new Map(reminders.filter((r) => r.at > now).map((r) => [r.key, r]));
// Don't touch the OS at all without permission.
const permission = await getNotificationPermission();
if (permission !== 'granted') return;
try {
const { LocalNotifications } = await import('@capacitor/local-notifications');
const previous = loadIds();
const stale = Object.entries(previous)
.filter(([key]) => !desired.has(key))
.map(([, id]) => id);
await cancelNative(stale);
const next: ReminderIdMap = {};
const toSchedule: LocalNotificationSchema[] = [];
for (const [key, reminder] of desired) {
const id = previous[key] ?? numericId(`reminder:${key}`);
next[key] = id;
if (previous[key] === undefined) {
toSchedule.push({
id,
title: reminder.title,
body: reminder.body,
schedule: { at: new Date(reminder.at), allowWhileIdle: true },
extra: { path: reminder.path },
});
}
}
if (toSchedule.length > 0) {
await LocalNotifications.schedule({ notifications: toSchedule });
}
saveIds(next);
} catch (err) {
console.warn('[reminders] sync failed', err);
}
}