Skip to content

Fix sprint interval detection: accept run sub-types, pace-based speed filter, add velocity_smooth fallback fetch#30

Merged
MaximumTrainer merged 7 commits intomainfrom
copilot/investigate-silver-sprint-issue
Apr 14, 2026
Merged

Fix sprint interval detection: accept run sub-types, pace-based speed filter, add velocity_smooth fallback fetch#30
MaximumTrainer merged 7 commits intomainfrom
copilot/investigate-silver-sprint-issue

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 14, 2026

Sprint intervals from Intervals.icu were silently dropped, resulting in "No interval history — using Vmax model only" even when structured sprint data existed. Three independent filters each contributed to data loss.

Activity type filter too strict

IntervalsActivitySchema used z.literal('Run'), silently rejecting TrailRun, VirtualRun, Track, etc. Changed to z.enum(RUN_ACTIVITY_TYPES):

export const RUN_ACTIVITY_TYPES = [
  'Run', 'TrailRun', 'VirtualRun', 'Track', 'TrackAndField', 'Treadmill',
] as const;

Defined in src/domain/schema.ts as the single source of truth; src/schema.ts re-exports from it. Webhook handler's recent-activity filter also updated to use RUN_ACTIVITY_TYPES instead of hardcoded 'Run'.

Pace-based sprint detection with duration cap

Sprint interval detection now uses two criteria derived from distance, time, and pace:

  • Pace threshold: computed average speed (distance / time) must be faster than 3:00/km (≥ 5.56 m/s). This replaces the previous duration-aware speed floor (2.0 / 4.0 m/s).
  • Duration cap: intervals must be ≤ 25 seconds. Anything longer is rejected as a non-sprint effort.

Both checks are applied consistently to API interval parsing (fromAPIInterval()) and velocity stream parsing (classifyBurst()). Target sprint distances: 30m, 60m, 90m, 100m, 120m, 150m.

velocity_smooth fallback was always a no-op

The fallback SprintParser.parseTrackSession(a) always received an empty velocity_smooth array because the activity list endpoint omits it. Now fetches GET /api/v1/activity/{id}/streams to get the velocity stream when both the /intervals endpoint and the list-level stream yield nothing. Fallback fetches run sequentially to avoid rate-limit bursts, and failures are logged via clientLogger.warn with the activity ID and reason.

Sprint distance detection tests

Added comprehensive tests confirming detection of 30m, 60m, 90m, 100m, 120m, and 150m sprint intervals at both competitive and recreational paces, from both API interval data and velocity stream parsing, including a multi-rep workout scenario with all six distances.

Copilot AI and others added 2 commits April 14, 2026 08:06
…ld 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>
@MaximumTrainer
Copy link
Copy Markdown
Owner

@copilot ensure we can detected 30m, 60m, 90m, 100m, 150m distance intervals. These should be based on distance, time & pace

…t 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>
Copy link
Copy Markdown
Contributor Author

Copilot AI commented Apr 14, 2026

@copilot ensure we can detected 30m, 60m, 90m, 100m, 150m distance intervals. These should be based on distance, time & pace

Added 16 tests in 17b2241 covering detection of 30m, 60m, 90m, 100m, and 150m intervals. Each distance is tested with:

  • API interval data at both competitive and recreational paces (e.g., 100m in 12s competitive, 16s recreational)
  • Velocity stream parsing with realistic 1Hz data
  • A multi-rep workout test (30m + 60m + 100m + 150m with REST intervals between)

All distances were already accepted by the parser — the existing filters (10–400m range, duration-aware speed floor) correctly pass sprint efforts at all these distances. The new tests confirm this explicitly with realistic time & pace values. 479 tests pass.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Fixes silent dropping of valid sprint interval history from Intervals.icu by broadening accepted run activity sub-types, relaxing the average-speed floor for short reps, and making the velocity-stream fallback actually retrieve data when the list endpoint omits it.

Changes:

  • Accept Intervals.icu run sub-types via RUN_ACTIVITY_TYPES (Run, TrailRun, VirtualRun, Track, etc.) instead of only literal "Run".
  • Make sprint average-speed filtering duration-aware (≤30s uses a lower floor) to avoid rejecting valid short sprints.
  • Add a last-resort per-activity fetch to retrieve velocity_smooth for parsing when /intervals and list-level stream data are empty.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
