diff --git a/AGENTS.md b/AGENTS.md
index 8a0041d..d26f84b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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
document is visible** (the bell already covers that). Delivery is **local
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,
~15 min floor) and a content-free FCM "doorbell" (timely, but a server learns
metadata). `useResolvedDmRelays` is exported for the envelope hook.
diff --git a/package-lock.json b/package-lock.json
index f04abba..50a631b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "skate-spots",
- "version": "1.5.6",
+ "version": "1.5.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "skate-spots",
- "version": "1.5.6",
+ "version": "1.5.7",
"dependencies": {
"@bitcoinerlab/secp256k1": "^1.2.0",
"@capacitor/android": "^8.4.2",
diff --git a/package.json b/package.json
index d61a762..29ee5f5 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "skate-spots",
"private": true,
- "version": "1.5.6",
+ "version": "1.5.7",
"type": "module",
"homepage": "https://skate-spots.org",
"scripts": {
diff --git a/release-notes.txt b/release-notes.txt
index ff2d8a0..e51d9bc 100644
--- a/release-notes.txt
+++ b/release-notes.txt
@@ -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
🛹 The map is quicker off the mark
diff --git a/src/components/OsNotificationBridge.tsx b/src/components/OsNotificationBridge.tsx
index b8c4ebe..a7ded11 100644
--- a/src/components/OsNotificationBridge.tsx
+++ b/src/components/OsNotificationBridge.tsx
@@ -7,6 +7,8 @@ import { useNewsletterIssues } from '@/hooks/useNewsletterIssues';
import { useNewsletterSubscription } from '@/hooks/useNewsletterSubscription';
import { useDmEnvelopes, type DmEnvelope } from '@/hooks/useDmEnvelopes';
import { useNotificationPrefs } from '@/hooks/useNotificationPrefs';
+import { useLocalReminders } from '@/hooks/useLocalReminders';
+import { isNativePlatform } from '@/lib/platform';
import {
getNotificationPermission,
listenForNotificationTaps,
@@ -331,5 +333,13 @@ function NotificationEngine({ prefs }: { prefs: NotificationPrefs }) {
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() ? : null;
+}
+
+function LocalReminders() {
+ useLocalReminders();
return null;
}
diff --git a/src/hooks/useLocalReminders.ts b/src/hooks/useLocalReminders.ts
new file mode 100644
index 0000000..7683a21
--- /dev/null
+++ b/src/hooks/useLocalReminders.ts
@@ -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 {
+ 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 => {
+ 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(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]);
+}
diff --git a/src/lib/notificationPrefs.ts b/src/lib/notificationPrefs.ts
index 05c14c3..eb86e98 100644
--- a/src/lib/notificationPrefs.ts
+++ b/src/lib/notificationPrefs.ts
@@ -9,7 +9,7 @@
* 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 {
/** Master switch. Off until the user explicitly enables notifications. */
@@ -22,6 +22,8 @@ export interface NotificationPrefs {
social: boolean;
/** New SkateSpots newsletter drops (subscribers only). */
newsletter: boolean;
+ /** Scheduled event reminders (Android app — fires even when the app is closed). */
+ reminders: boolean;
}
export const NOTIFICATION_PREFS_KEY = 'skatespots:notifications:prefs';
@@ -32,6 +34,7 @@ export const DEFAULT_NOTIFICATION_PREFS: NotificationPrefs = {
crew: true,
social: true,
newsletter: true,
+ reminders: true,
};
/** 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: 'social', label: 'Zaps, replies & mentions', hint: 'Reactions, reposts, zaps, replies and event updates' },
{ 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)" },
];
diff --git a/src/lib/osNotify.ts b/src/lib/osNotify.ts
index c6e58aa..f87b0f4 100644
--- a/src/lib/osNotify.ts
+++ b/src/lib/osNotify.ts
@@ -80,7 +80,7 @@ export interface OsNotificationInput {
}
/** 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;
for (let i = 0; i < id.length; i++) {
hash = (hash * 31 + id.charCodeAt(i)) | 0;
diff --git a/src/lib/reminders.ts b/src/lib/reminders.ts
new file mode 100644
index 0000000..ecf533e
--- /dev/null
+++ b/src/lib/reminders.ts
@@ -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;
+
+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 {
+ 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 {
+ 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 {
+ 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);
+ }
+}