Skip to content
Open
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
937667d
fix: radar shows false crashes, overlapping cars and wrong pace sides
estem8 Oct 18, 2026
bfee575
fix: radar keeps pit lane cars alongside and splits the draw module
estem8 Oct 18, 2026
b7dcf28
chore: filter radar pit wall side once and trim draw module exports
estem8 Oct 18, 2026
89c8642
test: cover radar pit wall separation with the focus car in the pits
estem8 Oct 18, 2026
f57913b
fix: radar hazards ignore corner pace and replay scrubs, no spacing w…
estem8 Oct 18, 2026
8535a46
feat: radar pole side per track from a data file
estem8 Oct 18, 2026
872aff7
data: Fuji GP pole side is verified (right)
estem8 Oct 18, 2026
6d59daf
refactor: pole side table reads unknown sources as geometry and keeps…
estem8 Oct 18, 2026
084dfb9
feat: radar keybinding to flip the pole side per track, remembered lo…
estem8 Oct 18, 2026
1dc9e4f
data: pole side table carries track names and iRacing's start/restart…
estem8 Oct 18, 2026
79f5112
feat: report the player's radar pole side corrections, per start and …
estem8 Oct 18, 2026
ff1b6af
test: mock the pole side storage in the paused tape bridge spec
estem8 Oct 18, 2026
2616a5a
fix: the road shifts to the middle of a two-wide formation, and follo…
estem8 Oct 18, 2026
55b9224
feat: report radar pole side corrections a minute after the last flip
estem8 Oct 18, 2026
6b21944
feat: the pole side key and a note on it in the Radar settings
estem8 Oct 18, 2026
b37afd8
refactor: keep the pole side table out of the renderer bundles, simpl…
estem8 Oct 18, 2026
8f3a662
feat: the pole side flip also works on a standing grid
estem8 Oct 18, 2026
13ac8d5
fix: address the code review of the radar branch
estem8 Oct 18, 2026
3cdffd1
fix: address the second code review of the radar branch
estem8 Oct 18, 2026
3ef9cfe
fix: keep a pole side correction when its write fails, test the host …
estem8 Oct 18, 2026
e6ea5c3
fix: resolve the pole side file path lazily so importing the module n…
estem8 Oct 18, 2026
11ea837
fix: read the pole side file ahead of the radar without sync I/O, flu…
estem8 Oct 18, 2026
13aa5a8
fix: load the pole side file once before any reader, never replace a …
estem8 Oct 18, 2026
180e2e0
fix: recheck the analytics opt-out right before sending the pole side…
estem8 Oct 18, 2026
1aeb24c
fix: forget a sudden drop once the car keeps moving above a crawl for…
estem8 Oct 18, 2026
2af30a7
fix: keep pole side types out of the processors, log no file paths, s…
estem8 Oct 19, 2026
7311a13
fix: address the code review of the pole side and hazard changes
estem8 Oct 19, 2026
1cdc197
fix: ease the radar road to its lane instead of jumping when a format…
estem8 Oct 19, 2026
1b7fb49
test: cover the pole side report hash storage
estem8 Oct 19, 2026
c1723d0
test: use promise-based fs in the pole side hash spec hooks
estem8 Oct 19, 2026
cb71bb1
test: use promise-based fs in the pole side overrides storage spec
estem8 Oct 19, 2026
03c06cb
Merge remote-tracking branch 'upstream/main' into fix/radar-fixes
estem8 Oct 19, 2026
1541a9c
fix: radar pace car on the shifted road, drop the pole notice on a re…
estem8 Oct 25, 2026
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"fetch-lovely-data": "npx tsx ./tools/fetch-lovely-data.ts",
"fetch-lovely-track-data": "npx tsx ./tools/fetch-lovely-track-data.ts",
"generate-garage61-ids": "npx tsx ./tools/generate-garage61-ids.ts",
"generate-radar-pole-sides": "npx tsx ./tools/generate-radar-pole-sides.ts",
"prepare": "husky"
},
"devDependencies": {
Expand Down
199 changes: 199 additions & 0 deletions src/app/analytics.radarPoleSide.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { EventMessage, IdentifyMessage } from 'posthog-node';
import type { DashboardLayout } from '@irdashies/types';
import os from 'node:os';
import path from 'node:path';
import { mkdtempSync } from 'node:fs';

const { userDataPath, store } = vi.hoisted(() => ({
userDataPath: { value: '' },
store: { data: new Map<string, unknown>() },
}));

vi.mock('electron', () => ({
app: { getPath: vi.fn(() => userDataPath.value) },
screen: {
getAllDisplays: vi.fn().mockReturnValue([]),
getPrimaryDisplay: vi
.fn()
.mockReturnValue({ size: { width: 0, height: 0 } }),
},
}));

vi.mock('electron-log/main', () => ({
default: {
transports: { file: {}, console: {} },
initialize: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));

vi.mock('./storage/storage', () => ({
readData: vi.fn((key: string) => store.data.get(key)),
writeData: vi.fn((key: string, value: unknown) => store.data.set(key, value)),
}));

vi.mock('./storage/analytics', () => ({
getAnalyticsOptOut: vi.fn().mockReturnValue(false),
}));

const { corrections, flipped } = vi.hoisted(() => ({
corrections: { value: {} as Record<string, unknown> },
flipped: { listeners: new Set<() => void>() },
}));
vi.mock('./storage/radarPoleSideOverrides', () => ({
radarPoleSideOverrides: {
snapshot: () => corrections.value,
onChange: (listener: () => void) => {
flipped.listeners.add(listener);
return () => flipped.listeners.delete(listener);
},
},
}));

import { Analytics } from './analytics';
import { getAnalyticsOptOut } from './storage/analytics';

const HASH_KEY = 'radarPoleSideReportHash';

const makeProvider = () => ({
capture: vi.fn<(event: EventMessage) => void>(),
captureConfirmed: vi
.fn<(event: EventMessage) => Promise<void>>()
.mockResolvedValue(undefined),
identify: vi.fn<(message: IdentifyMessage) => void>(),
shutdown: vi.fn<(shutdownTimeoutMs?: number) => void>(),
});

describe('Analytics radar pole side report', () => {
let provider: ReturnType<typeof makeProvider>;

const start = async (version = '2.0.0') => {
const analytics = new Analytics(provider);
await analytics.init(version, {} as DashboardLayout);
await analytics.waitForRadarPoleSideUpload();
};
const reports = () =>
provider.captureConfirmed.mock.calls
.map(([event]) => event)
.filter((event) => event.event === 'radar_pole_side');

beforeEach(() => {
userDataPath.value = mkdtempSync(path.join(os.tmpdir(), 'irdashies-'));
store.data.clear();
corrections.value = {};
flipped.listeners.clear();
provider = makeProvider();
vi.mocked(getAnalyticsOptOut).mockReturnValue(false);
});

it('sends nothing for an installation that never corrected a side', async () => {
await start();
expect(reports()).toEqual([]);
});

it('reports the corrections once, then remembers what was acknowledged', async () => {
corrections.value = { '95': { start: false, table: true } };
await start();

expect(reports()).toHaveLength(1);
expect(reports()[0]).toMatchObject({
event: 'radar_pole_side',
properties: {
version: '2.0.0',
track_count: 1,
tracks: { '95': { start_left: false, table_left: true } },
},
});
expect(store.data.get(HASH_KEY)).toBe(reports()[0].properties?.data_hash);

await start('3.0.0');
expect(reports()).toHaveLength(1);
});

it('reports a change, and a revert to nothing', async () => {
corrections.value = { '95': { start: false, table: true } };
await start();
corrections.value = {
'95': { start: false, restart: false, table: true },
};
await start();
corrections.value = {};
await start();

expect(reports()).toHaveLength(3);
expect(reports()[2].properties?.track_count).toBe(0);
});

it('does not record a hash when delivery fails, so it is retried', async () => {
corrections.value = { '95': { start: false, table: true } };
provider.captureConfirmed.mockRejectedValueOnce(new Error('down'));
await expect(start()).resolves.toBeUndefined();
expect(store.data.get(HASH_KEY)).toBeUndefined();

await start();
expect(store.data.get(HASH_KEY)).toBeDefined();
});

it('respects the analytics opt-out', async () => {
corrections.value = { '95': { start: false, table: true } };
vi.mocked(getAnalyticsOptOut).mockReturnValue(true);
await start();
expect(reports()).toEqual([]);
});

it('ignores a stored value that is not a hash', async () => {
store.data.set(HASH_KEY, 'not-a-hash');
await start();
expect(reports()).toEqual([]);
});

describe('after a flip', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});

const flip = () => flipped.listeners.forEach((listener) => listener());

it('reports a minute after the last flip, not at once and not once per press', async () => {
await start();
expect(reports()).toEqual([]);

corrections.value = { '95': { start: false, table: true } };
flip();
await vi.advanceTimersByTimeAsync(30_000);
flip();
await vi.advanceTimersByTimeAsync(59_000);
expect(reports()).toEqual([]);

await vi.advanceTimersByTimeAsync(2_000);
expect(reports()).toHaveLength(1);
expect(reports()[0].properties?.track_count).toBe(1);
expect(store.data.get(HASH_KEY)).toBeDefined();
});

it('sends nothing when the flips cancelled out and nothing was ever sent', async () => {
await start();
flip();
await vi.advanceTimersByTimeAsync(61_000);
expect(reports()).toEqual([]);
});

it('stops its timer when the app shuts down', async () => {
const analytics = new Analytics(provider);
await analytics.init('2.0.0', {} as DashboardLayout);
await analytics.waitForRadarPoleSideUpload();
corrections.value = { '95': { start: false, table: true } };
flip();
analytics.shutdown();
await vi.advanceTimersByTimeAsync(120_000);
expect(reports()).toEqual([]);
});
});
});
97 changes: 97 additions & 0 deletions src/app/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,27 @@ import logger from './logger';
import type ElectronLog from 'electron-log';
import type { LogMessage } from 'electron-log';
import { readPitLaneCalibrationForAnalytics } from './analytics/pitLaneCalibration';
import {
MAX_POLE_SIDE_PROPERTIES_BYTES,
buildRadarPoleSideReport,
} from './analytics/radarPoleSideReport';
import { radarPoleSideOverrides } from './storage/radarPoleSideOverrides';
import {
readPitLaneAnalyticsCheckpoint,
writePitLaneAnalyticsCheckpoint,
} from './storage/pitLaneAnalyticsCheckpoint';

declare const POSTHOG_KEY: string;

/**
* How long after the last flip the radar pole side report goes out, so a
* burst of key presses is one report and nothing waits for the next launch.
*/
const POLE_SIDE_REPORT_DELAY_MS = 60 * 1000;

/** Hash of the last radar pole side report PostHog acknowledged. */
const POLE_SIDE_HASH_KEY = 'radarPoleSideReportHash';

const DEDUP_WINDOW_MS = 60 * 60 * 1000;
const DEDUP_KEY_LENGTH = 30;
const DEDUP_MAX_ENTRIES = 5000;
Expand Down Expand Up @@ -80,6 +94,10 @@ export class Analytics {
private provider: AnalyticsProvider | null = null;
private pitLaneCalibrationAttempted = false;
private pitLaneCalibrationTask: Promise<void> | undefined;
private radarPoleSideTask: Promise<void> | undefined;
private poleSideEvent: { hash: string; uuid: string } | undefined;
private radarPoleSideTimer: ReturnType<typeof setTimeout> | undefined;
private watchingRadarPoleSides = false;

constructor(provider?: AnalyticsProvider) {
if (provider) {
Expand Down Expand Up @@ -122,6 +140,7 @@ export class Analytics {
}

shutdown(shutdownTimeoutMs?: number): void {
clearTimeout(this.radarPoleSideTimer);
if (this.provider) {
this.provider.shutdown(shutdownTimeoutMs);
}
Expand Down Expand Up @@ -214,6 +233,84 @@ export class Analytics {
).catch(() => {
logger.warn('[Analytics] Could not publish pit lane calibration');
});
// After the pit lane report, so startup does one thing at a time.
this.radarPoleSideTask ??= this.pitLaneCalibrationTask.then(() =>
this.captureRadarPoleSides(userId, version)
);
this.reportRadarPoleSidesAfterFlips(userId, version);
}

/**
* A flip is reported a minute after the last one rather than at the next
* launch. If the app closes first, the launch still sends it, since the
* acknowledged hash was never recorded.
*/
private reportRadarPoleSidesAfterFlips(
userId: string,
version: string
): void {
if (this.watchingRadarPoleSides) return;
this.watchingRadarPoleSides = true;
radarPoleSideOverrides.onChange(() => {
clearTimeout(this.radarPoleSideTimer);
this.radarPoleSideTimer = setTimeout(() => {
this.radarPoleSideTask = (
this.radarPoleSideTask ?? Promise.resolve()
).then(() => this.captureRadarPoleSides(userId, version));
}, POLE_SIDE_REPORT_DELAY_MS);
this.radarPoleSideTimer.unref?.();
});
}

waitForRadarPoleSideUpload(): Promise<void> {
return this.radarPoleSideTask ?? Promise.resolve();
}

/**
* Reports the pole sides the player corrected with the radar key, once per
* change, in the background. A revert to the table is reported too, but
* nothing is sent for an installation that never corrected anything.
*/
private async captureRadarPoleSides(
userId: string,
version: string
): Promise<void> {
try {
if (!this.provider || getAnalyticsOptOut()) return;
const report = buildRadarPoleSideReport(
radarPoleSideOverrides.snapshot(),
version
);
const stored = readData<unknown>(POLE_SIDE_HASH_KEY);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Store the report hash without synchronous I/O

When the one-minute post-flip timer reaches captureRadarPoleSides, this call and line 317 go through storage/storage.ts's readFileSync/writeFileSync; that callback executes on Electron's main thread, so a slow or locked user-data volume stalls telemetry/window processing and the checkpoint write bypasses the required debouncing and quit flush. Use dedicated async checkpoint persistence, like the pit-lane report does.

AGENTS.md reference: AGENTS.md:L14-L14

Useful? React with 👍 / 👎.

const last =
typeof stored === 'string' && /^[0-9a-f]{64}$/.test(stored)
? stored
: undefined;
if (report.properties.track_count === 0 && last === undefined) return;
if (last === report.hash) return;
if (
Buffer.byteLength(JSON.stringify(report.properties), 'utf8') >
MAX_POLE_SIDE_PROPERTIES_BYTES
) {
logger.warn('[Analytics] Skipping oversized radar pole side report');
return;
}
// The same report keeps its event id across retries, so a send whose
// acknowledgement was lost is not counted twice while the app runs.
if (this.poleSideEvent?.hash !== report.hash) {
this.poleSideEvent = { hash: report.hash, uuid: crypto.randomUUID() };
}
await this.provider.captureConfirmed({
distinctId: userId,
event: 'radar_pole_side',
uuid: this.poleSideEvent.uuid,
properties: report.properties,
});
// Recorded only after PostHog acknowledged it, so a failure retries.
if (!getAnalyticsOptOut()) writeData(POLE_SIDE_HASH_KEY, report.hash);
} catch {
logger.warn('[Analytics] Could not publish radar pole side report');
}
}

waitForCalibrationUpload(): Promise<void> {
Expand Down
Loading
Loading