Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <overpass-export.json> --project the-postbox-game`. Stores each postbox as `{ geohash (precision 9), geopoint, overpass_id, monarch?, reference?, county? }` in `postbox/{osm_<id>}` 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 <path>` (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).
Expand Down Expand Up @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions firestore.rules
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

}
}
33 changes: 33 additions & 0 deletions functions/src/_dateUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
2 changes: 1 addition & 1 deletion functions/src/_notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
export async function sendToUser(uid: string, title: string, body: string): Promise<void> {
const doc = await database.collection("fcmTokens").doc(uid).get();
const tokens: string[] = (doc.data()?.tokens as string[] | undefined) ?? [];
if (tokens.length === 0) return;
Expand Down
2 changes: 2 additions & 0 deletions functions/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -19,6 +20,7 @@ export {
onUserDeleted,
updateDisplayName,
newDayScoreboard,
streakReminder,
registerFcmToken,
onFriendAdded,
userClaimHistory,
Expand Down
1 change: 1 addition & 0 deletions functions/src/onUserCreated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
180 changes: 180 additions & 0 deletions functions/src/streakReminder.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | undefined
): boolean {
const prefs = fdata?.notificationPrefs as Record<string, boolean> | 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<string, unknown>;
}

/**
* 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<Recipient[]> {
const snap = await database
.collection("users")
.where("lastClaimDate", "==", yesterday)
.get();
return snap.docs.map((doc) => {
const fdata = doc.data() as Record<string, unknown>;
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<void> = sendToUser
): Promise<number> {
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})`
);
}
);
Loading
Loading