diff --git a/apps/front/package.json b/apps/front/package.json index f8967174..390753b6 100644 --- a/apps/front/package.json +++ b/apps/front/package.json @@ -23,6 +23,7 @@ "react-i18next": "^17.0.11", "react-router-dom": "7.18.2", "react-use-websocket": "^4.13.0", + "recharts": "^3.10.1", "unique-names-generator": "^4.7.1", "uuid": "^14.0.1" } diff --git a/apps/front/public/_headers b/apps/front/public/_headers new file mode 100644 index 00000000..d9c16f18 --- /dev/null +++ b/apps/front/public/_headers @@ -0,0 +1,15 @@ +/ranked-stats + X-Robots-Tag: noindex, nofollow + Cache-Control: no-store + +/en/ranked-stats + X-Robots-Tag: noindex, nofollow + Cache-Control: no-store + +/fr/ranked-stats + X-Robots-Tag: noindex, nofollow + Cache-Control: no-store + +/zh-tw/ranked-stats + X-Robots-Tag: noindex, nofollow + Cache-Control: no-store diff --git a/apps/front/src/components/App.tsx b/apps/front/src/components/App.tsx index 44435c8b..d833951b 100644 --- a/apps/front/src/components/App.tsx +++ b/apps/front/src/components/App.tsx @@ -1,7 +1,11 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' import { BrowserRouter, useLocation } from 'react-router-dom' -import { DEFAULT_LANGUAGE, getPathLanguage } from '../translations' +import { + DEFAULT_LANGUAGE, + getPathLanguage, + getPathWithoutLanguage +} from '../translations' import { ensurePlayerIdentity } from '../utils/playerIdentity' import { Language } from './Language' import { PlayerIdentityTransfer } from './PlayerIdentityTransfer' @@ -27,17 +31,22 @@ function LanguageSync() { return null } -export function App() { +function AppContent() { const mainContentRef = React.useRef>(null) + const { pathname } = useLocation() + const isRankedStatsPage = getPathWithoutLanguage(pathname) === '/ranked-stats' React.useEffect(() => { + if (isRankedStatsPage) { + return + } void ensurePlayerIdentity().catch((error) => { console.error('Failed to initialize player identity.', error) }) - }, []) + }, [isRankedStatsPage]) return ( - + <>
@@ -59,6 +68,14 @@ export function App() {
+ + ) +} + +export function App() { + return ( + + ) } diff --git a/apps/front/src/components/RankedStats.test.tsx b/apps/front/src/components/RankedStats.test.tsx new file mode 100644 index 00000000..cb596c70 --- /dev/null +++ b/apps/front/src/components/RankedStats.test.tsx @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import { getRankedStats } from '../utils/api' +import { RankedStatsPage } from './RankedStats' + +vi.mock('../utils/api', () => ({ getRankedStats: vi.fn() })) + +describe('RankedStatsPage', () => { + beforeEach(() => { + vi.mocked(getRankedStats).mockReset() + vi.mocked(getRankedStats).mockResolvedValue({ + generatedAt: Date.UTC(2026, 7, 11, 12), + totals: { + players: 125, + matches: 48, + wins: 42, + draws: 12, + losses: 42, + averageEloGain: 14.5 + }, + current: { activePlayers: 8, queuedPlayers: 3 }, + history: { + players: [ + { timestamp: Date.UTC(2026, 7, 10), value: 120 }, + { timestamp: Date.UTC(2026, 7, 11), value: 125 } + ], + queue: [ + { timestamp: Date.UTC(2026, 7, 11, 11), value: 1 }, + { timestamp: Date.UTC(2026, 7, 11, 12), value: 3 } + ] + } + }) + }) + + it('shows aggregate and live values and marks the page as noindex', async () => { + const view = render() + + expect(await screen.findByText('ranked.stats.title')).toBeVisible() + expect(screen.getByText('125')).toBeVisible() + expect(screen.getByText('48')).toBeVisible() + expect(screen.getByText('14.5')).toBeVisible() + expect(screen.getByText('8')).toBeVisible() + expect(screen.getByText('3')).toBeVisible() + expect(document.head.querySelector('meta[name="robots"]')).toHaveAttribute( + 'content', + 'noindex' + ) + + view.unmount() + expect(document.head.querySelector('meta[name="robots"]')).toBeNull() + }) +}) diff --git a/apps/front/src/components/RankedStats.tsx b/apps/front/src/components/RankedStats.tsx new file mode 100644 index 00000000..a9b238bb --- /dev/null +++ b/apps/front/src/components/RankedStats.tsx @@ -0,0 +1,239 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { + Area, + AreaChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis +} from 'recharts' +import { + type RankedStats, + type RankedStatsTimePoint +} from '@knucklebones/common' +import { useNoIndex } from '../hooks/useNoIndex' +import { getRankedStats } from '../utils/api' + +const REFRESH_INTERVAL_MS = 15_000 + +interface IndicatorProps { + label: string + value: string +} + +function Indicator({ label, value }: IndicatorProps) { + return ( +
+
+ {label} +
+
{value}
+
+ ) +} + +interface StatsChartProps { + data: RankedStatsTimePoint[] + id: string + label: string + title: string + formatTimestamp(timestamp: number): string + formatValue(value: number): string +} + +function StatsChart({ + data, + id, + label, + title, + formatTimestamp, + formatValue +}: StatsChartProps) { + return ( +
+

{title}

+
+ + + + + + + + + + + + formatTimestamp(Number(timestamp))} + formatter={(value) => [formatValue(Number(value)), label]} + contentStyle={{ + backgroundColor: '#0f172a', + border: 'none', + borderRadius: '0.75rem', + color: '#f8fafc' + }} + /> + + + +
+
+ ) +} + +export function RankedStatsPage() { + const { t, i18n } = useTranslation() + const [stats, setStats] = React.useState() + const [hasError, setHasError] = React.useState(false) + useNoIndex() + + React.useEffect(() => { + let disposed = false + + const loadStats = async () => { + try { + const nextStats = await getRankedStats() + if (!disposed) { + setStats(nextStats) + setHasError(false) + } + } catch { + if (!disposed) { + setHasError(true) + } + } + } + + void loadStats() + const refreshInterval = setInterval( + () => void loadStats(), + REFRESH_INTERVAL_MS + ) + return () => { + disposed = true + clearInterval(refreshInterval) + } + }, []) + + const numberFormatter = React.useMemo( + () => new Intl.NumberFormat(i18n.language), + [i18n.language] + ) + const playerDateFormatter = React.useMemo( + () => + new Intl.DateTimeFormat(i18n.language, { + month: 'short', + day: 'numeric' + }), + [i18n.language] + ) + const queueTimeFormatter = React.useMemo( + () => + new Intl.DateTimeFormat(i18n.language, { + hour: '2-digit', + minute: '2-digit' + }), + [i18n.language] + ) + + if (stats === undefined) { + return ( +
+

+ {hasError ? t('ranked.stats.error') : t('ranked.stats.loading')} +

+
+ ) + } + + const indicators = [ + ['players', stats.totals.players], + ['matches', stats.totals.matches], + ['wins', stats.totals.wins], + ['draws', stats.totals.draws], + ['losses', stats.totals.losses], + ['average-elo-gain', stats.totals.averageEloGain], + ['active-players', stats.current.activePlayers], + ['queued-players', stats.current.queuedPlayers] + ] as const + + return ( +
+
+

+ {t('ranked.stats.title')} +

+

+ {t('ranked.stats.subtitle')} +

+

+ {t('ranked.stats.updated', { + time: new Intl.DateTimeFormat(i18n.language, { + dateStyle: 'medium', + timeStyle: 'medium' + }).format(stats.generatedAt) + })} +

+ {hasError && ( +

+ {t('ranked.stats.refresh-error')} +

+ )} +
+ +
+ {indicators.map(([key, value]) => ( + + ))} +
+ +
+ playerDateFormatter.format(timestamp)} + formatValue={(value) => numberFormatter.format(value)} + /> + queueTimeFormatter.format(timestamp)} + formatValue={(value) => numberFormatter.format(value)} + /> +
+
+ ) +} diff --git a/apps/front/src/components/Router.tsx b/apps/front/src/components/Router.tsx index 65f76f7a..06b890a0 100644 --- a/apps/front/src/components/Router.tsx +++ b/apps/front/src/components/Router.tsx @@ -1,3 +1,4 @@ +import * as React from 'react' import { Navigate, Outlet, Routes, Route, useParams } from 'react-router-dom' import { isLanguageSupported } from '../translations' import { Game } from './Game' @@ -6,6 +7,20 @@ import { HomePage } from './HomePage' import { HowToPlayPage } from './HowToPlay' import { RankedMatchmaking } from './RankedMatchmaking' +const RankedStatsPage = React.lazy(() => + import('./RankedStats').then((module) => ({ + default: module.RankedStatsPage + })) +) + +function RankedStatsRoute() { + return ( + + + + ) +} + function GameRoute() { return ( @@ -30,11 +45,13 @@ export function Router() { } /> } /> } /> + } /> }> } /> } /> } /> } /> + } /> {/* Handle 404 */} diff --git a/apps/front/src/translations/resources/en.json b/apps/front/src/translations/resources/en.json index baf04bd2..88199da7 100644 --- a/apps/front/src/translations/resources/en.json +++ b/apps/front/src/translations/resources/en.json @@ -64,6 +64,30 @@ "decline-error": "We couldn't decline the match. Try again.", "join-error": "We couldn't join ranked matchmaking. Retrying…", "connection-error": "The matchmaking connection was interrupted. Reconnecting…" + }, + "stats": { + "title": "Ranked statistics", + "subtitle": "Live and historical activity across ranked matchmaking.", + "loading": "Loading ranked statistics…", + "error": "Ranked statistics could not be loaded. Retrying…", + "refresh-error": "The latest refresh failed. The previous values are still shown.", + "updated": "Updated {{time}}", + "indicators": { + "players": "Ranked players", + "matches": "Matches played", + "wins": "Player wins", + "draws": "Player draws", + "losses": "Player losses", + "average-elo-gain": "Average Elo gain", + "active-players": "Playing now", + "queued-players": "In queue now" + }, + "graphs": { + "players": "Ranked players · Last 30 days", + "players-value": "Players", + "queue": "Queue size · 15-minute peak · Last 24 hours", + "queue-value": "Queued players" + } } }, "guide": { diff --git a/apps/front/src/translations/resources/fr.json b/apps/front/src/translations/resources/fr.json index af6934e2..4ca83264 100644 --- a/apps/front/src/translations/resources/fr.json +++ b/apps/front/src/translations/resources/fr.json @@ -64,6 +64,30 @@ "decline-error": "Impossible de refuser la partie. Réessayez.", "join-error": "Impossible de rejoindre la file classée. Nouvelle tentative…", "connection-error": "La connexion à la file a été interrompue. Reconnexion…" + }, + "stats": { + "title": "Statistiques classées", + "subtitle": "Activité en direct et historique du matchmaking classé.", + "loading": "Chargement des statistiques classées…", + "error": "Impossible de charger les statistiques classées. Nouvelle tentative…", + "refresh-error": "La dernière actualisation a échoué. Les valeurs précédentes restent affichées.", + "updated": "Mis à jour le {{time}}", + "indicators": { + "players": "Joueurs classés", + "matches": "Parties jouées", + "wins": "Victoires des joueurs", + "draws": "Égalités des joueurs", + "losses": "Défaites des joueurs", + "average-elo-gain": "Gain Elo moyen", + "active-players": "En train de jouer", + "queued-players": "Dans la file" + }, + "graphs": { + "players": "Joueurs classés · 30 derniers jours", + "players-value": "Joueurs", + "queue": "Taille de la file · Pic sur 15 min · 24 dernières heures", + "queue-value": "Joueurs dans la file" + } } }, "guide": { diff --git a/apps/front/src/translations/resources/zh-tw.json b/apps/front/src/translations/resources/zh-tw.json index c7b240bf..74685705 100644 --- a/apps/front/src/translations/resources/zh-tw.json +++ b/apps/front/src/translations/resources/zh-tw.json @@ -64,6 +64,30 @@ "decline-error": "無法拒絕配對,請再試一次。", "join-error": "無法加入排名配對,正在重試…", "connection-error": "配對連線中斷,正在重新連線…" + }, + "stats": { + "title": "排名統計", + "subtitle": "排名配對的即時與歷史活動。", + "loading": "正在載入排名統計…", + "error": "無法載入排名統計,正在重試…", + "refresh-error": "最新資料更新失敗,目前仍顯示先前的數值。", + "updated": "更新時間:{{time}}", + "indicators": { + "players": "排名玩家", + "matches": "已完成對局", + "wins": "玩家勝場", + "draws": "玩家平手", + "losses": "玩家敗場", + "average-elo-gain": "平均 Elo 增益", + "active-players": "目前遊玩中", + "queued-players": "目前排隊中" + }, + "graphs": { + "players": "排名玩家 · 最近 30 天", + "players-value": "玩家", + "queue": "排隊人數 · 每 15 分鐘峰值 · 最近 24 小時", + "queue-value": "排隊玩家" + } } }, "guide": { diff --git a/apps/front/src/utils/api.ts b/apps/front/src/utils/api.ts index 4d6c4b3c..3b7541f0 100644 --- a/apps/front/src/utils/api.ts +++ b/apps/front/src/utils/api.ts @@ -15,9 +15,11 @@ import { type PlayerIdentityBootstrap, playerIdentityBootstrapSchema, type RankedProfile, + type RankedStats, type RankedRematchStatus, rankedRematchStatusSchema, rankedProfileSchema, + rankedStatsSchema, type WebSocketTicket, webSocketTicketSchema } from '@knucklebones/common' @@ -156,6 +158,22 @@ export async function getRankedProfile(): Promise { return result.data } +export async function getRankedStats(): Promise { + const response = await sendApiRequest( + '/v1/ranked/stats', + 'GET', + undefined, + null + ) + const result = rankedStatsSchema.safeParse(await response.json()) + + if (!result.success) { + throw new Error('The server returned invalid ranked statistics.') + } + + return result.data +} + export async function joinMatchmaking(): Promise { return await getMatchmakingResponse('/v1/matchmaking/join', 'POST') } diff --git a/apps/worker/migrations/0008_create_ranked_queue_samples.sql b/apps/worker/migrations/0008_create_ranked_queue_samples.sql new file mode 100644 index 00000000..3d8c7957 --- /dev/null +++ b/apps/worker/migrations/0008_create_ranked_queue_samples.sql @@ -0,0 +1,9 @@ +CREATE TABLE ranked_queue_samples ( + queue_key TEXT NOT NULL, + sampled_at INTEGER NOT NULL, + queued_players INTEGER NOT NULL CHECK (queued_players >= 0), + PRIMARY KEY (queue_key, sampled_at) +); + +CREATE INDEX ranked_queue_samples_history + ON ranked_queue_samples (queue_key, sampled_at DESC); diff --git a/apps/worker/src/durable-objects/MatchmakingDurableObject.ts b/apps/worker/src/durable-objects/MatchmakingDurableObject.ts index 5dd40bd2..f87c542e 100644 --- a/apps/worker/src/durable-objects/MatchmakingDurableObject.ts +++ b/apps/worker/src/durable-objects/MatchmakingDurableObject.ts @@ -3,6 +3,7 @@ import { DEFAULT_RATING_POOL, type MatchmakingPopulation, type MatchmakingStatus, + matchmakingPopulationSchema, matchmakingStatusSchema, matchIdSchema, playerIdSchema, @@ -23,6 +24,7 @@ import { releaseRankedMatch, reserveRankedMatch } from '../utils/rankedMatches' +import { recordRankedQueueSize } from '../utils/rankedStats' const MATCHMAKING_STATE_KEY = 'matchmaking-state' const RATING_SELECTION_WINDOW_MS = 500 @@ -56,6 +58,7 @@ export class MatchmakingDurableObject { cloudflareEnvironment: CloudflareEnvironment sentry: Toucan activePlayerCountCache?: { value: number; expiresAt: number } + lastRecordedQueueSize?: number constructor( state: DurableObjectState, @@ -74,6 +77,17 @@ export class MatchmakingDurableObject { try { const url = new URL(request.url) + if (request.method === 'GET' && url.pathname === '/population') { + const now = Date.now() + const state = await this.getActiveState(now) + await this.persistState(state, now) + return Response.json( + matchmakingPopulationSchema.parse( + await this.getPopulation(state, now) + ), + { headers: { 'Cache-Control': 'no-store' } } + ) + } const playerId = request.headers.get('X-Player-Id') const parsedPlayerId = playerIdSchema.safeParse(playerId) @@ -694,6 +708,19 @@ export class MatchmakingDurableObject { now: number ): Promise { await this.state.storage.put(MATCHMAKING_STATE_KEY, state) + if (this.lastRecordedQueueSize !== state.waiting.length) { + try { + await recordRankedQueueSize( + this.cloudflareEnvironment.PLAYERS_DB, + RANKED_QUEUE_KEY, + state.waiting.length, + now + ) + this.lastRecordedQueueSize = state.waiting.length + } catch (error) { + this.sentry.captureException(error) + } + } const nextAlarmAt = this.getNextAlarmAt(state, now) if (nextAlarmAt === undefined) { await this.state.storage.deleteAlarm() diff --git a/apps/worker/src/endpoints/getRankedStats.ts b/apps/worker/src/endpoints/getRankedStats.ts new file mode 100644 index 00000000..bb4eb5bc --- /dev/null +++ b/apps/worker/src/endpoints/getRankedStats.ts @@ -0,0 +1,199 @@ +import { + DEFAULT_RATING_POOL, + matchmakingPopulationSchema, + RANKED_QUEUE_KEY, + rankedStatsSchema, + type RankedStatsTimePoint +} from '@knucklebones/common' +import { type CloudflareEnvironment } from '../types/cloudflareEnvironment' + +const DAY_MS = 24 * 60 * 60 * 1_000 +const PLAYER_HISTORY_DAYS = 30 +const QUEUE_HISTORY_MS = DAY_MS +const QUEUE_BUCKET_MS = 15 * 60 * 1_000 + +interface ProfileTotalsRow { + players: number + wins: number + draws: number + losses: number +} + +interface MatchTotalsRow { + matches: number + average_elo_gain: number +} + +interface CountRow { + count: number +} + +interface TimeCountRow { + timestamp: number + count: number +} + +interface QueueSampleRow { + timestamp: number + value: number +} + +export async function getRankedStats( + _request: Request, + cloudflareEnvironment: CloudflareEnvironment +): Promise { + const now = Date.now() + const firstPlayerDay = startOfUtcDay(now) - (PLAYER_HISTORY_DAYS - 1) * DAY_MS + const queueSince = now - QUEUE_HISTORY_MS + const database = cloudflareEnvironment.PLAYERS_DB + + const [ + profileTotals, + matchTotals, + earlierPlayers, + playerRows, + queueRows, + population + ] = await Promise.all([ + database + .prepare( + `SELECT COUNT(*) AS players, + COALESCE(SUM(wins), 0) AS wins, + COALESCE(SUM(draws), 0) AS draws, + COALESCE(SUM(losses), 0) AS losses + FROM player_ratings + WHERE rating_pool = ?` + ) + .bind(DEFAULT_RATING_POOL) + .first(), + database + .prepare( + `SELECT + COALESCE(SUM(CASE WHEN result <> 'no-contest' THEN 1 ELSE 0 END), 0) + AS matches, + COALESCE(AVG(CASE WHEN result <> 'no-contest' AND rating_delta <> 0 + THEN ABS(rating_delta) END), 0) AS average_elo_gain + FROM rated_matches + WHERE rating_pool = ?` + ) + .bind(DEFAULT_RATING_POOL) + .first(), + database + .prepare('SELECT COUNT(*) AS count FROM players WHERE created_at < ?') + .bind(firstPlayerDay) + .first(), + database + .prepare( + `SELECT CAST(created_at / ? AS INTEGER) * ? AS timestamp, + COUNT(*) AS count + FROM players + WHERE created_at >= ? + GROUP BY timestamp + ORDER BY timestamp` + ) + .bind(DAY_MS, DAY_MS, firstPlayerDay) + .all(), + database + .prepare( + `SELECT CAST(sampled_at / ? AS INTEGER) * ? AS timestamp, + MAX(queued_players) AS value + FROM ranked_queue_samples + WHERE queue_key = ? AND sampled_at >= ? + GROUP BY timestamp + ORDER BY timestamp` + ) + .bind(QUEUE_BUCKET_MS, QUEUE_BUCKET_MS, RANKED_QUEUE_KEY, queueSince) + .all(), + getCurrentPopulation(cloudflareEnvironment) + ]) + + if ( + profileTotals === null || + matchTotals === null || + earlierPlayers === null + ) { + throw new Error('The ranked statistics query returned no result.') + } + + const response = rankedStatsSchema.parse({ + generatedAt: now, + totals: { + players: profileTotals.players, + matches: matchTotals.matches, + wins: profileTotals.wins, + draws: profileTotals.draws, + losses: profileTotals.losses, + averageEloGain: matchTotals.average_elo_gain + }, + current: population, + history: { + players: buildPlayerHistory( + firstPlayerDay, + earlierPlayers.count, + playerRows.results + ), + queue: appendCurrentQueueSample( + queueRows.results, + now, + population.queuedPlayers + ) + } + }) + + return Response.json(response, { + headers: { 'Cache-Control': 'public, max-age=15' } + }) +} + +async function getCurrentPopulation( + cloudflareEnvironment: CloudflareEnvironment +) { + const id = + cloudflareEnvironment.MATCHMAKING_DURABLE_OBJECT.idFromName( + RANKED_QUEUE_KEY + ) + const matchmaking = cloudflareEnvironment.MATCHMAKING_DURABLE_OBJECT.get(id) + const response = await matchmaking.fetch('https://dummy-url/population') + if (!response.ok) { + throw new Error('The matchmaker rejected the population request.') + } + return matchmakingPopulationSchema.parse(await response.json()) +} + +function buildPlayerHistory( + firstDay: number, + earlierPlayers: number, + rows: TimeCountRow[] +): RankedStatsTimePoint[] { + const counts = new Map(rows.map((row) => [row.timestamp, row.count])) + let total = earlierPlayers + + return Array.from({ length: PLAYER_HISTORY_DAYS }, (_, index) => { + const timestamp = firstDay + index * DAY_MS + total += counts.get(timestamp) ?? 0 + return { timestamp, value: total } + }) +} + +function appendCurrentQueueSample( + rows: QueueSampleRow[], + now: number, + queuedPlayers: number +): RankedStatsTimePoint[] { + const history = rows.map(({ timestamp, value }) => ({ timestamp, value })) + const currentBucket = Math.floor(now / QUEUE_BUCKET_MS) * QUEUE_BUCKET_MS + const last = history.at(-1) + + if (last?.timestamp === currentBucket) { + last.value = Math.max(last.value, queuedPlayers) + } else { + history.push({ timestamp: currentBucket, value: queuedPlayers }) + } + + return history +} + +function startOfUtcDay(timestamp: number): number { + const date = new Date(timestamp) + return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()) +} diff --git a/apps/worker/src/endpoints/index.ts b/apps/worker/src/endpoints/index.ts index 75edbc87..f980f703 100644 --- a/apps/worker/src/endpoints/index.ts +++ b/apps/worker/src/endpoints/index.ts @@ -2,6 +2,7 @@ export * from './clientProtocolDiagnostic' export * from './createPlayer' export * from './displayName' export * from './getRankedProfile' +export * from './getRankedStats' export * from './init' export * from './matchmaking' export * from './play' diff --git a/apps/worker/src/utils/rankedStats.ts b/apps/worker/src/utils/rankedStats.ts new file mode 100644 index 00000000..4751faaf --- /dev/null +++ b/apps/worker/src/utils/rankedStats.ts @@ -0,0 +1,30 @@ +const QUEUE_SAMPLE_RETENTION_MS = 7 * 24 * 60 * 60 * 1_000 + +export async function recordRankedQueueSize( + database: D1Database, + queueKey: string, + queuedPlayers: number, + sampledAt = Date.now() +): Promise { + if (!Number.isSafeInteger(queuedPlayers) || queuedPlayers < 0) { + throw new Error('The ranked queue size is invalid.') + } + + await database.batch([ + database + .prepare( + `INSERT INTO ranked_queue_samples + (queue_key, sampled_at, queued_players) + VALUES (?, ?, ?) + ON CONFLICT (queue_key, sampled_at) + DO UPDATE SET queued_players = excluded.queued_players` + ) + .bind(queueKey, sampledAt, queuedPlayers), + database + .prepare( + `DELETE FROM ranked_queue_samples + WHERE queue_key = ? AND sampled_at < ?` + ) + .bind(queueKey, sampledAt - QUEUE_SAMPLE_RETENTION_MS) + ]) +} diff --git a/apps/worker/src/workers/index.ts b/apps/worker/src/workers/index.ts index 55173aa8..592f09aa 100644 --- a/apps/worker/src/workers/index.ts +++ b/apps/worker/src/workers/index.ts @@ -9,6 +9,7 @@ import { deleteDisplayName, displayName, getRankedProfile, + getRankedStats, getRankedRematchStatus, getMatchmakingStatus, init, @@ -72,6 +73,7 @@ router .post('/v1/identity/credentials/rotate', rotateDeviceCredential) .post('/v1/identity/credentials/revoke-others', revokeOtherDeviceCredentials) .delete('/v1/identity/credentials/:credentialId', revokeDeviceCredential) + .get('/v1/ranked/stats', getRankedStats) .all('/v1/ranked/*', authenticatePlayerRequest) .get('/v1/ranked/profile', getRankedProfile) .all('/v1/matchmaking/*', authenticatePlayerRequest) diff --git a/apps/worker/test/worker.integration.test.ts b/apps/worker/test/worker.integration.test.ts index 0a2baec3..88c459c6 100644 --- a/apps/worker/test/worker.integration.test.ts +++ b/apps/worker/test/worker.integration.test.ts @@ -13,6 +13,7 @@ import { type PresenceUpdateResult, rankedMatchSettlementResultSchema, rankedProfileSchema, + rankedStatsSchema, webSocketTicketSchema, type PlayerCredentials } from '@knucklebones/common' @@ -2381,3 +2382,31 @@ describe('ranked matchmaking', () => { }) }) }) + +describe('public ranked statistics', () => { + it('reports player and queue activity without authentication', async () => { + const player = await createPlayer() + const join = await request('/v1/matchmaking/join', { + method: 'POST', + headers: authorization(player) + }) + expect(join.status).toBe(200) + + const response = await request('/v1/ranked/stats') + expect(response.status).toBe(200) + const stats = rankedStatsSchema.parse(await response.json()) + + expect(stats.totals).toMatchObject({ + players: 1, + matches: 0, + wins: 0, + draws: 0, + losses: 0, + averageEloGain: 0 + }) + expect(stats.current).toEqual({ activePlayers: 0, queuedPlayers: 1 }) + expect(stats.history.players).toHaveLength(30) + expect(stats.history.players.at(-1)?.value).toBe(1) + expect(stats.history.queue.at(-1)?.value).toBe(1) + }) +}) diff --git a/packages/common/src/schemas/index.ts b/packages/common/src/schemas/index.ts index 69339ebd..722837cc 100644 --- a/packages/common/src/schemas/index.ts +++ b/packages/common/src/schemas/index.ts @@ -6,3 +6,4 @@ export * from './identifiers' export * from './matchmaking' export * from './playerIdentity' export * from './ranking' +export * from './rankedStats' diff --git a/packages/common/src/schemas/matchmaking.ts b/packages/common/src/schemas/matchmaking.ts index bb54d62a..10eba020 100644 --- a/packages/common/src/schemas/matchmaking.ts +++ b/packages/common/src/schemas/matchmaking.ts @@ -1,6 +1,7 @@ import { z } from 'zod/mini' import { DEFAULT_RATING_POOL, + type MatchmakingPopulation, type MatchmakingStatus, RANKED_MATCH_FORMAT, RANKED_QUEUE_KEY, @@ -25,17 +26,17 @@ export const rankedMatchAssignmentSchema = z.object({ expiresAt: z.int().check(z.minimum(0)) }) +export const matchmakingPopulationSchema = z.object({ + queuedPlayers: z.int().check(z.minimum(0)), + activePlayers: z.int().check(z.minimum(0)) +}) satisfies z.ZodMiniType + export const matchmakingStatusSchema = z.union([ z.object({ status: z.literal('idle') }), z.object({ status: z.literal('waiting'), joinedAt: z.int().check(z.minimum(0)), - population: z.optional( - z.object({ - queuedPlayers: z.int().check(z.minimum(0)), - activePlayers: z.int().check(z.minimum(0)) - }) - ) + population: z.optional(matchmakingPopulationSchema) }), z.object({ status: z.literal('match-found'), diff --git a/packages/common/src/schemas/rankedStats.ts b/packages/common/src/schemas/rankedStats.ts new file mode 100644 index 00000000..29282dea --- /dev/null +++ b/packages/common/src/schemas/rankedStats.ts @@ -0,0 +1,28 @@ +import { z } from 'zod/mini' +import { type RankedStats } from '../types' + +const nonNegativeIntegerSchema = z.int().check(z.minimum(0)) +const rankedStatsTimePointSchema = z.object({ + timestamp: nonNegativeIntegerSchema, + value: nonNegativeIntegerSchema +}) + +export const rankedStatsSchema = z.object({ + generatedAt: nonNegativeIntegerSchema, + totals: z.object({ + players: nonNegativeIntegerSchema, + matches: nonNegativeIntegerSchema, + wins: nonNegativeIntegerSchema, + draws: nonNegativeIntegerSchema, + losses: nonNegativeIntegerSchema, + averageEloGain: z.number().check(z.minimum(0)) + }), + current: z.object({ + activePlayers: nonNegativeIntegerSchema, + queuedPlayers: nonNegativeIntegerSchema + }), + history: z.object({ + players: z.array(rankedStatsTimePointSchema), + queue: z.array(rankedStatsTimePointSchema) + }) +}) satisfies z.ZodMiniType diff --git a/packages/common/src/types/index.ts b/packages/common/src/types/index.ts index aeaeb300..913a0506 100644 --- a/packages/common/src/types/index.ts +++ b/packages/common/src/types/index.ts @@ -6,3 +6,4 @@ export * from './play' export * from './playerIdentity' export * from './protocol' export * from './ranking' +export * from './rankedStats' diff --git a/packages/common/src/types/rankedStats.ts b/packages/common/src/types/rankedStats.ts new file mode 100644 index 00000000..83f4ff7b --- /dev/null +++ b/packages/common/src/types/rankedStats.ts @@ -0,0 +1,24 @@ +export interface RankedStatsTimePoint { + timestamp: number + value: number +} + +export interface RankedStats { + generatedAt: number + totals: { + players: number + matches: number + wins: number + draws: number + losses: number + averageEloGain: number + } + current: { + activePlayers: number + queuedPlayers: number + } + history: { + players: RankedStatsTimePoint[] + queue: RankedStatsTimePoint[] + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc273e07..01d0ab7f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -152,6 +152,9 @@ importers: react-use-websocket: specifier: ^4.13.0 version: 4.13.0 + recharts: + specifier: ^3.10.1 + version: 3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@17.0.2)(react@19.2.8)(redux@5.0.1) unique-names-generator: specifier: ^4.7.1 version: 4.7.1 @@ -1027,6 +1030,17 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + '@reduxjs/toolkit@2.12.0': + resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==} + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 || ^19 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + '@rolldown/binding-android-arm64@1.2.1': resolution: {integrity: sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1146,6 +1160,9 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@swc/core-darwin-arm64@1.15.47': resolution: {integrity: sha512-GsoMtan3ojGGMGFbl31mmRu5ctZ56re8grGE8mO/OHJ8O+JRkzod02fe7X6ZQ8JvamA3imkEkx/h3u+vsOgPgA==} engines: {node: '>=10'} @@ -1415,6 +1432,33 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -1438,6 +1482,9 @@ packages: '@types/react@19.2.18': resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@typescript-eslint/eslint-plugin@8.65.0': resolution: {integrity: sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1912,6 +1959,50 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + data-urls@7.0.0: resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -1945,6 +2036,9 @@ packages: supports-color: optional: true + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} @@ -2030,6 +2124,9 @@ packages: resolution: {integrity: sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==} engines: {node: '>= 0.4'} + es-toolkit@1.50.0: + resolution: {integrity: sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==} + esbuild@0.28.1: resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} engines: {node: '>=18'} @@ -2125,6 +2222,9 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + expect-type@1.4.0: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} @@ -2285,6 +2385,9 @@ packages: resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} engines: {node: '>= 4'} + immer@11.1.16: + resolution: {integrity: sha512-Xs7H9rBc+kti1J6RueUvbEBkmOz7jqj11XYgf+YMXAYzu8EeE7hwZ9poLXdVfVnGmJu7QAf41T7H2KuF6QoK6Q==} + imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -2297,6 +2400,10 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -2863,6 +2970,18 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-redux@9.3.0: + resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==} + peerDependencies: + '@types/react': ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true + react-router-dom@7.18.2: resolution: {integrity: sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==} engines: {node: '>=20.0.0'} @@ -2892,10 +3011,26 @@ packages: resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} engines: {node: '>=0.10.0'} + recharts@3.10.1: + resolution: {integrity: sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + redent@3.0.0: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + redux-thunk@3.1.0: + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 + + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -2908,6 +3043,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + reselect@5.2.0: + resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -3055,6 +3193,9 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -3181,6 +3322,9 @@ packages: resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} hasBin: true + victory-vendor@37.3.6: + resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + vite@8.2.0: resolution: {integrity: sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4095,6 +4239,18 @@ snapshots: dependencies: react: 19.2.8 + '@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1))(react@19.2.8)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@standard-schema/utils': 0.3.0 + immer: 11.1.16 + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.2.0 + optionalDependencies: + react: 19.2.8 + react-redux: 9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1) + '@rolldown/binding-android-arm64@1.2.1': optional: true @@ -4163,6 +4319,8 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@standard-schema/utils@0.3.0': {} + '@swc/core-darwin-arm64@1.15.47': optional: true @@ -4369,6 +4527,30 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/d3-array@3.2.2': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + '@types/deep-eql@4.0.2': {} '@types/esrecurse@4.3.1': {} @@ -4389,6 +4571,8 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/use-sync-external-store@0.0.6': {} + '@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2))(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -4793,6 +4977,44 @@ snapshots: csstype@3.2.3: {} + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + data-urls@7.0.0: dependencies: whatwg-mimetype: 5.0.0 @@ -4830,6 +5052,8 @@ snapshots: optionalDependencies: supports-color: 10.2.2 + decimal.js-light@2.5.1: {} + decimal.js@10.6.0: {} deep-is@0.1.4: {} @@ -4967,6 +5191,8 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + es-toolkit@1.50.0: {} + esbuild@0.28.1: optionalDependencies: '@esbuild/aix-ppc64': 0.28.1 @@ -5118,6 +5344,8 @@ snapshots: esutils@2.0.3: {} + eventemitter3@5.0.4: {} + expect-type@1.4.0: {} fast-deep-equal@3.1.3: {} @@ -5263,6 +5491,8 @@ snapshots: ignore@7.0.6: {} + immer@11.1.16: {} + imurmurhash@0.1.4: {} indent-string@4.0.0: {} @@ -5273,6 +5503,8 @@ snapshots: hasown: 2.0.4 side-channel: 1.1.1 + internmap@2.0.3: {} + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.9 @@ -5739,6 +5971,15 @@ snapshots: react-is@17.0.2: {} + react-redux@9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 19.2.8 + use-sync-external-store: 1.6.0(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + redux: 5.0.1 + react-router-dom@7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: react: 19.2.8 @@ -5767,11 +6008,37 @@ snapshots: react@19.2.8: {} + recharts@3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@17.0.2)(react@19.2.8)(redux@5.0.1): + dependencies: + '@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1))(react@19.2.8) + clsx: 2.1.1 + decimal.js-light: 2.5.1 + es-toolkit: 1.50.0 + eventemitter3: 5.0.4 + immer: 11.1.16 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react-is: 17.0.2 + react-redux: 9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1) + reselect: 5.2.0 + tiny-invariant: 1.3.3 + use-sync-external-store: 1.6.0(react@19.2.8) + victory-vendor: 37.3.6 + transitivePeerDependencies: + - '@types/react' + - redux + redent@3.0.0: dependencies: indent-string: 4.0.0 strip-indent: 3.0.0 + redux-thunk@3.1.0(redux@5.0.1): + dependencies: + redux: 5.0.1 + + redux@5.0.1: {} + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.9 @@ -5794,6 +6061,8 @@ snapshots: require-from-string@2.0.2: {} + reselect@5.2.0: {} + resolve-pkg-maps@1.0.0: {} resolve@2.0.0-next.7: @@ -6002,6 +6271,8 @@ snapshots: tapable@2.3.3: {} + tiny-invariant@1.3.3: {} + tinybench@2.9.0: {} tinyexec@1.2.4: {} @@ -6183,6 +6454,23 @@ snapshots: uuid@14.0.1: {} + victory-vendor@37.3.6: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0): dependencies: lightningcss: 1.33.0