diff --git a/packages/shared/src/dto/gamification.ts b/packages/shared/src/dto/gamification.ts
index 27499de1..78f89a23 100644
--- a/packages/shared/src/dto/gamification.ts
+++ b/packages/shared/src/dto/gamification.ts
@@ -63,3 +63,54 @@ export interface AchievementDto {
export interface MyProgressionDto {
xp: number | null;
}
+
+/** [G7] Which population a leaderboard ranks. */
+export type LeaderboardScope = "global" | "friends";
+
+/**
+ * [G7] The window a leaderboard sums XP over — calendar month or calendar
+ * year in the server's clock, recomputed live from the ledger rather than a
+ * snapshot (see the ticket: no snapshot table needed for the MVP).
+ */
+export type LeaderboardPeriod = "month" | "year";
+
+/**
+ * [G7] One ranked row. Deliberately lean, not a `UserSummaryDto`: it carries
+ * no `profileAccess`, so a PRIVATE row is never distinguishable from a
+ * PUBLIC one — the leaderboard shows a pseudo and nothing else, ever.
+ *
+ * `level` is never sent — same rule as everywhere else in gamification: the
+ * client derives it from `xp` via `levelProgress()`.
+ *
+ * `avatarUrl` is null (client falls back to the identicon) whenever the row
+ * is a PRIVATE account the viewer isn't friends with, regardless of whether
+ * they uploaded a real photo — see the [G7] plan for why this is a stricter
+ * rule than the profile page (which shows a PRIVATE stranger's real avatar).
+ *
+ * `rank` follows SQL `RANK()` semantics: tied rows share the same number and
+ * the next distinct rank skips ahead by the tie's size (1, 2, 2, 4 — not
+ * 1, 2, 2, 3). The UI shows the number only on the first of a tied group (by
+ * account age) and a dash on the rest — recomputed client-side by comparing
+ * consecutive entries, not carried as a field.
+ */
+export interface LeaderboardEntryDto {
+ id: string;
+ username: string;
+ displayName: string;
+ avatarUrl: string | null;
+ xp: number;
+ rank: number;
+ isViewer: boolean;
+}
+
+/**
+ * [G7] `entries` is capped at the top 100 (no pagination for the MVP).
+ * `viewerOutsideTop` carries the viewer's own row only when it did NOT make
+ * that cut — when it did, the viewer's row is already in `entries` (flagged
+ * `isViewer`) and this is null, so the UI never shows both at once. Also
+ * null when the viewer has zero XP for the period (not ranked yet).
+ */
+export interface LeaderboardDto {
+ entries: LeaderboardEntryDto[];
+ viewerOutsideTop: LeaderboardEntryDto | null;
+}
diff --git a/packages/shared/src/error-codes.ts b/packages/shared/src/error-codes.ts
index 7a06c84a..f1b775eb 100644
--- a/packages/shared/src/error-codes.ts
+++ b/packages/shared/src/error-codes.ts
@@ -159,6 +159,7 @@ export const ErrorCode = {
// gamification
GamificationAchievementNotFound: "gamification.achievement_not_found",
+ GamificationFeatureDisabled: "gamification.feature_disabled",
// cross-cutting — owned by the infra rather than a single domain
ValidationFailed: "validation.failed",
From 762f5f9167827cf7ac811f65d441193df28ae5d4 Mon Sep 17 00:00:00 2001
From: Logan Willem
Date: Sat, 5 Sep 2026 12:14:01 +0200
Subject: [PATCH 5/8] =?UTF-8?q?=F0=9F=90=9B=20Restore=20the=20podium=20tin?=
=?UTF-8?q?t=20and=20"you"=20marker=20on=20the=20leaderboard?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The approved mockup ("Bobine") had a gold/silver/bronze gradient tint
+ colored rank number on the top 3, and a left accent stripe on your
own row — both were lost between the mockup and the actual page, which
shipped with a plain flat list. Reuses the existing tier-gold/silver/
bronze tokens (same ones the achievement medallions use) rather than
inventing new colors. Divider switched to dashed to match the
"pellicule" look too.
Co-Authored-By: Claude Sonnet 5
---
.../src/routes/app/leaderboard/+page.svelte | 34 ++++++++++++++++---
1 file changed, 29 insertions(+), 5 deletions(-)
diff --git a/apps/web/src/routes/app/leaderboard/+page.svelte b/apps/web/src/routes/app/leaderboard/+page.svelte
index b7018f87..e589da7a 100644
--- a/apps/web/src/routes/app/leaderboard/+page.svelte
+++ b/apps/web/src/routes/app/leaderboard/+page.svelte
@@ -17,12 +17,37 @@
import { m } from "$lib/paraglide/messages.js";
import {
levelForXp,
+ type LeaderboardEntryDto,
type LeaderboardPeriod,
type LeaderboardScope,
} from "@loomkeep/shared";
import { flip } from "svelte/animate";
import { fade } from "svelte/transition";
+ // Podium tint for the top 3 — a left-to-right fade, same tier colors as
+ // the achievements medallions (text-tier-gold/silver/bronze). The viewer's
+ // own row overrides this even at rank 1: "this is you" outranks "this is
+ // gold" as the thing to notice.
+ function rankTextClass(entry: LeaderboardEntryDto): string {
+ if (entry.isViewer) return "text-accent";
+ if (entry.rank === 1) return "text-tier-gold";
+ if (entry.rank === 2) return "text-tier-silver";
+ if (entry.rank === 3) return "text-tier-bronze";
+ return "text-fg";
+ }
+
+ function rowClass(entry: LeaderboardEntryDto): string {
+ if (entry.isViewer)
+ return "bg-accent/10 shadow-[inset_2px_0_0_0_var(--color-accent)]";
+ if (entry.rank === 1)
+ return "bg-linear-to-r from-tier-gold/15 to-transparent hover:from-tier-gold/20";
+ if (entry.rank === 2)
+ return "bg-linear-to-r from-tier-silver/15 to-transparent hover:from-tier-silver/20";
+ if (entry.rank === 3)
+ return "bg-linear-to-r from-tier-bronze/15 to-transparent hover:from-tier-bronze/20";
+ return "hover:bg-surface-2";
+ }
+
let scope = $state("global");
let period = $state("month");
@@ -94,17 +119,16 @@
{:else if entries.length === 0 && !viewerOutsideTop}
{m.gamification_leaderboard_empty()}
{:else}
-
{/if}
From d2a9e69fe09e24e51136dcccb63fe81cb64a96d1 Mon Sep 17 00:00:00 2001
From: Logan Willem
Date: Sat, 5 Sep 2026 12:39:43 +0200
Subject: [PATCH 7/8] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Raise=20LEVEL=5FBASE?=
=?UTF-8?q?=20so=20DOMAIN=5FSTARTED=20alone=20can't=20clear=20level=202?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A brand-new account got to ~level 2.8 the moment it added its very
first item: WORK_ADDED (2 XP) + DOMAIN_STARTED (100 XP, a one-off
per-domain milestone) = 102 XP, comfortably past the old level 2
threshold (52 XP). Rather than shrink DOMAIN_STARTED, raised
LEVEL_BASE 40 -> 100 (LEVEL_STEP unchanged), so level 2 now costs 112
XP — the single biggest freebie in the game no longer levels you up
by itself. LEVEL_CAP_LEVEL/XP_AT_CAP_LEVEL recomputed to keep the
quadratic and capped branches meeting exactly at the boundary (now
75 -> 76 instead of 80 -> 81), and level.util.spec.ts's thresholds
updated to match — all 11 cases still pass.
Co-Authored-By: Claude Sonnet 5
---
apps/api/src/gamification/level.util.spec.ts | 58 +++++++++----------
apps/web/messages/en/errors.json | 34 +++++------
apps/web/messages/fr/errors.json | 16 ++---
.../register/check-email/+page.svelte | 4 +-
packages/shared/src/level.ts | 38 +++++++-----
5 files changed, 81 insertions(+), 69 deletions(-)
diff --git a/apps/api/src/gamification/level.util.spec.ts b/apps/api/src/gamification/level.util.spec.ts
index 5d003d58..43af75d5 100644
--- a/apps/api/src/gamification/level.util.spec.ts
+++ b/apps/api/src/gamification/level.util.spec.ts
@@ -10,26 +10,26 @@ describe("xpForLevel", () => {
expect(xpForLevel(1)).toBe(0);
});
- it("matches the calibrated thresholds from the [G1] plan", () => {
- expect(xpForLevel(2)).toBe(52);
- expect(xpForLevel(5)).toBe(280);
- expect(xpForLevel(10)).toBe(900);
- expect(xpForLevel(20)).toBe(3040);
- expect(xpForLevel(40)).toBe(10_920);
- expect(xpForLevel(60)).toBe(23_600);
- expect(xpForLevel(LEVEL_CAP_LEVEL)).toBe(41_080);
- expect(xpForLevel(LEVEL_CAP_LEVEL + 1)).toBe(42_080);
- expect(xpForLevel(100)).toBe(61_080);
- expect(xpForLevel(430)).toBe(391_080);
+ it("matches the calibrated thresholds (raised once DOMAIN_STARTED alone was clearing level 2)", () => {
+ expect(xpForLevel(2)).toBe(112);
+ expect(xpForLevel(5)).toBe(520);
+ expect(xpForLevel(10)).toBe(1440);
+ expect(xpForLevel(20)).toBe(4180);
+ expect(xpForLevel(40)).toBe(13_260);
+ expect(xpForLevel(60)).toBe(27_140);
+ expect(xpForLevel(LEVEL_CAP_LEVEL)).toBe(40_700);
+ expect(xpForLevel(LEVEL_CAP_LEVEL + 1)).toBe(41_700);
+ expect(xpForLevel(100)).toBe(65_700);
+ expect(xpForLevel(430)).toBe(395_700);
});
- it("is continuous across the cap boundary (79 → 80 → 81)", () => {
- const cost79to80 = xpForLevel(80) - xpForLevel(79);
- const cost80to81 = xpForLevel(81) - xpForLevel(80);
- const cost81to82 = xpForLevel(82) - xpForLevel(81);
- expect(cost79to80).toBe(988); // 40 + 12*79, still uncapped
- expect(cost80to81).toBe(1000); // 40 + 12*80 == cap, exactly
- expect(cost81to82).toBe(1000); // flat cap from here on
+ it("is continuous across the cap boundary (74 → 75 → 76)", () => {
+ const cost74to75 = xpForLevel(75) - xpForLevel(74);
+ const cost75to76 = xpForLevel(76) - xpForLevel(75);
+ const cost76to77 = xpForLevel(77) - xpForLevel(76);
+ expect(cost74to75).toBe(988); // 100 + 12*74, still uncapped
+ expect(cost75to76).toBe(1000); // 100 + 12*75 == cap, exactly
+ expect(cost76to77).toBe(1000); // flat cap from here on
});
it("is strictly monotonic increasing", () => {
@@ -45,13 +45,13 @@ describe("levelForXp", () => {
});
it("matches the calibrated thresholds exactly at the boundary", () => {
- expect(levelForXp(52)).toBe(2);
- expect(levelForXp(51)).toBe(1);
- expect(levelForXp(900)).toBe(10);
- expect(levelForXp(41_080)).toBe(LEVEL_CAP_LEVEL);
- expect(levelForXp(42_080)).toBe(LEVEL_CAP_LEVEL + 1);
- expect(levelForXp(43_080)).toBe(LEVEL_CAP_LEVEL + 2);
- expect(levelForXp(391_080)).toBe(430);
+ expect(levelForXp(112)).toBe(2);
+ expect(levelForXp(111)).toBe(1);
+ expect(levelForXp(1440)).toBe(10);
+ expect(levelForXp(40_700)).toBe(LEVEL_CAP_LEVEL);
+ expect(levelForXp(41_700)).toBe(LEVEL_CAP_LEVEL + 1);
+ expect(levelForXp(42_700)).toBe(LEVEL_CAP_LEVEL + 2);
+ expect(levelForXp(395_700)).toBe(430);
});
it("handles a very large XP total past the cap", () => {
@@ -76,21 +76,21 @@ describe("levelProgress", () => {
expect(levelProgress(0)).toEqual({
level: 1,
xpInLevel: 0,
- xpToNext: 52,
+ xpToNext: 112,
});
});
it("reports partial progress mid-level", () => {
- // 30 XP into level 1 (which costs 52 to clear).
+ // 30 XP into level 1 (which costs 112 to clear).
expect(levelProgress(30)).toEqual({
level: 1,
xpInLevel: 30,
- xpToNext: 22,
+ xpToNext: 82,
});
});
it("is consistent across the cap boundary", () => {
- const progress = levelProgress(41_080 + 500);
+ const progress = levelProgress(40_700 + 500);
expect(progress.level).toBe(LEVEL_CAP_LEVEL);
expect(progress.xpInLevel).toBe(500);
expect(progress.xpToNext).toBe(500);
diff --git a/apps/web/messages/en/errors.json b/apps/web/messages/en/errors.json
index 6a9a56f5..ca855153 100644
--- a/apps/web/messages/en/errors.json
+++ b/apps/web/messages/en/errors.json
@@ -5,22 +5,22 @@
"apierr_auth_email_already_exists": "An account already exists with this email address.",
"apierr_auth_registration_disabled": "Registration is currently closed.",
"apierr_auth_anti_bot_verification_failed": "Anti-bot verification failed. Please try again.",
- "apierr_auth_password_breached": "This password has appeared in a known data breach — please choose a different one.",
+ "apierr_auth_password_breached": "This password has appeared in a known data breach, please choose a different one.",
"apierr_auth_invalid_verification_token": "This verification link is invalid or has expired.",
"apierr_auth_already_verified": "This account is already verified.",
"apierr_auth_invalid_credentials": "Incorrect credentials.",
"apierr_auth_invalid_mfa_challenge": "This verification session is invalid or has expired. Please log in again.",
- "apierr_auth_mfa_too_many_attempts": "Too many attempts — please log in again.",
+ "apierr_auth_mfa_too_many_attempts": "Too many attempts, please log in again.",
"apierr_auth_mfa_invalid_code": "Invalid code.",
- "apierr_auth_invalid_refresh_token": "Your session has expired — please log in again.",
+ "apierr_auth_invalid_refresh_token": "Your session has expired, please log in again.",
"apierr_auth_invalid_reset_token": "This reset link is invalid or has expired.",
"apierr_auth_mfa_totp_not_in_progress": "No TOTP setup in progress.",
"apierr_auth_current_password_incorrect": "The current password is incorrect.",
"apierr_auth_missing_access_token": "Authentication required.",
- "apierr_auth_invalid_access_token": "Your session has expired — please log in again.",
+ "apierr_auth_invalid_access_token": "Your session has expired, please log in again.",
"apierr_auth_missing_except_param": "Invalid request.",
"apierr_admin_cache_item_not_found": "This item could not be found in the cache.",
- "apierr_admin_cache_resync_failed": "Resync failed — the source may be unreachable.",
+ "apierr_admin_cache_resync_failed": "Resync failed, the source may be unreachable.",
"apierr_library_episode_not_aired": "This episode hasn't aired yet.",
"apierr_user_avatar_too_large": "The image is too large.",
"apierr_user_avatar_invalid_type": "The file doesn't match the expected image type.",
@@ -32,7 +32,7 @@
"apierr_library_no_watch_to_undo": "No watch to undo for this episode.",
"apierr_library_entry_not_found": "This item could not be found in your library.",
"apierr_library_entry_forbidden": "This item belongs to another account.",
- "apierr_library_replay_not_movie": "Only movies can have replays — series/anime rewatches are tracked per episode.",
+ "apierr_library_replay_not_movie": "Only movies can have replays, series/anime rewatches are tracked per episode.",
"apierr_library_replay_not_found": "This replay could not be found.",
"apierr_library_replay_forbidden": "This replay belongs to another account.",
"apierr_catalog_unknown_media_type": "Unknown media type.",
@@ -40,7 +40,7 @@
"apierr_catalog_media_type_required": "The media type is required.",
"apierr_catalog_item_not_found": "This item could not be found in the catalogue.",
"apierr_catalog_person_not_found": "This person could not be found in the catalogue.",
- "apierr_catalog_provider_unavailable": "The catalogue is temporarily unavailable — try again in a moment.",
+ "apierr_catalog_provider_unavailable": "The catalogue is temporarily unavailable, try again in a moment.",
"apierr_catalog_search_query_required": "A search term is required.",
"apierr_import_malformed_export": "The exported file couldn't be read or is malformed.",
"apierr_import_job_not_found": "This import could not be found.",
@@ -49,27 +49,27 @@
"apierr_import_job_no_analysis": "This import hasn't been analyzed yet.",
"apierr_import_job_already_running": "Another import is already running. Wait for it to finish before starting a new one.",
"apierr_import_unknown_source": "Unknown import source.",
- "apierr_import_free_quota_exceeded": "One free import per domain — go premium to re-import in this domain.",
- "apierr_import_source_unavailable": "The import source is temporarily unavailable — try again in a moment.",
- "apierr_import_steam_profile_not_found": "Steam profile not found — check the id or profile URL.",
- "apierr_import_steam_library_private": "Steam library inaccessible — the profile and game details must be public.",
+ "apierr_import_free_quota_exceeded": "One free import per domain, go premium to re-import in this domain.",
+ "apierr_import_source_unavailable": "The import source is temporarily unavailable, try again in a moment.",
+ "apierr_import_steam_profile_not_found": "Steam profile not found, check the id or profile URL.",
+ "apierr_import_steam_library_private": "Steam library inaccessible, the profile and game details must be public.",
"apierr_import_archive_empty": "The uploaded archive is empty.",
"apierr_import_archive_unreadable": "The archive couldn't be read.",
"apierr_import_archive_missing_files": "The archive is missing required files.",
- "apierr_import_archive_malformed": "The archive couldn't be read — one of its files is malformed.",
+ "apierr_import_archive_malformed": "The archive couldn't be read, one of its files is malformed.",
"apierr_gamification_achievement_not_found": "This achievement could not be found.",
"apierr_gamification_feature_disabled": "This feature isn't available on this instance.",
"apierr_validation_invalid_param": "Invalid parameter.",
"apierr_validation_failed": "The form contains errors.",
"apierr_internal_error": "Something went wrong.",
- "apierr_network_offline": "Couldn't reach the server — check your connection.",
+ "apierr_network_offline": "Couldn't reach the server, check your connection.",
"apierr_status_400": "This request couldn't be processed.",
- "apierr_status_401": "Your session has expired — please log in again.",
+ "apierr_status_401": "Your session has expired, please log in again.",
"apierr_status_403": "You don't have access to this resource.",
"apierr_status_404": "This couldn't be found.",
"apierr_status_409": "This action conflicts with the current state.",
- "apierr_status_429": "Too many requests — try again in a moment.",
- "apierr_status_429_retry": "Too many requests — try again in {seconds}s.",
+ "apierr_status_429": "Too many requests, try again in a moment.",
+ "apierr_status_429_retry": "Too many requests, try again in {seconds}s.",
"apierr_status_500": "Something went wrong on the server.",
"apierr_admin_cache_item_referenced": "This cache item is still referenced and can't be deleted.",
"apierr_admin_cache_item_has_content": "This cache item still has content and can't be deleted.",
@@ -96,7 +96,7 @@
"apierr_lists_item_not_found": "This item could not be found in the list.",
"apierr_lists_item_already_exists": "This item is already in the list.",
"apierr_lists_reorder_mismatch": "The submitted order doesn't match the list's current items.",
- "apierr_lists_stale": "This list changed in the meantime — reload the page.",
+ "apierr_lists_stale": "This list changed in the meantime, reload the page.",
"apierr_lists_member_user_not_found": "This user could not be found.",
"apierr_lists_cannot_add_self": "You can't add yourself to this list.",
"apierr_lists_member_already_editor": "This user can already edit this list.",
diff --git a/apps/web/messages/fr/errors.json b/apps/web/messages/fr/errors.json
index 985b06ba..2ace4778 100644
--- a/apps/web/messages/fr/errors.json
+++ b/apps/web/messages/fr/errors.json
@@ -5,12 +5,12 @@
"apierr_auth_email_already_exists": "Un compte existe déjà avec cette adresse e-mail.",
"apierr_auth_registration_disabled": "Les inscriptions sont actuellement fermées.",
"apierr_auth_anti_bot_verification_failed": "La vérification anti-robot a échoué. Réessaie.",
- "apierr_auth_password_breached": "Ce mot de passe a été trouvé dans une fuite de données connue — choisis-en un autre.",
+ "apierr_auth_password_breached": "Ce mot de passe a été trouvé dans une fuite de données connue, choisis-en un autre.",
"apierr_auth_invalid_verification_token": "Ce lien de vérification est invalide ou a expiré.",
"apierr_auth_already_verified": "Ce compte est déjà vérifié.",
"apierr_auth_invalid_credentials": "Identifiants incorrects.",
"apierr_auth_invalid_mfa_challenge": "Cette session de vérification est invalide ou a expiré. Reconnecte-toi.",
- "apierr_auth_mfa_too_many_attempts": "Trop de tentatives — reconnecte-toi.",
+ "apierr_auth_mfa_too_many_attempts": "Trop de tentatives, reconnecte-toi.",
"apierr_auth_mfa_invalid_code": "Code invalide.",
"apierr_auth_invalid_refresh_token": "Ta session a expiré, reconnecte-toi.",
"apierr_auth_invalid_reset_token": "Ce lien de réinitialisation est invalide ou a expiré.",
@@ -32,7 +32,7 @@
"apierr_library_no_watch_to_undo": "Aucun visionnage à annuler pour cet épisode.",
"apierr_library_entry_not_found": "Cet élément est introuvable dans ta bibliothèque.",
"apierr_library_entry_forbidden": "Cet élément appartient à un autre compte.",
- "apierr_library_replay_not_movie": "Seuls les films peuvent être revus — les séries et animes se suivent épisode par épisode.",
+ "apierr_library_replay_not_movie": "Seuls les films peuvent être revus, les séries et animes se suivent épisode par épisode.",
"apierr_library_replay_not_found": "Ce revisionnage est introuvable.",
"apierr_library_replay_forbidden": "Ce revisionnage appartient à un autre compte.",
"apierr_catalog_unknown_media_type": "Type de média inconnu.",
@@ -49,20 +49,20 @@
"apierr_import_job_no_analysis": "Cet import n'a pas encore été analysé.",
"apierr_import_job_already_running": "Un autre import est déjà en cours. Attendez sa fin avant d'en démarrer un nouveau.",
"apierr_import_unknown_source": "Source d'import inconnue.",
- "apierr_import_free_quota_exceeded": "Un import gratuit par domaine — passe premium pour réimporter dans ce domaine.",
+ "apierr_import_free_quota_exceeded": "Un import gratuit par domaine, passe premium pour réimporter dans ce domaine.",
"apierr_import_source_unavailable": "La source d'import est momentanément indisponible, réessaie dans un instant.",
- "apierr_import_steam_profile_not_found": "Profil Steam introuvable — vérifie l'identifiant ou l'URL du profil.",
- "apierr_import_steam_library_private": "Bibliothèque Steam inaccessible — le profil et les détails des jeux doivent être publics.",
+ "apierr_import_steam_profile_not_found": "Profil Steam introuvable, vérifie l'identifiant ou l'URL du profil.",
+ "apierr_import_steam_library_private": "Bibliothèque Steam inaccessible, le profil et les détails des jeux doivent être publics.",
"apierr_import_archive_empty": "L'archive envoyée est vide.",
"apierr_import_archive_unreadable": "L'archive est illisible.",
"apierr_import_archive_missing_files": "Il manque des fichiers requis dans l'archive.",
- "apierr_import_archive_malformed": "L'archive est illisible — un de ses fichiers est mal formé.",
+ "apierr_import_archive_malformed": "L'archive est illisible, un de ses fichiers est mal formé.",
"apierr_gamification_achievement_not_found": "Ce succès est introuvable.",
"apierr_gamification_feature_disabled": "Cette fonctionnalité n'est pas disponible sur cette instance.",
"apierr_validation_invalid_param": "Paramètre invalide.",
"apierr_validation_failed": "Le formulaire contient des erreurs.",
"apierr_internal_error": "Une erreur inattendue s'est produite.",
- "apierr_network_offline": "Impossible de contacter le serveur — vérifie ta connexion.",
+ "apierr_network_offline": "Impossible de contacter le serveur, vérifie ta connexion.",
"apierr_status_400": "La demande n'a pas pu être traitée.",
"apierr_status_401": "Ta session a expiré, reconnecte-toi.",
"apierr_status_403": "Tu n'as pas accès à cette ressource.",
diff --git a/apps/web/src/routes/(verification)/register/check-email/+page.svelte b/apps/web/src/routes/(verification)/register/check-email/+page.svelte
index 41b5c369..0ce63c30 100644
--- a/apps/web/src/routes/(verification)/register/check-email/+page.svelte
+++ b/apps/web/src/routes/(verification)/register/check-email/+page.svelte
@@ -70,8 +70,8 @@
diff --git a/packages/shared/src/level.ts b/packages/shared/src/level.ts
index c53eb1fa..614a9c41 100644
--- a/packages/shared/src/level.ts
+++ b/packages/shared/src/level.ts
@@ -5,31 +5,35 @@
*
* cost(N → N+1) = min(LEVEL_BASE + LEVEL_STEP × N, LEVEL_CAP_COST), where N
* is the level being left. The constant term gives the first levels real
- * weight (level 2 costs 52 XP — a real evening, not a couple of clicks); the
- * cap makes every level cost the same 1000 XP past LEVEL_CAP_LEVEL, so a
- * heavy user's level keeps climbing forever without the curve blowing up.
+ * weight (level 2 costs 112 XP) — raised from an original 40/52 once
+ * DOMAIN_STARTED's 100 XP one-off (see xp-rules.ts) turned out to clear the
+ * old threshold on its own, making a brand-new account hit level 2 the
+ * moment it added a single item. 112 keeps that one milestone from being
+ * enough by itself. The cap makes every level cost the same 1000 XP past
+ * LEVEL_CAP_LEVEL, so a heavy user's level keeps climbing forever without
+ * the curve blowing up.
*
* Closed form (avoids an O(level) loop to answer "what level is N XP" — see
* `levelForXp`): summing the per-level cost above from level 1 up to L gives
* an arithmetic series while uncapped, then a flat term once capped.
* xpForLevel(L), L ≤ LEVEL_CAP_LEVEL + 1: (L − 1) × (LEVEL_BASE + 6L)
- * — derived from Σ_{N=1}^{L-1} (40 + 12N) = (L-1)·40 + 12·(L-1)L/2
- * = (L-1)·(40 + 6L)
+ * — derived from Σ_{N=1}^{L-1} (100 + 12N) = (L-1)·100 + 12·(L-1)L/2
+ * = (L-1)·(100 + 6L)
* xpForLevel(L), L > LEVEL_CAP_LEVEL + 1: XP_AT_CAP_LEVEL + (L − LEVEL_CAP_LEVEL − 1) × LEVEL_CAP_COST
- * Both branches agree exactly at L = LEVEL_CAP_LEVEL + 1 (81): the last
- * uncapped step (level 80 → 81) already costs exactly LEVEL_CAP_COST, so the
+ * Both branches agree exactly at L = LEVEL_CAP_LEVEL + 1 (76): the last
+ * uncapped step (level 75 → 76) already costs exactly LEVEL_CAP_COST, so the
* quadratic and the linear formula meet without a seam — verified by
* `level.util.spec.ts`'s continuity test around that boundary.
*/
-export const LEVEL_BASE = 40;
+export const LEVEL_BASE = 100;
export const LEVEL_STEP = 12;
export const LEVEL_CAP_COST = 1000;
-// Level whose outgoing step first hits LEVEL_CAP_COST: 40 + 12×80 = 1000.
-export const LEVEL_CAP_LEVEL = 80;
+// Level whose outgoing step first hits LEVEL_CAP_COST: 100 + 12×75 = 1000.
+export const LEVEL_CAP_LEVEL = 75;
// xpForLevel(LEVEL_CAP_LEVEL + 1), i.e. the cumulative XP at which every
// subsequent level starts costing a flat LEVEL_CAP_COST. Precomputed so
// `levelForXp` doesn't need to evaluate the quadratic branch to find it.
-const XP_AT_CAP_LEVEL = 42_080;
+const XP_AT_CAP_LEVEL = 41_700;
/** Cumulative XP required to reach `level` (level 1 = 0 XP). */
export function xpForLevel(level: number): number {
@@ -40,10 +44,18 @@ export function xpForLevel(level: number): number {
return XP_AT_CAP_LEVEL + (level - (LEVEL_CAP_LEVEL + 1)) * LEVEL_CAP_COST;
}
-/** The level reached at a given XP total (exact, O(1) — inverse of `xpForLevel`). */
+/**
+ * The level reached at a given XP total (exact, O(1) — inverse of
+ * `xpForLevel`). The quadratic solves 6L² + 94L − (100 + xp) = 0 — the
+ * general form is 6L² + (LEVEL_BASE − 6)L − (LEVEL_BASE + xp) = 0, so the
+ * 11236/94 below are (LEVEL_BASE − 6)² + 24×LEVEL_BASE and LEVEL_BASE − 6
+ * for the current LEVEL_BASE=100; both need recomputing if LEVEL_BASE
+ * changes again (see `level.util.spec.ts`'s round-trip test, which would
+ * catch a mismatch immediately).
+ */
export function levelForXp(xp: number): number {
if (xp <= XP_AT_CAP_LEVEL) {
- return Math.floor((Math.sqrt(2116 + 24 * xp) - 34) / 12);
+ return Math.floor((Math.sqrt(11_236 + 24 * xp) - 94) / 12);
}
return (
From 3a55737dc94f617ac582ff1b98485040c6db7f6b Mon Sep 17 00:00:00 2001
From: Logan Willem
Date: Sat, 5 Sep 2026 12:54:56 +0200
Subject: [PATCH 8/8] =?UTF-8?q?=E2=9C=A8=20Refactor=20header=20components?=
=?UTF-8?q?=20in=20multiple=20settings=20pages=20to=20use=20PageHeader=20f?=
=?UTF-8?q?or=20consistency?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../src/lib/components/ImportWizard.svelte | 15 ++++---------
.../components/SecuritySection.svelte | 18 +++++++++++-----
.../routes/app/settings/import/+page.svelte | 19 +++++------------
.../app/settings/import/simkl/+page.svelte | 21 +++++--------------
.../import/simkl/callback/+page.svelte | 18 +++++-----------
.../routes/app/settings/sessions/+page.svelte | 20 ++++++------------
6 files changed, 38 insertions(+), 73 deletions(-)
diff --git a/apps/web/src/lib/components/ImportWizard.svelte b/apps/web/src/lib/components/ImportWizard.svelte
index 40815621..6b814bc5 100644
--- a/apps/web/src/lib/components/ImportWizard.svelte
+++ b/apps/web/src/lib/components/ImportWizard.svelte
@@ -49,6 +49,7 @@
importReportLabel,
importReportSubtitle,
} from "./import-presentation";
+ import PageHeader from "./PageHeader.svelte";
// `intro` holds the source-specific export instructions (markup, hence a
// snippet rather than a config field); everything else comes from `source`.
@@ -423,17 +424,9 @@