first reading the start of a map with a 3 minute delay to overcome the transition period from the previous map

This commit is contained in:
jenz
2026-10-04 20:50:33 +02:00
parent 7a6051987e
commit d64f8c3f29
5 changed files with 32 additions and 5 deletions
@@ -2,6 +2,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useContainerWidth } from '@/lib/useContainerWidth'; import { useContainerWidth } from '@/lib/useContainerWidth';
import { START_BASELINE_MIN, START_BASELINE_MS } from '@/lib/populationBaseline';
interface Point { interface Point {
t: string; t: string;
@@ -91,7 +92,18 @@ export default function MapSessionTimelineChart({ mapId }: { mapId: number }) {
// right at map start vs. right at map end (or "now" if still running) — // right at map start vs. right at map end (or "now" if still running) —
// same definition as the dumbbell chart, found by nearest bucket to each // same definition as the dumbbell chart, found by nearest bucket to each
// boundary rather than a separate query. // boundary rather than a separate query.
const startPoint = mapStart != null ? findNearestPoint(xForEpoch(mapStart)) : null; //
// The "start" reading is taken START_BASELINE_MS after the map begins, not
// at the exact change instant: right at the change the population is still
// the previous map's (people leaving/reconnecting), so reading it at t=0
// would blame this map for the previous one's attrition. Clamped to the
// map's end (or latest point, if still running) so short maps still work.
const lastPointEpoch = points.length > 0 ? points[points.length - 1].tEpoch : null;
const baselineEpoch =
mapStart != null
? Math.min(mapStart + START_BASELINE_MS, mapEnd ?? lastPointEpoch ?? mapStart + START_BASELINE_MS)
: null;
const startPoint = baselineEpoch != null ? findNearestPoint(xForEpoch(baselineEpoch)) : null;
const endPoint = mapEnd != null ? findNearestPoint(xForEpoch(mapEnd)) : points[points.length - 1] ?? null; const endPoint = mapEnd != null ? findNearestPoint(xForEpoch(mapEnd)) : points[points.length - 1] ?? null;
const delta = startPoint && endPoint ? endPoint.players - startPoint.players : null; const delta = startPoint && endPoint ? endPoint.players - startPoint.players : null;
const deltaColor = delta == null ? '#8A9691' : delta > 0 ? '#3FD37A' : delta < 0 ? '#E0665C' : '#8A9691'; const deltaColor = delta == null ? '#8A9691' : delta > 0 ? '#3FD37A' : delta < 0 ? '#E0665C' : '#8A9691';
@@ -105,7 +117,7 @@ export default function MapSessionTimelineChart({ mapId }: { mapId: number }) {
<h2 className="text-lg text-ink">Population during this map</h2> <h2 className="text-lg text-ink">Population during this map</h2>
{startPoint && endPoint && ( {startPoint && endPoint && (
<p className="text-sm text-ink-muted mt-1"> <p className="text-sm text-ink-muted mt-1">
Started with <span className="text-ink">{startPoint.players}</span> players, ended with{' '} Had <span className="text-ink">{startPoint.players}</span> players {START_BASELINE_MIN} min in, ended with{' '}
<span className="text-ink">{endPoint.players}</span> <span className="text-ink">{endPoint.players}</span>
<span style={{ color: deltaColor }}> <span style={{ color: deltaColor }}>
{' '} {' '}
@@ -1,5 +1,6 @@
import { query } from '@/lib/db'; import { query } from '@/lib/db';
import { parseDbDate } from '@/lib/dates'; import { parseDbDate } from '@/lib/dates';
import { START_BASELINE_MS } from '@/lib/populationBaseline';
// --- Why this exists --------------------------------------------------- // --- Why this exists ---------------------------------------------------
// //
@@ -120,7 +121,11 @@ async function computeMapImpactStats(): Promise<MapImpactSummary[]> {
const durationMs = nextStartMs - startMs; const durationMs = nextStartMs - startMs;
if (durationMs < MIN_REAL_PERIOD_MS) continue; // tickrate-restart artifact, not a real occurrence if (durationMs < MIN_REAL_PERIOD_MS) continue; // tickrate-restart artifact, not a real occurrence
const startPlayers = populationAt(startMs); // Read the starting population START_BASELINE_MS into the map rather than
// at the exact change instant, so players still leaving the previous map
// aren't counted against this one. Clamped to the occurrence's end (it is
// always >= 2 min long here, but may be shorter than the baseline).
const startPlayers = populationAt(Math.min(startMs + START_BASELINE_MS, nextStartMs));
const endPlayers = populationAt(nextStartMs); const endPlayers = populationAt(nextStartMs);
const netChange = endPlayers - startPlayers; const netChange = endPlayers - startPlayers;
const retentionPct = startPlayers > 0 ? (endPlayers / startPlayers) * 100 : null; const retentionPct = startPlayers > 0 ? (endPlayers / startPlayers) * 100 : null;
@@ -0,0 +1,8 @@
// How long after a map starts we wait before reading its "starting"
// population. At the exact instant of a map change the player count is still
// the previous map's (people leaving or reconnecting), so reading it at t=0
// would blame the new map for attrition that happened before/around the
// change. Shared by the leaderboard, the population chart SQL and the
// per-session timeline so all three agree. Safe to import from client code.
export const START_BASELINE_MIN = 3;
export const START_BASELINE_MS = START_BASELINE_MIN * 60_000;
@@ -1,4 +1,5 @@
import { query } from '@/lib/db'; import { query } from '@/lib/db';
import { START_BASELINE_MIN } from '@/lib/populationBaseline';
// map_end_dt was dropped from the schema — it was redundant with (and // map_end_dt was dropped from the schema — it was redundant with (and
// occasionally got out of sync with) the next row's map_start_dt, since a // occasionally got out of sync with) the next row's map_start_dt, since a
@@ -123,8 +124,8 @@ export async function getMapPopulationSeries(limit = 40, before?: string): Promi
AND (s.session_end_dt IS NULL OR s.session_end_dt > m.map_start_dt) AND (s.session_end_dt IS NULL OR s.session_end_dt > m.map_start_dt)
) AS total_players, ) AS total_players,
(SELECT COUNT(DISTINCT s.steamid) FROM playtime_display_sessions s (SELECT COUNT(DISTINCT s.steamid) FROM playtime_display_sessions s
WHERE s.session_start_dt <= m.map_start_dt WHERE s.session_start_dt <= LEAST(m.map_start_dt + INTERVAL ${START_BASELINE_MIN} MINUTE, COALESCE(${NEXT_MAP_START('m')}, NOW()))
AND (s.session_end_dt IS NULL OR s.session_end_dt > m.map_start_dt) AND (s.session_end_dt IS NULL OR s.session_end_dt > LEAST(m.map_start_dt + INTERVAL ${START_BASELINE_MIN} MINUTE, COALESCE(${NEXT_MAP_START('m')}, NOW())))
) AS start_players, ) AS start_players,
(SELECT COUNT(DISTINCT s.steamid) FROM playtime_display_sessions s (SELECT COUNT(DISTINCT s.steamid) FROM playtime_display_sessions s
WHERE s.session_start_dt <= COALESCE(${NEXT_MAP_START('m')}, NOW()) WHERE s.session_start_dt <= COALESCE(${NEXT_MAP_START('m')}, NOW())
File diff suppressed because one or more lines are too long