Diff leaderboard

This commit is contained in:
Kalle
2026-08-16 19:14:22 +03:00
parent 55957f7aca
commit 8da2b66ac6
16 changed files with 177 additions and 18 deletions

View File

@@ -193,3 +193,11 @@ zod codec.
a manual `Response` (Kit's `redirect()` only allows 3xx).
- The differ impersonates with `maxRedirects: 0` so the redirect target may be
a not-yet-migrated route.
- Numeric `width`/`height` on `<img>` are set through the DOM property setters
on hydration, which truncate fractions (`27.76` → `27`, an 1px layout shift
vs React per image). Pass dimension attributes as strings when they can be
fractional — `Image.svelte` does this for all its images.
- SvelteKit 3 + adapter-node derive the CSRF/remote-function self-origin from
proxy headers, guessing `https://` bare; e2e builds set
`paths.origin` + `csrf.trustedOrigins: ["*"]` (see `vite.config.ts`) or every
tooling/e2e form POST is rejected 403.

View File

@@ -41,9 +41,14 @@ export async function seedMisc({
await seedNotifications(users, tournaments);
await seedUserReports(users, sendouq);
await LiveStreamFactory.replaceAll(
users.showcaseIds.slice(0, STREAM_COUNT).map((userId) => ({ userId })),
);
// the differ seeds a world where nobody is live on Twitch: live-stream chrome
// (sidebar streams, friend stream activity) depends on the tournament cluster,
// which migrates to apps/web in a later wave
if (process.env.SEED_QUIET_WORLD !== "true") {
await LiveStreamFactory.replaceAll(
users.showcaseIds.slice(0, STREAM_COUNT).map((userId) => ({ userId })),
);
}
await SplatoonRotationFactory.replaceAll();
}

View File

@@ -10,6 +10,16 @@ test.describe("Leaderboards", () => {
page,
factories,
}) => {
// season 1 renders its first ten entries through the static top-ten
// showcase (names come from top-ten.json, not the database), so the
// asserted users must rank below ten fillers to appear as normal rows
for (let i = 0; i < 10; i++) {
const filler = await factories.UserFactory.create();
await factories.SkillFactory.create(
{ userId: filler.id, mu: 40 - i },
{ matchesCount: 10 },
);
}
const better = await factories.UserFactory.create({
discordName: "BetterPlayer",
});
@@ -17,7 +27,7 @@ test.describe("Leaderboards", () => {
discordName: "WorsePlayer",
});
await factories.SkillFactory.create(
{ userId: better.id, mu: 30 },
{ userId: better.id, mu: 25 },
{ matchesCount: 10 },
);
await factories.SkillFactory.create(
@@ -52,7 +62,6 @@ test.describe("Leaderboards", () => {
weaponSplId: 40,
});
await factories.XRankPlacementFactory.create({
playerUserId: zonesPlayer.id,
name: LOW_POWER_NAME,
playerSplId: `runner-up-${zonesPlayer.id}`,
mode: "SZ",

View File

@@ -20,7 +20,7 @@
},
"dependencies": {
"@epic-web/cachified": "5.6.3",
"@lucide/svelte": "0.544.0",
"@lucide/svelte": "1.28.0",
"@sendou/components": "workspace:*",
"@sendou/in-game-lists": "workspace:*",
"@sendou/utils": "workspace:*",

View File

@@ -34,12 +34,14 @@ let {
<!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events -->
<div {title} class={containerClass} style={containerStyle} {onclick}>
<!-- dimensions as strings: numbers go through the img.width/height DOM
property setters on hydration, which truncate fractional values (27.76 -> 27) -->
<img
{alt}
src="{path}.avif"
class={className}
width={size ?? width}
height={size ?? height}
width={(size ?? width)?.toString()}
height={(size ?? height)?.toString()}
{style}
draggable="false"
{loading}

View File

@@ -84,6 +84,10 @@ const unseenFriendRequests = $derived(
const isFrontPage = $derived(page.url.pathname === "/");
// xxx: wire to the chat sidebar when the chat rebuild lands (svelte-big-bang
// phase 3); until then the button renders for layout parity with the React app
const noopUntilChatRebuild = () => {};
const breadcrumbs = $derived(
(page.data as { breadcrumbs?: Breadcrumb[] }).breadcrumbs ?? [],
);
@@ -422,6 +426,8 @@ function formatRelativeDate(timestamp: number) {
showSupport={Boolean(!user?.roles.includes("MINOR_SUPPORT"))}
showSearch={Boolean(user)}
isLoggedIn={Boolean(user)}
onChatToggle={user ? noopUntilChatRebuild : undefined}
onChatModalToggle={user ? noopUntilChatRebuild : undefined}
chatUnreadCount={0}
/>
<div id="nprogress-anchor" aria-hidden="true"></div>

View File

@@ -254,6 +254,11 @@ const isMac = $derived(
font-size: var(--font-2xs);
background-color: var(--color-bg-higher);
font-family: inherit;
/** needs to go away so we have enough space even with both side panels open */
@media screen and (max-width: 1100px) {
display: none;
}
}
.chatButtonWrapperPersistent {

View File

@@ -3,6 +3,8 @@ import adapter from "@sveltejs/adapter-node";
import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite";
const IS_E2E_BUILD = process.env.VITE_E2E_TEST_RUN === "true";
export default defineConfig(({ mode }) => ({
define: {
__GIT_COMMIT__: JSON.stringify(process.env.RENDER_GIT_COMMIT ?? ""),
@@ -19,6 +21,15 @@ export default defineConfig(({ mode }) => ({
adapter: adapter(),
compilerOptions: { experimental: { async: true } },
experimental: { remoteFunctions: true },
// e2e/differ tooling POSTs (impersonate, theme) carry no Origin header,
// and the adapter guesses https:// when no proxy headers are present, so
// origin checks can never pass against the http test servers
...(IS_E2E_BUILD
? {
csrf: { trustedOrigins: ["*"] },
paths: { origin: process.env.VITE_SITE_DOMAIN },
}
: {}),
}),
],
test: {

View File

@@ -76,8 +76,8 @@
"status": "pending"
},
"leaderboards": {
"status": "migrated",
"notes": "phase 2 vertical slice; verified pending differ + e2e run"
"status": "verified",
"notes": "phase 2 vertical slice; differ clean (pixel/head/resource/aria) and e2e green against both apps 2026-08-16"
},
"lfg": {
"status": "pending"
@@ -730,7 +730,7 @@
"feature": "top-search"
},
"features/leaderboards/routes/leaderboards.tsx": {
"status": "migrated",
"status": "verified",
"path": "/leaderboards",
"feature": "leaderboards",
"notes": "ported by hand as the phase 2 golden slice; loader -> getLeaderboards query, action -> skipTeam/unskipTeam commands"

View File

@@ -246,6 +246,7 @@ const hasVisibleItems = $derived(items.size > 0);
const uid = $props.id();
const labelId = $derived(label ? `${uid}-select-label` : undefined);
const valueId = `${uid}-select-value`;
</script>
<svelte:window
@@ -265,11 +266,12 @@ const labelId = $derived(label ? `${uid}-select-label` : undefined);
aria-haspopup="listbox"
aria-expanded={open}
aria-label={ariaLabel}
aria-labelledby={labelId && !ariaLabel ? `${valueId} ${labelId}` : undefined}
data-required={isRequired || undefined}
onclick={() => setOpen(!open)}
onkeydown={onTriggerKeydown}
>
<span class="selectValue" data-placeholder={selectedText === undefined ? "true" : undefined}>
<span id={valueId} class="selectValue" data-placeholder={selectedText === undefined ? "true" : undefined}>
{#if currentKey !== null && valueContent}
{@render valueContent(currentKey)}
{:else if selectedText !== undefined}

10
pnpm-lock.yaml generated
View File

@@ -23,8 +23,8 @@ importers:
specifier: 5.6.3
version: 5.6.3
'@lucide/svelte':
specifier: 0.544.0
version: 0.544.0(svelte@5.56.9)
specifier: 1.28.0
version: 1.28.0(svelte@5.56.9)
'@sendou/components':
specifier: workspace:*
version: link:../../packages/components
@@ -1232,8 +1232,8 @@ packages:
'@lix-js/sdk@0.12.2':
resolution: {integrity: sha512-sKGOPdVKdChS5q60F4VQE4ZTcf0B7ZJM3KGCVQFN2suVNKhHcllbsCiwRYuM5WL7Yu1ql7YexltJ2FCsDqKePg==}
'@lucide/svelte@0.544.0':
resolution: {integrity: sha512-9f9O6uxng2pLB01sxNySHduJN3HTl5p0HDu4H26VR51vhZfiMzyOMe9Mhof3XAk4l813eTtl+/DYRvGyoRR+yw==}
'@lucide/svelte@1.28.0':
resolution: {integrity: sha512-C1Ge84KW2z4q44/WDIEo/2KC2jby5u6mSlta7q+p6g7u0ld8sC/w1fMBiSswF+4bxAl9jlzzaVTpp79qqkiy5g==}
peerDependencies:
svelte: ^5
@@ -5933,7 +5933,7 @@ snapshots:
'@lix-js/sdk-linux-x64': 0.12.2
'@lix-js/sdk-win32-x64': 0.12.2
'@lucide/svelte@0.544.0(svelte@5.56.9)':
'@lucide/svelte@1.28.0(svelte@5.56.9)':
dependencies:
svelte: 5.56.9

View File

@@ -51,6 +51,10 @@ The run writes `output/<timestamp>/`:
serves (`VITE_E2E_TEST_RUN=true` baked in), reused when fresh.
2. Migrate + seed `db-differ-seed.sqlite3` with the clock frozen at `SEED_NOW`
(`scripts/seed.ts` installs `installSeedClock`), then copy it for each side.
The differ seeds with `SEED_QUIET_WORLD=true`: nobody is live on Twitch, so
the sidebar streams chrome (which depends on the not-yet-migrated tournament
cluster) renders identically empty on both sides. Drop the flag when the
tournament wave migrates.
3. `scripts/route-census.ts` — walks `app/routes.ts` and resolves every dynamic
param from the seeded database. Never hand-listed: a new route without a
resolver is a hard error; a resolver finding no seed data marks the row

View File

@@ -0,0 +1,64 @@
#!/bin/bash
# Memory watchdog: runs a command while sampling system-wide memory per second
# and hard-capping the command's own process tree before macOS starts killing
# the biggest process on the machine (usually the claude session).
#
# usage: memwatch.sh <log-dir> <cap-mb-per-process> -- <command...>
#
# writes to <log-dir>:
# samples.tsv time \t free-pct \t top processes "rss_mb:pid:comm" (system-wide)
# events.log watchdog kills and threshold crossings
set -u
LOG_DIR="$1"
CAP_MB="$2"
shift 2
[ "$1" = "--" ] && shift
mkdir -p "$LOG_DIR"
SAMPLES="$LOG_DIR/samples.tsv"
EVENTS="$LOG_DIR/events.log"
: >"$SAMPLES"
: >"$EVENTS"
"$@" &
ROOT_PID=$!
tree_pids() {
# ROOT_PID plus all transitive children
local all="$ROOT_PID" frontier="$ROOT_PID" next
while [ -n "$frontier" ]; do
next=$(pgrep -P "$(echo "$frontier" | tr ' ' ',')" 2>/dev/null | tr '\n' ' ')
frontier=$(echo "$next" | xargs)
[ -n "$frontier" ] && all="$all $frontier"
done
echo "$all"
}
while kill -0 "$ROOT_PID" 2>/dev/null; do
ts=$(date +%H:%M:%S)
free_pct=$(memory_pressure -Q 2>/dev/null | grep -oE '[0-9]+%' | head -1 | tr -d '%')
top=$(ps -axo rss=,pid=,comm= | sort -rn | head -12 | awk '{printf "%d:%s:%s ", $1/1024, $2, $3}')
printf '%s\t%s%%\t%s\n' "$ts" "${free_pct:-?}" "$top" >>"$SAMPLES"
for pid in $(tree_pids); do
rss_kb=$(ps -o rss= -p "$pid" 2>/dev/null | tr -d ' ')
[ -z "$rss_kb" ] && continue
rss_mb=$((rss_kb / 1024))
if [ "$rss_mb" -gt "$CAP_MB" ]; then
cmd=$(ps -o command= -p "$pid" | cut -c1-200)
echo "$ts KILLED pid=$pid rss=${rss_mb}MB > cap=${CAP_MB}MB cmd=$cmd" >>"$EVENTS"
kill -9 "$pid"
fi
done
if [ -n "$free_pct" ] && [ "$free_pct" -lt 15 ]; then
echo "$ts PRESSURE free=${free_pct}% — top: $(echo "$top" | cut -c1-300)" >>"$EVENTS"
fi
sleep 1
done
wait "$ROOT_PID"
exit_code=$?
echo "$(date +%H:%M:%S) command exited with $exit_code" >>"$EVENTS"
exit "$exit_code"

View File

@@ -337,6 +337,19 @@ async function capturePage(
const page = await context.newPage();
const notes: string[] = [];
if (process.env.DIFFER_DEBUG === "true") {
page.on("pageerror", (e) =>
// biome-ignore lint/suspicious/noConsole: CLI script output
console.error(`[pageerror ${url}]`, String(e).slice(0, 300)),
);
page.on("requestfailed", (request) => {
const failure = request.failure()?.errorText ?? "";
if (failure.includes("REFUSED"))
// biome-ignore lint/suspicious/noConsole: CLI script output
console.error(`[refused] ${request.url().slice(0, 140)}`);
});
}
try {
await page.setViewportSize({
width: viewport.width,
@@ -351,12 +364,36 @@ async function capturePage(
.catch(() => {
notes.push("page never reported hydration");
});
// lazy post-hydration data (the notification bell) races the screenshot:
// give post-hydration effects a beat to launch their fetches, then wait
// for the app to report every in-flight fetch settled
await page.waitForTimeout(150);
await page
.locator('[data-testid="hydrated"][data-router-idle="true"]')
.waitFor({ state: "attached", timeout: HYDRATION_TIMEOUT_MS })
.catch(() => {
notes.push("router never went idle");
});
if ((await page.getByTestId("error-page").count()) > 0) {
notes.push("rendered the error page (seed data gap?)");
}
await page.addStyleTag({ content: KILL_ANIMATIONS_CSS });
// strip comment nodes and merge the text nodes around them: React SSR
// splits text at every interpolation (`2019-<!-- -->2026`), and glyphs
// after a text-node boundary rasterize at different subpixel offsets
// than the same text in one node, scattering one-shade-off pixels
await page.evaluate(() => {
const walker = document.createTreeWalker(
document.documentElement,
NodeFilter.SHOW_COMMENT,
);
const comments: Node[] = [];
while (walker.nextNode()) comments.push(walker.currentNode);
for (const comment of comments) comment.parentNode?.removeChild(comment);
document.documentElement.normalize();
});
await page.evaluate(() => document.fonts.ready);
const screenshot = await settleScreenshot(page, notes);
@@ -604,6 +641,8 @@ function normalizeHead(head: string, origins: string[]): string {
const tag = match[0]
.replace(/\s+/g, " ")
.replace(/\s*\/?>$/, ">")
// attribute names are case-insensitive and React emits camelCase (charSet)
.replace(/ ([A-Za-z-]+)=/g, (_, name) => ` ${name.toLowerCase()}=`)
.trim();
if (match[1].toLowerCase() === "link") {

View File

@@ -39,6 +39,7 @@ export function prepare(config: RunConfig): Census {
run(config, "./node_modules/.bin/vite-node", ["./scripts/seed.ts"], {
DB_PATH: SEED_DB,
SEED_NOW: String(config.seedNow),
SEED_QUIET_WORLD: "true",
});
// one checkpointed file copies cleanly; the servers re-enable WAL themselves

View File

@@ -121,8 +121,11 @@ async function waitForServer(baseURL: string, timeout = 60000) {
function killPortListeners(...ports: number[]) {
try {
// -t must stay separate from -i: a clustered bare `-i` selects every
// internet socket on the machine and the kill below would slaughter
// unrelated apps (Discord, the terminal running this...)
const pids = execSync(
`lsof -ti ${ports.map((p) => `-i :${p}`).join(" ")} || true`,
`lsof -t ${ports.map((p) => `-iTCP:${p}`).join(" ")} -sTCP:LISTEN || true`,
{
stdio: "pipe",
},