From c4d149c2eb3f5cd49b4169cfbede407955f2d750 Mon Sep 17 00:00:00 2001 From: Paco Cartones <253313177+pacocartones@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:22:18 +0200 Subject: [PATCH] fix(dashboard): keep Profile XP and level consistent (#11604) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged via /merge-batch (lote 2026-08-26 batch 2, v3.8.51). Boarded no worktree combinado junto com outras ~20 PRs; validação única: typecheck/complexity/cognitive-complexity/changelog-integrity verdes, file-size rebaseado onde necessário (crescimento legítimo), lint com os mesmos 228 achados pré-existentes confirmados via sonda contra o tip puro (não introduzidos por este lote), e 292 testes focados (unit) + 18 (vitest) passando. Obrigado pela contribuição. --- .../fixes/11604-profile-xp-consistency.md | 1 + src/lib/db/gamification.ts | 12 +++--- src/lib/gamification/xp.ts | 24 ++++++++--- .../unit/gamification/db-gamification.test.ts | 26 ++++++++++- tests/unit/gamification/xp.test.ts | 43 +++++++++++++------ 5 files changed, 80 insertions(+), 26 deletions(-) create mode 100644 changelog.d/fixes/11604-profile-xp-consistency.md diff --git a/changelog.d/fixes/11604-profile-xp-consistency.md b/changelog.d/fixes/11604-profile-xp-consistency.md new file mode 100644 index 0000000000..f8f29a4ed2 --- /dev/null +++ b/changelog.d/fixes/11604-profile-xp-consistency.md @@ -0,0 +1 @@ +- **fix(dashboard):** Keep the Profile level and progress aligned with aggregate XP, including bounded handling for invalid totals ([#11604](https://github.com/diegosouzapw/OmniRoute/pull/11604)) — thanks @pacocartones diff --git a/src/lib/db/gamification.ts b/src/lib/db/gamification.ts index cd6c907170..b55cd4cf21 100644 --- a/src/lib/db/gamification.ts +++ b/src/lib/db/gamification.ts @@ -299,22 +299,22 @@ export function getBadgeDefinitions(category?: string): BadgeDefinition[] { /** * Aggregate XP across every API key (the operator-wide profile view used by the - * dashboard profile page, which is not scoped to a single key). Sums total XP and - * takes the highest reached level. (#3484) + * dashboard profile page, which is not scoped to a single key). The aggregate + * level must be derived from the same summed XP displayed by the profile. (#3484) */ export function getAggregateXp(): UserLevelRow { const row = db() .prepare( `SELECT COALESCE(SUM(total_xp), 0) AS total_xp, - COALESCE(MAX(current_level), 1) AS current_level, MAX(updated_at) AS updated_at FROM user_levels` ) - .get() as { total_xp: number; current_level: number; updated_at: string | null }; + .get() as { total_xp: number; updated_at: string | null }; + const totalXp = row?.total_xp ?? 0; return { apiKeyId: "*", - totalXp: row?.total_xp ?? 0, - currentLevel: row?.current_level ?? 1, + totalXp, + currentLevel: calculateLevel(totalXp), updatedAt: row?.updated_at ?? "", }; } diff --git a/src/lib/gamification/xp.ts b/src/lib/gamification/xp.ts index 083f936b5e..53ea4926a7 100644 --- a/src/lib/gamification/xp.ts +++ b/src/lib/gamification/xp.ts @@ -44,22 +44,32 @@ export function cumulativeXpForLevel(level: number): number { } /** - * Calculate level from total XP using the inverse of the cumulative XP curve. + * Calculate the exact level from total XP. * - * The cumulative XP for level L approximates to `100 * L^2.5 / 2.5`. - * Solving for L gives `L ≈ (totalXp * 2.5 / 100) ^ 0.4`. + * The inverse cumulative curve supplies a fast initial estimate. The result is + * then reconciled against the exact floored thresholds from `xpForLevel`. * * @param totalXp - Total accumulated XP * @returns Current level (minimum 1) * * @example * calculateLevel(0) // 1 - * calculateLevel(5000) // ~8 - * calculateLevel(100000) // ~100 + * calculateLevel(cumulativeXpForLevel(10)) // 10 */ export function calculateLevel(totalXp: number): number { - if (totalXp <= 0) return 1; - return Math.max(1, Math.floor(Math.pow((totalXp * 2.5) / 100, 0.4))); + if (!Number.isFinite(totalXp) || totalXp <= 0) return 1; + const boundedXp = Math.min(totalXp, Number.MAX_SAFE_INTEGER); + + // Use the inverse curve only as a fast starting point, then reconcile it + // against the exact floored cumulative thresholds used by the XP engine. + let level = Math.max(1, Math.floor(Math.pow((boundedXp * 2.5) / 100, 0.4))); + while (level > 1 && cumulativeXpForLevel(level) > boundedXp) { + level -= 1; + } + while (cumulativeXpForLevel(level + 1) <= boundedXp) { + level += 1; + } + return level; } /** diff --git a/tests/unit/gamification/db-gamification.test.ts b/tests/unit/gamification/db-gamification.test.ts index 0be7ef6b9b..f5f3ec0659 100644 --- a/tests/unit/gamification/db-gamification.test.ts +++ b/tests/unit/gamification/db-gamification.test.ts @@ -1,6 +1,6 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { addXp, getXp } from "../../../src/lib/db/gamification"; +import { addXp, getAggregateXp, getXp } from "../../../src/lib/db/gamification"; import { calculateLevel } from "../../../src/lib/gamification/xp"; import { getDbInstance } from "../../../src/lib/db/core"; @@ -32,4 +32,28 @@ describe("DB Gamification — addXp level computation", () => { db.prepare("DELETE FROM user_levels WHERE api_key_id = ?").run(testKey); db.prepare("DELETE FROM xp_audit_log WHERE api_key_id = ?").run(testKey); }); + + it("derives the operator level from aggregate XP instead of the highest key level", () => { + const firstKey = `test-aggregate-xp-a-${Date.now()}`; + const secondKey = `test-aggregate-xp-b-${Date.now()}`; + const db = getDbInstance(); + const existing = getAggregateXp(); + const firstXp = 9000; + const secondXp = 8153; + + try { + addXp(firstKey, "request", firstXp); + addXp(secondKey, "request", secondXp); + + const aggregate = getAggregateXp(); + const expectedTotal = existing.totalXp + firstXp + secondXp; + assert.equal(aggregate.totalXp, expectedTotal); + assert.equal(aggregate.currentLevel, calculateLevel(expectedTotal)); + } finally { + for (const key of [firstKey, secondKey]) { + db.prepare("DELETE FROM user_levels WHERE api_key_id = ?").run(key); + db.prepare("DELETE FROM xp_audit_log WHERE api_key_id = ?").run(key); + } + } + }); }); diff --git a/tests/unit/gamification/xp.test.ts b/tests/unit/gamification/xp.test.ts index a2c260d740..932c11bf17 100644 --- a/tests/unit/gamification/xp.test.ts +++ b/tests/unit/gamification/xp.test.ts @@ -20,31 +20,50 @@ describe("XP/Level Engine", () => { assert.equal(calculateLevel(-100), 1); }); + it("falls back to level 1 for non-finite XP", () => { + assert.equal(calculateLevel(Number.NaN), 1); + assert.equal(calculateLevel(Number.POSITIVE_INFINITY), 1); + assert.equal(calculateLevel(Number.NEGATIVE_INFINITY), 1); + }); + + it("bounds finite XP above the safe integer range", () => { + assert.equal(calculateLevel(Number.MAX_VALUE), calculateLevel(Number.MAX_SAFE_INTEGER)); + }); + it("returns level 1 for small XP", () => { assert.equal(calculateLevel(50), 1); }); - it("returns level ~5 for 3162 XP (xpForLevel(10))", () => { - const level = calculateLevel(3162); - assert.ok(level >= 4 && level <= 7, `Expected ~5, got ${level}`); + it("returns level 5 for 3162 XP", () => { + assert.equal(calculateLevel(3162), 5); }); - it("returns level ~10 for cumulative level-10 XP", () => { + it("returns level 10 for cumulative level-10 XP", () => { const xp = cumulativeXpForLevel(10); - const level = calculateLevel(xp); - assert.ok(level >= 9 && level <= 11, `Expected ~10, got ${level}`); + assert.equal(calculateLevel(xp), 10); }); - it("returns level ~25 for cumulative level-25 XP", () => { + it("returns level 25 for cumulative level-25 XP", () => { const xp = cumulativeXpForLevel(25); - const level = calculateLevel(xp); - assert.ok(level >= 24 && level <= 26, `Expected ~25, got ${level}`); + assert.equal(calculateLevel(xp), 25); }); - it("returns level ~50 for cumulative level-50 XP", () => { + it("returns level 50 for cumulative level-50 XP", () => { const xp = cumulativeXpForLevel(50); - const level = calculateLevel(xp); - assert.ok(level >= 49 && level <= 51, `Expected ~50, got ${level}`); + assert.equal(calculateLevel(xp), 50); + }); + + it("matches every exact cumulative level boundary", () => { + for (let expected = 1; expected <= 100; expected++) { + assert.equal(calculateLevel(cumulativeXpForLevel(expected)), expected); + } + }); + + it("does not advance until the next cumulative boundary", () => { + for (let current = 1; current < 100; current++) { + const beforeNext = cumulativeXpForLevel(current + 1) - 1; + assert.equal(calculateLevel(beforeNext), current); + } }); it("monotonically increases", () => {