mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-26 13:17:15 -05:00
Add org participant stats admin page (#3197)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -423,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";
|
||||
@@ -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,6 +1,14 @@
|
||||
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,
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -181,6 +181,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"),
|
||||
|
||||
@@ -410,6 +410,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}`;
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -43,5 +43,8 @@
|
||||
"leave.confirm": "您确定要退出 {{organizationName}} 吗?",
|
||||
"leave.soleAdmin": "您是该组织唯一的管理员。请先添加另一位管理员,或者联系网站管理员删除该组织。",
|
||||
"new.heading": "创建组织",
|
||||
"new.noPermissions": "您没有创建组织的权限。只有拥有赛事创建权限的用户才能创建组织。"
|
||||
"new.noPermissions": "您没有创建组织的权限。只有拥有赛事创建权限的用户才能创建组织。",
|
||||
"stats.title": "",
|
||||
"stats.established.title": "",
|
||||
"stats.established.help": ""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user