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

@@ -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<LevelPayload> {
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 });
});

View File

@@ -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);
});
});
});

View File

@@ -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<string, unknown>) =>
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<typeof createRoot>; container: HTMLDivElement }> = [];
function stubFetch(levelBody: Record<string, unknown>) {
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(<ProfilePage />));
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");
});
});