Skip to content
4 changes: 2 additions & 2 deletions api/index.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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
Expand Down
16 changes: 15 additions & 1 deletion src/domain/schema.ts
Original file line number Diff line number Diff line change
@@ -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(),
Expand Down
42 changes: 32 additions & 10 deletions src/domain/sprint/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +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.
* 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

/**
* 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 MAX_SPRINT_DURATION = 25; // seconds

/**
* Parse a full session's velocity_smooth stream into classified intervals.
Expand Down Expand Up @@ -78,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);
Expand Down Expand Up @@ -138,11 +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.
if (flyingVelocity > 0 && flyingVelocity < this.MIN_SPRINT_AVG_SPEED) {
// 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;
}

Expand Down
66 changes: 61 additions & 5 deletions src/hooks/useIntervalsData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,14 +237,70 @@ 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) => {
// 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 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) {
return result.value.intervals;
allTrainingIntervals.push(...result.value.intervals);
continue;
}
return SprintParser.parseTrackSession(a);
});

// 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;
}

// 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
);
continue;
}
const streams = await streamsRes.json();
const rawVelocitySmooth = Array.isArray(streams?.velocity_smooth?.data)
? streams.velocity_smooth.data
: Array.isArray(streams?.velocity_smooth) ? streams.velocity_smooth : [];
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 })
);
} 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
Expand Down
72 changes: 18 additions & 54 deletions src/schema.ts
Original file line number Diff line number Diff line change
@@ -1,54 +1,18 @@
import { z } from 'zod';

export const IntervalsActivitySchema = z.object({
id: z.string(),
type: z.literal('Run'),
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<typeof IntervalsActivitySchema>;

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<typeof IntervalsWellnessSchema>;

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<typeof IntervalsEventSchema>;

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<typeof IntervalsAthleteSchema>;
/**
* Legacy schema re-export — all types live in src/domain/schema.ts.
* This file exists for backward compatibility with older import paths.
*/
export {
RUN_ACTIVITY_TYPES,
IntervalsActivitySchema,
IntervalsWellnessSchema,
IntervalsEventSchema,
IntervalsAthleteSchema,
} from './domain/schema';

export type {
IntervalsActivity,
IntervalsWellness,
IntervalsEvent,
IntervalsAthlete,
} from './domain/schema';
4 changes: 2 additions & 2 deletions tests/api/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
15 changes: 11 additions & 4 deletions tests/domain/schema.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
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')
* velocity_smooth: z.array(z.number()) — required
* type: z.enum(RUN_ACTIVITY_TYPES) — accepted run sub-types
* velocity_smooth: z.array(z.number()) — optional, defaults to []
* max_speed: z.number() — required
* icu_training_load: z.number()
* icu_atl: z.number() — Fatigue
Expand All @@ -30,11 +30,18 @@ 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('defaults velocity_smooth to empty array when not provided', () => {
const { velocity_smooth, ...rest } = validActivity;
const result = IntervalsActivitySchema.safeParse(rest);
Expand Down
Loading
Loading