From 1b7879339d061344a15de3e7e5bc721ab35b4abb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 08:06:02 +0000 Subject: [PATCH 1/7] Fix sprint detection: relax activity type filter, lower speed threshold for short intervals, add velocity_smooth fallback fetch Agent-Logs-Url: https://github.com/MaximumTrainer/SilverSprint/sessions/fc9c0d66-3d33-4abb-bfa2-fcaafa6df2a1 Co-authored-by: MaximumTrainer <1376575+MaximumTrainer@users.noreply.github.com> --- api/index.ts | 4 +-- src/domain/schema.ts | 16 ++++++++++- src/domain/sprint/parser.ts | 23 ++++++++++++++-- src/hooks/useIntervalsData.ts | 43 ++++++++++++++++++++++++------ src/schema.ts | 16 ++++++++++- tests/api/index.test.ts | 4 +-- tests/domain/schema.test.ts | 28 ++++++++++++++++--- tests/domain/sprint/parser.test.ts | 40 +++++++++++++++++++++++++++ 8 files changed, 155 insertions(+), 19 deletions(-) diff --git a/api/index.ts b/api/index.ts index 9b576a5..51bbff2 100644 --- a/api/index.ts +++ b/api/index.ts @@ -1,7 +1,7 @@ import { SprintParser, TrackInterval } from '../src/domain/sprint/parser'; import { SilverSprintLogic } from '../src/domain/sprint/core'; import { IntervalsCustomStreams } from '../src/domain/sprint/custom-streams'; -import { IntervalsActivitySchema, IntervalsIntervalSchema } from '../src/domain/schema'; +import { IntervalsActivitySchema, IntervalsIntervalSchema, RUN_ACTIVITY_TYPES } from '../src/domain/schema'; import { logger } from './logger'; import { z } from 'zod'; @@ -120,7 +120,7 @@ export default async function handler(req: ServerlessRequest, res: ServerlessRes ); const recentActivities: Array<{ type?: string; id?: string; max_speed?: number }> = recentRes.ok ? await recentRes.json() : []; const validVmaxes = recentActivities - .filter((a) => a.type === 'Run' && a.id !== id && (a.max_speed ?? 0) > 0) + .filter((a) => (RUN_ACTIVITY_TYPES as readonly string[]).includes(a.type ?? '') && a.id !== id && (a.max_speed ?? 0) > 0) .map((a) => a.max_speed!); const avgVmax = validVmaxes.length > 0 diff --git a/src/domain/schema.ts b/src/domain/schema.ts index 89c46b8..9e28601 100644 --- a/src/domain/schema.ts +++ b/src/domain/schema.ts @@ -1,8 +1,22 @@ import { z } from 'zod'; +/** + * Activity types from Intervals.icu that represent running. + * The API uses Strava sport-type strings; we accept all run sub-types + * so that trail runs, virtual runs, and track sessions are not silently dropped. + */ +export const RUN_ACTIVITY_TYPES = [ + 'Run', + 'TrailRun', + 'VirtualRun', + 'Track', + 'TrackAndField', + 'Treadmill', +] as const; + export const IntervalsActivitySchema = z.object({ id: z.string(), - type: z.literal('Run'), + type: z.enum(RUN_ACTIVITY_TYPES), start_date_local: z.string().optional(), velocity_smooth: z.array(z.number()).default([]), max_speed: z.number(), diff --git a/src/domain/sprint/parser.ts b/src/domain/sprint/parser.ts index 1e5a0ed..f6a0d8b 100644 --- a/src/domain/sprint/parser.ts +++ b/src/domain/sprint/parser.ts @@ -33,13 +33,27 @@ export class SprintParser { private static readonly REST_INTERVAL_TYPES = ['REST', 'ACTIVE_REST', 'WARMUP', 'COOLDOWN', 'RECOVERY'] as const; /** - * Minimum average speed (m/s) to be considered a sprint effort. + * Minimum average speed (m/s) to be considered a sprint effort for longer intervals (> 30s). * Filters out rest periods that are labelled WORK by Intervals.icu * (e.g. 300-second walk-back recovery intervals with avg 0.2–0.6 m/s). * Even a standing-start 10 m sprint has an average speed > 4 m/s. */ private static readonly MIN_SPRINT_AVG_SPEED = 4.0; // m/s ≈ 14.4 km/h + /** + * Lower average-speed floor for short intervals (≤ 30s). + * GPS-measured average speed for sub-10s laps is unreliable because the lap + * boundary may include approach/deceleration time. We still reject obvious + * rest intervals (e.g. standing around at < 2 m/s). + */ + private static readonly MIN_SHORT_INTERVAL_AVG_SPEED = 2.0; // m/s ≈ 7.2 km/h + + /** + * Duration threshold (seconds): intervals at or below this duration use the + * lower average-speed floor ({@link MIN_SHORT_INTERVAL_AVG_SPEED}). + */ + private static readonly SHORT_INTERVAL_DURATION = 30; // seconds + /** * Parse a full session's velocity_smooth stream into classified intervals. * Each second in the stream is treated as 1 sample at the given velocity (1Hz). @@ -142,7 +156,12 @@ export class SprintParser { // average speed (e.g. 0.2–0.6 m/s walk-back) even though max_speed may be // non-zero (residual from the preceding sprint). Any real sprint effort — // even a short standing-start — produces an average speed above this floor. - if (flyingVelocity > 0 && flyingVelocity < this.MIN_SPRINT_AVG_SPEED) { + // Short intervals (≤ 30s) use a lower threshold because GPS-measured average + // speed is unreliable for sub-10s laps (approach/deceleration artefacts). + const speedFloor = duration <= this.SHORT_INTERVAL_DURATION + ? this.MIN_SHORT_INTERVAL_AVG_SPEED + : this.MIN_SPRINT_AVG_SPEED; + if (flyingVelocity > 0 && flyingVelocity < speedFloor) { return null; } diff --git a/src/hooks/useIntervalsData.ts b/src/hooks/useIntervalsData.ts index e2d8b5b..8b59bfb 100644 --- a/src/hooks/useIntervalsData.ts +++ b/src/hooks/useIntervalsData.ts @@ -237,14 +237,41 @@ export const useIntervalsData = (athleteId: string, accessToken: string, authTyp ); // Merge: for each activity use API intervals when available, else fall back - // to parsing the velocity_smooth stream (which may be empty from the list API). - const allTrainingIntervals = activitiesForIntervals.flatMap((a, idx) => { - const result = intervalFetches[idx]; - if (result.status === 'fulfilled' && result.value.intervals.length > 0) { - return result.value.intervals; - } - return SprintParser.parseTrackSession(a); - }); + // to fetching the full activity detail (for its velocity_smooth stream) and + // parsing it. The activity list endpoint omits velocity_smooth, so the + // parseTrackSession fallback only works when the full detail is fetched. + const allTrainingIntervals = (await Promise.all( + activitiesForIntervals.map(async (a, idx) => { + const result = intervalFetches[idx]; + if (result.status === 'fulfilled' && result.value.intervals.length > 0) { + return result.value.intervals; + } + + // Attempt to parse from the activity's velocity_smooth (may be populated + // by the list endpoint on some accounts). + const streamIntervals = SprintParser.parseTrackSession(a); + if (streamIntervals.length > 0) return streamIntervals; + + // Last resort: fetch the full activity detail to get its velocity_smooth + // stream, then parse that. This is an extra API call per activity that + // lacked both interval and stream data. + try { + const detailRes = await fetch( + `${INTERVALS_BASE}/api/v1/activity/${a.id}`, + { headers } + ); + if (!detailRes.ok) return []; + const detail = await detailRes.json(); + const velocitySmooth: number[] = Array.isArray(detail?.velocity_smooth) + ? detail.velocity_smooth + : []; + if (velocitySmooth.length === 0) return []; + return SprintParser.parseTrackSession({ velocity_smooth: velocitySmooth }); + } catch { + return []; + } + }) + )).flat(); // Aggregate total training load from ALL interval types across recent sessions. // This captures non-sprint load (warmup, cooldown, rest) that would otherwise diff --git a/src/schema.ts b/src/schema.ts index b4b5ffe..526e7ad 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -1,8 +1,22 @@ import { z } from 'zod'; +/** + * Activity types from Intervals.icu that represent running. + * The API uses Strava sport-type strings; we accept all run sub-types + * so that trail runs, virtual runs, and track sessions are not silently dropped. + */ +export const RUN_ACTIVITY_TYPES = [ + 'Run', + 'TrailRun', + 'VirtualRun', + 'Track', + 'TrackAndField', + 'Treadmill', +] as const; + export const IntervalsActivitySchema = z.object({ id: z.string(), - type: z.literal('Run'), + type: z.enum(RUN_ACTIVITY_TYPES), start_date_local: z.string().optional(), velocity_smooth: z.array(z.number()).default([]), max_speed: z.number(), diff --git a/tests/api/index.test.ts b/tests/api/index.test.ts index 63aed95..09743a2 100644 --- a/tests/api/index.test.ts +++ b/tests/api/index.test.ts @@ -221,10 +221,10 @@ describe('Webhook handler — full pipeline with mocked fetch (§5)', () => { vi.unstubAllGlobals(); }); - it('returns 422 when activity data fails Zod validation', async () => { + it('returns 422 when activity data fails Zod validation (non-run type)', async () => { const invalidActivity = { id: 'act_invalid', - type: 'Ride', // Not 'Run' — should fail literal check + type: 'Ride', // Not a run type — should fail enum check velocity_smooth: [1, 2, 3], max_speed: 5.0, icu_training_load: 50, diff --git a/tests/domain/schema.test.ts b/tests/domain/schema.test.ts index 15f1185..6e77480 100644 --- a/tests/domain/schema.test.ts +++ b/tests/domain/schema.test.ts @@ -1,13 +1,13 @@ import { describe, it, expect } from 'vitest'; import { z } from 'zod'; -import { IntervalsActivitySchema, IntervalsWellnessSchema, IntervalsIntervalSchema } from '../../src/domain/schema'; +import { IntervalsActivitySchema, IntervalsWellnessSchema, IntervalsIntervalSchema, RUN_ACTIVITY_TYPES } from '../../src/domain/schema'; /** * Tests for README §2.2 — Data Ingestion Schema (Zod) * * The spec requires: * id: z.string() - * type: z.literal('Run') + * type: z.enum(RUN_ACTIVITY_TYPES) — accepted run sub-types * velocity_smooth: z.array(z.number()) — required * max_speed: z.number() — required * icu_training_load: z.number() @@ -30,11 +30,33 @@ describe('IntervalsActivitySchema (§2.2)', () => { expect(result.success).toBe(true); }); - it('rejects activity with wrong type (must be literal "Run")', () => { + it('rejects activity with non-run type (e.g. Ride)', () => { const result = IntervalsActivitySchema.safeParse({ ...validActivity, type: 'Ride' }); expect(result.success).toBe(false); }); + it('accepts all RUN_ACTIVITY_TYPES', () => { + for (const runType of RUN_ACTIVITY_TYPES) { + const result = IntervalsActivitySchema.safeParse({ ...validActivity, type: runType }); + expect(result.success).toBe(true); + } + }); + + it('accepts TrailRun activities', () => { + const result = IntervalsActivitySchema.safeParse({ ...validActivity, type: 'TrailRun' }); + expect(result.success).toBe(true); + }); + + it('accepts VirtualRun activities', () => { + const result = IntervalsActivitySchema.safeParse({ ...validActivity, type: 'VirtualRun' }); + expect(result.success).toBe(true); + }); + + it('accepts Track activities', () => { + const result = IntervalsActivitySchema.safeParse({ ...validActivity, type: 'Track' }); + expect(result.success).toBe(true); + }); + it('defaults velocity_smooth to empty array when not provided', () => { const { velocity_smooth, ...rest } = validActivity; const result = IntervalsActivitySchema.safeParse(rest); diff --git a/tests/domain/sprint/parser.test.ts b/tests/domain/sprint/parser.test.ts index 5c33381..1e62743 100644 --- a/tests/domain/sprint/parser.test.ts +++ b/tests/domain/sprint/parser.test.ts @@ -362,6 +362,46 @@ describe('SprintParser.fromAPIInterval — Intervals.icu /activity/{id}/interval }); expect(result).not.toBeNull(); }); + + it('accepts a short interval (≤30s) with low GPS average speed but valid max_speed', () => { + // GPS-measured average speed for very short laps (5s) can be unreliable + // due to approach/deceleration artefacts. This should still be accepted + // because duration ≤ 30s and average_speed ≥ 2.0 m/s. + const result = SprintParser.fromAPIInterval({ + type: 'WORK', + distance: 15, + moving_time: 5, + max_speed: 8.5, + average_speed: 2.95, // 5:39/km — below 4.0 m/s but above 2.0 m/s + }); + expect(result).not.toBeNull(); + expect(result!.distance).toBe(15); + expect(result!.vMax).toBe(8.5); + }); + + it('rejects a short interval with average speed below 2.0 m/s', () => { + // Even for short intervals, sub-2.0 m/s is standing/walking — not a sprint + const result = SprintParser.fromAPIInterval({ + type: 'WORK', + distance: 15, + moving_time: 10, + max_speed: 5.0, + average_speed: 1.5, + }); + expect(result).toBeNull(); + }); + + it('still rejects longer intervals (>30s) with average speed below 4.0 m/s', () => { + // Longer intervals use the standard 4.0 m/s floor + const result = SprintParser.fromAPIInterval({ + type: 'WORK', + distance: 100, + moving_time: 35, + max_speed: 6.0, + average_speed: 2.86, // above 2.0 but below 4.0 — rejected for long intervals + }); + expect(result).toBeNull(); + }); }); describe('SprintParser.parseTrackSession — 400m upper-distance filter', () => { From f11f1019e7003f539b61d5e1f2519c33363e5e78 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 08:08:07 +0000 Subject: [PATCH 2/7] Remove redundant individual run-type tests; keep comprehensive loop test Agent-Logs-Url: https://github.com/MaximumTrainer/SilverSprint/sessions/fc9c0d66-3d33-4abb-bfa2-fcaafa6df2a1 Co-authored-by: MaximumTrainer <1376575+MaximumTrainer@users.noreply.github.com> --- tests/domain/schema.test.ts | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/tests/domain/schema.test.ts b/tests/domain/schema.test.ts index 6e77480..b6026cd 100644 --- a/tests/domain/schema.test.ts +++ b/tests/domain/schema.test.ts @@ -42,21 +42,6 @@ describe('IntervalsActivitySchema (§2.2)', () => { } }); - it('accepts TrailRun activities', () => { - const result = IntervalsActivitySchema.safeParse({ ...validActivity, type: 'TrailRun' }); - expect(result.success).toBe(true); - }); - - it('accepts VirtualRun activities', () => { - const result = IntervalsActivitySchema.safeParse({ ...validActivity, type: 'VirtualRun' }); - expect(result.success).toBe(true); - }); - - it('accepts Track activities', () => { - const result = IntervalsActivitySchema.safeParse({ ...validActivity, type: 'Track' }); - expect(result.success).toBe(true); - }); - it('defaults velocity_smooth to empty array when not provided', () => { const { velocity_smooth, ...rest } = validActivity; const result = IntervalsActivitySchema.safeParse(rest); From 17b2241fd4584ba5e019f0c899a1a63a62383cad Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 08:21:45 +0000 Subject: [PATCH 3/7] Add comprehensive detection tests for 30m, 60m, 90m, 100m, 150m sprint distances Agent-Logs-Url: https://github.com/MaximumTrainer/SilverSprint/sessions/7eda597d-b3c7-4011-8d9d-7b526b0084cf Co-authored-by: MaximumTrainer <1376575+MaximumTrainer@users.noreply.github.com> --- tests/domain/sprint/parser.test.ts | 243 +++++++++++++++++++++++++++++ 1 file changed, 243 insertions(+) diff --git a/tests/domain/sprint/parser.test.ts b/tests/domain/sprint/parser.test.ts index 1e62743..80c3a1a 100644 --- a/tests/domain/sprint/parser.test.ts +++ b/tests/domain/sprint/parser.test.ts @@ -404,6 +404,249 @@ describe('SprintParser.fromAPIInterval — Intervals.icu /activity/{id}/interval }); }); +/** + * Common sprint distances — ensure 30m, 60m, 90m, 100m, 150m are all detected + * from both API intervals and velocity streams with realistic time & pace data. + * + * Reference paces (recreational → competitive): + * 30m: 4.5–5.5s (avg 5.5–6.7 m/s) + * 60m: 7.5–9.5s (avg 6.3–8.0 m/s) + * 90m: 11–14s (avg 6.4–8.2 m/s) + * 100m: 12–16s (avg 6.3–8.3 m/s) + * 150m: 19–25s (avg 6.0–7.9 m/s) + */ +describe('SprintParser — common sprint distance detection (30m, 60m, 90m, 100m, 150m)', () => { + // ----- 30m ----- + it('detects a 30m sprint from API interval (competitive: 4.2s)', () => { + const result = SprintParser.fromAPIInterval({ + type: 'WORK', + distance: 30, + moving_time: 4.2, + max_speed: 9.5, + average_speed: 7.14, // 30 / 4.2 + }); + expect(result).not.toBeNull(); + expect(result!.distance).toBe(30); + expect(result!.type).toBe('Acceleration'); + expect(result!.vMax).toBe(9.5); + expect(result!.duration).toBe(4.2); + expect(result!.flyingVelocity).toBe(7.14); + }); + + it('detects a 30m sprint from API interval (recreational: 5.5s)', () => { + const result = SprintParser.fromAPIInterval({ + type: 'WORK', + distance: 30, + moving_time: 5.5, + max_speed: 7.5, + average_speed: 5.45, // 30 / 5.5 + }); + expect(result).not.toBeNull(); + expect(result!.distance).toBe(30); + expect(result!.type).toBe('Acceleration'); + }); + + it('detects a 30m sprint from velocity stream', () => { + // ~30m: 4s acceleration phase then stop + const stream = [0, 0, 3.0, 6.0, 8.5, 9.5, 3.0, 0]; + const result = SprintParser.parseTrackSession({ velocity_smooth: stream }); + expect(result.length).toBeGreaterThanOrEqual(1); + expect(result[0].type).toBe('Acceleration'); + expect(result[0].distance).toBeGreaterThanOrEqual(10); + expect(result[0].distance).toBeLessThanOrEqual(40); + }); + + // ----- 60m ----- + it('detects a 60m sprint from API interval (competitive: 7.5s)', () => { + const result = SprintParser.fromAPIInterval({ + type: 'WORK', + distance: 60, + moving_time: 7.5, + max_speed: 10.2, + average_speed: 8.0, // 60 / 7.5 + }); + expect(result).not.toBeNull(); + expect(result!.distance).toBe(60); + expect(result!.type).toBe('MaxVelocity'); + expect(result!.vMax).toBe(10.2); + expect(result!.duration).toBe(7.5); + expect(result!.flyingVelocity).toBe(8.0); + }); + + it('detects a 60m sprint from API interval (recreational: 9.5s)', () => { + const result = SprintParser.fromAPIInterval({ + type: 'WORK', + distance: 60, + moving_time: 9.5, + max_speed: 7.8, + average_speed: 6.32, // 60 / 9.5 + }); + expect(result).not.toBeNull(); + expect(result!.distance).toBe(60); + expect(result!.type).toBe('MaxVelocity'); + }); + + it('detects a 60m sprint from velocity stream', () => { + // ~60m burst: accel then maintain speed + const stream = [0, 0, 3.0, 6.0, 9.0, 10.0, 10.0, 10.0, 9.5, 3.0, 0]; + const result = SprintParser.parseTrackSession({ velocity_smooth: stream }); + expect(result.length).toBeGreaterThanOrEqual(1); + expect(result[0].type).toBe('MaxVelocity'); + expect(result[0].distance).toBeGreaterThanOrEqual(40); + expect(result[0].distance).toBeLessThanOrEqual(80); + }); + + // ----- 90m ----- + it('detects a 90m sprint from API interval (competitive: 11s)', () => { + const result = SprintParser.fromAPIInterval({ + type: 'WORK', + distance: 90, + moving_time: 11, + max_speed: 10.5, + average_speed: 8.18, // 90 / 11 + }); + expect(result).not.toBeNull(); + expect(result!.distance).toBe(90); + expect(result!.type).toBe('SpeedEndurance'); + expect(result!.vMax).toBe(10.5); + }); + + it('detects a 90m sprint from API interval (recreational: 14s)', () => { + const result = SprintParser.fromAPIInterval({ + type: 'WORK', + distance: 90, + moving_time: 14, + max_speed: 8.0, + average_speed: 6.43, // 90 / 14 + }); + expect(result).not.toBeNull(); + expect(result!.distance).toBe(90); + expect(result!.type).toBe('SpeedEndurance'); + }); + + it('detects a 90m sprint from velocity stream', () => { + // ~90m burst: accel then 10s at ~8-9 m/s + const stream = [0, 0, 3.0, 6.0, 8.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 3.0, 0]; + const result = SprintParser.parseTrackSession({ velocity_smooth: stream }); + expect(result.length).toBeGreaterThanOrEqual(1); + expect(result[0].type).toBe('SpeedEndurance'); + expect(result[0].distance).toBeGreaterThanOrEqual(80); + expect(result[0].distance).toBeLessThanOrEqual(150); + }); + + // ----- 100m ----- + it('detects a 100m sprint from API interval (competitive: 12s)', () => { + const result = SprintParser.fromAPIInterval({ + type: 'WORK', + distance: 100, + moving_time: 12, + max_speed: 10.8, + average_speed: 8.33, // 100 / 12 + }); + expect(result).not.toBeNull(); + expect(result!.distance).toBe(100); + expect(result!.type).toBe('SpeedEndurance'); + expect(result!.vMax).toBe(10.8); + expect(result!.duration).toBe(12); + expect(result!.flyingVelocity).toBe(8.33); + }); + + it('detects a 100m sprint from API interval (recreational: 16s)', () => { + const result = SprintParser.fromAPIInterval({ + type: 'WORK', + distance: 100, + moving_time: 16, + max_speed: 8.0, + average_speed: 6.25, // 100 / 16 + }); + expect(result).not.toBeNull(); + expect(result!.distance).toBe(100); + expect(result!.type).toBe('SpeedEndurance'); + }); + + it('detects a 100m sprint from velocity stream', () => { + // ~100m burst: accel then sustain at ~9 m/s + const stream = [ + 0, 0, + 3.0, 6.0, 8.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, + 3.0, 0, + ]; + const result = SprintParser.parseTrackSession({ velocity_smooth: stream }); + expect(result.length).toBeGreaterThanOrEqual(1); + expect(result[0].type).toBe('SpeedEndurance'); + expect(result[0].distance).toBeGreaterThanOrEqual(80); + expect(result[0].distance).toBeLessThanOrEqual(150); + }); + + // ----- 150m ----- + it('detects a 150m sprint from API interval (competitive: 19s)', () => { + const result = SprintParser.fromAPIInterval({ + type: 'WORK', + distance: 150, + moving_time: 19, + max_speed: 10.5, + average_speed: 7.89, // 150 / 19 + }); + expect(result).not.toBeNull(); + expect(result!.distance).toBe(150); + expect(result!.type).toBe('SpeedEndurance'); + expect(result!.vMax).toBe(10.5); + }); + + it('detects a 150m sprint from API interval (recreational: 25s)', () => { + const result = SprintParser.fromAPIInterval({ + type: 'WORK', + distance: 150, + moving_time: 25, + max_speed: 8.0, + average_speed: 6.0, // 150 / 25 + }); + expect(result).not.toBeNull(); + expect(result!.distance).toBe(150); + expect(result!.type).toBe('SpeedEndurance'); + }); + + it('detects a 150m sprint from velocity stream', () => { + // ~150m burst: accel phase + sustained running at ~9 m/s + const stream = [ + 0, 0, + 3.0, 6.0, 8.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, + 3.0, 0, + ]; + const result = SprintParser.parseTrackSession({ velocity_smooth: stream }); + expect(result.length).toBeGreaterThanOrEqual(1); + expect(result[0].type).toBe('SpeedEndurance'); + expect(result[0].distance).toBeGreaterThanOrEqual(80); + expect(result[0].distance).toBeLessThanOrEqual(160); + }); + + // ----- Multi-rep session: typical sprint workout with varied distances ----- + it('detects multiple sprint distances in a single API interval set', () => { + const intervals = [ + { type: 'WORK', distance: 30, moving_time: 4.5, max_speed: 9.0, average_speed: 6.67 }, + { type: 'REST', distance: 30, moving_time: 120, max_speed: 1.5, average_speed: 0.25 }, + { type: 'WORK', distance: 60, moving_time: 8, max_speed: 9.5, average_speed: 7.5 }, + { type: 'REST', distance: 60, moving_time: 180, max_speed: 1.2, average_speed: 0.33 }, + { type: 'WORK', distance: 100, moving_time: 13, max_speed: 10.0, average_speed: 7.69 }, + { type: 'REST', distance: 100, moving_time: 240, max_speed: 1.0, average_speed: 0.42 }, + { type: 'WORK', distance: 150, moving_time: 20, max_speed: 10.2, average_speed: 7.5 }, + ]; + const detected = intervals + .map(i => SprintParser.fromAPIInterval(i)) + .filter((i): i is TrackInterval => i !== null); + + expect(detected.length).toBe(4); // 4 WORK intervals, 3 REST excluded + expect(detected[0].distance).toBe(30); + expect(detected[0].type).toBe('Acceleration'); + expect(detected[1].distance).toBe(60); + expect(detected[1].type).toBe('MaxVelocity'); + expect(detected[2].distance).toBe(100); + expect(detected[2].type).toBe('SpeedEndurance'); + expect(detected[3].distance).toBe(150); + expect(detected[3].type).toBe('SpeedEndurance'); + }); +}); + describe('SprintParser.parseTrackSession — 400m upper-distance filter', () => { it('excludes velocity-stream bursts longer than 400m', () => { // Simulate a 500m easy jog at 4 m/s for 125 seconds → distance ~500m From b60943d955bbdce1a15111315cfa926eec21058f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 08:37:18 +0000 Subject: [PATCH 4/7] Address PR review: deduplicate schema, add fallback logging, use /streams endpoint, limit concurrency Agent-Logs-Url: https://github.com/MaximumTrainer/SilverSprint/sessions/ffde43e1-68ad-4464-80a8-6c92388534a2 Co-authored-by: MaximumTrainer <1376575+MaximumTrainer@users.noreply.github.com> --- src/hooks/useIntervalsData.ts | 80 +++++++++++++++++++++------------- src/schema.ts | 82 +++++++---------------------------- tests/domain/schema.test.ts | 2 +- 3 files changed, 66 insertions(+), 98 deletions(-) diff --git a/src/hooks/useIntervalsData.ts b/src/hooks/useIntervalsData.ts index 8b59bfb..ddcd798 100644 --- a/src/hooks/useIntervalsData.ts +++ b/src/hooks/useIntervalsData.ts @@ -237,41 +237,59 @@ export const useIntervalsData = (athleteId: string, accessToken: string, authTyp ); // Merge: for each activity use API intervals when available, else fall back - // to fetching the full activity detail (for its velocity_smooth stream) and + // to fetching the activity's /streams endpoint (for its velocity_smooth) and // parsing it. The activity list endpoint omits velocity_smooth, so the - // parseTrackSession fallback only works when the full detail is fetched. - const allTrainingIntervals = (await Promise.all( - activitiesForIntervals.map(async (a, idx) => { - const result = intervalFetches[idx]; - if (result.status === 'fulfilled' && result.value.intervals.length > 0) { - return result.value.intervals; - } + // parseTrackSession fallback only works when the stream is fetched separately. + // To avoid an unbounded burst of fallback requests (rate-limit risk), we + // process activities that need a fallback sequentially. + const allTrainingIntervals: TrackInterval[] = []; + for (let idx = 0; idx < activitiesForIntervals.length; idx++) { + const a = activitiesForIntervals[idx]; + const result = intervalFetches[idx]; + if (result.status === 'fulfilled' && result.value.intervals.length > 0) { + allTrainingIntervals.push(...result.value.intervals); + continue; + } + + // Attempt to parse from the activity's velocity_smooth (may be populated + // by the list endpoint on some accounts). + const streamIntervals = SprintParser.parseTrackSession(a); + if (streamIntervals.length > 0) { + allTrainingIntervals.push(...streamIntervals); + continue; + } - // Attempt to parse from the activity's velocity_smooth (may be populated - // by the list endpoint on some accounts). - const streamIntervals = SprintParser.parseTrackSession(a); - if (streamIntervals.length > 0) return streamIntervals; - - // Last resort: fetch the full activity detail to get its velocity_smooth - // stream, then parse that. This is an extra API call per activity that - // lacked both interval and stream data. - try { - const detailRes = await fetch( - `${INTERVALS_BASE}/api/v1/activity/${a.id}`, - { headers } + // Last resort: fetch the activity's /streams endpoint to get velocity_smooth. + // This is an extra API call per activity that lacked both interval and + // list-level stream data. Sequential processing avoids rate-limit bursts. + try { + const streamsRes = await fetch( + `${INTERVALS_BASE}/api/v1/activity/${a.id}/streams`, + { headers } + ); + if (!streamsRes.ok) { + clientLogger.warn( + `Failed to fetch velocity stream for activity ${a.id}: ${streamsRes.status} ${streamsRes.statusText}`, + athleteId ); - if (!detailRes.ok) return []; - const detail = await detailRes.json(); - const velocitySmooth: number[] = Array.isArray(detail?.velocity_smooth) - ? detail.velocity_smooth - : []; - if (velocitySmooth.length === 0) return []; - return SprintParser.parseTrackSession({ velocity_smooth: velocitySmooth }); - } catch { - return []; + continue; } - }) - )).flat(); + const streams = await streamsRes.json(); + const velocitySmooth: number[] = Array.isArray(streams?.velocity_smooth?.data) + ? streams.velocity_smooth.data + : Array.isArray(streams?.velocity_smooth) ? streams.velocity_smooth : []; + if (velocitySmooth.length === 0) continue; + allTrainingIntervals.push( + ...SprintParser.parseTrackSession({ velocity_smooth: velocitySmooth }) + ); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + clientLogger.warn( + `Failed to fetch or parse velocity stream for activity ${a.id}: ${reason}`, + athleteId + ); + } + } // Aggregate total training load from ALL interval types across recent sessions. // This captures non-sprint load (warmup, cooldown, rest) that would otherwise diff --git a/src/schema.ts b/src/schema.ts index 526e7ad..1140f7f 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -1,68 +1,18 @@ -import { z } from 'zod'; - /** - * Activity types from Intervals.icu that represent running. - * The API uses Strava sport-type strings; we accept all run sub-types - * so that trail runs, virtual runs, and track sessions are not silently dropped. + * Legacy schema re-export — all types live in src/domain/schema.ts. + * This file exists for backward compatibility with older import paths. */ -export const RUN_ACTIVITY_TYPES = [ - 'Run', - 'TrailRun', - 'VirtualRun', - 'Track', - 'TrackAndField', - 'Treadmill', -] as const; - -export const IntervalsActivitySchema = z.object({ - id: z.string(), - type: z.enum(RUN_ACTIVITY_TYPES), - start_date_local: z.string().optional(), - velocity_smooth: z.array(z.number()).default([]), - max_speed: z.number(), - icu_training_load: z.number(), - icu_atl: z.number(), // Fatigue - icu_ctl: z.number(), // Fitness -}); - -export type IntervalsActivity = z.infer; - -export const IntervalsWellnessSchema = z.object({ - id: z.string(), - date: z.string().optional(), - hrv: z.number().optional(), - restingHR: z.number().optional(), - readiness: z.number().optional(), - weight: z.number().optional(), -}); - -export type IntervalsWellness = z.infer; - -export const IntervalsEventSchema = z.object({ - id: z.union([z.string(), z.number()]).transform(String), - category: z.string(), - start_date_local: z.string(), - name: z.string().nullish(), - type: z.string().nullish(), - /** Distance in metres (planned distance on the event) */ - distance: z.number().nullish(), - /** Distance target in metres (alternative field for planned races) */ - distance_target: z.number().nullish(), -}); - -export type IntervalsEvent = z.infer; - -export const IntervalsAthleteSchema = z.object({ - id: z.union([z.string(), z.number()]).transform(String), - name: z.string().nullable().optional(), - /** Date of birth, ISO format e.g. "1980-06-15" */ - icu_date_of_birth: z.string().nullable().optional(), - /** Body weight in kg (from Strava sync) */ - weight: z.number().nullable().optional(), - /** Body weight in kg (Intervals.icu setting) */ - icu_weight: z.number().nullable().optional(), - /** Sex: "M" | "F" | "X" */ - sex: z.string().nullable().optional(), -}); - -export type IntervalsAthlete = z.infer; \ No newline at end of file +export { + RUN_ACTIVITY_TYPES, + IntervalsActivitySchema, + IntervalsWellnessSchema, + IntervalsEventSchema, + IntervalsAthleteSchema, +} from './domain/schema'; + +export type { + IntervalsActivity, + IntervalsWellness, + IntervalsEvent, + IntervalsAthlete, +} from './domain/schema'; \ No newline at end of file diff --git a/tests/domain/schema.test.ts b/tests/domain/schema.test.ts index b6026cd..244a55c 100644 --- a/tests/domain/schema.test.ts +++ b/tests/domain/schema.test.ts @@ -8,7 +8,7 @@ import { IntervalsActivitySchema, IntervalsWellnessSchema, IntervalsIntervalSche * The spec requires: * id: z.string() * type: z.enum(RUN_ACTIVITY_TYPES) — accepted run sub-types - * velocity_smooth: z.array(z.number()) — required + * velocity_smooth: z.array(z.number()) — optional, defaults to [] * max_speed: z.number() — required * icu_training_load: z.number() * icu_atl: z.number() — Fatigue From fa5dfc704ccb2ff0a785d95c8504e52870174177 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 08:50:10 +0000 Subject: [PATCH 5/7] =?UTF-8?q?Update=20sprint=20detection:=20pace=20>=203?= =?UTF-8?q?:00/km,=20duration=20=E2=89=A4=2025s,=20add=20120m=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/MaximumTrainer/SilverSprint/sessions/b4d7be9f-3508-4902-bb5e-328b6a3d9f75 Co-authored-by: MaximumTrainer <1376575+MaximumTrainer@users.noreply.github.com> --- src/domain/sprint/parser.ts | 55 +++++++------ tests/domain/sprint/parser.test.ts | 124 +++++++++++++++++++++-------- 2 files changed, 121 insertions(+), 58 deletions(-) diff --git a/src/domain/sprint/parser.ts b/src/domain/sprint/parser.ts index f6a0d8b..70d26ff 100644 --- a/src/domain/sprint/parser.ts +++ b/src/domain/sprint/parser.ts @@ -33,26 +33,18 @@ export class SprintParser { private static readonly REST_INTERVAL_TYPES = ['REST', 'ACTIVE_REST', 'WARMUP', 'COOLDOWN', 'RECOVERY'] as const; /** - * Minimum average speed (m/s) to be considered a sprint effort for longer intervals (> 30s). - * Filters out rest periods that are labelled WORK by Intervals.icu - * (e.g. 300-second walk-back recovery intervals with avg 0.2–0.6 m/s). - * Even a standing-start 10 m sprint has an average speed > 4 m/s. + * Minimum average speed (m/s) for a sprint interval, derived from a + * 3:00 min/km pace: 1000 m / 180 s ≈ 5.556 m/s. + * Anything slower is not a sprint effort (jog / walk-back / rest). */ - private static readonly MIN_SPRINT_AVG_SPEED = 4.0; // m/s ≈ 14.4 km/h + private static readonly MIN_SPRINT_PACE_SPEED = 1000 / 180; // ≈ 5.556 m/s — pace < 3:00/km /** - * Lower average-speed floor for short intervals (≤ 30s). - * GPS-measured average speed for sub-10s laps is unreliable because the lap - * boundary may include approach/deceleration time. We still reject obvious - * rest intervals (e.g. standing around at < 2 m/s). + * Maximum duration (seconds) for a sprint interval. + * Sprint efforts (30–150 m) are completed in under 25 s even at recreational + * pace. Longer intervals are recovery jogs or distance-running laps. */ - private static readonly MIN_SHORT_INTERVAL_AVG_SPEED = 2.0; // m/s ≈ 7.2 km/h - - /** - * Duration threshold (seconds): intervals at or below this duration use the - * lower average-speed floor ({@link MIN_SHORT_INTERVAL_AVG_SPEED}). - */ - private static readonly SHORT_INTERVAL_DURATION = 30; // seconds + private static readonly MAX_SPRINT_DURATION = 25; // seconds /** * Parse a full session's velocity_smooth stream into classified intervals. @@ -92,6 +84,17 @@ export class SprintParser { return null; } + // Reject bursts longer than the maximum sprint duration + if (burst.length > this.MAX_SPRINT_DURATION) { + return null; + } + + // Reject bursts with average pace slower than 3:00/km + const avgSpeed = distance / burst.length; + if (avgSpeed < this.MIN_SPRINT_PACE_SPEED) { + return null; + } + const vMax = Math.max(...burst); const flyingVelocity = this.computeFlyingVelocity(burst); const type = this.classifyType(distance); @@ -152,16 +155,16 @@ export class SprintParser { return null; } - // Reject rest periods that Intervals.icu labels as WORK: they have very low - // average speed (e.g. 0.2–0.6 m/s walk-back) even though max_speed may be - // non-zero (residual from the preceding sprint). Any real sprint effort — - // even a short standing-start — produces an average speed above this floor. - // Short intervals (≤ 30s) use a lower threshold because GPS-measured average - // speed is unreliable for sub-10s laps (approach/deceleration artefacts). - const speedFloor = duration <= this.SHORT_INTERVAL_DURATION - ? this.MIN_SHORT_INTERVAL_AVG_SPEED - : this.MIN_SPRINT_AVG_SPEED; - if (flyingVelocity > 0 && flyingVelocity < speedFloor) { + // Reject intervals that exceed the maximum sprint duration (25 s). + if (duration > this.MAX_SPRINT_DURATION) { + return null; + } + + // Reject intervals with average pace slower than 3:00/km. + // Computed from distance/time (not the API's average_speed) so the check + // is always applied, even when the API omits average_speed. + const computedAvgSpeed = distance / duration; + if (computedAvgSpeed < this.MIN_SPRINT_PACE_SPEED) { return null; } diff --git a/tests/domain/sprint/parser.test.ts b/tests/domain/sprint/parser.test.ts index 80c3a1a..1d8bdc0 100644 --- a/tests/domain/sprint/parser.test.ts +++ b/tests/domain/sprint/parser.test.ts @@ -277,7 +277,8 @@ describe('SprintParser.fromAPIInterval — Intervals.icu /activity/{id}/interval expect(result!.type).toBe('MaxVelocity'); }); - it('accepts a 400m interval (boundary — longest sprint event)', () => { + it('rejects a 400m interval (duration exceeds 25 s sprint cap)', () => { + // 400m sprint at 52 s is well above the 25 s maximum sprint duration const result = SprintParser.fromAPIInterval({ type: 'WORK', distance: 400, @@ -285,9 +286,7 @@ describe('SprintParser.fromAPIInterval — Intervals.icu /activity/{id}/interval max_speed: 9.0, average_speed: 7.7, }); - expect(result).not.toBeNull(); - expect(result!.distance).toBe(400); - expect(result!.type).toBe('SpecialEndurance'); + expect(result).toBeNull(); }); it('excludes intervals longer than 400m', () => { @@ -352,8 +351,8 @@ describe('SprintParser.fromAPIInterval — Intervals.icu /activity/{id}/interval expect(result).toBeNull(); }); - it('accepts a WORK interval without average_speed (speed-only device)', () => { - // When average_speed is absent the minimum-speed check is skipped + it('accepts a WORK interval without average_speed (computed pace used)', () => { + // average_speed absent → pace check uses computed distance/time = 8.0 m/s const result = SprintParser.fromAPIInterval({ type: 'WORK', distance: 40, @@ -363,24 +362,20 @@ describe('SprintParser.fromAPIInterval — Intervals.icu /activity/{id}/interval expect(result).not.toBeNull(); }); - it('accepts a short interval (≤30s) with low GPS average speed but valid max_speed', () => { - // GPS-measured average speed for very short laps (5s) can be unreliable - // due to approach/deceleration artefacts. This should still be accepted - // because duration ≤ 30s and average_speed ≥ 2.0 m/s. + it('rejects a short interval with computed pace slower than 3:00/km', () => { + // 15m in 5s → computed avg speed = 3.0 m/s (5:33/km) — below 3:00/km threshold const result = SprintParser.fromAPIInterval({ type: 'WORK', distance: 15, moving_time: 5, max_speed: 8.5, - average_speed: 2.95, // 5:39/km — below 4.0 m/s but above 2.0 m/s + average_speed: 2.95, }); - expect(result).not.toBeNull(); - expect(result!.distance).toBe(15); - expect(result!.vMax).toBe(8.5); + expect(result).toBeNull(); }); - it('rejects a short interval with average speed below 2.0 m/s', () => { - // Even for short intervals, sub-2.0 m/s is standing/walking — not a sprint + it('rejects an interval with computed pace slower than 3:00/km', () => { + // 15m in 10s → computed avg speed = 1.5 m/s — well below pace threshold const result = SprintParser.fromAPIInterval({ type: 'WORK', distance: 15, @@ -391,31 +386,34 @@ describe('SprintParser.fromAPIInterval — Intervals.icu /activity/{id}/interval expect(result).toBeNull(); }); - it('still rejects longer intervals (>30s) with average speed below 4.0 m/s', () => { - // Longer intervals use the standard 4.0 m/s floor + it('rejects intervals exceeding 25 s (sprint duration cap)', () => { + // 100m in 35 s — duration exceeds max sprint duration even though pace is fast enough const result = SprintParser.fromAPIInterval({ type: 'WORK', distance: 100, moving_time: 35, max_speed: 6.0, - average_speed: 2.86, // above 2.0 but below 4.0 — rejected for long intervals + average_speed: 2.86, }); expect(result).toBeNull(); }); }); /** - * Common sprint distances — ensure 30m, 60m, 90m, 100m, 150m are all detected + * Common sprint distances — ensure 30m, 60m, 90m, 100m, 120m, 150m are all detected * from both API intervals and velocity streams with realistic time & pace data. * + * Criteria: pace faster than 3:00/km (>5.56 m/s) and duration ≤ 25 s. + * * Reference paces (recreational → competitive): - * 30m: 4.5–5.5s (avg 5.5–6.7 m/s) + * 30m: 4.2–5.3s (avg 5.7–7.1 m/s) * 60m: 7.5–9.5s (avg 6.3–8.0 m/s) * 90m: 11–14s (avg 6.4–8.2 m/s) * 100m: 12–16s (avg 6.3–8.3 m/s) + * 120m: 15–20s (avg 6.0–8.0 m/s) * 150m: 19–25s (avg 6.0–7.9 m/s) */ -describe('SprintParser — common sprint distance detection (30m, 60m, 90m, 100m, 150m)', () => { +describe('SprintParser — common sprint distance detection (30m, 60m, 90m, 100m, 120m, 150m)', () => { // ----- 30m ----- it('detects a 30m sprint from API interval (competitive: 4.2s)', () => { const result = SprintParser.fromAPIInterval({ @@ -433,13 +431,13 @@ describe('SprintParser — common sprint distance detection (30m, 60m, 90m, 100m expect(result!.flyingVelocity).toBe(7.14); }); - it('detects a 30m sprint from API interval (recreational: 5.5s)', () => { + it('detects a 30m sprint from API interval (recreational: 5.0s)', () => { const result = SprintParser.fromAPIInterval({ type: 'WORK', distance: 30, - moving_time: 5.5, + moving_time: 5.0, max_speed: 7.5, - average_speed: 5.45, // 30 / 5.5 + average_speed: 6.0, // 30 / 5.0 = 6.0 m/s — above 3:00/km threshold }); expect(result).not.toBeNull(); expect(result!.distance).toBe(30); @@ -578,6 +576,50 @@ describe('SprintParser — common sprint distance detection (30m, 60m, 90m, 100m expect(result[0].distance).toBeLessThanOrEqual(150); }); + // ----- 120m ----- + it('detects a 120m sprint from API interval (competitive: 15s)', () => { + const result = SprintParser.fromAPIInterval({ + type: 'WORK', + distance: 120, + moving_time: 15, + max_speed: 10.5, + average_speed: 8.0, // 120 / 15 + }); + expect(result).not.toBeNull(); + expect(result!.distance).toBe(120); + expect(result!.type).toBe('SpeedEndurance'); + expect(result!.vMax).toBe(10.5); + expect(result!.duration).toBe(15); + expect(result!.flyingVelocity).toBe(8.0); + }); + + it('detects a 120m sprint from API interval (recreational: 20s)', () => { + const result = SprintParser.fromAPIInterval({ + type: 'WORK', + distance: 120, + moving_time: 20, + max_speed: 8.0, + average_speed: 6.0, // 120 / 20 + }); + expect(result).not.toBeNull(); + expect(result!.distance).toBe(120); + expect(result!.type).toBe('SpeedEndurance'); + }); + + it('detects a 120m sprint from velocity stream', () => { + // ~120m burst: accel phase + sustained ~9 m/s + const stream = [ + 0, 0, + 3.0, 6.0, 8.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, + 3.0, 0, + ]; + const result = SprintParser.parseTrackSession({ velocity_smooth: stream }); + expect(result.length).toBeGreaterThanOrEqual(1); + expect(result[0].type).toBe('SpeedEndurance'); + expect(result[0].distance).toBeGreaterThanOrEqual(80); + expect(result[0].distance).toBeLessThanOrEqual(150); + }); + // ----- 150m ----- it('detects a 150m sprint from API interval (competitive: 19s)', () => { const result = SprintParser.fromAPIInterval({ @@ -629,25 +671,29 @@ describe('SprintParser — common sprint distance detection (30m, 60m, 90m, 100m { type: 'REST', distance: 60, moving_time: 180, max_speed: 1.2, average_speed: 0.33 }, { type: 'WORK', distance: 100, moving_time: 13, max_speed: 10.0, average_speed: 7.69 }, { type: 'REST', distance: 100, moving_time: 240, max_speed: 1.0, average_speed: 0.42 }, + { type: 'WORK', distance: 120, moving_time: 17, max_speed: 10.0, average_speed: 7.06 }, + { type: 'REST', distance: 100, moving_time: 240, max_speed: 1.0, average_speed: 0.42 }, { type: 'WORK', distance: 150, moving_time: 20, max_speed: 10.2, average_speed: 7.5 }, ]; const detected = intervals .map(i => SprintParser.fromAPIInterval(i)) .filter((i): i is TrackInterval => i !== null); - expect(detected.length).toBe(4); // 4 WORK intervals, 3 REST excluded + expect(detected.length).toBe(5); // 5 WORK intervals, 4 REST excluded expect(detected[0].distance).toBe(30); expect(detected[0].type).toBe('Acceleration'); expect(detected[1].distance).toBe(60); expect(detected[1].type).toBe('MaxVelocity'); expect(detected[2].distance).toBe(100); expect(detected[2].type).toBe('SpeedEndurance'); - expect(detected[3].distance).toBe(150); + expect(detected[3].distance).toBe(120); expect(detected[3].type).toBe('SpeedEndurance'); + expect(detected[4].distance).toBe(150); + expect(detected[4].type).toBe('SpeedEndurance'); }); }); -describe('SprintParser.parseTrackSession — 400m upper-distance filter', () => { +describe('SprintParser.parseTrackSession — distance and duration filters', () => { it('excludes velocity-stream bursts longer than 400m', () => { // Simulate a 500m easy jog at 4 m/s for 125 seconds → distance ~500m const stream = Array(125).fill(4.0); @@ -655,12 +701,26 @@ describe('SprintParser.parseTrackSession — 400m upper-distance filter', () => expect(result).toEqual([]); }); - it('accepts a 400m velocity-stream burst at sprint pace', () => { - // 400m sprint: 44 seconds at 9 m/s → distance 396m ≤ 400m + it('excludes velocity-stream bursts exceeding 25 s (sprint duration cap)', () => { + // 400m sprint: 44 seconds at 9 m/s → fast pace but duration > 25 s const stream = Array(44).fill(9.0); const result = SprintParser.parseTrackSession({ velocity_smooth: stream }); + expect(result).toEqual([]); + }); + + it('excludes velocity-stream bursts with pace slower than 3:00/km', () => { + // 20 seconds at 4 m/s → 80m, avg speed 4.0 m/s (4:10/km) — below 3:00/km + const stream = Array(20).fill(4.0); + const result = SprintParser.parseTrackSession({ velocity_smooth: stream }); + expect(result).toEqual([]); + }); + + it('accepts a sprint-range burst within duration and pace limits', () => { + // 15 seconds at 9 m/s → 135m, avg 9.0 m/s (1:51/km), duration 15 s ≤ 25 + const stream = Array(15).fill(9.0); + const result = SprintParser.parseTrackSession({ velocity_smooth: stream }); expect(result.length).toBe(1); - expect(result[0].type).toBe('SpecialEndurance'); - expect(result[0].distance).toBeLessThanOrEqual(400); + expect(result[0].type).toBe('SpeedEndurance'); + expect(result[0].distance).toBeLessThanOrEqual(150); }); }); From 61aa0d0fa9912e95c1ab5396faaa1c5eb12717af Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 08:51:50 +0000 Subject: [PATCH 6/7] Polish test names for clarity on sprint detection filter criteria Agent-Logs-Url: https://github.com/MaximumTrainer/SilverSprint/sessions/b4d7be9f-3508-4902-bb5e-328b6a3d9f75 Co-authored-by: MaximumTrainer <1376575+MaximumTrainer@users.noreply.github.com> --- tests/domain/sprint/parser.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/domain/sprint/parser.test.ts b/tests/domain/sprint/parser.test.ts index 1d8bdc0..6b45ca4 100644 --- a/tests/domain/sprint/parser.test.ts +++ b/tests/domain/sprint/parser.test.ts @@ -277,7 +277,7 @@ describe('SprintParser.fromAPIInterval — Intervals.icu /activity/{id}/interval expect(result!.type).toBe('MaxVelocity'); }); - it('rejects a 400m interval (duration exceeds 25 s sprint cap)', () => { + it('rejects a 400m interval (52 s duration exceeds 25 s sprint cap)', () => { // 400m sprint at 52 s is well above the 25 s maximum sprint duration const result = SprintParser.fromAPIInterval({ type: 'WORK', @@ -693,7 +693,7 @@ describe('SprintParser — common sprint distance detection (30m, 60m, 90m, 100m }); }); -describe('SprintParser.parseTrackSession — distance and duration filters', () => { +describe('SprintParser.parseTrackSession — 400m distance and 25s duration filters', () => { it('excludes velocity-stream bursts longer than 400m', () => { // Simulate a 500m easy jog at 4 m/s for 125 seconds → distance ~500m const stream = Array(125).fill(4.0); From 1c55708c040e812825c5a31a25e223049cb53939 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 09:08:25 +0000 Subject: [PATCH 7/7] Sanitize velocity stream data and fix duration-cap test fixture Agent-Logs-Url: https://github.com/MaximumTrainer/SilverSprint/sessions/dba94336-2fce-4422-aae1-da161c88a932 Co-authored-by: MaximumTrainer <1376575+MaximumTrainer@users.noreply.github.com> --- src/hooks/useIntervalsData.ts | 15 +++++++++++++-- tests/domain/sprint/parser.test.ts | 10 +++++----- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/hooks/useIntervalsData.ts b/src/hooks/useIntervalsData.ts index ddcd798..55321f2 100644 --- a/src/hooks/useIntervalsData.ts +++ b/src/hooks/useIntervalsData.ts @@ -275,10 +275,21 @@ export const useIntervalsData = (athleteId: string, accessToken: string, authTyp continue; } const streams = await streamsRes.json(); - const velocitySmooth: number[] = Array.isArray(streams?.velocity_smooth?.data) + const rawVelocitySmooth = Array.isArray(streams?.velocity_smooth?.data) ? streams.velocity_smooth.data : Array.isArray(streams?.velocity_smooth) ? streams.velocity_smooth : []; - if (velocitySmooth.length === 0) continue; + const velocitySmooth = rawVelocitySmooth.filter( + (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value) + ); + if (velocitySmooth.length === 0) { + if (rawVelocitySmooth.length > 0) { + clientLogger.warn( + `Skipping velocity stream for activity ${a.id}: stream contained no valid numeric samples`, + athleteId + ); + } + continue; + } allTrainingIntervals.push( ...SprintParser.parseTrackSession({ velocity_smooth: velocitySmooth }) ); diff --git a/tests/domain/sprint/parser.test.ts b/tests/domain/sprint/parser.test.ts index 6b45ca4..b3aa4b4 100644 --- a/tests/domain/sprint/parser.test.ts +++ b/tests/domain/sprint/parser.test.ts @@ -387,13 +387,13 @@ describe('SprintParser.fromAPIInterval — Intervals.icu /activity/{id}/interval }); it('rejects intervals exceeding 25 s (sprint duration cap)', () => { - // 100m in 35 s — duration exceeds max sprint duration even though pace is fast enough + // 160m in 26 s → computed avg speed ≈ 6.15 m/s, so pace passes while duration exceeds the 25 s cap const result = SprintParser.fromAPIInterval({ type: 'WORK', - distance: 100, - moving_time: 35, - max_speed: 6.0, - average_speed: 2.86, + distance: 160, + moving_time: 26, + max_speed: 8.0, + average_speed: 6.15, }); expect(result).toBeNull(); });