diff --git a/CLAUDE.md b/CLAUDE.md index 55a5805..8c22407 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,7 @@ Fully implemented end-to-end. ## Key paths - **App entry**: `lib/main.dart` → **if unauthenticated** → `_UnauthGate` → `Intro` (first run) or `LoginScreen`; **if authenticated** → `Home`. `Home` (`lib/home.dart`) is a `NavigationBar` + `IndexedStack` shell: tabs are **Nearby** (index 0), **Claim** (index 1), **Leaderboard** (index 2), **Friends** (index 3), **History** (index 4 — `ClaimHistoryScreen`, map/list `ViewToggle`). The AppBar `PopupMenuButton` has My reports, Admin · Reports (admins only), Settings, How to play. Named routes `/nearby`, `/claim`, `/friends`, `/leaderboard`, `/history`, `/settings` are retained for deep-link use (each wrapped in an auth guard). -- **Backend**: `functions/src/index.ts` exports `nearbyPostboxes`, `startScoring`, `updateDisplayName`, `onUserCreated`, `newDayScoreboard`, `registerFcmToken`, `onFriendAdded`, `userClaimHistory`, `submitReport`, `reviewReport`, `routePostboxes`. Helper modules: `_lookupPostboxes.ts` (ngeohash + Firestore geohash prefix queries), `_getPoints.ts` (monarch → points: EIIR=2, GR/GVR/GVIR/SCOTTISH_CROWN=4, VR=7, EVIIR/CIIIR=9, EVIIIR=12; also `KNOWN_MONARCHS`, `pointsForMonarch`), `_leaderboardUtils.ts` (period key staleness, merge/sort helpers), `_nearbyUtils.ts` (`applyUserClaims` for per-user claim state), `_streakUtils.ts` (`computeNewStreak`), `_notifications.ts` (FCM send, notification eligibility helpers), `_recomputeScores.ts` (retroactive re-scoring after a cypher correction), `_routePlanner.ts` (pure orienteering + corridor filters + beam search; reused by `routePostboxes` and the `scripts/plan_route.ts` CLI). `reports.ts` holds the two report callables + the pure helpers `buildOsmChange`, `parsePhotos`, `nextQuotaState`. `functions/set_admin.js` is a CLI to grant/revoke the `admin` custom claim. Friends list in `users/{uid}/friends` array; leaderboards updated by Cloud Functions in `leaderboards/{daily|weekly|monthly|lifetime}` documents. `reports/{id}` holds user-submitted data-problem reports (server-write only); `reportQuotas/{uid}` is the per-user daily submit-rate counter (server-only, never client-read). `fcmTokens/{uid}` stores FCM tokens (separate collection — not exposed via world-readable `users/{uid}` rules). `newDayScoreboard` scheduled at midnight London time; resets daily scores, rebuilds weekly/monthly from claims. +- **Backend**: `functions/src/index.ts` exports `nearbyPostboxes`, `startScoring`, `updateDisplayName`, `onUserCreated`, `newDayScoreboard`, `streakReminder`, `registerFcmToken`, `onFriendAdded`, `userClaimHistory`, `submitReport`, `reviewReport`, `routePostboxes`. Helper modules: `_lookupPostboxes.ts` (ngeohash + Firestore geohash prefix queries), `_getPoints.ts` (monarch → points: EIIR=2, GR/GVR/GVIR/SCOTTISH_CROWN=4, VR=7, EVIIR/CIIIR=9, EVIIIR=12; also `KNOWN_MONARCHS`, `pointsForMonarch`), `_leaderboardUtils.ts` (period key staleness, merge/sort helpers), `_nearbyUtils.ts` (`applyUserClaims` for per-user claim state), `_streakUtils.ts` (`computeNewStreak`), `_dateUtils.ts` (`getTodayLondon`, `previousDay`, `getLondonHourMinute`), `_notifications.ts` (FCM send via the now-exported `sendToUser`, notification eligibility helpers), `_recomputeScores.ts` (retroactive re-scoring after a cypher correction), `_routePlanner.ts` (pure orienteering + corridor filters + beam search; reused by `routePostboxes` and the `scripts/plan_route.ts` CLI). `reports.ts` holds the two report callables + the pure helpers `buildOsmChange`, `parsePhotos`, `nextQuotaState`. `functions/set_admin.js` is a CLI to grant/revoke the `admin` custom claim. Friends list in `users/{uid}/friends` array; leaderboards updated by Cloud Functions in `leaderboards/{daily|weekly|monthly|lifetime}` documents. `reports/{id}` holds user-submitted data-problem reports (server-write only); `reportQuotas/{uid}` is the per-user daily submit-rate counter (server-only, never client-read). `fcmTokens/{uid}` stores FCM tokens (separate collection — not exposed via world-readable `users/{uid}` rules). `newDayScoreboard` scheduled at midnight London time; resets daily scores, rebuilds weekly/monthly from claims. `streakReminder` (`functions/src/streakReminder.ts`) is a scheduled gameplay notification: it polls every 15 min from 17:00–20:45 Europe/London and fires once per day at a random slot within that window, pushing a "you'll lose your streak" reminder to users whose `lastClaimDate == yesterday` (claimed yesterday but not today), gated on the `streakReminder` notification pref. Dispatch bookkeeping (random target slot + sent marker) lives in the server-only `notificationState/streakReminder` doc. - **Postbox data source and storage**: Postbox data is **sourced from OpenStreetMap (OSM)**—e.g. Overpass API (`amenity=post_box`, UK area). **test.json** in the repo is a sample of the OSM/Overpass response: nodes with `type`, `id`, `lat`, `lon`, and `tags` (e.g. `amenity`, `ref`, `royal_cypher`, `post_box:type`, `collection_times`, `postal_code`). This data is **not** queried from OSM at app runtime; it is **ingested and stored in the cloud database** (Firestore). The app and existing Cloud Functions read from Firestore only. - **OSM→Firestore import pipeline**: Implemented in `functions/import_postboxes.js` (the single canonical importer — no `scripts/` duplicate). Run from the `functions/` directory: `node import_postboxes.js --project the-postbox-game`. Stores each postbox as `{ geohash (precision 9), geopoint, overpass_id, monarch?, reference?, county? }` in `postbox/{osm_}` with batch writes of 400. **Incremental by default**: subsequent runs only write nodes whose post-validation fields (geohash, lat/lon, monarch, reference, county) have changed since the previous run, tracked via a SHA-256 manifest at `functions/.last_import_manifest.json` (gitignored). Flags: `--dry-run` (scan & report counts without writing), `--prune` (soft-mark `osm_*` docs whose OSM node disappeared via `removedFromOsm: true` + `removedFromOsmAt` — never hard-deletes; re-appearance auto-clears because every normal write delete()s the flag), `--manifest ` (override), `--no-manifest` (force full re-import), `--overwrite-corrections` (re-import over `correctedBy` docs). GEOHASH_PRECISION must remain 9 (maximum) so stored hashes match precision-8 prefix queries used by the 30 m claim scan. Postboxes added from accepted "missing postbox" reports live at `postbox/manual_{reportId}` with `source: 'user_report'`, `reportId`, and the same geohash/geopoint schema; an accepted cypher correction also adds `correctedBy`/`correctedAt` (and a future OSM re-import of the now-added node would need dedup against `manual_*` docs — not yet built). New Flutter deps for reporting: `firebase_storage`, `image_picker`, `exif`, `url_launcher`. - **Auth**: `UserRepository` + `AuthenticationBloc`; Google Sign-In + Email/Password; `firebase_options.dart` has Android, iOS, macOS, Web, and Windows configurations (generated by FlutterFire CLI). @@ -93,7 +93,7 @@ Web build succeeds (`flutter build web`). Android debug build fails with Java he 2. **iOS build**: A `Podfile` will be needed (`pod install` in the `ios/` directory). Firebase options are already configured. 3. **Rate limiting / App Check enforcement**: App Check is configured with `AndroidPlayIntegrityProvider` for release builds — ensure it is enforced in the Firebase Console. 4. **Friend challenges / social features**: Friend profile pages (`UserProfilePage`) and friends-only leaderboard filtering are implemented. Friend challenges (e.g. direct head-to-head invites) are not yet implemented. -5. **Push notifications (social)**: Friend-first-claim, overtake, and friend-added FCM notifications are implemented. Gameplay notifications (daily reminder, streak break, rare postbox nearby) are not yet implemented. +5. **Push notifications (social)**: Friend-first-claim, overtake, and friend-added FCM notifications are implemented. The streak-loss reminder gameplay notification (`streakReminder`) is now implemented. Remaining gameplay notifications (daily reminder, rare postbox nearby) are not yet implemented. **Already done** (items from prior list that are now complete): - `startScoring` Cloud Function (with per-user claims, streaks, leaderboard updates) diff --git a/firestore.rules b/firestore.rules index d879df7..f831ce9 100644 --- a/firestore.rules +++ b/firestore.rules @@ -144,5 +144,13 @@ service cloud.firestore { allow read, write: if false; } + // ── notificationState collection ───────────────────────────────────────── + // Server-only dispatch bookkeeping for scheduled notifications (e.g. the + // once-per-day streak reminder's random target slot / sent marker). Written + // exclusively by Cloud Functions (Admin SDK); clients never touch it. + match /notificationState/{docId} { + allow read, write: if false; + } + } } diff --git a/functions/src/_dateUtils.ts b/functions/src/_dateUtils.ts index adad8df..a6cf78a 100644 --- a/functions/src/_dateUtils.ts +++ b/functions/src/_dateUtils.ts @@ -11,3 +11,36 @@ export function getTodayLondon(): string { return `${y}-${m}-${d}`; // Returns "YYYY-MM-DD" using Europe/London timezone; handles BST/GMT automatically } + +/** + * Returns the calendar day before `dateStr` ("YYYY-MM-DD") as "YYYY-MM-DD". + * + * The date string is interpreted at UTC midnight for the subtraction. This is + * the same trick used inline in newDayScoreboard for computing "yesterday" and + * is DST-safe: we only ever move a whole day in a fixed-offset (UTC) frame, so + * no wall-clock hour can be skipped or repeated. + */ +export function previousDay(dateStr: string): string { + const d = new Date(dateStr + "T00:00:00Z"); + d.setUTCDate(d.getUTCDate() - 1); + return d.toISOString().slice(0, 10); +} + +/** + * Returns the current hour and minute in Europe/London (24-hour clock), + * accounting for BST/GMT automatically. Used to place "now" within the + * streak-reminder polling window. + */ +export function getLondonHourMinute(): { hour: number; minute: number } { + const parts = new Intl.DateTimeFormat('en-GB', { + timeZone: 'Europe/London', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).formatToParts(new Date()); + const hourStr = parts.find(p => p.type === 'hour')!.value; + const minute = parseInt(parts.find(p => p.type === 'minute')!.value, 10); + // Intl can emit "24" for midnight under hour12:false; normalise to 0. + const hour = parseInt(hourStr, 10) % 24; + return { hour, minute }; +} diff --git a/functions/src/_notifications.ts b/functions/src/_notifications.ts index 5998d84..2f4ab4e 100644 --- a/functions/src/_notifications.ts +++ b/functions/src/_notifications.ts @@ -37,7 +37,7 @@ export function diffFriends(before: string[], after: string[]): string[] { * on the user document so they are not exposed to other authenticated users * through the world-readable `users/{uid}` Firestore rules. */ -async function sendToUser(uid: string, title: string, body: string): Promise { +export async function sendToUser(uid: string, title: string, body: string): Promise { const doc = await database.collection("fcmTokens").doc(uid).get(); const tokens: string[] = (doc.data()?.tokens as string[] | undefined) ?? []; if (tokens.length === 0) return; diff --git a/functions/src/index.ts b/functions/src/index.ts index acbb1f4..1a9c97b 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -6,6 +6,7 @@ import { onUserCreated } from "./onUserCreated"; import { onUserDeleted } from "./onUserDeleted"; import { updateDisplayName } from "./updateDisplayName"; import { newDayScoreboard } from "./newDayScoreboard"; +import { streakReminder } from "./streakReminder"; import { registerFcmToken, onFriendAdded } from "./_notifications"; import { userClaimHistory } from "./userClaimHistory"; import { submitReport, reviewReport } from "./reports"; @@ -19,6 +20,7 @@ export { onUserDeleted, updateDisplayName, newDayScoreboard, + streakReminder, registerFcmToken, onFriendAdded, userClaimHistory, diff --git a/functions/src/onUserCreated.ts b/functions/src/onUserCreated.ts index af15bfb..a37f1b5 100644 --- a/functions/src/onUserCreated.ts +++ b/functions/src/onUserCreated.ts @@ -48,6 +48,7 @@ export const onUserCreated = functions friendFirstScore: true, friendOvertakes: true, addedAsFriend: true, + streakReminder: true, }, // Initialise all numeric fields to 0 so Firestore queries that sort or // compare on these fields include new users before their first claim, diff --git a/functions/src/streakReminder.ts b/functions/src/streakReminder.ts new file mode 100644 index 0000000..c79f859 --- /dev/null +++ b/functions/src/streakReminder.ts @@ -0,0 +1,180 @@ +import "./adminInit"; +import * as admin from "firebase-admin"; +import { onSchedule } from "firebase-functions/v2/scheduler"; +import { logger } from "firebase-functions"; +import { getTodayLondon, previousDay, getLondonHourMinute } from "./_dateUtils"; +import { sendToUser } from "./_notifications"; + +const db = admin.firestore(); + +// The reminder polls every 15 minutes from 17:00 to 20:45 Europe/London, giving +// 16 discrete slots (0 = 17:00 … 15 = 20:45). The last slot sits safely before +// 21:00 so a fired reminder always lands inside the 5pm–9pm window. +export const STREAK_REMINDER_SLOTS = 16; +const WINDOW_START_HOUR = 17; + +// Firestore doc holding the once-per-day dispatch state. +const STATE_DOC_PATH = "notificationState/streakReminder"; + +interface ReminderState { + day?: string; // "YYYY-MM-DD" the current targetSlot was rolled for + targetSlot?: number; // slot (0..SLOTS-1) at/after which to fire today + sentDay?: string; // "YYYY-MM-DD" the reminder was actually sent +} + +/** + * Maps a Europe/London hour+minute to a 15-minute slot index within the + * reminder window. Clamped to [0, totalSlots-1]: the scheduler only invokes us + * inside 17:00–20:59, but clamping keeps the maths total regardless. + */ +export function slotForLondonTime( + hour: number, + minute: number, + totalSlots = STREAK_REMINDER_SLOTS +): number { + const raw = (hour - WINDOW_START_HOUR) * 4 + Math.floor(minute / 15); + if (raw < 0) return 0; + if (raw > totalSlots - 1) return totalSlots - 1; + return raw; +} + +/** + * Pure dispatch decision. Given the persisted state, today's date, the current + * slot and an injectable RNG, returns whether to fire now plus the state to + * persist. + * + * - On the first run of a new day we roll a fresh random targetSlot so the + * reminder lands at an unpredictable time each day. + * - Once sent for `today` we never fire again that day. + * - We fire when the current slot has reached-or-passed the target (`>=`, not + * `===`) so a skipped scheduler run doesn't cause us to miss the whole day. + */ +export function decideFire( + state: ReminderState | undefined, + today: string, + currentSlot: number, + totalSlots: number, + rng: () => number +): { fire: boolean; newState: ReminderState } { + let s: ReminderState = state ?? {}; + if (s.day !== today) { + // New day: roll a fresh target slot in [0, totalSlots-1]. Any sentDay from a + // previous day is dropped — it's meaningless once the day flips (the + // `sentDay === today` guard below would never match it), and carrying an + // absent one through as `undefined` would break the Firestore write (this + // project doesn't enable ignoreUndefinedProperties). + s = { day: today, targetSlot: Math.floor(rng() * totalSlots) }; + } + if (s.sentDay === today) return { fire: false, newState: s }; + if (currentSlot >= (s.targetSlot ?? 0)) { + return { fire: true, newState: { ...s, sentDay: today } }; + } + return { fire: false, newState: s }; +} + +/** + * True unless the user has explicitly disabled the streak reminder. Absent / + * undefined pref means enabled, matching the opt-out convention used by the + * other notification eligibility helpers. + */ +export function shouldNotifyStreakReminder( + fdata: Record | undefined +): boolean { + const prefs = fdata?.notificationPrefs as Record | undefined; + return prefs?.streakReminder !== false; +} + +/** + * Builds the reminder body, tailoring it to the streak length when known. + */ +export function streakReminderBody(streak: number | undefined): string { + const base = + "You claimed yesterday but not yet today, squire. Claim a postbox before " + + "midnight to keep your "; + if (typeof streak === "number" && streak > 0) { + return `${base}${streak}-day streak alive.`; + } + return `${base}streak alive.`; +} + +interface Recipient { + uid: string; + streak?: number; + fdata: Record; +} + +/** + * Finds users whose most recent claim was `yesterday` — i.e. they claimed + * yesterday but have not yet claimed today (a claim today advances + * lastClaimDate to today). Exported for unit testing. + */ +export async function findStreakReminderRecipients( + yesterday: string, + database: admin.firestore.Firestore = db +): Promise { + const snap = await database + .collection("users") + .where("lastClaimDate", "==", yesterday) + .get(); + return snap.docs.map((doc) => { + const fdata = doc.data() as Record; + return { uid: doc.id, streak: fdata.streak as number | undefined, fdata }; + }); +} + +/** + * Sends the streak-loss reminder to every eligible recipient (fan-out with + * allSettled so one failed push doesn't abort the rest). Exported for testing. + */ +export async function sendStreakReminders( + recipients: Recipient[], + send: (uid: string, title: string, body: string) => Promise = sendToUser +): Promise { + const eligible = recipients.filter((r) => shouldNotifyStreakReminder(r.fdata)); + await Promise.allSettled( + eligible.map((r) => + send(r.uid, "Don't lose your streak! 🔥", streakReminderBody(r.streak)) + ) + ); + return eligible.length; +} + +// Runs every 15 minutes from 17:00 to 20:45 London time; fires the reminder +// once per day at a random slot within that window. +// Manually trigger via https://console.cloud.google.com/cloudscheduler +export const streakReminder = onSchedule( + { schedule: "*/15 17-20 * * *", timeZone: "Europe/London" }, + async (_event) => { + const today = getTodayLondon(); + const { hour, minute } = getLondonHourMinute(); + const currentSlot = slotForLondonTime(hour, minute); + + // Atomically decide + record so two overlapping invocations can't both + // fire the reminder for the same day. + const stateRef = db.doc(STATE_DOC_PATH); + const fire = await db.runTransaction(async (tx) => { + const snap = await tx.get(stateRef); + const { fire, newState } = decideFire( + snap.data() as ReminderState | undefined, + today, + currentSlot, + STREAK_REMINDER_SLOTS, + Math.random + ); + tx.set(stateRef, newState, { merge: false }); + return fire; + }); + + if (!fire) { + logger.info(`Streak reminder: no send this slot (slot=${currentSlot})`); + return; + } + + const yesterday = previousDay(today); + const recipients = await findStreakReminderRecipients(yesterday); + const sent = await sendStreakReminders(recipients); + logger.info( + `Streak reminder fired at slot ${currentSlot}: ${sent} of ${recipients.length} candidates notified (yesterday=${yesterday})` + ); + } +); diff --git a/functions/src/test/test.index.ts b/functions/src/test/test.index.ts index 2d44ecc..6ae19cd 100644 --- a/functions/src/test/test.index.ts +++ b/functions/src/test/test.index.ts @@ -3,7 +3,16 @@ import test from "firebase-functions-test"; import * as myFunctions from "../index"; import { filterToCorridor, filterToEllipse, beamSearchOrienteering, metresBetween } from "../_routePlanner"; import { getPoints } from "../_getPoints"; -import { getTodayLondon } from "../_dateUtils"; +import { getTodayLondon, previousDay, getLondonHourMinute } from "../_dateUtils"; +import { + decideFire, + slotForLondonTime, + shouldNotifyStreakReminder, + streakReminderBody, + findStreakReminderRecipients, + sendStreakReminders, + STREAK_REMINDER_SLOTS, +} from "../streakReminder"; import { getWeekStart, getMonthStart, getPeriodKey, mergePeriodEntries, mergeLifetimeEntries, updateUserLeaderboards, countySlug } from "../_leaderboardUtils"; import { setPrecision, getLatLng, MAX_GEOHASH_PRECISION } from "../_lookupPostboxes"; import { applyUserClaims } from "../_nearbyUtils"; @@ -3689,3 +3698,208 @@ describe("account deletion: removeUserFromLeaderboards (mock Firestore)", () => assert.deepStrictEqual(ids("lifetime_by_county/counties/avon"), ["u2"]); }); }); + +// ── Streak reminder (scheduled notification) ────────────────────────────────── + +describe("previousDay", () => { + it("subtracts one day", () => { + assert.strictEqual(previousDay("2026-07-05"), "2026-07-04"); + }); + it("crosses a month boundary", () => { + assert.strictEqual(previousDay("2026-07-01"), "2026-06-30"); + }); + it("crosses a year boundary", () => { + assert.strictEqual(previousDay("2026-01-01"), "2025-12-31"); + }); + it("handles a leap day", () => { + assert.strictEqual(previousDay("2024-03-01"), "2024-02-29"); + }); +}); + +describe("getLondonHourMinute", () => { + it("returns in-range hour and minute", () => { + const { hour, minute } = getLondonHourMinute(); + assert.ok(Number.isInteger(hour) && hour >= 0 && hour <= 23, `hour ${hour}`); + assert.ok(Number.isInteger(minute) && minute >= 0 && minute <= 59, `minute ${minute}`); + }); +}); + +describe("slotForLondonTime", () => { + it("maps 17:00 to slot 0 and 20:45 to the last slot", () => { + assert.strictEqual(slotForLondonTime(17, 0), 0); + assert.strictEqual(slotForLondonTime(20, 45), STREAK_REMINDER_SLOTS - 1); + }); + it("maps quarter-hours within an hour", () => { + assert.strictEqual(slotForLondonTime(17, 14), 0); + assert.strictEqual(slotForLondonTime(17, 15), 1); + assert.strictEqual(slotForLondonTime(18, 30), 6); + }); + it("clamps below the window to 0 and above to the last slot", () => { + assert.strictEqual(slotForLondonTime(9, 0), 0); + assert.strictEqual(slotForLondonTime(23, 59), STREAK_REMINDER_SLOTS - 1); + }); +}); + +describe("shouldNotifyStreakReminder", () => { + it("is enabled when no prefs exist", () => { + assert.strictEqual(shouldNotifyStreakReminder({}), true); + assert.strictEqual(shouldNotifyStreakReminder(undefined), true); + }); + it("is enabled when the key is absent from prefs", () => { + assert.strictEqual( + shouldNotifyStreakReminder({ notificationPrefs: { friendOvertakes: false } }), + true + ); + }); + it("is disabled only when explicitly false", () => { + assert.strictEqual( + shouldNotifyStreakReminder({ notificationPrefs: { streakReminder: false } }), + false + ); + assert.strictEqual( + shouldNotifyStreakReminder({ notificationPrefs: { streakReminder: true } }), + true + ); + }); +}); + +describe("streakReminderBody", () => { + it("includes the streak length when known", () => { + assert.ok(streakReminderBody(12).includes("12-day streak")); + }); + it("omits the number for a missing or zero streak", () => { + assert.ok(!/\d/.test(streakReminderBody(undefined))); + assert.ok(!/\d/.test(streakReminderBody(0))); + }); +}); + +describe("decideFire", () => { + const TODAY = "2026-07-05"; + + it("rolls a random target slot on the first run of a new day", () => { + // rng 0.5 * 16 = 8 → floor → slot 8 + const { fire, newState } = decideFire(undefined, TODAY, 0, 16, () => 0.5); + assert.strictEqual(newState.day, TODAY); + assert.strictEqual(newState.targetSlot, 8); + assert.strictEqual(fire, false, "slot 0 is before target 8"); + }); + + it("re-rolls and drops a stale sentDay when the stored state is from a previous day", () => { + const prev = { day: "2026-07-04", targetSlot: 2, sentDay: "2026-07-04" }; + // rng 0.5 * 16 = 8 → target slot 8; current slot 0 is before it, so no fire + // and we can observe the re-rolled target. The stale sentDay is dropped so + // the persisted state never carries an undefined field into Firestore. + const { fire, newState } = decideFire(prev, TODAY, 0, 16, () => 0.5); + assert.strictEqual(fire, false); + assert.strictEqual(newState.day, TODAY); + assert.strictEqual(newState.targetSlot, 8); + assert.strictEqual(newState.sentDay, undefined, "stale sentDay dropped"); + assert.ok(!("sentDay" in newState), "sentDay key omitted entirely, not set to undefined"); + }); + + it("does not fire before the target slot", () => { + const state = { day: TODAY, targetSlot: 10 }; + const { fire, newState } = decideFire(state, TODAY, 9, 16, () => 0); + assert.strictEqual(fire, false); + assert.strictEqual(newState.sentDay, undefined); + }); + + it("fires when the current slot reaches the target and stamps sentDay", () => { + const state = { day: TODAY, targetSlot: 10 }; + const { fire, newState } = decideFire(state, TODAY, 10, 16, () => 0); + assert.strictEqual(fire, true); + assert.strictEqual(newState.sentDay, TODAY); + }); + + it("fires when the current slot has passed the target (robust to skipped runs)", () => { + const state = { day: TODAY, targetSlot: 5 }; + const { fire } = decideFire(state, TODAY, 12, 16, () => 0); + assert.strictEqual(fire, true); + }); + + it("never fires twice on the same day", () => { + const state = { day: TODAY, targetSlot: 5, sentDay: TODAY }; + const { fire, newState } = decideFire(state, TODAY, 15, 16, () => 0); + assert.strictEqual(fire, false); + assert.strictEqual(newState.sentDay, TODAY); + }); +}); + +describe("findStreakReminderRecipients (unit, mock Firestore)", () => { + type UserDoc = { lastClaimDate?: string; streak?: number; notificationPrefs?: Record }; + + function makeMockDb(users: Record) { + const db = { + collection: (name: string) => { + if (name !== "users") throw new Error(`Unexpected collection: ${name}`); + let preds: Array<{ field: string; op: string; val: unknown }> = []; + const q = { + where(f: string, o: string, v: unknown) { + preds = [...preds, { field: f, op: o, val: v }]; + return q; + }, + async get() { + return { + docs: Object.entries(users) + .filter(([, d]) => + preds.every(({ field, op, val }) => { + const v = (d as Record)[field]; + return op === "==" ? v === val : false; + }) + ) + .map(([id, d]) => ({ id, data: () => d as Record })), + }; + }, + }; + return q; + }, + }; + return db as unknown as import("firebase-admin").firestore.Firestore; + } + + it("returns only users whose lastClaimDate equals yesterday", async () => { + const db = makeMockDb({ + u1: { lastClaimDate: "2026-07-04", streak: 3 }, + u2: { lastClaimDate: "2026-07-05", streak: 9 }, // claimed today — excluded + u3: { lastClaimDate: "2026-07-04", streak: 1 }, + u4: { streak: 0 }, // never claimed — excluded + }); + const recipients = await findStreakReminderRecipients("2026-07-04", db); + const uids = recipients.map((r) => r.uid).sort(); + assert.deepStrictEqual(uids, ["u1", "u3"]); + assert.strictEqual(recipients.find((r) => r.uid === "u1")!.streak, 3); + }); +}); + +describe("sendStreakReminders", () => { + function recipient(uid: string, streak: number, prefs?: Record) { + return { uid, streak, fdata: { streak, notificationPrefs: prefs } as Record }; + } + + it("sends to eligible recipients and skips opted-out ones", async () => { + const sent: string[] = []; + const count = await sendStreakReminders( + [ + recipient("u1", 5), + recipient("u2", 2, { streakReminder: false }), + recipient("u3", 8, { streakReminder: true }), + ], + async (uid) => { sent.push(uid); } + ); + assert.strictEqual(count, 2); + assert.deepStrictEqual(sent.sort(), ["u1", "u3"]); + }); + + it("continues past a failing send (allSettled)", async () => { + const sent: string[] = []; + const count = await sendStreakReminders( + [recipient("u1", 5), recipient("u2", 3)], + async (uid) => { + if (uid === "u1") throw new Error("push failed"); + sent.push(uid); + } + ); + assert.strictEqual(count, 2, "both are eligible even if one send throws"); + assert.deepStrictEqual(sent, ["u2"]); + }); +}); diff --git a/lib/settings_screen.dart b/lib/settings_screen.dart index b61ae6d..b073e13 100644 --- a/lib/settings_screen.dart +++ b/lib/settings_screen.dart @@ -35,6 +35,7 @@ class _SettingsScreenState extends State { 'friendFirstScore': true, 'friendOvertakes': true, 'addedAsFriend': true, + 'streakReminder': true, }; bool _notifPrefsLoaded = false; final _userRepository = UserRepository(); @@ -80,6 +81,7 @@ class _SettingsScreenState extends State { 'friendFirstScore': raw['friendFirstScore'] as bool? ?? true, 'friendOvertakes': raw['friendOvertakes'] as bool? ?? true, 'addedAsFriend': raw['addedAsFriend'] as bool? ?? true, + 'streakReminder': raw['streakReminder'] as bool? ?? true, }; } _notifPrefsLoaded = true; @@ -771,6 +773,14 @@ class _SettingsScreenState extends State { value: _notifPrefs['addedAsFriend']!, onChanged: (v) => _setNotifPref('addedAsFriend', v), ), + SwitchListTile( + secondary: const Icon(Icons.local_fire_department_outlined), + title: const Text('Streak reminder'), + subtitle: const Text( + 'An evening nudge to claim before you lose your daily streak'), + value: _notifPrefs['streakReminder']!, + onChanged: (v) => _setNotifPref('streakReminder', v), + ), ], const Divider(height: 24), _sectionHeader('About'),