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.
This commit is contained in:
SkateSpots 2026-09-29 14:12:55 +07:00
parent a4b79adadd
commit d966bca148
9 changed files with 257 additions and 5 deletions

View file

@ -554,6 +554,15 @@ session with **no other memory of this project**. So:
opening the app never replays history as a flood; **(3) suppressed while the opening the app never replays history as a flood; **(3) suppressed while the
document is visible** (the bell already covers that). Delivery is **local document is visible** (the bell already covers that). Delivery is **local
only** — a fully closed app/browser is unreachable without a server push. only** — a fully closed app/browser is unreachable without a server push.
**Phase 1b — scheduled reminders (2026-09-29):** `lib/reminders.ts` +
`useLocalReminders` hand the OS an alarm 1 h before each upcoming event the
user RSVP'd going/interested. Native-only; the hook is mounted from the bridge
only when `prefs.reminders` AND `isNativePlatform()`. Because the **OS holds
the alarm it fires with the app fully closed** — the only closed-app
notification achievable without a server. Scheduled ids live in
`skatespots:notifications:reminders`; the hook cancels them all on unmount
(opt-out / logout). New pref key `reminders` (default on). Pro-expiry reminder
deliberately skipped — it would pull in the heavy `useSasquatchPro` query.
**Phase 2/3 (NOT built):** background runner (on-device polling, closed-app, **Phase 2/3 (NOT built):** background runner (on-device polling, closed-app,
~15 min floor) and a content-free FCM "doorbell" (timely, but a server learns ~15 min floor) and a content-free FCM "doorbell" (timely, but a server learns
metadata). `useResolvedDmRelays` is exported for the envelope hook. metadata). `useResolvedDmRelays` is exported for the envelope hook.

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "skate-spots", "name": "skate-spots",
"version": "1.5.6", "version": "1.5.7",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "skate-spots", "name": "skate-spots",
"version": "1.5.6", "version": "1.5.7",
"dependencies": { "dependencies": {
"@bitcoinerlab/secp256k1": "^1.2.0", "@bitcoinerlab/secp256k1": "^1.2.0",
"@capacitor/android": "^8.4.2", "@capacitor/android": "^8.4.2",

View file

@ -1,7 +1,7 @@
{ {
"name": "skate-spots", "name": "skate-spots",
"private": true, "private": true,
"version": "1.5.6", "version": "1.5.7",
"type": "module", "type": "module",
"homepage": "https://skate-spots.org", "homepage": "https://skate-spots.org",
"scripts": { "scripts": {

View file

@ -1,3 +1,12 @@
v1.5.7
⏰ Event reminders
- RSVP "going" or "interested" to an event and your phone nudges you an hour
before it starts — even when SkateSpots is closed. It's an alarm the device
holds, not a server, so nothing leaves your phone.
- Turn it on in Settings -> Notifications (Android app).
---
v1.5.6 v1.5.6
🛹 The map is quicker off the mark 🛹 The map is quicker off the mark

View file

@ -7,6 +7,8 @@ import { useNewsletterIssues } from '@/hooks/useNewsletterIssues';
import { useNewsletterSubscription } from '@/hooks/useNewsletterSubscription'; import { useNewsletterSubscription } from '@/hooks/useNewsletterSubscription';
import { useDmEnvelopes, type DmEnvelope } from '@/hooks/useDmEnvelopes'; import { useDmEnvelopes, type DmEnvelope } from '@/hooks/useDmEnvelopes';
import { useNotificationPrefs } from '@/hooks/useNotificationPrefs'; import { useNotificationPrefs } from '@/hooks/useNotificationPrefs';
import { useLocalReminders } from '@/hooks/useLocalReminders';
import { isNativePlatform } from '@/lib/platform';
import { import {
getNotificationPermission, getNotificationPermission,
listenForNotificationTaps, listenForNotificationTaps,
@ -331,5 +333,13 @@ function NotificationEngine({ prefs }: { prefs: NotificationPrefs }) {
prefs, prefs,
]); ]);
// Scheduled reminders are native-only (the web has no scheduler), so only
// mount the hook — and therefore only run its event/RSVP queries — when the
// platform can actually use them.
return prefs.reminders && isNativePlatform() ? <LocalReminders /> : null;
}
function LocalReminders() {
useLocalReminders();
return null; return null;
} }

View file

@ -0,0 +1,101 @@
import { useEffect, useMemo, useRef } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useSkateRelays } from '@/hooks/useSkateRelays';
import { useUpcomingSkateEvents } from '@/hooks/useSkateEvents';
import { eventCoord } from '@/hooks/useEventRsvps';
import { CALENDAR_RSVP_KIND, parseEventRsvp } from '@/lib/types';
import { cancelAllScheduledReminders, syncScheduledReminders, type Reminder } from '@/lib/reminders';
/**
* Schedules an OS reminder an hour before each upcoming event the user RSVP'd
* "going" or "interested" to. Because the OS holds the alarm, it fires even when
* the app is closed — the one closed-app notification that needs no server.
*
* Uses only existing queries (upcoming events + the user's own RSVPs); no new
* relay destination, no signing, nothing decrypted.
*/
const REMINDER_LEAD_MS = 60 * 60 * 1000; // one hour before the start
const MIN_NOTICE_MS = 5 * 60 * 1000; // ignore events starting within 5 minutes
const SOON_MS = 60 * 1000; // ...but if the lead already passed, fire in a minute
/** The coordinates of events the user has said they're going to / interested in. */
function useMyRsvpCoords(): Set<string> {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const { user } = useCurrentUser();
const query = useQuery({
queryKey: ['my-rsvp-coords', user?.pubkey, getQueryKeySuffix()],
enabled: !!user?.pubkey,
staleTime: 60_000,
queryFn: async (c): Promise<string[]> => {
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]);
const events = await querySkateContent(
[{ kinds: [CALENDAR_RSVP_KIND], authors: [user!.pubkey], limit: 200 }],
{ signal },
);
const coords: string[] = [];
for (const event of events) {
const rsvp = parseEventRsvp(event);
if (rsvp && (rsvp.status === 'accepted' || rsvp.status === 'tentative')) {
coords.push(rsvp.eventCoord);
}
}
return coords;
},
});
return useMemo(() => new Set(query.data ?? []), [query.data]);
}
/**
* Live only while the caller renders it (guarded to native + opted in). When it
* unmounts — notifications turned off, or logged out — every reminder it
* scheduled is cancelled.
*/
export function useLocalReminders(): void {
const { data: events = [] } = useUpcomingSkateEvents(100);
const myCoords = useMyRsvpCoords();
const signatureRef = useRef<string | null>(null);
useEffect(
() => () => {
void cancelAllScheduledReminders();
},
[],
);
useEffect(() => {
const now = Date.now();
const reminders: Reminder[] = [];
for (const event of events) {
const coord = eventCoord(event);
if (!myCoords.has(coord)) continue;
const startMs = event.start * 1000;
if (startMs - now < MIN_NOTICE_MS) continue; // already started / imminent
const at = Math.max(startMs - REMINDER_LEAD_MS, now + SOON_MS);
reminders.push({
key: `event:${coord}`,
title: `🛹 ${event.title}`,
body: event.location ? `Starts soon at ${event.location}` : 'Your event starts soon',
path: `/event/${event.pubkey}/${event.identifier}`,
at,
});
}
// Only talk to the OS when the desired set actually changed (the "fire soon"
// time drifts by the minute, so bucket it).
const signature = reminders
.map((r) => `${r.key}@${Math.floor(r.at / 60_000)}`)
.sort()
.join('|');
if (signature === signatureRef.current) return;
signatureRef.current = signature;
void syncScheduledReminders(reminders);
}, [events, myCoords]);
}

