Merge branch 'main' into user-card

This commit is contained in:
Kalle
2026-07-02 17:58:02 +03:00
103 changed files with 2212 additions and 1223 deletions

View File

@@ -22,7 +22,7 @@ export type LoaderNotification = NonNullable<
export function useNotifications() {
const [root] = useMatches();
const notifications = (root.data as RootLoaderData | undefined)
const notifications = (root.loaderData as RootLoaderData | undefined)
?.notifications;
const unseenIds = React.useMemo(

View File

@@ -605,7 +605,8 @@ function PageIcon({ crumb }: { crumb: Breadcrumb }) {
return null;
}
const isExternal = crumb.imgPath.includes(".");
const lastPathSegment = crumb.imgPath.split("/").pop() ?? "";
const isExternal = lastPathSegment.includes(".");
const iconClass = clsx(styles.pageIcon, "rounded");
return (

View File

@@ -37,6 +37,15 @@ sql.pragma("mmap_size = 3221225472");
// connections; pair with a periodic `PRAGMA optimize;` (see OptimizeDatabase routine)
sql.pragma("optimize = 0x10002");
// Strips diacritics so accent-insensitive name searches are possible
// (e.g. "cafe" matches "Café"). Combined with LIKE's built-in ASCII
// case-insensitivity this also folds case for the resulting latin letters.
sql.function("unaccent", { deterministic: true }, (value) =>
typeof value === "string"
? value.normalize("NFD").replace(/\p{M}/gu, "")
: value,
);
export const db = new Kysely<DB>({
dialect: new SqliteDialect({
database: sql,

View File

@@ -23,10 +23,9 @@ export async function wrapActionForApi(
} catch (e) {
if (e instanceof Response && e.status === 302) {
const location = e.headers.get("Location") ?? "";
if (location.includes("__error=")) {
const errorMsg = new URLSearchParams(location.replace("?", "")).get(
"__error",
);
const search = location.slice(location.indexOf("?") + 1);
const errorMsg = new URLSearchParams(search).get("__error");
if (errorMsg !== null) {
return new Response(JSON.stringify({ error: errorMsg }), {
status: 400,
headers: { "Content-Type": "application/json" },

View File

@@ -54,7 +54,7 @@ export const handle: SendouRouteHandle = {
};
export const meta: MetaFunction = (args) => {
const data = args.data as SerializeFrom<typeof loader> | null;
const data = args.loaderData as SerializeFrom<typeof loader> | null;
if (!data) return [];

View File

@@ -19,7 +19,7 @@ export { loader };
export const handle: SendouRouteHandle = {
breadcrumb: ({ match }) => {
const data = match.data as SerializeFrom<typeof loader> | undefined;
const data = match.loaderData as SerializeFrom<typeof loader> | undefined;
if (!data) return [];
@@ -35,7 +35,7 @@ export const handle: SendouRouteHandle = {
export const meta: MetaFunction = (args) => {
invariant(args.params.slug);
const data = args.data as SerializeFrom<typeof loader> | null;
const data = args.loaderData as SerializeFrom<typeof loader> | null;
if (!data) return [];

View File

@@ -4,5 +4,5 @@ import type { RootLoaderData } from "~/root";
export function useUser() {
const [root] = useMatches();
return (root.data as RootLoaderData | undefined)?.user;
return (root.loaderData as RootLoaderData | undefined)?.user;
}

View File

@@ -18,7 +18,7 @@ export default function EditBadgePage() {
const isStaff = useHasRole("STAFF");
const matches = useMatches();
const parentMatch = matches.at(-2)!;
const data = parentMatch.data as BadgeDetailsLoaderData;
const data = parentMatch.loaderData as BadgeDetailsLoaderData;
const { badge } = useOutletContext<BadgeDetailsContext>();
const canManageBadge = useHasPermission(badge, "MANAGE");

View File

@@ -188,6 +188,23 @@ export type SpecialWeaponParams = SpecialWeaponParamsObject[SpecialWeaponId] & {
BumpDamage?: number;
JumpDamage?: number;
TickDamage?: number;
// Map planner range circle params (populated by scripts/create-analyzer-json.ts).
/** Effect radius for area specials (Big Bubbler, Ink Storm, ...) */
Range_Radius?: number;
/** Straight-flight range for projectile specials with a fixed distance (Inkjet) */
Range_Distance?: number;
/** Outer blast radius drawn around a projectile special's impact */
Range_BlastRadius?: number;
/** Projectile trajectory params (Trizooka, Crab Tank); see comp-analyzer weapon-range */
Range_SpawnSpeed?: number;
Range_GoStraightStateEndMaxSpeed?: number;
Range_GoStraightToBrakeStateFrame?: number;
Range_FreeGravity?: number;
Range_FreeAirResist?: number;
Range_BrakeAirResist?: number;
Range_BrakeGravity?: number;
Range_BrakeToFreeStateFrame?: number;
};
export type ParamsJson = {

View File

@@ -51,6 +51,14 @@ export function mainWeaponParams(weaponId: MainWeaponId): MainWeaponParams {
return { ...baseStats, ...kit } as MainWeaponParams;
}
export function specialWeaponParams(
specialWeaponId: SpecialWeaponId,
): SpecialWeaponParams {
const params = rawWeaponParams as unknown as ParamsJson;
return params.specialWeapons[specialWeaponId] as SpecialWeaponParams;
}
export function buildToAbilityPoints(build: BuildAbilitiesTupleWithUnknown) {
const result: AbilityPoints = new Map();

View File

@@ -2326,6 +2326,15 @@ export const weaponParams = {
},
],
DirectDamage: 2200,
Range_SpawnSpeed: 1,
Range_GoStraightStateEndMaxSpeed: 1,
Range_GoStraightToBrakeStateFrame: 18,
Range_FreeGravity: 0.0190565,
Range_FreeAirResist: 0.01985,
Range_BrakeAirResist: 0.09,
Range_BrakeGravity: 0.09,
Range_BrakeToFreeStateFrame: 10,
Range_BlastRadius: 4,
},
"2": {
overwrites: {
@@ -2364,6 +2373,7 @@ export const weaponParams = {
Distance: 6,
},
],
Range_Radius: 35,
},
"4": {
overwrites: {
@@ -2412,6 +2422,8 @@ export const weaponParams = {
},
},
TickDamage: 33,
Range_Distance: 27,
Range_BlastRadius: 12.6,
},
"7": {
overwrites: {
@@ -2428,6 +2440,7 @@ export const weaponParams = {
},
DirectDamage: 300,
WaveDamage: 450,
Range_Radius: 20,
},
"8": {
overwrites: {
@@ -2504,6 +2517,8 @@ export const weaponParams = {
},
],
DirectDamage: 1200,
Range_Distance: 30,
Range_BlastRadius: 5,
},
"11": {
overwrites: {
@@ -2534,6 +2549,8 @@ export const weaponParams = {
},
],
ThrowDirectDamage: 2200,
Range_Distance: 24,
Range_BlastRadius: 8,
},
"12": {
ArmorHP: 5000,
@@ -2557,6 +2574,11 @@ export const weaponParams = {
},
],
BumpDamage: 400,
Range_SpawnSpeed: 3.36,
Range_GoStraightStateEndMaxSpeed: 2.232,
Range_GoStraightToBrakeStateFrame: 7,
Range_FreeGravity: 0.016,
Range_BlastRadius: 4.8,
},
"13": {
overwrites: {
@@ -2591,6 +2613,7 @@ export const weaponParams = {
Distance: 14.9,
},
],
Range_Radius: 9,
},
"14": {
overwrites: {
@@ -2601,6 +2624,8 @@ export const weaponParams = {
},
},
TickDamage: 75,
Range_Distance: 28,
Range_BlastRadius: 7.7,
},
"15": {
overwrites: {
@@ -2641,6 +2666,8 @@ export const weaponParams = {
Distance: 6,
},
],
Range_Distance: 30,
Range_BlastRadius: 6,
},
"17": {
overwrites: {
@@ -2676,6 +2703,7 @@ export const weaponParams = {
Distance: 10.5,
},
],
Range_Radius: 7,
},
"19": {
overwrites: {

View File

@@ -18,12 +18,12 @@ import { loader } from "../loaders/builds.$slug.popular.server";
export { loader };
export const meta: MetaFunction<typeof loader> = (args) => {
if (!args.data) return [];
if (!args.loaderData) return [];
return metaTags({
title: `${args.data.weaponName} popular builds`,
ogTitle: `${args.data.weaponName} Splatoon 3 popular builds`,
description: `List of most popular ability combinations for ${args.data.weaponName}.`,
title: `${args.loaderData.weaponName} popular builds`,
ogTitle: `${args.loaderData.weaponName} Splatoon 3 popular builds`,
description: `List of most popular ability combinations for ${args.loaderData.weaponName}.`,
location: args.location,
});
};
@@ -31,7 +31,7 @@ export const meta: MetaFunction<typeof loader> = (args) => {
export const handle: SendouRouteHandle = {
i18n: ["analyzer", "builds"],
breadcrumb: ({ match }) => {
const data = match.data as SerializeFrom<typeof loader> | undefined;
const data = match.loaderData as SerializeFrom<typeof loader> | undefined;
if (!data) return [];

View File

@@ -21,12 +21,12 @@ import { MAX_AP } from "~/features/build-analyzer/analyzer-constants";
import styles from "./builds.$slug.stats.module.css";
export const meta: MetaFunction<typeof loader> = (args) => {
if (!args.data) return [];
if (!args.loaderData) return [];
return metaTags({
title: `${args.data.weaponName} popular abilities`,
ogTitle: `${args.data.weaponName} Splatoon 3 popular abilities`,
description: `List of the most popular abilities for ${args.data.weaponName} in Splatoon 3.`,
title: `${args.loaderData.weaponName} popular abilities`,
ogTitle: `${args.loaderData.weaponName} Splatoon 3 popular abilities`,
description: `List of the most popular abilities for ${args.loaderData.weaponName} in Splatoon 3.`,
location: args.location,
});
};
@@ -34,7 +34,7 @@ export const meta: MetaFunction<typeof loader> = (args) => {
export const handle: SendouRouteHandle = {
i18n: ["weapons", "builds", "analyzer"],
breadcrumb: ({ match }) => {
const data = match.data as SerializeFrom<typeof loader> | undefined;
const data = match.loaderData as SerializeFrom<typeof loader> | undefined;
if (!data) return [];

View File

@@ -129,12 +129,12 @@ function filterKey(filter: ParsedFilter): string {
}
export const meta: MetaFunction<typeof loader> = (args) => {
if (!args.data) return [];
if (!args.loaderData) return [];
return metaTags({
title: `${args.data.weaponName} builds`,
ogTitle: `${args.data.weaponName} Splatoon 3 builds`,
description: `Collection of ${args.data.weaponName} builds from the top competitive players. Find the best combination of abilities and level up your gameplay.`,
title: `${args.loaderData.weaponName} builds`,
ogTitle: `${args.loaderData.weaponName} Splatoon 3 builds`,
description: `Collection of ${args.loaderData.weaponName} builds from the top competitive players. Find the best combination of abilities and level up your gameplay.`,
location: args.location,
});
};
@@ -142,7 +142,7 @@ export const meta: MetaFunction<typeof loader> = (args) => {
export const handle: SendouRouteHandle = {
i18n: ["weapons", "builds", "gear", "analyzer"],
breadcrumb: ({ match }) => {
const data = match.data as SerializeFrom<typeof loader> | undefined;
const data = match.loaderData as SerializeFrom<typeof loader> | undefined;
if (!data) return [];

View File

@@ -44,6 +44,7 @@ export const action: ActionFunction = async ({ request }) => {
const isEditing = Boolean(data.eventToEditId);
const isAddingTournament = data.toToolsEnabled;
const isTournamentAdder = user.roles.includes("TOURNAMENT_ADDER");
const organizationId = data.organizationId
? Number(data.organizationId)
: null;
@@ -52,7 +53,7 @@ export const action: ActionFunction = async ({ request }) => {
await validateOrganization({
userId: user.id,
organizationId,
isTournamentAdder: user.roles.includes("TOURNAMENT_ADDER"),
isTournamentAdder,
});
} else if (!isEditing) {
requireRole(
@@ -141,7 +142,10 @@ export const action: ActionFunction = async ({ request }) => {
"Tournament has already started",
);
errorToastIfFalsy(tournament.isAdmin(user), "Not authorized");
errorToastIfFalsy(
tournament.canEditEventInfo(user, { isTournamentAdder }),
"Not authorized",
);
// once published, a tournament can't be flipped back to draft
if (!tournament.isDraft) {

View File

@@ -41,7 +41,7 @@ import { loader } from "../loaders/calendar.$id.server";
export { action, loader };
export const meta: MetaFunction = (args) => {
const data = args.data as SerializeFrom<typeof loader>;
const data = args.loaderData as SerializeFrom<typeof loader>;
if (!data) return [];
@@ -57,7 +57,7 @@ export const meta: MetaFunction = (args) => {
export const handle: SendouRouteHandle = {
i18n: ["calendar", "game-misc"],
breadcrumb: ({ match }) => {
const data = match.data as SerializeFrom<typeof loader> | undefined;
const data = match.loaderData as SerializeFrom<typeof loader> | undefined;
if (!data) return [];

View File

@@ -34,12 +34,14 @@ import { loader } from "../loaders/calendar.new.server";
export { action, loader };
export const meta: MetaFunction<typeof loader> = (args) => {
if (!args.data) return [];
if (!args.loaderData) return [];
const what = args.data.isAddingTournament ? "tournament" : "calendar event";
const what = args.loaderData.isAddingTournament
? "tournament"
: "calendar event";
return metaTags({
title: args.data.eventToEdit ? `Editing ${what}` : `New ${what}`,
title: args.loaderData.eventToEdit ? `Editing ${what}` : `New ${what}`,
location: args.location,
});
};

View File

@@ -763,7 +763,7 @@ export function useCurrentRouteChatCodes(): string[] {
const matches = useMatches();
for (const match of matches) {
const matchData = match.data as
const matchData = match.loaderData as
| { chatCode?: string | string[] }
| undefined;
if (matchData?.chatCode) {

View File

@@ -0,0 +1,60 @@
.container {
display: flex;
flex-direction: column;
gap: var(--s-2);
}
.legend {
display: flex;
gap: var(--s-4);
font-size: var(--font-xs);
color: var(--color-text-high);
}
.legendItem {
display: flex;
align-items: center;
gap: var(--s-1-5);
}
.legendSwatch {
width: 12px;
height: 12px;
border-radius: 2px;
}
.row {
display: grid;
grid-template-columns: 32px 1fr 3rem;
align-items: center;
gap: var(--s-2);
}
.track {
position: relative;
height: 14px;
border-radius: var(--radius-field);
background: var(--color-bg-high);
overflow: hidden;
}
.bar {
position: absolute;
inset-block: 0;
inset-inline-start: 0;
border-radius: var(--radius-field);
}
.blast {
position: absolute;
inset-block: 0;
opacity: 0.4;
}
.range {
font-size: var(--font-xs);
font-weight: var(--weight-semi);
color: var(--color-text-high);
text-align: end;
font-variant-numeric: tabular-nums;
}

View File

@@ -0,0 +1,85 @@
// note: dev only component, not used in production code
import { useTranslation } from "react-i18next";
import { Image } from "~/components/Image";
import { specialWeaponImageUrl } from "~/utils/urls";
import {
getSpecialsWithRange,
type SpecialWeaponWithRange,
} from "../core/special-weapon-range";
import styles from "./SpecialRangeVisualization.module.css";
const RANGE_TYPE_COLOR: Record<SpecialWeaponWithRange["rangeType"], string> = {
projectile: "#8cd4f5",
radius: "#f5b8d0",
};
export function SpecialRangeVisualization() {
const { t } = useTranslation(["weapons"]);
const specials = getSpecialsWithRange();
if (specials.length === 0) {
return null;
}
const maxRange = Math.max(
...specials.map((special) => special.range + (special.blastRadius ?? 0)),
);
return (
<div className={styles.container} data-testid="special-range-visualization">
<div className={styles.legend}>
<div className={styles.legendItem}>
<span
className={styles.legendSwatch}
style={{ backgroundColor: RANGE_TYPE_COLOR.projectile }}
/>
projectile
</div>
<div className={styles.legendItem}>
<span
className={styles.legendSwatch}
style={{ backgroundColor: RANGE_TYPE_COLOR.radius }}
/>
radius
</div>
</div>
{specials.map((special) => {
const color = RANGE_TYPE_COLOR[special.rangeType];
const rangeWidth = (special.range / maxRange) * 100;
const blastWidth = special.blastRadius
? (special.blastRadius / maxRange) * 100
: 0;
return (
<div key={special.specialWeaponId} className={styles.row}>
<Image
path={specialWeaponImageUrl(special.specialWeaponId)}
width={28}
height={28}
alt={t(`weapons:SPECIAL_${special.specialWeaponId}`)}
title={t(`weapons:SPECIAL_${special.specialWeaponId}`)}
/>
<div className={styles.track}>
{blastWidth > 0 ? (
<span
className={styles.blast}
style={{
insetInlineStart: `${rangeWidth}%`,
width: `${blastWidth}%`,
backgroundColor: color,
}}
/>
) : null}
<span
className={styles.bar}
style={{ width: `${rangeWidth}%`, backgroundColor: color }}
/>
</div>
<span className={styles.range}>{special.range.toFixed(1)}</span>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,57 @@
import { describe, expect, test } from "vitest";
import {
BIG_BUBBLER_ID,
BOOYAH_BOMB_ID,
CRAB_TANK_ID,
SPLATTERCOLOR_SCREEN_ID,
specialWeaponIds,
TRIZOOKA_ID,
WAVE_BREAKER_ID,
} from "~/modules/in-game-lists/weapon-ids";
import { getSpecialWeaponRange } from "./special-weapon-range";
describe("special weapon range", () => {
test("computes a projectile range for Trizooka", () => {
const result = getSpecialWeaponRange(TRIZOOKA_ID);
expect(result?.rangeType).toBe("projectile");
expect(result?.range).toBeGreaterThan(0);
});
test("Crab Tank reaches further than Trizooka", () => {
const crab = getSpecialWeaponRange(CRAB_TANK_ID);
const trizooka = getSpecialWeaponRange(TRIZOOKA_ID);
expect(crab!.range).toBeGreaterThan(trizooka!.range);
});
test("throws-and-bursts specials expose a throw range plus explosion blast", () => {
const booyah = getSpecialWeaponRange(BOOYAH_BOMB_ID);
expect(booyah?.rangeType).toBe("projectile");
expect(booyah?.range).toBeGreaterThan(0);
expect(booyah?.blastRadius).toBeGreaterThan(0);
});
test("returns the effect radius for area specials", () => {
const waveBreaker = getSpecialWeaponRange(WAVE_BREAKER_ID);
expect(waveBreaker?.rangeType).toBe("radius");
expect(waveBreaker?.range).toBeGreaterThan(0);
});
test("returns null for global / utility specials without a meaningful circle", () => {
expect(getSpecialWeaponRange(BIG_BUBBLER_ID)).toBeNull();
expect(getSpecialWeaponRange(SPLATTERCOLOR_SCREEN_ID)).toBeNull();
});
test("every special is either unsupported or has a positive finite range", () => {
for (const id of specialWeaponIds) {
const result = getSpecialWeaponRange(id);
if (result === null) continue;
expect(Number.isFinite(result.range)).toBe(true);
expect(result.range).toBeGreaterThan(0);
}
});
});

View File

@@ -0,0 +1,88 @@
import { specialWeaponParams } from "~/features/build-analyzer/core/utils";
import type { SpecialWeaponId } from "~/modules/in-game-lists/types";
import { specialWeaponIds } from "~/modules/in-game-lists/weapon-ids";
import {
calculateGroundRange,
simulateTrajectoryPoints,
type TrajectoryParams,
} from "./weapon-range";
const DEFAULT_BRAKE_AIR_RESIST = 0.36;
const DEFAULT_BRAKE_GRAVITY = 0.07;
const DEFAULT_BRAKE_TO_FREE_FRAME = 4;
const DEFAULT_FREE_GRAVITY = 0.016;
const DEFAULT_FREE_AIR_RESIST = 0;
export interface SpecialWeaponRangeResult {
/** Radius of the range circle, in game distance units. */
range: number;
/** Extra outer radius covered by the projectile's blast, in game distance units. */
blastRadius?: number;
rangeType: "projectile" | "radius";
}
/**
* Range circle definition for a special weapon, or `null` when the special has no meaningful
* range to draw. The underlying values come from `weapon-params.ts` (populated by
* `scripts/create-analyzer-json.ts`): projectile specials reuse the main weapon trajectory
* model, the rest carry a single effect radius.
*/
export function getSpecialWeaponRange(
specialWeaponId: SpecialWeaponId,
): SpecialWeaponRangeResult | null {
const params = specialWeaponParams(specialWeaponId);
if (params.Range_Radius !== undefined) {
return { range: params.Range_Radius, rangeType: "radius" };
}
if (params.Range_Distance !== undefined) {
return {
range: params.Range_Distance,
blastRadius: params.Range_BlastRadius,
rangeType: "projectile",
};
}
if (params.Range_SpawnSpeed === undefined) return null;
const trajectoryParams: TrajectoryParams = {
spawnSpeed: params.Range_SpawnSpeed,
goStraightStateEndMaxSpeed:
params.Range_GoStraightStateEndMaxSpeed ?? params.Range_SpawnSpeed,
goStraightToBrakeStateFrame: params.Range_GoStraightToBrakeStateFrame ?? 4,
freeGravity: params.Range_FreeGravity ?? DEFAULT_FREE_GRAVITY,
freeAirResist: params.Range_FreeAirResist ?? DEFAULT_FREE_AIR_RESIST,
brakeAirResist: params.Range_BrakeAirResist ?? DEFAULT_BRAKE_AIR_RESIST,
brakeGravity: params.Range_BrakeGravity ?? DEFAULT_BRAKE_GRAVITY,
brakeToFreeFrame:
params.Range_BrakeToFreeStateFrame ?? DEFAULT_BRAKE_TO_FREE_FRAME,
};
const range = calculateGroundRange(
simulateTrajectoryPoints(trajectoryParams),
);
return {
range,
blastRadius: params.Range_BlastRadius,
rangeType: "projectile",
};
}
export interface SpecialWeaponWithRange extends SpecialWeaponRangeResult {
specialWeaponId: SpecialWeaponId;
}
/**
* Every special weapon that has a range to draw, widest first. Specials without a meaningful
* range circle are omitted.
*/
export function getSpecialsWithRange(): SpecialWeaponWithRange[] {
return specialWeaponIds
.flatMap((specialWeaponId): SpecialWeaponWithRange[] => {
const result = getSpecialWeaponRange(specialWeaponId);
return result ? [{ specialWeaponId, ...result }] : [];
})
.sort((a, b) => b.range - a.range);
}

View File

@@ -2,7 +2,7 @@ import { mainWeaponParams } from "~/features/build-analyzer/core/utils";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { weaponCategories } from "~/modules/in-game-lists/weapon-ids";
interface TrajectoryParams {
export interface TrajectoryParams {
spawnSpeed: number;
goStraightStateEndMaxSpeed: number;
goStraightToBrakeStateFrame: number;
@@ -37,7 +37,7 @@ function getWeaponCategoryName(weaponId: MainWeaponId): string | undefined {
const PLAYER_HEIGHT = 1.0;
function calculateGroundRange(trajectory: TrajectoryPoint[]): number {
export function calculateGroundRange(trajectory: TrajectoryPoint[]): number {
for (let i = 1; i < trajectory.length; i++) {
const point = trajectory[i];
const prevPoint = trajectory[i - 1];
@@ -55,7 +55,9 @@ function calculateBouncingRange(trajectory: TrajectoryPoint[]): number {
return lastPoint?.z ?? 0;
}
function simulateTrajectoryPoints(params: TrajectoryParams): TrajectoryPoint[] {
export function simulateTrajectoryPoints(
params: TrajectoryParams,
): TrajectoryPoint[] {
const {
spawnSpeed,
goStraightStateEndMaxSpeed,

View File

@@ -5,6 +5,7 @@ import {
weaponIdToType,
} from "~/modules/in-game-lists/weapon-ids";
import { RangeVisualization } from "../components/RangeVisualization";
import { SpecialRangeVisualization } from "../components/SpecialRangeVisualization";
export default function AllRangesPage() {
return (
@@ -31,6 +32,10 @@ export default function AllRangesPage() {
</section>
);
})}
<section>
<h2 style={{ textTransform: "capitalize" }}>specials</h2>
<SpecialRangeVisualization />
</section>
</Main>
);
}

View File

@@ -45,7 +45,7 @@ export const handle: SendouRouteHandle = {
};
export const meta: MetaFunction = (args) => {
const data = args.data as SerializeFrom<typeof loader> | null;
const data = args.loaderData as SerializeFrom<typeof loader> | null;
if (!data) return [];

View File

@@ -33,6 +33,7 @@ import {
} from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { getSpecialWeaponRange } from "~/features/comp-analyzer/core/special-weapon-range";
import { getWeaponRange } from "~/features/comp-analyzer/core/weapon-range";
import { useTheme } from "~/features/theme/core/provider";
import type { LanguageCode } from "~/modules/i18n/config";
@@ -41,6 +42,7 @@ import { stageIds } from "~/modules/in-game-lists/stage-ids";
import type {
MainWeaponId,
ModeShort,
SpecialWeaponId,
StageId,
} from "~/modules/in-game-lists/types";
import {
@@ -71,6 +73,7 @@ const GAME_UNITS_TO_PX: Record<"MINI" | "OVER", number> = {
OVER: 8.4,
};
const MAIN_WEAPON_URL_PATTERN = /main-weapons-outlined\/(\d+)/;
const SPECIAL_WEAPON_URL_PATTERN = /special-weapons\/(\d+)/;
export default function Planner() {
const { t, i18n } = useTranslation(["common"]);
@@ -783,6 +786,36 @@ function extractMainWeaponIdFromSrc(src: string): MainWeaponId | null {
return id as MainWeaponId;
}
function extractSpecialWeaponIdFromSrc(src: string): SpecialWeaponId | null {
const match = src.match(SPECIAL_WEAPON_URL_PATTERN);
if (!match) return null;
const id = Number(match[1]);
if (!specialWeaponIds.includes(id as SpecialWeaponId)) return null;
return id as SpecialWeaponId;
}
function rangeForSrc(
src: string,
): { range: number; blastRadius?: number } | null {
const mainWeaponId = extractMainWeaponIdFromSrc(src);
if (mainWeaponId !== null) {
const result = getWeaponRange(mainWeaponId);
if (result.rangeType === "unsupported" || result.range <= 0) return null;
return { range: result.range, blastRadius: result.blastRadius };
}
const specialWeaponId = extractSpecialWeaponIdFromSrc(src);
if (specialWeaponId !== null) {
const result = getSpecialWeaponRange(specialWeaponId);
if (!result || result.range <= 0) return null;
return { range: result.range, blastRadius: result.blastRadius };
}
return null;
}
function createRangeCircleForShape(
editor: Editor,
shape: ReturnType<Editor["getCurrentPageShapes"]>[number],
@@ -796,11 +829,8 @@ function createRangeCircleForShape(
const asset = editor.getAsset(assetId as TLAssetId);
if (asset?.type !== "image" || !asset.props.src) return;
const weaponId = extractMainWeaponIdFromSrc(asset.props.src);
if (!weaponId) return;
const rangeResult = getWeaponRange(weaponId);
if (rangeResult.rangeType === "unsupported" || rangeResult.range <= 0) return;
const rangeResult = rangeForSrc(asset.props.src);
if (!rangeResult) return;
const centerX = shape.x + (shape.props as { w: number }).w / 2;
const centerY = shape.y + (shape.props as { h: number }).h / 2;

View File

@@ -23,7 +23,7 @@ export { loader };
export const handle: SendouRouteHandle = {
i18n: ["weapons", "common", "analyzer", "params"],
breadcrumb: ({ match }) => {
const data = match.data as SerializeFrom<typeof loader> | undefined;
const data = match.loaderData as SerializeFrom<typeof loader> | undefined;
if (!data) return [];
return [
{
@@ -36,10 +36,10 @@ export const handle: SendouRouteHandle = {
};
export const meta: MetaFunction<typeof loader> = (args) => {
if (!args.data) return [];
if (!args.loaderData) return [];
return metaTags({
title: `${args.data.weaponName} parameters`,
description: `${args.data.weaponName} parameters with version history compared across ${comparedAcross(args.data.kind)}.`,
title: `${args.loaderData.weaponName} parameters`,
description: `${args.loaderData.weaponName} parameters with version history compared across ${comparedAcross(args.loaderData.kind)}.`,
location: args.location,
});
};

View File

@@ -15,7 +15,7 @@ export default function PlusCommentModalPage() {
const user = useUser();
const matches = useMatches();
const params = useParams();
const data = matches.at(-2)!.data as PlusSuggestionsLoaderData;
const data = matches.at(-2)!.loaderData as PlusSuggestionsLoaderData;
const targetUserId = Number(params.userId);
const tierSuggestedTo = Number(params.tier);

View File

@@ -15,7 +15,7 @@ export { action };
export default function PlusNewSuggestionModalPage() {
const user = useUser();
const matches = useMatches();
const data = matches.at(-2)!.data as PlusSuggestionsLoaderData;
const data = matches.at(-2)!.loaderData as PlusSuggestionsLoaderData;
const tierOptions = PLUS_TIERS.filter((tier) => {
// user will be redirected anyway

View File

@@ -14,7 +14,7 @@ import { loader } from "../loaders/q.match.$id.server";
export { action, loader };
export const meta: MetaFunction = (args) => {
const data = args.data as SerializeFrom<typeof loader> | null;
const data = args.loaderData as SerializeFrom<typeof loader> | null;
if (!data) return [];

View File

@@ -48,7 +48,7 @@ function ThemeSelector() {
function CustomColorSelector() {
const [root] = useMatches();
const rootData = root.data as RootLoaderData | undefined;
const rootData = root.loaderData as RootLoaderData | undefined;
const isSupporter = useHasRole("SUPPORTER");
const fetcher = useFetcher();

View File

@@ -11,7 +11,7 @@ export function TeamGoBackButton() {
const [, parentRoute] = useMatches();
invariant(parentRoute);
const layoutData = parentRoute.data as TeamLoaderData;
const layoutData = parentRoute.loaderData as TeamLoaderData;
return (
<div className="stack">

View File

@@ -45,7 +45,7 @@ export default function TeamIndexPage() {
const { t } = useTranslation(["team"]);
const [, parentRoute] = useMatches();
invariant(parentRoute);
const layoutData = parentRoute.data as TeamLoaderData;
const layoutData = parentRoute.loaderData as TeamLoaderData;
const members = layoutData.team.members;
const playerMembers = members.filter(
@@ -110,7 +110,7 @@ function ActionButtons() {
const isAdmin = useHasRole("ADMIN");
const [, parentRoute] = useMatches();
invariant(parentRoute);
const layoutData = parentRoute.data as TeamLoaderData;
const layoutData = parentRoute.loaderData as TeamLoaderData;
const team = layoutData.team;
if (!isTeamMember({ user, team }) && !isAdmin) {

View File

@@ -15,15 +15,15 @@ export { loader };
import styles from "../team.module.css";
export const meta: MetaFunction<typeof loader> = (args) => {
if (!args.data) return [];
if (!args.loaderData) return [];
return metaTags({
title: args.data.team.name,
description: args.data.team.bio ?? undefined,
title: args.loaderData.team.name,
description: args.loaderData.team.bio ?? undefined,
location: args.location,
image: args.data.team.avatarUrl
image: args.loaderData.team.avatarUrl
? {
url: args.data.team.avatarUrl,
url: args.loaderData.team.avatarUrl,
dimensions: {
width: 124,
height: 124,
@@ -36,7 +36,7 @@ export const meta: MetaFunction<typeof loader> = (args) => {
export const handle: SendouRouteHandle = {
i18n: ["team"],
breadcrumb: ({ match }) => {
const data = match.data as SerializeFrom<typeof loader> | undefined;
const data = match.loaderData as SerializeFrom<typeof loader> | undefined;
if (!data) return [];

View File

@@ -22,7 +22,7 @@ export { action, loader };
export const handle: SendouRouteHandle = {
breadcrumb: ({ match }) => {
const data = match.data as SerializeFrom<typeof loader> | undefined;
const data = match.loaderData as SerializeFrom<typeof loader> | undefined;
if (!data) return [];
@@ -44,16 +44,16 @@ export const handle: SendouRouteHandle = {
};
export const meta: MetaFunction<typeof loader> = (args) => {
if (!args.data) return [];
if (!args.loaderData) return [];
const aliasesStr =
args.data.names.aliases.length > 0
? ` (Aliases: ${args.data.names.aliases.join(", ")})`
args.loaderData.names.aliases.length > 0
? ` (Aliases: ${args.loaderData.names.aliases.join(", ")})`
: "";
return metaTags({
title: `${args.data.names.primary} X Battle Top 500 Placements`,
description: `Splatoon 3 X Battle results for the player ${args.data.names.primary}${aliasesStr}`,
title: `${args.loaderData.names.primary} X Battle Top 500 Placements`,
description: `Splatoon 3 X Battle results for the player ${args.loaderData.names.primary}${aliasesStr}`,
location: args.location,
});
};

View File

@@ -23,6 +23,7 @@ import { Redirect } from "~/components/Redirect";
import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "~/features/admin/core/dev-controls";
import { useUser } from "~/features/auth/core/user";
import { useTournament } from "~/features/tournament/routes/to.$id";
import { useHasRole } from "~/modules/permissions/hooks";
import {
calendarEventPage,
tournamentAdminPage,
@@ -42,6 +43,7 @@ export default function TournamentAdminLayout() {
const tournament = useTournament();
const outletContext = useOutletContext();
const user = useUser();
const isTournamentAdder = useHasRole("TOURNAMENT_ADDER");
const location = useLocation();
const showReopen = Boolean(
@@ -73,7 +75,8 @@ export default function TournamentAdminLayout() {
return (
<div className={clsx("stack lg", containerClassName("wide"))}>
{tournament.isAdmin(user) && !tournament.hasStarted ? (
{tournament.canEditEventInfo(user, { isTournamentAdder }) &&
!tournament.hasStarted ? (
<div className="stack horizontal items-end">
<LinkButton
to={tournamentEditPage(tournament.ctx.eventId)}

View File

@@ -626,3 +626,196 @@ describe("single elimination standings - third place match", () => {
).toBe(4);
});
});
const reportLowerIdWinner = (
storage: InMemoryDatabase,
manager: BracketsManager,
matchId: number,
) => {
const match = storage.select<any>("match", matchId);
invariant(match, `match ${matchId} not found`);
const opponent1Lower = match.opponent1.id < match.opponent2.id;
manager.update.match({
id: matchId,
opponent1: opponent1Lower ? { score: 2, result: "win" } : { score: 0 },
opponent2: opponent1Lower ? { score: 0 } : { score: 2, result: "win" },
});
};
const readyMatches = (
storage: InMemoryDatabase,
predicate: (match: any) => boolean,
) =>
storage
.select<any>("match")!
.filter(
(match) =>
predicate(match) &&
match.opponent1?.id != null &&
match.opponent2?.id != null &&
match.opponent1.result == null &&
match.opponent2.result == null,
);
describe("single elimination standings - projected ties", () => {
// Two semifinal losers tie for 3rd (no consolation final). Reports only one
// semifinal so the other is still in progress, mirroring the projected
// standings bug where the finished team is shown one placement too low.
const partialSingleEliminationTournament = () => {
const storage = new InMemoryDatabase();
const manager = new BracketsManager(storage);
manager.create({
name: "SE",
tournamentId: 1,
type: "single_elimination",
seeding: [1, 2, 3, 4],
settings: {},
});
const semifinals = storage
.select<any>("match")!
.filter((match) => match.opponent1?.id && match.opponent2?.id);
invariant(semifinals.length === 2, "Expected two semifinal matches");
const decided = semifinals[0];
const decidedLoserId = Math.max(decided.opponent1.id, decided.opponent2.id);
reportLowerIdWinner(storage, manager, decided.id);
const tournament = testTournament({
ctx: {
settings: {
bracketProgression: [
{
type: "single_elimination",
name: "SE",
requiresCheckIn: false,
settings: {},
sources: [],
},
],
},
},
data: manager.get.tournamentData(1),
});
return { tournament, decidedLoserId };
};
it("projects a finished semifinal loser as tied 3rd before the other semifinal finishes", () => {
const { tournament, decidedLoserId } = partialSingleEliminationTournament();
const standings = tournament.bracketByIdx(0)!.standings;
expect(standings.find((s) => s.team.id === decidedLoserId)?.placement).toBe(
3,
);
});
});
describe("double elimination standings - projected ties", () => {
// 8-team DE: losers round 2 produces the 5th/6th tie. Plays out the whole
// winners bracket and losers round 1, then reports only one of the two
// losers round 2 matches so its loser should already project to tied 5th
// while the sibling match is still unfinished.
const partialDoubleEliminationTournament = () => {
const storage = new InMemoryDatabase();
const manager = new BracketsManager(storage);
manager.create({
name: "DE",
tournamentId: 1,
type: "double_elimination",
seeding: [1, 2, 3, 4, 5, 6, 7, 8],
settings: { grandFinal: "double", seedOrdering: ["natural"] },
});
const groupId = (number: number) =>
storage.select<any>("group")!.find((g) => g.number === number)!.id;
const winnersGroupId = groupId(1);
const losersGroupId = groupId(2);
const losersRoundId = (number: number) =>
storage
.select<any>("round")!
.find((r) => r.group_id === losersGroupId && r.number === number)!.id;
// play out the entire winners bracket so all losers feed in
let winnersReady = readyMatches(
storage,
(m) => m.group_id === winnersGroupId,
);
while (winnersReady.length) {
for (const match of winnersReady) {
reportLowerIdWinner(storage, manager, match.id);
}
winnersReady = readyMatches(
storage,
(m) => m.group_id === winnersGroupId,
);
}
// losers round 1: both matches -> two teams eliminated, tied 7th/8th
for (const match of readyMatches(
storage,
(m) => m.round_id === losersRoundId(1),
)) {
reportLowerIdWinner(storage, manager, match.id);
}
// losers round 2: report only one of the two matches
const losersRound2 = readyMatches(
storage,
(m) => m.round_id === losersRoundId(2),
);
invariant(losersRound2.length === 2, "Expected two losers round 2 matches");
const decided = losersRound2[0];
const decidedLoserId = Math.max(decided.opponent1.id, decided.opponent2.id);
const stillPlayingTeamIds = [
losersRound2[1].opponent1.id,
losersRound2[1].opponent2.id,
];
reportLowerIdWinner(storage, manager, decided.id);
const tournament = testTournament({
ctx: {
settings: {
bracketProgression: [
{
type: "double_elimination",
name: "DE",
requiresCheckIn: false,
settings: {},
sources: [],
},
],
},
},
data: manager.get.tournamentData(1),
});
return { tournament, decidedLoserId, stillPlayingTeamIds };
};
it("projects a finished losers-round-2 loser as tied 5th before the sibling match finishes", () => {
const { tournament, decidedLoserId } = partialDoubleEliminationTournament();
const standings = tournament.bracketByIdx(0)!.standings;
expect(standings.find((s) => s.team.id === decidedLoserId)?.placement).toBe(
5,
);
});
it("does not yet place teams still playing their losers round 2 match", () => {
const { tournament, stillPlayingTeamIds } =
partialDoubleEliminationTournament();
const standings = tournament.bracketByIdx(0)!.standings;
for (const teamId of stillPlayingTeamIds) {
expect(standings.find((s) => s.team.id === teamId)).toBe(undefined);
}
});
});

View File

@@ -5,6 +5,7 @@ import type { Round } from "~/modules/brackets-model";
import invariant from "~/utils/invariant";
import type { BracketMapCounts } from "../toMapList";
import { Bracket, type Standing } from "./Bracket";
import { cumulativeEliminationsByRound } from "./utils";
export class DoubleEliminationBracket extends Bracket {
get type(): Tables["TournamentStage"]["type"] {
@@ -73,13 +74,13 @@ export class DoubleEliminationBracket extends Bracket {
const losersGroupId = this.data.group.find((g) => g.number === 2)?.id;
const losersMatches = this.data.match
.filter((match) => match.group_id === losersGroupId)
.sort((a, b) => a.round_id - b.round_id);
const teams: { id: number; lostAt: number }[] = [];
for (const match of this.data.match
.slice()
.sort((a, b) => a.round_id - b.round_id)) {
if (match.group_id !== losersGroupId) continue;
for (const match of losersMatches) {
if (
match.opponent1?.result !== "win" &&
match.opponent2?.result !== "win"
@@ -97,8 +98,8 @@ export class DoubleEliminationBracket extends Bracket {
teams.push({ id: loser.id, lostAt: match.round_id });
}
const teamCountWhoDidntLoseInLosersYet =
this.participantTournamentTeamIds.length - teams.length;
const eliminationsThroughLosersRound =
cumulativeEliminationsByRound(losersMatches);
const result: Standing[] = [];
for (const roundId of R.unique(teams.map((team) => team.lostAt))) {
@@ -107,16 +108,18 @@ export class DoubleEliminationBracket extends Bracket {
teamsLostThisRound.push(teams.shift()!);
}
const placement =
this.participantTournamentTeamIds.length -
eliminationsThroughLosersRound.get(roundId)! +
1;
for (const { id: teamId } of teamsLostThisRound) {
const team = this.tournament.teamById(teamId);
invariant(team, `Team not found for id: ${teamId}`);
const teamsPlacedAbove =
teamCountWhoDidntLoseInLosersYet + teams.length;
result.push({
team,
placement: teamsPlacedAbove + 1,
placement,
});
}
}

View File

@@ -5,6 +5,7 @@ import type { Round } from "~/modules/brackets-model";
import invariant from "~/utils/invariant";
import type { BracketMapCounts } from "../toMapList";
import { Bracket, type Standing } from "./Bracket";
import { cumulativeEliminationsByRound } from "./utils";
export class SingleEliminationBracket extends Bracket {
get type(): Tables["TournamentStage"]["type"] {
@@ -96,6 +97,8 @@ export class SingleEliminationBracket extends Bracket {
const teamCountWhoDidntLoseYet =
this.participantTournamentTeamIds.length - teams.length;
const eliminationsThroughRound = cumulativeEliminationsByRound(matches);
const result: Standing[] = [];
for (const roundId of R.unique(teams.map((team) => team.lostAt))) {
const teamsLostThisRound: { id: number }[] = [];
@@ -103,15 +106,18 @@ export class SingleEliminationBracket extends Bracket {
teamsLostThisRound.push(teams.shift()!);
}
const placement =
this.participantTournamentTeamIds.length -
eliminationsThroughRound.get(roundId)! +
1;
for (const { id: teamId } of teamsLostThisRound) {
const team = this.tournament.teamById(teamId);
invariant(team, `Team not found for id: ${teamId}`);
const teamsPlacedAbove = teamCountWhoDidntLoseYet + teams.length;
result.push({
team,
placement: teamsPlacedAbove + 1,
placement,
});
}
}

View File

@@ -0,0 +1,31 @@
import * as R from "remeda";
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
/**
* Maps each round_id to the cumulative number of teams eliminated by the end of
* that round, counting one elimination per non-bye match. This is a structural
* property of the bracket that does not depend on which matches have already
* been reported, so teams tied at the same placement resolve to the same
* placement even while some of their round's matches are still in progress.
*/
export function cumulativeEliminationsByRound(
matches: TournamentManagerDataSet["match"],
): Map<number, number> {
const result = new Map<number, number>();
const roundIds = R.unique(matches.map((match) => match.round_id)).sort(
(a, b) => a - b,
);
let cumulativeEliminations = 0;
for (const roundId of roundIds) {
const eliminationsThisRound = matches.filter(
(match) =>
match.round_id === roundId && match.opponent1 && match.opponent2,
).length;
cumulativeEliminations += eliminationsThisRound;
result.set(roundId, cumulativeEliminations);
}
return result;
}

View File

@@ -1382,6 +1382,31 @@ export class Tournament {
return this.ctx.author.id === user.id;
}
/**
* Checks if the given user can edit the tournament's calendar event info.
*
* Mirrors the authorization enforced when the edit is submitted: organization
* admins can only edit when the organization is established, unless they have
* the TOURNAMENT_ADDER role.
*/
canEditEventInfo(
user: OptionalIdObject,
{ isTournamentAdder }: { isTournamentAdder: boolean },
) {
if (!user) return false;
if (isAdmin(user)) return true;
if (this.ctx.author.id === user.id) return true;
const isOrganizationAdmin = this.ctx.organization?.members.some(
(member) => member.userId === user.id && member.role === "ADMIN",
);
return Boolean(
isOrganizationAdmin &&
(isTournamentAdder || this.ctx.organization?.isEstablished),
);
}
/** Checks if the given user is an organizer of the tournament. */
isOrganizer(user: OptionalIdObject) {
if (!user) return false;

View File

@@ -6923,6 +6923,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
id: 3,
name: "Inkling Performance Labs",
slug: "inkling-performance-labs",
isEstablished: 1,
logoUrl: "fZrToLQrkqV3UZkdgwp0Q-1722263644749.webp",
series: [],
members: [

View File

@@ -2026,6 +2026,7 @@ export const SWIM_OR_SINK_167 = (
id: 3,
name: "Inkling Performance Labs",
slug: "inkling-performance-labs",
isEstablished: 1,
logoUrl: "fZrToLQrkqV3UZkdgwp0Q-1722263644749.webp",
series: [],
members: [

View File

@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { dbInsertUsers, dbReset } from "~/utils/Test";
import * as TournamentOrganizationRepository from "./TournamentOrganizationRepository.server";
import { seedOrgEventWithParticipants } from "./test-utils";
const createOrganization = async ({
ownerId,
@@ -90,3 +91,99 @@ describe("findByUserId", () => {
expect(result).toHaveLength(0);
});
});
describe("countActiveParticipants", () => {
const WINDOW_START = 1_700_000_000;
const WINDOW_END = WINDOW_START + 60 * 60 * 24 * 31;
const IN_WINDOW = WINDOW_START + 60 * 60 * 24;
const countForOrg = (organizationId: number) =>
TournamentOrganizationRepository.countActiveParticipants({
organizationId,
startTime: WINDOW_START,
endTime: WINDOW_END,
});
beforeEach(async () => {
await dbInsertUsers(5);
});
afterEach(() => {
dbReset();
});
test("counts distinct participants across the organization's events in the window", async () => {
const org = await createOrganization({ ownerId: 1, name: "Org" });
await seedOrgEventWithParticipants({
organizationId: org.id,
startTime: IN_WINDOW,
participantUserIds: [1, 2],
});
await seedOrgEventWithParticipants({
organizationId: org.id,
startTime: IN_WINDOW,
participantUserIds: [2, 3],
});
// users 1, 2, 3 — user 2 played in both events but is counted once
expect(await countForOrg(org.id)).toBe(3);
});
test("excludes teams that did not check in", async () => {
const org = await createOrganization({ ownerId: 1, name: "Org" });
await seedOrgEventWithParticipants({
organizationId: org.id,
startTime: IN_WINDOW,
participantUserIds: [1, 2],
checkIn: "none",
});
expect(await countForOrg(org.id)).toBe(0);
});
test("excludes teams that checked out", async () => {
const org = await createOrganization({ ownerId: 1, name: "Org" });
await seedOrgEventWithParticipants({
organizationId: org.id,
startTime: IN_WINDOW,
participantUserIds: [1, 2],
checkIn: "out",
});
expect(await countForOrg(org.id)).toBe(0);
});
test("excludes events outside the time window", async () => {
const org = await createOrganization({ ownerId: 1, name: "Org" });
await seedOrgEventWithParticipants({
organizationId: org.id,
startTime: WINDOW_END + 60 * 60 * 24,
participantUserIds: [1, 2],
});
expect(await countForOrg(org.id)).toBe(0);
});
test("excludes other organizations' events", async () => {
const org = await createOrganization({ ownerId: 1, name: "Org" });
const otherOrg = await createOrganization({ ownerId: 2, name: "Other" });
await seedOrgEventWithParticipants({
organizationId: otherOrg.id,
startTime: IN_WINDOW,
participantUserIds: [1, 2, 3],
});
expect(await countForOrg(org.id)).toBe(0);
});
test("returns 0 when the organization has no events", async () => {
const org = await createOrganization({ ownerId: 1, name: "Org" });
expect(await countForOrg(org.id)).toBe(0);
});
});

View File

@@ -200,7 +200,13 @@ export function searchByName({
"avatarUrl",
),
])
.where("TournamentOrganization.name", "like", `%${query}%`)
.where(({ eb, ref }) =>
eb(
sql`unaccent(${ref("TournamentOrganization.name")})`,
"like",
sql`unaccent(${`%${query}%`})`,
),
)
.orderBy("TournamentOrganization.name", "asc")
.limit(limit)
.execute();
@@ -417,6 +423,49 @@ export async function findAllEventsBySeries({
return events.map(mapEvent);
}
/**
* Counts the distinct players who participated in at least one match of a
* tournament hosted by the organization, whose event started within the
* `[startTime, endTime]` range. Only players belonging to teams that checked
* in (and did not check out) are included.
*
* `startTime` and `endTime` are database timestamps (seconds).
*/
export async function countActiveParticipants({
organizationId,
startTime,
endTime,
}: {
organizationId: number;
startTime: number;
endTime: number;
}) {
const result = await db
.selectFrom("CalendarEvent as ce")
.innerJoin("CalendarEventDate as ced", "ced.eventId", "ce.id")
.innerJoin("Tournament as t", "t.id", "ce.tournamentId")
.innerJoin("TournamentTeam as tt", "tt.tournamentId", "t.id")
.innerJoin(
"TournamentTeamCheckIn as ttci",
"ttci.tournamentTeamId",
"tt.id",
)
.innerJoin(
"TournamentMatchGameResultParticipant as tmgrp",
"tmgrp.tournamentTeamId",
"tt.id",
)
.select(({ fn }) => fn.count<number>("tmgrp.userId").distinct().as("count"))
.where("ce.organizationId", "=", organizationId)
.where("ced.startTime", ">=", startTime)
.where("ced.startTime", "<", endTime)
.where("ttci.checkedInAt", "is not", null)
.where("ttci.isCheckOut", "=", 0)
.executeTakeFirst();
return result?.count ?? 0;
}
interface UpdateArgs
extends Pick<
Tables["TournamentOrganization"],

View File

@@ -0,0 +1,54 @@
import { addMonths, format, startOfMonth, subMonths } from "date-fns";
import type { LoaderFunctionArgs } from "react-router";
import * as R from "remeda";
import { requirePermission } from "~/modules/permissions/guards.server";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import * as TournamentOrganizationRepository from "../TournamentOrganizationRepository.server";
import {
ESTABLISHED_ORG,
MONTH_PARAM_FORMAT,
} from "../tournament-organization-constants";
import { organizationFromParams } from "../tournament-organization-utils.server";
export async function loader({ params }: LoaderFunctionArgs) {
const organization = await organizationFromParams(params);
requirePermission(organization, "EDIT");
const fullMonths = recentFullMonths(ESTABLISHED_ORG.MONTHS_CONSIDERED);
const monthlyCounts = await Promise.all(
fullMonths.map((month) =>
TournamentOrganizationRepository.countActiveParticipants({
organizationId: organization.id,
startTime: dateToDatabaseTimestamp(month),
endTime: dateToDatabaseTimestamp(addMonths(month, 1)),
}),
),
);
const monthlyStats = fullMonths.map((month, index) => ({
month: format(month, MONTH_PARAM_FORMAT),
count: monthlyCounts[index],
}));
const averageMonthlyParticipants = R.mean(monthlyCounts) ?? 0;
return {
monthlyStats,
averageMonthlyParticipants,
};
}
/** The `count` most recent full months
* (excluding the current month), most recent first. */
function recentFullMonths(count: number) {
const months: Date[] = [];
const thisMonthStart = startOfMonth(new Date());
for (let index = 0; index < count; index++) {
months.push(subMonths(thisMonthStart, index + 1));
}
return months;
}

View File

@@ -0,0 +1,74 @@
.statNumber {
font-size: 2.5rem;
font-weight: var(--weight-extra);
line-height: 1;
}
.progress {
display: flex;
flex-direction: column;
gap: var(--s-2);
}
.progressHeader {
display: flex;
align-items: baseline;
gap: var(--s-2);
}
.progressTrack {
width: 100%;
height: 0.75rem;
border-radius: var(--radius-full);
background-color: var(--color-bg-higher);
overflow: hidden;
}
.progressBar {
height: 100%;
border-radius: var(--radius-full);
background-color: var(--color-accent);
transition: width 0.3s ease;
}
.progressBarMet {
background-color: var(--color-success);
}
.breakdown {
display: flex;
flex-direction: column;
gap: var(--s-2);
margin-top: var(--s-2);
}
.breakdownRow {
display: grid;
grid-template-columns: 6rem 1fr 2.5rem;
align-items: center;
gap: var(--s-3);
}
.breakdownLabel {
font-size: var(--font-xs);
color: var(--color-text-high);
}
.breakdownTrack {
height: 0.5rem;
border-radius: var(--radius-full);
background-color: var(--color-bg-higher);
overflow: hidden;
}
.breakdownBar {
height: 100%;
border-radius: var(--radius-full);
background-color: var(--color-accent);
}
.breakdownCount {
font-size: var(--font-sm);
font-weight: var(--weight-bold);
text-align: right;
}

View File

@@ -0,0 +1,92 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import type { SerializeFrom } from "~/utils/remix";
import { dbInsertUsers, dbReset, wrappedLoader } from "~/utils/Test";
import { loader } from "../loaders/org.$slug.stats.server";
import * as TournamentOrganizationRepository from "../TournamentOrganizationRepository.server";
import { seedOrgEventWithParticipants } from "../test-utils";
import { ESTABLISHED_ORG } from "../tournament-organization-constants";
const statsLoader = wrappedLoader<SerializeFrom<typeof loader>>({ loader });
const createOrg = () =>
TournamentOrganizationRepository.create({ ownerId: 1, name: "Org" });
describe("org stats loader", () => {
beforeEach(async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 0, 15));
await dbInsertUsers(5);
});
afterEach(() => {
vi.useRealTimers();
dbReset();
});
test("throws when the user is not an org admin", async () => {
const org = await createOrg();
await expect(
statsLoader({ user: "regular", params: { slug: org.slug } }),
).rejects.toThrow();
});
test("allows an org admin", async () => {
const org = await createOrg();
const data = await statsLoader({
user: "admin",
params: { slug: org.slug },
});
expect(data.monthlyStats).toHaveLength(ESTABLISHED_ORG.MONTHS_CONSIDERED);
});
test("returns finished months most recent first, excluding the current month", async () => {
const org = await createOrg();
const data = await statsLoader({
user: "admin",
params: { slug: org.slug },
});
// system time is Jan 2026 -> most recent finished month is Dec 2025,
// and the current (ongoing) month is not included
expect(data.monthlyStats.map((m) => m.month)).toEqual([
"2025-12",
"2025-11",
"2025-10",
"2025-09",
"2025-08",
"2025-07",
]);
});
test("counts participants per month and averages over the considered months", async () => {
const org = await createOrg();
// 3 participants in December 2025 (a finished month)
await seedOrgEventWithParticipants({
organizationId: org.id,
startTime: dateToDatabaseTimestamp(new Date(2025, 11, 10)),
participantUserIds: [1, 2, 3],
});
// an event in the current month is ignored
await seedOrgEventWithParticipants({
organizationId: org.id,
startTime: dateToDatabaseTimestamp(new Date(2026, 0, 5)),
participantUserIds: [1, 2, 3, 4, 5],
});
const data = await statsLoader({
user: "admin",
params: { slug: org.slug },
});
expect(data.monthlyStats[0]).toEqual({ month: "2025-12", count: 3 });
expect(data.averageMonthlyParticipants).toBeCloseTo(
3 / ESTABLISHED_ORG.MONTHS_CONSIDERED,
);
});
});

View File

@@ -0,0 +1,123 @@
import clsx from "clsx";
import { parse } from "date-fns";
import { ProgressBar } from "react-aria-components";
import { useTranslation } from "react-i18next";
import { useLoaderData } from "react-router";
import { Main } from "~/components/Main";
import { Section } from "~/components/Section";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { loader } from "../loaders/org.$slug.stats.server";
import {
ESTABLISHED_ORG,
MONTH_PARAM_FORMAT,
} from "../tournament-organization-constants";
import styles from "./org.$slug.stats.module.css";
export { loader };
export const handle: SendouRouteHandle = {
i18n: ["org"],
};
export default function OrganizationStatsPage() {
return (
<Main className="stack lg">
<EstablishedStatus />
</Main>
);
}
function EstablishedStatus() {
const { t } = useTranslation(["org"]);
const { formatter } = useDateTimeFormat({ month: "short", year: "numeric" });
const { monthlyStats, averageMonthlyParticipants } =
useLoaderData<typeof loader>();
const meetsThreshold =
averageMonthlyParticipants >= ESTABLISHED_ORG.GAIN_THRESHOLD;
const maxCount = Math.max(
ESTABLISHED_ORG.GAIN_THRESHOLD,
...monthlyStats.map((monthStat) => monthStat.count),
);
return (
<Section title={t("org:stats.established.title")}>
<div className="stack md">
<ProgressBar
value={averageMonthlyParticipants}
minValue={0}
maxValue={ESTABLISHED_ORG.GAIN_THRESHOLD}
aria-label={t("org:stats.established.title")}
className={styles.progress}
>
{({ percentage }) => (
<>
<div className={styles.progressHeader}>
<span className={styles.statNumber}>
{averageMonthlyParticipants.toFixed(1)}
</span>
<span className="text-lighter">
/ {ESTABLISHED_ORG.GAIN_THRESHOLD}
</span>
</div>
<div className={styles.progressTrack}>
<div
className={clsx(styles.progressBar, {
[styles.progressBarMet]: meetsThreshold,
})}
style={{ width: `${percentage}%` }}
/>
</div>
</>
)}
</ProgressBar>
<div className="text-xs text-lighter">
{t("org:stats.established.help", {
months: ESTABLISHED_ORG.MONTHS_CONSIDERED,
gain: ESTABLISHED_ORG.GAIN_THRESHOLD,
lose: ESTABLISHED_ORG.LOSE_THRESHOLD,
})}
</div>
<div className={styles.breakdown}>
{monthlyStats.map((monthStat) => (
<ProgressBar
key={monthStat.month}
value={monthStat.count}
minValue={0}
maxValue={maxCount}
aria-label={formatMonth(monthStat.month, formatter)}
className={styles.breakdownRow}
>
{({ percentage }) => (
<>
<span className={styles.breakdownLabel}>
{formatMonth(monthStat.month, formatter)}
</span>
<div className={styles.breakdownTrack}>
<div
className={styles.breakdownBar}
style={{ width: `${percentage}%` }}
/>
</div>
<span className={styles.breakdownCount}>
{monthStat.count}
</span>
</>
)}
</ProgressBar>
))}
</div>
</div>
</Section>
);
}
function formatMonth(
monthString: string,
formatter: { format: (date: Date | number) => string | null },
) {
const date = parse(monthString, MONTH_PARAM_FORMAT, new Date());
return formatter.format(date) ?? undefined;
}

View File

@@ -1,4 +1,11 @@
import { Link as LinkIcon, Lock, LogOut, SquarePen, Users } from "lucide-react";
import {
ChartNoAxesColumn,
Link as LinkIcon,
Lock,
LogOut,
SquarePen,
Users,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import type { MetaFunction } from "react-router";
import { Link, useLoaderData, useSearchParams } from "react-router";
@@ -33,6 +40,7 @@ import {
navIconUrl,
tournamentOrganizationEditPage,
tournamentOrganizationPage,
tournamentOrganizationStatsPage,
tournamentPage,
userPage,
} from "~/utils/urls";
@@ -47,15 +55,15 @@ import { updateIsEstablishedSchema } from "../tournament-organization-schemas";
export { action, loader };
export const meta: MetaFunction<typeof loader> = (args) => {
if (!args.data) return [];
if (!args.loaderData) return [];
return metaTags({
title: args.data.organization.name,
title: args.loaderData.organization.name,
location: args.location,
description: args.data.organization.description ?? undefined,
image: args.data.organization.avatarUrl
description: args.loaderData.organization.description ?? undefined,
image: args.loaderData.organization.avatarUrl
? {
url: args.data.organization.avatarUrl,
url: args.loaderData.organization.avatarUrl,
dimensions: { width: 124, height: 124 },
}
: undefined,
@@ -65,7 +73,7 @@ export const meta: MetaFunction<typeof loader> = (args) => {
export const handle: SendouRouteHandle = {
i18n: ["badges", "org"],
breadcrumb: ({ match }) => {
const data = match.data as SerializeFrom<typeof loader> | undefined;
const data = match.loaderData as SerializeFrom<typeof loader> | undefined;
if (!data) return [];
@@ -118,8 +126,9 @@ function LogoHeader() {
const currentMember = user
? data.organization.members.find((m) => m.id === user.id)
: undefined;
const isOrgAdmin = currentMember?.role === "ADMIN";
const isSoleAdmin =
currentMember?.role === "ADMIN" &&
isOrgAdmin &&
data.organization.members.filter((m) => m.role === "ADMIN").length === 1;
return (
@@ -140,6 +149,17 @@ function LogoHeader() {
{t("common:actions.edit")}
</LinkButton>
) : null}
{isOrgAdmin ? (
<LinkButton
to={tournamentOrganizationStatsPage(data.organization.slug)}
icon={<ChartNoAxesColumn />}
size="small"
variant="outlined"
testId="org-stats-button"
>
{t("org:stats.title")}
</LinkButton>
) : null}
{currentMember ? (
isSoleAdmin ? (
<SendouDialog

View File

@@ -0,0 +1,142 @@
import { db } from "~/db/sql";
import invariant from "../../utils/invariant";
import { dbInsertTournament } from "../tournament/tournament-test-utils";
/**
* Seeds a played tournament hosted by `organizationId`, starting at `startTime`
* (a database timestamp in seconds), with one team whose roster is
* `participantUserIds`. The team is checked in by default.
*
* Creates the full chain the active-participants query relies on:
* CalendarEvent → CalendarEventDate → Tournament → TournamentTeam
* (+ TournamentTeamCheckIn) → stage/group/round/match → game result +
* participants.
*
* Only meant for use in tests.
*/
export async function seedOrgEventWithParticipants({
organizationId,
startTime,
participantUserIds,
checkIn = "in",
}: {
organizationId: number;
startTime: number;
participantUserIds: number[];
checkIn?: "in" | "out" | "none";
}) {
const { tournamentId } = await dbInsertTournament({
organizationId,
startTime,
});
invariant(tournamentId, "Expected tournamentId to be defined");
const event = await db
.insertInto("CalendarEvent")
.values({
authorId: participantUserIds[0],
name: `Event ${tournamentId}`,
bracketUrl: "https://example.com/bracket",
organizationId,
tournamentId,
})
.returning("id")
.executeTakeFirstOrThrow();
await db
.insertInto("CalendarEventDate")
.values({ eventId: event.id, startTime })
.execute();
const team = await db
.insertInto("TournamentTeam")
.values({
tournamentId,
name: `Team ${tournamentId}`,
inviteCode: `inv-${tournamentId}`,
})
.returning("id")
.executeTakeFirstOrThrow();
if (checkIn !== "none") {
await db
.insertInto("TournamentTeamCheckIn")
.values({
tournamentTeamId: team.id,
checkedInAt: startTime,
isCheckOut: checkIn === "out" ? 1 : 0,
})
.execute();
}
const stage = await db
.insertInto("TournamentStage")
.values({
tournamentId,
name: "Stage",
number: 1,
type: "single_elimination",
settings: "{}",
})
.returning("id")
.executeTakeFirstOrThrow();
const group = await db
.insertInto("TournamentGroup")
.values({ stageId: stage.id, number: 1 })
.returning("id")
.executeTakeFirstOrThrow();
const round = await db
.insertInto("TournamentRound")
.values({
stageId: stage.id,
groupId: group.id,
number: 1,
maps: JSON.stringify({ count: 3, type: "BEST_OF" }),
})
.returning("id")
.executeTakeFirstOrThrow();
const match = await db
.insertInto("TournamentMatch")
.values({
stageId: stage.id,
groupId: group.id,
roundId: round.id,
number: 1,
status: 4,
opponentOne: JSON.stringify({ id: team.id, score: 1 }),
opponentTwo: JSON.stringify({ id: team.id, score: 0 }),
})
.returning("id")
.executeTakeFirstOrThrow();
const gameResult = await db
.insertInto("TournamentMatchGameResult")
.values({
matchId: match.id,
mode: "SZ",
number: 1,
reporterId: participantUserIds[0],
source: "TO",
stageId: 1,
winnerTeamId: team.id,
})
.returning("id")
.executeTakeFirstOrThrow();
await db
.insertInto("TournamentMatchGameResultParticipant")
.values(
participantUserIds.map((userId) => ({
matchGameResultId: gameResult.id,
userId,
tournamentTeamId: team.id,
})),
)
.execute();
return { tournamentId, teamId: team.id };
}

View File

@@ -1,9 +1,17 @@
export const TOURNAMENT_SERIES_EVENTS_PER_PAGE = 20;
export const TOURNAMENT_SERIES_LEADERBOARD_SIZE = 50;
export const MONTH_PARAM_FORMAT = "yyyy-MM";
export const ESTABLISHED_ORG = {
MONTHS_CONSIDERED: 6,
GAIN_THRESHOLD: 150,
LOSE_THRESHOLD: 100,
};
export const TOURNAMENT_ORGANIZATION = {
DESCRIPTION_MAX_LENGTH: 1_000,
BAN_REASON_MAX_LENGTH: 200,
MAX_BANNED_USERS: 100,
MAX_MEMBER_OF_COUNT: 3,
MAX_MEMBER_OF_COUNT: 5,
};

View File

@@ -85,6 +85,7 @@ export async function findById(id: number) {
"TournamentOrganization.id",
"TournamentOrganization.name",
"TournamentOrganization.slug",
"TournamentOrganization.isEstablished",
concatUserSubmittedImagePrefix(
innerEb.ref("UserSubmittedImage.url"),
).as("logoUrl"),

View File

@@ -27,15 +27,14 @@ import styles from "./to.$id.info.module.css";
export { action, loader };
export const meta: MetaFunction<typeof loader> = (args) => {
const tournamentData = JSON.parse(args.matches[1].data as any)?.tournament as
| TournamentData
| undefined;
const tournamentData = JSON.parse(args.matches[1].loaderData as any)
?.tournament as TournamentData | undefined;
if (!tournamentData) return [];
return metaTags({
title: tournamentData.ctx.name,
description: args.data?.description
? removeMarkdown(args.data.description)
description: args.loaderData?.description
? removeMarkdown(args.loaderData.description)
: undefined,
image: {
url: tournamentData.ctx.logoUrl,

View File

@@ -29,12 +29,12 @@ import { useTournament } from "./to.$id";
export { loader };
export const meta: MetaFunction<typeof loader> = (args) => {
const tournamentData = JSON.parse(args.matches[1].data as any)
const tournamentData = JSON.parse(args.matches[1].loaderData as any)
?.tournament as TournamentData;
if (!args.data || !tournamentData) return [];
if (!args.loaderData || !tournamentData) return [];
const team = tournamentData.ctx.teams.find(
(t) => t.id === args.data!.tournamentTeamId,
(t) => t.id === args.loaderData!.tournamentTeamId,
)!;
const teamLogoUrl = team.team?.logoUrl ?? team.pickupAvatarUrl;

View File

@@ -31,7 +31,7 @@ export const shouldRevalidate: ShouldRevalidateFunction = (args) => {
};
export const meta: MetaFunction = (args) => {
const rawData = args.data as string | undefined;
const rawData = args.loaderData as string | undefined;
if (!rawData) return [];
@@ -51,7 +51,7 @@ export const meta: MetaFunction = (args) => {
export const handle: SendouRouteHandle = {
i18n: ["tournament", "calendar"],
breadcrumb: ({ match }) => {
const rawData = match.data as string | undefined;
const rawData = match.loaderData as string | undefined;
if (!rawData) return [];

View File

@@ -9,9 +9,19 @@ import * as TournamentTeamRepository from "./TournamentTeamRepository.server";
/**
* Creates a mock tournament with one single elimination bracket.
*
* @returns The created event and tournament ids.
*/
export async function dbInsertTournament() {
await CalendarRepository.create({
export async function dbInsertTournament({
organizationId = null,
startTime = null,
}: {
/** Organization hosting the tournament. Defaults to no organization. */
organizationId?: number | null;
/** Event start time as a database timestamp (seconds). Defaults to now. */
startTime?: number | null;
} = {}) {
return CalendarRepository.create({
isFullTournament: true,
authorId: 1,
badges: [],
@@ -19,9 +29,9 @@ export async function dbInsertTournament() {
description: null,
discordInviteCode: "test-discord",
name: "Test Tournament",
organizationId: null,
organizationId,
rules: null,
startTimes: [databaseTimestampNow()],
startTimes: [startTime ?? databaseTimestampNow()],
tags: null,
bracketProgression: [
{

View File

@@ -782,6 +782,7 @@ describe("tournamentNameParts", () => {
id: 1,
name: "Sendou's Tournaments",
slug: "sendou",
isEstablished: 1,
logoUrl: null,
members: [],
series: [{ name: "In The Zone" }],

View File

@@ -171,7 +171,7 @@ export function useUserCardData(
if (typeof userId !== "number") return undefined;
for (const match of matches) {
const data = match.data as
const data = match.loaderData as
| { userCards?: Map<number, UserCardData> }
| undefined;
const card = data?.userCards?.get(userId);

View File

@@ -20,7 +20,7 @@ export { action, loader };
export default function UserAdminPage() {
const [, parentRoute] = useMatches();
invariant(parentRoute);
const layoutData = parentRoute.data as UserPageLoaderData;
const layoutData = parentRoute.loaderData as UserPageLoaderData;
return (
<div className="stack xl">

View File

@@ -35,7 +35,7 @@ export default function UserArtPage() {
});
const [, parentRoute] = useMatches();
invariant(parentRoute);
const layoutData = parentRoute.data as UserPageLoaderData;
const layoutData = parentRoute.loaderData as UserPageLoaderData;
const hasBothArtMadeByAndMadeOf =
data.arts.some((a) => a.author) && data.arts.some((a) => !a.author);

View File

@@ -20,7 +20,7 @@ export default function NewBuildPage() {
const { defaultValues, gearIdToAbilities } = useLoaderData<typeof loader>();
const [, parentRoute] = useMatches();
invariant(parentRoute);
const layoutData = parentRoute.data as UserPageLoaderData;
const layoutData = parentRoute.loaderData as UserPageLoaderData;
const { t } = useTranslation(["builds"]);
if (layoutData.user.buildsCount >= BUILD.MAX_COUNT) {

View File

@@ -39,7 +39,7 @@ type BuildFilter = "ALL" | "PUBLIC" | "PRIVATE" | MainWeaponId;
export default function UserBuildsPage() {
const { t } = useTranslation(["builds", "user"]);
const user = useUser();
const layoutData = useMatches().at(-2)!.data as UserPageLoaderData;
const layoutData = useMatches().at(-2)!.loaderData as UserPageLoaderData;
const data = useLoaderData<typeof loader>();
const [weaponFilter, setWeaponFilter] = useSearchParamState<BuildFilter>({
defaultValue: "ALL",
@@ -122,7 +122,7 @@ function BuildsFilters({
const { t } = useTranslation(["weapons", "builds"]);
const data = useLoaderData<typeof loader>();
const user = useUser();
const layoutData = useMatches().at(-2)!.data as UserPageLoaderData;
const layoutData = useMatches().at(-2)!.loaderData as UserPageLoaderData;
if (data.builds.length === 0) return null;

View File

@@ -27,7 +27,7 @@ export default function UserEditPage() {
const { t } = useTranslation(["common", "user"]);
const [, parentRoute] = useMatches();
invariant(parentRoute);
const layoutData = parentRoute.data as UserPageLoaderData;
const layoutData = parentRoute.loaderData as UserPageLoaderData;
const data = useLoaderData<typeof loader>();
const isSupporter = useHasRole("SUPPORTER");
const isArtist = useHasRole("ARTIST");

View File

@@ -73,7 +73,7 @@ function NewUserInfoPage() {
const user = useUser();
const [, parentRoute] = useMatches();
invariant(parentRoute);
const layoutData = parentRoute.data as UserPageLoaderData;
const layoutData = parentRoute.loaderData as UserPageLoaderData;
const { navItems } = useOutletContext<{ navItems: UserPageNavItem[] }>();
if (data.type !== "new") {
@@ -171,7 +171,7 @@ export function OldUserInfoPage() {
const data = useLoaderData<typeof loader>();
const [, parentRoute] = useMatches();
invariant(parentRoute);
const layoutData = parentRoute.data as UserPageLoaderData;
const layoutData = parentRoute.loaderData as UserPageLoaderData;
if (data.type !== "old") {
throw new Error("Expected old user data");

View File

@@ -25,7 +25,7 @@ export default function UserResultsPage() {
const [, parentRoute] = useMatches();
invariant(parentRoute);
const layoutData = parentRoute.data as UserPageLoaderData;
const layoutData = parentRoute.loaderData as UserPageLoaderData;
const [searchParams, setSearchParams] = useSearchParams();
const showAll = searchParams.get("all") === "true";

View File

@@ -78,7 +78,7 @@ export default function UserSeasonsPage() {
const data = useLoaderData<typeof loader>();
const [, parentRoute] = useMatches();
invariant(parentRoute);
const layoutData = parentRoute.data as UserPageLoaderData;
const layoutData = parentRoute.loaderData as UserPageLoaderData;
if (!data) {
return (
@@ -325,7 +325,7 @@ function Rank({
const { t } = useTranslation(["user"]);
const [, parentRoute] = useMatches();
invariant(parentRoute);
const layoutData = parentRoute.data as UserPageLoaderData;
const layoutData = parentRoute.loaderData as UserPageLoaderData;
const maxOrdinal = Math.max(...skills.map((s) => s.ordinal));
@@ -469,7 +469,7 @@ function Stages({
stages: NonNullable<UserSeasonsPageLoaderData["info"]["stages"]>;
}) {
const { t } = useTranslation(["user", "game-misc"]);
const layoutData = useMatches().at(-2)!.data as UserPageLoaderData;
const layoutData = useMatches().at(-2)!.loaderData as UserPageLoaderData;
return (
<div className="stack horizontal justify-center md flex-wrap">
@@ -804,7 +804,7 @@ function Results({
function GroupMatchResult({ match }: { match: SeasonGroupMatch }) {
const [, parentRoute] = useMatches();
invariant(parentRoute);
const layoutData = parentRoute.data as UserPageLoaderData;
const layoutData = parentRoute.loaderData as UserPageLoaderData;
const userId = layoutData.user.id;
// score when match has not yet been played or was canceled

View File

@@ -30,11 +30,11 @@ export { loader };
import "~/features/user-page/user-page.module.css";
export const meta: MetaFunction<typeof loader> = (args) => {
if (!args.data) return [];
if (!args.loaderData) return [];
return metaTags({
title: args.data.user.username,
description: `${args.data.user.username}'s profile on sendou.ink including builds, tournament results, art and more.`,
title: args.loaderData.user.username,
description: `${args.loaderData.user.username}'s profile on sendou.ink including builds, tournament results, art and more.`,
location: args.location,
});
};
@@ -42,7 +42,7 @@ export const meta: MetaFunction<typeof loader> = (args) => {
export const handle: SendouRouteHandle = {
i18n: ["user", "badges", "game-badges"],
breadcrumb: ({ match }) => {
const data = match.data as UserPageLoaderData | undefined;
const data = match.loaderData as UserPageLoaderData | undefined;
if (!data) return [];
@@ -86,7 +86,9 @@ export default function UserPageLayout() {
const allResultsCount =
data.user.calendarEventResultsCount + data.user.tournamentResultsCount;
const isNewUserPage = matches.some((m) => (m.data as any)?.type === "new");
const isNewUserPage = matches.some(
(m) => (m.loaderData as any)?.type === "new",
);
const navItems: UserPageNavItem[] = [
{

View File

@@ -19,7 +19,7 @@ export default function UserVodsPage() {
const [, parentRoute] = useMatches();
invariant(parentRoute);
const data = useLoaderData<typeof loader>();
const layoutData = parentRoute.data as UserPageLoaderData;
const layoutData = parentRoute.loaderData as UserPageLoaderData;
const [, setSearchParams] = useSearchParams();
const setPage = (page: number) => {

View File

@@ -42,7 +42,7 @@ export { action, loader };
export const handle: SendouRouteHandle = {
i18n: ["vods"],
breadcrumb: ({ match }) => {
const data = match.data as SerializeFrom<typeof loader> | undefined;
const data = match.loaderData as SerializeFrom<typeof loader> | undefined;
if (!data) return [];
@@ -62,10 +62,10 @@ export const handle: SendouRouteHandle = {
};
export const meta: MetaFunction<typeof loader> = (args) => {
if (!args.data) return [];
if (!args.loaderData) return [];
return metaTags({
title: args.data.vod.title,
title: args.loaderData.vod.title,
description:
"Splatoon 3 VoD with timestamps to check out specific weapons as well as map and mode combinations.",
location: args.location,

View File

@@ -0,0 +1,47 @@
import { describe, expect, test } from "vitest";
import type { MainWeaponId } from "./types";
import { filterWeapon } from "./utils";
describe("filterWeapon", () => {
const sBlast = { type: "MAIN" as const, id: 260 as MainWeaponId };
test("matches ignoring hyphens (e.g. 's blast' finds 'S-BLAST')", () => {
expect(
filterWeapon({
weapon: sBlast,
weaponName: "S-BLAST '92",
searchTerm: "s blast",
}),
).toBe(true);
});
test("matches with the hyphen still present", () => {
expect(
filterWeapon({
weapon: sBlast,
weaponName: "S-BLAST '92",
searchTerm: "s-blast",
}),
).toBe(true);
});
test("matches ignoring case", () => {
expect(
filterWeapon({
weapon: sBlast,
weaponName: "S-BLAST '92",
searchTerm: "SBLAST",
}),
).toBe(true);
});
test("does not match unrelated weapon", () => {
expect(
filterWeapon({
weapon: sBlast,
weaponName: "S-BLAST '92",
searchTerm: "splattershot",
}),
).toBe(false);
});
});

View File

@@ -8,7 +8,11 @@ export function isAbility(value: string): value is Ability {
}
const normalizeTerm = (term: string): string => {
return term.trim().toLocaleLowerCase();
return term
.normalize("NFD")
.replace(/\p{M}/gu, "")
.replace(/[^\p{L}\p{N}]/gu, "")
.toLocaleLowerCase();
};
export function filterWeapon({

View File

@@ -61,9 +61,11 @@ import { isSupporter } from "./modules/permissions/utils";
import { IS_E2E_TEST_RUN } from "./utils/e2e";
import { allI18nNamespaces } from "./utils/i18n";
import { isRevalidation, metaTags, type SerializeFrom } from "./utils/remix";
import { requestContextMiddleware } from "./utils/request-context-middleware.server";
import { APP_ICON_URL, pwaSplashScreenImageUrl } from "./utils/urls";
export const middleware: Route.MiddlewareFunction[] = [
requestContextMiddleware,
sessionIdMiddleware,
userMiddleware,
];
@@ -334,7 +336,7 @@ function useCustomThemeVars() {
const styles: Map<string, number> = new Map();
for (const match of matches) {
const data = match.data as { customTheme?: CustomTheme } | undefined;
const data = match.loaderData as { customTheme?: CustomTheme } | undefined;
if (data?.customTheme) {
for (const [key, value] of Object.entries(data.customTheme)) {

View File

@@ -193,6 +193,10 @@ export default [
...prefix("/org/:slug", [
index("features/tournament-organization/routes/org.$slug.tsx"),
route("edit", "features/tournament-organization/routes/org.$slug.edit.tsx"),
route(
"stats",
"features/tournament-organization/routes/org.$slug.stats.tsx",
),
]),
route("/faq", "features/info/routes/faq.tsx"),

View File

@@ -8,6 +8,7 @@ import type { z } from "zod";
import type { navItems } from "~/components/layout/nav-items";
import { ServerConfig } from "~/config.server";
import { logger } from "./logger";
import { currentRequestPathname } from "./request-context.server";
export function notFoundIfFalsy<T>(value: T | null | undefined): T {
if (!value) throw new Response(null, { status: 404 });
@@ -226,7 +227,7 @@ export function canAccessLohiEndpoint(request: Request) {
}
function errorToastRedirect(message: string) {
return redirect(`?__error=${message}`);
return redirect(`${currentRequestPathname() ?? ""}?__error=${message}`);
}
/** Asserts condition is truthy. Throws a redirect triggering an error toast with given message otherwise. */
@@ -258,7 +259,7 @@ export function errorToast(message: string) {
}
export function successToast(message: string) {
return redirect(`?__success=${message}`);
return redirect(`${currentRequestPathname() ?? ""}?__success=${message}`);
}
export function successToastWithRedirect({

View File

@@ -0,0 +1,16 @@
import { runWithRequestContext } from "./request-context.server";
type MiddlewareArgs = {
request: Request;
url: URL;
context: unknown;
};
type MiddlewareFn = (
args: MiddlewareArgs,
next: () => Promise<Response>,
) => Promise<Response>;
// TODO: this is only needed for our current hacky toast setup, once a proper one in place this middleware can be deleted
export const requestContextMiddleware: MiddlewareFn = ({ url }, next) =>
runWithRequestContext({ url }, () => next());

View File

@@ -0,0 +1,26 @@
import { AsyncLocalStorage } from "node:async_hooks";
// TODO: this is only needed for our current hacky toast setup, once a proper one in place this middleware can be deleted
interface RequestContext {
/** Normalized request URL, as provided to middleware in framework mode
* (single-fetch `.data` suffix and internal search params removed). */
url: URL;
}
const requestContextAsyncLocalStorage = new AsyncLocalStorage<RequestContext>();
/** Runs `fn` with the given request context available to server-side helpers
* (e.g. toast redirects) that don't otherwise receive the request. */
export function runWithRequestContext<T>(
context: RequestContext,
fn: () => T,
): T {
return requestContextAsyncLocalStorage.run(context, fn);
}
/** Normalized pathname of the current request, or `undefined` outside a request
* context. Used to build absolute redirects from helpers lacking the request. */
export function currentRequestPathname(): string | undefined {
return requestContextAsyncLocalStorage.getStore()?.url.pathname;
}

View File

@@ -425,6 +425,8 @@ export const tournamentOrganizationPage = ({
};
export const tournamentOrganizationEditPage = (organizationSlug: string) =>
`${tournamentOrganizationPage({ organizationSlug })}/edit`;
export const tournamentOrganizationStatsPage = (organizationSlug: string) =>
`${tournamentOrganizationPage({ organizationSlug })}/stats`;
export const sendouQInviteLink = (inviteCode: string) =>
`${SENDOUQ_PAGE}?${JOIN_CODE_SEARCH_PARAM_KEY}=${inviteCode}`;

View File

@@ -70,6 +70,17 @@ test.describe("Tournament Organization", () => {
.selectOption("ADMIN");
await submit(page);
// Establish the organization so its admins can edit tournament event info
await navigate({ page, url });
await page.getByRole("tab", { name: "Admin" }).click();
const isEstablishedForm = createFormHelpers(
page,
updateIsEstablishedSchema,
);
await waitForPOSTResponse(page, () =>
isEstablishedForm.check("isEstablished"),
);
// 3. As the promoted user, verify edit controls are visible and page can be accessed
await impersonate(page, NZAP_TEST_ID);
await navigate({

View File

@@ -43,5 +43,8 @@
"leave.confirm": "",
"leave.soleAdmin": "",
"new.heading": "",
"new.noPermissions": ""
"new.noPermissions": "",
"stats.title": "",
"stats.established.title": "",
"stats.established.help": ""
}

View File

@@ -43,5 +43,8 @@
"leave.confirm": "",
"leave.soleAdmin": "",
"new.heading": "",
"new.noPermissions": ""
"new.noPermissions": "",
"stats.title": "",
"stats.established.title": "",
"stats.established.help": ""
}

View File

@@ -43,5 +43,8 @@
"leave.confirm": "Are you sure you want to leave {{organizationName}}?",
"leave.soleAdmin": "You are the only admin of this organization. Add another admin first or ask a site administrator to delete it.",
"new.heading": "New Organization",
"new.noPermissions": "No permissions to add organizations. Organizations can be created by users with tournament adder permissions."
"new.noPermissions": "No permissions to add organizations. Organizations can be created by users with tournament adder permissions.",
"stats.title": "Stats",
"stats.established.title": "Established status",
"stats.established.help": "Average active players over the last {{months}} months. Reach {{gain}} to become established, drop below {{lose}} to lose it. Check the FAQ page for more information on established organizations."
}

View File

@@ -43,5 +43,8 @@
"leave.confirm": "¿Seguro que quieres abandonar {{organizationName}}?",
"leave.soleAdmin": "Eres el único admin de esta organización. Añade otro admin primero o pide a un administrador del sitio que la elimine.",
"new.heading": "Nueva organización",
"new.noPermissions": "Sin permisos para añadir organizaciones. Las organizaciones pueden ser creadas por usuarios con permisos de organizador de torneos."
"new.noPermissions": "Sin permisos para añadir organizaciones. Las organizaciones pueden ser creadas por usuarios con permisos de organizador de torneos.",
"stats.title": "",
"stats.established.title": "",
"stats.established.help": ""
}

View File

@@ -43,5 +43,8 @@
"leave.confirm": "",
"leave.soleAdmin": "",
"new.heading": "",
"new.noPermissions": ""
"new.noPermissions": "",
"stats.title": "",
"stats.established.title": "",
"stats.established.help": ""
}

View File

@@ -43,5 +43,8 @@
"leave.confirm": "",
"leave.soleAdmin": "",
"new.heading": "",
"new.noPermissions": ""
"new.noPermissions": "",
"stats.title": "",
"stats.established.title": "",
"stats.established.help": ""
}

View File

@@ -43,5 +43,8 @@
"leave.confirm": "",
"leave.soleAdmin": "",
"new.heading": "",
"new.noPermissions": ""
"new.noPermissions": "",
"stats.title": "",
"stats.established.title": "",
"stats.established.help": ""
}

View File

@@ -43,5 +43,8 @@
"leave.confirm": "",
"leave.soleAdmin": "",
"new.heading": "",
"new.noPermissions": ""
"new.noPermissions": "",
"stats.title": "",
"stats.established.title": "",
"stats.established.help": ""
}

View File

@@ -43,5 +43,8 @@
"leave.confirm": "",
"leave.soleAdmin": "",
"new.heading": "",
"new.noPermissions": ""
"new.noPermissions": "",
"stats.title": "",
"stats.established.title": "",
"stats.established.help": ""
}

View File

@@ -43,5 +43,8 @@
"leave.confirm": "",
"leave.soleAdmin": "",
"new.heading": "",
"new.noPermissions": ""
"new.noPermissions": "",
"stats.title": "",
"stats.established.title": "",
"stats.established.help": ""
}

View File

@@ -43,5 +43,8 @@
"leave.confirm": "",
"leave.soleAdmin": "",
"new.heading": "",
"new.noPermissions": ""
"new.noPermissions": "",
"stats.title": "",
"stats.established.title": "",
"stats.established.help": ""
}

View File

@@ -43,5 +43,8 @@
"leave.confirm": "",
"leave.soleAdmin": "",
"new.heading": "",
"new.noPermissions": ""
"new.noPermissions": "",
"stats.title": "",
"stats.established.title": "",
"stats.established.help": ""
}

View File

@@ -43,5 +43,8 @@
"leave.confirm": "",
"leave.soleAdmin": "",
"new.heading": "",
"new.noPermissions": ""
"new.noPermissions": "",
"stats.title": "",
"stats.established.title": "",
"stats.established.help": ""
}

View File

@@ -43,5 +43,8 @@
"leave.confirm": "",
"leave.soleAdmin": "",
"new.heading": "",
"new.noPermissions": ""
"new.noPermissions": "",
"stats.title": "",
"stats.established.title": "",
"stats.established.help": ""
}

View File

@@ -43,5 +43,8 @@
"leave.confirm": "",
"leave.soleAdmin": "",
"new.heading": "",
"new.noPermissions": ""
"new.noPermissions": "",
"stats.title": "",
"stats.established.title": "",
"stats.established.help": ""
}

View File

@@ -141,7 +141,7 @@
"actions.noOutline": "无描边",
"actions.join": "加入",
"host": "房主",
"seed": "",
"seed": "{{number}} 号种子",
"actions.nevermind": "反悔",
"actions.clickHere": "点击此处",
"actions.goBack": "返回",

View File

@@ -43,5 +43,8 @@
"leave.confirm": "您确定要退出 {{organizationName}} 吗?",
"leave.soleAdmin": "您是该组织唯一的管理员。请先添加另一位管理员,或者联系网站管理员删除该组织。",
"new.heading": "创建组织",
"new.noPermissions": "您没有创建组织的权限。只有拥有赛事创建权限的用户才能创建组织。"
"new.noPermissions": "您没有创建组织的权限。只有拥有赛事创建权限的用户才能创建组织。",
"stats.title": "",
"stats.established.title": "",
"stats.established.help": ""
}

View File

@@ -47,10 +47,10 @@
"@faker-js/faker": "10.4.0",
"@formatjs/intl-durationformat": "0.10.14",
"@internationalized/date": "3.12.2",
"@react-router/node": "7.17.0",
"@react-router/serve": "7.15.0",
"@react-router/node": "8.1.0",
"@react-router/serve": "8.1.0",
"@remix-run/form-data-parser": "0.17.3",
"@sentry/react-router": "^10.57.0",
"@sentry/react-router": "10.63.0",
"@tldraw/tldraw": "3.12.1",
"@zumer/snapdom": "2.12.8",
"better-sqlite3": "12.10.0",
@@ -84,7 +84,7 @@
"react-error-boundary": "6.1.2",
"react-flip-toolkit": "7.2.4",
"react-i18next": "17.0.8",
"react-router": "7.17.0",
"react-router": "8.1.0",
"react-use-draggable-scroll": "0.4.7",
"remeda": "2.39.0",
"remix-auth": "4.2.0",
@@ -100,9 +100,9 @@
"@babel/preset-typescript": "7.29.7",
"@biomejs/biome": "2.5.1",
"@playwright/test": "1.60.0",
"@react-router/dev": "7.17.0",
"@react-router/dev": "8.1.0",
"@types/better-sqlite3": "7.6.13",
"@types/node": "25.9.3",
"@types/node": "26.0.0",
"@types/node-cron": "3.0.11",
"@types/nprogress": "0.2.3",
"@types/react": "19.2.17",

View File

@@ -1,12 +0,0 @@
diff --git a/dist/cli.js b/dist/cli.js
index 08277520abadf36c3da03e50afc08919d316d5b2..55d9a1cf1f0220a96acd9f330b5668b03e4b6ddc 100644
--- a/dist/cli.js
+++ b/dist/cli.js
@@ -127,7 +127,6 @@ async function run() {
);
app.use(build.publicPath, import_express2.default.static(build.assetsBuildDirectory));
app.use(import_express2.default.static("public", { maxAge: "1h" }));
- app.use((0, import_morgan.default)("tiny"));
if (build.fetch) {
app.all("*", (0, import_node_fetch_server.createRequestListener)(build.fetch));
} else {

View File

@@ -0,0 +1,12 @@
diff --git a/dist/cli.js b/dist/cli.js
index 7871cebe46f6df886b76364db5346adfa1838622..495960a0a12d3d027ababaacc0b92a7dc39274a6 100644
--- a/dist/cli.js
+++ b/dist/cli.js
@@ -118,7 +118,6 @@ async function run() {
}));
app.use(expressPublicPath, express.static(build.assetsBuildDirectory));
app.use(express.static("public", { maxAge: "1h" }));
- app.use(morgan("tiny"));
if (build.fetch) app.all("/{*splat}", createRequestListener(build.fetch));
else app.all("/{*splat}", createRequestHandler({
build: buildModule,

1505
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More