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;