Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions apps/frontend/app/api/daily-players/route.ts
Original file line number Diff line number Diff line change
@@ -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`,
},
}
);
}
10 changes: 4 additions & 6 deletions apps/frontend/app/status/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export default async function Index() {
lastArchiveSnapshotsDate,
archiveSnapshotsFailedCount,
rollupBounds,
rollupDays,
rollupDayCount,
rollupDayFailedCount,
rollupBackfillFailedCount,
oldestSnapshot,
Expand All @@ -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({
Expand Down Expand Up @@ -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;

Expand Down
30 changes: 26 additions & 4 deletions apps/frontend/components/Chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -151,6 +168,11 @@ export function BarChart({
return <EmptyChart label={emptyLabel} />;
}

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;
Expand All @@ -164,7 +186,7 @@ export function BarChart({
role="img"
>
<YAxis ticks={[0, max / 2, max]} scaleY={scaleY} formatValue={formatValue} fontSize={fontSize} />
<XAxis dates={points.map((point) => point.date)} formatDate={formatDate} fontSize={fontSize} />
<XAxis dates={dates} formatDate={axisDate} fontSize={fontSize} />

{points.map((point, index) =>
point.value === null ? null : (
Expand All @@ -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}
/>
Expand Down
18 changes: 11 additions & 7 deletions apps/frontend/utils/dailyPlayers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@ const PRESET_DAYS: Record<string, number> = {
'1y': 365,
};

export const CACHE_SECONDS = 3600;

export const getDailyPlayers = unstable_cache(
async (range: string): Promise<DailyPlayersPayload> => {
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;
Expand All @@ -32,20 +34,22 @@ 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' },
});

return {
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 }
);
6 changes: 6 additions & 0 deletions apps/worker/src/rollup/writeDayRollup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } });
Expand Down
4 changes: 2 additions & 2 deletions apps/worker/src/workers/rollupBackfill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)));

Expand Down
13 changes: 9 additions & 4 deletions apps/worker/src/workers/rollupDay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });

Expand All @@ -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,
Expand All @@ -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 } });

Expand All @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions apps/worker/src/workers/rollupDay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
5 changes: 5 additions & 0 deletions libs/prisma/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions libs/teerank/src/lib/date.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Loading