fix(dashboard): keep Profile XP and level consistent (#11604)

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.
This commit is contained in:
Paco Cartones
2026-08-26 14:22:18 +02:00
committed by GitHub
parent a0422b9af8
commit c4d149c2eb
5 changed files with 80 additions and 26 deletions

View File

@@ -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

View File

@@ -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 ?? "",
};
}

View File

@@ -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;
}
/**

View File

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

View File

@@ -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", () => {