From e87c61b5aa5542f865d7fe68395dc06b022425dd Mon Sep 17 00:00:00 2001 From: needs <624097+needs@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:34:11 +0200 Subject: [PATCH] Serve the daily players chart from a per-day totals table Counting PlayerDay rows per day meant a sequential scan of every monthly partition, so the home page's all-time range took ages. No index could have helped: Postgres has no index-skip-scan for aggregates, so even a covering index still walks every entry. Add a GlobalDay table holding one row per rolled-up day, written inside the rollup transaction and backfilled by the migration, and read it from the chart, the status page, and the backfill worker's rolled-up-day scan, which ran the same unfiltered groupBy on every tick. The API response now carries Cache-Control, so switching back to a range already viewed is served from the browser cache. Chart labels take the year once the range needs it: MMM d, then MMM d, yyyy across a year boundary, then MMM yyyy past a year. They are built from the UTC calendar day, which also fixes every label rendering a day early west of Greenwich. Co-Authored-By: Claude Opus 5 (1M context) --- apps/frontend/app/api/daily-players/route.ts | 9 ++++-- apps/frontend/app/status/page.tsx | 10 +++---- apps/frontend/components/Chart.tsx | 30 ++++++++++++++++--- apps/frontend/utils/dailyPlayers.ts | 18 ++++++----- apps/worker/src/rollup/writeDayRollup.ts | 6 ++++ apps/worker/src/workers/rollupBackfill.ts | 4 +-- apps/worker/src/workers/rollupDay.test.ts | 13 +++++--- apps/worker/src/workers/rollupDay.ts | 4 +-- .../20260824000000_global_day/migration.sql | 11 +++++++ libs/prisma/prisma/schema.prisma | 5 ++++ libs/teerank/src/lib/date.ts | 4 +++ 11 files changed, 87 insertions(+), 27 deletions(-) create mode 100644 libs/prisma/prisma/migrations/20260824000000_global_day/migration.sql diff --git a/apps/frontend/app/api/daily-players/route.ts b/apps/frontend/app/api/daily-players/route.ts index 0235378..77707db 100644 --- a/apps/frontend/app/api/daily-players/route.ts +++ b/apps/frontend/app/api/daily-players/route.ts @@ -1,8 +1,13 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getDailyPlayers } from '../../../utils/dailyPlayers'; +import { CACHE_SECONDS, getDailyPlayers } from '../../../utils/dailyPlayers'; export async function GET(request: NextRequest) { return NextResponse.json( - await getDailyPlayers(request.nextUrl.searchParams.get('range') ?? '90d') + await getDailyPlayers(request.nextUrl.searchParams.get('range') ?? '90d'), + { + headers: { + 'Cache-Control': `public, max-age=${CACHE_SECONDS}, stale-while-revalidate=86400`, + }, + } ); } diff --git a/apps/frontend/app/status/page.tsx b/apps/frontend/app/status/page.tsx index eb1f5e3..bca7630 100644 --- a/apps/frontend/app/status/page.tsx +++ b/apps/frontend/app/status/page.tsx @@ -44,7 +44,7 @@ export default async function Index() { lastArchiveSnapshotsDate, archiveSnapshotsFailedCount, rollupBounds, - rollupDays, + rollupDayCount, rollupDayFailedCount, rollupBackfillFailedCount, oldestSnapshot, @@ -59,13 +59,11 @@ export default async function Index() { getLastMapCountDate(), getLastArchiveSnapshotsDate(), getArchiveSnapshotsFailedCount(), - prisma.playerDay.aggregate({ + prisma.globalDay.aggregate({ _min: { day: true }, _max: { day: true }, }), - prisma.playerDay.groupBy({ - by: ['day'], - }), + prisma.globalDay.count(), getRollupDayFailedCount(), getRollupBackfillFailedCount(), prisma.gameServerSnapshot.findFirst({ @@ -164,7 +162,7 @@ export default async function Index() { ? 0 : Math.round((latestRollupDay.getTime() - oldestRollupDay.getTime()) / DAY_MS) + 1 - - rollupDays.length; + rollupDayCount; const rollupFailedCount = rollupDayFailedCount + rollupBackfillFailedCount; diff --git a/apps/frontend/components/Chart.tsx b/apps/frontend/components/Chart.tsx index e1f2dee..3ff81f6 100644 --- a/apps/frontend/components/Chart.tsx +++ b/apps/frontend/components/Chart.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { format } from 'date-fns'; +import { DAY_MS, localizeUtcDay } from '@teerank/teerank/date'; import { formatInteger } from '../utils/format'; export type ChartPoint = { @@ -15,6 +16,22 @@ const MARGIN = { top: 12, right: 12, bottom: 32, left: 90 }; const INNER_WIDTH = WIDTH - MARGIN.left - MARGIN.right; const INNER_HEIGHT = HEIGHT - MARGIN.top - MARGIN.bottom; +function axisDatePattern(dates: Date[]) { + const span = (dates[dates.length - 1].getTime() - dates[0].getTime()) / DAY_MS; + + if (span > 366) { + return 'MMM yyyy'; + } + + return dates[0].getUTCFullYear() === dates[dates.length - 1].getUTCFullYear() + ? 'MMM d' + : 'MMM d, yyyy'; +} + +function formatUtcDate(date: Date, pattern: string) { + return format(localizeUtcDay(date), pattern); +} + function niceCeiling(value: number) { if (value <= 0) { return 1; @@ -120,8 +137,8 @@ function EmptyChart({ label }: { label: string }) { export function BarChart({ points, - formatDate = (date) => format(date, 'MMM d'), - formatTooltipDate = formatDate, + formatDate, + formatTooltipDate, formatValue = formatInteger, emptyLabel = 'No data yet', fontSize = 14, @@ -151,6 +168,11 @@ export function BarChart({ return ; } + const dates = points.map((point) => point.date); + const axisDate = formatDate ?? ((date: Date) => formatUtcDate(date, axisDatePattern(dates))); + const tooltipDate = + formatTooltipDate ?? ((date: Date) => formatUtcDate(date, 'MMM d, yyyy')); + const max = niceCeiling(Math.max(...values)); const scaleY = (value: number) => MARGIN.top + INNER_HEIGHT * (1 - value / max); const barWidth = (INNER_WIDTH / points.length) * 0.7; @@ -164,7 +186,7 @@ export function BarChart({ role="img" > - point.date)} formatDate={formatDate} fontSize={fontSize} /> + {points.map((point, index) => point.value === null ? null : ( @@ -179,7 +201,7 @@ export function BarChart({ strokeWidth="1" className="stroke-transparent transition-[fill-opacity] hover:[fill-opacity:1] hover:stroke-[#00000059]" onMouseEnter={(event) => - showTooltip(event, `${formatValue(point.value as number)} on ${formatTooltipDate(point.date)}`) + showTooltip(event, `${formatValue(point.value as number)} on ${tooltipDate(point.date)}`) } onMouseLeave={hideTooltip} /> diff --git a/apps/frontend/utils/dailyPlayers.ts b/apps/frontend/utils/dailyPlayers.ts index 6acf5b3..7588f06 100644 --- a/apps/frontend/utils/dailyPlayers.ts +++ b/apps/frontend/utils/dailyPlayers.ts @@ -17,13 +17,15 @@ const PRESET_DAYS: Record = { '1y': 365, }; +export const CACHE_SECONDS = 3600; + export const getDailyPlayers = unstable_cache( async (range: string): Promise => { const to = utcYesterday(); let from: Date; if (range === 'all') { - const result = await prisma.playerDay.aggregate({ _min: { day: true } }); + const result = await prisma.globalDay.aggregate({ _min: { day: true } }); const minFrom = addUtcDays(to, -(MAX_SPAN_DAYS - 1)); const minDay = result._min.day; from = minDay === null || minDay > to ? to : minDay < minFrom ? minFrom : minDay; @@ -32,10 +34,9 @@ export const getDailyPlayers = unstable_cache( from = addUtcDays(to, -(PRESET_DAYS[range] - 1)); } - const rows = await prisma.playerDay.groupBy({ - by: ['day'], - where: { day: { gte: from } }, - _count: { _all: true }, + const rows = await prisma.globalDay.findMany({ + where: { day: { gte: from, lte: to } }, + select: { day: true, playerCount: true }, orderBy: { day: 'asc' }, }); @@ -43,9 +44,12 @@ export const getDailyPlayers = unstable_cache( range, from: formatUtcDay(from), to: formatUtcDay(to), - days: rows.map(({ day, _count }) => ({ day: formatUtcDay(day), players: _count._all })), + days: rows.map(({ day, playerCount }) => ({ + day: formatUtcDay(day), + players: playerCount, + })), }; }, ['home-daily-players'], - { revalidate: 3600 } + { revalidate: CACHE_SECONDS } ); diff --git a/apps/worker/src/rollup/writeDayRollup.ts b/apps/worker/src/rollup/writeDayRollup.ts index 38f2b37..053ce64 100644 --- a/apps/worker/src/rollup/writeDayRollup.ts +++ b/apps/worker/src/rollup/writeDayRollup.ts @@ -128,6 +128,12 @@ export async function writeDayRollup(day: Date, rollup: DayRollup) { await prisma.$transaction( async (tx) => { + await tx.globalDay.upsert({ + where: { day }, + create: { day, playerCount: playerRows.length }, + update: { playerCount: playerRows.length }, + }); + await tx.playerDay.deleteMany({ where: { day } }); await tx.serverDay.deleteMany({ where: { day } }); await tx.mapDay.deleteMany({ where: { day } }); diff --git a/apps/worker/src/workers/rollupBackfill.ts b/apps/worker/src/workers/rollupBackfill.ts index 71ea3d5..f364205 100644 --- a/apps/worker/src/workers/rollupBackfill.ts +++ b/apps/worker/src/workers/rollupBackfill.ts @@ -50,8 +50,8 @@ async function listMissingArchivedDays() { return dayEndMs + hoursToMilliseconds(SNAPSHOT_RETENTION_HOURS) <= Date.now(); }); - const rolledUpDays = await prisma.playerDay.groupBy({ - by: ['day'], + const rolledUpDays = await prisma.globalDay.findMany({ + select: { day: true }, }); const rolledUp = new Set(rolledUpDays.map(({ day }) => formatUtcDay(day))); diff --git a/apps/worker/src/workers/rollupDay.test.ts b/apps/worker/src/workers/rollupDay.test.ts index 5cc0fe6..2b38558 100644 --- a/apps/worker/src/workers/rollupDay.test.ts +++ b/apps/worker/src/workers/rollupDay.test.ts @@ -127,7 +127,7 @@ describe('rollupDay', () => { }; test('skips a day that is already rolled up', async () => { - prismaMock.playerDay.findFirst.mockResolvedValue({ playerId: 1 } as never); + prismaMock.globalDay.findUnique.mockResolvedValue({ day } as never); await rollupDay({ day: '2026-08-19' }); @@ -140,12 +140,12 @@ describe('rollupDay', () => { await rollupDay({ day: today }); - expect(prismaMock.playerDay.findFirst).not.toHaveBeenCalled(); + expect(prismaMock.globalDay.findUnique).not.toHaveBeenCalled(); expect(prismaMock.gameServerSnapshot.findMany).not.toHaveBeenCalled(); }); test('aggregates a day and writes all five tables', async () => { - prismaMock.playerDay.findFirst.mockResolvedValue(null); + prismaMock.globalDay.findUnique.mockResolvedValue(null); prismaMock.gameServerSnapshot.findMany.mockResolvedValue([ { id: 1, @@ -161,6 +161,11 @@ describe('rollupDay', () => { await rollupDay({ day: '2026-08-19' }); + expect(prismaMock.globalDay.upsert).toHaveBeenCalledWith({ + where: { day }, + create: { day, playerCount: 1 }, + update: { playerCount: 1 }, + }); expect(prismaMock.playerDay.deleteMany).toHaveBeenCalledWith({ where: { day } }); expect(prismaMock.serverDay.deleteMany).toHaveBeenCalledWith({ where: { day } }); @@ -182,7 +187,7 @@ describe('rollupDay', () => { }); test('players that no longer exist are dropped', async () => { - prismaMock.playerDay.findFirst.mockResolvedValue(null); + prismaMock.globalDay.findUnique.mockResolvedValue(null); prismaMock.gameServerSnapshot.findMany.mockResolvedValue([ { id: 1, diff --git a/apps/worker/src/workers/rollupDay.ts b/apps/worker/src/workers/rollupDay.ts index b67fafe..c1799c6 100644 --- a/apps/worker/src/workers/rollupDay.ts +++ b/apps/worker/src/workers/rollupDay.ts @@ -14,9 +14,9 @@ const ROLLUP_BATCH_SIZE = getEnvInt('ROLLUP_BATCH_SIZE', 2000); const ROLLUP_TIME_BUDGET_MS = getEnvInt('ROLLUP_TIME_BUDGET_MS', 10 * 60 * 1000); export async function isDayRolledUp(day: Date) { - const existing = await prisma.playerDay.findFirst({ + const existing = await prisma.globalDay.findUnique({ where: { day }, - select: { playerId: true }, + select: { day: true }, }); return existing !== null; diff --git a/libs/prisma/prisma/migrations/20260824000000_global_day/migration.sql b/libs/prisma/prisma/migrations/20260824000000_global_day/migration.sql new file mode 100644 index 0000000..9a9f642 --- /dev/null +++ b/libs/prisma/prisma/migrations/20260824000000_global_day/migration.sql @@ -0,0 +1,11 @@ +-- CreateTable +CREATE TABLE "GlobalDay" ( + "day" DATE NOT NULL, + "playerCount" INTEGER NOT NULL, + + CONSTRAINT "GlobalDay_pkey" PRIMARY KEY ("day") +); + +-- Backfill the days already rolled up. +INSERT INTO "GlobalDay" ("day", "playerCount") +SELECT "day", count(*)::int FROM "PlayerDay" GROUP BY "day"; diff --git a/libs/prisma/prisma/schema.prisma b/libs/prisma/prisma/schema.prisma index 2d0c901..206a1a0 100644 --- a/libs/prisma/prisma/schema.prisma +++ b/libs/prisma/prisma/schema.prisma @@ -325,6 +325,11 @@ model GlobalCounts { // tables are partitioned by range on day/hour (see the migration); every // primary key leads with the partition key. +model GlobalDay { + day DateTime @id @db.Date + playerCount Int +} + model PlayerDay { day DateTime @db.Date playerId Int diff --git a/libs/teerank/src/lib/date.ts b/libs/teerank/src/lib/date.ts index 3a93260..a5bbece 100644 --- a/libs/teerank/src/lib/date.ts +++ b/libs/teerank/src/lib/date.ts @@ -22,6 +22,10 @@ export function utcYesterday() { return addUtcDays(startOfUtcDay(new Date()), -1); } +export function localizeUtcDay(date: Date) { + return new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()); +} + export function eachUtcDay(from: Date, to: Date) { const count = Math.round((to.getTime() - from.getTime()) / DAY_MS) + 1;