From 4f4aa74199b91c6e65190e89be20ccde611fcece Mon Sep 17 00:00:00 2001 From: Paco Cartones <253313177+pacocartones@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:14:41 +0200 Subject: [PATCH] feat(gamification): show the real daily streak on the profile page (#12377) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../features/12377-profile-streak-card.md | 1 + .../(dashboard)/dashboard/profile/page.tsx | 11 +- src/app/api/gamification/level/route.ts | 7 +- src/lib/gamification/index.ts | 2 +- src/lib/gamification/streaks.ts | 32 ++++++ .../gamification/level-route-streak.test.ts | 83 ++++++++++++++ tests/unit/gamification/streaks.test.ts | 71 +++++++++++- tests/unit/ui/profile-streak.test.tsx | 101 ++++++++++++++++++ 8 files changed, 303 insertions(+), 5 deletions(-) create mode 100644 changelog.d/features/12377-profile-streak-card.md create mode 100644 tests/unit/gamification/level-route-streak.test.ts create mode 100644 tests/unit/ui/profile-streak.test.tsx diff --git a/changelog.d/features/12377-profile-streak-card.md b/changelog.d/features/12377-profile-streak-card.md new file mode 100644 index 0000000000..61467c6183 --- /dev/null +++ b/changelog.d/features/12377-profile-streak-card.md @@ -0,0 +1 @@ +- **feat(gamification):** the dashboard Profile page now shows the real daily streak — `/api/gamification/level` returns `streak: { current, longest }` (per key with `apiKeyId`, operator-wide maximum otherwise) and the streak card reads it instead of a hard-coded 0 (#2403) diff --git a/src/app/(dashboard)/dashboard/profile/page.tsx b/src/app/(dashboard)/dashboard/profile/page.tsx index 4864fc8c78..1aed52eb09 100644 --- a/src/app/(dashboard)/dashboard/profile/page.tsx +++ b/src/app/(dashboard)/dashboard/profile/page.tsx @@ -79,6 +79,14 @@ function BadgeIcon({ icon, earned }: { icon: string | null; earned: boolean }) { ); } +/** + * Current daily streak carried by `/api/gamification/level` (#2403). Older or partial + * payloads without a `streak` field, or with a non-numeric count, render as no streak. + */ +function readStreakCount(data: { streak?: { current?: unknown } | null }): number { + return Number(data.streak?.current) || 0; +} + const RARITY_COLORS: Record = { common: "text-gray-400 border-gray-500/30", uncommon: "text-green-400 border-green-500/30", @@ -97,7 +105,7 @@ export default function ProfilePage() { const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const [selectedBadge, setSelectedBadge] = useState(null); - const [streak] = useState(0); // streak data comes from future API + const [streak, setStreak] = useState(0); const fetchData = useCallback(async () => { try { @@ -114,6 +122,7 @@ export default function ProfilePage() { if (levelRes.ok) { const data = await levelRes.json(); setUserLevel(data.level ?? data); + setStreak(readStreakCount(data)); } if (badgesRes.ok) { const data = await badgesRes.json(); diff --git a/src/app/api/gamification/level/route.ts b/src/app/api/gamification/level/route.ts index 572081967c..dc4d94b7ed 100644 --- a/src/app/api/gamification/level/route.ts +++ b/src/app/api/gamification/level/route.ts @@ -1,6 +1,8 @@ /** * GET /api/gamification/level — current XP/level for a key, or the operator-wide * aggregate when no `apiKeyId` is supplied (the dashboard profile page case). (#3484) + * The daily streak rides along in the same payload so the profile streak card can show + * real data without a second round trip. (#2403) * * LOCAL_ONLY: not process-spawning; management-scoped via requireManagementAuth. */ @@ -8,6 +10,7 @@ import { NextRequest, NextResponse } from "next/server"; import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; import { getXp, getAggregateXp } from "@/lib/db/gamification"; +import { getStreak, getAggregateStreak } from "@/lib/gamification/streaks"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; export async function OPTIONS() { @@ -20,5 +23,7 @@ export async function GET(request: NextRequest) { const apiKeyId = new URL(request.url).searchParams.get("apiKeyId"); const level = apiKeyId ? getXp(apiKeyId) : getAggregateXp(); - return NextResponse.json({ level }, { headers: CORS_HEADERS }); + const streakData = apiKeyId ? await getStreak(apiKeyId) : await getAggregateStreak(); + const streak = { current: streakData.currentStreak, longest: streakData.longestStreak }; + return NextResponse.json({ level, streak }, { headers: CORS_HEADERS }); } diff --git a/src/lib/gamification/index.ts b/src/lib/gamification/index.ts index 8cf0db476e..56862fcad6 100644 --- a/src/lib/gamification/index.ts +++ b/src/lib/gamification/index.ts @@ -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, diff --git a/src/lib/gamification/streaks.ts b/src/lib/gamification/streaks.ts index b4973ff93e..4406375ac1 100644 --- a/src/lib/gamification/streaks.ts +++ b/src/lib/gamification/streaks.ts @@ -107,6 +107,38 @@ export async function getStreak(apiKeyId: string): Promise { 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 +> { + 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. * diff --git a/tests/unit/gamification/level-route-streak.test.ts b/tests/unit/gamification/level-route-streak.test.ts new file mode 100644 index 0000000000..88698ab978 --- /dev/null +++ b/tests/unit/gamification/level-route-streak.test.ts @@ -0,0 +1,83 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// The dashboard profile page reads `/api/gamification/level` without an apiKeyId +// (operator-wide view, #3484) and now expects the streak alongside the level payload so +// the streak card (#2403) shows real data instead of a hard-coded 0. + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-level-streak-")); +process.env.DATA_DIR = TEST_DATA_DIR; +if (!process.env.API_KEY_SECRET) { + process.env.API_KEY_SECRET = "test-level-streak-secret-" + Date.now(); +} + +const { getDbInstance, resetDbInstance } = await import("../../../src/lib/db/core.ts"); +const { updateStreak } = await import("../../../src/lib/gamification/streaks.ts"); +const { GET } = await import("../../../src/app/api/gamification/level/route.ts"); +const { NextRequest } = await import("next/server"); + +const STREAK_NAMESPACE = "gamification:streaks"; + +interface LevelPayload { + level: { apiKeyId: string; totalXp: number; currentLevel: number } | null; + streak: { current: number; longest: number }; +} + +async function getLevel(query = ""): Promise { + const response = await GET(new NextRequest(`http://localhost/api/gamification/level${query}`)); + assert.equal(response.status, 200); + return (await response.json()) as LevelPayload; +} + +test.before(async () => { + await updateStreak("key-a"); // today → current 1 / longest 1 + getDbInstance() + .prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)") + .run( + STREAK_NAMESPACE, + "key-b", + JSON.stringify({ + currentStreak: 7, + longestStreak: 9, + lastActiveDate: "2026-08-31", + streakStartDate: "2026-08-25", + }) + ); +}); + +test.after(() => { + try { + getDbInstance().close(); + } catch { + /* ignore */ + } + try { + resetDbInstance(); + } catch { + /* ignore */ + } + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +test("GET /api/gamification/level without apiKeyId returns the aggregate streak next to the level", async () => { + const body = await getLevel(); + assert.equal(body.level?.apiKeyId, "*"); + assert.deepEqual(body.streak, { current: 7, longest: 9 }); +}); + +test("GET /api/gamification/level?apiKeyId returns that key's own streak", async () => { + const keyA = await getLevel("?apiKeyId=key-a"); + assert.deepEqual(keyA.streak, { current: 1, longest: 1 }); + + const keyB = await getLevel("?apiKeyId=key-b"); + assert.deepEqual(keyB.streak, { current: 7, longest: 9 }); +}); + +test("GET /api/gamification/level?apiKeyId for an unknown key returns a zero streak, not an error", async () => { + const body = await getLevel("?apiKeyId=never-seen"); + assert.equal(body.level, null); + assert.deepEqual(body.streak, { current: 0, longest: 0 }); +}); diff --git a/tests/unit/gamification/streaks.test.ts b/tests/unit/gamification/streaks.test.ts index 0b2e6bf557..9e7188f0ea 100644 --- a/tests/unit/gamification/streaks.test.ts +++ b/tests/unit/gamification/streaks.test.ts @@ -1,6 +1,24 @@ -import { describe, it } from "node:test"; +import { after, describe, it } from "node:test"; import assert from "node:assert/strict"; -import { getStreak, updateStreak } from "../../../src/lib/gamification/streaks"; +import { getDbInstance, resetDbInstance } from "../../../src/lib/db/core"; +import { getAggregateStreak, getStreak, updateStreak } from "../../../src/lib/gamification/streaks"; + +const STREAK_NAMESPACE = "gamification:streaks"; + +function seedStreakRow(apiKeyId: string, value: string): void { + getDbInstance() + .prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)") + .run(STREAK_NAMESPACE, apiKeyId, value); +} + +after(() => { + try { + getDbInstance().close(); + } catch { + /* ignore */ + } + resetDbInstance(); +}); describe("Streak Tracker", () => { describe("getStreak", () => { @@ -33,4 +51,53 @@ describe("Streak Tracker", () => { assert.equal(streak.streakStartDate, streak.lastActiveDate); }); }); + + describe("getAggregateStreak", () => { + it("returns zero streak when no key has ever been active", async () => { + // The updateStreak cases above already wrote rows for this process' DB. + getDbInstance().prepare("DELETE FROM key_value WHERE namespace = ?").run(STREAK_NAMESPACE); + + const agg = await getAggregateStreak(); + assert.equal(agg.currentStreak, 0); + assert.equal(agg.longestStreak, 0); + }); + + it("takes the max current and max longest streak across every key", async () => { + await updateStreak("agg-key-a"); // current 1 / longest 1, written by the tracker itself + seedStreakRow( + "agg-key-b", + JSON.stringify({ + currentStreak: 3, + longestStreak: 3, + lastActiveDate: "2026-08-31", + streakStartDate: "2026-08-29", + }) + ); + seedStreakRow( + "agg-key-c", + JSON.stringify({ + currentStreak: 0, + longestStreak: 9, + lastActiveDate: "2026-07-01", + streakStartDate: "2026-06-23", + }) + ); + + const agg = await getAggregateStreak(); + assert.equal(agg.currentStreak, 3); // max(1, 3, 0), not the sum + assert.equal(agg.longestStreak, 9); // max(1, 3, 9) — may come from a different key + }); + + it("ignores malformed rows in the namespace instead of throwing", async () => { + seedStreakRow("agg-key-broken", "not json"); + seedStreakRow( + "agg-key-strings", + JSON.stringify({ currentStreak: "12", longestStreak: null }) + ); + + const agg = await getAggregateStreak(); + assert.equal(agg.currentStreak, 3); + assert.equal(agg.longestStreak, 9); + }); + }); }); diff --git a/tests/unit/ui/profile-streak.test.tsx b/tests/unit/ui/profile-streak.test.tsx new file mode 100644 index 0000000000..56d3ccd2c3 --- /dev/null +++ b/tests/unit/ui/profile-streak.test.tsx @@ -0,0 +1,101 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +// Render the key plus its ICU arguments so the assertions can see the count that reached +// the `dayStreak` message (e.g. `dayStreak{"count":7}`). +const translate = (key: string, values?: Record) => + values ? `${key}${JSON.stringify(values)}` : key; +vi.mock("next-intl", () => ({ + useLocale: () => "en", + useTranslations: () => Object.assign(translate, { has: () => false }), +})); + +const { default: ProfilePage } = await import("@/app/(dashboard)/dashboard/profile/page"); + +const roots: Array<{ root: ReturnType; container: HTMLDivElement }> = []; + +function stubFetch(levelBody: Record) { + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/level")) { + return { ok: true, json: async () => levelBody }; + } + return { ok: true, json: async () => ({ badges: [] }) }; + }) + ); +} + +function mountProfile() { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + roots.push({ root, container }); + act(() => root.render()); + return container; +} + +async function waitForLoad(container: HTMLDivElement) { + for (let i = 0; i < 40 && container.querySelector('[role="status"]'); i++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + } +} + +afterEach(() => { + for (const { root, container } of roots.splice(0)) { + act(() => root.unmount()); + container.remove(); + } + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("Profile streak card", () => { + it("renders the current streak from the level response", async () => { + stubFetch({ + level: { totalXp: 150, currentLevel: 2 }, + streak: { current: 7, longest: 9 }, + }); + + const container = mountProfile(); + await waitForLoad(container); + + const text = container.textContent ?? ""; + expect(text).toContain('dayStreak{"count":7}'); + expect(text).toContain("maintainStreak"); + expect(container.querySelector('[role="alert"]')).toBeNull(); + }); + + it("hides the streak card when the current streak is 0", async () => { + stubFetch({ + level: { totalXp: 150, currentLevel: 2 }, + streak: { current: 0, longest: 9 }, + }); + + const container = mountProfile(); + await waitForLoad(container); + + const text = container.textContent ?? ""; + expect(text).not.toContain("dayStreak"); + expect(text).not.toContain("maintainStreak"); + }); + + it("hides the streak card when the response carries no streak field", async () => { + stubFetch({ level: { totalXp: 150, currentLevel: 2 } }); + + const container = mountProfile(); + await waitForLoad(container); + + expect(container.textContent ?? "").not.toContain("dayStreak"); + }); +});