Skip to content
Open
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
116 changes: 109 additions & 7 deletions bun.lock

Large diffs are not rendered by default.

17 changes: 17 additions & 0 deletions web/db/migrations/0007_user_preferences.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
-- Per-user app preferences and custom theme. Replaces the
-- `glidecomp:preferences` and `glidecomp:theme` localStorage keys for
-- authenticated users so settings sync across devices.
--
-- One row per user. Both blobs are opaque JSON owned by the client; the
-- server only enforces size limits and treats them as strings.
--
-- theme_json is nullable so "reset to default" is a clean NULL rather than
-- a sentinel value. CASCADE on user delete piggybacks on the existing
-- account-deletion flow — no extra cleanup code needed.

CREATE TABLE "user_preferences" (
"user_id" TEXT PRIMARY KEY NOT NULL REFERENCES "user"("id") ON DELETE CASCADE,
"prefs_json" TEXT NOT NULL DEFAULT '{}',
"theme_json" TEXT,
"updated_at" TEXT NOT NULL DEFAULT (datetime('now'))
);
16 changes: 7 additions & 9 deletions web/engine/src/event-detector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -494,8 +494,8 @@ function evaluateTakeoffCriteria(
): number {
let criteriaMetCount = 0;

// Criteria 1: Instant ground speed check
if (index < fixes.length - 1) {
// Criteria 1: Instant ground speed check (needs a previous fix to compare to)
if (index >= 1) {
const speed = calculateGroundSpeed(fixes[index - 1], fixes[index]);
if (speed > config.minGroundSpeed) criteriaMetCount++;
}
Expand Down Expand Up @@ -801,13 +801,11 @@ export function detectFlightEvents(
return allEvents;
}

// Find the index of the takeoff fix in the original array
const takeoffIndex = fixes.findIndex(f => f.time.getTime() === takeoffEvent.time.getTime());

if (takeoffIndex === -1) {
// Shouldn't happen, but safety check
return allEvents;
}
// Read the takeoff fix index from the event itself. Looking it up by
// timestamp is unsafe — cheap GPS loggers stall and emit consecutive
// fixes with identical timestamps, so findIndex can land on a fix
// earlier than the real takeoff and leak pre-takeoff data downstream.
const takeoffIndex = (takeoffEvent.details as FixIndexDetails).fixIndex;

// Create a slice of fixes from takeoff onwards for analysis
const flightFixes = fixes.slice(takeoffIndex);
Expand Down
11 changes: 6 additions & 5 deletions web/engine/src/gap-scoring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { resolveTurnpointSequence } from './turnpoint-sequence';
import { getESSIndex } from './xctsk-parser';
import { calculateOptimizedTaskDistance } from './task-optimizer';
import { andoyerDistance } from './geo';
import { maxBy, minBy } from './array-utils';

// ---------------------------------------------------------------------------
// Competition parameters
Expand Down Expand Up @@ -540,7 +541,7 @@ export function scoreTask(
const scoredDistances = pilotResults.map(pr =>
applyMinimumDistance(pr.result.flownDistance, fullParams.minimumDistance)
);
const bestDistance = scoredDistances.length > 0 ? Math.max(...scoredDistances) : 0;
const bestDistance = scoredDistances.length > 0 ? maxBy(scoredDistances, d => d) : 0;

const goalPilots = pilotResults.filter(pr => pr.result.madeGoal);
const essPilots = pilotResults.filter(pr => pr.result.essReaching !== null);
Expand All @@ -552,7 +553,7 @@ export function scoreTask(
const validTimes = timeCandidates
.map(pr => pr.result.speedSectionTime)
.filter((t): t is number => t !== null && t > 0);
const bestTime = validTimes.length > 0 ? Math.min(...validTimes) : null;
const bestTime = validTimes.length > 0 ? minBy(validTimes, t => t) : null;

const taskDistance = calculateOptimizedTaskDistance(task);

Expand Down Expand Up @@ -601,8 +602,8 @@ export function scoreTask(
.map(pr => pr.result.essReaching?.time.getTime())
.filter((t): t is number => t !== undefined);

const taskFirstSSSTime = allSSSTimes.length > 0 ? Math.min(...allSSSTimes) : 0;
const taskLastESSTime = allESSTimes.length > 0 ? Math.max(...allESSTimes) : taskFirstSSSTime + 3600000;
const taskFirstSSSTime = allSSSTimes.length > 0 ? minBy(allSSSTimes, t => t) : 0;
const taskLastESSTime = allESSTimes.length > 0 ? maxBy(allESSTimes, t => t) : taskFirstSSSTime + 3600000;

leadingCoefficients = pilotResults.map(pr => {
const sssTime = pr.result.sssReaching?.time.getTime() ?? null;
Expand All @@ -615,7 +616,7 @@ export function scoreTask(
});

const finiteLCs = leadingCoefficients.filter(lc => isFinite(lc));
minLC = finiteLCs.length > 0 ? Math.min(...finiteLCs) : 0;
minLC = finiteLCs.length > 0 ? minBy(finiteLCs, lc => lc) : 0;
} else {
leadingCoefficients = pilotResults.map(() => Infinity);
}
Expand Down
93 changes: 93 additions & 0 deletions web/engine/tests/event-detector-duplicate-timestamp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { describe, it, expect } from 'bun:test';
import { detectFlightEvents } from '../src/event-detector';
import { createFixAt, type IGCFix } from './test-helpers';

/**
* Regression test for the duplicate-timestamp takeoff slice bug.
*
* detectFlightEvents used to re-derive the takeoff fix index by scanning for
* the first fix whose timestamp matches the takeoff event's timestamp:
*
* const takeoffIndex = fixes.findIndex(
* f => f.time.getTime() === takeoffEvent.time.getTime()
* );
*
* Cheap GPS loggers stall and emit several fixes with identical timestamps.
* When a pre-takeoff fix shared the takeoff fix's timestamp, findIndex
* returned the earlier index. The downstream slice then began at fix 0,
* silently feeding pre-takeoff data (e.g. a stalled climb) into thermal /
* glide / altitude detection and producing spurious events.
*
* The fix is to use takeoffEvent.details.fixIndex, which detectTakeoff
* stores directly when it emits the event.
*/
describe('Event Detector - duplicate-timestamp takeoff slice', () => {
it('does not detect pre-takeoff thermals when a pre-takeoff fix shares the takeoff timestamp', () => {
const fixes: IGCFix[] = [];
const t0 = new Date('2024-01-15T14:00:00Z');

// Fixes 0-9: stationary at 500m, 1s apart. Establishes startAltitude.
for (let i = 0; i < 10; i++) {
fixes.push(createFixAt(new Date(t0.getTime() + i * 1000), 47.0, 11.0, 500));
}

// Fixes 10-69: slow climb at ~0.6 m/s for 60s with near-zero ground speed.
// This sustained climb meets the thermal threshold (min 0.5 m/s, 20s) but
// is engineered to NOT trip detectTakeoff:
// - ground speed ≈ 0 → fails takeoff criterion 1 (>5 m/s)
// - altitude gain ≤ 36m → fails criterion 2 (>50m above startAlt 500m)
// - climb rate 0.6 m/s → fails criterion 3 (>1 m/s)
for (let i = 0; i < 60; i++) {
fixes.push(createFixAt(
new Date(t0.getTime() + (10 + i) * 1000),
47.0 + i * 1e-7,
11.0 + i * 1e-7,
500 + i * 0.6,
));
}

// Fixes 70+: clear takeoff with rapid horizontal motion and 5 m/s climb.
for (let i = 0; i < 90; i++) {
fixes.push(createFixAt(
new Date(t0.getTime() + (70 + i) * 1000),
47.001 + i * 0.0005,
11.001 + i * 0.0005,
536 + i * 5,
));
}

// Sanity check on the clean track: takeoff is detected somewhere past
// the slow-climb segment, and no thermal entries occur during it.
const cleanEvents = detectFlightEvents(fixes);
const cleanTakeoff = cleanEvents.find(e => e.type === 'takeoff');
expect(cleanTakeoff).toBeDefined();
const cleanTakeoffIdx = (cleanTakeoff!.details as { fixIndex: number }).fixIndex;
expect(cleanTakeoffIdx).toBeGreaterThanOrEqual(70);

const cleanThermalEntries = cleanEvents.filter(e => e.type === 'thermal_entry');
for (const t of cleanThermalEntries) {
expect(t.segment!.startIndex).toBeGreaterThanOrEqual(cleanTakeoffIdx);
}

// Now inject the duplicate-timestamp scenario: rewrite fix[0]'s timestamp
// to match the takeoff fix's timestamp (simulating a GPS clock stall at
// the very beginning of the log). The buggy findIndex-by-time would
// collapse the takeoff slice down to fix 0 and let the pre-takeoff
// slow climb leak into thermal detection.
const corruptedFixes = fixes.map((f, i) =>
i === 0 ? { ...f, time: fixes[cleanTakeoffIdx].time } : f,
);

const events = detectFlightEvents(corruptedFixes);
const takeoff = events.find(e => e.type === 'takeoff');
expect(takeoff).toBeDefined();

const takeoffFixIdx = (takeoff!.details as { fixIndex: number }).fixIndex;

// Every thermal entry must sit at or after the real takeoff fix.
const thermalEntries = events.filter(e => e.type === 'thermal_entry');
for (const t of thermalEntries) {
expect(t.segment!.startIndex).toBeGreaterThanOrEqual(takeoffFixIdx);
}
});
});
5 changes: 4 additions & 1 deletion web/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,19 @@
"build": "vite build",
"preview": "vite preview",
"deploy": "vite build && wrangler pages deploy dist --project-name=glidecomp",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"//": "Basecoat fork - see docs/basecoat-fork.md for build/publish instructions",
"devDependencies": {
"@cloudflare/workers-types": "^4.20260509.1",
"@tailwindcss/vite": "^4.3.0",
"@pokle/basecoat": "0.3.10-beta3.pokle-selections",
"jsdom": "^25.0.1",
"tailwindcss": "^4.3.0",
"typescript": "^6.0.3",
"vite": "^7.3.3",
"vitest": "^4.1.5",
"wrangler": "4.87.0"
},
"dependencies": {
Expand Down
8 changes: 7 additions & 1 deletion web/frontend/src/analysis/config.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
/**
* Configuration storage abstraction.
* Currently backed by localStorage, designed for future migration to backend API.
*
* localStorage is the synchronous read cache; cloud sync (when signed in)
* is layered on top via auth/preferences-sync, which observes mutations
* through schedulePush() and reconciles on startup via clearCache().
*/

import { resolveThresholds, DEFAULT_GAP_PARAMETERS, type DetectionThresholds, type PartialThresholds, type GAPParameters } from '@glidecomp/engine';
import { preferencesSync } from '../auth/preferences-sync';

export interface MapLocation {
center: [lng: number, lat: number];
Expand Down Expand Up @@ -98,6 +102,8 @@ class ConfigStore {
detail: merged,
})
);

preferencesSync.schedulePush('prefs');
}

/**
Expand Down
Loading
Loading