mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-27 13:45:27 -05:00
Merge branch 'main' into user-card
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
57
app/features/comp-analyzer/core/special-weapon-range.test.ts
Normal file
57
app/features/comp-analyzer/core/special-weapon-range.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
88
app/features/comp-analyzer/core/special-weapon-range.ts
Normal file
88
app/features/comp-analyzer/core/special-weapon-range.ts
Normal 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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
31
app/features/tournament-bracket/core/Bracket/utils.ts
Normal file
31
app/features/tournament-bracket/core/Bracket/utils.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
123
app/features/tournament-organization/routes/org.$slug.stats.tsx
Normal file
123
app/features/tournament-organization/routes/org.$slug.stats.tsx
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
142
app/features/tournament-organization/test-utils.ts
Normal file
142
app/features/tournament-organization/test-utils.ts
Normal 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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -782,6 +782,7 @@ describe("tournamentNameParts", () => {
|
||||
id: 1,
|
||||
name: "Sendou's Tournaments",
|
||||
slug: "sendou",
|
||||
isEstablished: 1,
|
||||
logoUrl: null,
|
||||
members: [],
|
||||
series: [{ name: "In The Zone" }],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[] = [
|
||||
{
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
47
app/modules/in-game-lists/utils.test.ts
Normal file
47
app/modules/in-game-lists/utils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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({
|
||||
|
||||
16
app/utils/request-context-middleware.server.ts
Normal file
16
app/utils/request-context-middleware.server.ts
Normal 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());
|
||||
26
app/utils/request-context.server.ts
Normal file
26
app/utils/request-context.server.ts
Normal 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;
|
||||
}
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -43,5 +43,8 @@
|
||||
"leave.confirm": "",
|
||||
"leave.soleAdmin": "",
|
||||
"new.heading": "",
|
||||
"new.noPermissions": ""
|
||||
"new.noPermissions": "",
|
||||
"stats.title": "",
|
||||
"stats.established.title": "",
|
||||
"stats.established.help": ""
|
||||
}
|
||||
|
||||
@@ -43,5 +43,8 @@
|
||||
"leave.confirm": "",
|
||||
"leave.soleAdmin": "",
|
||||
"new.heading": "",
|
||||
"new.noPermissions": ""
|
||||
"new.noPermissions": "",
|
||||
"stats.title": "",
|
||||
"stats.established.title": "",
|
||||
"stats.established.help": ""
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -43,5 +43,8 @@
|
||||
"leave.confirm": "",
|
||||
"leave.soleAdmin": "",
|
||||
"new.heading": "",
|
||||
"new.noPermissions": ""
|
||||
"new.noPermissions": "",
|
||||
"stats.title": "",
|
||||
"stats.established.title": "",
|
||||
"stats.established.help": ""
|
||||
}
|
||||
|
||||
@@ -43,5 +43,8 @@
|
||||
"leave.confirm": "",
|
||||
"leave.soleAdmin": "",
|
||||
"new.heading": "",
|
||||
"new.noPermissions": ""
|
||||
"new.noPermissions": "",
|
||||
"stats.title": "",
|
||||
"stats.established.title": "",
|
||||
"stats.established.help": ""
|
||||
}
|
||||
|
||||
@@ -43,5 +43,8 @@
|
||||
"leave.confirm": "",
|
||||
"leave.soleAdmin": "",
|
||||
"new.heading": "",
|
||||
"new.noPermissions": ""
|
||||
"new.noPermissions": "",
|
||||
"stats.title": "",
|
||||
"stats.established.title": "",
|
||||
"stats.established.help": ""
|
||||
}
|
||||
|
||||
@@ -43,5 +43,8 @@
|
||||
"leave.confirm": "",
|
||||
"leave.soleAdmin": "",
|
||||
"new.heading": "",
|
||||
"new.noPermissions": ""
|
||||
"new.noPermissions": "",
|
||||
"stats.title": "",
|
||||
"stats.established.title": "",
|
||||
"stats.established.help": ""
|
||||
}
|
||||
|
||||
@@ -43,5 +43,8 @@
|
||||
"leave.confirm": "",
|
||||
"leave.soleAdmin": "",
|
||||
"new.heading": "",
|
||||
"new.noPermissions": ""
|
||||
"new.noPermissions": "",
|
||||
"stats.title": "",
|
||||
"stats.established.title": "",
|
||||
"stats.established.help": ""
|
||||
}
|
||||
|
||||
@@ -43,5 +43,8 @@
|
||||
"leave.confirm": "",
|
||||
"leave.soleAdmin": "",
|
||||
"new.heading": "",
|
||||
"new.noPermissions": ""
|
||||
"new.noPermissions": "",
|
||||
"stats.title": "",
|
||||
"stats.established.title": "",
|
||||
"stats.established.help": ""
|
||||
}
|
||||
|
||||
@@ -43,5 +43,8 @@
|
||||
"leave.confirm": "",
|
||||
"leave.soleAdmin": "",
|
||||
"new.heading": "",
|
||||
"new.noPermissions": ""
|
||||
"new.noPermissions": "",
|
||||
"stats.title": "",
|
||||
"stats.established.title": "",
|
||||
"stats.established.help": ""
|
||||
}
|
||||
|
||||
@@ -43,5 +43,8 @@
|
||||
"leave.confirm": "",
|
||||
"leave.soleAdmin": "",
|
||||
"new.heading": "",
|
||||
"new.noPermissions": ""
|
||||
"new.noPermissions": "",
|
||||
"stats.title": "",
|
||||
"stats.established.title": "",
|
||||
"stats.established.help": ""
|
||||
}
|
||||
|
||||
@@ -43,5 +43,8 @@
|
||||
"leave.confirm": "",
|
||||
"leave.soleAdmin": "",
|
||||
"new.heading": "",
|
||||
"new.noPermissions": ""
|
||||
"new.noPermissions": "",
|
||||
"stats.title": "",
|
||||
"stats.established.title": "",
|
||||
"stats.established.help": ""
|
||||
}
|
||||
|
||||
@@ -43,5 +43,8 @@
|
||||
"leave.confirm": "",
|
||||
"leave.soleAdmin": "",
|
||||
"new.heading": "",
|
||||
"new.noPermissions": ""
|
||||
"new.noPermissions": "",
|
||||
"stats.title": "",
|
||||
"stats.established.title": "",
|
||||
"stats.established.help": ""
|
||||
}
|
||||
|
||||
@@ -43,5 +43,8 @@
|
||||
"leave.confirm": "",
|
||||
"leave.soleAdmin": "",
|
||||
"new.heading": "",
|
||||
"new.noPermissions": ""
|
||||
"new.noPermissions": "",
|
||||
"stats.title": "",
|
||||
"stats.established.title": "",
|
||||
"stats.established.help": ""
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@
|
||||
"actions.noOutline": "无描边",
|
||||
"actions.join": "加入",
|
||||
"host": "房主",
|
||||
"seed": "",
|
||||
"seed": "{{number}} 号种子",
|
||||
"actions.nevermind": "反悔",
|
||||
"actions.clickHere": "点击此处",
|
||||
"actions.goBack": "返回",
|
||||
|
||||
@@ -43,5 +43,8 @@
|
||||
"leave.confirm": "您确定要退出 {{organizationName}} 吗?",
|
||||
"leave.soleAdmin": "您是该组织唯一的管理员。请先添加另一位管理员,或者联系网站管理员删除该组织。",
|
||||
"new.heading": "创建组织",
|
||||
"new.noPermissions": "您没有创建组织的权限。只有拥有赛事创建权限的用户才能创建组织。"
|
||||
"new.noPermissions": "您没有创建组织的权限。只有拥有赛事创建权限的用户才能创建组织。",
|
||||
"stats.title": "",
|
||||
"stats.established.title": "",
|
||||
"stats.established.help": ""
|
||||
}
|
||||
|
||||
12
package.json
12
package.json
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
12
patches/@react-router__serve@8.1.0.patch
Normal file
12
patches/@react-router__serve@8.1.0.patch
Normal 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
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
Reference in New Issue
Block a user