src/domain/schema.ts Broadens IntervalsActivitySchema.type to z.enum(RUN_ACTIVITY_TYPES) and exports the allowed run types.
src/schema.ts Mirrors the run-type enum expansion (but duplicates constants/schemas).
api/index.ts Updates webhook recent-activity filtering to treat run sub-types as valid runs.
src/domain/sprint/parser.ts Adjusts sprint interval acceptance by using a lower avg-speed floor for ≤30s intervals.
src/hooks/useIntervalsData.ts Improves interval-history extraction by fetching activity detail when stream data is missing and parsing velocity_smooth.
tests/domain/sprint/parser.test.ts Adds regression tests for the new duration-aware avg-speed threshold behavior.
tests/domain/schema.test.ts Updates schema tests to cover all RUN_ACTIVITY_TYPES and revised type expectations.
tests/api/index.test.ts Updates webhook validation test description/comments to reflect enum-based run-type checking.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/hooks/useIntervalsData.ts Outdated
Comment on lines +243 to +247
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;
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

This uses Promise.all(activitiesForIntervals.map(...)), which will fire all fallback fetches concurrently. If activitiesForIntervals is large, this can create an unbounded burst of requests (in addition to the already-parallel /intervals calls) and increase the chance of Intervals.icu rate-limits/timeouts. Consider limiting concurrency (queue/batch) or only attempting the detail fetch for activities likely to contain sprint reps.

Copilot uses AI. Check for mistakes.
Comment thread src/hooks/useIntervalsData.ts Outdated
Comment on lines +259 to +263
const detailRes = await fetch(
`${INTERVALS_BASE}/api/v1/activity/${a.id}`,
{ headers }
);
if (!detailRes.ok) return [];
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

The fallback fetches ${INTERVALS_BASE}/api/v1/activity/${a.id} and expects detail.velocity_smooth to be present. In api/index.ts, velocity data is primarily sourced from the /streams endpoint (with rawActivity.velocity_smooth only as a secondary fallback), which suggests /activity/{id} may not reliably include the stream. To avoid this fallback becoming a no-op for some accounts, consider fetching /activity/{id}/streams (and reading velocity_smooth.data) or mirroring the webhook’s stream-selection logic.

