Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
73eaecb
refactor(functions): extract route-planner helpers into shared module
code418 May 15, 2026
5ab8421
refactor(functions): hide beam-search dedup key from SearchState
code418 May 15, 2026
cd0b64e
feat(functions): add routePostboxes callable for en-route postbox counts
code418 May 15, 2026
7d5017f
refactor(claim): extract ClaimQuizSheet for reuse by route mode
code418 May 15, 2026
f34d9f5
refactor(claim): dedup streak listener and tidy ClaimQuizSheet padding
code418 May 15, 2026
6910898
feat(location): add positionStream for live route mode
code418 May 15, 2026
789b712
feat(route): add RouteSession and destination picker scaffold
code418 May 15, 2026
1ad91df
feat(route): add Nominatim address search to destination picker
code418 May 15, 2026
9b40e34
feat(route): add RoutePreviewScreen with pace toggle and debounced count
code418 May 15, 2026
3dfb995
feat(route): add LiveRouteScreen with streaming GPS and fuzzy compass
code418 May 15, 2026
60fbe4d
feat(route): completion screen and arrival notification
code418 May 15, 2026
d70c2c0
feat(route): wire entry, named route, and James message pool
code418 May 15, 2026
1466012
test(route): cover ClaimQuizSheet proximity trigger and dedupe
code418 May 15, 2026
f45e951
docs: record Route Mode flow and routePostboxes callable
code418 May 15, 2026
651d06b
build(android): bump compileSdk to 36, AGP to 8.9.1, Gradle to 8.11.1
code418 May 15, 2026
c205a99
merge: resolve claim.dart conflict, drop obsolete scan branches
code418 May 15, 2026
723b10c
Merge branch 'master' into feat/route-mode
code418 May 17, 2026
2cad224
fix(claim): accept any nearby valid cipher in the quiz
code418 May 17, 2026
c9b08ed
fix(route): include distance on slim postboxes so the auto-claim trig…
code418 May 17, 2026
a5b85e5
fix(import): set totalPostboxes from collection count, not write count
code418 May 17, 2026
94ef721
fix(rescore): don't stamp today's period marker when the user has no …
code418 May 17, 2026
0db74b3
fix(backfill): match recompute's guard on stamping today's period marker
code418 May 17, 2026
2bf5519
fix(route): bail _onPosition after arrival so leftover GPS frames don…
code418 May 17, 2026
4e4855a
fix(rescore): make repointClaimsForPostbox idempotent on retry
code418 May 17, 2026
c193905
fix(route): make 'Plan another route' actually start a new route
code418 May 17, 2026
3c3817d
fix(reports): don't pre-fill cypher report with the current value
code418 May 17, 2026
0a97074
chore: ignore Android build/ outputs
code418 May 17, 2026
5f9479f
delete junk
code418 May 17, 2026
8386672
feat(admin): add copy button for osmChange path on already-accepted r…
code418 May 17, 2026
7c7940a
fix(route): flip preview to loading immediately on param change
code418 May 17, 2026
61b5204
perf(admin): cache photo Storage URL future across rebuilds
code418 May 17, 2026
ad172f2
test(route): cover preview's slider-change-flips-to-loading guard
code418 May 17, 2026
c7c836c
perf(admin): cache the admin-claim Future across screen rebuilds
code418 May 17, 2026
e06eef5
fix(compass): guard FlutterCompass.events against MissingPluginException
code418 May 17, 2026
3422730
fix(login): disable Google button while a sign-in is in flight
code418 May 17, 2026
9d64ade
fix(route): cancel destination picker's debounce timer in dispose
code418 May 17, 2026
cf5f1f3
fix(route): close NominatimService's HTTP client on picker dispose
code418 May 17, 2026
3b8dbe9
perf(analytics): make observer a single static instance not a getter
code418 May 17, 2026
adf70c6
fix(rescore): clamp lifetime + county totals at 0 on negative deltas
code418 May 17, 2026
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
**/android/captures/
**/android/**/GeneratedPluginRegistrant.java
**/android/.gradle
**/android/build/
**/android/app/build/
**/android/gradlew
**/android/gradlew.bat
**/android/**/gradle-wrapper.jar
Expand Down
13 changes: 12 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,17 @@ Display names are stored by the `onUserCreated` Cloud Function in `users/{uid}.d

The app shows a **fuzzy compass** that gives the user an **indication** of where **claimed** and **unclaimed** postboxes are nearby (e.g. rough direction or "something in that direction"), **without** giving precise directions or exact locations. Goal: encourage exploration rather than turn-by-turn navigation. Implementation: `lib/fuzzy_compass.dart` — `to8Sectors(counts)` merges 16-wind into 8-wind sectors, `vagueLabel(count)` returns None/One/A few/Several. `_FuzzyCompassPainter` draws claimed sectors grey and unclaimed sectors red, with a North marker. `claimedCompassCounts` and `unclaimedCompassCounts` are returned by the `nearbyPostboxes` Cloud Function. Avoid showing exact bearings or distances that would allow pinpointing.

## Route Mode

A player-facing flow ("Walk to a destination" on the Nearby tab) that lets the user pick a destination and walk to it, claiming en-route postboxes along the way. The destination is shown **precisely** (the user picked it); postboxes stay **fuzzy** (same applyUserClaims contract as Nearby — server strips `geopoint`/`geohash` before sending). Flow lives under `lib/route/`:

- `route_session.dart`: a mutable session holder — start, destination, mode (corridor|detour), corridor metres (50–500), detour minutes (0–60), pace (walk 4.5 km/h or jog 8.5 km/h), and the most recent `routePostboxes` response.
- `destination_picker_screen.dart`: tap-on-map (via `PostboxMap.onTap`) AND a Nominatim search bar backed by `nominatim_service.dart` (UK-biased, 1 req/sec throttle, mandatory `User-Agent`).
- `route_preview_screen.dart`: pace `SegmentedButton`, corridor slider, extra-time slider (>0 switches mode to detour), debounced `routePostboxes` call, "X postboxes worth Y points" headline, "Start route" CTA.
- `live_route_screen.dart`: streaming GPS via `LocationService.positionStream()`, precise destination distance + bearing arrow + ETA at pace, fuzzy compass rotated to face the destination bearing (`route_compass_view.dart`), passive ambient layout. "Where now, postie?" button picks the highest-count fuzzy sector relative to the destination heading and fires a hint via James (player addresses James as "postie", James never refers to himself that way). Periodic `nearbyPostboxes` scan (≥12s OR ≥20m moved); when a returned box has `distance ≤ claimRadius && !claimedToday`, the reusable `ClaimQuizSheet` (extracted from `lib/claim.dart` into `lib/widgets/claim_quiz_sheet.dart`) is shown as a `compact:true` modal bottom sheet — same callables, same quiz, same anti-cheat path. 60s dedupe so the same postbox doesn't re-prompt after dismissal. On arrival (distance < 25 m), fires a local notification via `route_notifications.dart` (wraps `flutter_local_notifications`) then `pushReplacement`s to `route_completion_screen.dart`.
- Backend callable `routePostboxes` (`functions/src/routePostboxes.ts`) takes start/dest + mode + corridor or detour params, fetches via the same 9-cell geohash prefix pattern as `_lookupPostboxes.ts`, filters out the user's claimed-today set, then either sums `pointsForMonarch` over the corridor (`filterToCorridor` in `_routePlanner.ts`) OR runs the orienteering `beamSearchOrienteering` over the time ellipse (`filterToEllipse`). Returns ONLY `{ count, points, directDistanceM, budgetDistanceM, warnings }` — never postbox IDs, coords, or per-monarch breakdowns. 30 km destination cap.
- Shared algorithm module `functions/src/_routePlanner.ts` is reused by both the new callable and the internal CLI `functions/src/scripts/plan_route.ts` (single source of truth for the routing maths).

## Problem reporting, admin review & OSM corrections

Fully implemented end-to-end.
Expand All @@ -41,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`. 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). `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`, `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.
- **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`. 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. Use `--dry-run --limit 5` to preview. 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
29 changes: 17 additions & 12 deletions functions/backfill_lifetime_scores.js
Original file line number Diff line number Diff line change
Expand Up @@ -279,20 +279,25 @@ async function main() {
const chunk = updates.slice(i, i + BATCH_SIZE);
const batch = db.batch();
for (const u of chunk) {
// Only stamp a period marker when the user actually has claims in that
// period. Writing today's marker for a user with 0 daily points would
// make shouldNotifyFirstClaim / shouldNotifyOvertake treat them as
// already-having-claimed-today and suppress legitimate friend
// notifications for the rest of the day. Matches the corresponding
// guard in functions/src/_recomputeScores.ts.
const userUpdate = {
uniquePostboxesClaimed: u.uniquePostboxesClaimed,
lifetimePoints: u.lifetimePoints,
dailyPoints: u.dailyPoints,
weeklyPoints: u.weeklyPoints,
monthlyPoints: u.monthlyPoints,
};
if (u.dailyPoints > 0) userUpdate.dailyDate = today;
if (u.weeklyPoints > 0) userUpdate.weekStart = weekStart;
if (u.monthlyPoints > 0) userUpdate.monthStart = monthStart;
batch.set(
db.collection('users').doc(u.uid),
{
uniquePostboxesClaimed: u.uniquePostboxesClaimed,
lifetimePoints: u.lifetimePoints,
dailyPoints: u.dailyPoints,
weeklyPoints: u.weeklyPoints,
monthlyPoints: u.monthlyPoints,
// Markers so the Friends-only leaderboard doesn't zero out these
// totals on the staleness check (see leaderboard_screen.dart).
dailyDate: today,
weekStart,
monthStart,
},
userUpdate,
{ merge: true }
);
}
Expand Down
10 changes: 8 additions & 2 deletions functions/import_postboxes.js
Original file line number Diff line number Diff line change
Expand Up @@ -271,12 +271,18 @@ async function main() {

// Only persist the total count when importing without a --limit so the
// stored value reflects the full dataset (not a subset used for testing).
// Read the actual collection count rather than `written` — that figure
// excludes both the docs we skipped (correctedBy) and the manual_* docs
// added by accepted missing-postbox reports, so using it as the lifetime
// leaderboard's "X of Y" denominator would silently undercount.
if (opts.limit === Infinity) {
const countSnap = await col.count().get();
const totalPostboxes = countSnap.data().count;
await db.collection('meta').doc('stats').set(
{ totalPostboxes: written },
{ totalPostboxes },
{ merge: true }
);
console.log(`meta/stats.totalPostboxes updated to ${written.toLocaleString()}.`);
console.log(`meta/stats.totalPostboxes updated to ${totalPostboxes.toLocaleString()} (collection count).`);
}
}

Expand Down
16 changes: 15 additions & 1 deletion functions/src/_nearbyUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ import { LookupResult } from "./types";
export interface SlimPostbox {
monarch?: string;
claimedToday: boolean;
/** Metres from the user's scan position. Present whenever the underlying
* LookupResult computed a distance (always, in current callers). Omitted
* only as a defensive fallback — clients must treat absence as "unknown".
*
* Required for the Route Mode auto-claim trigger: it scans at 1 km radius
* for the fuzzy compass, then opens the claim sheet when any returned box
* is within the 30 m claim radius. Privacy impact is bounded — the user
* is at most `meters` from the box (≤ 2 km server cap), and they already
* have their own GPS fix, so multiple coordinated scans would be needed
* to triangulate exact postbox coordinates. */
distance?: number;
}

export interface UserSpecificResult {
Expand All @@ -28,12 +39,15 @@ export function applyUserClaims(
full: LookupResult,
userClaimedKeys: Set<string>
): UserSpecificResult {
// Strip precise location fields; override claimedToday per-user.
// Strip precise location fields; override claimedToday per-user. Distance is
// carried over so the client can show range cues and so Route Mode can detect
// when an unclaimed box is within the claim radius.
const slimPostboxes: Record<string, SlimPostbox> = {};
for (const [id, pb] of Object.entries(full.postboxes)) {
slimPostboxes[id] = {
...(pb.monarch !== undefined ? { monarch: pb.monarch } : {}),
claimedToday: userClaimedKeys.has(id),
...(typeof pb.distance === "number" ? { distance: pb.distance } : {}),
};
}

Expand Down
68 changes: 50 additions & 18 deletions functions/src/_recomputeScores.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,23 @@ export async function repointClaimsForPostbox(
const plain = !(typeof newMonarch === "string" && newMonarch.length > 0);

const batches: admin.firestore.WriteBatch[] = [];
let rewritten = 0;
for (let i = 0; i < snap.docs.length; i += WRITE_BATCH_SIZE) {
const batch = db.batch();
let writesInBatch = 0;
for (const doc of snap.docs.slice(i, i + WRITE_BATCH_SIZE)) {
const d = doc.data();
const uid = d.userid as string | undefined;
const oldPts = typeof d.points === "number" ? d.points : 0;
const currentMonarch = (typeof d.monarch === "string" && d.monarch.length > 0) ? d.monarch : null;
const targetMonarch = plain ? null : newMonarch;
// Skip claims that already match the target state. Two reasons:
// - Avoids re-stamping correctedFromMonarch / correctedFromPoints with
// the post-correction values (which would happen on a retry after a
// partial-success run), corrupting the audit trail by losing the
// real pre-correction values.
// - Saves writes on no-op admin retries.
if (currentMonarch === targetMonarch && oldPts === newPts) continue;
const update: Record<string, unknown> = {
points: newPts,
correctedAt: now,
Expand All @@ -75,13 +86,17 @@ export async function repointClaimsForPostbox(
monarch: plain ? admin.firestore.FieldValue.delete() : newMonarch,
};
batch.set(doc.ref, update, { merge: true });
writesInBatch++;
if (uid) deltaByUid.set(uid, (deltaByUid.get(uid) ?? 0) + (newPts - oldPts));
}
batches.push(batch);
if (writesInBatch > 0) {
batches.push(batch);
rewritten += writesInBatch;
}
}
await Promise.all(batches.map((b) => b.commit()));

return { deltaByUid, claimCount: snap.docs.length };
return { deltaByUid, claimCount: rewritten };
}

/**
Expand Down Expand Up @@ -146,22 +161,30 @@ export async function recomputeUserAggregates(
const freshDisplayName =
(uSnap.data()?.displayName as string | undefined) || displayName;
const currentLifetime = (uSnap.data()?.lifetimePoints as number | undefined) ?? 0;
const newLifetimePoints = currentLifetime + countyDelta;
// Clamp at 0 in case earlier bugs left lifetimePoints under-reported
// and a points-reducing cypher correction's delta would otherwise push
// it negative. Defensive — in healthy state currentLifetime is always
// ≥ |countyDelta| because countyDelta is the sum of point deltas on
// claims the user actually made.
const newLifetimePoints = Math.max(0, currentLifetime + countyDelta);
const currentUnique = (uSnap.data()?.uniquePostboxesClaimed as number | undefined) ?? 0;
tx.set(
userRef,
{
lifetimePoints: newLifetimePoints,
maxDailyPoints,
dailyPoints: periodSums.dailyPoints,
dailyDate: today,
weeklyPoints: periodSums.weeklyPoints,
weekStart,
monthlyPoints: periodSums.monthlyPoints,
monthStart,
},
{ merge: true }
);
// Only stamp the per-period markers when the user actually has claims
// in that period. Otherwise the rescore would overwrite a stale-but-
// truthful marker (e.g. yesterday) with today, and downstream code
// that treats `dailyDate === today` as evidence of a claim (notably
// shouldNotifyFirstClaim / shouldNotifyOvertake) would wrongly skip
// notifications for a user who hasn't claimed today.
const userUpdate: Record<string, unknown> = {
lifetimePoints: newLifetimePoints,
maxDailyPoints,
dailyPoints: periodSums.dailyPoints,
weeklyPoints: periodSums.weeklyPoints,
monthlyPoints: periodSums.monthlyPoints,
};
if (periodSums.dailyPoints > 0) userUpdate.dailyDate = today;
if (periodSums.weeklyPoints > 0) userUpdate.weekStart = weekStart;
if (periodSums.monthlyPoints > 0) userUpdate.monthStart = monthStart;
tx.set(userRef, userUpdate, { merge: true });
const existing = (lSnap.data()?.entries ?? []) as LifetimeLeaderboardEntry[];
const updated = mergeLifetimeEntries(existing, uid, freshDisplayName, currentUnique, newLifetimePoints);
tx.set(lifetimeRef, { periodKey: "lifetime", entries: updated }, { merge: false });
Expand All @@ -182,7 +205,16 @@ export async function recomputeUserAggregates(
await db.runTransaction(async (tx) => {
const [sSnap, lbSnap, uSnap] = await Promise.all([tx.get(statsRef), tx.get(lbRef), tx.get(userRef)]);
const prev = sSnap.data() ?? {};
const newTotal = ((prev.totalPoints as number | undefined) ?? 0) + countyDelta;
// Clamp at 0: if the stats doc doesn't exist (legacy claim made
// before per-county tracking, never backfilled), a negative
// countyDelta from a points-reducing cypher correction would
// otherwise write negative totalPoints. The leaderboard then
// sorts the user below users with 0 points, which is the right
// user-facing result.
const newTotal = Math.max(
0,
((prev.totalPoints as number | undefined) ?? 0) + countyDelta,
);
const newUnique = (prev.uniquePostboxesClaimed as number | undefined) ?? 0;
const name = (prev.county as string | undefined) || countyName || countySlugVal;
const freshDisplayName =
Expand Down
Loading
Loading