feat(gamification): show the real daily streak on the profile page (#12377)

The Profile page already rendered a streak card but fed it a hard-coded useState(0) with a "streak data comes from future API" note — while streaks.ts tracked per-key streaks all along and the MCP gamification_profile tool already returned them. GET /api/gamification/level now returns streak: { current, longest } next to level: the key's own streak with apiKeyId, the operator-wide maximum otherwise, matching the aggregate mode getAggregateXp uses (#3484). No new route, no OpenAPI change, no new i18n keys; a missing or zero streak keeps the card hidden exactly as before.

Validated in a combined worktree with all 25 PRs of this batch boarded together: typecheck:core clean, 443/443 node-runner tests plus 14/14 vitest across every test file the batch touches, and check-changelog-integrity, check:cycles (418 files), check:provider-consistency (272 REGISTRY entries, 355 canonical providers), check:docs-counts, check:docs-sync (42 locales) and check-file-size all green.

Thanks @pacocartones.
This commit is contained in:
Paco Cartones
2026-09-02 08:14:41 +02:00
committed by GitHub
parent 01d97beb8b
commit 4f4aa74199
8 changed files with 303 additions and 5 deletions

View File

@@ -26,7 +26,7 @@ export {
XP_REWARDS,
type XpAction,
} from "./xp";
export { updateStreak } from "./streaks";
export { getStreak, getAggregateStreak, updateStreak, type StreakData } from "./streaks";
export {
recordBadgeUnlock,
consumeBadgeUnlocks,

View File

@@ -107,6 +107,38 @@ export async function getStreak(apiKeyId: string): Promise<StreakData> {
return parseStreakJson(row.value);
}
/**
* Operator-wide streak for the dashboard profile page, which has no single API key
* (the aggregate mode of `/api/gamification/level`, #3484): the best `currentStreak`
* and the best `longestStreak` over every key in the namespace. Both are maxima, not
* sums, and may come from different keys. Malformed rows count as zero.
*
* @returns The highest current/longest streak across all API keys
*
* @example
* const agg = await getAggregateStreak();
* console.log(agg.currentStreak); // 7
*/
export async function getAggregateStreak(): Promise<
Pick<StreakData, "currentStreak" | "longestStreak">
> {
const aggregate = { currentStreak: 0, longestStreak: 0 };
if (isBuildPhase || isCloud) return aggregate;
const db = getDbInstance() as unknown as DbLike;
const rows = db
.prepare("SELECT value FROM key_value WHERE namespace = ?")
.all(NAMESPACE) as KeyValueRow[];
for (const row of rows) {
const streak = parseStreakJson(row.value);
aggregate.currentStreak = Math.max(aggregate.currentStreak, streak.currentStreak);
aggregate.longestStreak = Math.max(aggregate.longestStreak, streak.longestStreak);
}
return aggregate;
}
/**
* Update streak for today. Returns the new current streak count.
*