mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-13 18:32:12 +03:00
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:
1
changelog.d/features/12377-profile-streak-card.md
Normal file
1
changelog.d/features/12377-profile-streak-card.md
Normal file
@@ -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)
|
||||
@@ -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<string, string> = {
|
||||
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<BadgeDef | null>(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();
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
83
tests/unit/gamification/level-route-streak.test.ts
Normal file
83
tests/unit/gamification/level-route-streak.test.ts
Normal 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 });
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
101
tests/unit/ui/profile-streak.test.tsx
Normal file
101
tests/unit/ui/profile-streak.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user