Skip to content
Draft
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
53 changes: 53 additions & 0 deletions docs/plans/postbox-of-the-day.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Plan — Postbox of the Day

- **Status:** Proposed
- **Effort:** Medium
- **Firebase services:** Cloud Functions (scheduled), Firestore, FCM

## Overview

Each day, the backend picks a single postbox to be "Postbox of the Day" (POTD). Claiming POTD awards 2× points. Everyone gets a morning push (James-voiced teaser with a rough region hint) and in-app banner.

## Data model

- `postboxOfTheDay/{YYYY-MM-DD}` = `{ postboxId, monarch, regionOutcode, announcedAt, expiresAt }`.
- Optional `postboxOfTheDay/current` pointer doc updated atomically.
- `claims` entries gain an optional `bonusReason: "potd"` tag.

## Selection algorithm

- Scheduled Cloud Function `selectPostboxOfTheDay` runs at 06:00 London time.
- Weighted random pick favouring rarer monarchs but excluding those picked in the last 30 days (stored in `postboxOfTheDay/history`).
- Must have been claimed at least once historically (avoid dead locations).

## Notification

- Send to topic `rare_finds` or `potd` topic (created alongside this).
- Message: "James reckons something special is hiding around {OUTCODE}... Go have a peek."
- No exact coordinates in the push payload.

## Claim-path changes

- `startScoring` checks if the claimed postbox matches `postboxOfTheDay/current.postboxId` and applies the 2× multiplier.
- Records `bonusReason: "potd"` on the claim and emits an Analytics event `potd_claimed`.

## Client changes

- Home screen banner above the navigation bar when POTD is active and unclaimed by this user.
- Tapping the banner deep-links into Nearby with a James line: "Today's special is somewhere near here..."
- Use the existing `JamesController` to surface a bespoke line.

## Rollout

- Flag `feature_potd_enabled`.
- Soft launch: enable without 2× multiplier for a week to validate selection + notification; turn on multiplier in week 2.

## Risks

- Picking a postbox in an unpopulated area = nobody claims. Add a weight penalty for low-density regions using existing claim history.
- Push fatigue — respect `notificationPrefs` and quiet hours.

## Testing

