Scaffold apps/web SvelteKit 3 app with leaderboards server slice

- SvelteKit 3 (RC, pinned) + Svelte 5 + Vite 8 + adapter-node + TS 6
- paraglide pipeline: combine-locales from shared web-react locales, English-only dev settings, build-with-all-locales
- db layer port: node:sqlite dialect, json column handling, trimmed DB schema (leaderboards slice tables)
- cookie-compatible auth session decode (React Router HMAC format) resolved into event.locals via hooks
- leaderboards repository/core ported verbatim; loader converted to a remote query(), action split into skipTeam/unskipTeam commands with server-driven refresh
- valibot schemas for remote function validation
This commit is contained in:
Kalle
2026-08-16 11:30:13 +03:00
parent d3e1f22135
commit a9c18e0dda
63 changed files with 8206 additions and 36 deletions

6
apps/web/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
.svelte-kit/
build/
messages/
src/lib/paraglide/
project.inlang/cache/
project.inlang/settings.json.backup

44
apps/web/package.json Normal file
View File

@@ -0,0 +1,44 @@
{
"name": "web",
"version": "3.0.0",
"private": true,
"type": "module",
"imports": {
"#lib/*": "./src/lib/*"
},
"scripts": {
"dev": "pnpm run combine-locales && vite dev",
"build": "pnpm run combine-locales && vite build",
"build:all-locales": "tsx scripts/build-with-all-locales.ts",
"preview": "vite preview",
"start": "node build/index.js",
"combine-locales": "tsx scripts/combine-locales.ts",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"typecheck": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"test:unit": "vitest run"
},
"dependencies": {
"@epic-web/cachified": "5.6.3",
"@sendou/in-game-lists": "workspace:*",
"@sendou/utils": "workspace:*",
"date-fns": "4.4.0",
"kysely": "0.29.0",
"lru-cache": "^11.2.1",
"remeda": "2.39.0",
"sql-formatter": "^15.8.2",
"valibot": "^1.4.2"
},
"devDependencies": {
"@inlang/paraglide-js": "^2.24.1",
"@sveltejs/adapter-node": "6.0.0-next.10",
"@sveltejs/kit": "3.0.0-next.23",
"@sveltejs/vite-plugin-svelte": "^7.3.0",
"@types/node": "26.1.2",
"svelte": "^5.56.9",
"svelte-check": "^4.7.6",
"tsx": "^4.20.5",
"typescript": "~6.0.3",
"vite": "^8.2.1",
"vitest": "4.1.10"
}
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://inlang.com/schema/project-settings",
"baseLocale": "en",
"locales": ["en"],
"modules": [
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js",
"https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2/dist/index.js"
],
"plugin.inlang.messageFormat": {
"pathPattern": "./messages/{locale}.json"
}
}

View File

@@ -0,0 +1,35 @@
import { execSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const WEB_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const SETTINGS_PATH = path.join(WEB_DIR, "project.inlang/settings.json");
const LOCALES_DIR = path.resolve(WEB_DIR, "../web-react/locales");
const allLocales = fs
.readdirSync(LOCALES_DIR)
.filter((item) => fs.statSync(path.join(LOCALES_DIR, item)).isDirectory())
.sort();
console.log(`Building with locales: ${allLocales.join(", ")}`);
const backupPath = `${SETTINGS_PATH}.backup`;
fs.writeFileSync(backupPath, fs.readFileSync(SETTINGS_PATH, "utf8"));
try {
const buildSettings = {
...JSON.parse(fs.readFileSync(SETTINGS_PATH, "utf8")),
locales: allLocales,
};
fs.writeFileSync(
SETTINGS_PATH,
`${JSON.stringify(buildSettings, null, "\t")}\n`,
"utf8",
);
execSync("pnpm run build", { stdio: "inherit", cwd: WEB_DIR });
} finally {
fs.writeFileSync(SETTINGS_PATH, fs.readFileSync(backupPath, "utf8"));
fs.unlinkSync(backupPath);
}

View File

@@ -0,0 +1,80 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const WEB_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const LOCALES_DIR = path.resolve(WEB_DIR, "../web-react/locales");
const OUTPUT_DIR = path.join(WEB_DIR, "messages");
const settings = JSON.parse(
fs.readFileSync(path.join(WEB_DIR, "project.inlang/settings.json"), "utf8"),
) as { locales: string[] };
function convertVariableSyntax(text: unknown) {
if (typeof text !== "string") return text;
return text.replace(/\{\{([^}]+)\}\}/g, "{$1}");
}
function flattenObject(obj: Record<string, unknown>, prefix = "") {
const flattened: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
const formatKey = key.replaceAll(".", "_").replaceAll("-", "_");
const formatPrefix = prefix
? prefix.replaceAll(".", "_").replaceAll("-", "_")
: prefix;
const newKey = formatPrefix ? `${formatPrefix}_${formatKey}` : formatKey;
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
Object.assign(
flattened,
flattenObject(value as Record<string, unknown>, newKey),
);
} else {
flattened[newKey] = convertVariableSyntax(value);
}
}
return flattened;
}
if (!fs.existsSync(OUTPUT_DIR)) {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
}
const locales = fs.readdirSync(LOCALES_DIR).filter((item) => {
if (!fs.statSync(path.join(LOCALES_DIR, item)).isDirectory()) return false;
return settings.locales.includes(item);
});
console.log(
`Creating ${locales.length} language file(s) from ${LOCALES_DIR} to /messages...`,
);
for (const locale of locales) {
const localeDir = path.join(LOCALES_DIR, locale);
const jsonFiles = fs
.readdirSync(localeDir)
.filter((file) => file.endsWith(".json"));
const combinedMessages: Record<string, unknown> = {
$schema: "https://inlang.com/schema/inlang-message-format",
};
for (const file of jsonFiles) {
const filePath = path.join(localeDir, file);
const fileName = path.basename(file, ".json");
const jsonContent = JSON.parse(fs.readFileSync(filePath, "utf8"));
Object.assign(combinedMessages, flattenObject(jsonContent, fileName));
}
fs.writeFileSync(
path.join(OUTPUT_DIR, `${locale}.json`),
`${JSON.stringify(combinedMessages, null, "\t")}\n`,
"utf8",
);
}
console.log("Done.");

