diff --git a/src/app/(dashboard)/dashboard/radar/intel/page.tsx b/src/app/(dashboard)/dashboard/radar/intel/page.tsx new file mode 100644 index 0000000000..7c933b053d --- /dev/null +++ b/src/app/(dashboard)/dashboard/radar/intel/page.tsx @@ -0,0 +1,197 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { useTranslations } from "next-intl"; + +import type { RadarIntelFeed } from "@/lib/radar/intelFeedSchema"; +import { Card } from "@/shared/components"; + +interface IntelMeta { + version: string; + tier: "live"; + fetchedAt: string; + supporterVerified: true; +} + +export default function RadarIntelPage() { + const t = useTranslations("radarIntelPage"); + const [intel, setIntel] = useState(null); + const [meta, setMeta] = useState(null); + const [loading, setLoading] = useState(true); + const [syncing, setSyncing] = useState(false); + const [flagOff, setFlagOff] = useState(false); + const [error, setError] = useState(""); + + const load = useCallback(async () => { + const response = await fetch("/api/radar/intel"); + if (response.status === 404) { + setFlagOff(true); + return; + } + if (!response.ok) throw new Error("intel_load_failed"); + const body = (await response.json()) as { + intel?: RadarIntelFeed | null; + meta?: IntelMeta | null; + }; + setIntel(body.intel ?? null); + setMeta(body.meta ?? null); + }, []); + + const sync = useCallback(async () => { + setSyncing(true); + setError(""); + try { + const response = await fetch("/api/radar/intel/sync", { method: "POST" }); + if (response.status === 404) { + setFlagOff(true); + return; + } + if (!response.ok) throw new Error("intel_sync_failed"); + const status = (await response.json()) as { status?: string }; + if ( + ["error", "invalid_signature", "invalid_schema", "wrong_tier", "too_large"].includes( + status.status ?? "" + ) + ) { + setError(t("loadFailed")); + } + await load(); + } catch { + setError(t("loadFailed")); + await load().catch(() => undefined); + } finally { + setSyncing(false); + } + }, [load, t]); + + useEffect(() => { + load() + .catch(() => setError(t("loadFailed"))) + .finally(() => setLoading(false)); + }, [load, t]); + + if (flagOff) notFound(); + + return ( +
+
+
+ + ← {t("backToRadar")} + +

{t("title")}

+

{t("subtitle")}

+
+
+ {meta?.supporterVerified === true && ( + + {t("supporterBadge")} + + )} + +
+
+ + {error &&
{error}
} + + {loading ? ( +
+ {t("loading")} +
+ ) : !intel || !meta ? ( + +

{t("empty")}

+
+ ) : ( + <> +
+ +

{t("methodology")}

+

+ {t("eloMethod", { + initial: intel.methodology.initialRating, + factor: intel.methodology.kFactor, + })} +

+
+ +

{t("freshness")}

+

+ {t(`freshnessValues.${intel.catalog.freshness}`)} +

+

+ {t("ageDays", { days: intel.catalog.ageDays })} +

+
+ +

{t("trend")}

+

{t(`trendValues.${intel.catalog.trend}`)}

+

+ {t("modelDelta", { + current: intel.catalog.models.current, + added: intel.catalog.models.added, + removed: intel.catalog.models.removed, + })} +

+
+
+ + +
+

{t("ranking")}

+ {meta.version} +
+ {intel.rankings.length === 0 ? ( +

{t("noRankings")}

+ ) : ( +
+ + + + + + + + + + + + {intel.rankings.map((ranking) => ( + + + + + + + + ))} + +
#{t("model")}{t("category")}{t("rating")}{t("matches")}
{ranking.rank} + {ranking.provider}/{ranking.modelId} + {ranking.category}{ranking.rating}{ranking.matches}
+
+ )} +
+ + )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/radar/page.tsx b/src/app/(dashboard)/dashboard/radar/page.tsx index 7f209202a8..203d06e9b4 100644 --- a/src/app/(dashboard)/dashboard/radar/page.tsx +++ b/src/app/(dashboard)/dashboard/radar/page.tsx @@ -317,6 +317,14 @@ export default function RadarPage() {

{t("subtitle")}

+ {(pageState === "empty" || pageState === "populated") && ( + + {t("intel")} + + )} {(pageState === "empty" || pageState === "populated") && ( [] = [ criteria: JSON.stringify({ type: "threshold", metric: "uptime", threshold: 100, window: 7 }), hidden: 0, }, + { + id: "radar-supporter", + name: "Radar Supporter", + description: "Verified a live OmniRoute Radar supporter feed", + icon: "radar", + category: "contribution", + rarity: "rare", + criteria: JSON.stringify({ type: "action_count", action: "radar_supporter", threshold: 1 }), + hidden: 0, + }, // ── Streak (Engagement) ────────────────────────────────────────────────── { diff --git a/src/lib/gamification/events.ts b/src/lib/gamification/events.ts index cccb2474b0..cde799f5c0 100644 --- a/src/lib/gamification/events.ts +++ b/src/lib/gamification/events.ts @@ -25,7 +25,8 @@ export async function emitGamificationEvent(params: { | "combo_use" | "token_share" | "invite_redeem" - | "daily_login"; + | "daily_login" + | "radar_supporter"; metadata?: Record; }): Promise { const { apiKeyId, action, metadata } = params; @@ -33,6 +34,13 @@ export async function emitGamificationEvent(params: { if (!apiKeyId) return; // Skip if no API key try { + // A verified Radar supporter is a recognition event, not an XP or + // leaderboard action. The caller supplies only a one-way key identity. + if (action === "radar_supporter") { + await checkAndUnlockBadge(apiKeyId, "radar-supporter", false); + return; + } + // 1. Award XP const xpAmount = getXpForAction(action); if (xpAmount > 0) { @@ -87,7 +95,7 @@ export async function emitGamificationEvent(params: { } catch (err) { // Never throw — gamification must not break the request pipeline log.error("events.error", { - apiKeyId, + ...(action === "radar_supporter" ? {} : { apiKeyId }), action, error: err instanceof Error ? err.message : String(err), }); @@ -114,22 +122,25 @@ function getXpForAction(action: string): number { /** * Check and unlock a specific badge. */ -async function checkAndUnlockBadge(apiKeyId: string, badgeId: string): Promise { +async function checkAndUnlockBadge( + apiKeyId: string, + badgeId: string, + logIdentity = true +): Promise { const { unlockBadge, hasBadge } = await import("../db/gamification"); // #3472: dedup via user_badges directly. getBadges() INNER-JOINs badge_definitions, which is // empty until seeded, so it falsely reported "not earned" and re-emitted the unlock event on // every request. if (!hasBadge(apiKeyId, badgeId)) { unlockBadge(apiKeyId, badgeId); - log.info("events.badge_unlocked", { apiKeyId, badgeId }); + log.info("events.badge_unlocked", logIdentity ? { apiKeyId, badgeId } : { badgeId }); // Look up badge details from badge_definitions const { getDbInstance } = await import("../db/core"); const badgeRow = getDbInstance() .prepare("SELECT name, description, icon, rarity FROM badge_definitions WHERE id = ?") .get(badgeId) as - | { name: string; description: string | null; icon: string | null; rarity: string } - | undefined; + { name: string; description: string | null; icon: string | null; rarity: string } | undefined; // Record notification for SSE toast const { recordBadgeUnlock } = await import("./notifications"); diff --git a/tests/unit/radar-intel-page.test.ts b/tests/unit/radar-intel-page.test.ts new file mode 100644 index 0000000000..d4891ee8f0 --- /dev/null +++ b/tests/unit/radar-intel-page.test.ts @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; + +const pagePath = path.resolve(process.cwd(), "src/app/(dashboard)/dashboard/radar/intel/page.tsx"); +const radarPagePath = path.resolve(process.cwd(), "src/app/(dashboard)/dashboard/radar/page.tsx"); + +test("Radar links to a dedicated local-only Intel page", () => { + assert.ok(fs.existsSync(pagePath)); + assert.match(fs.readFileSync(radarPagePath, "utf8"), /href="\/dashboard\/radar\/intel"/); + const source = fs.readFileSync(pagePath, "utf8"); + assert.match(source, /fetch\("\/api\/radar\/intel"\)/); + assert.match(source, /fetch\("\/api\/radar\/intel\/sync",\s*\{\s*method:\s*"POST"/); + assert.doesNotMatch(source, /RADAR_FEED_URL|radar\.omniroute\.online|omr_|getDbInstance/); +}); + +test("Intel page exposes methodology, ranking, freshness, trend, and verified supporter badge only", () => { + const source = fs.readFileSync(pagePath, "utf8"); + for (const marker of [ + "methodology", + "rankings", + "freshness", + "trend", + "supporterVerified", + "radar-supporter", + ]) { + assert.match(source, new RegExp(marker)); + } + assert.doesNotMatch(source, /\bhealth\b|\buptime\b|\blatency\b|\btelemetry\b/i); +}); + +test("Intel UI strings exist in English and Brazilian Portuguese", () => { + for (const locale of ["en", "pt-BR"]) { + const messages = JSON.parse( + fs.readFileSync(path.resolve(process.cwd(), `src/i18n/messages/${locale}.json`), "utf8") + ) as { radarIntelPage?: Record; radarPage?: Record }; + for (const key of [ + "title", + "subtitle", + "methodology", + "supporterBadge", + "ranking", + "freshness", + "trend", + "empty", + "loadFailed", + ]) { + assert.equal(typeof messages.radarIntelPage?.[key], "string", `${locale}: ${key}`); + } + assert.equal(typeof messages.radarPage?.intel, "string", `${locale}: radarPage.intel`); + } +}); diff --git a/tests/unit/radar-supporter-gamification.test.ts b/tests/unit/radar-supporter-gamification.test.ts new file mode 100644 index 0000000000..a3b1d4b100 --- /dev/null +++ b/tests/unit/radar-supporter-gamification.test.ts @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-radar-supporter-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const { getDbInstance, resetDbInstance } = await import("../../src/lib/db/core.ts"); +const { BUILTIN_BADGES } = await import("../../src/lib/gamification/badges.ts"); +const { emitGamificationEvent } = await import("../../src/lib/gamification/events.ts"); + +test.after(() => { + resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("Radar supporter has a dedicated badge and zero-XP idempotent action", async () => { + const identity = `radar:${"a".repeat(64)}`; + const badge = BUILTIN_BADGES.find((item) => item.id === "radar-supporter"); + assert.ok(badge); + assert.equal(JSON.parse(badge.criteria).action, "radar_supporter"); + + await emitGamificationEvent({ apiKeyId: identity, action: "radar_supporter" }); + await emitGamificationEvent({ apiKeyId: identity, action: "radar_supporter" }); + + const db = getDbInstance(); + const userBadges = db + .prepare("SELECT badge_id AS badgeId FROM user_badges WHERE api_key_id = ?") + .all(identity) as Array<{ badgeId: string }>; + const xpRows = db + .prepare("SELECT action FROM xp_audit_log WHERE api_key_id = ?") + .all(identity) as Array<{ action: string }>; + const scoreRows = db + .prepare("SELECT score FROM leaderboard WHERE api_key_id = ?") + .all(identity) as Array<{ score: number }>; + + assert.deepEqual(userBadges, [{ badgeId: "radar-supporter" }]); + assert.deepEqual(xpRows, []); + assert.deepEqual(scoreRows, []); +});