- Unit test selection algorithm with a seeded RNG and fixture history.
- Integration test (firebase-functions-test) for the 2× multiplier path in `startScoring`.
11 changes: 7 additions & 4 deletions lib/claim.dart
Original file line number Diff line number Diff line change
Expand Up @@ -125,11 +125,14 @@ class _ClaimState extends State<Claim> with TickerProviderStateMixin {
postboxes[entry.key as String] =
Map<String, dynamic>.from(entry.value as Map);
}
// Cloud Functions serialise JS numbers as either int or double;
// assigning a double to a typed int field throws, so normalise via num.
int asInt(dynamic v) => (v as num?)?.toInt() ?? 0;
setState(() {
_count = counts['total'] ?? 0;
_maxPoints = points['max'] ?? 0;
_minPoints = points['min'] ?? 0;
_claimedToday = counts['claimedToday'] ?? 0;
_count = asInt(counts['total']);
_maxPoints = asInt(points['max']);
_minPoints = asInt(points['min']);
_claimedToday = asInt(counts['claimedToday']);
_postboxes = postboxes;
currentStage = _count > 0 ? ClaimStage.results : ClaimStage.empty;
});
Expand Down
19 changes: 11 additions & 8 deletions lib/nearby.dart
Original file line number Diff line number Diff line change
Expand Up @@ -113,23 +113,26 @@ class _NearbyState extends State<Nearby> {
final claimedCompass = data['claimedCompass'] != null
? Map<String, dynamic>.from(data['claimedCompass'] as Map)
: <String, dynamic>{};
// Cloud Functions serialise JS numbers as either int or double; assigning
// a double to a typed int field throws, so normalise every count via num.
int asInt(dynamic v) => (v as num?)?.toInt() ?? 0;
setState(() {
_count = counts['total'] ?? 0;
_maxPoints = pts['max'] ?? 0;
_minPoints = pts['min'] ?? 0;
_count = asInt(counts['total']);
_maxPoints = asInt(pts['max']);
_minPoints = asInt(pts['min']);
currentStage = NearbyStage.results;
for (final dir in const [
'N', 'NNE', 'NE', 'ENE', 'E', 'ESE',
'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW',
'W', 'WNW', 'NW', 'NNW',
]) {
_compassCounts[dir] = compass[dir] ?? 0;
_claimedCompassCounts[dir] = claimedCompass[dir] ?? 0;
_compassCounts[dir] = asInt(compass[dir]);
_claimedCompassCounts[dir] = asInt(claimedCompass[dir]);
}
_claimedToday = counts['claimedToday'] ?? 0;
_claimedToday = asInt(counts['claimedToday']);
for (final cipher in MonarchInfo.all) {
_cipherTotals[cipher] = counts[cipher] ?? 0;
_cipherClaimed[cipher] = counts['${cipher}_claimed'] ?? 0;
_cipherTotals[cipher] = asInt(counts[cipher]);
_cipherClaimed[cipher] = asInt(counts['${cipher}_claimed']);
}
_lastScanned = DateTime.now();
});
Expand Down
6 changes: 4 additions & 2 deletions lib/wear/wear_claim_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,10 @@ class _WearClaimPageState extends State<WearClaimPage> {
});
if (!mounted) return;
final counts = result.data['counts'] ?? {};
final total = (counts['total'] as int?) ?? 0;
final claimed = (counts['claimedToday'] as int?) ?? 0;
// Cloud Functions serialise JS numbers as either int or double; `as int?`
// would throw on a double, so normalise via num.
final total = (counts['total'] as num?)?.toInt() ?? 0;
final claimed = (counts['claimedToday'] as num?)?.toInt() ?? 0;
_postboxes = Map<String, dynamic>.from(result.data['postboxes'] ?? {});
setState(() {
_count = total;
Expand Down
17 changes: 14 additions & 3 deletions lib/wear/wear_compass_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,13 @@ class _WearCompassPageState extends State<WearCompassPage> {
final counts = result.data['counts'] ?? {};
final compassRaw = result.data['compass'] ?? {};
setState(() {
_totalCount = (counts['total'] as int?) ?? 0;
_compassCounts = Map<String, int>.from(compassRaw);
// Cloud Functions serialise JS numbers as either int or double;
// `as int?` would throw on a double, so normalise via num.
_totalCount = (counts['total'] as num?)?.toInt() ?? 0;
_compassCounts = {
for (final e in (compassRaw as Map).entries)
e.key as String: (e.value as num).toInt(),
};
_stage = _CompassStage.results;
});
// Haptic pulse to signal scan complete.
Expand Down Expand Up @@ -206,7 +211,13 @@ class _WearCompassPageState extends State<WearCompassPage> {
angle: -rotation,
child: CustomPaint(
size: Size(size, size),
painter: FuzzyCompassPainter(sectors: sectorValues),
// Pass rotation so the painter counter-rotates the 'N' label;
// otherwise it spins with the canvas and is unreadable whenever
// the watch is not pointing north.
painter: FuzzyCompassPainter(
sectors: sectorValues,
rotation: rotation,
),
),
),
// Centre count overlay (doesn't rotate)
Expand Down
8 changes: 6 additions & 2 deletions lib/wear/wear_status_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,14 @@ class WearStatusPage extends StatefulWidget {
class _WearStatusPageState extends State<WearStatusPage> {
final StreakService _streakService = StreakService();
late final Stream<int?> _streakStream = _streakService.streakStream();
// Cache so the StreamBuilder doesn't re-subscribe (and briefly show empty
// stats) on every rebuild. Computed once in initState from the uid at mount.
late final Stream<DocumentSnapshot<Map<String, dynamic>>>? _userDocStream =
_buildUserDocStream();

String? get _displayName => FirebaseAuth.instance.currentUser?.displayName;

Stream<DocumentSnapshot<Map<String, dynamic>>>? _userDocStream() {
Stream<DocumentSnapshot<Map<String, dynamic>>>? _buildUserDocStream() {
final uid = FirebaseAuth.instance.currentUser?.uid;
if (uid == null) return null;
return FirebaseFirestore.instance.collection('users').doc(uid).snapshots();
Expand Down Expand Up @@ -71,7 +75,7 @@ class _WearStatusPageState extends State<WearStatusPage> {

// Lifetime points
StreamBuilder<DocumentSnapshot<Map<String, dynamic>>>(
stream: _userDocStream(),
stream: _userDocStream,
builder: (context, snap) {
final data = snap.data?.data();
final lifetimePoints =
Expand Down
Loading