15
apps/web/src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,15 @@
import type { AuthenticatedUser } from "#lib/features/auth/user-types.ts";
declare global {
namespace App {
interface Locals {
user: AuthenticatedUser | undefined;
}
// interface Error {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

21
apps/web/src/app.html Normal file
View File

@@ -0,0 +1,21 @@
<!doctype html>
<html lang="%lang%" dir="ltr" class="%theme%scrollbar">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="initial-scale=1, viewport-fit=cover, user-scalable=no"
/>
<meta
name="apple-mobile-web-app-status-bar-style"
content="black-translucent"
/>
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="theme-color" content="#010115" />
%sveltekit.head%
</head>
<body>
%sveltekit.body%
</body>
</html>

View File

@@ -0,0 +1,34 @@
import type { Handle } from "@sveltejs/kit/hooks";
import { userIsBanned } from "#lib/features/ban/banned.server.ts";
import {
AUTH_COOKIE_NAME,
readSessionCookie,
sessionUserId,
} from "#lib/features/auth/session.server.ts";
import { getTheme } from "#lib/features/theme/theme.server.ts";
import * as UserRepository from "#lib/features/user-page/UserRepository.server.ts";
import { paraglideMiddleware } from "#lib/paraglide/server.js";
export const handle: Handle = async ({ event, resolve }) => {
const session = readSessionCookie(event.cookies.get(AUTH_COOKIE_NAME));
const userId = sessionUserId(session);
if (userId && !(await userIsBanned(userId))) {
event.locals.user = await UserRepository.findLeanById(userId);
}
return paraglideMiddleware(
event.request,
// no URL-based locale strategy is in use, so the request needs no rewriting
({ locale }) => {
return resolve(event, {
transformPageChunk: ({ html }) => {
const theme = getTheme(event.cookies);
return html
.replace("%lang%", locale)
.replace("%theme%", theme ? `${theme} ` : "");
},
});
},
);
};

104
apps/web/src/lib/config.ts Normal file
View File

@@ -0,0 +1,104 @@
import { IS_E2E_TEST_RUN } from "#lib/utils/e2e.ts";
/**
* Client (`VITE_*`) configuration. Import with `import { Config } from "#lib/config.ts"`
* and read values like `Config.siteDomain` or `Config.staticAssetsUrl`.
*
* Values are validated once when this module is first imported. Variables
* required in production fall back to development defaults outside of
* production.
*/
const env =
typeof import.meta.env !== "undefined"
? (import.meta.env as Record<string, string | undefined>)
: {};
const isProd =
typeof import.meta.env !== "undefined" &&
import.meta.env.PROD === true &&
!IS_E2E_TEST_RUN;
const TRUTHY_STRINGS = ["true", "1", "yes", "on", "y", "enabled"];
const FALSY_STRINGS = ["false", "0", "no", "off", "n", "disabled"];
const issues: Array<{ name: string; message: string }> = [];
const values = {
VITE_SITE_DOMAIN: requiredInProd("VITE_SITE_DOMAIN", "http://localhost:5173"),
VITE_STATIC_ASSETS_URL: withDefault(
"VITE_STATIC_ASSETS_URL",
"https://sendou-assets.nyc3.cdn.digitaloceanspaces.com",
),
VITE_TOURNAMENT_DEFAULT_LOGO: requiredInProd(
"VITE_TOURNAMENT_DEFAULT_LOGO",
"tournament-logo-default.avif",
),
VITE_PROD_MODE: stringBool("VITE_PROD_MODE"),
VITE_SHOW_LUTI_NAV_ITEM: stringBool("VITE_SHOW_LUTI_NAV_ITEM"),
};
if (issues.length > 0) {
throw envError(issues);
}
export const Config = {
/** Base URL of the site, e.g. `https://sendou.ink`. */
siteDomain: values.VITE_SITE_DOMAIN,
/** Base URL for static assets (images, sounds, svg). */
staticAssetsUrl: values.VITE_STATIC_ASSETS_URL,
/** Filename of the default tournament logo asset. */
tournamentDefaultLogo: values.VITE_TOURNAMENT_DEFAULT_LOGO,
/** Whether to use real seasons & league data (used when developing against the production database). */
prodMode: values.VITE_PROD_MODE,
/** Whether to show the LUTI navigation item. */
showLutiNavItem: values.VITE_SHOW_LUTI_NAV_ITEM,
};
function requiredInProd(name: string, devFallback: string): string {
const value = env[name];
if (!isProd) {
return value ?? devFallback;
}
if (value === undefined) {
issues.push({ name, message: "required in production" });
return "";
}
if (value.length === 0) {
issues.push({ name, message: "required in production (cannot be empty)" });
return "";
}
return value;
}
function withDefault(name: string, defaultValue: string): string {
return env[name] ?? defaultValue;
}
function stringBool(name: string): boolean {
const value = env[name];
if (value === undefined) return false;
const normalized = value.toLowerCase();
if (TRUTHY_STRINGS.includes(normalized)) return true;
if (FALSY_STRINGS.includes(normalized)) return false;
issues.push({
name,
message: `must be a boolean-like string (e.g. "true" or "false"), got "${value}"`,
});
return false;
}
function envError(issues: Array<{ name: string; message: string }>): Error {
const lines = issues.map((issue) => ` - ${issue.name}: ${issue.message}`);
return new Error(
`Invalid client environment configuration:\n${lines.join(
"\n",
)}\n\nSee .env.example for the full list of variables and how to set them.`,
);
}

View File

@@ -0,0 +1,34 @@
/**
* Shapes of JSON column payloads (and small column unions) shared by the
* server-only `DB` schema and client-facing types. Kept outside `src/lib/server`
* so client code can import the types without touching server modules.
*/
export type XRankPlacementRegion = "WEST" | "JPN";
export interface UserPreferences {
disableBuildAbilitySorting?: boolean;
disallowScrimPickupsFromUntrusted?: boolean;
/**
* What time format the user prefers?
*
* "auto" = use browser default (default value)
* "24h" = 24 hour format (e.g. 14:00)
* "12h" = 12 hour format (e.g. 2:00 PM)
* */
clockFormat?: "24h" | "12h" | "auto";
/** Is the new widget based user page enabled? (Supporter early preview) */
newProfileEnabled?: boolean;
/** Is spoiler-free mode enabled? Hides recent tournament results and scores until the user chooses to reveal them. */
spoilerFreeMode?: boolean;
weaponReportDefaultOpen?: boolean;
}
export interface PeakXP {
/** Peak XP across all divisions */
overall: number;
/** Peak XP (Takoroka division) */
takoroka: number | null;
/** Peak XP (Tentatek division) */
tentatek: number | null;
}

View File

@@ -0,0 +1,12 @@
export const ADMIN_DISCORD_ID = "79237403620945920";
export const ADMIN_ID =
typeof process !== "undefined" && process.env.NODE_ENV === "test" ? 1 : 274;
// Panda Scep Acing Baja Michi
export const STAFF_IDS = [11329, 9719, 9342, 20774, 23094];
// hfcRed
export const DEV_IDS = [27883];
// hfcRed Dreamy Cafy Acing
export const QA_IDS: number[] = [27883, 38176, 10654, 9342];
/** Users who can access the scanner while it is not yet enabled for everyone */
export const SCANNER_TESTER_IDS: number[] = [30228];

View File

@@ -0,0 +1,147 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import { ServerConfig } from "#lib/server/config.ts";
import { IS_E2E_TEST_RUN } from "#lib/utils/e2e.ts";
/**
* The auth session cookie, byte-compatible with the React app's
* `createCookieSessionStorage` cookie (name, JSON→base64 encoding and HMAC
* signature all identical) so that nobody gets logged out at cutover.
*/
export const AUTH_COOKIE_NAME = "__session";
export const SESSION_KEY = "user";
export const IMPERSONATED_SESSION_KEY = "impersonated_user";
const ONE_YEAR_IN_SECONDS = 31_536_000;
export type SessionData = Record<string, unknown>;
/** Decodes and verifies the auth session cookie value. Returns `null` for missing, tampered or malformed cookies. */
export function readSessionCookie(
cookieValue: string | undefined,
): SessionData | null {
if (!cookieValue) return null;
const unsigned = unsign(cookieValue, ServerConfig.sessionSecret);
if (unsigned === false) return null;
return decodeData(unsigned);
}
/** Encodes and signs session data into the auth cookie value. */
export function writeSessionCookie(data: SessionData): string {
return sign(encodeData(data), ServerConfig.sessionSecret);
}
export const authCookieOptions = {
sameSite: "lax",
domain:
ServerConfig.isProduction && !IS_E2E_TEST_RUN ? "sendou.ink" : undefined,
path: "/",
httpOnly: true,
secure: ServerConfig.isProduction && !IS_E2E_TEST_RUN,
maxAge: ONE_YEAR_IN_SECONDS,
} as const;
/** Resolves the acting user id of a session: the impersonated user when impersonating, otherwise the logged in user. */
export function sessionUserId(session: SessionData | null): number | null {
if (!session) return null;
const userId = session[IMPERSONATED_SESSION_KEY] ?? session[SESSION_KEY];
return typeof userId === "number" ? userId : null;
}
function sign(value: string, secret: string) {
const signature = createHmac("sha256", secret)
.update(value)
.digest("base64")
.replace(/=+$/, "");
return `${value}.${signature}`;
}
function unsign(cookie: string, secret: string): string | false {
const index = cookie.lastIndexOf(".");
if (index < 0) return false;
const value = cookie.slice(0, index);
const hash = cookie.slice(index + 1);
const expected = createHmac("sha256", secret)
.update(value)
.digest("base64")
.replace(/=+$/, "");
const hashBuffer = Buffer.from(hash);
const expectedBuffer = Buffer.from(expected);
if (hashBuffer.length !== expectedBuffer.length) return false;
return timingSafeEqual(hashBuffer, expectedBuffer) ? value : false;
}
function encodeData(value: SessionData): string {
return btoa(myUnescape(encodeURIComponent(JSON.stringify(value))));
}
function decodeData(value: string): SessionData {
try {
const parsed = JSON.parse(decodeURIComponent(myEscape(atob(value))));
return typeof parsed === "object" && parsed !== null ? parsed : {};
} catch {
return {};
}
}
// escape/unescape ported from React Router's cookie encoding (which follows
// core-js's escape/unescape) so multibyte session values round-trip identically
function myEscape(value: string): string {
let result = "";
let index = 0;
while (index < value.length) {
const chr = value.charAt(index++);
if (/[\w*+\-./@]/.exec(chr)) {
result += chr;
} else {
const code = chr.charCodeAt(0);
if (code < 256) {
result += `%${hex(code, 2)}`;
} else {
result += `%u${hex(code, 4).toUpperCase()}`;
}
}
}
return result;
}
function hex(code: number, length: number): string {
let result = code.toString(16);
while (result.length < length) result = `0${result}`;
return result;
}
function myUnescape(value: string): string {
let result = "";
let index = 0;
while (index < value.length) {
let chr = value.charAt(index++);
if (chr === "%") {
if (value.charAt(index) === "u") {
const part = value.slice(index + 1, index + 5);
if (/^[\da-f]{4}$/i.exec(part)) {
result += String.fromCharCode(Number.parseInt(part, 16));
index += 5;
continue;
}
} else {
const part = value.slice(index, index + 2);
if (/^[\da-f]{2}$/i.exec(part)) {
result += String.fromCharCode(Number.parseInt(part, 16));
index += 2;
continue;
}
}
}
result += chr;
}
return result;
}

View File

@@ -0,0 +1,23 @@
import type { UserPreferences } from "#lib/db/tables-json.ts";
import type { CustomTheme } from "#lib/features/theme/theme-types.ts";
import type { UnifiedLanguageCode } from "#lib/modules/i18n/languages.ts";
import type { Role } from "#lib/modules/permissions/types.ts";
/** The logged in user as resolved once per request, shape matching `UserRepository.findLeanById`. */
export interface AuthenticatedUser {
id: number;
username: string;
discordId: string;
discordAvatar: string | null;
customUrl: string | null;
customAvatarUrl: string | null;
createdAt: number | null;
customTheme: CustomTheme | null;
patronTier: number | null;
languages: UnifiedLanguageCode[] | null;
inGameName: string | null;
preferences: UserPreferences | null;
plusTier: number | null;
friendCode: string | null;
roles: Role[];
}

View File

@@ -0,0 +1,35 @@
import { error } from "@sveltejs/kit";
import { getRequestEvent } from "$app/server";
import type { AuthenticatedUser } from "./user-types.ts";
export type { AuthenticatedUser };
/** The logged in user of the current request, or `undefined` when logged out. */
export function getUser(): AuthenticatedUser | undefined {
return getRequestEvent().locals.user;
}
/** The logged in user of the current request; responds with 401 when logged out. */
export function requireUser(): AuthenticatedUser {
const user = getUser();
if (!user) {
error(401);
}
return user;
}
/** Id of the acting user; throws when logged out (use only where auth was already established). */
export function actorId(): number {
const user = getUser();
if (!user) {
throw new Error("No acting user in context");
}
return user.id;
}
/** Id of the acting user, or `null` when logged out. */
export function actorIdOrNull(): number | null {
return getUser()?.id ?? null;
}

View File

@@ -0,0 +1,32 @@
import * as UserRepository from "#lib/features/user-page/UserRepository.server.ts";
import { databaseTimestampToDate } from "#lib/utils/dates.ts";
let bannedUsers: Awaited<
ReturnType<typeof UserRepository.findAllBannedUsers>
> | null = null;
export function checkBanStatus(
banned: number | null | undefined,
now: Date = new Date(),
): boolean {
if (!banned) return false;
if (banned === 1) return true;
const banExpiresAt = databaseTimestampToDate(banned);
return banExpiresAt > now;
}
export async function userIsBanned(userId: number) {
if (!bannedUsers) {
bannedUsers = await UserRepository.findAllBannedUsers();
}
const banStatus = bannedUsers.get(userId);
return checkBanStatus(banStatus?.banned);
}
export async function refreshBannedCache() {
bannedUsers = await UserRepository.findAllBannedUsers();
}

View File

@@ -0,0 +1,507 @@
import type {
MainWeaponId,
RankedModeShort,
} from "@sendou/in-game-lists/types";
import type { InferResult } from "kysely";
import { sql } from "kysely";
import * as R from "remeda";
import { actorId } from "#lib/features/auth/user.server.ts";
import { db } from "#lib/server/db/sql.ts";
import type { Tables } from "#lib/server/db/tables.ts";
import {
commonUserSelect,
concatUserSubmittedImagePrefix,
jsonArrayFrom,
latestSkillPerSeason,
skillCountsAsSeasonSet,
} from "#lib/server/kysely.ts";
import { dateToDatabaseTimestamp } from "#lib/utils/dates.ts";
import * as Seasons from "../mmr/Seasons.ts";
import { ordinalToSp, type SkillTeamIdentifier } from "../mmr/mmr-utils.ts";
import {
DEFAULT_LEADERBOARD_MAX_SIZE,
MATCHES_COUNT_NEEDED_FOR_LEADERBOARD,
} from "./leaderboards-constants.ts";
// must stay above the largest weaponSplId
const USER_WEAPON_PACK_FACTOR = 100_000;
function addPowers<T extends { ordinal: number }>(entries: T[]) {
return entries.map((entry) => ({
...entry,
power: ordinalToSp(entry.ordinal),
}));
}
/** Numbers the entries by placement. A skipped team keeps its spot in the order but takes no number, the one below it getting the number it would have had. */
function addPlacementRank<T extends { isSkipped: boolean }>(entries: T[]) {
let placementRank = 0;
return entries.map((entry): T & { placementRank: number | null } => {
if (entry.isSkipped) return { ...entry, placementRank: null };
placementRank++;
return { ...entry, placementRank };
});
}
const teamLeaderboardBySeasonQuery = (season: number) =>
db
.selectFrom((eb) =>
eb
.selectFrom(
latestSkillPerSeason({ season, by: "identifier" }).as("LatestOfTeam"),
)
.select([
"LatestOfTeam.latestId as entryId",
"LatestOfTeam.ordinal",
"LatestOfTeam.identifier",
])
.where(
"LatestOfTeam.matchesCount",
">=",
MATCHES_COUNT_NEEDED_FOR_LEADERBOARD,
)
.orderBy("LatestOfTeam.ordinal", "desc")
.limit(DEFAULT_LEADERBOARD_MAX_SIZE)
.as("Entry"),
)
.select((eb) => [
"Entry.entryId",
"Entry.ordinal",
"Entry.identifier",
jsonArrayFrom(
eb
.selectFrom("SkillTeamUser")
.innerJoin("User", "SkillTeamUser.userId", "User.id")
.select((eb) => commonUserSelect(eb))
.whereRef("SkillTeamUser.skillId", "=", "Entry.entryId"),
).as("members"),
jsonArrayFrom(
eb
.selectFrom("SkillTeamUser")
.innerJoin("User", "SkillTeamUser.userId", "User.id")
.innerJoin(
"TeamMemberWithSecondary",
"TeamMemberWithSecondary.userId",
"User.id",
)
.innerJoin("Team", "Team.id", "TeamMemberWithSecondary.teamId")
.leftJoin(
"UserSubmittedImage",
"UserSubmittedImage.id",
"Team.avatarImgId",
)
.select((eb) => [
"Team.id",
"Team.name",
concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
"avatarUrl",
),
"Team.customUrl",
"TeamMemberWithSecondary.isMainTeam",
"TeamMemberWithSecondary.userId",
])
.whereRef("SkillTeamUser.skillId", "=", "Entry.entryId"),
).as("teams"),
])
.orderBy("Entry.ordinal", "desc")
.$narrowType<{ identifier: SkillTeamIdentifier }>();
type TeamLeaderboardBySeasonQueryReturnType = InferResult<
ReturnType<typeof teamLeaderboardBySeasonQuery>
>;
type TeamLeaderboardEntry = TeamLeaderboardBySeasonQueryReturnType[number] & {
isSkipped: boolean;
};
export async function findTeamLeaderboardBySeason({
season,
onlyOneEntryPerUser,
}: {
season: number;
onlyOneEntryPerUser: boolean;
}) {
const entries = addSkipped({
entries: await teamLeaderboardBySeasonQuery(season).execute(),
skippedIdentifiers: await findAllTeamSkipIdentifiersBySeason(season),
});
const withNonSqPlayersHandled = onlyOneEntryPerUser
? await filterOutNonSqPlayers({ season, entries })
: entries;
const oneEntryPerUser = onlyOneEntryPerUser
? filterOneEntryPerUser(withNonSqPlayersHandled)
: withNonSqPlayersHandled;
const withSharedTeam = resolveSharedTeam(oneEntryPerUser);
const withPower = addPowers(withSharedTeam);
return addPlacementRank(withPower);
}
function addSkipped(args: {
entries: TeamLeaderboardBySeasonQueryReturnType;
skippedIdentifiers: Set<SkillTeamIdentifier>;
}): TeamLeaderboardEntry[] {
return args.entries.map((entry) => ({
...entry,
isSkipped: args.skippedIdentifiers.has(entry.identifier),
}));
}
async function filterOutNonSqPlayers(args: {
entries: TeamLeaderboardEntry[];
season: number;
}) {
const validUserIds = new Set(
await userIdsWithEnoughSqMatchesForTeamLeaderboard(args.season),
);
return args.entries.filter((entry) =>
entry.members.every((member) => validUserIds.has(member.id)),
);
}
async function userIdsWithEnoughSqMatchesForTeamLeaderboard(seasonNth: number) {
// a Skill row with groupMatchId set exists exactly once per user per
// completed (not canceled) SendouQ match of the season
const rows = await db
.selectFrom("Skill")
.select("userId")
.where("season", "=", seasonNth)
.where("groupMatchId", "is not", null)
.where("userId", "is not", null)
.groupBy("userId")
.having(
(eb) => eb.fn.countAll(),
">=",
MATCHES_COUNT_NEEDED_FOR_LEADERBOARD,
)
.$narrowType<{ userId: number }>()
.execute();
return rows.map((row) => row.userId);
}
export async function hasEnoughSqMatchesByUserId(userId: number) {
const season = Seasons.currentOrPrevious();
if (!season) return false;
const dateRange = Seasons.nthToReportingDateRange(season.nth);
if (!dateRange) return false;
const rows = await db
.selectFrom("GroupMatch")
.innerJoin("GroupMember", (join) =>
join.on((eb) =>
eb.or([
eb("GroupMatch.alphaGroupId", "=", eb.ref("GroupMember.groupId")),
eb("GroupMatch.bravoGroupId", "=", eb.ref("GroupMember.groupId")),
]),
),
)
.innerJoin("Skill", (join) =>
join
.onRef("Skill.groupMatchId", "=", "GroupMatch.id")
.onRef("Skill.userId", "=", "GroupMember.userId"),
)
.where("GroupMember.userId", "=", userId)
.where(
"GroupMatch.createdAt",
">",
dateToDatabaseTimestamp(dateRange.starts),
)
.where("GroupMatch.createdAt", "<", dateToDatabaseTimestamp(dateRange.ends))
.select(db.fn.countAll<number>().as("count"))
.executeTakeFirstOrThrow();
return rows.count >= MATCHES_COUNT_NEEDED_FOR_LEADERBOARD;
}
/** The highest placing entry of each user. A skipped team is always kept and doesn't spend
* its players' entry, so a roster sharing players with it can still place below it. */
function filterOneEntryPerUser(entries: TeamLeaderboardEntry[]) {
const encounteredUserIds = new Set<number>();
return entries.filter((entry) => {
if (entry.isSkipped) return true;
if (entry.members.some((m) => encounteredUserIds.has(m.id))) {
return false;
}
for (const member of entry.members) {
encounteredUserIds.add(member.id);
}
return true;
});
}
function resolveSharedTeam(entries: ReturnType<typeof filterOneEntryPerUser>) {
return entries.map(({ teams, ...entry }) => {
const uniqueTeamIds = R.unique(teams.map((team) => team.id));
for (const teamId of uniqueTeamIds) {
const count = teams.filter((team) => team.id === teamId).length;
if (count === 4) {
return {
...entry,
team: teams.find((team) => team.id === teamId),
};
}
}
return {
...entry,
team: undefined,
};
});
}
async function findAllTeamSkipIdentifiersBySeason(season: number) {
const rows = await db
.selectFrom("LeaderboardTeamSkip")
.select("LeaderboardTeamSkip.identifier")
.where("LeaderboardTeamSkip.season", "=", season)
.execute();
return new Set(rows.map((row) => row.identifier));
}
/** Marks a team as not counting for the season's team leaderboard placements. Records the acting user as `skippedByUserId`. */
export async function insertTeamSkip(args: {
season: number;
identifier: SkillTeamIdentifier;
}) {
await db
.insertInto("LeaderboardTeamSkip")
.values({ ...args, skippedByUserId: actorId() })
.onConflict((oc) => oc.columns(["season", "identifier"]).doNothing())
.execute();
}
export async function deleteTeamSkip(args: {
season: number;
identifier: SkillTeamIdentifier;
}) {
await db
.deleteFrom("LeaderboardTeamSkip")
.where("LeaderboardTeamSkip.season", "=", args.season)
.where("LeaderboardTeamSkip.identifier", "=", args.identifier)
.execute();
}
export async function findSeasonsParticipatedInByUserId(userId: number) {
const rows = await db
.selectFrom("Skill")
.select("season")
.where("userId", "=", userId)
.where((eb) => skillCountsAsSeasonSet(eb, userId))
.groupBy("season")
.orderBy("season", "desc")
.execute();
return rows.map((row) => row.season);
}
export type XPLeaderboardItem = Awaited<
ReturnType<typeof findAllXPLeaderboard>
>[number];
function xpLeaderboardQuery(where?: {
mode?: RankedModeShort;
weaponSplId?: MainWeaponId;
}) {
// walks placements from the highest power down (power-descending indexes
// keeping only each player's best placement, so it can
// stop at the 500th distinct player instead of aggregating every player's
// max power first
return db
.selectFrom((eb) => {
let placements = eb
.selectFrom("XRankPlacement")
.select([
"XRankPlacement.id as entryId",
"XRankPlacement.playerId",
"XRankPlacement.weaponSplId",
"XRankPlacement.name",
"XRankPlacement.power",
])
.where(({ not, exists, selectFrom }) =>
not(
exists(
selectFrom("XRankPlacement as Better")
.select("Better.id")
.whereRef("Better.playerId", "=", "XRankPlacement.playerId")
.$if(Boolean(where?.mode), (qb) =>
qb.where("Better.mode", "=", where!.mode!),
)
.$if(typeof where?.weaponSplId === "number", (qb) =>
qb.where("Better.weaponSplId", "=", where!.weaponSplId!),
)
.where((eb) =>
eb.or([
eb("Better.power", ">", eb.ref("XRankPlacement.power")),
eb.and([
eb("Better.power", "=", eb.ref("XRankPlacement.power")),
eb("Better.id", "<", eb.ref("XRankPlacement.id")),
]),
]),
),
),
),
)
.orderBy("XRankPlacement.power", "desc")
.limit(DEFAULT_LEADERBOARD_MAX_SIZE);
if (where?.mode) {
placements = placements.where("XRankPlacement.mode", "=", where.mode);
}
if (typeof where?.weaponSplId === "number") {
placements = placements.where(
"XRankPlacement.weaponSplId",
"=",
where.weaponSplId,
);
}
return placements.as("Placement");
})
.innerJoin("SplatoonPlayer", "SplatoonPlayer.id", "Placement.playerId")
.leftJoin("User", "User.id", "SplatoonPlayer.userId")
.select((eb) => [
...commonUserSelect(eb),
"Placement.entryId",
"Placement.playerId",
"Placement.weaponSplId",
"Placement.name",
"Placement.power",
sql<number>`rank() over (order by "Placement"."power" desc)`.as(
"placementRank",
),
])
.orderBy("Placement.power", "desc")
.limit(DEFAULT_LEADERBOARD_MAX_SIZE);
}
export async function findAllXPLeaderboard() {
return xpLeaderboardQuery().execute();
}
export async function findModeXPLeaderboard(mode: RankedModeShort) {
return xpLeaderboardQuery({ mode }).execute();
}
export async function findWeaponXPLeaderboard(weaponSplId: MainWeaponId) {
return xpLeaderboardQuery({ weaponSplId }).execute();
}
export type UserSPLeaderboardItem = Awaited<
ReturnType<typeof findUserSPLeaderboard>
>[number];
export async function findUserSPLeaderboard(season: number) {
const rows = await db
.selectFrom(latestSkillPerSeason({ season, by: "userId" }).as("Latest"))
.innerJoin("User", "User.id", "Latest.userId")
.select((eb) => [
...commonUserSelect(eb),
"Latest.latestId as entryId",
"Latest.ordinal",
"User.plusSkippedForSeasonNth",
])
.where("Latest.matchesCount", ">=", MATCHES_COUNT_NEEDED_FOR_LEADERBOARD)
.orderBy("Latest.ordinal", "desc")
.execute();
let placementRank = 0;
return rows.map(({ ordinal, ...rest }, index) => {
if (index === 0 || ordinal !== rows[index - 1].ordinal) {
placementRank = index + 1;
}
return {
...rest,
placementRank,
pendingPlusTier: null as number | null,
power: ordinalToSp(ordinal),
};
});
}
export type SeasonPopularUsersWeapon = Record<
Tables["User"]["id"],
MainWeaponId
>;
export async function findSeasonPopularUsersWeapon(
season: number,
): Promise<SeasonPopularUsersWeapon> {
const { starts, ends } = Seasons.nthToReportingDateRange(season);
const startsTs = dateToDatabaseTimestamp(starts);
const endsTs = dateToDatabaseTimestamp(ends);
// grouping the ~quarter million rows a season has by one packed integer is
// measurably faster than by the (userId, weaponSplId) pair; the packed key
// also sorts identically to the pair so max() tie-breaking is unchanged
const packedUserWeapon = sql<number>`"ReportedWeapon"."userId" * ${sql.lit(
USER_WEAPON_PACK_FACTOR,
)} + "ReportedWeapon"."weaponSplId"`;
const sendouqWeapons = db
.selectFrom("ReportedWeapon")
.innerJoin("GroupMatch", "ReportedWeapon.groupMatchId", "GroupMatch.id")
.select(packedUserWeapon.as("packedUserWeapon"))
.where("GroupMatch.createdAt", ">=", startsTs)
.where("GroupMatch.createdAt", "<=", endsTs);
const tournamentWeapons = db
.selectFrom("ReportedWeapon")
.innerJoin(
"TournamentMatch",
"TournamentMatch.id",
"ReportedWeapon.tournamentMatchId",
)
.innerJoin(
"TournamentStage",
"TournamentStage.id",
"TournamentMatch.stageId",
)
.innerJoin("Tournament", "Tournament.id", "TournamentStage.tournamentId")
.select(packedUserWeapon.as("packedUserWeapon"))
.where("Tournament.isFinalized", "=", 1)
.where("ReportedWeapon.createdAt", ">=", startsTs)
.where("ReportedWeapon.createdAt", "<=", endsTs);
const rows = await db
.with("q1", (db) =>
db
.selectFrom(sendouqWeapons.unionAll(tournamentWeapons).as("merged"))
.select(({ fn, ref }) => [
sql<number>`${ref("merged.packedUserWeapon")} / ${sql.lit(
USER_WEAPON_PACK_FACTOR,
)}`.as("userId"),
sql<MainWeaponId>`${ref("merged.packedUserWeapon")} % ${sql.lit(
USER_WEAPON_PACK_FACTOR,
)}`.as("weaponSplId"),
fn.countAll<number>().as("count"),
])
.groupBy("merged.packedUserWeapon"),
)
.selectFrom("q1")
.select(({ fn }) => [
"q1.userId",
"q1.weaponSplId",
fn.max("q1.count").as("count"),
])
.groupBy("q1.userId")
.having(
({ fn }) => fn.max("q1.count"),
">",
MATCHES_COUNT_NEEDED_FOR_LEADERBOARD,
)
.execute();
return Object.fromEntries(rows.map((r) => [r.userId, r.weaponSplId]));
}

View File

@@ -0,0 +1,29 @@
import { rankedModesShort } from "@sendou/in-game-lists/modes";
import {
mainWeaponIds,
weaponCategories,
} from "@sendou/in-game-lists/weapon-ids";
export const MATCHES_COUNT_NEEDED_FOR_LEADERBOARD = 7;
export const DEFAULT_LEADERBOARD_MAX_SIZE = 500;
export const WEAPON_LEADERBOARD_MAX_SIZE = 100;
/** How many teams of the team leaderboard qualify, the divider being shown below the last of them. */
export const TEAM_LEADERBOARD_QUALIFYING_COUNT = 12;
export const LEADERBOARD_TYPES = [
"USER",
"TEAM",
"TEAM-ALL",
...(weaponCategories.map(
(category) => `USER-${category.name}`,
) as `USER-${(typeof weaponCategories)[number]["name"]}`[]),
"XP-ALL",
...(rankedModesShort.map(
(mode) => `XP-MODE-${mode}`,
) as `XP-MODE-${(typeof rankedModesShort)[number]}`[]),
...(mainWeaponIds.map(
(id) => `XP-WEAPON-${id}`,
) as `XP-WEAPON-${(typeof mainWeaponIds)[number]}`[]),
] as const;
export type LeaderboardType = (typeof LEADERBOARD_TYPES)[number];

View File

@@ -0,0 +1,235 @@
import { cachified } from "@epic-web/cachified";
import type { MainWeaponId } from "@sendou/in-game-lists/types";
import { weaponCategories } from "@sendou/in-game-lists/weapon-ids";
import type { Unwrapped } from "@sendou/utils/types";
import { cache, IN_MILLISECONDS, ttl } from "#lib/server/cache.ts";
import * as UserRepository from "../user-page/UserRepository.server.ts";
import { USER_LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN } from "../mmr/mmr-constants.ts";
import * as Seasons from "../mmr/Seasons.ts";
import { freshUserSkills } from "../mmr/tiered.server.ts";
import type {
SeasonPopularUsersWeapon,
UserSPLeaderboardItem,
} from "./LeaderboardRepository.server.ts";
import * as LeaderboardRepository from "./LeaderboardRepository.server.ts";
import { DEFAULT_LEADERBOARD_MAX_SIZE } from "./leaderboards-constants.ts";
import { seasonHasTopTen } from "./leaderboards-utils.ts";
export type UserLeaderboardWithAdditionsItem = Unwrapped<
typeof cachedFullUserLeaderboard
>;
export async function cachedFullUserLeaderboard(season: number) {
return cachified({
key: `user-leaderboard-season-${season}`,
cache,
ttl: ttl(IN_MILLISECONDS.HALF_HOUR),
staleWhileRevalidate: ttl(IN_MILLISECONDS.TWO_HOURS),
async getFreshValue() {
const leaderboard =
await LeaderboardRepository.findUserSPLeaderboard(season);
const withTiers = await addTiers(leaderboard, season);
const shouldAddPendingPlusTier =
season === Seasons.current()?.nth &&
leaderboard.length >= USER_LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN;
const withPendingPlusTiers = shouldAddPendingPlusTier
? addPendingPlusTiers(
withTiers,
await UserRepository.findAllPlusServerMembers(),
season,
)
: withTiers;
return addWeapons(
withPendingPlusTiers,
await LeaderboardRepository.findSeasonPopularUsersWeapon(season),
);
},
});
}
export async function cachedTeamLeaderboard({
season,
onlyOneEntryPerUser,
}: {
season: number;
onlyOneEntryPerUser: boolean;
}) {
return cachified({
key: teamLeaderboardCacheKey({ season, onlyOneEntryPerUser }),
cache,
ttl: ttl(IN_MILLISECONDS.HALF_HOUR),
staleWhileRevalidate: ttl(IN_MILLISECONDS.TWO_HOURS),
async getFreshValue() {
return LeaderboardRepository.findTeamLeaderboardBySeason({
season,
onlyOneEntryPerUser,
});
},
});
}
/** Clears both variants of a season's cached team leaderboard, so that a change to which teams are skipped shows without waiting for the cache to expire. */
export function clearCachedTeamLeaderboards(season: number) {
for (const onlyOneEntryPerUser of [true, false]) {
cache.delete(teamLeaderboardCacheKey({ season, onlyOneEntryPerUser }));
}
}
function teamLeaderboardCacheKey({
season,
onlyOneEntryPerUser,
}: {
season: number;
onlyOneEntryPerUser: boolean;
}) {
return `team-leaderboard-season-${season}-${onlyOneEntryPerUser ? "TEAM" : "TEAM-ALL"}`;
}
async function addTiers<T extends UserSPLeaderboardItem>(
entries: T[],
season: number,
) {
const tiers = await freshUserSkills(season);
const encounteredTiers = new Set<string>();
return entries.map((entry, i) => {
const tier = tiers.userSkills[entry.id].tier;
if (i < 10 && seasonHasTopTen(season)) {
return { ...entry, tier, firstOfTier: undefined };
}
const tierKey = `${tier.name}${tier.isPlus ? "+" : ""}`;
const tierAlreadyEncountered = encounteredTiers.has(tierKey);
if (!tierAlreadyEncountered) {
encounteredTiers.add(tierKey);
}
return {
...entry,
tier,
firstOfTier: !tierAlreadyEncountered ? tier : undefined,
};
});
}
const PLUS_TIER_QUOTA = {
"+1": 5,
"+2": 10,
"+3": 15,
} as const;
export function addPendingPlusTiers<T extends UserSPLeaderboardItem>(
entries: T[],
plusTiers: Array<{
userId: number;
plusTier: number;
}>,
seasonNth: number,
) {
const quota: { "+1": number; "+2": number; "+3": number } = {
...PLUS_TIER_QUOTA,
};
const resolveHighestPlusTierWithSpace = () => {
if (quota["+1"] > 0) return 1;
if (quota["+2"] > 0) return 2;
if (quota["+3"] > 0) return 3;
return null;
};
for (const entry of entries) {
const highestPlusTierWithSpace = resolveHighestPlusTierWithSpace();
if (!highestPlusTierWithSpace) break;
const plusTier = plusTiers.find((t) => t.userId === entry.id)?.plusTier;
if (plusTier && plusTier <= highestPlusTierWithSpace) continue;
if (entry.plusSkippedForSeasonNth === seasonNth) {
entry.plusSkippedForSeasonNth = null;
continue;
}
entry.pendingPlusTier = highestPlusTierWithSpace;
const key = `+${highestPlusTierWithSpace}` as const;
quota[key] -= 1;
}
return entries;
}
function addWeapons<T extends { id: number }>(
entries: T[],
weapons: SeasonPopularUsersWeapon,
) {
return entries.map((entry) => {
const weaponSplId = weapons[entry.id] as MainWeaponId | undefined;
return {
...entry,
weaponSplId,
};
});
}
export function filterByWeaponCategory<
T extends { weaponSplId?: MainWeaponId },
>(entries: Array<T>, category: (typeof weaponCategories)[number]["name"]) {
const weaponIdsOfCategory = new Set(
weaponCategories.find((c) => c.name === category)!.weaponIds,
);
return entries.filter(
(entry) =>
typeof entry.weaponSplId === "number" &&
weaponIdsOfCategory.has(entry.weaponSplId),
);
}
/**
* The entries of the full user leaderboard that are visible on the leaderboard
* page. Cut by placement rank instead of entry count so that players tied
* across the cutoff are all shown; {@link ownEntryPeek} covers exactly the
* entries this leaves out.
*/
export function shownUserLeaderboard(
leaderboard: UserLeaderboardWithAdditionsItem[],
) {
return leaderboard.filter(
(entry) => entry.placementRank <= DEFAULT_LEADERBOARD_MAX_SIZE,
);
}
export async function ownEntryPeek({
leaderboard,
userId,
season,
}: {
leaderboard: UserLeaderboardWithAdditionsItem[];
userId: number;
season: number;
}) {
const found = leaderboard.find(
(entry) =>
entry.id === userId && entry.placementRank > DEFAULT_LEADERBOARD_MAX_SIZE,
);
if (!found) return null;
const withTier = (await addTiers([found], season))[0];
const { intervals } = await freshUserSkills(season);
const currentTierIndex = intervals.findIndex(
(interval) =>
interval.name === withTier.tier.name &&
interval.isPlus === withTier.tier.isPlus,
);
const nextTier =
currentTierIndex > 0 ? intervals[currentTierIndex - 1] : undefined;
return {
entry: withTier,
nextTier,
};
}

View File

@@ -0,0 +1,21 @@
import * as v from "valibot";
import type { SkillTeamIdentifier } from "#lib/features/mmr/mmr-utils.ts";
import { LEADERBOARD_TYPES } from "./leaderboards-constants.ts";
export const leaderboardsQuerySchema = v.object({
type: v.picklist(LEADERBOARD_TYPES),
season: v.nullable(v.pipe(v.number(), v.integer())),
});
export type LeaderboardsQueryArgs = v.InferOutput<
typeof leaderboardsQuerySchema
>;
export const teamLeaderboardEntrySchema = v.object({
season: v.pipe(v.number(), v.integer(), v.minValue(0)),
identifier: v.pipe(
v.string(),
v.regex(/^\d+-\d+-\d+-\d+$/),
v.transform((value) => value as SkillTeamIdentifier),
),
});

View File

@@ -0,0 +1,40 @@
import playerData from "./top-ten.json" with { type: "json" };
interface TopTenPlayerData {
id: number;
name: string;
countryCode: string;
transforms?: { top?: number; left?: number };
}
const seasons = playerData as Array<TopTenPlayerData[] | undefined>;
export function seasonHasTopTen(season: number) {
return !!seasons[season];
}
export function playerTopTenPlacement({
season,
userId,
}: {
season: number;
userId: number;
}) {
for (const [i, player] of (seasons[season] ?? []).entries()) {
if (player.id === userId) {
return i + 1;
}
}
return null;
}
export function topTenPlayerData({
season,
placement,
}: {
season: number;
placement: number;
}): TopTenPlayerData | undefined {
return seasons[season]?.[placement - 1];
}

View File

@@ -0,0 +1,121 @@
import type {
MainWeaponId,
RankedModeShort,
} from "@sendou/in-game-lists/types";
import type { weaponCategories } from "@sendou/in-game-lists/weapon-ids";
import { logger } from "@sendou/utils/logger";
import { command, query } from "$app/server";
import { getUser, requireUser } from "#lib/features/auth/user.server.ts";
import { requireRole } from "#lib/modules/permissions/guards.server.ts";
import * as Seasons from "../mmr/Seasons.ts";
import * as LeaderboardRepository from "./LeaderboardRepository.server.ts";
import {
cachedFullUserLeaderboard,
cachedTeamLeaderboard,
clearCachedTeamLeaderboards,
filterByWeaponCategory,
ownEntryPeek,
shownUserLeaderboard,
} from "./leaderboards-core.server.ts";
import { WEAPON_LEADERBOARD_MAX_SIZE } from "./leaderboards-constants.ts";
import {
leaderboardsQuerySchema,
teamLeaderboardEntrySchema,
} from "./leaderboards-schemas.ts";
export const getLeaderboards = query(
leaderboardsQuerySchema,
async ({ type, season: seasonParam }) => {
const user = getUser();
const season =
Seasons.allStarted().find((s) => s === seasonParam) ??
Seasons.currentOrPrevious()!.nth;
const fullUserLeaderboard = type.includes("USER")
? await cachedFullUserLeaderboard(season)
: null;
const userLeaderboard = fullUserLeaderboard
? shownUserLeaderboard(fullUserLeaderboard)
: undefined;
const teamLeaderboard =
type === "TEAM" || type === "TEAM-ALL"
? await cachedTeamLeaderboard({
season,
onlyOneEntryPerUser: type !== "TEAM-ALL",
})
: null;
const isWeaponLeaderboard = userLeaderboard && type !== "USER";
const filteredLeaderboard = isWeaponLeaderboard
? filterByWeaponCategory(
fullUserLeaderboard!,
type.split("-")[1] as (typeof weaponCategories)[number]["name"],
).slice(0, WEAPON_LEADERBOARD_MAX_SIZE)
: userLeaderboard;
const showOwnEntryPeek = fullUserLeaderboard && !isWeaponLeaderboard && user;
const xpLeaderboard =
type === "XP-ALL"
? await LeaderboardRepository.findAllXPLeaderboard()
: type.startsWith("XP-MODE")
? await LeaderboardRepository.findModeXPLeaderboard(
type.split("-")[2] as RankedModeShort,
)
: type.startsWith("XP-WEAPON")
? await LeaderboardRepository.findWeaponXPLeaderboard(
Number(type.split("-")[2]) as MainWeaponId,
)
: null;
return {
userLeaderboard: filteredLeaderboard ?? userLeaderboard,
ownEntryPeek: showOwnEntryPeek
? await ownEntryPeek({
leaderboard: fullUserLeaderboard,
season,
userId: user.id,
})
: null,
teamLeaderboard,
xpLeaderboard,
season,
};
},
);
export const skipTeam = command(
teamLeaderboardEntrySchema,
async ({ season, identifier }) => {
requireRole("STAFF");
const user = requireUser();
await LeaderboardRepository.insertTeamSkip({ season, identifier });
logger.info(
`Team leaderboard: user ${user.id} skipped team ${identifier} of season ${season}`,
);
clearCachedTeamLeaderboards(season);
void getLeaderboards({ type: "TEAM", season }).refresh();
},
);
export const unskipTeam = command(
teamLeaderboardEntrySchema,
async ({ season, identifier }) => {
requireRole("STAFF");
const user = requireUser();
await LeaderboardRepository.deleteTeamSkip({ season, identifier });
logger.info(
`Team leaderboard: user ${user.id} unskipped team ${identifier} of season ${season}`,
);
clearCachedTeamLeaderboards(season);
void getLeaderboards({ type: "TEAM", season }).refresh();
},
);

View File

@@ -0,0 +1,278 @@
[
[
{
"name": "Grey",
"countryCode": "FR",
"id": 123,
"transforms": {
"left": 25,
"top": 5
}
},
{
"name": "Kyo",
"id": 101,
"countryCode": "US",
"transforms": {
"left": 11,
"top": 5
}
},
{
"name": "biscuit!",
"id": 25,
"countryCode": "US",
"transforms": {
"left": 29,
"top": 5
}
},
{
"name": "stalk",
"id": 40,
"countryCode": "US",
"transforms": {
"left": 32,
"top": 6
}
},
{
"name": "Xenith",
"id": 11517,
"countryCode": "US",
"transforms": {
"left": 19,
"top": 6
}
},
{
"name": "Kiver",
"id": 35,
"countryCode": "FR",
"transforms": {
"left": 34,
"top": 6
}
},
{
"name": "Oscar",
"id": 1343,
"countryCode": "MX",
"transforms": {
"left": 18,
"top": 6
}
},
{
"name": "Home",
"id": 201,
"countryCode": "ID",
"transforms": {
"left": 34,
"top": 8
}
},
{
"name": "Gos",
"id": 279,
"countryCode": "US",
"transforms": {
"left": 30,
"top": 6
}
},
{
"name": "Elis",
"id": 141,
"countryCode": "GB-WLS",
"transforms": {
"left": 27,
"top": 8
}
}
],
[
{
"name": "Jared",
"countryCode": "US",
"id": 305,
"transforms": {
"left": 25,
"top": 2
}
},
{
"name": "bran",
"id": 147,
"countryCode": "US",
"transforms": {
"left": 32,
"top": 5
}
},
{
"name": "Gos",
"id": 279,
"countryCode": "US",
"transforms": {
"left": 26,
"top": 5
}
},
{
"name": "stalk",
"id": 40,
"countryCode": "US",
"transforms": {
"left": 32,
"top": 6
}
},
{
"name": "Volty",
"id": 32,
"countryCode": "FR",
"transforms": {
"left": 34,
"top": 4
}
},
{
"name": "Grey",
"id": 123,
"countryCode": "FR",
"transforms": {
"left": 31,
"top": 6
}
},
{
"name": "bagel",
"id": 1343,
"countryCode": "US",
"transforms": {
"left": 11,
"top": 6
}
},
{
"name": "Burstie",
"id": 3128,
"countryCode": "CA",
"transforms": {
"left": 31,
"top": 5
}
},
{
"name": "Zero",
"id": 257,
"countryCode": "US",
"transforms": {
"left": 28,
"top": 7
}
},
{
"name": ".q",
"id": 164,
"countryCode": "US",
"transforms": {
"left": 17,
"top": 4
}
}
],
[
{
"name": "Jared",
"countryCode": "US",
"id": 305,
"transforms": {
"left": 25,
"top": 2
}
},
{
"name": "Kyo",
"id": 101,
"countryCode": "US",
"transforms": {
"left": 16,
"top": 5
}
},
{
"name": "y0shell",
"id": 439,
"countryCode": "GB",
"transforms": {
"left": 1,
"top": 5
}
},
{
"name": "PeLu",
"id": 62,
"countryCode": "PA",
"transforms": {
"left": -3,
"top": 5
}
},
{
"name": "bran",
"id": 147,
"countryCode": "US",
"transforms": {
"left": 18,
"top": 4
}
},
{
"name": "Half",
"id": 451,
"countryCode": "US",
"transforms": {
"left": 13,
"top": 4
}
},
{
"name": "Scar",
"id": 1043,
"countryCode": "FR",
"transforms": {
"left": 28,
"top": 4
}
},
{
"name": "Xenith",
"id": 11517,
"countryCode": "US",
"transforms": {
"left": 17,
"top": 4
}
},
{
"name": ".q",
"id": 164,
"countryCode": "US",
"transforms": {
"left": 17,
"top": 4
}
},
{
"name": "Volty",
"id": 32,
"countryCode": "FR",
"transforms": {
"left": 34,
"top": 4
}
}
]
]

View File

@@ -0,0 +1,238 @@
import { addHours } from "date-fns";
import { Config } from "#lib/config.ts";
import { IS_E2E_TEST_RUN } from "#lib/utils/e2e.ts";
/**
* How long past a season's end its matches can still be resolved. A match can be
* created up to the very end of the season and the stale match routine resolves
* it 24h after creation, with up to an hour of scheduling lag on top.
*/
const REPORTING_GRACE_HOURS = 25;
/**
* List of seasons with their respective start and end dates.
*
* Each season is represented as an object with the following properties:
* - `nth`: The sequential number of the season (starting from 0).
* - `starts`: The start date of the season as a `Date` object.
* - `ends`: The end date of the season as a `Date` object.
*
* Note: The value is conditionally set based on the environment. In development mode,
* the end date of the first season is set to a later date for testing purposes (ensures a season is always open).
*/
export const list =
// when we do pnpm run setup NODE_ENV is not set -> use test seasons
!process.env.NODE_ENV ||
IS_E2E_TEST_RUN ||
(process.env.NODE_ENV === "development" && !Config.prodMode)
? ([
{
nth: 0,
starts: new Date("2023-08-14T17:00:00.000Z"),
ends: new Date("2023-08-27T20:59:59.999Z"),
},
{
nth: 1,
starts: new Date("2023-09-11T17:00:00.000Z"),
ends: new Date("2030-11-17T20:59:59.999Z"),
},
] as const)
: ([
{
nth: 0,
starts: new Date("2023-08-14T17:00:00.000Z"),
ends: new Date("2023-08-27T20:59:59.999Z"),
},
{
nth: 1,
starts: new Date("2023-09-11T17:00:00.000Z"),
ends: new Date("2023-11-19T20:59:59.999Z"),
},
{
nth: 2,
starts: new Date("2023-12-04T17:00:00.000Z"),
ends: new Date("2024-02-18T20:59:59.999Z"),
},
{
nth: 3,
starts: new Date("2024-03-04T17:00:00.000Z"),
ends: new Date("2024-05-19T20:59:59.999Z"),
},
{
nth: 4,
starts: new Date("2024-06-03T17:00:00.000Z"),
ends: new Date("2024-08-18T20:59:59.999Z"),
},
{
nth: 5,
starts: new Date("2024-09-02T17:00:00.000Z"),
ends: new Date("2024-11-17T22:59:59.999Z"),
},
{
nth: 6,
starts: new Date("2024-12-02T18:00:00.000Z"),
ends: new Date("2025-02-16T21:59:59.999Z"),
},
{
nth: 7,
starts: new Date("2025-03-07T18:00:00.000Z"),
ends: new Date("2025-05-25T21:59:59.999Z"),
},
{
nth: 8,
starts: new Date("2025-06-16T18:00:00.000Z"),
ends: new Date("2025-08-24T22:00:00.000Z"),
},
{
nth: 9,
starts: new Date("2025-09-08T17:00:00.000Z"),
ends: new Date("2025-11-23T22:00:00.000Z"),
},
{
nth: 10,
starts: new Date("2025-12-08T17:00:00.000Z"),
ends: new Date("2026-02-22T22:00:00.000Z"),
},
{
nth: 11,
starts: new Date("2026-03-09T17:00:00.000Z"),
ends: new Date("2026-05-17T22:00:00.000Z"),
},
{
nth: 12,
starts: new Date("2026-06-01T17:00:00.000Z"),
ends: new Date("2026-08-23T22:00:00.000Z"),
},
] as const);
/**
* Represents an individual item from the `Seasons.list` array.
*/
export type ListItem = (typeof list)[number];
/**
* Determines the current season relative to the provided date (defaults to now), or falls back to the previous season if no current season is found.
*
* @returns The current season if it exists; otherwise, the previous season.
*/
export function currentOrPrevious(date?: Date): ListItem | null {
const _currentSeason = current(date);
if (_currentSeason) return _currentSeason;
return previous(date);
}
/**
* Determines the previous season relative to the provided date (defaults to now).
*
* @returns The previous season if one exists.
*/
export function previous(date = new Date()): ListItem | null {
let latestPreviousSeason: ListItem | null = null;
for (const season of list) {
if (date > season.ends) latestPreviousSeason = season;
}
return latestPreviousSeason;
}
let seasonEndedOverride = false;
/**
* Tests only: makes `current()` resolve to `null` as if every season had ended, so
* tests can cover the season boundary without one having to actually pass. Only
* "now" is affected, an explicitly given date still resolves to its real season.
*/
export function DANGEROUS_setSeasonEndedOverride(seasonEnded: boolean) {
seasonEndedOverride = seasonEnded;
}
/**
* Determines the current ongoing season relative to the provided date (defaults to now).
*
* @returns The current season if one exists.
*/
export function current(date?: Date): ListItem | null {
if (seasonEndedOverride && !date) return null;
const resolvedDate = date ?? new Date();
for (const season of list) {
if (resolvedDate >= season.starts && resolvedDate <= season.ends) {
return season;
}
}
return null;
}
/**
* Determines the next upcoming season relative to the provided date (defaults to now).
*
* @returns The next season if one exists.
*/
export function next(date = new Date()): ListItem | null {
for (const season of list) {
if (date < season.starts) return season;
}
return null;
}
/**
* Retrieves the date range for a specific season based on its number.
*
* @returns An object containing the start and end dates of the specified season.
* @throws {Error} If the season does not exist.
*/
export function nthToDateRange(nth: number) {
const seasonObject = list[nth];
if (!seasonObject) {
throw new Error(`Season ${nth} not found`);
}
return {
starts: seasonObject.starts,
ends: seasonObject.ends,
};
}
/**
* Retrieves the date range within which a season's results can land, i.e. the season
* itself plus the grace period matches started at the buzzer are resolved within.
*
* @returns An object containing the start date of the specified season and the end of its reporting grace period.
* @throws {Error} If the season does not exist.
*/
export function nthToReportingDateRange(nth: number) {
const { starts, ends } = nthToDateRange(nth);
return {
starts,
ends: addHours(ends, REPORTING_GRACE_HOURS),
};
}
/**
* Retrieves a list of season numbers that have started based on the provided date (defaults to now).
*
* @returns An array of season numbers in descending order (newest first). If no seasons have started, returns an array containing only `[0]`.
*/
export function allStarted(date = new Date()) {
const startedSeasons = list.filter((s) => date >= s.starts);
if (startedSeasons.length > 0) {
return startedSeasons.map((s) => s.nth).reverse();
}
return [0];
}
/**
* Retrieves a list of season numbers that have finished based on the provided date (defaults to now).
*
* @returns An array of season numbers in descending order. If no seasons have finished, returns an empty array.
*/
export function allFinished(date = new Date()) {
const finishedSeasons = list.filter((s) => date > s.ends);
return finishedSeasons.map((s) => s.nth).reverse();
}

View File

@@ -0,0 +1,11 @@
import { db } from "#lib/server/db/sql.ts";
import { latestSkillPerSeason } from "#lib/server/kysely.ts";
/** Every user's latest skill row of the season, ordered by ordinal descending. */
export async function findOrderedUserOrdinalsBySeason(season: number) {
return db
.selectFrom(latestSkillPerSeason({ season, by: "userId" }).as("latest"))
.select(["ordinal", "matchesCount", "userId"])
.orderBy("ordinal", "desc")
.execute();
}

View File

@@ -0,0 +1,26 @@
export const TIERS = [
{ name: "LEVIATHAN", percentile: 5 },
{ name: "DIAMOND", percentile: 10 },
{ name: "PLATINUM", percentile: 15 },
{ name: "GOLD", percentile: 17.5 },
{ name: "SILVER", percentile: 20 },
{ name: "BRONZE", percentile: 17.5 },
{ name: "IRON", percentile: 15 },
] as const;
export const TIERS_BEFORE_LEVIATHAN = [
{ name: "DIAMOND", percentile: 15 },
{ name: "PLATINUM", percentile: 15 },
{ name: "GOLD", percentile: 17.5 },
{ name: "SILVER", percentile: 20 },
{ name: "BRONZE", percentile: 17.5 },
{ name: "IRON", percentile: 15 },
] as const;
export type TierName = (typeof TIERS)[number]["name"];
/** `Skill.season` of the dummy skill inserted to close a canceled match without a skill change. */
export const CANCELED_MATCH_SEASON = -1;
export const USER_LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN = 200;
export const TEAM_LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN = 100;

View File

@@ -0,0 +1,38 @@
import invariant from "@sendou/utils/invariant";
import { TIERS, type TierName } from "./mmr-constants.ts";
/** Converts an openskill ordinal to the user-facing SP value (two decimals). */
export function ordinalToSp(ordinal: number) {
return toTwoDecimals(ordinal * 15 + 1000);
}
/** Converts an openskill ordinal to the user-facing SP value rounded to an integer. */
export function ordinalToRoundedSp(ordinal: number) {
return Math.round(ordinalToSp(ordinal));
}
function toTwoDecimals(value: number) {
return Number(value.toFixed(2));
}
/** The four user ids of a full team, ascending and joined by `-`. Identifies a team across matches. */
export type SkillTeamIdentifier = `${number}-${number}-${number}-${number}`;
/** Builds the {@link SkillTeamIdentifier} of the given four user ids. */
export function userIdsToIdentifier(userIds: number[]): SkillTeamIdentifier {
invariant(userIds.length === 4, "userIds for identifier must be length 4");
return [...userIds].sort((a, b) => a - b).join("-") as SkillTeamIdentifier;
}
/** Splits a {@link SkillTeamIdentifier} back into its user ids. */
export function identifierToUserIds(identifier: SkillTeamIdentifier) {
return identifier.split("-").map(Number);
}
/** Compares two tiers by rank; negative when `tier1` is higher. */
export function compareTwoTiers(tier1: TierName, tier2: TierName) {
return (
TIERS.findIndex(({ name }) => name === tier1) -
TIERS.findIndex(({ name }) => name === tier2)
);
}

View File

@@ -0,0 +1,148 @@
import { cachified } from "@epic-web/cachified";
import { cache } from "#lib/server/cache.ts";
import type { Tables } from "#lib/server/db/tables.ts";
import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "../leaderboards/leaderboards-constants.ts";
import {
TEAM_LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN,
TIERS,
TIERS_BEFORE_LEVIATHAN,
type TierName,
USER_LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN,
} from "./mmr-constants.ts";
import * as SkillRepository from "./SkillRepository.server.ts";
const USER_SKILLS_CACHE_KEY = "user-skills";
export interface TieredSkill {
ordinal: number;
tier: {
name: TierName;
isPlus: boolean;
};
approximate: boolean;
}
export async function freshUserSkills(season: number): Promise<{
userSkills: Record<string, TieredSkill>;
intervals: SkillTierInterval[];
isAccurateTiers: boolean;
}> {
const points = await SkillRepository.findOrderedUserOrdinalsBySeason(season);
const { intervals, isAccurateTiers } = skillTierIntervals(points, "user");
return {
intervals,
isAccurateTiers,
userSkills: Object.fromEntries(
points.map((p) => {
const { name, isPlus } = intervals.find(
(t) => t.neededOrdinal! <= p.ordinal,
) ?? { name: "IRON", isPlus: false };
return [
p.userId as number,
{
ordinal: p.ordinal,
tier: { name, isPlus },
approximate: p.matchesCount < MATCHES_COUNT_NEEDED_FOR_LEADERBOARD,
},
];
}),
),
};
}
const userSkillsCacheKey = (season: number) =>
`${USER_SKILLS_CACHE_KEY}-${season}`;
export function userSkills(season: number, { forceFresh = false } = {}) {
return cachified({
key: userSkillsCacheKey(season),
cache,
forceFresh,
// no ttl: a season's tiers only go stale when a match of it is played,
// and those code paths refresh this themselves
getFreshValue: () => freshUserSkills(season),
});
}
export async function refreshUserSkills(season: number) {
await userSkills(season, { forceFresh: true });
}
export type SkillTierInterval = ReturnType<
typeof skillTierIntervals
>["intervals"][number];
function skillTierIntervals(
orderedPoints: Array<Pick<Tables["Skill"], "ordinal" | "matchesCount">>,
type: "user" | "team",
) {
const LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN =
type === "user"
? USER_LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN
: TEAM_LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN;
let points = orderedPoints.filter(
(p) => p.matchesCount >= MATCHES_COUNT_NEEDED_FOR_LEADERBOARD,
);
const hasLeviathan = points.length >= LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN;
if (!hasLeviathan) {
// using all entries, no matter if they have enough to be on the leaderboard
// to create the tiers
points = orderedPoints;
}
const totalPlayers = points.length;
const tiersToUse = hasLeviathan ? TIERS : TIERS_BEFORE_LEVIATHAN;
const allTiers = tiersToUse.flatMap((tier) =>
[true, false].map((isPlus) => ({
...tier,
isPlus,
percentile: tier.percentile / 2,
})),
);
const result: Array<{
name: TierName;
isPlus: boolean;
/** inclusive */
neededOrdinal?: number;
}> = [
{
name: tiersToUse[0].name,
isPlus: true,
},
];
if (points.length === 1) {
result[0].neededOrdinal = points[0].ordinal;
return { intervals: result, isAccurateTiers: hasLeviathan };
}
let previousPercentiles = 0;
for (let i = 0; i < points.length; i++) {
const currentTier = allTiers[result.length - 1];
const currentPercentile = ((i + 1) / totalPlayers) * 100;
// "isPlus" is top 50% of that tier
const accPercentile = previousPercentiles + currentTier.percentile;
if (currentPercentile > accPercentile) {
// with few enough players the very first one already exceeds the top
// tier's share, and there is nobody below them to close the tier at
const previousPoints = points[i - 1] ?? points[i];
const thisTier = result[result.length - 1];
thisTier.neededOrdinal = previousPoints.ordinal;
const newTier = allTiers[result.length];
result.push({
name: newTier.name,
isPlus: newTier.isPlus,
});
previousPercentiles = accPercentile;
}
}
return { intervals: result, isAccurateTiers: hasLeviathan };
}

View File

@@ -0,0 +1,36 @@
/** CSS variables a supporter can override with their own custom theme. */
export const CUSTOM_THEME_VARS = [
"--_base-h",
"--_base-c-0",
"--_base-c-1",
"--_base-c-2",
"--_base-c-3",
"--_base-c-4",
"--_base-c-5",
"--_base-c-6",
"--_base-c-7",
"--_acc-h",
"--_acc-c-0",
"--_acc-c-1",
"--_acc-c-2",
"--_acc-c-3",
"--_acc-c-4",
"--_acc-c-5",
"--_second-h",
"--_second-c-0",
"--_second-c-1",
"--_second-c-2",
"--_second-c-3",
"--_second-c-4",
"--_second-c-5",
"--_chat-h",
"--_radius-box",
"--_radius-field",
"--_radius-selector",
"--_border-width",
"--_size-field",
"--_size-selector",
"--_size-spacing",
] as const;
export type CustomThemeVar = (typeof CUSTOM_THEME_VARS)[number];

View File

@@ -0,0 +1,5 @@
import type { CustomThemeVar } from "./theme-constants.ts";
export type CustomTheme = Omit<Record<CustomThemeVar, number>, "--_chat-h"> & {
"--_chat-h": number | null;
};

View File

@@ -0,0 +1,50 @@
import type { Cookies } from "@sveltejs/kit";
import {
readSessionCookie,
writeSessionCookie,
} from "#lib/features/auth/session.server.ts";
import { ServerConfig } from "#lib/server/config.ts";
import { IS_E2E_TEST_RUN } from "#lib/utils/e2e.ts";
/**
* The theme cookie, byte-compatible with the React app's signed `theme`
* session cookie so user theme choices survive the cutover.
*/
export const THEME_COOKIE_NAME = "theme";
const TEN_YEARS_IN_SECONDS = 315_360_000;
export type Theme = "dark" | "light";
export function isTheme(value: unknown): value is Theme {
return value === "dark" || value === "light";
}
const themeCookieOptions = {
secure: ServerConfig.isProduction && !IS_E2E_TEST_RUN,
sameSite: "lax",
path: "/",
httpOnly: true,
maxAge: TEN_YEARS_IN_SECONDS,
} as const;
/** The user's explicitly chosen theme, or `null` when following the system theme. */
export function getTheme(cookies: Cookies): Theme | null {
const session = readSessionCookie(cookies.get(THEME_COOKIE_NAME));
const themeValue = session?.theme;
return isTheme(themeValue) ? themeValue : null;
}
export function setTheme(cookies: Cookies, theme: Theme) {
cookies.set(
THEME_COOKIE_NAME,
writeSessionCookie({ theme }),
themeCookieOptions,
);
}
export function clearTheme(cookies: Cookies) {
cookies.delete(THEME_COOKIE_NAME, { path: "/" });
}

View File

@@ -0,0 +1,79 @@
import * as R from "remeda";
import { userRoles } from "#lib/modules/permissions/mapper.server.ts";
import { db } from "#lib/server/db/sql.ts";
import { commonUserSelect } from "#lib/server/kysely.ts";
/**
* The lean user representation used for the authenticated user: common fields
* plus the columns the app shell and permission checks need.
*/
export async function findLeanById(id: number) {
const user = await db
.selectFrom("User")
.leftJoin("PlusTier", "PlusTier.userId", "User.id")
.where("User.id", "=", id)
.select(({ eb }) => [
...commonUserSelect(eb),
"User.createdAt",
"User.customTheme",
"User.isArtist",
"User.isVideoAdder",
"User.isTournamentOrganizer",
"User.isApiAccesser",
"User.patronTier",
"User.languages",
"User.inGameName",
"User.preferences",
"PlusTier.tier as plusTier",
eb
.selectFrom("UserFriendCode")
.select("UserFriendCode.friendCode")
.where("UserFriendCode.userId", "=", id)
.orderBy("UserFriendCode.createdAt", "desc")
.limit(1)
.as("friendCode"),
])
.executeTakeFirst();
if (!user) return;
return {
...R.omit(user, [
"isArtist",
"isVideoAdder",
"isTournamentOrganizer",
"isApiAccesser",
]),
roles: userRoles(user),
};
}
/** Every user in the plus server with their tier. */
export function findAllPlusServerMembers() {
return db
.selectFrom("User")
.innerJoin("PlusTier", "PlusTier.userId", "User.id")
.select([
"User.id as userId",
"User.discordId",
"PlusTier.tier as plusTier",
])
.execute();
}
/** Users that currently have an active ban, keyed by user id. */
export async function findAllBannedUsers() {
const rows = await db
.selectFrom("User")
.select(["User.id as userId", "User.banned", "User.bannedReason"])
.where("User.banned", "!=", 0)
.execute();
const result: Map<number, (typeof rows)[number]> = new Map();
for (const row of rows) {
result.set(row.userId, row);
}
return result;
}

View File

@@ -0,0 +1,41 @@
export const DEFAULT_LANGUAGE = "en";
export const languages = [
{ code: "da", name: "Dansk" },
{ code: "de", name: "Deutsch" },
{ code: "en", name: "English" },
{ code: "es-ES", name: "Español (España)" },
{ code: "es-US", name: "Español (Latino)" },
{ code: "fr-CA", name: "Français (NoA)" },
{ code: "fr-EU", name: "Français (NoE)" },
{ code: "he", name: "עברית" },
{ code: "it", name: "Italiano" },
{ code: "ja", name: "日本語" },
{ code: "ko", name: "한국어" },
{ code: "pl", name: "Polski" },
{ code: "pt-BR", name: "Português do Brasil" },
{ code: "nl", name: "Nederlands" },
{ code: "ru", name: "Русский" },
{ code: "zh", name: "中文" },
] as const;
export const languagesUnified = [
{ code: "da", name: "Dansk" },
{ code: "de", name: "Deutsch" },
{ code: "en", name: "English" },
{ code: "es", name: "Español" },
{ code: "fr", name: "Français" },
{ code: "he", name: "עברית" },
{ code: "it", name: "Italiano" },
{ code: "ja", name: "日本語" },
{ code: "ko", name: "한국어" },
{ code: "pl", name: "Polski" },
{ code: "pt", name: "Português" },
{ code: "nl", name: "Nederlands" },
{ code: "ru", name: "Русский" },
{ code: "zh", name: "中文(普通话)" },
{ code: "yue", name: "中文(廣東話)" },
] as const;
export type LanguageCode = (typeof languages)[number]["code"];
export type UnifiedLanguageCode = (typeof languagesUnified)[number]["code"];

View File

@@ -0,0 +1,12 @@
import { error } from "@sveltejs/kit";
import { getUser } from "#lib/features/auth/user.server.ts";
import type { Role } from "./types.ts";
/** Responds with 403 unless the logged in user has the given role. */
export function requireRole(role: Role) {
const user = getUser();
if (!user?.roles.includes(role)) {
error(403, "Forbidden");
}
}

View File

@@ -0,0 +1,78 @@
import { userDiscordIdIsAged } from "#lib/utils/users.ts";
import type { Role } from "./types.ts";
import {
isAdmin,
isDev,
isQa,
isScannerTester,
isStaff,
isSupporter,
} from "./utils.ts";
/** Derives the user's global roles from their user row (joined with `PlusTier`). */
export function userRoles(user: {
id: number;
discordId: string;
plusTier: number | null;
isArtist: number;
isTournamentOrganizer: number;
isVideoAdder: number;
isApiAccesser: number;
patronTier: number | null;
}) {
const result: Array<Role> = [];
if (isAdmin(user)) {
result.push("ADMIN");
}
if (isStaff(user) || isAdmin(user)) {
result.push("STAFF");
}
if (isDev(user)) {
result.push("DEV");
}
if (isQa(user)) {
result.push("QA");
}
if (isScannerTester(user)) {
result.push("SCANNER_TESTER");
}
if (typeof user.patronTier === "number") {
result.push("MINOR_SUPPORT");
}
if (isSupporter(user)) {
result.push("SUPPORTER");
}
if (typeof user.plusTier === "number") {
result.push("PLUS_SERVER_MEMBER");
}
if (user.isArtist) {
result.push("ARTIST");
}
if (user.isVideoAdder) {
result.push("VIDEO_ADDER");
}
if (user.isTournamentOrganizer || isSupporter(user)) {
result.push("TOURNAMENT_ADDER");
}
if (userDiscordIdIsAged(user) || isSupporter(user)) {
result.push("CALENDAR_EVENT_ADDER");
}
if (user.isTournamentOrganizer || user.isApiAccesser || isSupporter(user)) {
result.push("API_ACCESSER");
}
return result;
}

View File

@@ -0,0 +1,21 @@
export type Permissions = Record<string, number[]>;
export type EntityWithPermissions = {
permissions: Permissions;
};
/** Represents a "global" role with permissions associated to it */
export type Role =
| "ADMIN"
| "STAFF"
| "PLUS_SERVER_MEMBER"
| "VIDEO_ADDER"
| "ARTIST"
| "CALENDAR_EVENT_ADDER"
| "TOURNAMENT_ADDER"
| "API_ACCESSER"
| "QA"
| "DEV"
| "SCANNER_TESTER"
| "SUPPORTER" // patrons of "Supporter" tier or higher
| "MINOR_SUPPORT"; // patrons of "Support" tier or higher

View File

@@ -0,0 +1,66 @@
import {
ADMIN_ID,
DEV_IDS,
QA_IDS,
SCANNER_TESTER_IDS,
STAFF_IDS,
} from "#lib/features/admin/admin-constants.ts";
import { IS_E2E_TEST_RUN } from "#lib/utils/e2e.ts";
import type { EntityWithPermissions } from "./types.ts";
/**
* Determines whether a user has a specific permission for a given entity.
* Single source of truth shared by `requirePermission` and `useHasPermission`.
*
* @returns A boolean indicating whether the user has the specified permission. Always false if user is not logged in.
*/
export function hasPermission<
T extends EntityWithPermissions,
K extends keyof T["permissions"],
>(obj: T, permission: K, user?: { id: number } | null) {
if (!user) return false;
// admin can do anything in production but not in development or e2e tests for better testing
if (
typeof process !== "undefined" &&
process.env.NODE_ENV === "production" &&
!IS_E2E_TEST_RUN &&
isAdmin(user)
) {
return true;
}
return (obj.permissions as Record<K, number[]>)[permission].includes(user.id);
}
export function isAdmin(user?: { id: number }) {
return user?.id === ADMIN_ID;
}
export function isStaff(user?: { id: number }) {
if (!user) return false;
return STAFF_IDS.includes(user.id);
}
export function isDev(user?: { id: number }) {
if (!user) return false;
return DEV_IDS.includes(user.id);
}
export function isQa(user?: { id: number }) {
if (!user) return false;
return QA_IDS.includes(user.id);
}
export function isScannerTester(user?: { id: number }) {
if (!user) return false;
return SCANNER_TESTER_IDS.includes(user.id);
}
export function isSupporter(user?: { patronTier: number | null }) {
return typeof user?.patronTier === "number" && user.patronTier >= 2;
}

View File

@@ -0,0 +1,80 @@
import type { CacheEntry } from "@epic-web/cachified";
import { ServerConfig } from "#lib/server/config.ts";
/**
* A lightweight least-recently-used cache backed by a `Map`. Once `max` entries
* are stored, inserting a new key evicts the least recently used one. Reading a
* key via `get` marks it as most recently used.
*/
export class LRUCache<K, V> {
private readonly max: number;
private readonly map = new Map<K, V>();
constructor({ max }: { max: number }) {
this.max = max;
}
get(key: K): V | undefined {
if (!this.map.has(key)) return undefined;
const value = this.map.get(key) as V;
this.map.delete(key);
this.map.set(key, value);
return value;
}
has(key: K): boolean {
return this.map.has(key);
}
set(key: K, value: V): void {
if (this.map.has(key)) {
this.map.delete(key);
}
this.map.set(key, value);
if (this.map.size > this.max) {
const oldest = this.map.keys().next().value as K;
this.map.delete(oldest);
}
}
delete(key: K): void {
this.map.delete(key);
}
clear(): void {
this.map.clear();
}
}
declare global {
// This preserves the LRU cache during development
var __lruCache: LRUCache<string, CacheEntry<unknown>> | undefined;
}
export const cache = (global.__lruCache = global.__lruCache
? global.__lruCache
: new LRUCache<string, CacheEntry<unknown>>({ max: 5000 }));
export const ttl = (ms: number) => (ServerConfig.disableCache ? 0 : ms);
export function syncCached<T>(key: string, getFreshValue: () => T) {
if (cache.has(key)) {
return cache.get(key) as T;
}
const value = getFreshValue();
cache.set(key, value as CacheEntry<unknown>);
return value;
}
export const IN_MILLISECONDS = {
HALF_HOUR: 30 * 60 * 1000,
ONE_HOUR: 60 * 60 * 1000,
TWO_HOURS: 2 * 60 * 60 * 1000,
TWO_DAYS: 2 * 24 * 60 * 60 * 1000,
};

View File

@@ -0,0 +1,111 @@
import { IS_E2E_TEST_RUN } from "#lib/utils/e2e.ts";
/**
* Server (`process.env`) configuration. Import with
* `import { ServerConfig } from "#lib/server/config.ts"` and read values like
* `ServerConfig.dbPath`.
*
* Values are validated once when this module is first imported. Variables
* required in production fall back to development defaults outside of
* production.
*/
const isProd = process.env.NODE_ENV === "production" && !IS_E2E_TEST_RUN;
const TRUTHY_STRINGS = ["true", "1", "yes", "on", "y", "enabled"];
const FALSY_STRINGS = ["false", "0", "no", "off", "n", "disabled"];
const issues: Array<{ name: string; message: string }> = [];
const values = {
NODE_ENV: process.env.NODE_ENV ?? "development",
DB_PATH: requiredInProd("DB_PATH", "db.sqlite3"),
SESSION_SECRET: requiredInProd("SESSION_SECRET", "secret"),
SQL_LOG: oneOf("SQL_LOG", ["none", "trunc", "full"] as const, "none"),
DISABLE_CACHE: stringBool("DISABLE_CACHE"),
};
if (issues.length > 0) {
throw envError(issues);
}
export const ServerConfig = {
/**
* Whether `NODE_ENV` is `"production"`. Note: this is `true` during e2e tests
* (which run a production build), so combine it with `IS_E2E_TEST_RUN` when
* you specifically need to exclude the e2e environment (as the session
* cookies do).
*/
isProduction: values.NODE_ENV === "production",
/** Whether the app is running under the test runner. */
isTest: values.NODE_ENV === "test",
/** Path to the SQLite database file. */
dbPath: values.DB_PATH,
/** Secret used to sign session cookies. */
sessionSecret: values.SESSION_SECRET,
/** SQL query logging level. */
sqlLog: values.SQL_LOG,
/** Whether response caching is disabled. */
disableCache: values.DISABLE_CACHE,
};
function requiredInProd(name: string, devFallback: string): string {
const value = process.env[name];
if (!isProd) {
return value ?? devFallback;
}
if (value === undefined) {
issues.push({ name, message: "required in production" });
return "";
}
if (value.length === 0) {
issues.push({ name, message: "required in production (cannot be empty)" });
return "";
}
return value;
}
function oneOf<T extends string>(
name: string,
options: readonly T[],
defaultValue: T,
): T {
const value = process.env[name];
if (value === undefined) return defaultValue;
if ((options as readonly string[]).includes(value)) return value as T;
issues.push({
name,
message: `must be one of ${options.join(", ")}, got "${value}"`,
});
return defaultValue;
}
function stringBool(name: string): boolean {
const value = process.env[name];
if (value === undefined) return false;
const normalized = value.toLowerCase();
if (TRUTHY_STRINGS.includes(normalized)) return true;
if (FALSY_STRINGS.includes(normalized)) return false;
issues.push({
name,
message: `must be a boolean-like string (e.g. "true" or "false"), got "${value}"`,
});
return false;
}
function envError(issues: Array<{ name: string; message: string }>): Error {
const lines = issues.map((issue) => ` - ${issue.name}: ${issue.message}`);
return new Error(
`Invalid server environment configuration:\n${lines.join(
"\n",
)}\n\nSee .env.example for the full list of variables and how to set them.`,
);
}

View File

@@ -0,0 +1,58 @@
import {
type InsertQueryNode,
type KyselyPlugin,
type PluginTransformQueryArgs,
type PluginTransformResultArgs,
type QueryResult,
RawNode,
type RootOperationNode,
SelectionNode,
SelectQueryNode,
type UnknownRow,
ValuesNode,
WhereNode,
} from "kysely";
/**
* Makes inserting an empty array of values a no-op instead of a syntax error.
* Kysely compiles `.values([])` into invalid SQL, so without this plugin every
* dynamic multi-row insert would need a length check before it. The empty
* insert is rewritten into `INSERT INTO "T" SELECT * FROM "T" WHERE 0` which
* inserts zero rows and returns zero rows for any `returning` clause.
*/
export class EmptyValuesNoopPlugin implements KyselyPlugin {
transformQuery(args: PluginTransformQueryArgs): RootOperationNode {
if (args.node.kind !== "InsertQueryNode" || !isEmptyInsert(args.node)) {
return args.node;
}
const { columns: _columns, ...node } = args.node;
return Object.freeze({
...node,
values: selectNothingFrom(args.node),
});
}
async transformResult(
args: PluginTransformResultArgs,
): Promise<QueryResult<UnknownRow>> {
return args.result;
}
}
function isEmptyInsert(node: InsertQueryNode) {
return (
node.values !== undefined &&
ValuesNode.is(node.values) &&
node.values.values.length === 0
);
}
function selectNothingFrom(node: InsertQueryNode): SelectQueryNode {
return Object.freeze({
...SelectQueryNode.createFrom([node.into!]),
selections: Object.freeze([SelectionNode.createSelectAll()]),
where: WhereNode.create(RawNode.createWithSql("0")),
});
}

View File

@@ -0,0 +1,51 @@
/**
* Every "Table.column" whose text content is a JSON document. The node-sqlite
* dialect parses only these columns (plus computed expression columns) when
* reading rows; all other text columns stay plain strings even if a user typed
* something JSON-shaped into them. Kept in sync with the JSONColumnType
* declarations of tables.ts by json-columns.test.ts.
*/
export const JSON_COLUMNS: ReadonlySet<string> = new Set([
"AllTeam.customTheme",
"AllTeam.mapModePreferences",
"Build.abilities",
"Build.modes",
"CalendarEvent.tags",
"GroupMatch.memento",
"IngestedMatch.data",
"LFGPost.languages",
"Notification.meta",
"NotificationUserSubscription.subscription",
"ScrimPost.visibility",
"SplatoonPlayer.peakXp",
"Team.customTheme",
"Team.mapModePreferences",
"Tournament.castTwitchAccounts",
"Tournament.castedMatchesInfo",
"Tournament.preparedMaps",
"Tournament.seedingSnapshot",
"Tournament.settings",
"TournamentAuditLog.metadata",
"TournamentMatch.opponentOne",
"TournamentMatch.opponentTwo",
"TournamentOrganization.socials",
"TournamentOrganizationSeries.substringMatches",
"TournamentOrganizationSeries.tierHistory",
"TournamentResult.setResults",
"TournamentRound.maps",
"TournamentStage.settings",
"TournamentTeam.activeRosterUserIds",
"User.buildSorting",
"User.customTheme",
"User.favoriteBadgeIds",
"User.favoriteTrophyIds",
"User.hiddenCardStats",
"User.hiddenTrophyIds",
"User.languages",
"User.mapModePreferences",
"User.preferences",
"User.pronouns",
"User.unverifiedPeakXP",
"User.weaponPool",
"UserWidget.widget",
]);

View File

@@ -0,0 +1,153 @@
import {
AggregateFunctionNode,
AliasNode,
ColumnNode,
FunctionNode,
IdentifierNode,
type OperationNode,
RawNode,
ReferenceNode,
type RootOperationNode,
SelectQueryNode,
} from "kysely";
import { JSON_COLUMNS } from "./json-columns";
/** SQL shapes {@link jsonValuedNode} recognizes: the subqueries the json helpers emit and direct `json*(` function calls (e.g. `jsonBuildObject`, `json_set`). */
const JSON_EXPRESSION_PREFIX =
/^\s*(\(select\s+(coalesce\()?)?json(_\w+)?\s*\(/i;
const NO_NAMES: ReadonlySet<string> = new Set();
/** Output names of the query's computed result columns whose value is a JSON document, e.g. the subquery a `jsonArrayFrom` selection compiles to. */
export function computedJsonColumns(
query: RootOperationNode,
): ReadonlySet<string> {
// only select queries have computed result columns: `returning` selections keep
// the origin metadata of the column they write to
return SelectQueryNode.is(query) ? outputNames(query) : NO_NAMES;
}
/** Output name a selection comes back under, or `undefined` for selections that have none (`selectAll()`). */
export function selectionOutputName(
selection: OperationNode,
): string | undefined {
if (ReferenceNode.is(selection) && ColumnNode.is(selection.column)) {
return selection.column.column.name;
}
if (ColumnNode.is(selection)) {
return selection.column.name;
}
if (AliasNode.is(selection) && IdentifierNode.is(selection.alias)) {
return selection.alias.name;
}
return undefined;
}
/** Whether a select list entry resolves to a JSON document. */
export function jsonValuedSelection(
selection: OperationNode,
sources?: SourceOutputNames,
): boolean {
if (AliasNode.is(selection)) return jsonValuedNode(selection.node, sources);
return jsonValuedReference(selection, sources);
}
/** Whether an expression resolves to a JSON document. */
export function jsonValuedNode(
node: OperationNode,
sources?: SourceOutputNames,
): boolean {
if (RawNode.is(node)) {
return JSON_EXPRESSION_PREFIX.test(node.sqlFragments[0] ?? "");
}
if (AggregateFunctionNode.is(node) || FunctionNode.is(node)) {
return node.func.startsWith("json");
}
if (SelectQueryNode.is(node)) {
const selections = node.selections ?? [];
return (
selections.length === 1 &&
jsonValuedSelection(selections[0].selection, sourceOutputNames(node))
);
}
return jsonValuedReference(node, sources);
}
/** JSON-valued output names of the derived tables and CTEs a query selects from, by the name they are visible under. */
type SourceOutputNames = ReadonlyMap<string, ReadonlySet<string>>;
function outputNames(select: SelectQueryNode): ReadonlySet<string> {
const sources = sourceOutputNames(select);
const names = new Set<string>();
for (const { selection } of select.selections ?? []) {
const name = selectionOutputName(selection);
if (name && jsonValuedSelection(selection, sources)) {
names.add(name);
}
}
// a compound select is named after its first branch, but any branch can be the
// one contributing the JSON document
for (const { expression } of select.setOperations ?? []) {
if (!SelectQueryNode.is(expression)) continue;
for (const name of outputNames(expression)) {
names.add(name);
}
}
return names;
}
function sourceOutputNames(select: SelectQueryNode): SourceOutputNames {
const sources = new Map<string, ReadonlySet<string>>();
for (const cte of select.with?.expressions ?? []) {
if (!SelectQueryNode.is(cte.expression)) continue;
sources.set(
cte.name.table.table.identifier.name,
outputNames(cte.expression),
);
}
const tables = [
...(select.from?.froms ?? []),
...(select.joins ?? []).map((join) => join.table),
];
for (const table of tables) {
if (
!AliasNode.is(table) ||
!SelectQueryNode.is(table.node) ||
!IdentifierNode.is(table.alias)
) {
continue;
}
sources.set(table.alias.name, outputNames(table.node));
}
return sources;
}
function jsonValuedReference(node: OperationNode, sources?: SourceOutputNames) {
if (
!ReferenceNode.is(node) ||
!ColumnNode.is(node.column) ||
node.table === undefined
) {
return false;
}
const table = node.table.table.identifier.name;
const column = node.column.column.name;
return (
JSON_COLUMNS.has(`${table}.${column}`) ||
Boolean(sources?.get(table)?.has(column))
);
}

View File

@@ -0,0 +1,456 @@
import type {
DatabaseSync,
SQLInputValue,
SQLOutputValue,
StatementSync,
} from "node:sqlite";
import {
CompiledQuery,
createQueryId,
type DatabaseConnection,
type Dialect,
type Driver,
IdentifierNode,
type Kysely,
type QueryCompiler,
type QueryResult,
RawNode,
type RootOperationNode,
SelectQueryNode,
SqliteAdapter,
SqliteIntrospector,
SqliteQueryCompiler,
} from "kysely";
/**
* Query kinds whose compiled SQL is stable enough to keep a prepared statement
* around for. Everything else (DDL, raw SQL, `begin`/`commit`) is prepared fresh.
*/
const CACHEABLE_QUERY_KINDS = new Set([
"SelectQueryNode",
"InsertQueryNode",
"UpdateQueryNode",
"DeleteQueryNode",
"MergeQueryNode",
]);
/**
* Leading keywords of raw statements that can not change the schema, so the
* column lists the statement cache is holding stay valid across them. Raw DDL
* (`create`, `alter`, `drop`, ...) is not here and clears the cache.
*/
const SCHEMA_PRESERVING_RAW_COMMANDS = new Set([
"begin",
"commit",
"rollback",
"savepoint",
"release",
"select",
"with",
"insert",
"update",
"delete",
"replace",
"pragma",
"analyze",
"explain",
]);
const STATEMENT_CACHE_SIZE = 5000;
const NO_JSON_OUTPUT_NAMES: ReadonlySet<string> = new Set();
export interface NodeSqliteDialectConfig {
database: DatabaseSync;
/**
* Keeps prepared statements around between queries, keyed by their SQL. Saves
* a re-compile per query at the cost of holding onto the compiled programs.
* Off by default because it assumes the schema does not change under the
* connection, which is not true while migrations run.
*/
cacheStatements?: boolean;
/**
* "Table.column" names whose text content is a JSON document. When given,
* result values of these columns are parsed into objects. Other text columns
* are always returned verbatim, so JSON-shaped user input stays a string.
* Origin metadata from `statement.columns()` sees through aliases, views,
* subqueries and CTEs, so the names here are the underlying table names
* (e.g. `AllTeam`, not the `Team` view).
*/
jsonColumns?: ReadonlySet<string>;
/**
* Output names of a query's computed result columns whose value is a JSON
* document, which is what `jsonArrayFrom`/`jsonObjectFrom` subqueries compile
* to. SQLite reports no origin for a computed expression, so only the query's
* own AST tells those apart from an ordinary `coalesce(...)` over user text.
* Called once per prepared statement. Requires {@link jsonColumns}.
*/
computedJsonColumns?: (query: RootOperationNode) => ReadonlySet<string>;
}
/**
* Kysely dialect backed by Node's built-in `node:sqlite` module, replacing the
* `better-sqlite3` native addon that Kysely's own `SqliteDialect` expects.
*
* Rows come back from `node:sqlite` as arrays rather than objects: the objects it
* builds itself are both slower to produce and have a `null` prototype, which is
* not what the rest of the codebase (or Kysely's own dialects) hand out.
*/
export class NodeSqliteDialect implements Dialect {
readonly #config: NodeSqliteDialectConfig;
constructor(config: NodeSqliteDialectConfig) {
this.#config = config;
}
createDriver(): Driver {
return new NodeSqliteDriver(this.#config);
}
createQueryCompiler(): QueryCompiler {
return new SqliteQueryCompiler();
}
createAdapter(): SqliteAdapter {
return new SqliteAdapter();
}
createIntrospector(db: Kysely<any>): SqliteIntrospector {
return new SqliteIntrospector(db);
}
}
class NodeSqliteDriver implements Driver {
readonly #config: NodeSqliteDialectConfig;
#connection?: NodeSqliteConnection;
constructor(config: NodeSqliteDialectConfig) {
this.#config = config;
}
async init(): Promise<void> {
this.#connection = new NodeSqliteConnection(this.#config);
}
async acquireConnection(): Promise<DatabaseConnection> {
return this.#connection!;
}
async beginTransaction(connection: DatabaseConnection): Promise<void> {
await connection.executeQuery(CompiledQuery.raw("begin"));
}
async commitTransaction(connection: DatabaseConnection): Promise<void> {
await connection.executeQuery(CompiledQuery.raw("commit"));
}
async rollbackTransaction(connection: DatabaseConnection): Promise<void> {
await connection.executeQuery(CompiledQuery.raw("rollback"));
}
async savepoint(
connection: DatabaseConnection,
savepointName: string,
compileQuery: QueryCompiler["compileQuery"],
): Promise<void> {
await connection.executeQuery(
compileQuery(
savepointCommand("savepoint", savepointName),
createQueryId(),
),
);
}
async rollbackToSavepoint(
connection: DatabaseConnection,
savepointName: string,
compileQuery: QueryCompiler["compileQuery"],
): Promise<void> {
await connection.executeQuery(
compileQuery(
savepointCommand("rollback to", savepointName),
createQueryId(),
),
);
}
async releaseSavepoint(
connection: DatabaseConnection,
savepointName: string,
compileQuery: QueryCompiler["compileQuery"],
): Promise<void> {
await connection.executeQuery(
compileQuery(savepointCommand("release", savepointName), createQueryId()),
);
}
async releaseConnection(): Promise<void> {
// the single connection is never handed back to a pool
}
async destroy(): Promise<void> {
this.#connection?.dispose();
this.#config.database.close();
}
}
/** Which result columns of one query hold a JSON document: by column origin, and by output name for the columns that have no origin. */
interface JsonColumns {
byOrigin: ReadonlySet<string>;
byOutputName: ReadonlySet<string>;
}
interface PreparedStatement {
statement: StatementSync;
/** Empty for statements that return no rows, which is how writes are detected. */
columnNames: string[];
/** Per result column: parse text values as JSON when building rows. */
jsonColumnFlags: boolean[];
/** Kept for re-deriving the flags when the column list turns out to be stale. */
jsonColumns?: JsonColumns;
}
class NodeSqliteConnection implements DatabaseConnection {
readonly #database: DatabaseSync;
readonly #cacheStatements: boolean;
readonly #jsonColumns?: ReadonlySet<string>;
readonly #computedJsonColumns?: (
query: RootOperationNode,
) => ReadonlySet<string>;
readonly #cache = new Map<string, PreparedStatement>();
constructor(config: NodeSqliteDialectConfig) {
this.#database = config.database;
this.#cacheStatements = config.cacheStatements ?? false;
this.#jsonColumns = config.jsonColumns;
this.#computedJsonColumns = config.computedJsonColumns;
}
async executeQuery<R>(compiledQuery: CompiledQuery): Promise<QueryResult<R>> {
const prepared = this.#preparedStatementFor(compiledQuery);
const parameters = compiledQuery.parameters as SQLInputValue[];
if (prepared.columnNames.length > 0) {
return { rows: readRows<R>(prepared, parameters) };
}
const { changes, lastInsertRowid } = prepared.statement.run(...parameters);
return {
insertId: BigInt(lastInsertRowid),
numAffectedRows: BigInt(changes),
rows: [],
};
}
async *streamQuery<R>(
compiledQuery: CompiledQuery,
): AsyncIterableIterator<QueryResult<R>> {
if (!SelectQueryNode.is(compiledQuery.query)) {
throw new Error(
"Sqlite driver only supports streaming of select queries",
);
}
// deliberately uncached: the cursor stays open across yields, so sharing the
// statement with another query would reset it mid-iteration
const prepared = prepare(
this.#database,
compiledQuery.sql,
this.#jsonColumnsFor(compiledQuery.query),
);
const parameters = compiledQuery.parameters as SQLInputValue[];
for (const row of prepared.statement.iterate(...parameters)) {
yield {
rows: [toRow<R>(prepared, row as unknown as SQLOutputValue[])],
};
}
}
dispose() {
this.#cache.clear();
}
#preparedStatementFor(compiledQuery: CompiledQuery): PreparedStatement {
const { sql, query } = compiledQuery;
if (!this.#cacheStatements || !CACHEABLE_QUERY_KINDS.has(query.kind)) {
// a schema change invalidates every column list the cache is holding
if (query.kind !== "RawNode" || canChangeSchema(sql)) {
this.#cache.clear();
}
return prepare(this.#database, sql, this.#jsonColumnsFor(query));
}
const cached = this.#cache.get(sql);
if (cached) {
// re-insert so the least recently used entry stays at the front
this.#cache.delete(sql);
this.#cache.set(sql, cached);
return cached;
}
const prepared = prepare(this.#database, sql, this.#jsonColumnsFor(query));
if (this.#cache.size >= STATEMENT_CACHE_SIZE) {
this.#cache.delete(this.#cache.keys().next().value!);
}
this.#cache.set(sql, prepared);
return prepared;
}
#jsonColumnsFor(query: RootOperationNode): JsonColumns | undefined {
if (!this.#jsonColumns) return undefined;
return {
byOrigin: this.#jsonColumns,
byOutputName: this.#computedJsonColumns?.(query) ?? NO_JSON_OUTPUT_NAMES,
};
}
}
function canChangeSchema(sql: string) {
const firstKeyword = sql
.trimStart()
.split(/[\s;(]/, 1)[0]
.toLowerCase();
return !SCHEMA_PRESERVING_RAW_COMMANDS.has(firstKeyword);
}
function prepare(
database: DatabaseSync,
sql: string,
jsonColumns: JsonColumns | undefined,
): PreparedStatement {
const statement = database.prepare(sql);
statement.setReturnArrays(true);
return { statement, jsonColumns, ...columnMetadata(statement, jsonColumns) };
}
function columnMetadata(
statement: StatementSync,
jsonColumns: JsonColumns | undefined,
) {
const columns = statement.columns();
return {
columnNames: columns.map((it) => it.name),
jsonColumnFlags: columns.map((it) => {
if (!jsonColumns) return false;
// a null origin is a computed expression (a jsonArrayFrom subquery, but also
// e.g. a coalesce over user text), which only the query itself can classify
if (it.column === null) return jsonColumns.byOutputName.has(it.name);
return jsonColumns.byOrigin.has(`${it.table}.${it.column}`);
}),
};
}
function readRows<R>(
prepared: PreparedStatement,
parameters: SQLInputValue[],
): R[] {
const rawRows = prepared.statement.all(
...parameters,
) as unknown as SQLOutputValue[][];
if (rawRows.length === 0) return [];
// `select *` widens when a migration adds a column, leaving a cached statement
// with a stale column list until the next read notices the mismatch
if (rawRows[0].length !== prepared.columnNames.length) {
Object.assign(
prepared,
columnMetadata(prepared.statement, prepared.jsonColumns),
);
}
const rows = new Array<R>(rawRows.length);
for (let i = 0; i < rawRows.length; i++) {
rows[i] = toRow<R>(prepared, rawRows[i]);
}
return rows;
}
function toRow<R>(prepared: PreparedStatement, rawRow: SQLOutputValue[]): R {
const { columnNames, jsonColumnFlags } = prepared;
const row: Record<string, unknown> = {};
for (let i = 0; i < columnNames.length; i++) {
const value = rawRow[i];
row[columnNames[i]] =
jsonColumnFlags[i] && typeof value === "string" && maybeJson(value)
? parseJsonValue(value)
: value;
}
return row as R;
}
function maybeJson(value: string) {
return (
(value.startsWith("{") && value.endsWith("}")) ||
(value.startsWith("[") && value.endsWith("]"))
);
}
function parseJsonValue(value: string): unknown {
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
return value;
}
sanitizeParsedJson(parsed);
return parsed;
}
/** Strips `__proto__` and `constructor.prototype` so the parsed document can not prototype-pollute downstream merges. */
function sanitizeParsedJson(value: unknown) {
if (Array.isArray(value)) {
for (const item of value) {
sanitizeParsedJson(item);
}
return;
}
if (!isPlainObject(value)) return;
for (const key of Object.keys(value)) {
if (key === "__proto__") {
delete value[key];
continue;
}
const child = value[key];
if (
key === "constructor" &&
isPlainObject(child) &&
Object.hasOwn(child, "prototype")
) {
delete child.prototype;
}
sanitizeParsedJson(child);
}
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
if (typeof value !== "object" || value === null) return false;
const proto = Object.getPrototypeOf(value);
return proto === null || proto === Object.prototype;
}
function savepointCommand(command: string, savepointName: string) {
return RawNode.createWithChildren([
RawNode.createWithSql(`${command} `),
IdentifierNode.create(savepointName),
]);
}

View File

@@ -0,0 +1,178 @@
import { DatabaseSync } from "node:sqlite";
import { styleText } from "node:util";
import { logger } from "@sendou/utils/logger";
import { roundToNDecimalPlaces } from "@sendou/utils/number";
import { Kysely, type LogEvent } from "kysely";
import { format } from "sql-formatter";
import { ServerConfig } from "#lib/server/config.ts";
import { EmptyValuesNoopPlugin } from "./empty-values-noop-plugin";
import { JSON_COLUMNS } from "./json-columns";
import { computedJsonColumns } from "./json-selections";
import { NodeSqliteDialect } from "./node-sqlite-dialect";
import type { DB } from "./tables";
import { WriteTrackerPlugin } from "./write-tracker";
const sql = new DatabaseSync(
ServerConfig.isTest ? ":memory:" : ServerConfig.dbPath,
);
if (ServerConfig.isTest) {
applyMigratedSchema(sql);
}
sql.exec("PRAGMA journal_mode = WAL");
// The synchronous=NORMAL setting provides the best balance between performance and safety for most applications running in WAL mode.
// You lose durability across power lose with synchronous NORMAL in WAL mode, but that is not important for most applications.
// Transactions are still atomic, consistent, and isolated, which are the most important characteristics in most use cases.
// Source: https://sqlite.org/pragma.html
sql.exec("PRAGMA synchronous = NORMAL");
sql.exec("PRAGMA foreign_keys = ON");
sql.exec("PRAGMA busy_timeout = 5000");
// 64MB page cache (default is 2MB)
sql.exec("PRAGMA cache_size = -65536");
// lets reads come straight from the OS page cache without read() syscalls
// Source: https://sqlite.org/mmap.html
sql.exec("PRAGMA mmap_size = 3221225472");
// see https://sqlite.org/pragma.html#pragma_optimize — recommended for long-lived
// connections; pair with a periodic `PRAGMA optimize;` (see OptimizeDatabase routine)
sql.exec("PRAGMA optimize = 0x10002");
// Strips diacritics so accent-insensitive name searches are possible
// (e.g. "cafe" matches "Café"). Combined with LIKE's built-in ASCII
// case-insensitivity this also folds case for the resulting latin letters.
sql.function("unaccent", { deterministic: true }, (value) =>
typeof value === "string"
? value.normalize("NFD").replace(/\p{M}/gu, "")
: value,
);
export const db = new Kysely<DB>({
dialect: new NodeSqliteDialect({
database: sql,
cacheStatements: true,
jsonColumns: JSON_COLUMNS,
computedJsonColumns,
}),
log,
plugins: [new EmptyValuesNoopPlugin(), new WriteTrackerPlugin()],
});
// Every test worker gets its own in-memory database, built by replaying the
// schema of the migrated (and otherwise empty) file that scripts/ensure-test-db.ts
// creates in vitest's globalSetup. Replaying the DDL rather than copying the file
// is what `node:sqlite` allows: unlike better-sqlite3 it cannot deserialize a
// database into memory.
function applyMigratedSchema(target: DatabaseSync) {
const source = new DatabaseSync("db-test.sqlite3", {
readOnly: true,
timeout: 5000,
});
try {
// virtual tables create their own shadow tables (e.g. UserSearch_data), so
// replaying those would fail on a table that already exists
const statements = source
.prepare(`
SELECT sql FROM sqlite_master m
WHERE sql IS NOT NULL
AND name NOT LIKE 'sqlite_%'
AND NOT EXISTS (
SELECT 1 FROM sqlite_master AS vt
WHERE vt.sql LIKE 'CREATE VIRTUAL TABLE%'
AND m.name LIKE vt.name || '_%'
)
ORDER BY m.rowid
`)
.all() as Array<{ sql: string }>;
for (const statement of statements) {
target.exec(statement.sql);
}
} finally {
source.close();
}
}
function log(event: LogEvent) {
if (ServerConfig.sqlLog === "trunc" || ServerConfig.sqlLog === "full") {
logQuery(event);
} else {
logError(event);
}
}
function logQuery(event: LogEvent) {
const isSelectQuery = Boolean((event.query.query as any).from?.froms);
if (event.level === "query" && isSelectQuery) {
const from = () =>
(event.query.query as any).from.froms.map(
// plain tables have the name under table, aliased tables and
// subqueries under alias
(f: any) => f.table?.identifier?.name ?? f.alias?.name ?? "unknown",
);
// eslint-disable-next-line no-console -- dev only
console.log(styleText("blue", `-- SQLITE QUERY to "${from()}" --`));
// eslint-disable-next-line no-console -- dev only
console.log(
styleText(
millisToColor(event.queryDurationMillis),
`${roundToNDecimalPlaces(event.queryDurationMillis, 1)}ms`,
),
);
// eslint-disable-next-line no-console -- dev only
console.log(formatSql(event.query.sql, event.query.parameters));
} else {
logError(event);
}
}
function logError(event: LogEvent) {
if (
event.level === "error" &&
// it seems that this error happens everytime something goes wrong inside transaction
// my guess is that the transaction is already implicitly rolled back in the case of error
// but kysely also does it explicitly -> fails because there is no transaction to rollback.
// this `logError` function at least makes it so that due to that the error doesn't get logged
// but of course the best solution would also avoid useless rollbacks, something for the future
// btw this particular check is here just to avoid the double "no transaction is active" log
!(event.error as Error).message.includes("no transaction is active")
) {
logger.error(event.error);
}
}
function millisToColor(millis: number) {
if (millis < 1) {
return "bgGreen";
}
if (millis < 5) {
return "green";
}
if (millis < 50) {
return "yellow";
}
return "red";
}
function formatSql(sql: string, params: readonly unknown[]) {
const formatted = format(sql);
const lines = formatted.split("\n");
if (ServerConfig.sqlLog === "full" || lines.length <= 11) {
return addParams(formatted, params);
}
const linesNotShown = lines.length - 10;
return `${lines.slice(0, 10).join("\n")}\n... (${linesNotShown} more lines) ...\n`;
}
function addParams(sql: string, params: readonly unknown[]) {
const coloredParams = params.map((param) =>
styleText("yellow", JSON.stringify(param)),
);
return sql.replace(/\?/g, () => coloredParams.shift() || "");
}

View File

@@ -0,0 +1,284 @@
import type {
MainWeaponId,
ModeShort,
} from "@sendou/in-game-lists/types";
import type {
ColumnType,
GeneratedAlways,
Insertable,
Selectable,
} from "kysely";
import type {
PeakXP,
UserPreferences,
XRankPlacementRegion,
} from "#lib/db/tables-json.ts";
import type { CustomTheme } from "#lib/features/theme/theme-types.ts";
import type { SkillTeamIdentifier } from "#lib/features/mmr/mmr-utils.ts";
import type { UnifiedLanguageCode } from "#lib/modules/i18n/languages.ts";
/**
* Trimmed port of `apps/web-react/app/db/tables.ts`: contains exactly the
* tables (and columns) the migrated features query. Grows with each migrated
* feature; the React app's file stays the source of truth until cutover.
*/
type Generated<T> =
T extends ColumnType<infer S, infer I, infer U>
? ColumnType<S, I | undefined, U>
: ColumnType<T, T | undefined, T>;
/** In SQLite booleans are presented as 0 (false) and 1 (true) */
export type DBBoolean = 0 | 1;
export type JSONColumnTypeNullable<
SelectType extends object | string | number | null,
> = ColumnType<SelectType | null, string | null, string | null>;
export type { PeakXP, UserPreferences, XRankPlacementRegion };
export interface User {
/** 1 = permabanned, timestamp = ban active till then */
banned: Generated<number | null>;
bannedReason: string | null;
country: string | null;
customTheme: JSONColumnTypeNullable<CustomTheme>;
customUrl: string | null;
discordAvatar: string | null;
customAvatarImgId: number | null;
discordId: string;
discordName: string;
customName: string | null;
/** coalesce(customName, discordName) */
username: ColumnType<string, never, never>;
/** Name the user is shown under in tournaments, set by organizers of established organizations. `null` = their `username` is used. */
tournamentName: string | null;
id: GeneratedAlways<number>;
inGameName: string | null;
isArtist: Generated<DBBoolean>;
isVideoAdder: Generated<DBBoolean>;
isTournamentOrganizer: Generated<DBBoolean>;
isApiAccesser: Generated<DBBoolean>;
languages: JSONColumnTypeNullable<UnifiedLanguageCode[]>;
patronTier: number | null;
preferences: JSONColumnTypeNullable<UserPreferences>;
plusSkippedForSeasonNth: number | null;
/** User creation date. Can be null because we did not always save this. */
createdAt: number | null;
}
export interface PlusTier {
tier: number;
userId: number;
}
export interface UserFriendCode {
friendCode: string;
userId: number;
submitterUserId: number;
createdAt: Generated<number>;
}
/** Shape shared by the `AllTeam` table and the `Team` view. See {@link DB} for which to select from. */
export interface Team {
avatarImgId: number | null;
bannerImgId: number | null;
createdAt: Generated<number>;
customUrl: string;
/** Soft delete marker. Always `null` when selected via the `Team` view, which filters these rows out. */
deletedAt: number | null;
id: GeneratedAlways<number>;
inviteCode: string;
name: string;
tag: string | null;
}
/**
* Shape shared by the `AllTeamMember` table and the `TeamMember` &
* `TeamMemberWithSecondary` views. See {@link DB} for which to select from.
*/
export interface TeamMember {
createdAt: Generated<number>;
isOwner: Generated<DBBoolean>;
isManager: Generated<DBBoolean>;
/** Always `null` when selected via the `TeamMember` or `TeamMemberWithSecondary` views, which filter these rows out. */
leftAt: number | null;
/** User-defined ordering of members within a team (ascending) */
order: Generated<number>;
teamId: number;
userId: number;
isMainTeam: Generated<DBBoolean>;
}
/** Read-only. See {@link DB} for how this view relates to `UnvalidatedUserSubmittedImage`. */
export interface UserSubmittedImage {
id: GeneratedAlways<number>;
submitterUserId: number | null;
url: string;
/** Never `null` in practice, the view filters unvalidated rows out. */
validatedAt: number | null;
}
export interface UnvalidatedUserSubmittedImage {
id: GeneratedAlways<number>;
submitterUserId: number;
url: string;
/** When was the image validated? If `null` should be hidden from other users. */
validatedAt: number | null;
}
export interface Skill {
groupMatchId: number | null;
id: GeneratedAlways<number>;
/** Set for team ratings, `null` for the solo ratings identified by `userId` instead. */
identifier: SkillTeamIdentifier | null;
matchesCount: number;
mu: number;
ordinal: number;
sigma: number;
season: number;
tournamentId: number | null;
userId: number | null;
/** Can be null because we did not always save this. */
createdAt: number | null;
}
export interface SkillTeamUser {
skillId: number;
userId: number;
}
/** A team that is shown on the team leaderboard but doesn't count for its placements, e.g. because its players want to qualify with another roster. */
export interface LeaderboardTeamSkip {
id: GeneratedAlways<number>;
season: number;
/** The team's roster, same as `Skill.identifier`. */
identifier: SkillTeamIdentifier;
skippedByUserId: number;
createdAt: Generated<number>;
}
export interface SplatoonPlayer {
id: GeneratedAlways<number>;
splId: string;
userId: number | null;
/** Players best XP across both divisions. Denormalized for performance. */
peakXp: JSONColumnTypeNullable<PeakXP>;
}
export interface XRankPlacement {
badges: string;
bannerSplId: number;
id: GeneratedAlways<number>;
mode: ModeShort;
month: number;
name: string;
nameDiscriminator: string;
playerId: number;
power: number;
rank: number;
region: XRankPlacementRegion;
title: string;
weaponSplId: MainWeaponId;
year: number;
}
export interface ReportedWeapon {
groupMatchId: number | null;
tournamentMatchId: number | null;
mapIndex: number;
userId: number;
weaponSplId: MainWeaponId;
createdAt: Generated<number>;
}
export interface GroupMatch {
alphaGroupId: number;
bravoGroupId: number;
chatCode: string | null;
confirmedAt: number | null;
confirmedByUserId: number | null;
createdAt: Generated<number>;
id: GeneratedAlways<number>;
cancelRequestedByUserId: number | null;
cancelAcceptedByUserId: number | null;
noScreen: Generated<DBBoolean>;
}
export interface GroupMember {
createdAt: Generated<number>;
groupId: number;
missedReadyCheckAt: number | null;
note: string | null;
userId: number;
}
export interface Tournament {
id: GeneratedAlways<number>;
/** Is the tournament finalized meaning all the matches are played and TO has locked it making it read-only */
isFinalized: Generated<DBBoolean>;
}
export interface TournamentStage {
id: GeneratedAlways<number>;
name: string;
number: number;
tournamentId: number;
createdAt: Generated<number>;
}
export interface TournamentMatch {
chatCode: string | null;
groupId: number;
id: GeneratedAlways<number>;
number: number;
roundId: number;
stageId: number;
startedAt: number | null;
}
export interface TournamentResult {
isHighlight: Generated<DBBoolean>;
participantCount: number;
placement: number;
tournamentId: number;
tournamentTeamId: number;
/** The SP change in total after the finalization of a ranked tournament. */
spDiff: number | null;
userId: number;
/** Division label for tournaments with multiple starting brackets (e.g., "D1", "D2") */
div: string | null;
}
export type Tables = { [P in keyof DB]: Selectable<DB[P]> };
export type TablesInsertable = { [P in keyof DB]: Insertable<DB[P]> };
export interface DB {
/** Table backing the `Team` view. Includes soft-deleted teams. */
AllTeam: Team;
/** Table backing the `TeamMember` & `TeamMemberWithSecondary` views. Includes members who have left and members of deleted teams. */
AllTeamMember: TeamMember;
GroupMatch: GroupMatch;
GroupMember: GroupMember;
LeaderboardTeamSkip: LeaderboardTeamSkip;
PlusTier: PlusTier;
ReportedWeapon: ReportedWeapon;
Skill: Skill;
SkillTeamUser: SkillTeamUser;
SplatoonPlayer: SplatoonPlayer;
/** View of `AllTeam` without soft-deleted teams. */
Team: Team;
/** View of `AllTeamMember` with only current members of the user's main team. */
TeamMember: TeamMember;
/** View of `AllTeamMember` with current members of both main and secondary teams. */
TeamMemberWithSecondary: TeamMember;
Tournament: Tournament;
TournamentMatch: TournamentMatch;
TournamentResult: TournamentResult;
TournamentStage: TournamentStage;
UnvalidatedUserSubmittedImage: UnvalidatedUserSubmittedImage;
User: User;
UserFriendCode: UserFriendCode;
UserSubmittedImage: UserSubmittedImage;
XRankPlacement: XRankPlacement;
}

View File

@@ -0,0 +1,41 @@
import type {
KyselyPlugin,
PluginTransformQueryArgs,
PluginTransformResultArgs,
QueryResult,
RootOperationNode,
UnknownRow,
} from "kysely";
let dirty = false;
/**
* Records whether anything has been written to the database, so that a caller can
* react to writes it did not make itself: the vitest teardown wipes only when a
* test wrote something, and an e2e worker flushes the app server's caches only
* when its factories wrote something.
*/
export class WriteTrackerPlugin implements KyselyPlugin {
transformQuery(args: PluginTransformQueryArgs): RootOperationNode {
if (args.node.kind !== "SelectQueryNode") {
dirty = true;
}
return args.node;
}
async transformResult(
args: PluginTransformResultArgs,
): Promise<QueryResult<UnknownRow>> {
return args.result;
}
}
/** Whether the database has been written to since the last {@link markDatabaseClean}. */
export function isDatabaseDirty() {
return dirty;
}
export function markDatabaseClean() {
dirty = false;
}

View File

@@ -0,0 +1,273 @@
import {
type AliasedRawBuilder,
type Expression,
type ExpressionBuilder,
type RawBuilder,
sql,
} from "kysely";
import type {
jsonArrayFrom as sqliteJsonArrayFrom,
jsonBuildObject as sqliteJsonBuildObject,
jsonObjectFrom as sqliteJsonObjectFrom,
} from "kysely/helpers/sqlite";
import { Config } from "#lib/config.ts";
import {
jsonValuedNode,
jsonValuedSelection,
selectionOutputName,
} from "#lib/server/db/json-selections.ts";
import { db } from "#lib/server/db/sql.ts";
import type { DB, Tables } from "#lib/server/db/tables.ts";
import { IS_E2E_TEST_RUN } from "#lib/utils/e2e.ts";
type CommonUserSelectOptions = {
alias?: string;
prefix?: string;
idAs?: string;
/** For tournament scoped queries: `username` resolves to {@link tournamentUsername}. */
inTournament?: boolean;
};
type UserTableAlias<O> = O extends { alias: infer A extends string }
? A
: "User";
type PrefixedUserColumn<O, C extends string> = O extends {
prefix: infer P extends string;
}
? `${P}${Capitalize<C>}`
: C;
type UserIdColumn<O> = O extends { idAs: infer I extends string }
? I
: PrefixedUserColumn<O, "id">;
type CommonUserSelectResult<O> = readonly [
`${UserTableAlias<O>}.id as ${UserIdColumn<O>}`,
`${UserTableAlias<O>}.username as ${PrefixedUserColumn<O, "username">}`,
`${UserTableAlias<O>}.discordId as ${PrefixedUserColumn<O, "discordId">}`,
`${UserTableAlias<O>}.discordAvatar as ${PrefixedUserColumn<O, "discordAvatar">}`,
`${UserTableAlias<O>}.customUrl as ${PrefixedUserColumn<O, "customUrl">}`,
AliasedRawBuilder<string | null, PrefixedUserColumn<O, "customAvatarUrl">>,
];
/**
* Select list for the fields shared by every user representation across the app. Includes
* `customAvatarUrl`, the full URL of the user's supporter custom avatar (resolved from
* `User.customAvatarImgId`), or `null` when they have none. By default reads from `"User"` which
* must be in scope at the call site; pass `alias` when the table is joined under another name
* (`alias: "LinkedUser"`), `prefix` to prefix every output column (`prefix: "sender"` →
* `senderId`, `senderUsername`, ...), `idAs` to rename only the id column (`idAs: "userId"`) and
* `inTournament` to resolve `username` via {@link tournamentUsername}.
*/
export function commonUserSelect<const O extends CommonUserSelectOptions>(
eb: ExpressionBuilder<DB, any>,
options?: O,
): CommonUserSelectResult<O> {
const alias = options?.alias ?? "User";
const prefix = options?.prefix;
const outputName = (column: string) =>
prefix ? `${prefix}${column[0].toUpperCase()}${column.slice(1)}` : column;
const idName = options?.idAs ?? outputName("id");
return [
`${alias}.id as ${idName}`,
options?.inTournament
? tournamentUsername(alias).as(outputName("username"))
: `${alias}.username as ${outputName("username")}`,
`${alias}.discordId as ${outputName("discordId")}`,
`${alias}.discordAvatar as ${outputName("discordAvatar")}`,
`${alias}.customUrl as ${outputName("customUrl")}`,
customAvatarUrl(eb, alias).as(outputName("customAvatarUrl")),
] as unknown as CommonUserSelectResult<O>;
}
/**
* SQL expression resolving to the full URL of a user's supporter custom avatar (from
* `User.customAvatarImgId`), or `null` when they have none. Alias it
* (`.as("customAvatarUrl")`) when selecting it directly. Pass `alias` when the `User` table is
* joined under another name. Prefer {@link commonUserSelect}; reach for this only when it
* doesn't fit (e.g. a hand-built `jsonBuildObject`).
*/
export function customAvatarUrl(
eb: ExpressionBuilder<DB, any>,
alias = "User",
) {
return concatUserSubmittedImagePrefix(
eb
.selectFrom("UserSubmittedImage")
.select("UserSubmittedImage.url")
.whereRef(
"UserSubmittedImage.id",
"=",
sql.ref(`${alias}.customAvatarImgId`),
)
.$asScalar(),
).$castTo<string | null>();
}
export type CommonUser = Pick<
Tables["User"],
"id" | "username" | "discordId" | "discordAvatar" | "customUrl"
> & { customAvatarUrl: string | null };
/** Represents User joined with PlusTier table */
export type UserWithPlusTier = Tables["User"] & {
plusTier: Tables["PlusTier"]["tier"] | null;
};
/** Resolves the name a user is shown under in tournaments: `tournamentName` when set, otherwise `username`. */
export function tournamentUsername(alias = "User") {
return sql<string>`coalesce(${sql.ref(`${alias}.tournamentName`)}, ${sql.ref(
`${alias}.username`,
)})`;
}
const USER_SUBMITTED_IMAGE_ROOT =
(typeof process !== "undefined" &&
process.env.NODE_ENV === "development" &&
!Config.prodMode) ||
IS_E2E_TEST_RUN ||
(typeof process !== "undefined" && process.env.NODE_ENV === "test")
? "http://127.0.0.1:9000/sendou"
: "https://sendou.nyc3.cdn.digitaloceanspaces.com";
/**
* Groups a season's `Skill` rows to the latest one per user or per team
* identifier via SQLite's bare column rule: with a `max()` aggregate the other
* selected columns come from the row that produced the max. This form is
* plan-stable regardless of stats: a single grouped scan of the covering index,
* no temp b-tree per partition.
*/
export function latestSkillPerSeason<By extends "userId" | "identifier">({
season,
by,
}: {
season: number;
by: By;
}) {
return db
.selectFrom("Skill")
.select(({ fn }) => [
fn.max("Skill.id").as("latestId"),
"Skill.ordinal" as const,
"Skill.matchesCount" as const,
`Skill.${by}` as `Skill.${By}`,
])
.where("Skill.season", "=", season)
.where(`Skill.${by}`, "is not", null)
.groupBy(`Skill.${by}`);
}
/**
* Predicate for `Skill` rows of the user that represent a played set: either a SendouQ match or
* a ranked tournament the user has a result in. Filters out e.g. skills of tournament teams the
* user dropped from before results. `"Skill"` must be in scope at the call site.
*/
export function skillCountsAsSeasonSet(
eb: ExpressionBuilder<DB, "Skill">,
userId: number,
) {
return eb.or([
eb("Skill.groupMatchId", "is not", null),
eb.exists(
eb
.selectFrom("TournamentResult")
.select("TournamentResult.userId")
.whereRef("TournamentResult.tournamentId", "=", "Skill.tournamentId")
.where("TournamentResult.userId", "=", userId),
),
]);
}
/** Concats the file name (a bit misleadingly called `url` in the DB schema) with the root URL, giving the full URL for the image */
export function concatUserSubmittedImagePrefix<T extends string | null>(
expr: Expression<T>,
) {
// null-propagating || instead of iif(expr is not null, concat(...), null)
// so a correlated subquery passed as expr is evaluated only once per row
return sql<T extends null ? string | null : string>`(${sql.lit(
`${USER_SUBMITTED_IMAGE_ROOT}/`,
)} || ${expr})`;
}
type SelectQueryBuilderExpression<O> = Parameters<
typeof sqliteJsonArrayFrom<O>
>[0];
/**
* Drop-in replacement for kysely's sqlite `jsonArrayFrom`. Emits the same query, except
* JSON-valued selections (per {@link jsonValuedSelection}: JSON columns, nested json helpers) get
* `json(...)` applied at the `json_object` argument position. SQLite's JSON subtype never
* survives a subquery boundary, so without the re-tag such values would be embedded as
* strings; the dialect parses each result column exactly once and relies on documents
* arriving fully nested. Always use this over the kysely one.
*/
export function jsonArrayFrom<O>(
expr: SelectQueryBuilderExpression<O>,
): ReturnType<typeof sqliteJsonArrayFrom<O>> {
return sql`(select coalesce(json_group_array(json_object(${sql.join(
jsonObjectArgs(expr, "agg"),
)})), '[]') from ${expr} as agg)` as ReturnType<
typeof sqliteJsonArrayFrom<O>
>;
}
/** Drop-in replacement for kysely's sqlite `jsonObjectFrom`, see {@link jsonArrayFrom}. */
export function jsonObjectFrom<O>(
expr: SelectQueryBuilderExpression<O>,
): ReturnType<typeof sqliteJsonObjectFrom<O>> {
return sql`(select json_object(${sql.join(
jsonObjectArgs(expr, "obj"),
)}) from ${expr} as obj)` as ReturnType<typeof sqliteJsonObjectFrom<O>>;
}
/** Drop-in replacement for kysely's sqlite `jsonBuildObject`, see {@link jsonArrayFrom}. */
export function jsonBuildObject<O extends Record<string, Expression<unknown>>>(
obj: O,
): ReturnType<typeof sqliteJsonBuildObject<O>> {
return sql`json_object(${sql.join(
Object.keys(obj).flatMap((key) => [
sql.lit(key),
jsonValuedNode(obj[key].toOperationNode())
? sql`json(${obj[key]})`
: obj[key],
]),
)})` as ReturnType<typeof sqliteJsonBuildObject<O>>;
}
/**
* Re-tags a JSON-valued expression with SQLite's `json()` so it stays a nested document
* (instead of an escaped string) inside {@link jsonBuildObject}/{@link jsonArrayFrom}.
* Only needed for expressions the helpers can not recognize as JSON on their own, e.g. a
* raw `IIF(...)` over a JSON column.
*/
export function asJson<T>(expr: Expression<T>): RawBuilder<T> {
return sql<T>`json(${expr})`;
}
function jsonObjectArgs(
expr: SelectQueryBuilderExpression<unknown>,
table: string,
) {
const args: Expression<unknown>[] = [];
for (const { selection } of expr.toOperationNode().selections ?? []) {
const name = selectionOutputName(selection);
if (!name) {
throw new Error(
"jsonArrayFrom and jsonObjectFrom can only handle explicit selections. selectAll() is not allowed in the subquery.",
);
}
const ref = sql.ref(`${table}.${name}`);
args.push(
sql.lit(name),
jsonValuedSelection(selection) ? sql`json(${ref})` : ref,
);
}
return args;
}

View File

@@ -0,0 +1,659 @@
/** biome-ignore-all lint/style/noDescendingSpecificity: Biome v2 migration */
@layer base {
* {
margin: 0;
overflow-wrap: break-word;
}
html {
color-scheme: light dark;
accent-color: var(--color-text-accent);
scroll-padding-top: var(--layout-sticky-top);
}
body {
width: 100%;
min-height: 100vh;
display: flex;
line-height: var(--line-height);
overflow-x: hidden;
color: var(--color-text);
background-color: var(--color-bg);
font-family: lexend, sans-serif;
-moz-osx-font-smoothing: antialiased;
-webkit-font-smoothing: antialiased;
-webkit-tab-highlight-color: transparent;
-webkit-text-size-adjust: 100%;
}
h1,
h2,
h3,
h4,
h5,
h6,
p {
overflow-wrap: anywhere;
}
h1,
h2,
h3 {
text-wrap: balance;
}
p {
text-wrap: pretty;
}
img,
svg,
canvas,
video,
audio,
iframe,
embed,
object {
display: block;
max-width: 100%;
}
input,
button,
textarea,
select {
font: inherit;
color: inherit;
}
details {
& summary {
cursor: pointer;
user-select: none;
-webkit-user-select: none;
}
}
a {
color: var(--color-text-accent);
text-decoration: none;
border-radius: var(--radius-field);
&:focus-visible {
outline: var(--focus-ring);
outline-offset: 1px;
}
}
/* :not([name="text"]) workaround not to select textarea on map planner */
textarea:not([name="text"]) {
width: 100%;
min-height: 8rem;
padding: var(--field-padding);
border: var(--border-style);
border-radius: var(--radius-field);
background-color: var(--color-bg);
font-size: var(--font-sm);
outline: none;
resize: both;
&:focus-within {
outline: var(--focus-ring);
outline-offset: 1px;
}
}
.input-container {
display: flex;
border: var(--border-style);
border-radius: var(--radius-field);
background-color: var(--color-bg);
height: var(--field-size);
font-size: var(--font-sm);
outline: none;
&:focus-within {
outline: var(--focus-ring);
outline-offset: 1px;
}
}
.in-container {
width: 100%;
padding: 0 var(--field-padding);
border: none;
background: transparent;
margin: auto 0;
outline: none;
&:-webkit-autofill,
&:-webkit-autofill:hover,
&:-webkit-autofill:focus {
-webkit-text-fill-color: var(--color-text);
-webkit-background-clip: text;
caret-color: var(--color-text);
}
}
.input-addon {
--addon-radius: calc(var(--radius-field) - 2px);
display: grid;
border-radius: var(--addon-radius) 0 0 var(--addon-radius);
background-color: var(--color-bg-high);
color: var(--color-text-high);
font-size: var(--font-sm);
padding-inline: var(--field-padding);
place-items: center;
white-space: nowrap;
}
input:not(.in-container) {
width: 100%;
box-sizing: border-box;
border: var(--border-style);
border-radius: var(--radius-field);
background: var(--color-bg);
padding: 0 var(--field-padding);
height: var(--field-size);
font-size: var(--font-sm);
outline: none;
&:-webkit-autofill,
&:-webkit-autofill:hover,
&:-webkit-autofill:focus {
-webkit-text-fill-color: var(--color-text);
-webkit-background-clip: text;
caret-color: var(--color-text);
}
&::placeholder {
color: var(--color-text-high);
}
&:disabled {
pointer-events: none;
cursor: not-allowed;
opacity: 0.5;
outline: none;
}
&:focus-within:not(:read-only) {
outline: var(--focus-ring);
outline-offset: 1px;
}
}
input[type="checkbox"] {
appearance: none;
cursor: pointer;
padding: 0;
width: var(--selector-size-sm);
height: var(--selector-size-sm);
border-radius: calc(var(--radius-selector) / 2);
background-color: var(--color-bg);
border: var(--border-style);
&:focus-within {
outline: none;
}
&:focus-visible {
outline: var(--focus-ring);
outline-offset: 1px;
}
&:checked,
&:indeterminate {
border-color: var(--color-text-accent);
background-color: var(--color-text-accent);
background-repeat: no-repeat;
background-position: center;
background-size: var(--field-size-icon) auto;
}
&:checked {
background-image: var(--field-icon-checkbox);
}
&:indeterminate {
background-image: var(--field-icon-minus);
}
/* Hacky selector to remove the margin bottom from the container from Label.tsx */
&:has(+ div > label) + div {
margin-bottom: 0;
}
}
input[type="radio"] {
appearance: none;
cursor: pointer;
vertical-align: middle;
width: var(--selector-size-sm);
height: var(--selector-size-sm);
background-color: var(--color-bg);
border: var(--border-style);
border-radius: var(--radius-full);
padding: 0;
&:focus-within {
outline: none;
}
&:focus-visible {
outline: var(--focus-ring);
outline-offset: 1px;
}
&::after {
content: "";
display: block;
width: 50%;
height: 50%;
border-radius: var(--radius-full);
background-color: var(--color-bg);
margin: auto;
margin-top: 25%;
}
&:checked {
border-color: var(--color-text-accent);
&::after {
background-color: var(--color-text-accent);
}
}
/* Hacky selector to remove the margin bottom from the container from Label.tsx */
&:has(+ div > label) + div {
margin-bottom: 0;
}
}
label {
display: block;
font-size: var(--font-xs);
font-weight: var(--weight-bold);
margin-block-end: var(--label-margin);
}
fieldset {
border: none;
border-radius: var(--radius-box);
background-color: var(--color-bg-high);
font-size: var(--font-sm);
padding-block-end: var(--s-3);
padding-inline: var(--s-3);
}
legend {
border-radius: var(--radius-field);
background-color: transparent;
font-size: var(--font-xs);
font-weight: var(--weight-bold);
}
article {
white-space: pre-wrap;
}
select {
all: unset;
width: 100%;
box-sizing: border-box;
border: var(--border-style);
border-radius: var(--radius-field);
background-color: var(--color-bg);
cursor: pointer;
padding: 0 var(--field-padding);
padding-right: calc(
var(--field-size-icon) +
var(--field-padding) +
var(--s-2)
);
height: var(--field-size);
font-size: var(--font-sm);
display: inline-flex;
align-items: center;
text-overflow: ellipsis;
white-space: nowrap;
appearance: none;
overflow: hidden;
@supports (appearance: base-select) {
appearance: base-select;
}
&:disabled {
pointer-events: none;
cursor: not-allowed;
opacity: 0.5;
outline: none;
}
&:focus-within {
outline: var(--focus-ring);
outline-offset: 1px;
}
&::picker(select) {
@supports (appearance: base-select) {
appearance: base-select;
}
background-color: var(--color-bg);
border: var(--border-style);
border-radius: var(--radius-field);
padding: var(--s-1);
margin: 0.5rem;
translate: -0.5rem;
max-height: 50dvh;
scrollbar-width: thin;
scrollbar-color: var(--color-bg-higher) transparent;
}
&::picker-icon {
display: none;
}
optgroup {
color: var(--color-text);
}
option {
background-color: var(--color-bg);
color: var(--color-text);
border-radius: var(--radius-field);
font-size: var(--font-sm);
font-weight: var(--weight-semi);
padding: var(--s-1-5);
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
&[disabled] {
color: var(--color-text-high);
}
&:hover:not([disabled]) {
background-color: var(--color-bg-high);
}
}
}
input[type="date"],
input[type="datetime-local"],
input[type="month"],
input[type="time"],
input[type="week"],
select {
background-position: center right var(--field-padding);
background-size: var(--field-size-icon) auto;
background-repeat: no-repeat;
}
input[type="date"],
input[type="datetime-local"],
input[type="month"],
input[type="week"] {
background-image: var(--field-icon-date);
}
input[type="time"] {
background-image: var(--field-icon-time);
}
select {
background-image: var(--field-icon-chevron);
}
[type="date"]::-webkit-calendar-picker-indicator,
[type="datetime-local"]::-webkit-calendar-picker-indicator,
[type="month"]::-webkit-calendar-picker-indicator,
[type="time"]::-webkit-calendar-picker-indicator,
[type="week"]::-webkit-calendar-picker-indicator {
width: var(--field-size-icon);
opacity: 0;
}
@-moz-document url-prefix() {
[type="date"],
[type="datetime-local"],
[type="month"],
[type="time"],
[type="week"] {
background-image: none !important;
}
}
@-moz-document url-prefix() {
input[type="date"],
input[type="datetime-local"],
input[type="month"],
input[type="time"],
input[type="week"] {
background-image: none;
}
}
input[type="color"] {
padding: calc(var(--field-size) / 8) var(--field-padding);
&::-webkit-color-swatch {
border: 0;
border-radius: var(--radius-field);
}
&::-moz-color-swatch {
border: 0;
border-radius: var(--radius-field);
}
}
input[type="file"] {
padding: 0;
cursor: pointer;
font-size: var(--font-sm);
color: var(--color-text-high);
overflow: hidden;
&:focus-visible {
outline: var(--focus-ring);
outline-offset: 1px;
}
&::file-selector-button {
height: 100%;
border: none;
background-color: var(--color-bg-higher);
color: var(--color-text-high);
white-space: nowrap;
margin-right: var(--s-2);
}
}
input[type="range"] {
--track-color: var(--color-bg-higher);
--thumb-color: var(--color-text-high);
border: none;
padding: 0;
appearance: none;
background: 0 0;
width: 100%;
height: var(--selector-size);
&:focus {
outline: none;
}
&:focus-within {
--track-color: var(--color-bg-higher);
--thumb-color: var(--color-text-accent);
}
&::-webkit-slider-runnable-track {
width: 100%;
height: calc(var(--selector-size) / 4);
background-color: var(--track-color);
border-radius: var(--radius-full);
transition: 100ms background-color;
}
&::-moz-range-track {
width: 100%;
height: calc(var(--selector-size) / 4);
background-color: var(--track-color);
border-radius: var(--radius-full);
transition: 100ms background-color;
}
&::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: var(--selector-size);
height: var(--selector-size);
margin-top: calc(
(calc(var(--selector-size) / 4) - var(--selector-size)) /
2
);
background-color: var(--thumb-color);
border: 2px solid var(--color-bg);
border-radius: var(--radius-full);
cursor: pointer;
transition: 100ms background-color;
}
&::-moz-range-thumb {
-webkit-appearance: none;
appearance: none;
width: var(--selector-size);
height: var(--selector-size);
margin-top: calc(
(calc(var(--selector-size) / 4) - var(--selector-size)) /
2
);
background-color: var(--thumb-color);
border: 2px solid var(--color-bg);
border-radius: var(--radius-full);
cursor: pointer;
transition: 100ms background-color;
}
}
progress {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
display: inline-block;
width: 100%;
height: var(--selector-size);
overflow: hidden;
border: none;
border-radius: var(--radius-full);
background-color: var(--color-bg-high);
color: var(--color-text-accent);
vertical-align: baseline;
&::-webkit-progress-bar {
border-radius: var(--radius-full);
background: 0 0;
}
&::-moz-progress-bar {
background-color: var(--color-text-accent);
}
&[value]::-webkit-progress-value {
background-color: var(--color-text-accent);
transition: inline-size 0.2s ease;
}
}
td > input[type="checkbox"] {
vertical-align: middle;
}
hr {
border: 1px solid var(--color-bg-high);
}
abbr {
&:not([title]) {
text-decoration: none;
}
&[title] {
cursor: help;
}
}
.article {
& > p {
padding-block: var(--s-3);
}
& img {
height: auto;
}
}
/* Hack to deal with Safari bug when the image is loading. See: https://stackoverflow.com/a/73466877 */
@media not all and (min-resolution: 0.001dpcm) {
img[loading="lazy"] {
clip-path: inset(0.5px);
}
}
}
#nprogress {
& .bar {
background: var(--color-text-accent) !important;
}
& .spinner {
display: none !important;
}
& .peg {
display: none !important;
}
}
/* Track that NProgress renders its bar into (see NPROGRESS_ANCHOR_ID). It is a
* child of the header positioned just below the header's 1.5px bottom border, so
* it follows the header on scroll and in standalone (PWA) mode. It spans only the
* header width (the area between the sidebars), and its overflow: hidden clips the
* bar — which NProgress slides in from the left — so the bar can never extend over
* a sidebar. Unlayered + id selector so it beats NProgress's own unlayered
* .nprogress-custom-parent rules (and the @layer-wrapped CSS modules). */
#nprogress-anchor {
position: absolute;
top: calc(100% + 1.5px);
left: 0;
right: 0;
height: 2.5px;
overflow: hidden;
pointer-events: none;
}
.nprogress-custom-parent #nprogress .bar {
height: 100%;
}
.top-leaderboard {
min-height: 100px;
max-height: 100px;
overflow-y: clip;
margin-top: 10px;
display: grid;
place-items: center;
}
@media screen and (max-width: 599px) {
body:not(:has(.planner)) .publift-widget-sticky_footer-container {
bottom: calc(
var(--layout-nav-height) +
env(safe-area-inset-bottom)
) !important;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,32 @@
/*
Lexend variable font, self-hosted. The weight range is capped at 700 to match
`--weight-extra`, so `font-weight: bolder` keeps clamping there as it did when
the four static weights were requested from Google Fonts.
The vietnamese subset is intentionally omitted: no locale uses it.
*/
@font-face {
font-family: "Lexend";
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url("./fonts/lexend-latin.woff2") format("woff2");
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6,
U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122,
U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "Lexend";
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url("./fonts/lexend-latin-ext.woff2") format("woff2");
unicode-range:
U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7,
U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F,
U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F,
U+A720-A7FF;
}

Binary file not shown.

Binary file not shown.

217
apps/web/src/lib/styles/normalize.css vendored Normal file
View File

@@ -0,0 +1,217 @@
@layer reset {
/*! modern-normalize v3.0.1 | MIT License | https://github.com/sindresorhus/modern-normalize */
/*
Document
========
*/
/**
Use a better box model (opinionated).
*/
*,
::before,
::after {
box-sizing: border-box;
}
/**
1. Improve consistency of default fonts in all browsers. (https://github.com/sindresorhus/modern-normalize/issues/3)
2. Correct the line height in all browsers.
3. Prevent adjustments of font size after orientation changes in iOS.
4. Use a more readable tab size (opinionated).
*/
html {
font-family:
system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji"; /* 1 */
line-height: 1.15; /* 2 */
-webkit-text-size-adjust: 100%; /* 3 */
tab-size: 4; /* 4 */
}
/*
Sections
========
*/
/**
Remove the margin in all browsers.
*/
body {
margin: 0;
}
/*
Text-level semantics
====================
*/
/**
Add the correct font weight in Chrome and Safari.
*/
b,
strong {
font-weight: bolder;
}
/**
1. Improve consistency of default fonts in all browsers. (https://github.com/sindresorhus/modern-normalize/issues/3)
2. Correct the odd 'em' font sizing in all browsers.
*/
code,
kbd,
samp,
pre {
font-family:
ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", Menlo,
monospace; /* 1 */
font-size: 1em; /* 2 */
}
/**
Add the correct font size in all browsers.
*/
small {
font-size: 80%;
}
/**
Prevent 'sub' and 'sup' elements from affecting the line height in all browsers.
*/
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
/*
Tabular data
============
*/
/**
Correct table border color inheritance in Chrome and Safari. (https://issues.chromium.org/issues/40615503, https://bugs.webkit.org/show_bug.cgi?id=195016)
*/
table {
border-color: currentcolor;
}
/*
Forms
=====
*/
/**
1. Change the font styles in all browsers.
2. Remove the margin in Firefox and Safari.
*/
button,
input,
optgroup,
select,
textarea {
font-family: inherit; /* 1 */
font-size: 100%; /* 1 */
line-height: 1.15; /* 1 */
margin: 0; /* 2 */
}
/**
Correct the inability to style clickable types in iOS and Safari.
*/
button,
[type="button"],
[type="reset"],
[type="submit"] {
appearance: button;
-webkit-appearance: button;
}
/**
Remove the padding so developers are not caught out when they zero out 'fieldset' elements in all browsers.
*/
legend {
padding: 0;
}
/**
Add the correct vertical alignment in Chrome and Firefox.
*/
progress {
vertical-align: baseline;
}
/**
Correct the cursor style of increment and decrement buttons in Safari.
*/
::-webkit-inner-spin-button,
::-webkit-outer-spin-button {
height: auto;
}
/**
1. Correct the odd appearance in Chrome and Safari.
2. Correct the outline style in Safari.
*/
[type="search"] {
appearance: textfield; /* 1 */
-webkit-appearance: textfield; /* 1 */
outline-offset: -2px; /* 2 */
}
/**
Remove the inner padding in Chrome and Safari on macOS.
*/
::-webkit-search-decoration {
-webkit-appearance: none;
}
/**
1. Correct the inability to style clickable types in iOS and Safari.
2. Change font properties to 'inherit' in Safari.
*/
::-webkit-file-upload-button {
-webkit-appearance: button; /* 1 */
font: inherit; /* 2 */
}
/*
Interactive
===========
*/
/*
Add the correct display in Chrome and Safari.
*/
summary {
display: list-item;
}
}

View File

@@ -0,0 +1,537 @@
@layer utilities {
.text-xl {
font-size: var(--font-xl);
}
.text-lg {
font-size: var(--font-lg);
}
.text-sm {
font-size: var(--font-sm);
}
.text-md {
font-size: var(--font-md);
}
.text-xs {
font-size: var(--font-xs);
}
.text-xxs {
font-size: var(--font-2xs);
}
.text-center {
text-align: center;
}
.text-main-forced {
color: var(--color-text);
}
.text-lighter {
color: var(--color-text-high);
}
.text-error {
color: var(--color-error);
}
.text-success {
color: var(--color-success);
}
.text-accent-high {
color: var(--color-accent-high);
}
.text-warning {
color: var(--color-warning);
}
.text-theme {
color: var(--color-text-accent);
}
.text-theme-secondary {
color: var(--color-text-second);
}
.text-uppercase {
text-transform: uppercase;
}
.text-capitalize {
text-transform: capitalize;
}
.underline {
text-decoration: underline;
text-decoration-thickness: 2px;
}
.line-through {
text-decoration: line-through;
}
.color-success {
color: var(--color-success);
}
.color-warning {
color: var(--color-warning);
}
.color-error {
color: var(--color-error);
}
.color-info {
color: var(--color-info);
}
.bg {
background-color: var(--color-bg);
}
.bg-lighter {
background-color: var(--color-bg-high);
}
.rounded {
border-radius: var(--radius-box);
}
.rounded-sm {
border-radius: var(--radius-field);
}
.rounded-full {
border-radius: 100%;
}
.font-body {
font-weight: var(--weight-body);
}
.font-semi-bold {
font-weight: var(--weight-semi);
}
.font-bold {
font-weight: var(--weight-bold);
}
.h-max {
height: max-content;
}
.w-full {
width: 100%;
}
.w-4 {
width: var(--s-4);
}
.w-6 {
width: var(--s-6);
}
.w-24 {
width: var(--s-24);
}
.w-max {
width: max-content;
}
.p-1 {
padding: var(--s-1);
}
.py-4 {
padding-block: var(--s-4);
}
.pl-0 {
padding-inline-start: 0;
}
.pl-4 {
padding-inline-start: var(--s-4);
}
.pt-0 {
padding-block-start: 0;
}
.pt-0-5 {
padding-block-start: var(--s-0-5);
}
.m-auto-0 {
margin: auto 0;
}
.mt-1 {
margin-block-start: var(--s-1);
}
.mt-2 {
margin-block-start: var(--s-2);
}
.mt-3 {
margin-block-start: var(--s-3);
}
.mt-4 {
margin-block-start: var(--s-4);
}
.mt-6 {
margin-block-start: var(--s-6);
}
.mt-12 {
margin-block-start: var(--s-12);
}
.mb-0 {
margin-block-end: 0;
}
.mb-1 {
margin-block-end: var(--s-1);
}
.mb-2 {
margin-block-end: var(--s-2);
}
.mb-4 {
margin-block-end: var(--s-4);
}
.ml-auto {
margin-inline-start: auto;
}
.ml-1 {
margin-inline-start: var(--s-1);
}
.ml-2 {
margin-inline-start: var(--s-2);
}
.ml-6 {
margin-inline-start: var(--s-6);
}
.mr-auto {
margin-inline-end: auto;
}
.mr-1 {
margin-inline-end: var(--s-1);
}
.mr-2 {
margin-inline-end: var(--s-2);
}
.mx-auto {
margin-inline: auto;
}
.mx-1 {
margin-inline: var(--s-1);
}
.mx-2 {
margin-inline: var(--s-2);
}
.my-1 {
margin-block: var(--s-1);
}
.my-2 {
margin-block: var(--s-2);
}
.my-3 {
margin-block: var(--s-3);
}
.my-4 {
margin-block: var(--s-4);
}
.my-24 {
margin-block: var(--s-24);
}
.m-0-auto {
margin: 0 auto;
}
.line-height-tight {
line-height: 1.3;
}
.hidden {
display: none;
}
.block {
display: block;
}
.invisible {
visibility: hidden;
}
.whitespace-pre-wrap {
white-space: pre-wrap;
}
.whitespace-nowrap {
white-space: nowrap;
}
.overflow-x-auto {
overflow-x: auto;
}
.relative {
position: relative;
}
.italic {
font-style: italic;
}
.contents {
display: contents;
}
.flex {
display: flex;
}
.items-center {
align-items: center;
}
.items-start {
align-items: flex-start;
}
.items-end {
align-items: flex-end;
}
.justify-center {
justify-content: center;
}
.justify-end {
justify-content: flex-end;
}
.justify-between {
justify-content: space-between;
}
.justify-self-end {
justify-self: flex-end;
}
.justify-self-center {
justify-self: center;
}
.self-center {
align-self: center;
}
.self-start {
align-self: flex-start;
}
.justify-self-start {
justify-self: flex-start;
}
.flex-wrap {
flex-wrap: wrap;
}
.all-unset {
all: unset;
}
.focus-text-decoration {
&:focus-visible {
outline: none;
text-decoration: underline;
}
}
.label-no-spacing {
--label-margin: 0;
}
.cursor-pointer {
cursor: pointer;
}
.opaque {
opacity: 0.6;
}
.scrollbar,
textarea {
/* Firefox, Chrome 121+, Safari 18.2+ */
@supports (scrollbar-color: auto) {
scrollbar-width: thin;
scrollbar-color: var(--color-bg-higher) transparent;
}
/* Legacy */
@supports not (scrollbar-color: auto) {
&::-webkit-scrollbar,
&::-webkit-scrollbar-track {
background-color: transparent;
height: 14px;
width: 14px;
}
&::-webkit-scrollbar-thumb {
background-color: var(--color-base-4);
border-radius: var(--radius-full);
border: 4px solid transparent;
background-clip: content-box;
}
&::-webkit-scrollbar-corner {
background-color: transparent;
}
}
}
.stack {
display: flex;
flex-direction: column;
&.xxxs {
gap: var(--s-0-5);
}
&.xxs {
gap: var(--s-1);
}
&.xs {
gap: var(--s-1-5);
}
&.sm {
gap: var(--s-2);
}
&.sm-column {
column-gap: var(--s-2);
}
&.sm-plus {
gap: var(--s-3);
}
&.md {
gap: var(--s-4);
}
&.md-plus {
gap: var(--s-6);
}
&.lg {
gap: var(--s-8);
}
&.xs-row {
row-gap: var(--s-1-5);
}
&.lg-row {
row-gap: var(--s-8);
}
&.xl {
gap: var(--s-12);
}
&.xxl {
gap: var(--s-16);
}
&.horizontal {
flex-direction: row;
}
}
.flex-same-size {
flex: 1 1 0px;
}
.small-icon {
width: 1.2rem;
height: 1.2rem;
}
.small-text {
font-size: var(--font-2xs);
}
.dotted {
text-decoration-style: dotted;
text-decoration-line: underline;
text-decoration-thickness: 2px;
}
.summary {
border-radius: var(--radius-box);
background-color: var(--color-bg);
font-size: var(--font-xs);
font-weight: var(--weight-semi);
padding-block: var(--s-1);
padding-inline: var(--s-2);
}
html[dir="rtl"] .fix-rtl {
transform: rotate(180deg);
}
.clickable {
cursor: pointer;
}
.no-results {
text-align: center;
color: var(--color-text-high);
font-style: italic;
font-size: var(--font-xs);
}
.half-width {
width: 100%;
max-width: 24rem;
margin-inline: auto;
}
}

View File

@@ -0,0 +1,313 @@
@layer reset, base, elements, components, utilities;
/*
⚠️⚠️⚠️
Make sure to read styles.md before editing colors in this file,
it contains important information about how the styles work and how to edit them properly
⚠️⚠️⚠️
*/
/*
All the tokens that can be set by custom themes
and their default values are defined here
These should not be consumed directly, only used as a base for other vars!
The hue and chroma values should not be edited manually, instead
use oklch-gamut.ts to generate valid color palettes!
A subtree can force a color scheme regardless of the html class with
[data-theme="dark"|"light"] and opt out of an active custom theme with
[data-default-theme] (e.g. image export previews)
*/
html,
[data-default-theme] {
--_base-h: 268;
--_base-c-0: 0;
--_base-c-1: 0.02357547860966049;
--_base-c-2: 0.031;
--_base-c-3: 0.06999999999999999;
--_base-c-4: 0.0645;
--_base-c-5: 0.068;
--_base-c-6: 0.0645;
--_base-c-7: 0.0335;
--_acc-h: 253;
--_acc-c-0: 0.0801740871856143;
--_acc-c-1: 0.1603481743712286;
--_acc-c-2: 0.0816;
--_acc-c-3: 0.06;
--_acc-c-4: 0.16343179310913686;
--_acc-c-5: 0.09867579961306376;
--_second-h: 73;
--_second-c-0: 0.055379201356259726;
--_second-c-1: 0.11075840271251945;
--_second-c-2: 0.0816;
--_second-c-3: 0.06;
--_second-c-4: 0.11288837199545253;
--_second-c-5: 0.06815901705385813;
--_radius-box: 3;
--_radius-field: 2;
--_radius-selector: 2;
--_border-width: 2;
/*
Ommited from being overridden when viewing a page that loads a custom theme
to prevent accessibility issues and layout shifts
*/
--_size-field: 1;
--_size-selector: 1;
--_size-spacing: 1;
}
/*
'low' and 'high' do not indicate lightness!
This is important because dark goes from light to dark, and light goes from dark to light
--color-base-x should rarely be consumed directly
They mostly exist as a base for other vars like --color-text and --color-bg
--color-text-accent and --color-text-second can be used as a background
or highlight color that maintains a high contrast ratio in both light and dark modes
If used as a background color, --color-text-inverse should be used as the text color
Info, success, warning, and error colors:
Preffer using --color-x-high for text
It is recommended to use --color-x-low as the background to ensure a high contrast ratio
You can use --color-x for things like borders and icons
Field icons are defined for both dark and light mode because SVGs
cannot use currentColor inside data URLs
Any changes here NEED to be reflected in oklch-gamut.ts as well
*/
html.dark,
html.dark [data-custom-theme],
[data-theme="dark"] {
--color-base-0: oklch(100% var(--_base-c-0) var(--_base-h));
--color-base-1: oklch(95% var(--_base-c-1) var(--_base-h));
--color-base-2: oklch(90% var(--_base-c-2) var(--_base-h));
--color-base-3: oklch(64% var(--_base-c-3) var(--_base-h));
--color-base-4: oklch(46% var(--_base-c-4) var(--_base-h));
--color-base-5: oklch(32% var(--_base-c-5) var(--_base-h));
--color-base-6: oklch(25% var(--_base-c-6) var(--_base-h));
--color-base-7: oklch(17% var(--_base-c-7) var(--_base-h));
--color-accent-low: oklch(26% var(--_acc-c-0) var(--_acc-h));
--color-accent: oklch(52% var(--_acc-c-1) var(--_acc-h));
--color-accent-high: oklch(83% var(--_acc-c-2) var(--_acc-h));
--color-second-low: oklch(
from var(--color-accent-low) l var(--_second-c-0) var(--_second-h)
);
--color-second: oklch(
from var(--color-accent) l var(--_second-c-1) var(--_second-h)
);
--color-second-high: oklch(
from var(--color-accent-high) l var(--_second-c-2) var(--_second-h)
);
--color-text-accent: var(--color-accent-high);
--color-text-second: var(--color-second-high);
--color-info-low: oklch(26% 0.09 275);
--color-info: oklch(52% 0.27 275);
--color-info-high: oklch(83% 0.08 275);
--color-success-low: oklch(35% 0.08 135);
--color-success: oklch(85% 0.22 135);
--color-success-high: oklch(91% 0.12 135);
--color-warning-low: oklch(38% 0.05 85);
--color-warning: oklch(82% 0.14 85);
--color-warning-high: oklch(94% 0.05 85);
--color-error-low: oklch(31% 0.07 10);
--color-error: oklch(65% 0.25 10);
--color-error-high: oklch(87% 0.07 10);
--color-chart-alpha: #da5b76;
--color-chart-bravo: #6572e4;
--field-icon-checkbox: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(20, 20, 20)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");
--field-icon-minus: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(20, 20, 20)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E");
--field-icon-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(145, 145, 145)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
--field-icon-date: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(145, 145, 145)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Cline x1='16' y1='2' x2='16' y2='6'%3E%3C/line%3E%3Cline x1='8' y1='2' x2='8' y2='6'%3E%3C/line%3E%3Cline x1='3' y1='10' x2='21' y2='10'%3E%3C/line%3E%3C/svg%3E");
--field-icon-time: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(145, 145, 145)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cpolyline points='12 6 12 12 16 14'%3E%3C/polyline%3E%3C/svg%3E");
}
html.light,
html.light [data-custom-theme],
[data-theme="light"] {
--color-base-0: oklch(17% var(--_base-c-7) var(--_base-h));
--color-base-1: oklch(25% var(--_base-c-6) var(--_base-h));
--color-base-2: oklch(32% var(--_base-c-5) var(--_base-h));
--color-base-3: oklch(46% var(--_base-c-4) var(--_base-h));
--color-base-4: oklch(64% var(--_base-c-3) var(--_base-h));
--color-base-5: oklch(90% var(--_base-c-2) var(--_base-h));
--color-base-6: oklch(95% var(--_base-c-1) var(--_base-h));
--color-base-7: oklch(100% var(--_base-c-0) var(--_base-h));
--color-accent-low: oklch(88% var(--_acc-c-3) var(--_acc-h));
--color-accent: oklch(53% var(--_acc-c-4) var(--_acc-h));
--color-accent-high: oklch(32% var(--_acc-c-5) var(--_acc-h));
--color-second-low: oklch(
from var(--color-accent-low) l var(--_second-c-0) var(--_second-h)
);
--color-second: oklch(
from var(--color-accent) l var(--_second-c-1) var(--_second-h)
);
--color-second-high: oklch(
from var(--color-accent-high) l var(--_second-c-2) var(--_second-h)
);
--color-text-accent: var(--color-accent);
--color-text-second: var(--color-second);
--color-info-low: oklch(87.897% 0.0555 279.573);
--color-info: oklch(53.03% 0.24331 270.147);
--color-info-high: oklch(31.865% 0.1766 267.138);
--color-success-low: oklch(95.604% 0.06419 135.506);
--color-success: oklch(79.407% 0.25035 139.5);
--color-success-high: oklch(44.893% 0.13293 138.416);
--color-warning-low: oklch(94.216% 0.05193 85.101);
--color-warning: oklch(82.426% 0.15029 82.564);
--color-warning-high: oklch(46.236% 0.09033 79.825);
--color-error-low: oklch(89.949% 0.04928 355.498);
--color-error: oklch(65.223% 0.22164 5.216);
--color-error-high: oklch(41.268% 0.15511 8.446);
--color-chart-alpha: #b82851;
--color-chart-bravo: #4246c2;
--field-icon-checkbox: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");
--field-icon-minus: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E");
--field-icon-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(90, 90, 90)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
--field-icon-date: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(90, 90, 90)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Cline x1='16' y1='2' x2='16' y2='6'%3E%3C/line%3E%3Cline x1='8' y1='2' x2='8' y2='6'%3E%3C/line%3E%3Cline x1='3' y1='10' x2='21' y2='10'%3E%3C/line%3E%3C/svg%3E");
--field-icon-time: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(90, 90, 90)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cpolyline points='12 6 12 12 16 14'%3E%3C/polyline%3E%3C/svg%3E");
}
/*
These are the vars you mainly want to use
*/
html,
/* biome-ignore lint/style/noDescendingSpecificity: [data-custom-theme] and [data-theme] re-declare theme vars for subtrees; different properties than the html.dark/light blocks so no real override */
[data-custom-theme],
[data-theme] {
--color-text: var(--color-base-0);
--color-text-high: var(--color-base-3);
--color-text-inverse: var(--color-base-7);
--color-bg: var(--color-base-7);
--color-bg-high: var(--color-base-6);
--color-bg-higher: var(--color-base-5);
--color-bg-nav: color-mix(
in oklch,
var(--color-bg) 75%,
var(--color-bg-high) 25%
);
--color-bg-ability: oklch(11.736% 0.00867 215.976);
--color-bg-badge: oklch(0% 0 0);
--color-border: var(--color-base-5);
--color-border-high: var(--color-base-4);
--border-width: calc(var(--_border-width) * 1px);
--border-style: var(--border-width) solid var(--color-border);
--border-style-high: var(--border-width) solid var(--color-border-high);
--border-style-accent: var(--border-width) solid var(--color-text-accent);
--focus-ring: 2px solid var(--color-text-accent);
--focus-ring-error: 2px solid var(--color-error);
--font-xl: 1.5rem;
--font-lg: 1.2rem;
--font-md: 1rem;
--font-sm: 0.9rem;
--font-xs: 0.8rem;
--font-2xs: 0.7rem;
--font-3xs: 0.6rem;
--weight-extra: 700;
--weight-bold: 600;
--weight-semi: 500;
--weight-body: 400;
--line-height: 1.5;
/*
Tokens used for spacings
*/
--s-0-5: calc(var(--_size-spacing) * 0.5 * 0.25rem);
--s-1: calc(var(--_size-spacing) * 1 * 0.25rem);
--s-1-5: calc(var(--_size-spacing) * 1.5 * 0.25rem);
--s-2: calc(var(--_size-spacing) * 2 * 0.25rem);
--s-2-5: calc(var(--_size-spacing) * 2.5 * 0.25rem);
--s-3: calc(var(--_size-spacing) * 3 * 0.25rem);
--s-4: calc(var(--_size-spacing) * 4 * 0.25rem);
--s-5: calc(var(--_size-spacing) * 5 * 0.25rem);
--s-6: calc(var(--_size-spacing) * 6 * 0.25rem);
--s-8: calc(var(--_size-spacing) * 8 * 0.25rem);
--s-10: calc(var(--_size-spacing) * 10 * 0.25rem);
--s-12: calc(var(--_size-spacing) * 12 * 0.25rem);
--s-16: calc(var(--_size-spacing) * 16 * 0.25rem);
--s-24: calc(var(--_size-spacing) * 24 * 0.25rem);
--s-32: calc(var(--_size-spacing) * 32 * 0.25rem);
/*
Tokens used to define layout dimensions
--layout-sticky-top can be used by any element with position: sticky
that sticks relative to the document scroll to ensure
a smooth transition between sticking and un-sticking
Elements with position: sticky that do not stick relative
to the document scroll do not need to use this var
*/
--layout-nav-height: 55px;
--layout-sidenav-width: 250px;
--layout-fuse-bottom-height: 90px;
--layout-main-padding: var(--s-3);
--layout-sticky-top: calc(
var(--layout-nav-height) +
var(--layout-main-padding) +
env(safe-area-inset-top)
);
/*
Boxes are elements that contain other elements, like cards, containers, and modals
but can also be top level elements in a visual hierarchy
Fields are interactive elements like buttons, inputs, and selects
but also any child of a box that is interactable or contains content
Selectors are elements like checkboxes, radio buttons, and toggles
but can also be any small element like bages, tags, and pills
that are complementary to fields
*/
--field-size-xs: calc(var(--_size-field) * 6 * 0.25rem);
--field-size-sm: calc(var(--_size-field) * 8 * 0.25rem);
--field-size: calc(var(--_size-field) * 10 * 0.25rem);
--field-size-lg: calc(var(--_size-field) * 12 * 0.25rem);
--field-size-icon: 18px;
--field-padding: 0.6rem;
--selector-size-xs: calc(var(--_size-selector) * 4 * 0.25rem);
--selector-size-sm: calc(var(--_size-selector) * 5 * 0.25rem);
--selector-size: calc(var(--_size-selector) * 6 * 0.25rem);
--selector-size-lg: calc(var(--_size-selector) * 7 * 0.25rem);
--radius-box: calc(var(--_radius-box) * 4px);
--radius-field: calc(var(--_radius-field) * 4px);
--radius-selector: calc(var(--_radius-selector) * 4px);
--radius-full: 99999px;
--radius-avatar: var(--radius-full);
--label-margin: var(--s-1);
}

View File

@@ -0,0 +1,15 @@
export function databaseTimestampToDate(timestamp: number) {
return new Date(databaseTimestampToJavascriptTimestamp(timestamp));
}
export function databaseTimestampToJavascriptTimestamp(timestamp: number) {
return timestamp * 1000;
}
export function dateToDatabaseTimestamp(date: Date) {
return Math.floor(date.getTime() / 1000);
}
export function databaseTimestampNow() {
return dateToDatabaseTimestamp(new Date());
}

View File

@@ -0,0 +1,5 @@
export const IS_E2E_TEST_RUN =
(typeof import.meta.env !== "undefined" &&
import.meta.env.VITE_E2E_TEST_RUN === "true") ||
(typeof process !== "undefined" &&
process.env.VITE_E2E_TEST_RUN === "true");

View File

@@ -0,0 +1,29 @@
import { logger } from "@sendou/utils/logger";
const DISCORD_ID_MIN_LENGTH = 17;
// snowflake logic from https://github.dev/vegeta897/snow-stamp/blob/main/src/util.js
const DISCORD_EPOCH = 1420070400000;
/** Converts a Discord snowflake ID string into the `Date` it encodes. */
export function convertSnowflakeToDate(snowflake: string) {
const milliseconds = BigInt(snowflake) >> 22n;
return new Date(Number(milliseconds) + DISCORD_EPOCH);
}
const AGED_CRITERIA = 1000 * 60 * 60 * 24 * 30 * 3; // 3 months
/** Whether the user's Discord account is old enough to unlock features gated on account age. */
export function userDiscordIdIsAged(user: { discordId: string }) {
if (!user.discordId || user.discordId.length < DISCORD_ID_MIN_LENGTH) {
logger.error("Invalid or missing discord id", {
discordId: user.discordId,
});
return false;
}
const timestamp = convertSnowflakeToDate(user.discordId).getTime();
return Date.now() - timestamp > AGED_CRITERIA;
}

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { page } from "$app/state";
</script>
<main data-testid="error-page">
<h2>Error {page.status}</h2>
</main>

View File

@@ -0,0 +1,12 @@
<script lang="ts">
import "#lib/styles/fonts.css";
import "#lib/styles/vars.css";
import "#lib/styles/normalize.css";
import "#lib/styles/common.css";
import "#lib/styles/utils.css";
import "#lib/styles/flags.css";
let { children } = $props();
</script>
{@render children()}

View File

@@ -0,0 +1 @@
<h1>Leaderboards</h1>

9
apps/web/tsconfig.json Normal file
View File

@@ -0,0 +1,9 @@
{
"extends": "$app/tsconfig",
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": false,
"types": ["$app/types", "node", "vite/client"]
},
"include": ["src", "scripts", "vite.config.ts"]
}

23
apps/web/vite.config.ts Normal file
View File

@@ -0,0 +1,23 @@
import { paraglideVitePlugin } from "@inlang/paraglide-js";
import adapter from "@sveltejs/adapter-node";
import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite";
export default defineConfig(({ mode }) => ({
plugins: [
paraglideVitePlugin({
project: "./project.inlang",
outdir: "./src/lib/paraglide",
strategy: ["cookie", "preferredLanguage", "baseLocale"],
outputStructure: mode === "production" ? "message-modules" : "locale-modules",
}),
sveltekit({
adapter: adapter(),
compilerOptions: { experimental: { async: true } },
experimental: { remoteFunctions: true },
}),
],
test: {
include: ["src/**/*.test.ts"],
},
}));

966
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff