From ac5c502110ccec3b3fc69fcfec1de3ff0a0049da Mon Sep 17 00:00:00 2001 From: Miodec Date: Thu, 18 Jun 2026 13:10:41 +0200 Subject: [PATCH 1/2] chore: add util for checking if context is showing timed test --- frontend/src/ts/test/events/stats.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/frontend/src/ts/test/events/stats.ts b/frontend/src/ts/test/events/stats.ts index d5619051d4e4..de81dd7a4a82 100644 --- a/frontend/src/ts/test/events/stats.ts +++ b/frontend/src/ts/test/events/stats.ts @@ -62,6 +62,16 @@ function getTimerBoundaries(eventLog: EventLog): number[] { return boundaries; } +function isTimedTest(eventLog: EventLog): boolean { + const { context } = eventLog; + return ( + context.mode === "time" || + (context.mode === "words" && context.mode2 === "0") || + (context.mode === "custom" && context.customTextLimitMode === "time") || + (context.mode === "custom" && context.customTextLimitValue === 0) + ); +} + export function getStartToFirstKeypressMs(eventLog: EventLog): number { if (eventLog.context.mode === "zen") return 0; @@ -321,16 +331,12 @@ export function getChars(eventLog: EventLog): CharCounts { const { events, context } = eventLog; const { bailedOut } = context; - const isTimedTest = - context.mode === "time" || - (context.mode === "words" && context.mode2 === "0") || - (context.mode === "custom" && context.customTextLimitMode === "time") || - (context.mode === "custom" && context.customTextLimitValue === 0); + const isTimed = isTimedTest(eventLog); const eventsPerWord = getEventsPerWord(events); const lastWordIndex = inferActiveWordIndex(eventsPerWord); - const countPartial = isTimedTest || bailedOut; + const countPartial = isTimed || bailedOut; const acc: CharCounts = { allCorrect: 0, From bd079bb5fb5a0b0300bcfa13cf4dd0cec88b964b Mon Sep 17 00:00:00 2001 From: Miodec Date: Thu, 18 Jun 2026 15:34:32 +0200 Subject: [PATCH 2/2] refactor: rework timer drift compensation --- frontend/__tests__/test/events/stats.spec.ts | 201 +++++++++++++++++-- frontend/src/ts/test/events/stats.ts | 106 ++++++++-- frontend/src/ts/test/events/types.ts | 8 +- frontend/src/ts/test/test-timer.ts | 125 ++++++++---- 4 files changed, 368 insertions(+), 72 deletions(-) diff --git a/frontend/__tests__/test/events/stats.spec.ts b/frontend/__tests__/test/events/stats.spec.ts index 9779372e2821..20e8a6e7e666 100644 --- a/frontend/__tests__/test/events/stats.spec.ts +++ b/frontend/__tests__/test/events/stats.spec.ts @@ -130,9 +130,12 @@ function input( function timer( event: "start" | "step" | "end", timerVal: number, + opts: { catchup?: true } = {}, ): TimerEventData { if (event === "step") { - return { event, timer: timerVal, drift: 0 }; + return opts.catchup + ? { event, timer: timerVal, catchup: true } + : { event, timer: timerVal, drift: 0 }; } return { event, timer: timerVal }; } @@ -170,7 +173,7 @@ describe("stats.ts", () => { inputPerWord.clear(); }); - describe("getTimerBoundaries", () => { + describe("getLaggedTimerBoundaries", () => { it("returns step boundaries and end", () => { logTestEvent("timer", 1000, timer("start", 0)); logTestEvent("timer", 2000, timer("step", 1)); @@ -180,7 +183,7 @@ describe("stats.ts", () => { const eventLog = buildEventLog(); // end testMs=3000, last step testMs=3000 — gap is 0 < 500, end skipped - expect(statsTesting.getTimerBoundaries(eventLog)).toEqual([ + expect(statsTesting.getLaggedTimerBoundaries(eventLog)).toEqual([ 1000, 2000, 3000, ]); }); @@ -192,7 +195,9 @@ describe("stats.ts", () => { const eventLog = buildEventLog(); // endMs=1500 → 1500%1000=500ms → roundTo2(0.5)=0.5 → boundary added - expect(statsTesting.getTimerBoundaries(eventLog)).toEqual([1000, 1500]); + expect(statsTesting.getLaggedTimerBoundaries(eventLog)).toEqual([ + 1000, 1500, + ]); }); it("skips end when too close to last step", () => { @@ -202,7 +207,7 @@ describe("stats.ts", () => { const eventLog = buildEventLog(); // end at testMs 1400, last step at testMs 1000 — gap is 400 < 500 - expect(statsTesting.getTimerBoundaries(eventLog)).toEqual([1000]); + expect(statsTesting.getLaggedTimerBoundaries(eventLog)).toEqual([1000]); }); it("includes end boundary when endMs % 1000 rounds to 0.5s", () => { @@ -212,7 +217,9 @@ describe("stats.ts", () => { logTestEvent("timer", 2496, timer("end", 1)); const eventLog = buildEventLog(); - expect(statsTesting.getTimerBoundaries(eventLog)).toEqual([1000, 1496]); + expect(statsTesting.getLaggedTimerBoundaries(eventLog)).toEqual([ + 1000, 1496, + ]); }); it("skips end boundary when endMs % 1000 rounds below 0.5s", () => { @@ -222,7 +229,7 @@ describe("stats.ts", () => { logTestEvent("timer", 2494, timer("end", 1)); const eventLog = buildEventLog(); - expect(statsTesting.getTimerBoundaries(eventLog)).toEqual([1000]); + expect(statsTesting.getLaggedTimerBoundaries(eventLog)).toEqual([1000]); }); it("skips end boundary for .49 test even when step fires slightly early (drift)", () => { @@ -234,7 +241,7 @@ describe("stats.ts", () => { logTestEvent("timer", 2490, timer("end", 1)); const eventLog = buildEventLog(); - expect(statsTesting.getTimerBoundaries(eventLog)).toEqual([995]); + expect(statsTesting.getLaggedTimerBoundaries(eventLog)).toEqual([995]); }); it("includes end boundary for .99 test even when step fires late (drift)", () => { @@ -246,7 +253,9 @@ describe("stats.ts", () => { logTestEvent("timer", 2990, timer("end", 1)); const eventLog = buildEventLog(); - expect(statsTesting.getTimerBoundaries(eventLog)).toEqual([1510, 1990]); + expect(statsTesting.getLaggedTimerBoundaries(eventLog)).toEqual([ + 1510, 1990, + ]); }); it("excludes short trailing interval (<500ms) for non-round test duration", () => { @@ -257,7 +266,7 @@ describe("stats.ts", () => { const eventLog = buildEventLog(); // end testMs=1350, last step testMs=1000 — gap is 350 < 500, end skipped - expect(statsTesting.getTimerBoundaries(eventLog)).toEqual([1000]); + expect(statsTesting.getLaggedTimerBoundaries(eventLog)).toEqual([1000]); }); it("excludes short trailing interval (<500ms) for sub one second test duration", () => { @@ -267,14 +276,14 @@ describe("stats.ts", () => { const eventLog = buildEventLog(); // end testMs=1350, last step testMs=1000 — gap is 350 < 500, end skipped - expect(statsTesting.getTimerBoundaries(eventLog)).toEqual([]); + expect(statsTesting.getLaggedTimerBoundaries(eventLog)).toEqual([]); }); it("returns empty when no timer events", () => { logTestEvent("keydown", 1000, keyDown()); const eventLog = buildEventLog(); - expect(statsTesting.getTimerBoundaries(eventLog)).toEqual([]); + expect(statsTesting.getLaggedTimerBoundaries(eventLog)).toEqual([]); }); it("adjusts end in zen mode by removing trailing afk", () => { @@ -288,7 +297,7 @@ describe("stats.ts", () => { logTestEvent("timer", 5000, timer("end", 4)); const eventLog = buildEventLog(); - const boundaries = statsTesting.getTimerBoundaries(eventLog); + const boundaries = statsTesting.getLaggedTimerBoundaries(eventLog); // adjusted end = 4000 - 3500 = 500, steps at 1000 and 2000 are past it expect(boundaries).toEqual([500]); }); @@ -305,7 +314,7 @@ describe("stats.ts", () => { const eventLog = buildEventLog(); // 20 step boundaries, no end boundary (testSeconds rounds to 20.00) - expect(statsTesting.getTimerBoundaries(eventLog)).toHaveLength(20); + expect(statsTesting.getLaggedTimerBoundaries(eventLog)).toHaveLength(20); }); it("skips end boundary in time mode even when endMs %1000 >= 500ms", () => { @@ -320,7 +329,7 @@ describe("stats.ts", () => { logTestEvent("timer", 119994, timer("end", 120)); const eventLog = buildEventLog(); - const boundaries = statsTesting.getTimerBoundaries(eventLog); + const boundaries = statsTesting.getLaggedTimerBoundaries(eventLog); // 120 step boundaries, no end boundary expect(boundaries).toHaveLength(120); }); @@ -336,7 +345,9 @@ describe("stats.ts", () => { logTestEvent("timer", 29994, timer("end", 30)); const eventLog = buildEventLog(); - expect(statsTesting.getTimerBoundaries(eventLog)).toHaveLength(30); + expect(statsTesting.getLaggedTimerBoundaries(eventLog)).toHaveLength( + 30, + ); } finally { customTextLimit.mode = "words"; } @@ -366,7 +377,7 @@ describe("stats.ts", () => { logTestEvent("timer", endMs, timer("end", fullSeconds)); const eventLog = buildEventLog(); - const boundaries = statsTesting.getTimerBoundaries(eventLog); + const boundaries = statsTesting.getLaggedTimerBoundaries(eventLog); const roundedDuration = Math.round(endMs / 1000); expect(boundaries).toHaveLength(roundedDuration); }); @@ -374,6 +385,162 @@ describe("stats.ts", () => { }); }); + describe("getTimerBoundaries", () => { + it("returns empty when no end event", () => { + logTestEvent("timer", 1000, timer("start", 0)); + logTestEvent("timer", 2000, timer("step", 1)); + + const eventLog = buildEventLog(); + expect(statsTesting.getTimerBoundaries(eventLog)).toEqual([]); + }); + + it("returns ideal-grid boundaries based on end event's tick count", () => { + logTestEvent("timer", 1000, timer("start", 0)); + // step events with arbitrary drift — should not affect output + logTestEvent("timer", 1995, timer("step", 1)); + logTestEvent("timer", 3050, timer("step", 2)); + logTestEvent("timer", 3990, timer("step", 3)); + logTestEvent("timer", 4000, timer("end", 3)); + + const eventLog = buildEventLog(); + expect(statsTesting.getTimerBoundaries(eventLog)).toEqual([ + 1000, 2000, 3000, + ]); + }); + + it("uses end event's timer field for tick count, ignoring step count", () => { + // simulates a stall: only one real step fired, but the test ended at tick 5 + logTestEvent("timer", 1000, timer("start", 0)); + logTestEvent("timer", 2079, timer("step", 1)); + // catch-up + recovery would have logged more step events here + logTestEvent("timer", 6000, timer("end", 5)); + + const eventLog = buildEventLog(); + expect(statsTesting.getTimerBoundaries(eventLog)).toEqual([ + 1000, 2000, 3000, 4000, 5000, + ]); + }); + + it("appends fractional tail for non-timed test with .5s+ remainder", () => { + logTestEvent("timer", 1000, timer("start", 0)); + logTestEvent("timer", 4500, timer("end", 3)); + + const eventLog = buildEventLog(); + // 3 ticks + tail at 3500ms + expect(statsTesting.getTimerBoundaries(eventLog)).toEqual([ + 1000, 2000, 3000, 3500, + ]); + }); + + it("omits fractional tail under .5s for non-timed test", () => { + logTestEvent("timer", 1000, timer("start", 0)); + logTestEvent("timer", 4250, timer("end", 3)); + + const eventLog = buildEventLog(); + expect(statsTesting.getTimerBoundaries(eventLog)).toEqual([ + 1000, 2000, 3000, + ]); + }); + + it("omits fractional tail in time mode regardless of remainder", () => { + (Config as { mode: string }).mode = "time"; + logTestEvent("timer", 0, timer("start", 0)); + logTestEvent("timer", 15500, timer("end", 15)); + + const eventLog = buildEventLog(); + // 15 boundaries, no tail + expect(statsTesting.getTimerBoundaries(eventLog)).toHaveLength(15); + }); + + it("trims zen-mode trailing afk and caps tick count", () => { + (Config as { mode: string }).mode = "zen"; + logTestEvent("timer", 1000, timer("start", 0)); + logTestEvent("keydown", 1500, keyDown()); + logTestEvent("keyup", 1600, keyUp()); + // last keypress at testMs 500, end at testMs 4000 → afk = 3500 + // adjusted endMs = 500 → 0 full ticks, plus tail (500ms >= .5s) + logTestEvent("timer", 5000, timer("end", 4)); + + const eventLog = buildEventLog(); + expect(statsTesting.getTimerBoundaries(eventLog)).toEqual([500]); + }); + }); + + describe("getTimerBoundaryLabels", () => { + it("returns empty when no timer events", () => { + const eventLog = buildEventLog(); + expect(statsTesting.getTimerBoundaryLabels(eventLog)).toEqual([]); + }); + + it("labels clean step boundaries by index", () => { + logTestEvent("timer", 1000, timer("start", 0)); + logTestEvent("timer", 2000, timer("step", 1)); + logTestEvent("timer", 3000, timer("step", 2)); + logTestEvent("timer", 4000, timer("step", 3)); + logTestEvent("timer", 4000, timer("end", 3)); + + const eventLog = buildEventLog(); + expect(statsTesting.getTimerBoundaryLabels(eventLog)).toEqual([ + "1", + "2", + "3", + ]); + }); + + it("labels a fractional trailing end boundary with its time", () => { + logTestEvent("timer", 1000, timer("start", 0)); + logTestEvent("timer", 2000, timer("step", 1)); + logTestEvent("timer", 3000, timer("step", 2)); + logTestEvent("timer", 4000, timer("step", 3)); + logTestEvent("timer", 4500, timer("end", 3)); + + const eventLog = buildEventLog(); + expect(statsTesting.getTimerBoundaryLabels(eventLog)).toEqual([ + "1", + "2", + "3", + "3.50", + ]); + }); + + it("tolerates small step drift (within ~1 frame)", () => { + // steps fire ~5ms early due to drift — still label "1", "2", etc. + // end at a clean whole second so no tail boundary is added + logTestEvent("timer", 1000, timer("start", 0)); + logTestEvent("timer", 1995, timer("step", 1)); + logTestEvent("timer", 2990, timer("step", 2)); + logTestEvent("timer", 3985, timer("step", 3)); + logTestEvent("timer", 4000, timer("end", 3)); + + const eventLog = buildEventLog(); + expect(statsTesting.getTimerBoundaryLabels(eventLog)).toEqual([ + "1", + "2", + "3", + ]); + }); + + it("labels the bucket containing a catchup recovery with LAG", () => { + // tick 2 (catchup) fires at testMs 3101 — falls in bucket (3000, 4000] + logTestEvent("timer", 1000, timer("start", 0)); + logTestEvent("timer", 2079, timer("step", 1)); + logTestEvent("timer", 4101, timer("step", 2, { catchup: true })); + logTestEvent("timer", 4101, timer("step", 3)); + logTestEvent("timer", 5050, timer("step", 4)); + logTestEvent("timer", 5050, timer("end", 4)); + + const eventLog = buildEventLog(); + // perfect-grid boundaries: [1000, 2000, 3000, 4000] + // bucket 4 (boundary 4000, range (3000, 4000]) contains catchup at 3101 → LAG + expect(statsTesting.getTimerBoundaryLabels(eventLog)).toEqual([ + "1", + "2", + "3", + "LAG", + ]); + }); + }); + describe("getStartToFirstKeypressMs", () => { it("returns time from start to first keydown", () => { logTestEvent("timer", 1000, timer("start", 0)); diff --git a/frontend/src/ts/test/events/stats.ts b/frontend/src/ts/test/events/stats.ts index de81dd7a4a82..e1c96bf26abc 100644 --- a/frontend/src/ts/test/events/stats.ts +++ b/frontend/src/ts/test/events/stats.ts @@ -5,7 +5,87 @@ import { roundTo2 } from "@monkeytype/util/numbers"; import { EventLog, TestEventNoMs } from "./types"; import Hangul from "hangul-js"; -function getTimerBoundaries(eventLog: EventLog): number[] { +// Produces chart x-axis labels for an array of timer boundaries (the perfect +// grid version). Each bucket is labeled by its index ("1", "2", ...). A +// fractional tail boundary is labeled with its actual time, e.g. "7.25". +// When showLagLabels is true, buckets that contained a catchup-recovery +// moment (real-time burst dump from a stall) are labeled "LAG" to flag that +// their data isn't a true per-second sample. +export function getTimerBoundaryLabels( + eventLog: EventLog, + showLagLabels = true, +): string[] { + const boundaries = getTimerBoundaries(eventLog); + const catchupTimes: number[] = []; + if (showLagLabels) { + for (const event of eventLog.events) { + if (event.type !== "timer") continue; + if (event.data.event === "step" && event.data.catchup === true) { + catchupTimes.push(event.testMs); + } + } + } + + return boundaries.map((boundary, i) => { + // only the last boundary can be a tail; everything before it is on the grid + const isLast = i === boundaries.length - 1; + const isTail = isLast && Math.abs(boundary - (i + 1) * 1000) > 100; + if (isTail) return roundTo2(boundary / 1000).toFixed(2); + // bucket spans (prevBoundary, boundary] — mark LAG if a catchup recovery + // landed inside it (real-time events dumped into one perfect-grid bucket) + if (showLagLabels) { + const prevBoundary = i === 0 ? 0 : (boundaries[i - 1] as number); + const hasCatchup = catchupTimes.some( + (t) => t > prevBoundary && t <= boundary, + ); + if (hasCatchup) return "LAG"; + } + return `${i + 1}`; + }); +} + +// Ideal-grid boundaries: evenly spaced at 1-second intervals between start +// and end, regardless of when real step events fired. Use for chart consumers +// that want smooth per-second buckets unaffected by timer drift or catchup +// bursts. For the real fire-time boundaries (drift/catchup-affected), use +// getLaggedTimerBoundaries. +export function getTimerBoundaries(eventLog: EventLog): number[] { + let endMs: number | undefined; + let tickCount = 0; + for (const event of eventLog.events) { + if (event.type !== "timer") continue; + if (event.data.event === "end") { + endMs = event.testMs; + // end event's `timer` field is Time.get() at finish — the canonical + // tick count, immune to step-event drift/early-fire artifacts + tickCount = event.data.timer; + } + } + if (endMs === undefined) return []; + + // zen/bailout: trim trailing afk and cap tickCount to fit the adjusted end + if (eventLog.context.mode === "zen" || eventLog.context.bailedOut) { + const lkte = getRawLastKeypressToEndMs(eventLog); + if (lkte < 7000) { + endMs -= lkte; + tickCount = Math.min(tickCount, Math.floor(endMs / 1000)); + } + } + + const boundaries: number[] = []; + for (let i = 1; i <= tickCount; i++) boundaries.push(i * 1000); + + // append a fractional tail boundary for non-timed tests with a .5s+ remainder + if (!isTimedTest(eventLog) && Math.round(roundTo2(endMs / 1000) % 1) >= 0.5) { + boundaries.push(endMs); + } + + return boundaries; +} + +// Real fire-time step boundaries: positions reflect when steps actually +// fired (drift, catchup, etc.). For ideal grid positions, use getTimerBoundaries. +export function getLaggedTimerBoundaries(eventLog: EventLog): number[] { const { events } = eventLog; const boundaries: number[] = []; let endMs: number | undefined; @@ -27,7 +107,6 @@ function getTimerBoundaries(eventLog: EventLog): number[] { const lkte = getRawLastKeypressToEndMs(eventLog); if (lkte < 7000) { endMs -= lkte; - // remove step boundaries past the adjusted end while ( boundaries.length > 0 && (boundaries[boundaries.length - 1] as number) > endMs @@ -38,23 +117,10 @@ function getTimerBoundaries(eventLog: EventLog): number[] { } if (endMs !== undefined) { - // Timed tests never push an extra bucket (legacy skips setLastSecondNotRound - // for time mode). For other modes, mirror the legacy condition exactly: - // Math.round(roundTo2(testSeconds) % 1) >= 0.5. The rounding must happen at - // the SECONDS level — taking the fractional ms first and rounding can give - // a different answer when the rounded seconds carry into the next integer - // (e.g. endMs=19997: roundTo2(19.997)=20.00 → no bucket, but 997ms/1000 - // rounds to 0.5 → wrongly adds a bucket). - - const { context } = eventLog; - const isTimedTest = - context.mode === "time" || - (context.mode === "words" && context.mode2 === "0") || - (context.mode === "custom" && context.customTextLimitMode === "time") || - (context.mode === "custom" && context.customTextLimitValue === 0); - - const testSeconds = roundTo2(endMs / 1000); - if (!isTimedTest && Math.round(testSeconds % 1) >= 0.5) { + if ( + !isTimedTest(eventLog) && + Math.round(roundTo2(endMs / 1000) % 1) >= 0.5 + ) { boundaries.push(endMs); } } @@ -683,6 +749,8 @@ export function getKeypressDurations(eventLog: EventLog): number[] { export const __testing = { getTimerBoundaries, + getLaggedTimerBoundaries, + getTimerBoundaryLabels, getTargetWord, inferActiveWordIndex, }; diff --git a/frontend/src/ts/test/events/types.ts b/frontend/src/ts/test/events/types.ts index 60566bdbd56a..a7e835f2f9e7 100644 --- a/frontend/src/ts/test/events/types.ts +++ b/frontend/src/ts/test/events/types.ts @@ -71,8 +71,14 @@ export type TimerEventData = | { event: "step"; timer: number; - drift: number; + // omitted on catchup steps (they all fire at the same testMs in a + // synchronous burst, so per-tick drift isn't a real measurement) + drift?: number; slowTimer?: true; + // true when this step fired as part of a catch-up burst from a stall + // (timerStep ran with the cheap path; only the final step of the burst + // has the full WPM/UI side effects) + catchup?: true; } | { event: "start" | "end"; diff --git a/frontend/src/ts/test/test-timer.ts b/frontend/src/ts/test/test-timer.ts index 01ff1a5c74c2..6613237d4e38 100644 --- a/frontend/src/ts/test/test-timer.ts +++ b/frontend/src/ts/test/test-timer.ts @@ -31,28 +31,68 @@ import { createTimer } from "animejs"; import { requestDebouncedAnimationFrame } from "../utils/debounced-animation-frame"; import { logTestEvent } from "./events/data"; -let lastLoop = 0; +let timerStartMs = 0; +let stopped = true; const newTimer = createTimer({ duration: 1000, - loop: true, autoplay: false, - onBegin: () => { - lastLoop = performance.now(); - }, - onLoop: () => { + onComplete: () => { + // sync guard — finish() is async and TestState.isActive flips behind an await + if (stopped) return; const now = performance.now(); + const expectedThisFireMs = timerStartMs + (Time.get() + 1) * 1000; + const drift = Numbers.roundTo2(now - expectedThisFireMs); + + // animejs is rAF-quantized and can fire fractionally early — reschedule + // the remainder; bounded by rAF granularity, can't tight-loop + if (drift < 0) { + console.debug("Rescheduling timer, fired early by", -drift, "ms"); + newTimer.duration = expectedThisFireMs - now; + newTimer.restart(); + return; + } - const drift = Numbers.roundTo2(Math.abs(1000 - (now - lastLoop))); checkIfTimerIsSlow(drift); - lastLoop = now; - timerStep(); - logTestEvent("timer", now, { - event: "step", - timer: Time.get(), - slowTimer: SlowTimer.get() ? true : undefined, - drift, - }); + // Catch up missed ticks via the cheap timerStep path, so a stall recovery + // doesn't pay N times for buildEventLog/WPM/UI. Each missed tick still + // gets a step event + per-tick side effects (playTimeWarning, layoutfluid). + const ticksDue = Math.floor((now - timerStartMs) / 1000); + while (!stopped && Time.get() + 1 < ticksDue) { + console.debug( + "Catching up timer, missed tick at", + Time.get() + 1, + "seconds", + ); + timerStep(now, true); + logTestEvent("timer", now, { + event: "step", + timer: Time.get(), + slowTimer: SlowTimer.get() ? true : undefined, + catchup: true, + }); + } + // Gated on !stopped to avoid duplicating the last catch-up event when a + // catch-up tick was the one that triggered finish. timerStep itself can + // flip stopped (Time hits maxTime) — we still log because the tick ran. + if (!stopped) { + timerStep(now, false); + logTestEvent("timer", now, { + event: "step", + timer: Time.get(), + slowTimer: SlowTimer.get() ? true : undefined, + drift, + }); + } + + if (stopped) return; + + // Anchor to the ideal grid relative to test start (not `now`) so a late + // tick doesn't permanently offset every tick after it. + const expectedNextFireMs = timerStartMs + (Time.get() + 1) * 1000; + + newTimer.duration = Math.max(0, expectedNextFireMs - now); + newTimer.restart(); }, }); @@ -80,6 +120,7 @@ export function enableTimerDebug(): void { } export function clear(logEnd = false, now = performance.now()): void { + stopped = true; clearLowFpsMode(); newTimer.reset(); if (timer !== null) clearTimeout(timer); @@ -252,29 +293,38 @@ export function getTimerStats(): TimerStats[] { return timerStats; } -function timerStep(): void { +function timerStep(_now: number, catchingUp: boolean): void { if (timerDebug) console.time("timer step -----------------------------"); - //calc Time.increment(); - const wpmAndRaw = calculateWpmRaw(); - const acc = calculateAcc(); - //ui updates - requestDebouncedAnimationFrame("test-timer.timerStep", () => { - premid(); - monkey(wpmAndRaw); - }); + if (catchingUp) { + // cheap per-tick side effects — must run for every missed tick during catch-up + // so warnings/layout switches still fire on the correct seconds + if (Config.playTimeWarning !== "off") playTimeWarning(); + layoutfluid(); + checkIfTimeIsUp(); + } else { + //calc — only the final, real-time tick pays for these + const wpmAndRaw = calculateWpmRaw(); + const acc = calculateAcc(); + + //ui updates + requestDebouncedAnimationFrame("test-timer.timerStep", () => { + premid(); + monkey(wpmAndRaw); + }); - // already using raf - TimerProgress.update(); - LiveSpeed.update(wpmAndRaw.wpm, wpmAndRaw.raw); + // already using raf + TimerProgress.update(); + LiveSpeed.update(wpmAndRaw.wpm, wpmAndRaw.raw); - //logic - if (Config.playTimeWarning !== "off") playTimeWarning(); - layoutfluid(); - const failed = checkIfFailed(wpmAndRaw, acc); - if (!failed) checkIfTimeIsUp(); + //logic + if (Config.playTimeWarning !== "off") playTimeWarning(); + layoutfluid(); + const failed = checkIfFailed(wpmAndRaw, acc); + if (!failed) checkIfTimeIsUp(); + } if (timerDebug) console.timeEnd("timer step -----------------------------"); } @@ -320,10 +370,13 @@ export async function start(now: number): Promise { } slowTimerNotifIds = []; void _startNew(now); - // void _startOld(); + // void _startOld(now); } async function _startNew(now: number): Promise { + stopped = false; + timerStartMs = now; + newTimer.duration = 1000; newTimer.play(); logTestEvent("timer", now, { event: "start", @@ -356,14 +409,16 @@ async function _startOld(): Promise { return; } - logTestEvent("timer", performance.now(), { + const now = performance.now(); + + logTestEvent("timer", now, { event: "step", timer: Time.get(), drift: drift, slowTimer: SlowTimer.get() ? true : undefined, }); - timerStep(); + timerStep(now, false); expected += interval; loop();