View file

@ -9,7 +9,7 @@
* nothing is requested from the OS until the user turns them on in Settings. * nothing is requested from the OS until the user turns them on in Settings.
*/ */
export type NotificationCategory = 'dms' | 'crew' | 'social' | 'newsletter'; export type NotificationCategory = 'dms' | 'crew' | 'social' | 'newsletter' | 'reminders';
export interface NotificationPrefs { export interface NotificationPrefs {
/** Master switch. Off until the user explicitly enables notifications. */ /** Master switch. Off until the user explicitly enables notifications. */
@ -22,6 +22,8 @@ export interface NotificationPrefs {
social: boolean; social: boolean;
/** New SkateSpots newsletter drops (subscribers only). */ /** New SkateSpots newsletter drops (subscribers only). */
newsletter: boolean; newsletter: boolean;
/** Scheduled event reminders (Android app — fires even when the app is closed). */
reminders: boolean;
} }
export const NOTIFICATION_PREFS_KEY = 'skatespots:notifications:prefs'; export const NOTIFICATION_PREFS_KEY = 'skatespots:notifications:prefs';
@ -32,6 +34,7 @@ export const DEFAULT_NOTIFICATION_PREFS: NotificationPrefs = {
crew: true, crew: true,
social: true, social: true,
newsletter: true, newsletter: true,
reminders: true,
}; };
/** Display metadata for the per-category toggles, in order. */ /** Display metadata for the per-category toggles, in order. */
@ -44,4 +47,5 @@ export const NOTIFICATION_CATEGORIES: Array<{
{ key: 'crew', label: 'Crews', hint: 'Invites, join requests and membership confirmations' }, { key: 'crew', label: 'Crews', hint: 'Invites, join requests and membership confirmations' },
{ key: 'social', label: 'Zaps, replies & mentions', hint: 'Reactions, reposts, zaps, replies and event updates' }, { key: 'social', label: 'Zaps, replies & mentions', hint: 'Reactions, reposts, zaps, replies and event updates' },
{ key: 'newsletter', label: 'Newsletter', hint: 'New SkateSpots drops (subscribers only)' }, { key: 'newsletter', label: 'Newsletter', hint: 'New SkateSpots drops (subscribers only)' },
{ key: 'reminders', label: 'Event reminders', hint: "A nudge an hour before events you're going to (Android app)" },
]; ];

View file

@ -80,7 +80,7 @@ export interface OsNotificationInput {
} }
/** Stable 32-bit int for the native notification id (which must be numeric). */ /** Stable 32-bit int for the native notification id (which must be numeric). */
function numericId(id: string): number { export function numericId(id: string): number {
let hash = 0; let hash = 0;
for (let i = 0; i < id.length; i++) { for (let i = 0; i < id.length; i++) {
hash = (hash * 31 + id.charCodeAt(i)) | 0; hash = (hash * 31 + id.charCodeAt(i)) | 0;

119
src/lib/reminders.ts Normal file
View file

@ -0,0 +1,119 @@
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);
}
}