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

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