diff --git a/.dockerignore b/.dockerignore
index 72785d04..bad19657 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -2,8 +2,28 @@
**/dist
**/build
**/.svelte-kit
-**/coverage
-.git
**/.env
**/.env.*
!**/.env.example
+*.log
+npm-debug.log*
+
+.vscode
+*.swp
+*.swo
+
+.git
+.gitignore
+
+**/Dockerfile*
+docker-compose*.yml
+
+*.md
+!README.md
+LICENSE*
+.DS_Store
+*.tsbuildinfo
+
+**/coverage
+**/test-results
+**/playwright-report
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 00000000..33fd6576
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,10 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+insert_final_newline = true
+trim_trailing_whitespace = true
+indent_style = space
+indent_size = 2
+
diff --git a/.github/CODEOWNERS.md b/.github/CODEOWNERS.md
new file mode 100644
index 00000000..c96205ff
--- /dev/null
+++ b/.github/CODEOWNERS.md
@@ -0,0 +1,3 @@
+# CODEOWNERS
+
+- @Logan2234
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 00000000..86e38c24
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,24 @@
+## Summary *
+
+
+
+## Related *
+
+- [ ] Quackback ticket:
+- [ ] Issue link:
+- [ ] n/a
+
+## Screenshots
+
+
+
+## Checklist
+
+- [ ] Tests added/updated for the behavior this changes (a bug fix includes a regression test that failed before it)
+- [ ] New user-facing strings added to **both** `fr` and `en` message catalogs
+- [ ] `pnpm build:package` run if `packages/shared` changed
+- [ ] A Prisma migration is included if `schema.prisma` changed
+- [ ] No `--no-verify` — hooks ran clean
+- [ ] Manual testing performed
diff --git a/.github/workflows/ghcr-cleanup.yml b/.github/workflows/ghcr-cleanup.yml
new file mode 100644
index 00000000..8adf5515
--- /dev/null
+++ b/.github/workflows/ghcr-cleanup.yml
@@ -0,0 +1,34 @@
+name: GHCR cleanup
+
+on:
+ schedule:
+ # Weekly is plenty — untagged versions only pile up one push to main at a
+ # time (docker-push in ci.yml), never in bulk.
+ - cron: "12 4 * * 1"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ cleanup:
+ runs-on: ubuntu-latest
+ permissions:
+ packages: write
+ steps:
+ # Every push to main tags both loomkeep-api and loomkeep-web with
+ # `latest` and the commit's short SHA (see ci.yml's docker-push job) —
+ # nothing is ever pushed genuinely untagged by that job itself. What
+ # accumulates as "untagged" in the package view is Buildx's build
+ # provenance/SBOM attestation manifests, one extra per pushed image.
+ # GHCR has no built-in expiry for a personal-account package (that's an
+ # org-only setting), so without this, they'd sit there forever. This
+ # action understands the referrer/attestation relationship and only
+ # removes what nothing else points to — a plain "delete anything
+ # untagged" approach would be unsafe here.
+ - uses: dataaxiom/ghcr-cleanup-action@d52806a0dc70b430571a37da1fde39733ffd640f # v1.2.2
+ with:
+ package: loomkeep-api,loomkeep-web
+ delete-untagged: true
+ delete-orphaned-images: true
+ registry-url: "https://ghcr.io"
diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 00000000..8fdd954d
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+22
\ No newline at end of file
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 00000000..2a76c8c2
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,90 @@
+# Contributing to Loomkeep
+
+Thanks for taking the time to contribute! Loomkeep is maintained solo
+([@Logan2234](https://github.com/Logan2234)) as a side project, so response
+times can vary — but PRs and issues are genuinely welcome.
+
+## Before you file something
+
+- **Feature idea or a bug in the app itself** (UI, sync, watch tracking...)?
+ That goes on [feedback.loomkeep.app](https://feedback.loomkeep.app), not
+ GitHub Issues — the **Feature Requests** and **Bug Reports** boards there
+ are public and votable.
+- **Bug in self-hosting/deployment** (Docker, migrations, reverse proxy)?
+ [Open a GitHub issue](https://github.com/Logan2234/loomkeep/issues/new/choose)
+ with the _Self-hosting / deployment bug_ template.
+- **Anything about the codebase itself** (a contribution question, CI, docs)?
+ Same place, _Other_ template.
+- **Security vulnerability?** Don't open a public issue — see
+ [SECURITY.md](SECURITY.md).
+
+## Project layout
+
+pnpm workspace, 100% TypeScript:
+
+| Path | What |
+| ----------------- | ---------------------------------------------------------- |
+| `apps/api` | NestJS + Prisma + PostgreSQL |
+| `apps/web` | SvelteKit PWA |
+| `packages/shared` | DTOs/enums shared by both, consumed from its built `dist/` |
+
+[`CLAUDE.md`](CLAUDE.md) is the deep-dive architecture doc (data model,
+auth, feature flags, i18n, conventions) — worth a skim before a non-trivial
+change, whether you're a human or an AI coding agent.
+
+## Local setup
+
+```sh
+pnpm i
+docker run -d --name loomkeep-dev-db -e POSTGRES_USER=loomkeep \
+ -e POSTGRES_PASSWORD=loomkeep -e POSTGRES_DB=loomkeep \
+ -p 5433:5432 postgres:18-alpine
+cp .env.example .env
+cp apps/api/.env.example apps/api/.env
+pnpm --filter @loomkeep/api exec prisma migrate dev
+pnpm generate
+pnpm dev # api on :3000, web on :5173
+```
+
+Full self-hosting instructions (Docker, add-ons, SSO...) are in the
+[README](README.md) — that setup is for _running_ Loomkeep, this one is for
+_working on it_.
+
+## Making a change
+
+1. Branch off `main`, `feat/`, `fix/`, `chore/` prefixes are required.
+2. Match the existing style rather than introducing a new one: read the
+ surrounding code before writing yours, and check `apps/web/DESIGN.md` for
+ anything UI-facing.
+3. Keep changes surgical — a bug fix doesn't need a drive-by refactor of
+ nearby code, and vice versa. If you spot something else worth fixing,
+ mention it in the PR description rather than folding it in.
+4. Every non-trivial feature needs at least one test; a bug fix needs a
+ regression test that fails before the fix and passes after.
+5. `pre-commit` (lint-staged) and `pre-push` (typecheck) hooks run
+ automatically — don't skip them (`--no-verify`). CI runs the full test
+ suite, e2e, and a few security/quality scans (CodeQL, Trivy, pa11y) on
+ every PR.
+
+### Commit messages
+
+Imperative, English, one emoji prefix per the summary line:
+
+| Emoji | Code | For |
+| ----- | ------------ | --------------------------- |
+| ✨ | `:sparkles:` | A new feature |
+| 🐛 | `:bug:` | A bug fix |
+| ♻️ | `:recycle:` | A refactor |
+| 📝 | `:memo:` | Docs, comments, tests |
+| ⚡ | `:zap:` | Performance, build, tooling |
+| 🔖 | `:bookmark:` | A version bump |
+
+## Opening a pull request
+
+Target `main`. The PR template asks for the essentials — fill it in, it's
+short on purpose. Draft PRs are fine if you want early feedback.
+
+## License
+
+AGPL-3.0. By contributing, you agree your changes are licensed under the
+same terms as the rest of the project.
diff --git a/SECURITY.md b/SECURITY.md
index fb2c0ac6..11820448 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -1,8 +1,12 @@
# Security Policy
-Loomkeep is a self-hosted, single-owner project — there's no hosted
-multi-tenant instance to protect, but a vulnerability in the code still
-matters for anyone self-hosting it.
+Loomkeep ships two ways: self-hosted (your own PostgreSQL, your own data),
+and as a hosted instance at [loomkeep.app](https://loomkeep.app) with real
+user accounts — registration, auth, and personal data (watch history,
+reviews, social graph) that a vulnerability could actually expose. Both
+matter: a report against the hosted instance is treated like a real
+multi-tenant incident, and a report against the self-hosted path is treated
+as something every self-hoster's data depends on.
## Supported versions
diff --git a/apps/api/package.json b/apps/api/package.json
index dc6c117c..dd3f50af 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -53,10 +53,10 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"dotenv": "^17.4.2",
- "fastify": "^5.12.1",
- "nestjs-pino": "^5.0.0",
- "nodemailer": "^9.1.0",
- "otpauth": "^9.5.1",
+ "fastify": "^5.12.3",
+ "nestjs-pino": "^5.1.0",
+ "nodemailer": "^10.0.0",
+ "otpauth": "^9.5.2",
"pino-http": "^11.0.0",
"prisma": "^7.10.0",
"reflect-metadata": "^0.2.2",
@@ -69,8 +69,8 @@
"@nestjs/cli": "^12.0.0",
"@nestjs/schematics": "^12.0.0",
"@nestjs/testing": "^12.0.1",
- "@swc/core": "^1.16.1",
- "@types/node": "^26.4.0",
+ "@swc/core": "^1.16.2",
+ "@types/node": "^26.4.1",
"@types/nodemailer": "^8.0.1",
"@types/supertest": "^7.2.1",
"@types/web-push": "^3.6.4",
diff --git a/apps/api/src/gamification/gamification-feature.guard.ts b/apps/api/src/gamification/gamification-feature.guard.ts
new file mode 100644
index 00000000..20a8b429
--- /dev/null
+++ b/apps/api/src/gamification/gamification-feature.guard.ts
@@ -0,0 +1,31 @@
+import { ErrorCode } from "@loomkeep/shared";
+import { type CanActivate, HttpStatus, Injectable } from "@nestjs/common";
+import { ConfigService } from "@nestjs/config";
+import { AppException } from "../common/app.exception";
+import { FeatureFlagsService } from "../feature-flags/feature-flags.service";
+import { isGamificationEnabled } from "./gamification.config";
+
+/**
+ * Gates a gamification-dependent endpoint behind the runtime
+ * GAMIFICATION_ENABLED flag. Exact copy of `SocialFeatureGuard`'s pattern:
+ * 404 (not 403) when disabled, so a deployment that turned gamification off
+ * doesn't even advertise the endpoint exists.
+ */
+@Injectable()
+export class GamificationFeatureGuard implements CanActivate {
+ constructor(
+ private readonly config: ConfigService,
+ private readonly flags: FeatureFlagsService,
+ ) {}
+
+ canActivate(): boolean {
+ if (!isGamificationEnabled(this.config, this.flags)) {
+ throw new AppException(
+ HttpStatus.NOT_FOUND,
+ ErrorCode.GamificationFeatureDisabled,
+ );
+ }
+
+ return true;
+ }
+}
diff --git a/apps/api/src/gamification/gamification.module.ts b/apps/api/src/gamification/gamification.module.ts
index 4a0620ef..9c9a80f3 100644
--- a/apps/api/src/gamification/gamification.module.ts
+++ b/apps/api/src/gamification/gamification.module.ts
@@ -6,10 +6,11 @@ import { GamificationController } from "./gamification.controller";
import { XpService } from "./xp.service";
// G1: XP ledger + level curve. G2 adds the achievement engine (registry in
-// achievements/registry.ts) and its /achievements endpoints. Still no
-// leaderboard endpoint yet — that is [G7]. GET /gamification/me serves the
-// viewer their own XP without going through the social profile, so levels
-// keep working on a SOCIAL_ENABLED=false instance ("solo first").
+// achievements/registry.ts) and its /achievements endpoints. GET
+// /gamification/me serves the viewer their own XP without going through the
+// social profile, so levels keep working on a SOCIAL_ENABLED=false instance
+// ("solo first"). The [G7] leaderboard is social-gated by nature, so it lives
+// in SocialModule instead — see the comment there.
@Module({
imports: [JobsModule],
controllers: [AchievementsController, GamificationController],
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/api/src/health/health.controller.ts b/apps/api/src/health/health.controller.ts
index 6b6218b6..743177b2 100644
--- a/apps/api/src/health/health.controller.ts
+++ b/apps/api/src/health/health.controller.ts
@@ -1,25 +1,21 @@
import { Controller, Get } from "@nestjs/common";
import {
- DiskHealthIndicator,
HealthCheck,
HealthCheckService,
MemoryHealthIndicator,
PrismaHealthIndicator,
} from "@nestjs/terminus";
-import { join } from "node:path";
import { Public } from "../auth/decorators/public.decorator";
import { PrismaService } from "../prisma/prisma.service";
const MEMORY_THRESHOLD_BYTES = 512 * 1024 * 1024;
-// Always on (not dev-gated): its purpose is the Docker/self-host healthcheck.
@Public()
@Controller("health")
export class HealthController {
constructor(
private readonly health: HealthCheckService,
private readonly prismaIndicator: PrismaHealthIndicator,
- private readonly diskIndicator: DiskHealthIndicator,
private readonly memoryIndicator: MemoryHealthIndicator,
private readonly prisma: PrismaService,
) {}
@@ -29,13 +25,6 @@ export class HealthController {
check() {
return this.health.check([
() => this.prismaIndicator.pingCheck("database", this.prisma),
- // Same directory BackupService writes daily dumps to (see its own
- // BACKUP_DIR fallback) — a full disk fails backups silently otherwise.
- () =>
- this.diskIndicator.checkStorage("disk", {
- path: process.env.BACKUP_DIR ?? join(process.cwd(), "backups"),
- thresholdPercent: 0.9,
- }),
() =>
this.memoryIndicator.checkHeap("memory_heap", MEMORY_THRESHOLD_BYTES),
() => this.memoryIndicator.checkRSS("memory_rss", MEMORY_THRESHOLD_BYTES),
diff --git a/apps/api/src/social/follow.service.ts b/apps/api/src/social/follow.service.ts
index 7771eec5..343591eb 100644
--- a/apps/api/src/social/follow.service.ts
+++ b/apps/api/src/social/follow.service.ts
@@ -14,6 +14,7 @@ import { NotificationService } from "../notifications/notification.service";
import { PrismaService } from "../prisma/prisma.service";
import { toUserSummaryDto } from "../users/avatar.util";
import { VisibilityService } from "./visibility.service";
+import { computeIsFriend } from "./visibility.util";
const USER_SUMMARY_SELECT = {
id: true,
@@ -370,4 +371,37 @@ export class FollowService {
});
return rows.map((r) => toUserSummaryDto(r.followee));
}
+
+ /**
+ * Every user id `userId` is a friend of, per `computeIsFriend` — a PRIVATE
+ * account followed (their acceptance already means friend-level), or a
+ * PUBLIC account followed back. Two queries, not one per candidate: the
+ * [G7] friends-scoped leaderboard is the first caller that needs the whole
+ * set at once rather than a single pairwise relation.
+ */
+ async listFriendIds(userId: string): Promise {
+ const [followees, followers] = await Promise.all([
+ this.prisma.follow.findMany({
+ where: { followerId: userId, status: "ACCEPTED" },
+ select: {
+ followeeId: true,
+ followee: { select: { profileAccess: true } },
+ },
+ }),
+ this.prisma.follow.findMany({
+ where: { followeeId: userId, status: "ACCEPTED" },
+ select: { followerId: true },
+ }),
+ ]);
+ const followsMe = new Set(followers.map((f) => f.followerId));
+ return followees
+ .filter((f) =>
+ computeIsFriend(
+ f.followee.profileAccess,
+ true,
+ followsMe.has(f.followeeId),
+ ),
+ )
+ .map((f) => f.followeeId);
+ }
}
diff --git a/apps/api/src/social/leaderboard/dto/leaderboard-entry-response.dto.ts b/apps/api/src/social/leaderboard/dto/leaderboard-entry-response.dto.ts
new file mode 100644
index 00000000..4ca732dd
--- /dev/null
+++ b/apps/api/src/social/leaderboard/dto/leaderboard-entry-response.dto.ts
@@ -0,0 +1,15 @@
+import type { LeaderboardEntryDto } from "@loomkeep/shared";
+import { ApiProperty } from "@nestjs/swagger";
+
+export class LeaderboardEntryResponseDto implements LeaderboardEntryDto {
+ id!: string;
+ username!: string;
+ displayName!: string;
+
+ @ApiProperty({ type: String, nullable: true })
+ avatarUrl!: string | null;
+
+ xp!: number;
+ rank!: number;
+ isViewer!: boolean;
+}
diff --git a/apps/api/src/social/leaderboard/dto/leaderboard-query.dto.ts b/apps/api/src/social/leaderboard/dto/leaderboard-query.dto.ts
new file mode 100644
index 00000000..1bab9637
--- /dev/null
+++ b/apps/api/src/social/leaderboard/dto/leaderboard-query.dto.ts
@@ -0,0 +1,12 @@
+import type { LeaderboardPeriod, LeaderboardScope } from "@loomkeep/shared";
+import { IsIn, IsOptional } from "class-validator";
+
+export class LeaderboardQueryDto {
+ @IsOptional()
+ @IsIn(["global", "friends"])
+ scope?: LeaderboardScope;
+
+ @IsOptional()
+ @IsIn(["month", "year"])
+ period?: LeaderboardPeriod;
+}
diff --git a/apps/api/src/social/leaderboard/dto/leaderboard-response.dto.ts b/apps/api/src/social/leaderboard/dto/leaderboard-response.dto.ts
new file mode 100644
index 00000000..5bd399e9
--- /dev/null
+++ b/apps/api/src/social/leaderboard/dto/leaderboard-response.dto.ts
@@ -0,0 +1,11 @@
+import type { LeaderboardDto } from "@loomkeep/shared";
+import { ApiProperty } from "@nestjs/swagger";
+import { LeaderboardEntryResponseDto } from "./leaderboard-entry-response.dto";
+
+export class LeaderboardResponseDto implements LeaderboardDto {
+ @ApiProperty({ type: LeaderboardEntryResponseDto, isArray: true })
+ entries!: LeaderboardEntryResponseDto[];
+
+ @ApiProperty({ type: LeaderboardEntryResponseDto, nullable: true })
+ viewerOutsideTop!: LeaderboardEntryResponseDto | null;
+}
diff --git a/apps/api/src/social/leaderboard/leaderboard.controller.ts b/apps/api/src/social/leaderboard/leaderboard.controller.ts
new file mode 100644
index 00000000..82592b6e
--- /dev/null
+++ b/apps/api/src/social/leaderboard/leaderboard.controller.ts
@@ -0,0 +1,37 @@
+import type { LeaderboardDto } from "@loomkeep/shared";
+import { Controller, Get, Query, UseGuards } from "@nestjs/common";
+import { ApiOkResponse } from "@nestjs/swagger";
+import {
+ CurrentUser,
+ type JwtPayload,
+} from "../../auth/decorators/current-user.decorator";
+import { GamificationFeatureGuard } from "../../gamification/gamification-feature.guard";
+import { SocialFeatureGuard } from "../social-feature.guard";
+import { LeaderboardQueryDto } from "./dto/leaderboard-query.dto";
+import { LeaderboardResponseDto } from "./dto/leaderboard-response.dto";
+import { LeaderboardService } from "./leaderboard.service";
+
+/**
+ * [G7] Ranks by XP. Gated behind BOTH SocialFeatureGuard and
+ * GamificationFeatureGuard — a leaderboard needs XP to exist AND other users
+ * to rank against, so either flag being off 404s it, same as every other
+ * social surface.
+ */
+@Controller("leaderboard")
+@UseGuards(SocialFeatureGuard, GamificationFeatureGuard)
+export class LeaderboardController {
+ constructor(private readonly leaderboard: LeaderboardService) {}
+
+ @Get()
+ @ApiOkResponse({ type: LeaderboardResponseDto })
+ get(
+ @CurrentUser() user: JwtPayload,
+ @Query() query: LeaderboardQueryDto,
+ ): Promise {
+ return this.leaderboard.getLeaderboard(
+ user.sub,
+ query.scope ?? "global",
+ query.period ?? "month",
+ );
+ }
+}
diff --git a/apps/api/src/social/leaderboard/leaderboard.service.spec.ts b/apps/api/src/social/leaderboard/leaderboard.service.spec.ts
new file mode 100644
index 00000000..ad684df5
--- /dev/null
+++ b/apps/api/src/social/leaderboard/leaderboard.service.spec.ts
@@ -0,0 +1,226 @@
+import { ProfileAccess } from "@loomkeep/shared";
+import { describe, expect, it, vi } from "vitest";
+import type { PrismaService } from "../../prisma/prisma.service";
+import type { FollowService } from "../follow.service";
+import { LeaderboardService, periodRange } from "./leaderboard.service";
+
+interface MockUser {
+ id: string;
+ username: string;
+ displayName: string;
+ avatarUpdatedAt: Date | null;
+ profileAccess: ProfileAccess;
+ createdAt: Date;
+}
+
+function makeUser(over: Partial & { id: string }): MockUser {
+ return {
+ username: over.id,
+ displayName: over.id,
+ avatarUpdatedAt: null,
+ profileAccess: ProfileAccess.PUBLIC,
+ createdAt: new Date("2026-01-01T00:00:00.000Z"),
+ ...over,
+ };
+}
+
+function makeService(opts: {
+ sums: { userId: string; _sum: { amount: number | null } }[];
+ users: MockUser[];
+ follows?: { followeeId: string }[];
+ friendIds?: string[];
+}) {
+ const groupBy = vi.fn().mockResolvedValue(opts.sums);
+ const findManyUser = vi.fn().mockResolvedValue(opts.users);
+ const findManyFollow = vi.fn().mockResolvedValue(opts.follows ?? []);
+
+ const prisma = {
+ xpEntry: { groupBy },
+ user: { findMany: findManyUser },
+ follow: { findMany: findManyFollow },
+ } as unknown as PrismaService;
+
+ const follow = {
+ listFriendIds: vi.fn().mockResolvedValue(opts.friendIds ?? []),
+ } as unknown as FollowService;
+
+ return {
+ service: new LeaderboardService(prisma, follow),
+ groupBy,
+ findManyUser,
+ findManyFollow,
+ follow,
+ };
+}
+
+describe("LeaderboardService.getLeaderboard", () => {
+ it("shares a rank across a tie and skips ahead by the tie's size for the next distinct value", async () => {
+ const users = [
+ makeUser({ id: "a", createdAt: new Date("2026-01-01") }),
+ makeUser({ id: "b", createdAt: new Date("2026-02-01") }),
+ makeUser({ id: "c", createdAt: new Date("2026-03-01") }),
+ makeUser({ id: "d", createdAt: new Date("2026-04-01") }),
+ ];
+ const { service } = makeService({
+ sums: [
+ { userId: "a", _sum: { amount: 100 } },
+ { userId: "b", _sum: { amount: 90 } },
+ { userId: "c", _sum: { amount: 90 } },
+ { userId: "d", _sum: { amount: 80 } },
+ ],
+ users,
+ });
+
+ const { entries } = await service.getLeaderboard(
+ "nobody",
+ "global",
+ "month",
+ );
+ const rankOf = (id: string) => entries.find((e) => e.id === id)?.rank;
+
+ expect(rankOf("a")).toBe(1);
+ expect(rankOf("b")).toBe(2);
+ expect(rankOf("c")).toBe(2);
+ expect(rankOf("d")).toBe(4);
+ });
+
+ it("puts the viewer's row in viewerOutsideTop only past the Top 100 cutoff, not in entries", async () => {
+ const users = Array.from({ length: 101 }, (_, i) =>
+ makeUser({ id: `u${i}` }),
+ );
+ const sums = users.map((u, i) => ({
+ userId: u.id,
+ // 101 distinct values, strictly descending, so every row gets its own rank.
+ _sum: { amount: 1000 - i },
+ }));
+ const viewerId = "u100"; // last row: rank 101, outside the Top 100.
+
+ const { service } = makeService({ sums, users });
+ const result = await service.getLeaderboard(viewerId, "global", "month");
+
+ expect(result.entries).toHaveLength(100);
+ expect(result.entries.some((e) => e.id === viewerId)).toBe(false);
+ expect(result.viewerOutsideTop?.id).toBe(viewerId);
+ expect(result.viewerOutsideTop?.rank).toBe(101);
+ expect(result.viewerOutsideTop?.isViewer).toBe(true);
+ });
+
+ it("returns an empty board (not an error) when nobody has XP this period", async () => {
+ const { service } = makeService({ sums: [], users: [] });
+ const result = await service.getLeaderboard("viewer", "global", "month");
+ expect(result).toEqual({ entries: [], viewerOutsideTop: null });
+ });
+
+ it("masks a PRIVATE row's avatar unless the viewer follows them, regardless of what they uploaded", async () => {
+ const users = [
+ makeUser({
+ id: "stranger",
+ profileAccess: ProfileAccess.PRIVATE,
+ avatarUpdatedAt: new Date("2026-01-01"),
+ }),
+ makeUser({
+ id: "friend",
+ profileAccess: ProfileAccess.PRIVATE,
+ avatarUpdatedAt: new Date("2026-01-01"),
+ }),
+ makeUser({
+ id: "public-user",
+ profileAccess: ProfileAccess.PUBLIC,
+ avatarUpdatedAt: new Date("2026-01-01"),
+ }),
+ ];
+ const { service, findManyFollow } = makeService({
+ sums: [
+ { userId: "stranger", _sum: { amount: 300 } },
+ { userId: "friend", _sum: { amount: 200 } },
+ { userId: "public-user", _sum: { amount: 100 } },
+ ],
+ users,
+ follows: [{ followeeId: "friend" }],
+ });
+
+ const { entries } = await service.getLeaderboard(
+ "viewer",
+ "global",
+ "month",
+ );
+ const byId = (id: string) => entries.find((e) => e.id === id)!;
+
+ expect(byId("stranger").avatarUrl).toBeNull();
+ expect(byId("friend").avatarUrl).not.toBeNull();
+ expect(byId("public-user").avatarUrl).not.toBeNull();
+ // Only the PRIVATE rows are ever checked against Follow — never the
+ // viewer's own or an already-public row.
+ expect(findManyFollow).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: expect.objectContaining({
+ followeeId: { in: expect.arrayContaining(["stranger", "friend"]) },
+ }),
+ }),
+ );
+ });
+
+ it("excludes GHOST and hideProgression accounts from the query itself", async () => {
+ const { service, groupBy } = makeService({ sums: [], users: [] });
+ await service.getLeaderboard("viewer", "global", "month");
+
+ expect(groupBy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: expect.objectContaining({
+ user: {
+ profileAccess: { not: ProfileAccess.GHOST },
+ hideProgression: false,
+ },
+ }),
+ }),
+ );
+ });
+
+ it("scopes the friends leaderboard to the viewer plus their friend ids", async () => {
+ const { service, groupBy, follow } = makeService({
+ sums: [],
+ users: [],
+ friendIds: ["friend-1", "friend-2"],
+ });
+
+ await service.getLeaderboard("viewer", "friends", "year");
+
+ expect(follow.listFriendIds).toHaveBeenCalledWith("viewer");
+ expect(groupBy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: expect.objectContaining({
+ userId: { in: ["viewer", "friend-1", "friend-2"] },
+ }),
+ }),
+ );
+ });
+});
+
+describe("periodRange", () => {
+ it("bounds a calendar month, exclusive of the next month's first instant", () => {
+ const { start, end } = periodRange(
+ "month",
+ new Date("2026-02-15T10:00:00Z"),
+ );
+ expect(start.toISOString()).toBe("2026-02-01T00:00:00.000Z");
+ expect(end.toISOString()).toBe("2026-03-01T00:00:00.000Z");
+ });
+
+ it("rolls a December month over into January of the next year", () => {
+ const { start, end } = periodRange(
+ "month",
+ new Date("2026-12-15T10:00:00Z"),
+ );
+ expect(start.toISOString()).toBe("2026-12-01T00:00:00.000Z");
+ expect(end.toISOString()).toBe("2027-01-01T00:00:00.000Z");
+ });
+
+ it("bounds a calendar year", () => {
+ const { start, end } = periodRange(
+ "year",
+ new Date("2026-07-01T00:00:00Z"),
+ );
+ expect(start.toISOString()).toBe("2026-01-01T00:00:00.000Z");
+ expect(end.toISOString()).toBe("2027-01-01T00:00:00.000Z");
+ });
+});
diff --git a/apps/api/src/social/leaderboard/leaderboard.service.ts b/apps/api/src/social/leaderboard/leaderboard.service.ts
new file mode 100644
index 00000000..47f205d3
--- /dev/null
+++ b/apps/api/src/social/leaderboard/leaderboard.service.ts
@@ -0,0 +1,199 @@
+import {
+ type LeaderboardDto,
+ type LeaderboardEntryDto,
+ type LeaderboardPeriod,
+ type LeaderboardScope,
+ ProfileAccess,
+} from "@loomkeep/shared";
+import { Injectable } from "@nestjs/common";
+import { PrismaService } from "../../prisma/prisma.service";
+import { avatarUrl } from "../../users/avatar.util";
+import { FollowService } from "../follow.service";
+
+// No pagination for the MVP (see the [G7] ticket) — the visible list is
+// capped here, but a whole tied group at the boundary is kept together (see
+// the `rank <= TOP_CUTOFF` filter below), so it can render slightly more
+// than 100 rows in the rare case of a tie straddling the cutoff.
+const TOP_CUTOFF = 100;
+
+interface RankedRow {
+ id: string;
+ xp: number;
+ rank: number;
+ user: {
+ username: string;
+ displayName: string;
+ avatarUpdatedAt: Date | null;
+ profileAccess: ProfileAccess;
+ };
+}
+
+@Injectable()
+export class LeaderboardService {
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly follow: FollowService,
+ ) {}
+
+ /**
+ * Ranks by XP summed over the given calendar period — recomputed live from
+ * the ledger every call (no snapshot table for the MVP, per the [G7]
+ * ticket). GHOST and `hideProgression` accounts never appear, in either
+ * scope: a leaderboard is exactly the "other viewers" a Figurant or a
+ * hidden-progression account already opted out of showing XP to.
+ *
+ * Ranking happens in plain JS rather than a SQL window function (no raw
+ * query exists anywhere else in this codebase): `groupBy` has no way to
+ * order by a joined column, so the tie-break (older account wins) has to
+ * happen after fetching. The candidate set is bounded by *distinct active
+ * users this period*, not by ledger row count, which is the population a
+ * self-hosted instance actually has — pulling it into Node to sort is the
+ * simplest correct thing, not a shortcut that stops working at scale here.
+ */
+ async getLeaderboard(
+ viewerId: string,
+ scope: LeaderboardScope,
+ period: LeaderboardPeriod,
+ ): Promise {
+ const { start, end } = periodRange(period);
+ const candidateIds =
+ scope === "friends"
+ ? [viewerId, ...(await this.follow.listFriendIds(viewerId))]
+ : null;
+
+ const sums = await this.prisma.xpEntry.groupBy({
+ by: ["userId"],
+ where: {
+ createdAt: { gte: start, lt: end },
+ ...(candidateIds ? { userId: { in: candidateIds } } : {}),
+ user: {
+ profileAccess: { not: ProfileAccess.GHOST },
+ hideProgression: false,
+ },
+ },
+ _sum: { amount: true },
+ });
+
+ if (sums.length === 0) {
+ return { entries: [], viewerOutsideTop: null };
+ }
+
+ const users = await this.prisma.user.findMany({
+ where: { id: { in: sums.map((s) => s.userId) } },
+ select: {
+ id: true,
+ username: true,
+ displayName: true,
+ avatarUpdatedAt: true,
+ profileAccess: true,
+ createdAt: true,
+ },
+ });
+ const userById = new Map(users.map((u) => [u.id, u]));
+
+ const sorted = sums
+ .map((s) => ({
+ id: s.userId,
+ xp: s._sum.amount ?? 0,
+ user: userById.get(s.userId)!,
+ }))
+ // Older account wins a tie — arbitrary but deterministic, and it's
+ // what decides which of two tied rows shows the shared rank number
+ // versus a dash (see LeaderboardEntryDto.rank).
+ .sort(
+ (a, b) =>
+ b.xp - a.xp ||
+ a.user.createdAt.getTime() - b.user.createdAt.getTime(),
+ );
+
+ let rank = 0;
+ let previousXp: number | null = null;
+ const ranked: RankedRow[] = sorted.map((row, index) => {
+ if (row.xp !== previousXp) rank = index + 1;
+ previousXp = row.xp;
+ return { id: row.id, xp: row.xp, rank, user: row.user };
+ });
+
+ const revealedPrivateIds = await this.revealedPrivateIds(viewerId, ranked);
+ const toDto = (row: RankedRow) =>
+ this.toEntryDto(row, viewerId, revealedPrivateIds);
+
+ const entries = ranked.filter((r) => r.rank <= TOP_CUTOFF).map(toDto);
+ const viewerRow = ranked.find((r) => r.id === viewerId);
+ const viewerOutsideTop =
+ viewerRow && viewerRow.rank > TOP_CUTOFF ? toDto(viewerRow) : null;
+
+ return { entries, viewerOutsideTop };
+ }
+
+ /**
+ * A PRIVATE row's real avatar only shows to a viewer who is friends with
+ * them (an accepted follow — see `computeIsFriend`'s PRIVATE branch); every
+ * other viewer gets `avatarUrl: null` regardless of whether they uploaded a
+ * photo, so the row looks exactly like any public account without one.
+ */
+ private async revealedPrivateIds(
+ viewerId: string,
+ rows: RankedRow[],
+ ): Promise> {
+ const privateIds = rows
+ .filter(
+ (r) =>
+ r.user.profileAccess === ProfileAccess.PRIVATE && r.id !== viewerId,
+ )
+ .map((r) => r.id);
+ if (privateIds.length === 0) return new Set();
+
+ const follows = await this.prisma.follow.findMany({
+ where: {
+ followerId: viewerId,
+ followeeId: { in: privateIds },
+ status: "ACCEPTED",
+ },
+ select: { followeeId: true },
+ });
+ return new Set(follows.map((f) => f.followeeId));
+ }
+
+ private toEntryDto(
+ row: RankedRow,
+ viewerId: string,
+ revealedPrivateIds: Set,
+ ): LeaderboardEntryDto {
+ const isViewer = row.id === viewerId;
+ const showRealAvatar =
+ isViewer ||
+ row.user.profileAccess !== ProfileAccess.PRIVATE ||
+ revealedPrivateIds.has(row.id);
+
+ return {
+ id: row.id,
+ username: row.user.username,
+ displayName: row.user.displayName,
+ avatarUrl: showRealAvatar
+ ? avatarUrl({ id: row.id, avatarUpdatedAt: row.user.avatarUpdatedAt })
+ : null,
+ xp: row.xp,
+ rank: row.rank,
+ isViewer,
+ };
+ }
+}
+
+/** Calendar month/year in UTC — exported for direct testing. */
+export function periodRange(
+ period: LeaderboardPeriod,
+ now: Date = new Date(),
+): { start: Date; end: Date } {
+ if (period === "month") {
+ return {
+ start: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)),
+ end: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)),
+ };
+ }
+
+ return {
+ start: new Date(Date.UTC(now.getUTCFullYear(), 0, 1)),
+ end: new Date(Date.UTC(now.getUTCFullYear() + 1, 0, 1)),
+ };
+}
diff --git a/apps/api/src/social/social.module.ts b/apps/api/src/social/social.module.ts
index 0186a7cf..e62db4a9 100644
--- a/apps/api/src/social/social.module.ts
+++ b/apps/api/src/social/social.module.ts
@@ -3,6 +3,8 @@ import { GamificationModule } from "../gamification/gamification.module";
import { NotificationModule } from "../notifications/notification.module";
import { ActivityService } from "./activity.service";
import { FollowService } from "./follow.service";
+import { LeaderboardController } from "./leaderboard/leaderboard.controller";
+import { LeaderboardService } from "./leaderboard/leaderboard.service";
import { PrivacyController } from "./privacy.controller";
import { PrivacyService } from "./privacy.service";
import { ProfileService } from "./profile.service";
@@ -10,16 +12,21 @@ import { SocialController } from "./social.controller";
import { VisibilityService } from "./visibility.service";
// P4 social graph, profiles, search and privacy. Every route is gated behind
-// SOCIAL_ENABLED via SocialFeatureGuard on the controllers.
+// SOCIAL_ENABLED via SocialFeatureGuard on the controllers. The [G7]
+// leaderboard lives here rather than in GamificationModule: it's inherently
+// social-gated and leans on FollowService for the friends scope — putting it
+// in Gamification would need Gamification to import Social, which already
+// imports Gamification (AchievementService), a real circular dependency.
@Module({
imports: [NotificationModule, GamificationModule],
- controllers: [SocialController, PrivacyController],
+ controllers: [SocialController, PrivacyController, LeaderboardController],
providers: [
VisibilityService,
FollowService,
ProfileService,
PrivacyService,
ActivityService,
+ LeaderboardService,
],
exports: [VisibilityService, ActivityService, FollowService, ProfileService],
})
diff --git a/apps/web/messages/en/errors.json b/apps/web/messages/en/errors.json
index f9ce0cca..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,26 +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.",
@@ -95,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/en/gamification.json b/apps/web/messages/en/gamification.json
index b4a29e8f..9646c8aa 100644
--- a/apps/web/messages/en/gamification.json
+++ b/apps/web/messages/en/gamification.json
@@ -154,7 +154,7 @@
"gamification_curious_cat_description": "Clicked the version number.",
"gamification_page_title": "Achievements",
- "gamification_page_subtitle": "Everything there is to earn, and what you have left.",
+ "gamification_page_subtitle": "Discover all the achievements to unlock and track your progress.",
"gamification_my_achievements": "My achievements",
"gamification_hero_total": "/ {total} achievements",
@@ -195,5 +195,17 @@
"gamification_bubble_level_title": "Level {level}",
"gamification_bubble_dismiss": "Dismiss",
"gamification_disabled": "Progression is disabled on this instance.",
- "gamification_empty": "No achievements to show."
+ "gamification_empty": "No achievements to show.",
+
+ "gamification_leaderboard_title": "Leaderboard",
+ "gamification_leaderboard_subtitle": "Rankings by level.",
+ "gamification_leaderboard_tab_global": "Global",
+ "gamification_leaderboard_tab_friends": "Friends",
+ "gamification_leaderboard_period_month": "This month",
+ "gamification_leaderboard_period_year": "This year",
+ "gamification_leaderboard_xp_unit": "xp",
+ "gamification_leaderboard_you": "You",
+ "gamification_leaderboard_disabled": "The leaderboard isn't available on this instance.",
+ "gamification_leaderboard_empty": "Nobody has earned XP this period yet.",
+ "gamification_view_leaderboard": "View leaderboard"
}
diff --git a/apps/web/messages/en/other.json b/apps/web/messages/en/other.json
index 89e6f382..7e053520 100644
--- a/apps/web/messages/en/other.json
+++ b/apps/web/messages/en/other.json
@@ -17,6 +17,8 @@
"admin_job_reports_digest": "Report digest",
"admin_job_backup": "Automatic backup",
"admin_job_inactive_accounts": "Inactive accounts (reminder + deletion)",
+ "admin_job_gamification_reconcile": "XP reconciliation (integrity check)",
+ "admin_job_gamification_achievements_sweep": "Achievements sweep (safety net)",
"admin_job_hourly": "Every hour",
"admin_job_every_hours": "Every {hours} hours",
"admin_job_daily_at": "Every day at {time}",
@@ -231,7 +233,7 @@
"profile_activity_days": "{days} days",
"profile_activity_summary_suffix": "in the last 3 months",
"profile_activity_first_trace": "· first seen on {date}",
- "profile_activity_view_stats": "See all activity in stats →",
+ "profile_activity_view_stats": "See all activity in stats",
"profile_connections_followers_title": "Followers",
"profile_connections_following_title": "Following",
"profile_connections_empty_followers": "Nobody follows this profile yet.",
@@ -241,6 +243,7 @@
"nav_section_library": "My library",
"nav_section_tracking": "Tracking",
"nav_feed": "Feed",
+ "nav_leaderboard": "Leaderboard",
"nav_profile": "My profile",
"auth_login_title": "Log in",
"auth_login_tagline": "Your private screening room.",
@@ -389,7 +392,7 @@
"add_to_list_button": "Add to a list",
"avatar_alt": "Avatar for {seed}",
"home_activity_title": "Recent activity",
- "home_activity_view_feed": "View feed →",
+ "home_activity_view_feed": "View feed",
"profile_reviews_title": "My reviews",
"activity_added": "added",
"activity_started": "started",
@@ -968,7 +971,7 @@
"admin_users_empty_filter": "No accounts match this filter.",
"admin_users_empty": "No registered accounts.",
"admin_users_enlarge_avatar": "Enlarge avatar",
- "admin_users_public_profile": "View public profile →",
+ "admin_users_public_profile": "View public profile",
"admin_users_inactivity_reminder": "Inactivity reminder sent on",
"admin_users_inactivity_deletion": "— automatic deletion if the account remains inactive (LK-C06).",
"admin_users_identity": "Identity",
diff --git a/apps/web/messages/fr/errors.json b/apps/web/messages/fr/errors.json
index 0f477bbf..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,19 +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/messages/fr/gamification.json b/apps/web/messages/fr/gamification.json
index 6f143906..06cdb1e0 100644
--- a/apps/web/messages/fr/gamification.json
+++ b/apps/web/messages/fr/gamification.json
@@ -154,7 +154,7 @@
"gamification_curious_cat_description": "A cliqué sur le numéro de version.",
"gamification_page_title": "Succès",
- "gamification_page_subtitle": "Tout ce qu'il y a à décrocher, et ce qu'il vous en reste.",
+ "gamification_page_subtitle": "Découvrez tous les succès à débloquer et suivez votre progression.",
"gamification_my_achievements": "Mes succès",
"gamification_hero_total": "/ {total} succès",
@@ -195,5 +195,17 @@
"gamification_bubble_level_title": "Niveau {level}",
"gamification_bubble_dismiss": "Masquer",
"gamification_disabled": "La progression est désactivée sur cette instance.",
- "gamification_empty": "Aucun succès à afficher."
+ "gamification_empty": "Aucun succès à afficher.",
+
+ "gamification_leaderboard_title": "Classement",
+ "gamification_leaderboard_subtitle": "Classement par niveau.",
+ "gamification_leaderboard_tab_global": "Global",
+ "gamification_leaderboard_tab_friends": "Amis",
+ "gamification_leaderboard_period_month": "Ce mois-ci",
+ "gamification_leaderboard_period_year": "Cette année",
+ "gamification_leaderboard_xp_unit": "xp",
+ "gamification_leaderboard_you": "Toi",
+ "gamification_leaderboard_disabled": "Le classement n'est pas disponible sur cette instance.",
+ "gamification_leaderboard_empty": "Personne n'a encore gagné d'XP sur cette période.",
+ "gamification_view_leaderboard": "Voir le classement"
}
diff --git a/apps/web/messages/fr/other.json b/apps/web/messages/fr/other.json
index dff5e506..2c11aee8 100644
--- a/apps/web/messages/fr/other.json
+++ b/apps/web/messages/fr/other.json
@@ -18,6 +18,8 @@
"admin_job_reports_digest": "Digest des signalements",
"admin_job_backup": "Sauvegarde automatique",
"admin_job_inactive_accounts": "Comptes inactifs (relance + suppression)",
+ "admin_job_gamification_reconcile": "Réconciliation XP (contrôle d'intégrité)",
+ "admin_job_gamification_achievements_sweep": "Balayage des succès (filet de sécurité)",
"admin_job_hourly": "Toutes les heures",
"admin_job_every_hours": "Toutes les {hours} heures",
"admin_job_daily_at": "Tous les jours à {time}",
@@ -231,7 +233,7 @@
"profile_activity_days": "{days} jours",
"profile_activity_summary_suffix": "ces 3 derniers mois",
"profile_activity_first_trace": "· première trace le {date}",
- "profile_activity_view_stats": "Voir toute l'activité dans les statistiques →",
+ "profile_activity_view_stats": "Voir toute l'activité dans les statistiques",
"profile_connections_followers_title": "Abonnés",
"profile_connections_following_title": "Abonnements",
"profile_connections_empty_followers": "Personne ne suit ce profil pour l'instant.",
@@ -241,6 +243,7 @@
"nav_section_library": "Ma bibliothèque",
"nav_section_tracking": "Suivi",
"nav_feed": "Activité",
+ "nav_leaderboard": "Classement",
"nav_profile": "Mon profil",
"auth_login_title": "Connexion",
"auth_login_tagline": "Ta salle de projection privée.",
@@ -389,7 +392,7 @@
"add_to_list_button": "Ajouter à une liste",
"avatar_alt": "Avatar de {seed}",
"home_activity_title": "Dernières activités",
- "home_activity_view_feed": "Voir l'activité →",
+ "home_activity_view_feed": "Voir l'activité",
"profile_reviews_title": "Mes reviews",
"activity_added": "a ajouté",
"activity_started": "a commencé",
@@ -968,7 +971,7 @@
"admin_users_empty_filter": "Aucun compte ne correspond à ce filtre.",
"admin_users_empty": "Aucun compte enregistré.",
"admin_users_enlarge_avatar": "Voir l'avatar en grand",
- "admin_users_public_profile": "Voir le profil public →",
+ "admin_users_public_profile": "Voir le profil public",
"admin_users_inactivity_reminder": "Relance inactivité envoyée le",
"admin_users_inactivity_deletion": "— suppression automatique si le compte reste inactif (LK-C06).",
"admin_users_identity": "Identité",
diff --git a/apps/web/src/lib/api/errors.ts b/apps/web/src/lib/api/errors.ts
index ac8a38c0..045a14b7 100644
--- a/apps/web/src/lib/api/errors.ts
+++ b/apps/web/src/lib/api/errors.ts
@@ -195,6 +195,8 @@ const MESSAGES = {
[ErrorCode.NetworkOffline]: () => m.apierr_network_offline(),
[ErrorCode.GamificationAchievementNotFound]: () =>
m.apierr_gamification_achievement_not_found(),
+ [ErrorCode.GamificationFeatureDisabled]: () =>
+ m.apierr_gamification_feature_disabled(),
} satisfies Record string>;
/**
diff --git a/apps/web/src/lib/api/gamification.ts b/apps/web/src/lib/api/gamification.ts
index a76668a6..6089e8fb 100644
--- a/apps/web/src/lib/api/gamification.ts
+++ b/apps/web/src/lib/api/gamification.ts
@@ -1,4 +1,9 @@
-import type { AchievementDto } from "@loomkeep/shared";
+import type {
+ AchievementDto,
+ LeaderboardDto,
+ LeaderboardPeriod,
+ LeaderboardScope,
+} from "@loomkeep/shared";
import { typedRequest } from "./generated/typed-request";
export function getAchievements() {
@@ -23,3 +28,12 @@ export function markAchievementDisplayed(id: string) {
export function getMyProgression() {
return typedRequest("/gamification/me");
}
+
+export function getLeaderboard(
+ scope: LeaderboardScope,
+ period: LeaderboardPeriod,
+) {
+ return typedRequest("/leaderboard", {
+ query: { scope, period },
+ }) as Promise;
+}
diff --git a/apps/web/src/lib/api/keys.ts b/apps/web/src/lib/api/keys.ts
index 6c02e5d7..e2d53a23 100644
--- a/apps/web/src/lib/api/keys.ts
+++ b/apps/web/src/lib/api/keys.ts
@@ -14,6 +14,8 @@ export const keys = {
achievements: () => ["gamification", "achievements"] as const,
pending: () => ["gamification", "pending"] as const,
progression: () => ["gamification", "progression"] as const,
+ leaderboard: (scope: string, period: string) =>
+ ["gamification", "leaderboard", scope, period] as const,
},
books: {
detail: (source: string, sourceId: string) =>
diff --git a/apps/web/src/lib/components/HomeActivityPreview.svelte b/apps/web/src/lib/components/HomeActivityPreview.svelte
index fb8ae785..71e4d78c 100644
--- a/apps/web/src/lib/components/HomeActivityPreview.svelte
+++ b/apps/web/src/lib/components/HomeActivityPreview.svelte
@@ -3,6 +3,7 @@
import { keys } from "$lib/api/keys";
import { createApiQuery } from "$lib/api/query.svelte";
import ActivityItem from "$lib/components/ActivityItem.svelte";
+ import Icon from "$lib/components/Icon.svelte";
import { appConfig } from "$lib/config.svelte";
import { m } from "$lib/paraglide/messages.js";
@@ -33,8 +34,11 @@
diff --git a/apps/web/src/lib/components/Icon.svelte b/apps/web/src/lib/components/Icon.svelte
index 57e37f41..fad38af3 100644
--- a/apps/web/src/lib/components/Icon.svelte
+++ b/apps/web/src/lib/components/Icon.svelte
@@ -244,9 +244,13 @@
{:else if name === "circle-arrow"}
+ {:else if name === "arrow-right"}
+
{:else if name === "pumpkin"}
+ {:else if name === "crown"}
+
{/if}
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 @@