diff --git a/apps/web/src/lib/components/match-page/IconBanner.svelte b/apps/web/src/lib/components/match-page/IconBanner.svelte
new file mode 100644
index 000000000..37d47d02d
--- /dev/null
+++ b/apps/web/src/lib/components/match-page/IconBanner.svelte
@@ -0,0 +1,81 @@
+
+
+
+ {@render icon()}
+
+ {#if subtitle}
+
{subtitle}
+ {/if}
+ {#if joinPool}
+ {@render joinInfo({ pool: joinPool, pass: joinPass, host })}
+ {/if}
+ {#if screenLegal !== undefined}
+ {@render screenNotice(screenLegal)}
+ {/if}
+ {#if topRight}
+
{@render topRight()}
+ {/if}
+
+
+
diff --git a/apps/web/src/lib/components/match-page/MatchActionTab.svelte b/apps/web/src/lib/components/match-page/MatchActionTab.svelte
new file mode 100644
index 000000000..d40cf5499
--- /dev/null
+++ b/apps/web/src/lib/components/match-page/MatchActionTab.svelte
@@ -0,0 +1,545 @@
+
+
+
+
+
+ {#if confirming && winnerId !== null && setEnding}
+ {@render setEndingConfirmation(winnerId, setEnding)}
+ {:else}
+
+
{m.q_match_action_selectWinner()}
+ {#if actionButtons}
+
{@render actionButtons()}
+ {/if}
+
+
+ {@render teamRadioOption(teams[0], "alpha", "winner-radio-1")}
+
+
+
+ {shortStageName(stageName(stageId))}
+
+ {@render teamRadioOption(teams[1], "bravo", "winner-radio-2")}
+
+
+ {#if withKo}
+
+
+
+ {/if}
+
+
+
+ {/if}
+ {@render secondaryAction?.()}
+
+
+{#snippet setEndingConfirmation(
+ confirmedWinnerId: number,
+ setEndingData: SetEndingData,
+)}
+ {@const winnerSide = confirmedWinnerId === teams[0].id ? "ALPHA" : "BRAVO"}
+ {@const newMap = {
+ stageId,
+ mode,
+ timestamp: Date.now(),
+ winner: winnerSide,
+ rosters: setEndingData.currentRosters,
+ ko: withKo ? isKo : undefined,
+ } satisfies TimelineMap}
+ {@const updatedScore = {
+ alpha: setEndingData.score.alpha + (winnerSide === "ALPHA" ? 1 : 0),
+ bravo: setEndingData.score.bravo + (winnerSide === "BRAVO" ? 1 : 0),
+ }}
+
+
+ {m.q_match_action_confirmSetEnding()}
+
+
+
+
+
+
+
+{/snippet}
+
+{#snippet teamRadioOption(
+ team: ActionTabTeam,
+ areaClass: "alpha" | "bravo",
+ testId: string,
+)}
+ {@const isSelected = winnerId === team.id}
+ {@const isOwnTeam = team.id === ownTeamId}
+ {@const hideLabel = ownTeamId === null}
+ {@const isLongName = team.name.length > LONG_TEAM_NAME_THRESHOLD}
+
+{/snippet}
+
+
diff --git a/apps/web/src/lib/components/match-page/MatchBanner.svelte b/apps/web/src/lib/components/match-page/MatchBanner.svelte
new file mode 100644
index 000000000..ee53a9c65
--- /dev/null
+++ b/apps/web/src/lib/components/match-page/MatchBanner.svelte
@@ -0,0 +1,232 @@
+
+
+
+
+
+
+
+ {modeShortName(mode)} {stageName(stageId)}
+
+
{@render children?.()}
+
+ {#if joinPool}
+ {@render joinInfo({ pool: joinPool, pass: joinPass, host })}
+ {/if}
+ {#if screenLegal !== undefined}
+ {@render screenNotice(screenLegal)}
+ {/if}
+
+
+{#snippet joinInfo({
+ pool,
+ pass,
+ host,
+}: {
+ pool: string;
+ pass?: string | null;
+ host?: BannerHost | null;
+})}
+
+
+
{m.q_match_pool()}
+
{pool}
+
+ {#if pass}
+
+
{m.q_match_password_short()}
+
{pass}
+
+ {/if}
+ {#if host}
+
+ {/if}
+
+{/snippet}
+
+{#snippet screenNotice(screenLegal: boolean)}
+
+
+ {#snippet trigger(triggerProps)}
+
+ {/snippet}
+ {screenLegal
+ ? m.q_match_screen_allowed({ special: m.weapons_SPECIAL_19() })
+ : m.q_match_screen_ban({ special: m.weapons_SPECIAL_19() })}
+
+
+{/snippet}
+
+
diff --git a/apps/web/src/lib/components/match-page/MatchBannerContainer.svelte b/apps/web/src/lib/components/match-page/MatchBannerContainer.svelte
new file mode 100644
index 000000000..105d5b542
--- /dev/null
+++ b/apps/web/src/lib/components/match-page/MatchBannerContainer.svelte
@@ -0,0 +1,22 @@
+
+
+{@render children()}
+
+
diff --git a/apps/web/src/lib/components/match-page/MatchBannerScheduledTime.svelte b/apps/web/src/lib/components/match-page/MatchBannerScheduledTime.svelte
new file mode 100644
index 000000000..8f7de2d9b
--- /dev/null
+++ b/apps/web/src/lib/components/match-page/MatchBannerScheduledTime.svelte
@@ -0,0 +1,20 @@
+
+
+
diff --git a/apps/web/src/lib/components/match-page/MatchBannerTopRow.svelte b/apps/web/src/lib/components/match-page/MatchBannerTopRow.svelte
new file mode 100644
index 000000000..da88aaa7c
--- /dev/null
+++ b/apps/web/src/lib/components/match-page/MatchBannerTopRow.svelte
@@ -0,0 +1,63 @@
+
+
+
+ {#if score}
+ {@render scoreDisplay(score)}
+ {:else}
+
+ {/if}
+ {@render children?.()}
+
+
+{#snippet scoreDisplay(score: Score)}
+
+
+ {score.alpha}-{score.bravo}
+
+
+ {#if score.isFinal}
+ {m.q_match_banner_final()}
+ {:else if score.count !== undefined}
+ {score.bestOf
+ ? m.q_match_banner_bestOf({ count: score.count })
+ : m.q_match_banner_playAll({ count: score.count })}
+ {/if}
+
+
+{/snippet}
+
+
diff --git a/apps/web/src/lib/components/match-page/MatchPage.svelte b/apps/web/src/lib/components/match-page/MatchPage.svelte
new file mode 100644
index 000000000..0b112c2f0
--- /dev/null
+++ b/apps/web/src/lib/components/match-page/MatchPage.svelte
@@ -0,0 +1,20 @@
+
+
+{@render children?.()}
+
+
diff --git a/apps/web/src/lib/components/match-page/MatchPageHeader.svelte b/apps/web/src/lib/components/match-page/MatchPageHeader.svelte
new file mode 100644
index 000000000..366f66116
--- /dev/null
+++ b/apps/web/src/lib/components/match-page/MatchPageHeader.svelte
@@ -0,0 +1,36 @@
+
+
+
+
+
{@render children()}
+
{subtitle}
+
+ {#if topRight}
{@render topRight()}
{/if}
+
+
+
diff --git a/apps/web/src/lib/components/match-page/MatchResultTab.svelte b/apps/web/src/lib/components/match-page/MatchResultTab.svelte
new file mode 100644
index 000000000..acaa7e910
--- /dev/null
+++ b/apps/web/src/lib/components/match-page/MatchResultTab.svelte
@@ -0,0 +1,19 @@
+
+
+
+
+ {@render children?.()}
+
diff --git a/apps/web/src/lib/components/match-page/MatchRosterTab.svelte b/apps/web/src/lib/components/match-page/MatchRosterTab.svelte
new file mode 100644
index 000000000..2b89b79a0
--- /dev/null
+++ b/apps/web/src/lib/components/match-page/MatchRosterTab.svelte
@@ -0,0 +1,275 @@
+
+
+
+
+
+
+ {@render teamRoster(teams[0], "alpha")}
+
+ {@render teamRoster(teams[1], "bravo")}
+
+
+
+{#snippet teamRoster(team: RosterTabTeam, side: "alpha" | "bravo")}
+
+ {@render teamHeader(team, side)}
+ {#if team.members.length > 0}
+
+ {#each team.members as member (member.id)}
+ -
+ {@render rosterMemberLink(member)}
+
+
+ {/each}
+
+ {/if}
+
+{/snippet}
+
+{#snippet teamHeader(team: RosterTabTeam, side: "alpha" | "bravo")}
+ {#if team.team}
+
+
+
+
+ {:else}
+ {@const defaultName = defaultNameOf(team)}
+
+ {/if}
+{/snippet}
+
+{#snippet teamHeaderMeta()}
+
+
+{/snippet}
+
+{#snippet rosterMemberLink(member: RosterTabMember)}
+ {@const sentiment = userCardContext
+ ?.userCards()
+ ?.get(member.id)?.privateNote?.sentiment}
+
+
+
+
+
+
+ {member.username}
+ {#if member.inGameName}
+ {member.inGameName}
+ {/if}
+
+
+
+{/snippet}
+
+
diff --git a/apps/web/src/lib/components/match-page/MatchTabs.svelte b/apps/web/src/lib/components/match-page/MatchTabs.svelte
new file mode 100644
index 000000000..94d549378
--- /dev/null
+++ b/apps/web/src/lib/components/match-page/MatchTabs.svelte
@@ -0,0 +1,72 @@
+
+
+
+ params.set({ tab: key as MatchTabsKey })}
+ disappearing={false}
+ padded={false}
+ >
+
+ {#each tabs as tab (tab)}
+ {@const Icon = TAB_ICONS[tab]}
+
+ {#snippet icon()}{/snippet}
+ {TAB_LABELS[tab]()}
+
+ {/each}
+
+
+ {@render children()}
+
+
+
+
diff --git a/apps/web/src/lib/components/match-page/MatchTimeline.svelte b/apps/web/src/lib/components/match-page/MatchTimeline.svelte
new file mode 100644
index 000000000..fe7d2a953
--- /dev/null
+++ b/apps/web/src/lib/components/match-page/MatchTimeline.svelte
@@ -0,0 +1,458 @@
+
+
+
+
+
+ {@render timelineHeader()}
+ {#if !compact}
+ {#each maps as map, i (`${map.timestamp}-${i}`)}
+ {@const previousMap = maps[i - 1]}
+ {@const substitutions = previousMap
+ ? inferSubstitutions(previousMap.rosters, map.rosters)
+ : []}
+
+ {#each substitutions as substitution (`${substitution.playerOut.id}-${substitution.playerIn.id}`)}
+ {@render timelineSubstitutionRow(substitution)}
+ {/each}
+ {@render timelineMapRow(map)}
+
+ {/each}
+ {/if}
+
+
+{#snippet timelineHeader()}
+ {@const initialRosters = maps[0]?.rosters}
+
+{/snippet}
+
+{#snippet timelineMapRow(map: TimelineMap)}
+
+
+ {@render sideResult(
+ map.winner === "ALPHA" ? "WIN" : "LOSS",
+ map.ko && map.winner === "ALPHA",
+ )}
+
+
+
+
+
+
+ {shortStageName(stageName(map.stageId))}
+
+
+
+ {@render sideResult(
+ map.winner === "BRAVO" ? "WIN" : "LOSS",
+ map.ko && map.winner === "BRAVO",
+ )}
+
+
+{/snippet}
+
+{#snippet sideResult(result: "WIN" | "LOSS", isKo: boolean | undefined)}
+
+
+
+{/snippet}
+
+{#snippet explainerIcon(icon: Snippet, description: string)}
+
+ {#snippet trigger(triggerProps)}
+
+ {/snippet}
+ {description}
+
+{/snippet}
+
+{#snippet substitutionIcon()}
+
+{/snippet}
+
+{#snippet timelineSubstitutionRow(substitution: InferredSubstitution)}
+
+
+ {#if substitution.side === "ALPHA"}
+ {@render substitutionDetail(substitution)}
+ {/if}
+
+
+ {@render explainerIcon(
+ substitutionIcon,
+ m.q_match_timeline_explainer_substitution(),
+ )}
+
+
+ {#if substitution.side === "BRAVO"}
+ {@render substitutionDetail(substitution)}
+ {/if}
+
+
+{/snippet}
+
+{#snippet substitutionDetail(substitution: InferredSubstitution)}
+
+
{m.q_match_timeline_out()}
+
+
+
+ {substitution.playerOut.username}
+
+
+
{m.q_match_timeline_in()}
+
+
+
+ {substitution.playerIn.username}
+
+
+
+{/snippet}
+
+
diff --git a/apps/web/src/lib/components/match-page/SecondaryAction.svelte b/apps/web/src/lib/components/match-page/SecondaryAction.svelte
new file mode 100644
index 000000000..94239359e
--- /dev/null
+++ b/apps/web/src/lib/components/match-page/SecondaryAction.svelte
@@ -0,0 +1,113 @@
+
+
+{#if !isOpen && collapsible}
+
+
+
+{:else}
+
+ {#if collapsible}
+
+ {/if}
+ {@render children()}
+
+{/if}
+
+
diff --git a/apps/web/src/lib/components/match-page/match-page-constants.ts b/apps/web/src/lib/components/match-page/match-page-constants.ts
new file mode 100644
index 000000000..983056e13
--- /dev/null
+++ b/apps/web/src/lib/components/match-page/match-page-constants.ts
@@ -0,0 +1,17 @@
+export const TAB_KEYS = {
+ ROSTERS: "rosters",
+ ACTION: "action",
+ RESULT: "result",
+ STATS: "stats",
+ ADMIN: "admin",
+} as const;
+
+export const MATCH_PAGE_TABS = [
+ "rosters",
+ "action",
+ "result",
+ "stats",
+ "admin",
+] as const;
+
+export type MatchTabsKey = (typeof TAB_KEYS)[keyof typeof TAB_KEYS];
diff --git a/apps/web/src/lib/components/match-page/match-page-search-params.test.ts b/apps/web/src/lib/components/match-page/match-page-search-params.test.ts
new file mode 100644
index 000000000..0a4a4cd83
--- /dev/null
+++ b/apps/web/src/lib/components/match-page/match-page-search-params.test.ts
@@ -0,0 +1,19 @@
+import { describe, expect, test } from "vitest";
+import { assertRoundTrips } from "#lib/modules/search-params/search-params-test-utils.ts";
+import { matchPageSearchParams } from "./match-page-search-params.ts";
+
+describe("matchPageSearchParams", () => {
+ test("round-trips representative values", () => {
+ assertRoundTrips(matchPageSearchParams, {
+ tab: [null, "rosters", "action", "result", "stats", "admin"],
+ });
+ });
+
+ test("decodes garbage to defaults", () => {
+ const parsed = matchPageSearchParams.parse(
+ new URLSearchParams("tab=nonsense"),
+ );
+
+ expect(parsed).toEqual({ tab: null });
+ });
+});
diff --git a/apps/web/src/lib/components/match-page/match-page-search-params.ts b/apps/web/src/lib/components/match-page/match-page-search-params.ts
new file mode 100644
index 000000000..8ca617195
--- /dev/null
+++ b/apps/web/src/lib/components/match-page/match-page-search-params.ts
@@ -0,0 +1,8 @@
+import * as v from "valibot";
+import * as SearchParams from "#lib/modules/search-params/search-params.ts";
+import { SP } from "#lib/modules/search-params/search-params.ts";
+import { MATCH_PAGE_TABS } from "./match-page-constants.ts";
+
+export const matchPageSearchParams = SearchParams.define({
+ tab: SP.param(v.nullable(v.picklist(MATCH_PAGE_TABS)), {}),
+});
diff --git a/apps/web/src/lib/components/match-page/match-page-utils.test.ts b/apps/web/src/lib/components/match-page/match-page-utils.test.ts
new file mode 100644
index 000000000..761da142a
--- /dev/null
+++ b/apps/web/src/lib/components/match-page/match-page-utils.test.ts
@@ -0,0 +1,142 @@
+import { describe, expect, test } from "vitest";
+import type { CommonUser } from "#lib/server/kysely.ts";
+import { inferSubstitutions, resolveRoomPass } from "./match-page-utils.ts";
+
+function user(id: number): CommonUser {
+ return {
+ id,
+ username: `user${id}`,
+ discordId: `discord${id}`,
+ discordAvatar: null,
+ customUrl: null,
+ customAvatarUrl: null,
+ };
+}
+
+describe("inferSubstitutions", () => {
+ test("returns an empty array when rosters are unchanged", () => {
+ const rosters = {
+ alpha: [user(1), user(2), user(3), user(4)],
+ bravo: [user(5), user(6), user(7), user(8)],
+ };
+
+ expect(inferSubstitutions(rosters, rosters)).toEqual([]);
+ });
+
+ test("detects a single substitution on alpha", () => {
+ const previous = {
+ alpha: [user(1), user(2), user(3), user(4)],
+ bravo: [user(5), user(6), user(7), user(8)],
+ };
+ const current = {
+ alpha: [user(1), user(2), user(3), user(9)],
+ bravo: previous.bravo,
+ };
+
+ expect(inferSubstitutions(previous, current)).toEqual([
+ { side: "ALPHA", playerOut: user(4), playerIn: user(9) },
+ ]);
+ });
+
+ test("detects substitutions on both sides in the same map transition", () => {
+ const previous = {
+ alpha: [user(1), user(2)],
+ bravo: [user(3), user(4)],
+ };
+ const current = {
+ alpha: [user(1), user(10)],
+ bravo: [user(11), user(4)],
+ };
+
+ expect(inferSubstitutions(previous, current)).toEqual([
+ { side: "ALPHA", playerOut: user(2), playerIn: user(10) },
+ { side: "BRAVO", playerOut: user(3), playerIn: user(11) },
+ ]);
+ });
+
+ test("pairs multiple substitutions on the same side by roster order", () => {
+ const previous = {
+ alpha: [user(1), user(2), user(3), user(4)],
+ bravo: [user(5), user(6)],
+ };
+ const current = {
+ alpha: [user(1), user(10), user(3), user(11)],
+ bravo: previous.bravo,
+ };
+
+ expect(inferSubstitutions(previous, current)).toEqual([
+ { side: "ALPHA", playerOut: user(2), playerIn: user(10) },
+ { side: "ALPHA", playerOut: user(4), playerIn: user(11) },
+ ]);
+ });
+
+ test("ignores unpaired leavers when no new player joined", () => {
+ const previous = {
+ alpha: [user(1), user(2), user(3), user(4)],
+ bravo: [user(5), user(6)],
+ };
+ const current = {
+ alpha: [user(1), user(2), user(3)],
+ bravo: previous.bravo,
+ };
+
+ expect(inferSubstitutions(previous, current)).toEqual([]);
+ });
+
+ test("ignores unpaired joiners when no player left", () => {
+ const previous = {
+ alpha: [user(1), user(2), user(3)],
+ bravo: [user(5), user(6)],
+ };
+ const current = {
+ alpha: [user(1), user(2), user(3), user(9)],
+ bravo: previous.bravo,
+ };
+
+ expect(inferSubstitutions(previous, current)).toEqual([]);
+ });
+
+ test("treats players switching sides as separate substitutions on each side", () => {
+ const previous = {
+ alpha: [user(1), user(2)],
+ bravo: [user(3), user(4)],
+ };
+ const current = {
+ alpha: [user(3), user(4)],
+ bravo: [user(1), user(2)],
+ };
+
+ expect(inferSubstitutions(previous, current)).toEqual([
+ { side: "ALPHA", playerOut: user(1), playerIn: user(3) },
+ { side: "ALPHA", playerOut: user(2), playerIn: user(4) },
+ { side: "BRAVO", playerOut: user(3), playerIn: user(1) },
+ { side: "BRAVO", playerOut: user(4), playerIn: user(2) },
+ ]);
+ });
+});
+
+describe("resolveRoomPass", () => {
+ test("returns a 4-digit password", () => {
+ const pass = resolveRoomPass(12345);
+
+ expect(pass).toMatch(/^\d{4}$/);
+ });
+
+ test("returns deterministic password for a given numeric seed", () => {
+ const pass1 = resolveRoomPass(12345);
+ const pass2 = resolveRoomPass(12345);
+ expect(pass1).toBe(pass2);
+ });
+
+ test("returns deterministic password for a given string seed", () => {
+ const pass1 = resolveRoomPass("test-seed");
+ const pass2 = resolveRoomPass("test-seed");
+ expect(pass1).toBe(pass2);
+ });
+
+ test("returns different passwords for different seeds", () => {
+ const pass1 = resolveRoomPass(1);
+ const pass2 = resolveRoomPass(2);
+ expect(pass1).not.toBe(pass2);
+ });
+});
diff --git a/apps/web/src/lib/components/match-page/match-page-utils.ts b/apps/web/src/lib/components/match-page/match-page-utils.ts
new file mode 100644
index 000000000..463cc7808
--- /dev/null
+++ b/apps/web/src/lib/components/match-page/match-page-utils.ts
@@ -0,0 +1,83 @@
+import { seededRandom } from "@sendou/utils/random";
+import * as R from "remeda";
+import type { CommonUser } from "#lib/server/kysely.ts";
+
+type MatchSide = "ALPHA" | "BRAVO";
+
+type Rosters = {
+ alpha: CommonUser[];
+ bravo: CommonUser[];
+};
+
+export interface InferredSubstitution {
+ side: MatchSide;
+ playerOut: CommonUser;
+ playerIn: CommonUser;
+}
+
+/**
+ * Compares the rosters of two consecutive maps and pairs up any
+ * players that dropped from a side with new players that joined the same side.
+ * The pairs are returned in roster order, so the first player out is paired with
+ * the first new player in. When the counts don't match, unpaired players are ignored.
+ */
+export function inferSubstitutions(
+ previousRosters: Rosters,
+ currentRosters: Rosters,
+): InferredSubstitution[] {
+ const result: InferredSubstitution[] = [];
+
+ for (const side of ["alpha", "bravo"] as const) {
+ const prevIds = new Set(previousRosters[side].map((u) => u.id));
+ const currIds = new Set(currentRosters[side].map((u) => u.id));
+
+ const out = previousRosters[side].filter((u) => !currIds.has(u.id));
+ const inn = currentRosters[side].filter((u) => !prevIds.has(u.id));
+
+ for (const [playerOut, playerIn] of R.zip(out, inn)) {
+ result.push({
+ side: side === "alpha" ? "ALPHA" : "BRAVO",
+ playerOut,
+ playerIn,
+ });
+ }
+ }
+
+ return result;
+}
+
+const NUM_MAP = {
+ "1": ["1", "2", "4"],
+ "2": ["2", "1", "3", "5"],
+ "3": ["3", "2", "6"],
+ "4": ["4", "1", "5", "7"],
+ "5": ["5", "2", "4", "6", "8"],
+ "6": ["6", "3", "5", "9"],
+ "7": ["7", "4", "8"],
+ "8": ["8", "7", "5", "9", "0"],
+ "9": ["9", "6", "8"],
+ "0": ["0", "8"],
+};
+
+/**
+ * Generates a deterministic 4-digit Splatoon private battle room password based on the provided seed.
+ *
+ * Given the same seed, this function will always return the same password.
+ */
+export function resolveRoomPass(seed: number | string) {
+ let pass = "5";
+ for (let i = 0; i < 3; i++) {
+ const { seededShuffle } = seededRandom(`${seed}-${i}`);
+
+ const key = pass[i] as keyof typeof NUM_MAP;
+ const opts = NUM_MAP[key];
+ const next = seededShuffle(opts)[0];
+ pass += next;
+ }
+
+ // prevent 5555 since many use it as a default pass
+ // making it a bit more common guess
+ if (pass === "5555") return "5800";
+
+ return pass;
+}
diff --git a/apps/web/src/lib/db/tables-json.ts b/apps/web/src/lib/db/tables-json.ts
index 9b4c0cf77..8b3fa9283 100644
--- a/apps/web/src/lib/db/tables-json.ts
+++ b/apps/web/src/lib/db/tables-json.ts
@@ -4,11 +4,14 @@
* so client code can import the types without touching server modules.
*/
+import type { ScrimFilters } from "#lib/features/scrims/scrims-types.ts";
+
export type XRankPlacementRegion = "WEST" | "JPN";
export interface UserPreferences {
disableBuildAbilitySorting?: boolean;
disallowScrimPickupsFromUntrusted?: boolean;
+ defaultScrimsFilters?: ScrimFilters;
/**
* What time format the user prefers?
*
diff --git a/apps/web/src/lib/features/chat/chat-state.svelte.ts b/apps/web/src/lib/features/chat/chat-state.svelte.ts
new file mode 100644
index 000000000..a687a6070
--- /dev/null
+++ b/apps/web/src/lib/features/chat/chat-state.svelte.ts
@@ -0,0 +1,24 @@
+/**
+ * Client-side chat UI state shared between the layout (sidebar/mobile panel)
+ * and pages that carry a chat room (e.g. a booked scrim). Written only from
+ * client-side lifecycles, so the module-level state can't leak between
+ * server-rendered requests.
+ */
+
+class ChatUiState {
+ /** Whether the desktop chat sidebar is open. */
+ sidebarOpen = $state(false);
+ /** The room the current route belongs to (a scrim page sets this on mount). */
+ routeChatRoomId = $state(null);
+ /** The room opened in the sidebar's single-room view. */
+ openRoomId = $state(null);
+
+ toggleSidebar() {
+ this.sidebarOpen = !this.sidebarOpen;
+ if (this.sidebarOpen && this.routeChatRoomId !== null) {
+ this.openRoomId = this.routeChatRoomId;
+ }
+ }
+}
+
+export const chatUi = new ChatUiState();
diff --git a/apps/web/src/lib/features/chat/chat.remote.ts b/apps/web/src/lib/features/chat/chat.remote.ts
new file mode 100644
index 000000000..e507d580e
--- /dev/null
+++ b/apps/web/src/lib/features/chat/chat.remote.ts
@@ -0,0 +1,152 @@
+import { error } from "@sveltejs/kit";
+import * as v from "valibot";
+import { getUser, requireUser } from "#lib/features/auth/user.server.ts";
+import * as Events from "#lib/server/events.ts";
+import { id } from "#lib/utils/schemas.ts";
+import { command, query } from "$app/server";
+import { CHAT } from "./chat-constants.ts";
+import { publishChatRoom } from "./chat.server.ts";
+import { canSendToRoom, roomLifecycle } from "./chat-utils.ts";
+import * as ChatRepository from "./ChatRepository.server.ts";
+
+/**
+ * A room's message stream: yields a fresh snapshot (messages, author display
+ * info, lifecycle) whenever anything about the room changes. Membership is
+ * checked server-side — knowing a room id is not enough to read it.
+ */
+export const getChatRoom = query.live(
+ v.object({ chatRoomId: id }),
+ async function* ({ chatRoomId }) {
+ const user = requireUser();
+ await requireRoomMember(chatRoomId, user);
+
+ yield await roomSnapshot(chatRoomId);
+
+ for await (const _ of Events.subscribe(
+ Events.chatRoomChannel(chatRoomId),
+ )) {
+ if (!(await ChatRepository.findRoomById(chatRoomId))) {
+ return;
+ }
+ yield await roomSnapshot(chatRoomId);
+ }
+ },
+);
+
+async function roomSnapshot(chatRoomId: number) {
+ const room = await ChatRepository.findRoomById(chatRoomId);
+ if (!room) error(404);
+
+ const messages = await ChatRepository.findMessagesByRoomId(chatRoomId);
+ const users = await ChatRepository.findChatUsersByUserIds([
+ ...new Set(
+ messages
+ .map((message) => message.userId)
+ .filter((userId) => userId !== null),
+ ),
+ ]);
+
+ return {
+ roomId: chatRoomId,
+ messages,
+ users,
+ lifecycle: roomLifecycle(room.inactiveAt),
+ };
+}
+
+/** The user's chat rooms with unseen counts, streamed live. */
+export const getChatRooms = query.live(async function* () {
+ const user = getUser();
+
+ if (!user) {
+ yield { rooms: [] };
+ return;
+ }
+
+ yield await roomsSnapshot(user.id);
+
+ for await (const _ of Events.subscribe(
+ Events.chatRoomsOfUserChannel(user.id),
+ )) {
+ yield await roomsSnapshot(user.id);
+ }
+});
+
+async function roomsSnapshot(userId: number) {
+ const rooms = await ChatRepository.findRoomsOfUser(userId);
+
+ return {
+ rooms: rooms.map((room) => ({
+ ...room,
+ unseenCount: room.unseenCount ?? 0,
+ lifecycle: roomLifecycle(room.inactiveAt),
+ })),
+ };
+}
+
+export const sendChatMessage = command(
+ v.object({
+ chatRoomId: id,
+ contents: v.pipe(
+ v.string(),
+ v.trim(),
+ v.minLength(1),
+ v.maxLength(CHAT.MESSAGE_MAX_LENGTH),
+ ),
+ }),
+ async ({ chatRoomId, contents }) => {
+ const user = requireUser();
+ const room = await requireRoomMember(chatRoomId, user);
+
+ if (!canSendToRoom(roomLifecycle(room.inactiveAt))) {
+ error(400, "Room is archived");
+ }
+
+ const message = await ChatRepository.insertMessage({
+ chatRoomId,
+ userId: user.id,
+ contents,
+ });
+ // the sender has obviously seen their own message
+ await ChatRepository.upsertRead({
+ chatRoomId,
+ userId: user.id,
+ lastSeenMessageId: message.id,
+ });
+
+ await publishChatRoom(chatRoomId);
+ },
+);
+
+/** Moves the reader's unseen marker up to the given message. */
+export const markChatRoomRead = command(
+ v.object({ chatRoomId: id, lastSeenMessageId: id }),
+ async ({ chatRoomId, lastSeenMessageId }) => {
+ const user = requireUser();
+ await requireRoomMember(chatRoomId, user);
+
+ await ChatRepository.upsertRead({
+ chatRoomId,
+ userId: user.id,
+ lastSeenMessageId,
+ });
+
+ // other tabs of the same user drop their unseen badge too
+ Events.publish(Events.chatRoomsOfUserChannel(user.id));
+ },
+);
+
+async function requireRoomMember(
+ chatRoomId: number,
+ user: { id: number; roles: string[] },
+) {
+ const room = await ChatRepository.findRoomById(chatRoomId);
+ if (!room) error(404, "Chat room not found");
+
+ const memberIds = await ChatRepository.findRoomMemberIds(chatRoomId);
+ if (!memberIds.includes(user.id) && !user.roles.includes("STAFF")) {
+ error(403, "Not a member of this chat room");
+ }
+
+ return room;
+}
diff --git a/apps/web/src/lib/features/chat/components/Chat.svelte b/apps/web/src/lib/features/chat/components/Chat.svelte
new file mode 100644
index 000000000..b9908d218
--- /dev/null
+++ b/apps/web/src/lib/features/chat/components/Chat.svelte
@@ -0,0 +1,318 @@
+
+
+
+
+
diff --git a/apps/web/src/lib/features/chat/components/ChatSidebar.svelte b/apps/web/src/lib/features/chat/components/ChatSidebar.svelte
new file mode 100644
index 000000000..22399f39b
--- /dev/null
+++ b/apps/web/src/lib/features/chat/components/ChatSidebar.svelte
@@ -0,0 +1,397 @@
+
+
+
+
+{#snippet roomItem(room: (typeof rooms)[number])}
+
+{/snippet}
+
+
diff --git a/apps/web/src/lib/features/friends/FriendRepository.server.ts b/apps/web/src/lib/features/friends/FriendRepository.server.ts
index 7e67ebd9d..bca7b121f 100644
--- a/apps/web/src/lib/features/friends/FriendRepository.server.ts
+++ b/apps/web/src/lib/features/friends/FriendRepository.server.ts
@@ -151,3 +151,112 @@ export async function findFriendIds(userId: number): Promise {
return rows.map((row) => row.friendId);
}
+
+/**
+ * The pending friend request between the two users regardless of direction, or `undefined` when
+ * there is none.
+ */
+export async function findFriendRequestBetween({
+ senderId,
+ receiverId,
+}: {
+ senderId: number;
+ receiverId: number;
+}) {
+ return db
+ .selectFrom("FriendRequest")
+ .select(["FriendRequest.id", "FriendRequest.senderId"])
+ .where((eb) =>
+ eb.or([
+ eb.and([
+ eb("FriendRequest.senderId", "=", senderId),
+ eb("FriendRequest.receiverId", "=", receiverId),
+ ]),
+ eb.and([
+ eb("FriendRequest.senderId", "=", receiverId),
+ eb("FriendRequest.receiverId", "=", senderId),
+ ]),
+ ]),
+ )
+ .executeTakeFirst();
+}
+
+/** Users who are friends with both given users. */
+export async function findMutualFriends({
+ loggedInUserId,
+ targetUserId,
+}: {
+ loggedInUserId: number;
+ targetUserId: number;
+}) {
+ return db
+ .selectFrom("Friendship as f1")
+ .innerJoin("Friendship as f2", (join) =>
+ join.on((eb) =>
+ eb.and([
+ eb(
+ eb
+ .case()
+ .when("f1.userOneId", "=", loggedInUserId)
+ .then(eb.ref("f1.userTwoId"))
+ .else(eb.ref("f1.userOneId"))
+ .end(),
+ "=",
+ eb
+ .case()
+ .when("f2.userOneId", "=", targetUserId)
+ .then(eb.ref("f2.userTwoId"))
+ .else(eb.ref("f2.userOneId"))
+ .end(),
+ ),
+ ]),
+ ),
+ )
+ .innerJoin("User", (join) =>
+ join.on((eb) =>
+ eb(
+ "User.id",
+ "=",
+ eb
+ .case()
+ .when("f1.userOneId", "=", loggedInUserId)
+ .then(eb.ref("f1.userTwoId"))
+ .else(eb.ref("f1.userOneId"))
+ .end(),
+ ),
+ ),
+ )
+ .where((eb) =>
+ eb.or([
+ eb("f1.userOneId", "=", loggedInUserId),
+ eb("f1.userTwoId", "=", loggedInUserId),
+ ]),
+ )
+ .where((eb) =>
+ eb.or([
+ eb("f2.userOneId", "=", targetUserId),
+ eb("f2.userTwoId", "=", targetUserId),
+ ]),
+ )
+ .select((eb) => commonUserSelect(eb))
+ .execute();
+}
+
+/** The friendship row between the two users, or `undefined` when they are not friends. */
+export async function findFriendship({
+ userOneId,
+ userTwoId,
+}: {
+ userOneId: number;
+ userTwoId: number;
+}) {
+ const minId = Math.min(userOneId, userTwoId);
+ const maxId = Math.max(userOneId, userTwoId);
+
+ return db
+ .selectFrom("Friendship")
+ .select("Friendship.id")
+ .where("Friendship.userOneId", "=", minId)
+ .where("Friendship.userTwoId", "=", maxId)
+ .executeTakeFirst();
+}
diff --git a/apps/web/src/lib/features/scrims/components/AssociationSelect.svelte b/apps/web/src/lib/features/scrims/components/AssociationSelect.svelte
new file mode 100644
index 000000000..458d382b8
--- /dev/null
+++ b/apps/web/src/lib/features/scrims/components/AssociationSelect.svelte
@@ -0,0 +1,34 @@
+
+
+
diff --git a/apps/web/src/lib/features/scrims/components/BaseVisibilityFormField.svelte b/apps/web/src/lib/features/scrims/components/BaseVisibilityFormField.svelte
new file mode 100644
index 000000000..06bba2386
--- /dev/null
+++ b/apps/web/src/lib/features/scrims/components/BaseVisibilityFormField.svelte
@@ -0,0 +1,45 @@
+
+
+
+ {#if noAssociations}
+
+ {m.scrims_forms_visibility_noneAvailable()}
+
+ {:else}
+
+ {/if}
+
diff --git a/apps/web/src/lib/features/scrims/components/DivsFilterPopover.svelte b/apps/web/src/lib/features/scrims/components/DivsFilterPopover.svelte
new file mode 100644
index 000000000..adeb561e7
--- /dev/null
+++ b/apps/web/src/lib/features/scrims/components/DivsFilterPopover.svelte
@@ -0,0 +1,43 @@
+
+
+
diff --git a/apps/web/src/lib/features/scrims/components/MapsTournamentFormField.svelte b/apps/web/src/lib/features/scrims/components/MapsTournamentFormField.svelte
new file mode 100644
index 000000000..61201006c
--- /dev/null
+++ b/apps/web/src/lib/features/scrims/components/MapsTournamentFormField.svelte
@@ -0,0 +1,12 @@
+
+
+{#if showField}
+
+{/if}
diff --git a/apps/web/src/lib/features/scrims/components/NewScrimForm.svelte b/apps/web/src/lib/features/scrims/components/NewScrimForm.svelte
new file mode 100644
index 000000000..9d2a0718d
--- /dev/null
+++ b/apps/web/src/lib/features/scrims/components/NewScrimForm.svelte
@@ -0,0 +1,90 @@
+
+
+ 0
+ ? { mode: "TEAM", teamId: data.teams[0].id }
+ : {
+ mode: "PICKUP",
+ users: nullFilledArray(
+ SCRIM.MAX_PICKUP_SIZE_EXCLUDING_OWNER,
+ ) as unknown as number[],
+ },
+ managedByAnyone: true,
+ maps: "NO_PREFERENCE",
+ mapsTournamentId: null,
+ }}
+ onSubmit={handleSubmit}
+>
+
+ {#snippet children(props)}
+
+ {/snippet}
+
+
+
+
+
+
+ {#snippet children(props)}
+
+ {/snippet}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/src/lib/features/scrims/components/NotFoundVisibilityFormField.svelte b/apps/web/src/lib/features/scrims/components/NotFoundVisibilityFormField.svelte
new file mode 100644
index 000000000..629a63cbc
--- /dev/null
+++ b/apps/web/src/lib/features/scrims/components/NotFoundVisibilityFormField.svelte
@@ -0,0 +1,108 @@
+
+
+
+
+{#if !noAssociations && baseVisibility !== "PUBLIC"}
+
+
+
+
+
+ {#if notFoundVisibility.at}
+
+
+
+
+ {/if}
+
+
+{/if}
+
+
diff --git a/apps/web/src/lib/features/scrims/components/PickMapDialog.svelte b/apps/web/src/lib/features/scrims/components/PickMapDialog.svelte
new file mode 100644
index 000000000..cbe9bb39d
--- /dev/null
+++ b/apps/web/src/lib/features/scrims/components/PickMapDialog.svelte
@@ -0,0 +1,33 @@
+
+
+
diff --git a/apps/web/src/lib/features/scrims/components/ScrimCardShell.svelte b/apps/web/src/lib/features/scrims/components/ScrimCardShell.svelte
new file mode 100644
index 000000000..55e5b9495
--- /dev/null
+++ b/apps/web/src/lib/features/scrims/components/ScrimCardShell.svelte
@@ -0,0 +1,145 @@
+
+
+
+
+
+ {#if children}{@render children()}{/if}
+
+ {#if footer && showFooter}
+
+ {@render footer()}
+
+ {/if}
+
+
+
diff --git a/apps/web/src/lib/features/scrims/components/ScrimExpandableText.svelte b/apps/web/src/lib/features/scrims/components/ScrimExpandableText.svelte
new file mode 100644
index 000000000..141daaf22
--- /dev/null
+++ b/apps/web/src/lib/features/scrims/components/ScrimExpandableText.svelte
@@ -0,0 +1,70 @@
+
+
+
+
+ {text}
+
+ {#if isOverflowing}
+
+ {/if}
+
+
+
diff --git a/apps/web/src/lib/features/scrims/components/ScrimMapListForm.svelte b/apps/web/src/lib/features/scrims/components/ScrimMapListForm.svelte
new file mode 100644
index 000000000..7b1eb1e68
--- /dev/null
+++ b/apps/web/src/lib/features/scrims/components/ScrimMapListForm.svelte
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
diff --git a/apps/web/src/lib/features/scrims/components/ScrimMapListManager.svelte b/apps/web/src/lib/features/scrims/components/ScrimMapListManager.svelte
new file mode 100644
index 000000000..472092996
--- /dev/null
+++ b/apps/web/src/lib/features/scrims/components/ScrimMapListManager.svelte
@@ -0,0 +1,130 @@
+
+
+ {
+ isOpen = open;
+ }}
+ collapsedLabel={m.scrims_mapByMap_manageMapLists()}
+ {standalone}
+>
+ {#snippet collapsedIcon()}{/snippet}
+
+ {#if !ownList}
+
+ {/if}
+
+ {#each sides as side (side)}
+ {@const list = data.mapByMap.mapLists.find(
+ (candidate) => candidate.side === side,
+ )}
+ {@const isOwn = side === viewerSide}
+
+
+
+ {#if list}
+ {#if list.tournament}
+ {list.tournament.name}
+ {:else}
+
+ {m.scrims_mapByMap_poolList({ count: list.mapList.length })}
+
+ {/if}
+ {#if isOwn}
+
+ removeMapList({ scrimPostId: data.post.id })}
+ >
+ {#snippet trigger(triggerProps)}
+
+ {/snippet}
+
+ {/if}
+ {:else}
+
+ {m.scrims_mapByMap_noListYet()}
+
+ {/if}
+
+
+ {/each}
+
+
+
+
+
diff --git a/apps/web/src/lib/features/scrims/components/ScrimMatchActionTab.svelte b/apps/web/src/lib/features/scrims/components/ScrimMatchActionTab.svelte
new file mode 100644
index 000000000..ac66a2074
--- /dev/null
+++ b/apps/web/src/lib/features/scrims/components/ScrimMatchActionTab.svelte
@@ -0,0 +1,167 @@
+
+
+{#if !data.mapByMap.locked}
+ {#if !viewerSide}
+
+
+ {m.scrims_mapByMap_nonParticipantNotice()}
+
+
+ {:else if !currentMap}
+
+
+
+ {:else}
+ {#key currentMap.id}
+
+ {#snippet actionButtons()}
+
+
+ {#key `${currentMap.id}-${currentMap.mode}-${currentMap.stageId}`}
+
+ {#snippet trigger(triggerProps)}
+
+ {/snippet}
+
+ {/key}
+ {/snippet}
+ {#snippet secondaryAction()}
+
+ {/snippet}
+
+ {/key}
+ {/if}
+{/if}
+
+
diff --git a/apps/web/src/lib/features/scrims/components/ScrimMatchBanner.svelte b/apps/web/src/lib/features/scrims/components/ScrimMatchBanner.svelte
new file mode 100644
index 000000000..6be192aa8
--- /dev/null
+++ b/apps/web/src/lib/features/scrims/components/ScrimMatchBanner.svelte
@@ -0,0 +1,79 @@
+
+
+{#snippet topRow()}
+
+
+
+{/snippet}
+
+{#if data.post.canceled}
+
+ {@render topRow()}
+
+ {#snippet icon()}{/snippet}
+
+
+{:else if currentMap}
+
+ {@render topRow()}
+
+
+{:else}
+
+ {@render topRow()}
+
+ {#snippet icon()}{/snippet}
+
+
+{/if}
diff --git a/apps/web/src/lib/features/scrims/components/ScrimMatchHeader.svelte b/apps/web/src/lib/features/scrims/components/ScrimMatchHeader.svelte
new file mode 100644
index 000000000..d19022976
--- /dev/null
+++ b/apps/web/src/lib/features/scrims/components/ScrimMatchHeader.svelte
@@ -0,0 +1,71 @@
+
+
+
+ {#snippet topRight()}
+ {#if canCancel}
+
+ {/if}
+ {/snippet}
+ {#if opponentSide}
+ {m.scrims_page_vs({ opponent: Scrim.sideDisplayName(opponentSide) })}
+ {/if}
+
diff --git a/apps/web/src/lib/features/scrims/components/ScrimMatchStatsTab.svelte b/apps/web/src/lib/features/scrims/components/ScrimMatchStatsTab.svelte
new file mode 100644
index 000000000..eb7450c26
--- /dev/null
+++ b/apps/web/src/lib/features/scrims/components/ScrimMatchStatsTab.svelte
@@ -0,0 +1,222 @@
+
+
+
+ {#if !viewerSide || maps.length === 0}
+ {m.scrims_mapByMap_stats_empty()}
+ {:else if stats}
+
+
+
+ {#each VIEW_OPTIONS as option (option)}
+ {
+ view = value as View;
+ }}
+ >
+ {viewLabel(option)}
+
+ {/each}
+
+ {#if ownPool}
+
+ {/if}
+
+
+ {#if view === "MODE"}
+ {@render statsTable(sortRows(stats.byMode), modeLabel)}
+ {:else if view === "STAGE"}
+ {@render statsTable(sortRows(stats.byStage), stageLabel)}
+ {:else}
+ {@render statsTable(sortRows(stats.byStageMode), stageModeLabel)}
+ {/if}
+
+ {/if}
+
+
+{#snippet modeLabel(key: string)}
+
+
+ {modeLongName(key)}
+
+{/snippet}
+
+{#snippet stageLabel(key: string)}
+
+
+ {stageName(Number(key))}
+
+{/snippet}
+
+{#snippet stageModeLabel(key: string)}
+ {@const [stageId, mode] = key.split("-")}
+
+
+
+ {stageName(Number(stageId))}
+
+{/snippet}
+
+{#snippet statsTable(
+ rows: StatsRow[],
+ label: import("svelte").Snippet<[string]>,
+)}
+ {#if rows.length === 0}
+ {m.scrims_mapByMap_stats_empty()}
+ {:else}
+
+
+
+ | {m.scrims_mapByMap_stats_col_label()} |
+ {m.scrims_mapByMap_stats_col_wins()} |
+ {m.scrims_mapByMap_stats_col_losses()} |
+ {m.scrims_mapByMap_stats_col_winPct()} |
+
+
+
+ {#each rows as row (row.key)}
+
+ | {@render label(row.key)} |
+ {row.wins} |
+ {row.losses} |
+ {Math.round(row.winRate * 100)}% |
+
+ {/each}
+
+
+ {/if}
+{/snippet}
+
+
diff --git a/apps/web/src/lib/features/scrims/components/ScrimMatchTabs.svelte b/apps/web/src/lib/features/scrims/components/ScrimMatchTabs.svelte
new file mode 100644
index 000000000..bb30e0c6a
--- /dev/null
+++ b/apps/web/src/lib/features/scrims/components/ScrimMatchTabs.svelte
@@ -0,0 +1,109 @@
+
+
+
+
+
+
+
+
diff --git a/apps/web/src/lib/features/scrims/components/ScrimPage.svelte b/apps/web/src/lib/features/scrims/components/ScrimPage.svelte
new file mode 100644
index 000000000..301cec139
--- /dev/null
+++ b/apps/web/src/lib/features/scrims/components/ScrimPage.svelte
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/src/lib/features/scrims/components/ScrimPostCard.svelte b/apps/web/src/lib/features/scrims/components/ScrimPostCard.svelte
new file mode 100644
index 000000000..7a1867f83
--- /dev/null
+++ b/apps/web/src/lib/features/scrims/components/ScrimPostCard.svelte
@@ -0,0 +1,419 @@
+
+
+
+
+ {#snippet avatar()}
+
+ {/snippet}
+ {#snippet rightIcons()}
+ {#if post.isPrivate}
+
+ {#snippet trigger(triggerProps)}
+
+ {/snippet}
+ {m.scrims_limitedVisibility()}
+
+ {/if}
+
+ {/snippet}
+
+
+ {@render infoItem("Start", startTime)}
+ {#if flexTimeDisplay}
+ {@render infoItem("Flex", flexText)}
+ {/if}
+ {#if post.divs}
+ {@render infoItem("Div", divsText)}
+ {/if}
+ {#if post.maps || post.mapsTournament}
+ {@render infoItem("Modes", modesContent)}
+ {/if}
+
+
+ {#if post.text}
+
+ {/if}
+
+ {#snippet footer()}
+ {@render actionButtons()}
+ {/snippet}
+
+
+
+{#snippet infoItem(label: string, content: import("svelte").Snippet)}
+
+
{label}
+
{@render content()}
+
+{/snippet}
+
+{#snippet startTime()}
+ {#if !post.isScheduledForFuture}
+ {#if post.canceled}
+
+ {m.scrims_now()}
+ Canceled
+
+ {:else}
+ {m.scrims_now()}
+ {/if}
+ {:else if post.canceled}
+
+
+
+
+ Canceled
+
+ {:else}
+
+ {/if}
+{/snippet}
+
+{#snippet flexText()}
+ {flexTimeDisplay}
+{/snippet}
+
+{#snippet divsText()}
+ {post.divs!.max === post.divs!.min
+ ? post.divs!.max
+ : `${post.divs!.min}-${post.divs!.max}`}
+{/snippet}
+
+{#snippet modesContent()}
+ {#if post.mapsTournament}
+ {@const tournament = post.mapsTournament}
+
+ {#snippet trigger(triggerProps)}
+
+ {/snippet}
+
+
+ {:else}
+ {#each getModesList(post.maps!) as mode (mode)}
+
+ {/each}
+ {/if}
+{/snippet}
+
+{#snippet actionButtons()}
+ {#if action === "REQUEST"}
+
+ {#if isRequestModalOpen}
+ {
+ isRequestModalOpen = false;
+ }}
+ />
+ {/if}
+ {:else if action === "VIEW_REQUEST"}
+
+ {#if isViewRequestModalOpen && userRequest}
+
+ {/if}
+ {:else if action === "CONTACT"}
+
+ {#snippet icon()}{/snippet}
+ {m.scrims_actions_contact()}
+
+ {:else if action === "DELETE"}
+
+ deleteScrimPost({ scrimPostId: post.id }).updates(getScrimPosts)}
+ >
+ {#snippet trigger(triggerProps)}
+
+ {/snippet}
+
+ {/if}
+{/snippet}
+
+
diff --git a/apps/web/src/lib/features/scrims/components/ScrimRequestCard.svelte b/apps/web/src/lib/features/scrims/components/ScrimRequestCard.svelte
new file mode 100644
index 000000000..fff8f141d
--- /dev/null
+++ b/apps/web/src/lib/features/scrims/components/ScrimRequestCard.svelte
@@ -0,0 +1,100 @@
+
+
+
+ {#snippet avatar()}
+
+ {/snippet}
+ {#snippet rightIcons()}
+
+ {/snippet}
+
+ {#if request.message}
+
+ {/if}
+
+ {#snippet footer()}
+ {#if canAccept}
+
+ acceptScrimRequest({ scrimPostRequestId: request.id }).updates(
+ getScrimPosts,
+ )}
+ >
+ {#snippet trigger(triggerProps)}
+
+ {/snippet}
+
+ {:else}
+
+ {#snippet trigger(triggerProps)}
+
+ {/snippet}
+ {m.scrims_acceptModal_prevented()}
+
+ {/if}
+ {/snippet}
+
diff --git a/apps/web/src/lib/features/scrims/components/ScrimRequestMembersList.svelte b/apps/web/src/lib/features/scrims/components/ScrimRequestMembersList.svelte
new file mode 100644
index 000000000..56fc1c579
--- /dev/null
+++ b/apps/web/src/lib/features/scrims/components/ScrimRequestMembersList.svelte
@@ -0,0 +1,43 @@
+
+
+
+ {#each sortedUsers as user (user.id)}
+
+
+
+
+
+
+ {user.username}
+ {#if user.isOwner}
+
+ {m.scrims_cancelRequestModal_requester()}
+
+ {/if}
+
+
+
+ {/each}
+
diff --git a/apps/web/src/lib/features/scrims/components/ScrimRequestModal.svelte b/apps/web/src/lib/features/scrims/components/ScrimRequestModal.svelte
new file mode 100644
index 000000000..27419e37b
--- /dev/null
+++ b/apps/web/src/lib/features/scrims/components/ScrimRequestModal.svelte
@@ -0,0 +1,101 @@
+
+
+
diff --git a/apps/web/src/lib/features/scrims/components/ScrimTeamAvatar.svelte b/apps/web/src/lib/features/scrims/components/ScrimTeamAvatar.svelte
new file mode 100644
index 000000000..22dc95dcb
--- /dev/null
+++ b/apps/web/src/lib/features/scrims/components/ScrimTeamAvatar.svelte
@@ -0,0 +1,28 @@
+
+
+{#if teamAvatarUrl}
+
+{:else}
+
+
+
+
+
+{/if}
diff --git a/apps/web/src/lib/features/scrims/components/ScrimTeamMembersPopover.svelte b/apps/web/src/lib/features/scrims/components/ScrimTeamMembersPopover.svelte
new file mode 100644
index 000000000..ddcababdc
--- /dev/null
+++ b/apps/web/src/lib/features/scrims/components/ScrimTeamMembersPopover.svelte
@@ -0,0 +1,65 @@
+
+
+
+ {#snippet trigger(triggerProps)}
+
+ {/snippet}
+
+ {#each users as user (user.id)}
+
+
+
+
+
+ {user.username}
+
+
+ {/each}
+
+
+
+
diff --git a/apps/web/src/lib/features/scrims/components/ScrimsDaySection.svelte b/apps/web/src/lib/features/scrims/components/ScrimsDaySection.svelte
new file mode 100644
index 000000000..0d1d4fdfc
--- /dev/null
+++ b/apps/web/src/lib/features/scrims/components/ScrimsDaySection.svelte
@@ -0,0 +1,148 @@
+
+
+
+
+
+
+
+ {#if user && (filteredCount > 0 || pendingRequestsCount > 0)}
+
+ {#if filteredCount > 0}
+
+ {/if}
+ {#if pendingRequestsCount > 0}
+
+ {/if}
+
+ {/if}
+
+
+ {#each showFiltered ? posts : filteredPosts as post (post.id)}
+ {#if !(hasRequested(post) && !showRequestPending)}
+
+ {/if}
+ {/each}
+
+
+
+
diff --git a/apps/web/src/lib/features/scrims/components/ScrimsFilters.svelte b/apps/web/src/lib/features/scrims/components/ScrimsFilters.svelte
new file mode 100644
index 000000000..5b7c7bc1b
--- /dev/null
+++ b/apps/web/src/lib/features/scrims/components/ScrimsFilters.svelte
@@ -0,0 +1,112 @@
+
+
+{#snippet weekdayPopover()}
+ writeFilters({ weekdayTimes: timeRange })}
+ />
+{/snippet}
+
+{#snippet weekendPopover()}
+ writeFilters({ weekendTimes: timeRange })}
+ />
+{/snippet}
+
+{#snippet divsPopover()}
+ writeFilters({ divs })}
+ />
+{/snippet}
+
+{#snippet actions()}
+ {#if canSaveAsDefault}
+
+ {/if}
+{/snippet}
+
+ writeFilters({ weekdayTimes: null }),
+ testId: "weekday-times-filter",
+ popover: weekdayPopover,
+ },
+ {
+ key: "weekendTimes",
+ name: m.scrims_filters_weekendTimes(),
+ formattedValue: filters.weekendTimes
+ ? `${filters.weekendTimes.start}–${filters.weekendTimes.end}`
+ : null,
+ onRemove: () => writeFilters({ weekendTimes: null }),
+ testId: "weekend-times-filter",
+ popover: weekendPopover,
+ },
+ {
+ key: "divs",
+ name: m.scrims_filters_divs(),
+ formattedValue: filters.divs
+ ? `${filters.divs.max}–${filters.divs.min}`
+ : null,
+ onRemove: () => writeFilters({ divs: null }),
+ testId: "divs-filter",
+ popover: divsPopover,
+ },
+ ]}
+ onReset={!Scrim.filtersAreDefault(filters)
+ ? () =>
+ writeFilters({
+ weekdayTimes: null,
+ weekendTimes: null,
+ divs: null,
+ })
+ : undefined}
+ {actions}
+/>
diff --git a/apps/web/src/lib/features/scrims/components/ScrimsPage.svelte b/apps/web/src/lib/features/scrims/components/ScrimsPage.svelte
new file mode 100644
index 000000000..6d0930dd7
--- /dev/null
+++ b/apps/web/src/lib/features/scrims/components/ScrimsPage.svelte
@@ -0,0 +1,245 @@
+
+
+
+
+
+
+ {m.scrims_associations_title()}
+
+
+
+ {#key pendingRequestPostId}
+
0
+ ? "owned"
+ : data.posts.booked.length > 0
+ ? "booked"
+ : "available"}
+ >
+ {#if user}
+
+
+ {#snippet icon()}{/snippet}
+ {m.scrims_tabs_available()}
+
+
+ {#snippet icon()}{/snippet}
+ {m.scrims_tabs_owned()}
+
+
+ {#snippet icon()}{/snippet}
+ {m.scrims_tabs_booked()}
+
+
+ {/if}
+
+ {#if data.posts.neutral.length > 0}
+
+ {#each postsByDay(data.posts.neutral) as [day, dayPosts] (day)}
+
+ params.set({ pendingRequestPostId: null })}
+ teams={data.teams}
+ />
+ {/each}
+
+ {:else}
+
+ {m.scrims_noneAvailable()}
+
+ {/if}
+
+
+ {#if data.posts.owned.length > 0}
+
+ {#each postsByDay(data.posts.owned) as [day, dayPosts] (day)}
+
+
+
+
+
+ {#each dayPosts as post (post.id)}
+ {@const isAccepted = post.requests.some(
+ (request) => request.isAccepted,
+ )}
+ {@const canDelete =
+ user &&
+ post.permissions.DELETE_POST.includes(user.id) &&
+ !isAccepted}
+
+
+ {#if post.requests.length > 0}
+
+ {#each post.requests as request (request.id)}
+
+ {/each}
+
+ {:else}
+
+ {m.scrims_noRequestsYet()}
+
+ {/if}
+
+ {/each}
+
+
+ {/each}
+
+ {:else}
+
+ {m.scrims_noOwnedPosts()}
+
+ {/if}
+
+
+ {#if data.posts.booked.length > 0}
+
+ {#each postsByDay(data.posts.booked) as [day, dayPosts] (day)}
+
+
+
+
+
+ {#each dayPosts as post (post.id)}
+ {@const acceptedRequest = post.requests.find(
+ (request) => request.isAccepted,
+ )}
+
+
+ {#if acceptedRequest}
+
+ {/if}
+
+ {/each}
+
+
+ {/each}
+
+ {:else}
+
+ {m.scrims_noBookedScrims()}
+
+ {/if}
+
+
+ {/key}
+
+ {m.calendar_inYourTimeZone()}
+ {timeZone}
+
+
+
diff --git a/apps/web/src/lib/features/scrims/components/SourceDependentFields.svelte b/apps/web/src/lib/features/scrims/components/SourceDependentFields.svelte
new file mode 100644
index 000000000..31d1ce102
--- /dev/null
+++ b/apps/web/src/lib/features/scrims/components/SourceDependentFields.svelte
@@ -0,0 +1,14 @@
+
+
+{#if source === "POOL"}
+
+{:else if source === "TOURNAMENT"}
+
+{/if}
diff --git a/apps/web/src/lib/features/scrims/components/TimeRangeFilterPopover.svelte b/apps/web/src/lib/features/scrims/components/TimeRangeFilterPopover.svelte
new file mode 100644
index 000000000..6cf7e5160
--- /dev/null
+++ b/apps/web/src/lib/features/scrims/components/TimeRangeFilterPopover.svelte
@@ -0,0 +1,42 @@
+
+
+
diff --git a/apps/web/src/lib/features/scrims/components/WithFormField.svelte b/apps/web/src/lib/features/scrims/components/WithFormField.svelte
new file mode 100644
index 000000000..979e2450e
--- /dev/null
+++ b/apps/web/src/lib/features/scrims/components/WithFormField.svelte
@@ -0,0 +1,254 @@
+
+
+
+
+
+
+ {#if fromValue?.mode === "PICKUP" && user}
+
+
+ {#each fromValue.users as userId, i (`${pickupFieldsKey}-${i}`)}
+ handleUserChange(selectedUser, i)}
+ isRequired={i < 3}
+ label={m.scrims_forms_with_user({ nth: i + 2 })}
+ />
+ {/each}
+ {#if translatedError}
+
+ {translatedError}
+
+ {:else}
+
+ {m.scrims_forms_with_explanation()}
+
+ {/if}
+
+ {/if}
+
+
+
diff --git a/apps/web/src/lib/features/scrims/scrims.remote.ts b/apps/web/src/lib/features/scrims/scrims.remote.ts
new file mode 100644
index 000000000..3895a5202
--- /dev/null
+++ b/apps/web/src/lib/features/scrims/scrims.remote.ts
@@ -0,0 +1,963 @@
+import invariant from "@sendou/utils/invariant";
+import { logger } from "@sendou/utils/logger";
+import { assertUnreachable } from "@sendou/utils/types";
+import { add, sub } from "date-fns";
+import * as R from "remeda";
+import * as v from "valibot";
+import * as AssociationRepository from "#lib/features/associations/AssociationRepository.server.ts";
+import * as Association from "#lib/features/associations/core/Association.ts";
+import {
+ type AuthenticatedUser,
+ getUser,
+ requireUser,
+} from "#lib/features/auth/user.server.ts";
+import { userIsBanned } from "#lib/features/ban/banned.server.ts";
+import { ensureScrimChatRoom, markScrimChatRoomInactive, sendSystemMessage } from "#lib/features/chat/chat.server.ts";
+import { notify } from "#lib/features/notifications/core/notify.server.ts";
+import { resolveNotifications } from "#lib/features/notifications/core/resolve.server.ts";
+import * as SQGroupRepository from "#lib/features/sendouq/SQGroupRepository.server.ts";
+import * as TeamRepository from "#lib/features/team/TeamRepository.server.ts";
+import { getMemberRoleType } from "#lib/features/team/team-utils.ts";
+import * as UserCardRepository from "#lib/features/user-page/UserCardRepository.server.ts";
+import * as UserRepository from "#lib/features/user-page/UserRepository.server.ts";
+import { requirePermission } from "#lib/modules/permissions/guards.server.ts";
+import * as Events from "#lib/server/events.ts";
+import {
+ databaseTimestampToDate,
+ dateToDatabaseTimestamp,
+} from "#lib/utils/dates.ts";
+import {
+ ConcurrentModificationError,
+ DuplicateEntryError,
+} from "#lib/utils/errors.ts";
+import {
+ errorToast,
+ errorToastIfFalsy,
+ notFoundIfNullish,
+} from "#lib/utils/respond.server.ts";
+import { id } from "#lib/utils/schemas.ts";
+import { toDBBoolean } from "#lib/utils/sql.ts";
+import { command, query, requested } from "$app/server";
+import * as Scrim from "./Scrim.ts";
+import * as ScrimMapByMap from "./ScrimMapByMap.ts";
+import * as ScrimMapListRepository from "./ScrimMapListRepository.server.ts";
+import * as ScrimMapRepository from "./ScrimMapRepository.server.ts";
+import * as ScrimPickupRosterRepository from "./ScrimPickupRosterRepository.server.ts";
+import * as ScrimPostRepository from "./ScrimPostRepository.server.ts";
+import { LUTI_DIVS, SCRIM } from "./scrims-constants.ts";
+import {
+ acceptRequestSchema,
+ cancelRequestSchema,
+ cancelScrimFormSchema,
+ deletePostSchema,
+ type fromSchema,
+ newRequestSchema,
+ persistScrimFiltersSchema,
+ pickMapFormSchema,
+ type RANGE_END_OPTIONS,
+ removeMapListSchema,
+ replayMapSchema,
+ reportMapSchema,
+ scrimsFiltersSchema,
+ scrimsNewFormSchema,
+ submitMapListFormSchema,
+ undoMapSchema,
+} from "./scrims-schemas.ts";
+import type { LutiDiv } from "./scrims-types.ts";
+import {
+ dividePosts,
+ generateTimeOptions,
+ parseMapPoolInput,
+ serializeLutiDiv,
+} from "./scrims-utils.ts";
+
+const getScrimPostsSchema = v.object({
+ ...scrimsFiltersSchema.entries,
+ useDefaults: v.boolean(),
+});
+
+/** The `/scrims` page data: visible posts divided into tabs plus filter state. */
+export const getScrimPosts = query(
+ getScrimPostsSchema,
+ async ({ weekdayTimes, weekendTimes, divs, useDefaults }) => {
+ const user = getUser();
+
+ const associations = user
+ ? await AssociationRepository.findByMemberUserId(user.id)
+ : null;
+
+ const filtersFromSearchParams = { weekdayTimes, weekendTimes, divs };
+
+ // when the user cleared or edited the filters the URL is the whole truth
+ // even when it ends up holding no filters at all
+ const filters =
+ useDefaults && Scrim.filtersAreDefault(filtersFromSearchParams)
+ ? (user?.preferences?.defaultScrimsFilters ?? Scrim.defaultFilters())
+ : filtersFromSearchParams;
+
+ const posts = (await ScrimPostRepository.findAllRelevant())
+ .filter(
+ (post) =>
+ (user && Scrim.isParticipating(post, user.id)) ||
+ Association.isVisible({
+ associations,
+ visibility: post.visibility,
+ contentOwnerUserId: post.users.find((u) => u.isOwner)?.id,
+ }),
+ )
+ .map((post) => ({
+ ...post,
+ visibility: null,
+ isPrivate: !Association.isPublic({
+ visibility: post.visibility,
+ }),
+ }));
+
+ const cardUserIds = R.unique(
+ posts.flatMap((post) => [
+ ...post.users.map((postUser) => postUser.id),
+ ...post.requests.flatMap((request) =>
+ request.users.map((requestUser) => requestUser.id),
+ ),
+ ]),
+ );
+
+ return {
+ ...(await UserCardRepository.findAllByUserIds({
+ userIds: cardUserIds,
+ })),
+ posts: dividePosts(posts, user?.id),
+ teams: user ? await TeamRepository.findAllByMemberUserId(user.id) : [],
+ filters,
+ canSaveAsDefault:
+ user != null &&
+ !R.isDeepEqual(
+ filters,
+ user.preferences?.defaultScrimsFilters ?? Scrim.defaultFilters(),
+ ),
+ };
+ },
+);
+
+/** The `/scrims/new` page data. */
+export const getScrimsNewData = query(async () => {
+ const user = requireUser();
+
+ return {
+ teams: await TeamRepository.findAllByMemberUserId(user.id),
+ associations: await AssociationRepository.findByMemberUserId(user.id),
+ recentPickupRosters: await ScrimPickupRosterRepository.findAllOwnRecent(),
+ };
+});
+
+/**
+ * A booked scrim's page data, streamed live: map-by-map actions by either side
+ * wake the stream so both teams see reports/list changes without refreshing
+ * (what skalop's revalidation broadcasts did for the React app).
+ */
+export const getScrim = query.live(
+ v.object({ scrimPostId: id }),
+ async function* ({ scrimPostId }) {
+ yield await scrimSnapshot(scrimPostId);
+
+ for await (const _ of Events.subscribe(Events.scrimChannel(scrimPostId))) {
+ yield await scrimSnapshot(scrimPostId);
+ }
+ },
+);
+
+async function scrimSnapshot(scrimPostId: number) {
+ const user = requireUser();
+
+ const post = notFoundIfNullish(
+ await ScrimPostRepository.findById(scrimPostId),
+ );
+
+ if (!Scrim.isAccepted(post)) {
+ errorToast("Scrim is not accepted");
+ }
+
+ if (!Scrim.isParticipating(post, user.id) && !user.roles.includes("STAFF")) {
+ errorToast("Not a participant of this scrim");
+ }
+
+ // visiting the page addresses these notifications
+ await resolveNotifications({
+ userIds: [user.id],
+ type: "SCRIM_SCHEDULED",
+ meta: { id: post.id },
+ });
+ await resolveNotifications({
+ userIds: [user.id],
+ type: "SCRIM_STARTING_SOON",
+ meta: { id: post.id },
+ });
+
+ const participantIds = Scrim.participantIdsListFromAccepted(post);
+
+ const chatRoom = await ensureScrimChatRoom({
+ scrimPostId: post.id,
+ startsAt: Scrim.getStartTime(post),
+ });
+
+ return {
+ ...(await UserCardRepository.findAllByUserIds({
+ userIds: participantIds,
+ include: { friendCode: true },
+ })),
+ post,
+ chatRoomId:
+ user.roles.includes("STAFF") || participantIds.includes(user.id)
+ ? chatRoom.id
+ : undefined,
+ anyUserPrefersNoScreen:
+ await UserRepository.anyUserPrefersNoScreen(participantIds),
+ mapByMap: await resolveMapByMap({ post, user }),
+ };
+}
+
+async function resolveMapByMap({
+ post,
+ user,
+}: {
+ post: NonNullable>>;
+ user: AuthenticatedUser;
+}) {
+ const [mapLists, maps] = await Promise.all([
+ ScrimMapListRepository.findMapListsByScrimPostId(post.id),
+ ScrimMapRepository.findMapsByScrimPostId(post.id),
+ ]);
+
+ const pool = mapLists.length > 0 ? ScrimMapByMap.unionPool(mapLists) : null;
+ const currentMap = maps.find((m) => m.reportedAt === null) ?? null;
+ const viewerSide = Scrim.sideOfUser(post, user.id);
+ const locked = Scrim.isTrackingLocked(maps, mapLists);
+
+ const ownList = viewerSide
+ ? mapLists.find((l) => l.side === viewerSide)
+ : undefined;
+
+ return {
+ mapLists,
+ maps,
+ currentMap,
+ viewerSide,
+ locked,
+ pool: pool ? pool.stageModePairs : null,
+ ownPool: ownList?.mapList ?? null,
+ };
+}
+
+export const createScrimPost = command(
+ scrimsNewFormSchema,
+ async (data) => {
+ const user = requireUser();
+
+ if (data.from.mode === "PICKUP") {
+ const pickupUserError = await validatePickup(data.from.users, user.id);
+ if (pickupUserError) {
+ return { fieldErrors: { from: pickupUserError.error } };
+ }
+ }
+
+ const rangeEndDate = data.rangeEnd
+ ? resolveRangeEndToDate(data.at, data.rangeEnd as RangeEnd)
+ : null;
+
+ const resolvedDivs = data.divs
+ ? resolveDivs(data.divs as [LutiDiv | null, LutiDiv | null])
+ : null;
+
+ await ScrimPostRepository.insert({
+ startsAt: dateToDatabaseTimestamp(data.at),
+ rangeEndsAt: rangeEndDate
+ ? dateToDatabaseTimestamp(rangeEndDate)
+ : null,
+ maxDiv: resolvedDivs?.[0] ? serializeLutiDiv(resolvedDivs[0]) : null,
+ minDiv: resolvedDivs?.[1] ? serializeLutiDiv(resolvedDivs[1]) : null,
+ text: data.postText,
+ managedByAnyone: data.managedByAnyone,
+ maps:
+ data.maps === "NO_PREFERENCE" || data.maps === "TOURNAMENT"
+ ? null
+ : (data.maps as "SZ" | "RANKED" | "ALL"),
+ mapsTournamentId: data.mapsTournamentId,
+ isScheduledForFuture:
+ data.at >
+ // 10 minutes is an arbitrary threshold
+ add(new Date(), {
+ minutes: 10,
+ }),
+ visibility:
+ data.baseVisibility !== "PUBLIC"
+ ? {
+ forAssociation: data.baseVisibility,
+ notFoundInstructions: data.notFoundVisibility.at
+ ? [
+ {
+ at: dateToDatabaseTimestamp(
+ data.notFoundVisibility.at,
+ ),
+ forAssociation:
+ data.notFoundVisibility.forAssociation !== "PUBLIC"
+ ? data.notFoundVisibility.forAssociation
+ : null,
+ },
+ ]
+ : undefined,
+ }
+ : null,
+ teamId: data.from.mode === "TEAM" ? data.from.teamId : null,
+ users: (
+ await usersListForPost({ authorId: user.id, from: data.from })
+ ).map((userId) => ({
+ userId,
+ isOwner: toDBBoolean(user.id === userId),
+ })),
+ });
+
+ if (data.from.mode === "PICKUP") {
+ await ScrimPickupRosterRepository.upsertOwn(data.from.users);
+ }
+
+ Events.publish(Events.scrimPostsChannel());
+ return null;
+ },
+);
+
+export const deleteScrimPost = command(
+ deletePostSchema,
+ async ({ scrimPostId }) => {
+ requireUser();
+
+ const post = await findPost({ postId: scrimPostId });
+ requirePermission(post, "DELETE_POST");
+
+ errorToastIfFalsy(
+ !Scrim.isAccepted(post),
+ "Can't delete an accepted scrim, cancel it instead",
+ );
+
+ await ScrimPostRepository.deleteById(post.id);
+
+ // requests to the deleted post can no longer be accepted
+ await resolveNotifications({
+ userIds: post.users.filter((u) => u.isOwner).map((u) => u.id),
+ type: "SCRIM_NEW_REQUEST",
+ meta: { scrimPostId: post.id },
+ });
+
+ await requested(getScrimPosts, 5).refreshAll();
+ },
+);
+
+export const newScrimRequest = command(newRequestSchema, async (data) => {
+ const user = requireUser();
+
+ const post = await findPost({ postId: data.scrimPostId });
+
+ if (post.visibility) {
+ const associations = await AssociationRepository.findByMemberUserId(
+ user.id,
+ );
+ const canSeePost = Association.isVisible({
+ associations,
+ visibility: post.visibility,
+ contentOwnerUserId: post.users.find((u) => u.isOwner)?.id,
+ });
+ errorToastIfFalsy(canSeePost, "Post not found");
+ }
+
+ if (data.from.mode === "PICKUP") {
+ const pickupUserError = await validatePickup(data.from.users, user.id);
+ if (pickupUserError) {
+ return { fieldErrors: { from: pickupUserError.error } };
+ }
+ }
+
+ if (post.rangeEndsAt && !data.at) {
+ return {
+ fieldErrors: { at: "Please select a time for the scrim" },
+ };
+ }
+
+ if (post.rangeEndsAt && data.at) {
+ const validTimeOptions = generateTimeOptions(
+ databaseTimestampToDate(post.startsAt),
+ databaseTimestampToDate(post.rangeEndsAt),
+ );
+ const requestTime = data.at.getTime();
+
+ if (!validTimeOptions.includes(requestTime)) {
+ return {
+ fieldErrors: {
+ at: "Selected time must be one of the available options",
+ },
+ };
+ }
+ }
+
+ try {
+ await ScrimPostRepository.insertRequest({
+ scrimPostId: data.scrimPostId,
+ teamId: data.from.mode === "TEAM" ? data.from.teamId : null,
+ message: data.message,
+ startsAt:
+ post.rangeEndsAt && data.at ? dateToDatabaseTimestamp(data.at) : null,
+ users: (
+ await usersListForPost({ authorId: user.id, from: data.from })
+ ).map((userId) => ({
+ userId,
+ isOwner: toDBBoolean(user.id === userId),
+ })),
+ });
+ } catch (thrown) {
+ if (thrown instanceof DuplicateEntryError) {
+ errorToast("Your team has already requested this scrim");
+ }
+ throw thrown;
+ }
+
+ notify({
+ userIds: post.users
+ .filter((postUser) => postUser.isOwner)
+ .map((postUser) => postUser.id),
+ notification: {
+ type: "SCRIM_NEW_REQUEST",
+ meta: {
+ fromUserId: user.id,
+ fromUsername: user.username,
+ scrimPostId: post.id,
+ },
+ },
+ });
+
+ Events.publish(Events.scrimPostsChannel());
+ await requested(getScrimPosts, 5).refreshAll();
+ return null;
+});
+
+export const acceptScrimRequest = command(
+ acceptRequestSchema,
+ async ({ scrimPostRequestId }) => {
+ const user = requireUser();
+
+ const { post, request } = await findRequest({
+ requestId: scrimPostRequestId,
+ });
+ requirePermission(post, "MANAGE_REQUESTS");
+
+ errorToastIfFalsy(!request.isAccepted, "Request is already accepted");
+
+ try {
+ await ScrimPostRepository.acceptRequest(scrimPostRequestId);
+ } catch (thrown) {
+ if (thrown instanceof ConcurrentModificationError) {
+ errorToast(
+ "Another request for this scrim was already accepted by someone else",
+ );
+ }
+ throw thrown;
+ }
+
+ // accepting one request settles the post, the rest can no longer be accepted
+ await resolveNotifications({
+ userIds: post.users.filter((u) => u.isOwner).map((u) => u.id),
+ type: "SCRIM_NEW_REQUEST",
+ meta: { scrimPostId: post.id },
+ });
+
+ const fullPost = await ScrimPostRepository.findById(post.id);
+ if (fullPost) {
+ await ensureScrimChatRoom({
+ scrimPostId: fullPost.id,
+ startsAt: Scrim.getStartTime(fullPost),
+ });
+ }
+
+ const postTeamName = Scrim.sideDisplayName(post);
+ const requestTeamName = Scrim.sideDisplayName(request);
+
+ notify({
+ userIds: post.users.map((m) => m.id),
+ defaultSeenUserIds: [user.id],
+ notification: {
+ type: "SCRIM_SCHEDULED",
+ meta: { id: post.id, opponentTeamName: requestTeamName },
+ },
+ });
+
+ notify({
+ userIds: request.users.map((m) => m.id),
+ defaultSeenUserIds: [user.id],
+ notification: {
+ type: "SCRIM_SCHEDULED",
+ meta: { id: post.id, opponentTeamName: postTeamName },
+ },
+ });
+
+ if (fullPost) {
+ try {
+ const bookedAt = databaseTimestampToDate(Scrim.getStartTime(fullPost));
+ const startTime = dateToDatabaseTimestamp(
+ sub(bookedAt, { hours: SCRIM.AUTO_CANCEL_WINDOW_HOURS }),
+ );
+ const endTime = dateToDatabaseTimestamp(
+ add(bookedAt, { hours: SCRIM.AUTO_CANCEL_WINDOW_HOURS }),
+ );
+
+ const { posts, requestIds } =
+ await ScrimPostRepository.findPendingOverlapsForUsers({
+ userIds: Scrim.participantIdsListFromAccepted(fullPost),
+ startTime,
+ endTime,
+ excludePostId: post.id,
+ });
+
+ for (const requestId of requestIds) {
+ await ScrimPostRepository.deleteRequest(requestId);
+ }
+
+ for (const removed of posts) {
+ await ScrimPostRepository.deleteById(removed.id);
+ notify({
+ userIds: removed.memberIds,
+ defaultSeenUserIds: [user.id],
+ notification: {
+ type: "SCRIM_AUTO_DELETED",
+ meta: { at: removed.startsAt },
+ },
+ });
+ await resolveNotifications({
+ userIds: removed.memberIds,
+ type: "SCRIM_NEW_REQUEST",
+ meta: { scrimPostId: removed.id },
+ });
+ }
+ } catch (thrown) {
+ logger.error("Failed to auto-cancel overlapping scrims", thrown);
+ }
+ }
+
+ Events.publish(Events.scrimPostsChannel());
+ await requested(getScrimPosts, 5).refreshAll();
+ },
+);
+
+export const cancelScrimRequest = command(
+ cancelRequestSchema,
+ async ({ scrimPostRequestId }) => {
+ requireUser();
+
+ const { post, request } = await findRequest({
+ requestId: scrimPostRequestId,
+ });
+ requirePermission(request, "CANCEL");
+
+ errorToastIfFalsy(!request.isAccepted, "Can't cancel an accepted request");
+
+ await ScrimPostRepository.deleteRequest(scrimPostRequestId);
+
+ const requestOwner = request.users.find((u) => u.isOwner);
+ if (requestOwner) {
+ await resolveNotifications({
+ userIds: post.users.filter((u) => u.isOwner).map((u) => u.id),
+ type: "SCRIM_NEW_REQUEST",
+ meta: {
+ scrimPostId: post.id,
+ fromUserId: requestOwner.id,
+ },
+ });
+ }
+
+ Events.publish(Events.scrimPostsChannel());
+ await requested(getScrimPosts, 5).refreshAll();
+ },
+);
+
+export const persistScrimFilters = command(
+ persistScrimFiltersSchema,
+ async ({ filters }) => {
+ requireUser();
+
+ await UserRepository.updateOwnPreferences({
+ defaultScrimsFilters: filters,
+ });
+
+ await requested(getScrimPosts, 5).refreshAll();
+ },
+);
+
+export const cancelScrim = command(cancelScrimFormSchema, async (data) => {
+ const user = requireUser();
+
+ const post = notFoundIfNullish(
+ await ScrimPostRepository.findById(data.scrimPostId),
+ );
+
+ requirePermission(post, "MANAGE_TRACKING");
+ requirePermission(post, "CANCEL");
+
+ errorToastIfFalsy(Scrim.isAccepted(post), "Scrim is not accepted");
+ errorToastIfFalsy(!post.canceled, "Scrim is already canceled");
+
+ if (databaseTimestampToDate(Scrim.getStartTime(post)) < new Date()) {
+ errorToast("Cannot cancel a scrim that was already scheduled to start");
+ }
+
+ await ScrimPostRepository.cancelScrim(post.id, data.reason);
+
+ const acceptedRequest = post.requests.find((r) => r.isAccepted);
+ if (acceptedRequest) {
+ const postTeamName = Scrim.sideDisplayName(post);
+ const requestTeamName = Scrim.sideDisplayName(acceptedRequest);
+
+ notify({
+ userIds: post.users.map((m) => m.id),
+ defaultSeenUserIds: [user.id],
+ notification: {
+ type: "SCRIM_CANCELED",
+ meta: { id: post.id, opponentTeamName: requestTeamName },
+ },
+ });
+
+ notify({
+ userIds: acceptedRequest.users.map((m) => m.id),
+ defaultSeenUserIds: [user.id],
+ notification: {
+ type: "SCRIM_CANCELED",
+ meta: { id: post.id, opponentTeamName: postTeamName },
+ },
+ });
+
+ // the canceled scrim is no longer happening
+ const participantIds = [
+ ...post.users.map((m) => m.id),
+ ...acceptedRequest.users.map((m) => m.id),
+ ];
+ await resolveNotifications({
+ userIds: participantIds,
+ type: "SCRIM_SCHEDULED",
+ meta: { id: post.id },
+ });
+ await resolveNotifications({
+ userIds: participantIds,
+ type: "SCRIM_STARTING_SOON",
+ meta: { id: post.id },
+ });
+ }
+
+ await markScrimChatRoomInactive(post.id);
+
+ Events.publish(Events.scrimChannel(post.id));
+ Events.publish(Events.scrimPostsChannel());
+ await requested(getScrimPosts, 5).refreshAll();
+ return null;
+});
+
+export const submitMapList = command(submitMapListFormSchema, async (data) => {
+ const { post, viewerSide } = await requireMapByMapActor(data.scrimPostId);
+
+ if (data.source === "FROM_POST") {
+ errorToastIfFalsy(post.mapsTournament, "Post has no tournament to use");
+ }
+
+ const serializedPool =
+ data.source === "POOL"
+ ? (parseMapPoolInput(data.serializedPool!)?.serialized ?? null)
+ : null;
+
+ errorToastIfFalsy(
+ data.source !== "POOL" || serializedPool,
+ "Invalid map pool",
+ );
+
+ const resolvedSource: "POOL" | "TOURNAMENT" =
+ data.source === "POOL" ? "POOL" : "TOURNAMENT";
+
+ await ScrimMapListRepository.submitMapListAndGenerateIfNeeded({
+ scrimPostId: post.id,
+ side: viewerSide,
+ source: resolvedSource,
+ tournamentId:
+ data.source === "FROM_POST"
+ ? post.mapsTournament!.id
+ : (data.tournamentId ?? null),
+ serializedPool,
+ });
+
+ Events.publish(Events.scrimChannel(post.id));
+ return null;
+});
+
+export const removeMapList = command(
+ removeMapListSchema,
+ async ({ scrimPostId }) => {
+ const { post, viewerSide } = await requireMapByMapActor(scrimPostId);
+
+ await ScrimMapListRepository.deleteMapList(post.id, viewerSide);
+
+ Events.publish(Events.scrimChannel(post.id));
+ },
+);
+
+export const reportMap = command(reportMapSchema, async (data) => {
+ const { post, maps } = await requireMapByMapActor(data.scrimPostId);
+
+ const target = maps.find((m) => m.id === data.mapId);
+ errorToastIfFalsy(target, "Map not found");
+ errorToastIfFalsy(target!.reportedAt === null, "Map already reported");
+
+ await ScrimMapRepository.reportMapAndGenerateNext({
+ scrimPostId: post.id,
+ mapId: data.mapId,
+ winnerSide: data.winnerSide,
+ });
+
+ Events.publish(Events.scrimChannel(post.id));
+});
+
+export const undoMap = command(undoMapSchema, async ({ scrimPostId }) => {
+ const { post, maps } = await requireMapByMapActor(scrimPostId);
+
+ const latest = Scrim.lastReportedMap(maps);
+ errorToastIfFalsy(ScrimMapByMap.canUndo(latest, maps), "Nothing to undo");
+
+ await ScrimMapRepository.undoMostRecentMap(post.id);
+
+ Events.publish(Events.scrimChannel(post.id));
+});
+
+export const replayMap = command(replayMapSchema, async ({ scrimPostId }) => {
+ const { post, maps, user } = await requireMapByMapActor(scrimPostId);
+
+ const latest = Scrim.lastReportedMap(maps);
+ errorToastIfFalsy(latest, "No map to replay");
+
+ const currentMap = maps.find((m) => m.reportedAt === null);
+ errorToastIfFalsy(currentMap, "No current map to replace");
+
+ await ScrimMapRepository.replaceCurrentMap({
+ scrimPostId: post.id,
+ mode: latest!.mode,
+ stageId: latest!.stageId,
+ });
+
+ await broadcastMapChange({ post, type: "MAP_REPLAYED", user });
+ Events.publish(Events.scrimChannel(post.id));
+});
+
+export const pickMap = command(pickMapFormSchema, async (data) => {
+ const { post, maps, user } = await requireMapByMapActor(data.scrimPostId);
+
+ const currentMap = maps.find((m) => m.reportedAt === null);
+ errorToastIfFalsy(currentMap, "No current map to replace");
+
+ await ScrimMapRepository.replaceCurrentMap({
+ scrimPostId: post.id,
+ mode: data.mode,
+ stageId: data.stageId,
+ });
+
+ await broadcastMapChange({ post, type: "MAP_PICKED", user });
+ Events.publish(Events.scrimChannel(post.id));
+ return null;
+});
+
+/** Loads the post + map-by-map context, gating on tracking permission & lock. */
+async function requireMapByMapActor(scrimPostId: number) {
+ const user = requireUser();
+ const post = notFoundIfNullish(
+ await ScrimPostRepository.findById(scrimPostId),
+ );
+
+ requirePermission(post, "MANAGE_TRACKING");
+
+ const viewerSide = Scrim.sideOfUser(post, user.id);
+
+ const [maps, mapLists] = await Promise.all([
+ ScrimMapRepository.findMapsByScrimPostId(post.id),
+ ScrimMapListRepository.findMapListsByScrimPostId(post.id),
+ ]);
+
+ if (Scrim.isTrackingLocked(maps, mapLists)) {
+ errorToast("Tracking is locked");
+ }
+
+ return { user, post, viewerSide: viewerSide!, maps, mapLists };
+}
+
+async function broadcastMapChange({
+ post,
+ type,
+ user,
+}: {
+ post: { id: number };
+ type: "MAP_REPLAYED" | "MAP_PICKED";
+ user: AuthenticatedUser;
+}) {
+ const room = await ensureScrimChatRoom({
+ scrimPostId: post.id,
+ startsAt: Scrim.getStartTime(
+ notFoundIfNullish(await ScrimPostRepository.findById(post.id)),
+ ),
+ });
+ await sendSystemMessage({
+ chatRoomId: room.id,
+ type,
+ context: { name: user.username },
+ });
+}
+
+async function findPost({ postId }: { postId: number }) {
+ const posts = await ScrimPostRepository.findAllRelevant();
+ const post = posts.find((relevantPost) => relevantPost.id === postId);
+
+ errorToastIfFalsy(post, "Post not found");
+
+ return post!;
+}
+
+async function findRequest({ requestId }: { requestId: number }) {
+ const posts = await ScrimPostRepository.findAllRelevant();
+ const post = posts.find((relevantPost) =>
+ relevantPost.requests.some((request) => request.id === requestId),
+ );
+ const request = post?.requests.find(
+ (postRequest) => postRequest.id === requestId,
+ );
+
+ errorToastIfFalsy(post && request, "Request not found");
+
+ return { post: post!, request: request! };
+}
+
+const usersListForPost = async ({
+ from,
+ authorId,
+}: {
+ from: v.InferOutput;
+ authorId: number;
+}) => {
+ if (from.mode === "PICKUP") {
+ return [authorId, ...from.users];
+ }
+
+ const teamId = from.teamId;
+ const team = (await TeamRepository.findAllByMemberUserId(authorId)).find(
+ (memberTeam) => memberTeam.id === teamId,
+ );
+ errorToastIfFalsy(team, "User is not a member of this team");
+
+ const filteredMembers = team!.members.filter(
+ (member) => getMemberRoleType(member) !== "OTHER",
+ );
+
+ // handle case when all users are from excluded roles
+ const result = (
+ filteredMembers.length >= SCRIM.MIN_MEMBERS_PER_TEAM
+ ? filteredMembers
+ : team!.members
+ ).map((member) => member.id);
+
+ if (result.length < SCRIM.MIN_MEMBERS_PER_TEAM) {
+ errorToast("Your team does not have enough members (4) to scrim");
+ }
+
+ // ensure author is included in the list even if they match the ignore condition
+ return result.includes(authorId) ? result : [authorId, ...result];
+};
+
+/** Validates that a pickup roster can be put together by the author. */
+async function validatePickup(userIds: number[], authorId: number) {
+ if (userIds.includes(authorId)) {
+ return { error: "Don't add yourself to the pickup member list" };
+ }
+
+ const friendsError = await validatePickupFriends(userIds, authorId);
+ if (friendsError) {
+ return friendsError;
+ }
+
+ const unbannedError = await validatePickupAllUnbanned(userIds);
+ if (unbannedError) {
+ return unbannedError;
+ }
+
+ return null;
+}
+
+async function validatePickupFriends(userIds: number[], authorId: number) {
+ const unconsentingUsers: string[] = [];
+
+ const friendsData = await SQGroupRepository.findFriendsAndTeammates(authorId);
+
+ for (const userId of userIds) {
+ const user = await UserRepository.findLeanById(userId);
+ invariant(user, "User not found");
+
+ if (
+ user.preferences?.disallowScrimPickupsFromUntrusted &&
+ !friendsData.friends.some((friend) => friend.id === userId)
+ ) {
+ unconsentingUsers.push(user.username);
+ }
+ }
+
+ return unconsentingUsers.length === 0
+ ? null
+ : {
+ error: `Following users don't allow non-friends to add: ${unconsentingUsers.join(", ")}. Ask them to add you as a friend.`,
+ };
+}
+
+async function validatePickupAllUnbanned(userIds: number[]) {
+ const bannedFlags = await Promise.all(
+ userIds.map((userId) => userIsBanned(userId)),
+ );
+
+ return bannedFlags.every((banned) => !banned)
+ ? null
+ : {
+ error: "Pickup includes banned users.",
+ };
+}
+
+type RangeEnd = (typeof RANGE_END_OPTIONS)[number];
+
+function resolveRangeEndToDate(startDate: Date, rangeEnd: RangeEnd): Date {
+ switch (rangeEnd) {
+ case "+30min":
+ return add(startDate, { minutes: 30 });
+ case "+1hour":
+ return add(startDate, { hours: 1 });
+ case "+1.5hours":
+ return add(startDate, { hours: 1, minutes: 30 });
+ case "+2hours":
+ return add(startDate, { hours: 2 });
+ case "+2.5hours":
+ return add(startDate, { hours: 2, minutes: 30 });
+ case "+3hours":
+ return add(startDate, { hours: 3 });
+ default: {
+ assertUnreachable(rangeEnd);
+ }
+ }
+}
+
+function resolveDivs(
+ divs: [LutiDiv | null, LutiDiv | null],
+): [LutiDiv | null, LutiDiv | null] {
+ const [max, min] = divs;
+ if (!max || !min) return divs;
+
+ const maxIndex = LUTI_DIVS.indexOf(max);
+ const minIndex = LUTI_DIVS.indexOf(min);
+
+ if (minIndex < maxIndex) {
+ return [min, max];
+ }
+ return divs;
+}
+
+export type ScrimPageData = Awaited>;
diff --git a/apps/web/src/lib/features/user-page/components/MutualFriends.svelte b/apps/web/src/lib/features/user-page/components/MutualFriends.svelte
new file mode 100644
index 000000000..00a69ecb8
--- /dev/null
+++ b/apps/web/src/lib/features/user-page/components/MutualFriends.svelte
@@ -0,0 +1,75 @@
+
+
+
+
+{#if mutualFriends.length > 0}
+
+
+ {#each visibleFriends as friend (friend.id)}
+
+ {/each}
+
+ {#if overflowCount > 0}
+
+{overflowCount}
+ {/if}
+
+ {mutualFriends.length === 1
+ ? m.user_mutualFriends_count_one({ count: mutualFriends.length })
+ : m.user_mutualFriends_count_other({ count: mutualFriends.length })}
+
+
+{/if}
+
+
diff --git a/apps/web/src/lib/features/user-page/components/UserCard.svelte b/apps/web/src/lib/features/user-page/components/UserCard.svelte
new file mode 100644
index 000000000..c7493df3a
--- /dev/null
+++ b/apps/web/src/lib/features/user-page/components/UserCard.svelte
@@ -0,0 +1,722 @@
+
+
+
+
+{#snippet divImage(region: XRankPlacementRegion)}
+ {#if region === "WEST"}
+
+ {/if}
+{/snippet}
+
+{#snippet statView(stat: UserCardStat)}
+ {#if stat.type === "XP"}
+ {@const unverified = stat.values.find((value) => !value.isVerified)}
+ {@const verified = stat.values.find((value) => value.isVerified)}
+ {@const primary = unverified ?? verified}
+ {@const secondary = unverified ? verified : undefined}
+
+ {#if primary}
+
+ {#if primary.isVerified}{/if}{@render divImage(primary.region)}
+ {primary.points}{m.user_card_xp()}
+
+ {/if}
+ {#if secondary}
+
+
+ {@render divImage(secondary.region)}
+ {secondary.points}{m.user_card_xp()}
+
+ {/if}
+
+ {:else if stat.type === "DIV"}
+ Div {stat.value}
+ {:else if stat.type === "PLUS"}
+
+
+ {stat.value}
+
+ {:else if stat.type === "SEASON"}
+
+
+ {#if typeof stat.top === "number"}
+
+
+
+ {/if}
+
+ {/if}
+{/snippet}
+
+{#snippet friendRequestButton(friendshipData: UserCardFriendship)}
+ {#if friendshipData.incomingFriendRequestId !== null}
+
+ {:else if friendshipData.sentFriendRequest}
+
+ {:else}
+
+ {/if}
+{/snippet}
+
+{#snippet noteView(note: NonNullable)}
+
+
+ {#if note.text}
+
{note.text}
+ {/if}
+
+
+
+
+
+{/snippet}
+
+{#if !data}
+ {@render children()}
+{:else}
+
+ {#snippet trigger(triggerProps)}
+
+ {/snippet}
+
+
+ {#if data.freeAgentPostId !== null}
+
+ {#snippet icon()}{/snippet}
+ {m.user_card_freeAgent()}
+
+ {/if}
+
+
+
+
+
+
+
{data.username}
+ {#if data.customUrl}
+
{data.customUrl}
+ {/if}
+ {#if data.friendCode}
+
SW-{data.friendCode}
+ {:else}
+
+
{"\u{200b}"}
+ {/if}
+
+
+ {#if showNoteView && data.privateNote}
+ {@render noteView(data.privateNote)}
+ {:else}
+ {#if stats.length > 0}
+
+ {#each stats as stat, i (stat.type)}
+ {#if i > 0}{/if}
+ {@render statView(stat)}
+ {/each}
+
+ {/if}
+ {#if !isOwnCard && withMutualFriends}
+
+ {#if friendship !== undefined}
+ {#if friendship.mutualFriends.length === 0}
+ {m.user_card_noMutualFriends()}
+ {:else}
+
+ {/if}
+ {/if}
+
+ {/if}
+ {#if data.shortBio}
+
{data.shortBio}
+ {/if}
+
+ {m.user_card_viewUserPage()}
+
+ {/if}
+
+
+{/if}
+
+
diff --git a/apps/web/src/lib/features/user-page/user-card-context.ts b/apps/web/src/lib/features/user-page/user-card-context.ts
new file mode 100644
index 000000000..258438d9c
--- /dev/null
+++ b/apps/web/src/lib/features/user-page/user-card-context.ts
@@ -0,0 +1,24 @@
+import { createContext } from "svelte";
+import type { UserCardData } from "./user-card-types.ts";
+
+export interface UserCardContext {
+ /** Card data by user id, as returned by `UserCardRepository.findAllByUserIds`. */
+ userCards: () => Map | undefined;
+}
+
+const [getOrThrow, set] = createContext();
+
+/** Provides `UserCard` data for the subtree; pages whose queries include `userCards` set this. */
+export const setUserCardContext = set;
+
+/**
+ * The nearest provided {@link UserCardContext}, or `undefined` when no ancestor set one —
+ * `UserCard` then renders its children plain without the popover.
+ */
+export function getUserCardContext(): UserCardContext | undefined {
+ try {
+ return getOrThrow();
+ } catch {
+ return undefined;
+ }
+}
diff --git a/apps/web/src/lib/features/user-page/user-card.remote.ts b/apps/web/src/lib/features/user-page/user-card.remote.ts
new file mode 100644
index 000000000..420aadb57
--- /dev/null
+++ b/apps/web/src/lib/features/user-page/user-card.remote.ts
@@ -0,0 +1,56 @@
+import * as v from "valibot";
+import { getUser } from "#lib/features/auth/user.server.ts";
+import * as FriendRepository from "#lib/features/friends/FriendRepository.server.ts";
+import { id } from "#lib/utils/schemas.ts";
+import { query } from "$app/server";
+import type { UserCardFriendship } from "./user-card-types.ts";
+
+/**
+ * Viewer-relative friendship data for a single user, lazy-loaded by the `UserCard`
+ * popover when it opens (keeps `isFriend` + `mutualFriends` out of the batched card
+ * query). Resolves to empty values when there is no logged-in viewer. Mutual friends
+ * are only resolved when the card opts in via `withMutualFriends` (some views, e.g.
+ * match pages, don't surface them), so the extra query is otherwise skipped.
+ */
+export const getUserCardFriendship = query(
+ v.object({ userId: id, withMutualFriends: v.boolean() }),
+ async ({ userId, withMutualFriends }): Promise => {
+ const viewer = getUser();
+
+ if (!viewer) {
+ return {
+ isFriend: false,
+ sentFriendRequest: false,
+ incomingFriendRequestId: null,
+ mutualFriends: [],
+ };
+ }
+
+ const [friendship, pendingRequest, mutualFriends] = await Promise.all([
+ FriendRepository.findFriendship({
+ userOneId: viewer.id,
+ userTwoId: userId,
+ }),
+ FriendRepository.findFriendRequestBetween({
+ senderId: viewer.id,
+ receiverId: userId,
+ }),
+ withMutualFriends
+ ? FriendRepository.findMutualFriends({
+ loggedInUserId: viewer.id,
+ targetUserId: userId,
+ })
+ : [],
+ ]);
+
+ return {
+ isFriend: Boolean(friendship),
+ sentFriendRequest: pendingRequest?.senderId === viewer.id,
+ incomingFriendRequestId:
+ pendingRequest && pendingRequest.senderId !== viewer.id
+ ? pendingRequest.id
+ : null,
+ mutualFriends,
+ };
+ },
+);
diff --git a/apps/web/src/lib/modules/i18n/messages.ts b/apps/web/src/lib/modules/i18n/messages.ts
index cae3be8aa..dba73d9d7 100644
--- a/apps/web/src/lib/modules/i18n/messages.ts
+++ b/apps/web/src/lib/modules/i18n/messages.ts
@@ -10,8 +10,11 @@ const messages = m as unknown as Record<
* keyed by id). Static call sites should always call the typed `m.*` functions
* directly instead; this exists only for id-indexed message families.
*/
-export function dynamicMessage(key: string): string {
- return messages[key]?.() ?? key;
+export function dynamicMessage(
+ key: string,
+ inputs?: Record,
+): string {
+ return messages[key]?.(inputs) ?? key;
}
/** Localized name of a main weapon. */
@@ -34,6 +37,11 @@ export function modeLongName(mode: string) {
return dynamicMessage(`game_misc_MODE_LONG_${mode}`);
}
+/** Localized short name of a mode (e.g. "SZ"). */
+export function modeShortName(mode: string) {
+ return dynamicMessage(`game_misc_MODE_SHORT_${mode}`);
+}
+
/** Localized stage name. */
export function stageName(stageId: number) {
return dynamicMessage(`game_misc_STAGE_${stageId}`);
diff --git a/apps/web/src/lib/modules/intl/date-time-format.ts b/apps/web/src/lib/modules/intl/date-time-format.ts
new file mode 100644
index 000000000..c4e274468
--- /dev/null
+++ b/apps/web/src/lib/modules/intl/date-time-format.ts
@@ -0,0 +1,94 @@
+import type { UserPreferences } from "#lib/db/tables-json.ts";
+import { loggedInUser } from "#lib/features/auth/user-state.ts";
+import { getLocale } from "#lib/paraglide/runtime.js";
+import { databaseTimestampToDate } from "#lib/utils/dates.ts";
+import { browser } from "$app/env";
+
+/**
+ * Zero-width space rendered (hidden via `invisible`) during SSR so the element
+ * reserves exactly one normal text line, avoiding layout shift on hydration
+ * without an empty box's baseline quirks.
+ */
+const SSR_PLACEHOLDER = "";
+
+const SSR_FORMATTER = {
+ format: (_date: Date | number) => SSR_PLACEHOLDER,
+ formatRange: (_from: Date | number, _to: Date | number) => SSR_PLACEHOLDER,
+};
+
+/**
+ * Resolves the language and hour cycle to use when formatting dates and times
+ * for the current user. Prefers a browser language sharing a base tag with the
+ * active site language (e.g. `en-GB` over site `en`) for regional formatting.
+ * `isLoaded` is `false` during SSR; gate locale-dependent output on it to
+ * avoid hydration flicker.
+ */
+export function userIntlPreference() {
+ const siteLanguage = getLocale();
+ const browserLanguages = browser ? navigator.languages : [];
+
+ // does the user want to use their browser language even if the site is in another language?
+ const language =
+ browserLanguages.find((lang) => compareLanguages(lang, siteLanguage)) ??
+ siteLanguage;
+
+ return {
+ language,
+ hourCycle: resolveHourCycle(loggedInUser()?.preferences?.clockFormat),
+ isLoaded: browser,
+ };
+}
+
+/**
+ * SSR-safe wrapper around `Intl.DateTimeFormat`.
+ *
+ * Uses the user's locale and hour cycle preferences via
+ * {@link userIntlPreference}. During SSR the returned formatter's methods
+ * return a zero-width space placeholder so the element reserves one text line
+ * until the client-formatted value replaces it on hydration.
+ *
+ * Inputs accept either a `Date` or a database timestamp (`number`); numbers
+ * are converted via `databaseTimestampToDate`.
+ */
+export function dateTimeFormat(options: Intl.DateTimeFormatOptions) {
+ const { language, hourCycle, isLoaded } = userIntlPreference();
+
+ const formatter = new Intl.DateTimeFormat(language, {
+ ...options,
+ ...(options.hour && hourCycle ? { hourCycle } : {}),
+ });
+
+ const realFormatter = {
+ format: (date: Date | number) => {
+ return formatter.format(
+ typeof date === "number" ? databaseTimestampToDate(date) : date,
+ );
+ },
+ formatRange: (from: Date | number, to: Date | number) => {
+ return formatter.formatRange(
+ typeof from === "number" ? databaseTimestampToDate(from) : from,
+ typeof to === "number" ? databaseTimestampToDate(to) : to,
+ );
+ },
+ };
+
+ return {
+ formatter: isLoaded ? realFormatter : SSR_FORMATTER,
+ isLoaded,
+ };
+}
+
+function resolveHourCycle(
+ clockFormat: UserPreferences["clockFormat"],
+): "h12" | "h23" | undefined {
+ if (clockFormat === "12h") return "h12";
+ if (clockFormat === "24h") return "h23";
+ return undefined;
+}
+
+function compareLanguages(a: string, b: string) {
+ const baseA = a.split("-")[0];
+ const baseB = b.split("-")[0];
+
+ return baseA.toUpperCase() === baseB.toUpperCase();
+}
diff --git a/apps/web/src/lib/modules/permissions/guards.server.ts b/apps/web/src/lib/modules/permissions/guards.server.ts
index d277758ed..c604cd62d 100644
--- a/apps/web/src/lib/modules/permissions/guards.server.ts
+++ b/apps/web/src/lib/modules/permissions/guards.server.ts
@@ -1,6 +1,7 @@
import { error } from "@sveltejs/kit";
-import { getUser } from "#lib/features/auth/user.server.ts";
-import type { Role } from "./types.ts";
+import { getUser, requireUser } from "#lib/features/auth/user.server.ts";
+import type { EntityWithPermissions, Role } from "./types.ts";
+import { hasPermission } from "./utils.ts";
/** Responds with 403 unless the logged in user has the given role. */
export function requireRole(role: Role) {
@@ -10,3 +11,17 @@ export function requireRole(role: Role) {
error(403, "Forbidden");
}
}
+
+/** Responds with 403 unless the logged in user has the given permission on the entity. */
+export function requirePermission<
+ T extends EntityWithPermissions,
+ K extends keyof T["permissions"],
+>(obj: T, permission: K) {
+ const user = requireUser();
+
+ if (hasPermission(obj, permission, user)) {
+ return;
+ }
+
+ error(403, "Forbidden");
+}
diff --git a/apps/web/src/lib/utils/respond.server.ts b/apps/web/src/lib/utils/respond.server.ts
new file mode 100644
index 000000000..8810f0248
--- /dev/null
+++ b/apps/web/src/lib/utils/respond.server.ts
@@ -0,0 +1,30 @@
+import { error } from "@sveltejs/kit";
+
+/**
+ * Rejects the remote function with a user-readable message.
+ *
+ * xxx: surface these as error toasts on the client (toast infra arrives with
+ * the layout polish) — for now callers see the failed mutation's message.
+ */
+export function errorToast(message: string): never {
+ error(400, message);
+}
+
+/** Like `errorToast` but only when the condition is falsy. */
+export function errorToastIfFalsy(
+ condition: unknown,
+ message: string,
+): asserts condition {
+ if (!condition) {
+ errorToast(message);
+ }
+}
+
+/** Responds with 404 when the value is null/undefined, narrowing it otherwise. */
+export function notFoundIfNullish(value: T): NonNullable {
+ if (value === null || value === undefined) {
+ error(404, "Not found");
+ }
+
+ return value;
+}
diff --git a/apps/web/src/lib/utils/sql.ts b/apps/web/src/lib/utils/sql.ts
new file mode 100644
index 000000000..5d22138ee
--- /dev/null
+++ b/apps/web/src/lib/utils/sql.ts
@@ -0,0 +1,5 @@
+import type { DBBoolean } from "#lib/server/db/tables.ts";
+
+export function toDBBoolean(value: boolean): DBBoolean {
+ return value ? 1 : 0;
+}
diff --git a/apps/web/src/lib/utils/urls.ts b/apps/web/src/lib/utils/urls.ts
index 0e1b54163..78780a304 100644
--- a/apps/web/src/lib/utils/urls.ts
+++ b/apps/web/src/lib/utils/urls.ts
@@ -30,6 +30,8 @@ export const twitchUrl = (accountName: string) =>
export const tournamentInfoPage = (tournamentId: number) =>
`/to/${tournamentId}/info`;
export const tournamentPage = (tournamentId: number) => `/to/${tournamentId}`;
+export const tournamentRegisterPage = (tournamentId: number) =>
+ `/to/${tournamentId}/register`;
export const tournamentStreamsPage = (tournamentId: number) =>
`/to/${tournamentId}/streams`;
export const SENDOUQ_STREAMS_PAGE = "/q/streams";
@@ -79,6 +81,21 @@ export const isCustomUrl = (value: string) => {
export const teamPage = (customUrl: string) => `/t/${customUrl}`;
+export const scrimsPage = () => "/scrims";
+export const scrimPage = (id: number) => `${scrimsPage()}/${id}`;
+export const newScrimPostPage = () => `${scrimsPage()}/new`;
+export const associationsPage = () => "/associations";
+
+export const LFG_PAGE = "/lfg";
+export const lfgPostPage = (postId: number) =>
+ `${LFG_PAGE}?post=${postId}#${postId}`;
+
+export const USER_CARD_EDIT_PAGE = "/user-card/edit";
+export const userCardEditPage = (args?: { returnTo?: string }) =>
+ args?.returnTo
+ ? `${USER_CARD_EDIT_PAGE}?returnTo=${encodeURIComponent(args.returnTo)}`
+ : USER_CARD_EDIT_PAGE;
+
export const topSearchPage = () => "/xsearch";
export const topSearchPlayerPage = (playerId: number) =>
`${topSearchPage()}/player/${playerId}`;
@@ -101,8 +118,12 @@ export const subWeaponImageUrl = (subWeaponSplId: number) =>
`${STATIC_ASSETS_URL}/img/sub-weapons/${subWeaponSplId}`;
export const specialWeaponImageUrl = (specialWeaponSplId: number) =>
`${STATIC_ASSETS_URL}/img/special-weapons/${specialWeaponSplId}`;
+export const brandImageUrl = (brand: string) =>
+ `${STATIC_ASSETS_URL}/img/brands/${brand}`;
export const stageImageUrl = (stageId: number) =>
`${STATIC_ASSETS_URL}/img/stages/${stageId}`;
+export const stageBannerImageUrl = (stageId: number) =>
+ `${STATIC_ASSETS_URL}/img/stage-banners/${stageId}.avif`;
export const modeImageUrl = (mode: string) =>
`${STATIC_ASSETS_URL}/img/modes/${mode}`;
export const tierImageUrl = (tier: string) =>
diff --git a/apps/web/src/params.ts b/apps/web/src/params.ts
new file mode 100644
index 000000000..b77c2d404
--- /dev/null
+++ b/apps/web/src/params.ts
@@ -0,0 +1,8 @@
+import { defineParams } from "@sveltejs/kit/params";
+
+export const params = defineParams({
+ integer: (param) => {
+ if (!/^\d+$/.test(param)) return;
+ return Number(param);
+ },
+});
diff --git a/apps/web/src/routes/run-routine/+server.ts b/apps/web/src/routes/run-routine/+server.ts
index ef48b0b4e..28c0363d4 100644
--- a/apps/web/src/routes/run-routine/+server.ts
+++ b/apps/web/src/routes/run-routine/+server.ts
@@ -1,9 +1,12 @@
import { error, json } from "@sveltejs/kit";
import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "#lib/features/admin/dev-controls.server.ts";
+import { deleteInactiveChatRooms } from "#lib/features/chat/chat.server.ts";
import type { RequestHandler } from "./$types";
// grows as routines are ported with their features (svelte-big-bang.md phase 4+)
-const routines = new Map Promise>();
+const routines = new Map Promise>([
+ ["deleteInactiveChatRooms", deleteInactiveChatRooms],
+]);
export const POST: RequestHandler = async ({ request }) => {
if (!DANGEROUS_CAN_ACCESS_DEV_CONTROLS) {
diff --git a/apps/web/src/routes/scrims/+page.svelte b/apps/web/src/routes/scrims/+page.svelte
new file mode 100644
index 000000000..0a577c65a
--- /dev/null
+++ b/apps/web/src/routes/scrims/+page.svelte
@@ -0,0 +1,12 @@
+
+
+
+
+
diff --git a/apps/web/src/routes/scrims/+page.ts b/apps/web/src/routes/scrims/+page.ts
new file mode 100644
index 000000000..dec839f23
--- /dev/null
+++ b/apps/web/src/routes/scrims/+page.ts
@@ -0,0 +1,14 @@
+import { navIconUrl, scrimsPage } from "#lib/utils/urls.ts";
+import type { PageLoad } from "./$types";
+
+export const load: PageLoad = () => {
+ return {
+ breadcrumbs: [
+ {
+ type: "IMAGE" as const,
+ imgPath: navIconUrl("scrims"),
+ href: scrimsPage(),
+ },
+ ],
+ };
+};
diff --git a/apps/web/src/routes/scrims/[id=integer]/+page.svelte b/apps/web/src/routes/scrims/[id=integer]/+page.svelte
new file mode 100644
index 000000000..8ba13ca6c
--- /dev/null
+++ b/apps/web/src/routes/scrims/[id=integer]/+page.svelte
@@ -0,0 +1,10 @@
+
+
+{#key scrimPostId}
+
+{/key}
diff --git a/apps/web/src/routes/scrims/[id=integer]/+page.ts b/apps/web/src/routes/scrims/[id=integer]/+page.ts
new file mode 100644
index 000000000..dec839f23
--- /dev/null
+++ b/apps/web/src/routes/scrims/[id=integer]/+page.ts
@@ -0,0 +1,14 @@
+import { navIconUrl, scrimsPage } from "#lib/utils/urls.ts";
+import type { PageLoad } from "./$types";
+
+export const load: PageLoad = () => {
+ return {
+ breadcrumbs: [
+ {
+ type: "IMAGE" as const,
+ imgPath: navIconUrl("scrims"),
+ href: scrimsPage(),
+ },
+ ],
+ };
+};
diff --git a/apps/web/src/routes/scrims/new/+page.svelte b/apps/web/src/routes/scrims/new/+page.svelte
new file mode 100644
index 000000000..3d96b052c
--- /dev/null
+++ b/apps/web/src/routes/scrims/new/+page.svelte
@@ -0,0 +1,8 @@
+
+
+
+
+
diff --git a/packages/components/src/LinkButton.svelte b/packages/components/src/LinkButton.svelte
new file mode 100644
index 000000000..f87e0451a
--- /dev/null
+++ b/packages/components/src/LinkButton.svelte
@@ -0,0 +1,214 @@
+
+
+
+ {#if icon}
+
+ {@render icon()}
+
+ {/if}
+ {#if children}{@render children()}{/if}
+
+
+
diff --git a/packages/components/src/MenuItem.svelte b/packages/components/src/MenuItem.svelte
index e3311cb5d..8ce639d1c 100644
--- a/packages/components/src/MenuItem.svelte
+++ b/packages/components/src/MenuItem.svelte
@@ -9,6 +9,7 @@ interface Props {
isActive?: boolean;
isDestructive?: boolean;
isDisabled?: boolean;
+ testId?: string;
children: Snippet;
}
@@ -19,6 +20,7 @@ let {
isActive,
isDestructive,
isDisabled,
+ testId,
children,
}: Props = $props();
@@ -45,6 +47,7 @@ function act() {
role="menuitem"
tabindex="-1"
aria-disabled={isDisabled || undefined}
+ data-testid={testId}
onclick={() => menu.close()}
>
{@render content()}
@@ -56,6 +59,7 @@ function act() {
role="menuitem"
tabindex="-1"
aria-disabled={isDisabled || undefined}
+ data-testid={testId}
onclick={act}
>
{@render content()}
diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts
index fea74fe62..92eb195c6 100644
--- a/packages/components/src/index.ts
+++ b/packages/components/src/index.ts
@@ -2,6 +2,7 @@ export { default as Button } from "./Button.svelte";
export { default as ChipRadio } from "./ChipRadio.svelte";
export { default as ChipRadioGroup } from "./ChipRadioGroup.svelte";
export { default as Dialog } from "./Dialog.svelte";
+export { default as LinkButton } from "./LinkButton.svelte";
export { default as Menu } from "./Menu.svelte";
export { default as Switch } from "./Switch.svelte";
export { default as MenuItem } from "./MenuItem.svelte";
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5c8884af5..6b972f0cf 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -67,6 +67,9 @@ importers:
valibot:
specifier: ^1.4.2
version: 1.4.2(typescript@6.0.3)
+ web-haptics:
+ specifier: 0.0.6
+ version: 0.0.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.56.9)
devDependencies:
'@inlang/paraglide-js':
specifier: ^2.24.1