Skip to content

Commit be6e240

Browse files
authored
Merge pull request #85 from code418/feat/lifetime-leaderboard
feat: Lifetime leaderboard tab — unique postboxes claimed
2 parents 7ca3e3c + c1b9007 commit be6e240

7 files changed

Lines changed: 246 additions & 57 deletions

File tree

functions/src/_leaderboardUtils.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,3 +111,42 @@ export async function updateUserLeaderboards(
111111
}
112112
}
113113
}
114+
115+
interface LifetimeLeaderboardEntry {
116+
uid: string;
117+
displayName: string;
118+
uniquePostboxesClaimed: number;
119+
totalPoints: number;
120+
}
121+
122+
/**
123+
* Upserts the user's lifetime entry in leaderboards/lifetime.
124+
* Sorts by uniquePostboxesClaimed descending, keeps top 100.
125+
* periodKey is always "lifetime" — no rollover.
126+
*/
127+
export async function updateLifetimeLeaderboard(
128+
uid: string,
129+
displayName: string,
130+
uniquePostboxesClaimed: number,
131+
totalPoints: number,
132+
db: Firestore
133+
): Promise<void> {
134+
const ref = db.collection("leaderboards").doc("lifetime");
135+
await db.runTransaction(async (tx) => {
136+
const snap = await tx.get(ref);
137+
const existing: LifetimeLeaderboardEntry[] =
138+
(snap.data()?.entries as LifetimeLeaderboardEntry[]) ?? [];
139+
140+
const others = existing.filter((e) => e.uid !== uid);
141+
const updated: LifetimeLeaderboardEntry[] = [
142+
...others,
143+
...(uniquePostboxesClaimed > 0 || totalPoints > 0
144+
? [{ uid, displayName, uniquePostboxesClaimed, totalPoints }]
145+
: []),
146+
]
147+
.sort((a, b) => b.uniquePostboxesClaimed - a.uniquePostboxesClaimed)
148+
.slice(0, 100);
149+
150+
tx.set(ref, { periodKey: "lifetime", entries: updated }, { merge: false });
151+
});
152+
}

functions/src/startScoring.ts

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import * as functions from "firebase-functions";
33
import { getPoints } from "./_getPoints";
44
import { getTodayLondon } from "./_dateUtils";
55
import { lookupPostboxes } from "./_lookupPostboxes";
6-
import { updateUserLeaderboards } from "./_leaderboardUtils";
6+
import { updateUserLeaderboards, updateLifetimeLeaderboard } from "./_leaderboardUtils";
77
import { computeNewStreak } from "./_streakUtils";
88

99
const database = admin.firestore();
@@ -100,7 +100,7 @@ export const startScoring = functions.https.onCall(async (request) => {
100100
// Keep dailyClaim on the postbox doc for display purposes (shows
101101
// "someone found this today" in future UI); does not gate claiming.
102102
tx.set(postboxRef, { dailyClaim: { date: todayLondon, by: userid } }, { merge: true });
103-
return pts;
103+
return { key, pts };
104104
});
105105
})
106106
);
@@ -112,10 +112,17 @@ export const startScoring = functions.https.onCall(async (request) => {
112112
}
113113
}
114114

115-
const earnedPoints = claimSettled
116-
.filter((r): r is PromiseFulfilledResult<number> => r.status === "fulfilled" && typeof r.value === "number")
115+
const successfulClaims = claimSettled
116+
.filter((r): r is PromiseFulfilledResult<{ key: string; pts: number }> =>
117+
r.status === "fulfilled" &&
118+
r.value !== null &&
119+
typeof r.value === "object" &&
120+
"key" in r.value
121+
)
117122
.map((r) => r.value);
118123

124+
const earnedPoints = successfulClaims.map((c) => c.pts);
125+
119126
// If no points were earned but at least one transaction was rejected (as
120127
// opposed to being skipped because already claimed today), surface an error
121128
// so the client shows a retry prompt rather than "Already claimed today".
@@ -153,6 +160,43 @@ export const startScoring = functions.https.onCall(async (request) => {
153160
// updateUserLeaderboards uses Promise.allSettled internally and never
154161
// throws; individual period failures are logged inside the function.
155162
await updateUserLeaderboards(userid, displayName, todayLondon, database);
163+
164+
// ── Lifetime leaderboard update ─────────────────────────────────────────
165+
try {
166+
// For each postbox claimed in this session, check if the user has any
167+
// prior claim on a different day. Empty result = first-ever claim for
168+
// that postbox → increment unique counter by 1.
169+
const uniqueChecks = await Promise.all(
170+
successfulClaims.map(({ key }) =>
171+
database.collection("claims")
172+
.where("userid", "==", userid)
173+
.where("postboxes", "==", `/postbox/${key}`)
174+
.where("dailyDate", "<", todayLondon)
175+
.limit(1)
176+
.get()
177+
.then((snap) => snap.empty ? 1 : 0)
178+
)
179+
);
180+
const uniqueIncrement = uniqueChecks.reduce<number>((a, b) => a + b, 0);
181+
const lifetimePointsIncrement = earnedPoints.reduce((s, p) => s + p, 0);
182+
183+
await database.collection("users").doc(userid).set(
184+
{
185+
uniquePostboxesClaimed: admin.firestore.FieldValue.increment(uniqueIncrement),
186+
lifetimePoints: admin.firestore.FieldValue.increment(lifetimePointsIncrement),
187+
},
188+
{ merge: true }
189+
);
190+
191+
const updatedUser = await database.collection("users").doc(userid).get();
192+
const d = updatedUser.data() ?? {};
193+
const uniquePostboxesClaimed = (d.uniquePostboxesClaimed as number | undefined) ?? 0;
194+
const lifetimePoints = (d.lifetimePoints as number | undefined) ?? 0;
195+
196+
await updateLifetimeLeaderboard(userid, displayName, uniquePostboxesClaimed, lifetimePoints, database);
197+
} catch (lifetimeErr) {
198+
console.error("lifetime leaderboard update failed (non-fatal):", lifetimeErr);
199+
}
156200
}
157201

158202
return {

functions/src/updateDisplayName.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import "./adminInit";
22
import * as admin from "firebase-admin";
33
import * as functions from "firebase-functions";
44
import { getTodayLondon } from "./_dateUtils";
5-
import { updateUserLeaderboards } from "./_leaderboardUtils";
5+
import { updateUserLeaderboards, updateLifetimeLeaderboard } from "./_leaderboardUtils";
66
import { containsProfanity } from "./_profanityFilter";
77

88
/**
@@ -75,5 +75,15 @@ export const updateDisplayName = functions.https.onCall(async (request) => {
7575
const today = getTodayLondon();
7676
await updateUserLeaderboards(uid, name, today, admin.firestore());
7777

78+
try {
79+
const userDoc = await admin.firestore().collection("users").doc(uid).get();
80+
const d = userDoc.data() ?? {};
81+
const uniquePostboxesClaimed = (d.uniquePostboxesClaimed as number | undefined) ?? 0;
82+
const lifetimePoints = (d.lifetimePoints as number | undefined) ?? 0;
83+
await updateLifetimeLeaderboard(uid, name, uniquePostboxesClaimed, lifetimePoints, admin.firestore());
84+
} catch (lifetimeErr) {
85+
console.error("lifetime leaderboard display name update failed (non-fatal):", lifetimeErr);
86+
}
87+
7888
return { displayName: name };
7989
});

lib/friends_screen.dart

Lines changed: 31 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -260,32 +260,44 @@ class _FriendsScreenState extends State<FriendsScreen> {
260260
future: _nameCache[friendUid] ??=
261261
_firestore.collection('users').doc(friendUid).get(),
262262
builder: (context, nameSnap) {
263+
final isLoading = nameSnap.connectionState == ConnectionState.waiting;
263264
final displayName = nameSnap.data?.data()?['displayName'] as String?;
264-
final label = displayName ?? friendUid;
265-
final initials = label.length >= 2
266-
? label.substring(0, 2).toUpperCase()
267-
: label.toUpperCase();
265+
final initials = displayName != null && displayName.length >= 2
266+
? displayName.substring(0, 2).toUpperCase()
267+
: '?';
268268
return Card(
269269
child: ListTile(
270270
leading: CircleAvatar(
271271
backgroundColor: postalRed,
272-
child: Text(
273-
initials,
274-
style: const TextStyle(
275-
color: Colors.white,
276-
fontWeight: FontWeight.bold,
277-
fontSize: 13,
278-
),
279-
),
272+
child: isLoading
273+
? const SizedBox(
274+
width: 16,
275+
height: 16,
276+
child: CircularProgressIndicator(
277+
color: Colors.white,
278+
strokeWidth: 2,
279+
),
280+
)
281+
: Text(
282+
initials,
283+
style: const TextStyle(
284+
color: Colors.white,
285+
fontWeight: FontWeight.bold,
286+
fontSize: 13,
287+
),
288+
),
280289
),
281-
title: Text(label, overflow: TextOverflow.ellipsis),
282-
subtitle: Text(
283-
displayName != null ? friendUid : 'UID',
284-
overflow: TextOverflow.ellipsis,
285-
style: Theme.of(context).textTheme.bodySmall?.copyWith(
286-
color: Theme.of(context).colorScheme.onSurfaceVariant,
290+
title: isLoading
291+
? Text(
292+
'Loading...',
293+
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
294+
color: Theme.of(context).colorScheme.onSurfaceVariant,
295+
),
296+
)
297+
: Text(
298+
displayName ?? 'Unknown player',
299+
overflow: TextOverflow.ellipsis,
287300
),
288-
),
289301
trailing: IconButton(
290302
icon: Icon(Icons.person_remove_outlined,
291303
color: Theme.of(context).colorScheme.onSurfaceVariant),

lib/james_messages.dart

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,14 @@ abstract final class JamesMessages {
5959
["Add friends by UID to see them here. More the merrier."],
6060
);
6161

62+
static const navLifetimeScores = JamesMessage(
63+
'jamesNavLifetimeScores',
64+
[
65+
"This is the all-time tally — unique postboxes ever claimed. "
66+
"Claiming the same box twice doesn't count, so get out and explore!",
67+
],
68+
);
69+
6270
/// Returns the nav hint for tab [index] (0–3), or null for unknown indices.
6371
static JamesMessage? forTabIndex(int index) => switch (index) {
6472
0 => navNearby,

lib/james_strip.dart

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,22 @@ class _JamesStripState extends State<JamesStrip> with SingleTickerProviderStateM
9090
});
9191
}
9292

93+
void _dismiss() {
94+
if (!_slideCtrl.isAnimating && !_slideCtrl.isCompleted) return;
95+
_typeTimer?.cancel();
96+
_dismissTimer?.cancel();
97+
final messageToDismiss = _currentMessage;
98+
_slideCtrl.reverse().then((_) {
99+
if (mounted && _currentMessage == messageToDismiss) {
100+
widget.controller.clear();
101+
setState(() {
102+
_currentMessage = '';
103+
_charIndex = 0;
104+
});
105+
}
106+
});
107+
}
108+
93109
void _startDismissTimer() {
94110
final messageToDismiss = _currentMessage;
95111
// Give at least 3 s, plus ~40 ms per character so longer messages stay
@@ -115,7 +131,9 @@ class _JamesStripState extends State<JamesStrip> with SingleTickerProviderStateM
115131
final colorScheme = Theme.of(context).colorScheme;
116132
return SlideTransition(
117133
position: _slideAnim,
118-
child: Container(
134+
child: GestureDetector(
135+
onTap: _dismiss,
136+
child: Container(
119137
decoration: BoxDecoration(
120138
color: colorScheme.surfaceContainerHighest,
121139
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
@@ -151,6 +169,7 @@ class _JamesStripState extends State<JamesStrip> with SingleTickerProviderStateM
151169
),
152170
),
153171
),
172+
),
154173
);
155174
}
156175
}

0 commit comments

Comments
 (0)