diff --git a/app/features/tournament-organization/TournamentOrganizationRepository.server.test.ts b/app/features/tournament-organization/TournamentOrganizationRepository.server.test.ts index cf3f4b0cd..9faf074aa 100644 --- a/app/features/tournament-organization/TournamentOrganizationRepository.server.test.ts +++ b/app/features/tournament-organization/TournamentOrganizationRepository.server.test.ts @@ -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); + }); +}); diff --git a/app/features/tournament-organization/TournamentOrganizationRepository.server.ts b/app/features/tournament-organization/TournamentOrganizationRepository.server.ts index f5be8ed33..363b9ff50 100644 --- a/app/features/tournament-organization/TournamentOrganizationRepository.server.ts +++ b/app/features/tournament-organization/TournamentOrganizationRepository.server.ts @@ -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("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"], diff --git a/app/features/tournament-organization/loaders/org.$slug.stats.server.ts b/app/features/tournament-organization/loaders/org.$slug.stats.server.ts new file mode 100644 index 000000000..472c22df6 --- /dev/null +++ b/app/features/tournament-organization/loaders/org.$slug.stats.server.ts @@ -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; +} diff --git a/app/features/tournament-organization/routes/org.$slug.stats.module.css b/app/features/tournament-organization/routes/org.$slug.stats.module.css new file mode 100644 index 000000000..db0f9c5e8 --- /dev/null +++ b/app/features/tournament-organization/routes/org.$slug.stats.module.css @@ -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; +} diff --git a/app/features/tournament-organization/routes/org.$slug.stats.test.ts b/app/features/tournament-organization/routes/org.$slug.stats.test.ts new file mode 100644 index 000000000..55f31a016 --- /dev/null +++ b/app/features/tournament-organization/routes/org.$slug.stats.test.ts @@ -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>({ 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, + ); + }); +}); diff --git a/app/features/tournament-organization/routes/org.$slug.stats.tsx b/app/features/tournament-organization/routes/org.$slug.stats.tsx new file mode 100644 index 000000000..7cbf5124a --- /dev/null +++ b/app/features/tournament-organization/routes/org.$slug.stats.tsx @@ -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 ( +
+ +
+ ); +} + +function EstablishedStatus() { + const { t } = useTranslation(["org"]); + const { formatter } = useDateTimeFormat({ month: "short", year: "numeric" }); + const { monthlyStats, averageMonthlyParticipants } = + useLoaderData(); + + const meetsThreshold = + averageMonthlyParticipants >= ESTABLISHED_ORG.GAIN_THRESHOLD; + + const maxCount = Math.max( + ESTABLISHED_ORG.GAIN_THRESHOLD, + ...monthlyStats.map((monthStat) => monthStat.count), + ); + + return ( +
+
+ + {({ percentage }) => ( + <> +
+ + {averageMonthlyParticipants.toFixed(1)} + + + / {ESTABLISHED_ORG.GAIN_THRESHOLD} + +
+
+
+
+ + )} + +
+ {t("org:stats.established.help", { + months: ESTABLISHED_ORG.MONTHS_CONSIDERED, + gain: ESTABLISHED_ORG.GAIN_THRESHOLD, + lose: ESTABLISHED_ORG.LOSE_THRESHOLD, + })} +
+
+ {monthlyStats.map((monthStat) => ( + + {({ percentage }) => ( + <> + + {formatMonth(monthStat.month, formatter)} + +
+
+
+ + {monthStat.count} + + + )} + + ))} +
+
+
+ ); +} + +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; +} diff --git a/app/features/tournament-organization/routes/org.$slug.tsx b/app/features/tournament-organization/routes/org.$slug.tsx index 93324dfe6..92fee3ec9 100644 --- a/app/features/tournament-organization/routes/org.$slug.tsx +++ b/app/features/tournament-organization/routes/org.$slug.tsx @@ -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")} ) : null} + {isOrgAdmin ? ( + } + size="small" + variant="outlined" + testId="org-stats-button" + > + {t("org:stats.title")} + + ) : null} {currentMember ? ( isSoleAdmin ? ( ({ + matchGameResultId: gameResult.id, + userId, + tournamentTeamId: team.id, + })), + ) + .execute(); + + return { tournamentId, teamId: team.id }; +} diff --git a/app/features/tournament-organization/tournament-organization-constants.ts b/app/features/tournament-organization/tournament-organization-constants.ts index 536e70132..415ccc208 100644 --- a/app/features/tournament-organization/tournament-organization-constants.ts +++ b/app/features/tournament-organization/tournament-organization-constants.ts @@ -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, diff --git a/app/features/tournament/tournament-test-utils.ts b/app/features/tournament/tournament-test-utils.ts index f2f5a756d..2b6206579 100644 --- a/app/features/tournament/tournament-test-utils.ts +++ b/app/features/tournament/tournament-test-utils.ts @@ -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: [ { diff --git a/app/routes.ts b/app/routes.ts index 0e3395c83..9f414bd4a 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -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"), diff --git a/app/utils/urls.ts b/app/utils/urls.ts index d1a79ce9c..98db9a442 100644 --- a/app/utils/urls.ts +++ b/app/utils/urls.ts @@ -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}`; diff --git a/locales/da/org.json b/locales/da/org.json index 9f378ba20..c4a8fa9df 100644 --- a/locales/da/org.json +++ b/locales/da/org.json @@ -43,5 +43,8 @@ "leave.confirm": "", "leave.soleAdmin": "", "new.heading": "", - "new.noPermissions": "" + "new.noPermissions": "", + "stats.title": "", + "stats.established.title": "", + "stats.established.help": "" } diff --git a/locales/de/org.json b/locales/de/org.json index 9f378ba20..c4a8fa9df 100644 --- a/locales/de/org.json +++ b/locales/de/org.json @@ -43,5 +43,8 @@ "leave.confirm": "", "leave.soleAdmin": "", "new.heading": "", - "new.noPermissions": "" + "new.noPermissions": "", + "stats.title": "", + "stats.established.title": "", + "stats.established.help": "" } diff --git a/locales/en/org.json b/locales/en/org.json index c82e2ae6b..cbc571502 100644 --- a/locales/en/org.json +++ b/locales/en/org.json @@ -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." } diff --git a/locales/es-ES/org.json b/locales/es-ES/org.json index 9f12eb2bb..dbc7a0038 100644 --- a/locales/es-ES/org.json +++ b/locales/es-ES/org.json @@ -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": "" } diff --git a/locales/es-US/org.json b/locales/es-US/org.json index 4a7bc44cc..02e055556 100644 --- a/locales/es-US/org.json +++ b/locales/es-US/org.json @@ -43,5 +43,8 @@ "leave.confirm": "", "leave.soleAdmin": "", "new.heading": "", - "new.noPermissions": "" + "new.noPermissions": "", + "stats.title": "", + "stats.established.title": "", + "stats.established.help": "" } diff --git a/locales/fr-CA/org.json b/locales/fr-CA/org.json index 9f378ba20..c4a8fa9df 100644 --- a/locales/fr-CA/org.json +++ b/locales/fr-CA/org.json @@ -43,5 +43,8 @@ "leave.confirm": "", "leave.soleAdmin": "", "new.heading": "", - "new.noPermissions": "" + "new.noPermissions": "", + "stats.title": "", + "stats.established.title": "", + "stats.established.help": "" } diff --git a/locales/fr-EU/org.json b/locales/fr-EU/org.json index dd775fa33..efc04bb06 100644 --- a/locales/fr-EU/org.json +++ b/locales/fr-EU/org.json @@ -43,5 +43,8 @@ "leave.confirm": "", "leave.soleAdmin": "", "new.heading": "", - "new.noPermissions": "" + "new.noPermissions": "", + "stats.title": "", + "stats.established.title": "", + "stats.established.help": "" } diff --git a/locales/he/org.json b/locales/he/org.json index 9f378ba20..c4a8fa9df 100644 --- a/locales/he/org.json +++ b/locales/he/org.json @@ -43,5 +43,8 @@ "leave.confirm": "", "leave.soleAdmin": "", "new.heading": "", - "new.noPermissions": "" + "new.noPermissions": "", + "stats.title": "", + "stats.established.title": "", + "stats.established.help": "" } diff --git a/locales/it/org.json b/locales/it/org.json index 430387f20..2b427a26f 100644 --- a/locales/it/org.json +++ b/locales/it/org.json @@ -43,5 +43,8 @@ "leave.confirm": "", "leave.soleAdmin": "", "new.heading": "", - "new.noPermissions": "" + "new.noPermissions": "", + "stats.title": "", + "stats.established.title": "", + "stats.established.help": "" } diff --git a/locales/ja/org.json b/locales/ja/org.json index 9f378ba20..c4a8fa9df 100644 --- a/locales/ja/org.json +++ b/locales/ja/org.json @@ -43,5 +43,8 @@ "leave.confirm": "", "leave.soleAdmin": "", "new.heading": "", - "new.noPermissions": "" + "new.noPermissions": "", + "stats.title": "", + "stats.established.title": "", + "stats.established.help": "" } diff --git a/locales/ko/org.json b/locales/ko/org.json index 9f378ba20..c4a8fa9df 100644 --- a/locales/ko/org.json +++ b/locales/ko/org.json @@ -43,5 +43,8 @@ "leave.confirm": "", "leave.soleAdmin": "", "new.heading": "", - "new.noPermissions": "" + "new.noPermissions": "", + "stats.title": "", + "stats.established.title": "", + "stats.established.help": "" } diff --git a/locales/nl/org.json b/locales/nl/org.json index 9f378ba20..c4a8fa9df 100644 --- a/locales/nl/org.json +++ b/locales/nl/org.json @@ -43,5 +43,8 @@ "leave.confirm": "", "leave.soleAdmin": "", "new.heading": "", - "new.noPermissions": "" + "new.noPermissions": "", + "stats.title": "", + "stats.established.title": "", + "stats.established.help": "" } diff --git a/locales/pl/org.json b/locales/pl/org.json index 9f378ba20..c4a8fa9df 100644 --- a/locales/pl/org.json +++ b/locales/pl/org.json @@ -43,5 +43,8 @@ "leave.confirm": "", "leave.soleAdmin": "", "new.heading": "", - "new.noPermissions": "" + "new.noPermissions": "", + "stats.title": "", + "stats.established.title": "", + "stats.established.help": "" } diff --git a/locales/pt-BR/org.json b/locales/pt-BR/org.json index 9f378ba20..c4a8fa9df 100644 --- a/locales/pt-BR/org.json +++ b/locales/pt-BR/org.json @@ -43,5 +43,8 @@ "leave.confirm": "", "leave.soleAdmin": "", "new.heading": "", - "new.noPermissions": "" + "new.noPermissions": "", + "stats.title": "", + "stats.established.title": "", + "stats.established.help": "" } diff --git a/locales/ru/org.json b/locales/ru/org.json index 0e1375483..6eefa3f4f 100644 --- a/locales/ru/org.json +++ b/locales/ru/org.json @@ -43,5 +43,8 @@ "leave.confirm": "", "leave.soleAdmin": "", "new.heading": "", - "new.noPermissions": "" + "new.noPermissions": "", + "stats.title": "", + "stats.established.title": "", + "stats.established.help": "" } diff --git a/locales/zh/org.json b/locales/zh/org.json index b4ce31ad1..da7350609 100644 --- a/locales/zh/org.json +++ b/locales/zh/org.json @@ -43,5 +43,8 @@ "leave.confirm": "您确定要退出 {{organizationName}} 吗?", "leave.soleAdmin": "您是该组织唯一的管理员。请先添加另一位管理员,或者联系网站管理员删除该组织。", "new.heading": "创建组织", - "new.noPermissions": "您没有创建组织的权限。只有拥有赛事创建权限的用户才能创建组织。" + "new.noPermissions": "您没有创建组织的权限。只有拥有赛事创建权限的用户才能创建组织。", + "stats.title": "", + "stats.established.title": "", + "stats.established.help": "" }