Copilot uses AI. Check for mistakes.
Comment thread src/hooks/useIntervalsData.ts Outdated
Comment on lines +263 to +270
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 {
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

The catch { return [] } here swallows network/JSON errors silently, making it hard to diagnose why interval history is missing in production. Please log the error (e.g., clientLogger.warn) with the activity id and failure reason/status before returning an empty result.

Suggested change
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 {
if (!detailRes.ok) {
clientLogger.warn(
`Failed to fetch interval history activity detail for activity ${a.id}: ${detailRes.status} ${detailRes.statusText}`,
athleteId
);
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 (error) {
const reason = error instanceof Error ? error.message : String(error);
clientLogger.warn(
`Failed to fetch or parse interval history activity detail for activity ${a.id}: ${reason}`,
athleteId
);

Copilot uses AI. Check for mistakes.
Comment thread src/schema.ts Outdated
Comment on lines +2 to +15

/**
* 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;
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

RUN_ACTIVITY_TYPES is defined here and also duplicated in src/domain/schema.ts. Keeping two separate copies of the same allowed-values list is likely to drift over time (one side updated, the other forgotten). Consider making one module the single source of truth (e.g., define in src/domain/schema.ts and re-export from src/schema.ts, or remove src/schema.ts if it’s unused).

Suggested change
/**
* 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;
import { RUN_ACTIVITY_TYPES } from './domain/schema';
export { RUN_ACTIVITY_TYPES } from './domain/schema';

Copilot uses AI. Check for mistakes.
Comment thread tests/domain/schema.test.ts Outdated
* id: z.string()
* type: z.literal('Run')
* type: z.enum(RUN_ACTIVITY_TYPES) — accepted run sub-types
* velocity_smooth: z.array(z.number()) — required
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

The header comment says velocity_smooth is “required”, but the schema/test behavior explicitly defaults it to an empty array when omitted. Please update this comment to match the actual contract (optional with default) to avoid misleading future changes.

Suggested change
* velocity_smooth: z.array(z.number()) required
* velocity_smooth: z.array(z.number()) optional, defaults to []

Copilot uses AI. Check for mistakes.
@MaximumTrainer
Copy link
Copy Markdown
Owner

@copilot apply changes based on the comments in this thread

…eams 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>
@MaximumTrainer
Copy link
Copy Markdown
Owner

@copilot update the interval logic to detect 30m, 60m, 90m, 100m 120m, 150m intervals based on pace, time and distance. where pace is greater than 3/km, time is less than 25 seconds

Copy link
Copy Markdown
Contributor Author

Copilot AI commented Apr 14, 2026

@copilot apply changes based on the comments in this thread

Applied all 5 review comments in b60943d:

  1. Deduplicated RUN_ACTIVITY_TYPESsrc/schema.ts now re-exports from src/domain/schema.ts instead of maintaining a separate copy.
  2. Added error logging — Fallback fetch failures now log via clientLogger.warn with activity ID and failure reason.
  3. Switched to /streams endpoint — Fallback now fetches /api/v1/activity/{id}/streams and reads velocity_smooth.data, matching the webhook's stream-selection logic.
  4. Limited concurrency — Replaced Promise.all with a sequential for loop so fallback fetches don't burst all at once.
  5. Fixed commentvelocity_smooth documented as "optional, defaults to []".

Copy link
Copy Markdown
Contributor Author

Copilot AI commented Apr 14, 2026

@copilot update the interval logic to detect 30m, 60m, 90m, 100m 120m, 150m intervals based on pace, time and distance. where pace is greater than 3/km, time is less than 25 seconds

Updated in fa5dfc7:

  • Pace threshold: computed from distance / time, must be faster than 3:00/km (≥ 5.56 m/s). Applied to both API intervals and velocity stream parsing.
  • Duration cap: intervals must be ≤ 25 seconds. Anything longer is rejected as a non-sprint effort.
  • 120m tests: added 3 tests (competitive at 15s, recreational at 20s, velocity stream).
  • Multi-rep test: updated to include 120m alongside 30m, 60m, 100m, 150m.

All 6 distances (30m, 60m, 90m, 100m, 120m, 150m) are detected at both competitive and recreational paces. 484 tests pass.

Copilot AI changed the title Fix sprint interval detection: accept run sub-types, fix short-lap speed filter, add velocity_smooth fallback fetch Fix sprint interval detection: accept run sub-types, pace-based speed filter, add velocity_smooth fallback fetch Apr 14, 2026
@MaximumTrainer MaximumTrainer requested a review from Copilot April 14, 2026 08:58
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/hooks/useIntervalsData.ts Outdated
Comment on lines +278 to +281
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;
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

The new fallback stream parsing assumes streams.velocity_smooth.data is a clean number[], but the /streams endpoint can plausibly return null, undefined, or string values. Passing those through to SprintParser.parseTrackSession() can produce NaN/string-concatenation in reduce() and Math.max(), leading to incorrect intervals or downstream NaN metrics. Please normalize/sanitize the stream before parsing (e.g., filter to finite numbers, or validate with a Zod schema) and skip/ warn when the stream contains no valid numeric samples.

Suggested change
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;
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): 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;
}

Copilot uses AI. Check for mistakes.
Comment thread tests/domain/sprint/parser.test.ts Outdated
Comment on lines +390 to +396
// 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,
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

This test is intended to verify the new 25s duration cap, but the chosen data (100m in 35s) is also far below the new pace threshold (computed avg speed ≈ 2.86 m/s < 5.56 m/s). As written, it would still pass even if the duration-cap check were removed, so it doesn’t actually cover the duration-cap behavior. Please update the fixture so pace passes while duration fails (e.g., a longer distance with >25s duration but computed avg speed still above the threshold).

Suggested change
// 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,
// 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: 160,
moving_time: 26,
max_speed: 8.0,
average_speed: 6.15,

Copilot uses AI. Check for mistakes.
@MaximumTrainer
Copy link
Copy Markdown
Owner

@copilot apply changes based on the comments in this thread

Copy link
Copy Markdown
Contributor Author

Copilot AI commented Apr 14, 2026

@copilot apply changes based on the comments in this thread

Applied both review comments in 1c55708:

  1. Sanitized velocity stream — raw stream data is now filtered to finite numbers before parsing, with a clientLogger.warn when the stream contains data but no valid numeric samples.
  2. Fixed duration-cap test — changed fixture to 160m in 26s (computed avg speed ≈ 6.15 m/s), so pace passes while duration exceeds the 25s cap. This properly isolates the duration check.

@MaximumTrainer MaximumTrainer marked this pull request as ready for review April 14, 2026 09:13
@MaximumTrainer MaximumTrainer merged commit e9d6014 into main Apr 14, 2026
2 checks passed
@MaximumTrainer MaximumTrainer deleted the copilot/investigate-silver-sprint-issue branch April 14, 2026 09:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants