Skip to content
Merged
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
39 changes: 39 additions & 0 deletions functions/src/_leaderboardUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,42 @@ export async function updateUserLeaderboards(
}
}
}

interface LifetimeLeaderboardEntry {
uid: string;
displayName: string;
uniquePostboxesClaimed: number;
totalPoints: number;
}

/**
* Upserts the user's lifetime entry in leaderboards/lifetime.
* Sorts by uniquePostboxesClaimed descending, keeps top 100.
* periodKey is always "lifetime" — no rollover.
*/
export async function updateLifetimeLeaderboard(
uid: string,
displayName: string,
uniquePostboxesClaimed: number,
totalPoints: number,
db: Firestore
): Promise<void> {
const ref = db.collection("leaderboards").doc("lifetime");
await db.runTransaction(async (tx) => {
const snap = await tx.get(ref);
const existing: LifetimeLeaderboardEntry[] =
(snap.data()?.entries as LifetimeLeaderboardEntry[]) ?? [];

const others = existing.filter((e) => e.uid !== uid);
const updated: LifetimeLeaderboardEntry[] = [
...others,
...(uniquePostboxesClaimed > 0 || totalPoints > 0
? [{ uid, displayName, uniquePostboxesClaimed, totalPoints }]
: []),
]
.sort((a, b) => b.uniquePostboxesClaimed - a.uniquePostboxesClaimed)
.slice(0, 100);

tx.set(ref, { periodKey: "lifetime", entries: updated }, { merge: false });
});
}
52 changes: 48 additions & 4 deletions functions/src/startScoring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import * as functions from "firebase-functions";
import { getPoints } from "./_getPoints";
import { getTodayLondon } from "./_dateUtils";
import { lookupPostboxes } from "./_lookupPostboxes";
import { updateUserLeaderboards } from "./_leaderboardUtils";
import { updateUserLeaderboards, updateLifetimeLeaderboard } from "./_leaderboardUtils";
import { computeNewStreak } from "./_streakUtils";

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

const earnedPoints = claimSettled
.filter((r): r is PromiseFulfilledResult<number> => r.status === "fulfilled" && typeof r.value === "number")
const successfulClaims = claimSettled
.filter((r): r is PromiseFulfilledResult<{ key: string; pts: number }> =>
r.status === "fulfilled" &&
r.value !== null &&
typeof r.value === "object" &&
"key" in r.value
)
.map((r) => r.value);

const earnedPoints = successfulClaims.map((c) => c.pts);

// If no points were earned but at least one transaction was rejected (as
// opposed to being skipped because already claimed today), surface an error
// so the client shows a retry prompt rather than "Already claimed today".
Expand Down Expand Up @@ -153,6 +160,43 @@ export const startScoring = functions.https.onCall(async (request) => {
// updateUserLeaderboards uses Promise.allSettled internally and never
// throws; individual period failures are logged inside the function.
await updateUserLeaderboards(userid, displayName, todayLondon, database);

// ── Lifetime leaderboard update ─────────────────────────────────────────
try {
// For each postbox claimed in this session, check if the user has any
// prior claim on a different day. Empty result = first-ever claim for
// that postbox → increment unique counter by 1.
const uniqueChecks = await Promise.all(
successfulClaims.map(({ key }) =>
database.collection("claims")
.where("userid", "==", userid)
.where("postboxes", "==", `/postbox/${key}`)
.where("dailyDate", "<", todayLondon)
.limit(1)
.get()
.then((snap) => snap.empty ? 1 : 0)
)
);
const uniqueIncrement = uniqueChecks.reduce<number>((a, b) => a + b, 0);
const lifetimePointsIncrement = earnedPoints.reduce((s, p) => s + p, 0);

await database.collection("users").doc(userid).set(
{
uniquePostboxesClaimed: admin.firestore.FieldValue.increment(uniqueIncrement),
lifetimePoints: admin.firestore.FieldValue.increment(lifetimePointsIncrement),
},
{ merge: true }
);

const updatedUser = await database.collection("users").doc(userid).get();
const d = updatedUser.data() ?? {};
const uniquePostboxesClaimed = (d.uniquePostboxesClaimed as number | undefined) ?? 0;
const lifetimePoints = (d.lifetimePoints as number | undefined) ?? 0;

await updateLifetimeLeaderboard(userid, displayName, uniquePostboxesClaimed, lifetimePoints, database);
} catch (lifetimeErr) {
console.error("lifetime leaderboard update failed (non-fatal):", lifetimeErr);
}
}

return {
Expand Down
12 changes: 11 additions & 1 deletion functions/src/updateDisplayName.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import "./adminInit";
import * as admin from "firebase-admin";
import * as functions from "firebase-functions";
import { getTodayLondon } from "./_dateUtils";
import { updateUserLeaderboards } from "./_leaderboardUtils";
import { updateUserLeaderboards, updateLifetimeLeaderboard } from "./_leaderboardUtils";
import { containsProfanity } from "./_profanityFilter";

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

try {
const userDoc = await admin.firestore().collection("users").doc(uid).get();
const d = userDoc.data() ?? {};
const uniquePostboxesClaimed = (d.uniquePostboxesClaimed as number | undefined) ?? 0;
const lifetimePoints = (d.lifetimePoints as number | undefined) ?? 0;
await updateLifetimeLeaderboard(uid, name, uniquePostboxesClaimed, lifetimePoints, admin.firestore());
} catch (lifetimeErr) {
console.error("lifetime leaderboard display name update failed (non-fatal):", lifetimeErr);
}

return { displayName: name };
});
50 changes: 31 additions & 19 deletions lib/friends_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -260,32 +260,44 @@ class _FriendsScreenState extends State<FriendsScreen> {
future: _nameCache[friendUid] ??=
_firestore.collection('users').doc(friendUid).get(),
builder: (context, nameSnap) {
final isLoading = nameSnap.connectionState == ConnectionState.waiting;
final displayName = nameSnap.data?.data()?['displayName'] as String?;
final label = displayName ?? friendUid;
final initials = label.length >= 2
? label.substring(0, 2).toUpperCase()
: label.toUpperCase();
final initials = displayName != null && displayName.length >= 2
? displayName.substring(0, 2).toUpperCase()
: '?';
return Card(
child: ListTile(
leading: CircleAvatar(
backgroundColor: postalRed,
child: Text(
initials,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 13,
),
),
child: isLoading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
)
: Text(
initials,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 13,
),
),
),
title: Text(label, overflow: TextOverflow.ellipsis),
subtitle: Text(
displayName != null ? friendUid : 'UID',
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
title: isLoading
? Text(
'Loading...',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
)
: Text(
displayName ?? 'Unknown player',
overflow: TextOverflow.ellipsis,
),
),
trailing: IconButton(
icon: Icon(Icons.person_remove_outlined,
color: Theme.of(context).colorScheme.onSurfaceVariant),
Expand Down
8 changes: 8 additions & 0 deletions lib/james_messages.dart
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,14 @@ abstract final class JamesMessages {
["Add friends by UID to see them here. More the merrier."],
);

static const navLifetimeScores = JamesMessage(
'jamesNavLifetimeScores',
[
"This is the all-time tally — unique postboxes ever claimed. "
"Claiming the same box twice doesn't count, so get out and explore!",
],
);

/// Returns the nav hint for tab [index] (0–3), or null for unknown indices.
static JamesMessage? forTabIndex(int index) => switch (index) {
0 => navNearby,
Expand Down
21 changes: 20 additions & 1 deletion lib/james_strip.dart
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,22 @@ class _JamesStripState extends State<JamesStrip> with SingleTickerProviderStateM
});
}

void _dismiss() {
if (!_slideCtrl.isAnimating && !_slideCtrl.isCompleted) return;
_typeTimer?.cancel();
_dismissTimer?.cancel();
final messageToDismiss = _currentMessage;
_slideCtrl.reverse().then((_) {
if (mounted && _currentMessage == messageToDismiss) {
widget.controller.clear();
setState(() {
_currentMessage = '';
_charIndex = 0;
});
}
});
}

void _startDismissTimer() {
final messageToDismiss = _currentMessage;
// Give at least 3 s, plus ~40 ms per character so longer messages stay
Expand All @@ -115,7 +131,9 @@ class _JamesStripState extends State<JamesStrip> with SingleTickerProviderStateM
final colorScheme = Theme.of(context).colorScheme;
return SlideTransition(
position: _slideAnim,
child: Container(
child: GestureDetector(
onTap: _dismiss,
child: Container(
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
Expand Down Expand Up @@ -151,6 +169,7 @@ class _JamesStripState extends State<JamesStrip> with SingleTickerProviderStateM
),
),
),
),
);
}
}
Expand Down
Loading
Loading