Add org participant stats admin page (#3197)

This commit is contained in:
Kim Tran
2026-07-01 20:36:43 +03:00
committed by GitHub
parent 0c5a55fcdf
commit 83484ff2d8
28 changed files with 739 additions and 22 deletions

View File

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

View File

@@ -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"],

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,11 @@
import { Link as LinkIcon, Lock, LogOut, SquarePen, Users } from "lucide-react";
import {
ChartNoAxesColumn,
Link as LinkIcon,
Lock,
LogOut,
SquarePen,
Users,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import type { MetaFunction } from "react-router";
import { Link, useLoaderData, useSearchParams } from "react-router";
@@ -33,6 +40,7 @@ import {
navIconUrl,
tournamentOrganizationEditPage,
tournamentOrganizationPage,
tournamentOrganizationStatsPage,
tournamentPage,
userPage,
} from "~/utils/urls";
@@ -118,8 +126,9 @@ function LogoHeader() {
const currentMember = user
? data.organization.members.find((m) => m.id === user.id)
: undefined;
const isOrgAdmin = currentMember?.role === "ADMIN";
const isSoleAdmin =
currentMember?.role === "ADMIN" &&
isOrgAdmin &&
data.organization.members.filter((m) => m.role === "ADMIN").length === 1;
return (
@@ -140,6 +149,17 @@ function LogoHeader() {
{t("common:actions.edit")}
</LinkButton>
) : null}
{isOrgAdmin ? (
<LinkButton
to={tournamentOrganizationStatsPage(data.organization.slug)}
icon={<ChartNoAxesColumn />}
size="small"
variant="outlined"
testId="org-stats-button"
>
{t("org:stats.title")}
</LinkButton>
) : null}
{currentMember ? (
isSoleAdmin ? (
<SendouDialog

View File

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

View File

@@ -1,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,

View File

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

View File

@@ -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"),

View File

@@ -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}`;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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