diff --git a/web/__tests__/effort-progression.test.ts b/web/__tests__/effort-progression.test.ts new file mode 100644 index 0000000..83bd214 --- /dev/null +++ b/web/__tests__/effort-progression.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest" + +import { + computeEffortProgression, + formatPace, + type EffortProgressionActivity, + type EffortProgressionZone, +} from "@/lib/compute/effort-progression" + +const zones: EffortProgressionZone[] = [ + { zone_number: 1, zone_name: "Z1", hr_min: 90, hr_max: 120 }, + { zone_number: 2, zone_name: "Z2", hr_min: 120, hr_max: 150 }, + { zone_number: 3, zone_name: "Z3", hr_min: 150, hr_max: 170 }, + { zone_number: 4, zone_name: "Z4", hr_min: 170, hr_max: 185 }, + { zone_number: 5, zone_name: "Z5", hr_min: 185, hr_max: null }, +] + +function activity(overrides: Partial): EffortProgressionActivity { + return { + sport_type: "Run", + start_date: "2026-05-01T08:00:00.000Z", + duration_sec: 3000, + moving_time_sec: 3000, + distance_m: 10000, + average_heartrate: 135, + ...overrides, + } +} + +describe("computeEffortProgression", () => { + it("keeps only classic runs with usable heart-rate and pace data", () => { + const result = computeEffortProgression( + [ + activity({ sport_type: "Run", average_heartrate: 135 }), + activity({ sport_type: "TrailRun", average_heartrate: 135 }), + activity({ sport_type: "Ride", average_heartrate: 135 }), + activity({ sport_type: "Run", average_heartrate: null }), + activity({ sport_type: "Run", distance_m: 1000 }), + ], + zones, + new Date("2026-06-01T00:00:00.000Z"), + ) + + expect(result.usableRunCount).toBe(1) + expect(result.zone2Summary?.currentSampleCount).toBe(1) + }) + + it("uses the dominant heart-rate zone when zone distribution is available", () => { + const result = computeEffortProgression( + [ + activity({ + average_heartrate: 155, + time_in_zones_json: [ + { zone: 2, sec: 2200 }, + { zone: 3, sec: 600 }, + ], + }), + activity({ + average_heartrate: 135, + start_date: "2026-05-08T08:00:00.000Z", + time_in_zones_json: [ + { zone: 1, sec: 900 }, + { zone: 2, sec: 1000 }, + { zone: 3, sec: 1100 }, + ], + }), + ], + zones, + new Date("2026-06-01T00:00:00.000Z"), + ) + + expect(result.usableRunCount).toBe(1) + expect(result.zone2Summary?.currentSampleCount).toBe(1) + }) + + it("compares recent zone 2 pace with the older baseline window", () => { + const result = computeEffortProgression( + [ + activity({ start_date: "2026-01-10T08:00:00.000Z", moving_time_sec: 3600, duration_sec: 3600 }), + activity({ start_date: "2026-01-20T08:00:00.000Z", moving_time_sec: 3500, duration_sec: 3500 }), + activity({ start_date: "2026-05-10T08:00:00.000Z", moving_time_sec: 3200, duration_sec: 3200 }), + activity({ start_date: "2026-05-20T08:00:00.000Z", moving_time_sec: 3000, duration_sec: 3000 }), + ], + zones, + new Date("2026-06-01T00:00:00.000Z"), + ) + + expect(result.zone2Summary?.baselinePaceSecPerKm).toBe(355) + expect(result.zone2Summary?.currentPaceSecPerKm).toBe(310) + expect(result.zone2Summary?.deltaPct).toBeCloseTo(12.68, 2) + }) + + it("builds monthly zone 2 buckets", () => { + const result = computeEffortProgression( + [ + activity({ start_date: "2026-05-04T08:00:00.000Z", moving_time_sec: 3000, duration_sec: 3000 }), + activity({ start_date: "2026-05-06T08:00:00.000Z", moving_time_sec: 3200, duration_sec: 3200 }), + activity({ start_date: "2026-06-01T08:00:00.000Z", moving_time_sec: 2900, duration_sec: 2900 }), + ], + zones, + new Date("2026-06-15T00:00:00.000Z"), + ) + + expect(result.monthlyZone2).toHaveLength(2) + expect(result.monthlyZone2[0].medianPaceSecPerKm).toBe(310) + }) +}) + +describe("formatPace", () => { + it("formats seconds per kilometer", () => { + expect(formatPace(335)).toBe("5:35/km") + expect(formatPace(null)).toBe("—") + }) +}) diff --git a/web/__tests__/vma-estimate.test.ts b/web/__tests__/vma-estimate.test.ts new file mode 100644 index 0000000..e4b8990 --- /dev/null +++ b/web/__tests__/vma-estimate.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest" + +import { bestStreamEfforts, estimateVma, paceFromKmh, type VmaActivity, type VmaZone } from "@/lib/compute/vma-estimate" + +const zones: VmaZone[] = [ + { zone_number: 1, hr_min: 90, hr_max: 120 }, + { zone_number: 2, hr_min: 120, hr_max: 145 }, + { zone_number: 3, hr_min: 145, hr_max: 165 }, + { zone_number: 4, hr_min: 165, hr_max: 180 }, + { zone_number: 5, hr_min: 180, hr_max: null }, +] + +function activity(overrides: Partial): VmaActivity { + return { + sport_type: "Run", + start_date: "2026-05-20T08:00:00.000Z", + duration_sec: 1440, + moving_time_sec: 1440, + distance_m: 6000, + elevation_gain_m: 20, + average_heartrate: 170, + max_heartrate: 184, + ...overrides, + } +} + +describe("estimateVma", () => { + it("estimates VMA from classic road runs", () => { + const result = estimateVma( + [ + activity({ duration_sec: 1440, moving_time_sec: 1440, distance_m: 6000 }), + activity({ start_date: "2026-05-10T08:00:00.000Z", duration_sec: 1200, moving_time_sec: 1200, distance_m: 5000 }), + activity({ start_date: "2026-04-20T08:00:00.000Z", duration_sec: 720, moving_time_sec: 720, distance_m: 3000 }), + ], + zones, + new Date("2026-06-01T00:00:00.000Z"), + ) + + expect(result.valueKmh).toBeGreaterThan(16) + expect(result.valueKmh).toBeLessThan(18) + expect(result.confidence).toBe("medium") + }) + + it("ignores trail and highly hilly runs", () => { + const result = estimateVma( + [ + activity({ sport_type: "TrailRun", duration_sec: 1200, moving_time_sec: 1200, distance_m: 6000 }), + activity({ elevation_gain_m: 300, duration_sec: 1200, moving_time_sec: 1200, distance_m: 6000 }), + ], + zones, + new Date("2026-06-01T00:00:00.000Z"), + ) + + expect(result.valueKmh).toBeNull() + expect(result.candidateCount).toBe(0) + }) + + it("returns low confidence with a single usable candidate", () => { + const result = estimateVma([activity({ duration_sec: 360, moving_time_sec: 360, distance_m: 1600 })], zones, new Date("2026-06-01T00:00:00.000Z")) + + expect(result.valueKmh).not.toBeNull() + expect(result.confidence).toBe("low") + }) + + it("weights short stream efforts into the estimate", () => { + const efforts = bestStreamEfforts( + { + date: "2026-05-20T08:00:00.000Z", + time: Array.from({ length: 601 }, (_, i) => i), + distance: Array.from({ length: 601 }, (_, i) => i * 4.5), + heartrate: Array.from({ length: 601 }, () => 182), + altitude: Array.from({ length: 601 }, () => 20), + }, + [300, 360], + ) + const result = estimateVma([], zones, new Date("2026-06-01T00:00:00.000Z"), efforts) + + expect(efforts).toHaveLength(2) + expect(result.valueKmh).toBeGreaterThan(15) + expect(result.confidence).toBe("medium") + }) +}) + +describe("paceFromKmh", () => { + it("formats pace from speed", () => { + expect(paceFromKmh(15)).toBe("4:00/km") + expect(paceFromKmh(null)).toBe("—") + }) +}) diff --git a/web/app/(app)/coaching/[id]/loading.tsx b/web/app/(app)/coaching/[id]/loading.tsx new file mode 100644 index 0000000..e92437a --- /dev/null +++ b/web/app/(app)/coaching/[id]/loading.tsx @@ -0,0 +1,12 @@ +import { Loader2 } from "lucide-react" + +export default function GroupLoading() { + return ( +
+
+ + Chargement du groupe... +
+
+ ) +} diff --git a/web/app/(app)/coaching/coaching-client.tsx b/web/app/(app)/coaching/coaching-client.tsx index e87452c..ceb1eca 100644 --- a/web/app/(app)/coaching/coaching-client.tsx +++ b/web/app/(app)/coaching/coaching-client.tsx @@ -18,7 +18,7 @@ import { import { toast } from "sonner" import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" +import { Button, buttonVariants } from "@/components/ui/button" import { Card, CardContent, @@ -199,10 +199,15 @@ export function CoachingClient({ initialGroups }: CoachingClientProps) { Code : {group.invite_code} - - + + Accéder diff --git a/web/app/(app)/progression/page.tsx b/web/app/(app)/progression/page.tsx index 68af2fb..b429729 100644 --- a/web/app/(app)/progression/page.tsx +++ b/web/app/(app)/progression/page.tsx @@ -1,5 +1,5 @@ import type { Metadata } from "next" -import { startOfWeek, subWeeks, format } from "date-fns" +import { startOfWeek, subMonths, subWeeks, format } from "date-fns" import { fr } from "date-fns/locale" import { LineChart } from "lucide-react" @@ -10,7 +10,12 @@ import type { ZoneEntry } from "@/components/activity/zone-bars" import { WeeklyVolume } from "@/components/progression/weekly-volume" import { UserPRs } from "@/components/progression/user-prs" import { StravaAchievements } from "@/components/progression/strava-achievements" +import { EffortProgressionCard } from "@/components/progression/effort-progression-card" +import { VmaEstimateCard } from "@/components/progression/vma-estimate-card" +import { computeEffortProgression } from "@/lib/compute/effort-progression" +import { estimateVma } from "@/lib/compute/vma-estimate" import { ensureValidStravaToken } from "@/lib/server/strava/tokens" +import { getVmaStreamEfforts } from "@/lib/server/strava/vma" import { ProgressionAutoRefresh } from "./progression-auto-refresh" @@ -31,15 +36,20 @@ export default async function ProgressionPage() { if (!user) return null const now = new Date() - const twelveWeeksAgo = subWeeks(startOfWeek(now, { weekStartsOn: 1 }), 11) + const sixMonthsAgo = subMonths(now, 6) - const [activitiesRes, prActivitiesRes] = await Promise.all([ + const [activitiesRes, zonesRes, prActivitiesRes] = await Promise.all([ supabase .from("activities") - .select("sport_type, start_date, duration_sec, distance_m, time_in_zones_json") + .select("provider, provider_activity_id, sport_type, start_date, duration_sec, moving_time_sec, distance_m, elevation_gain_m, average_heartrate, max_heartrate, time_in_zones_json") .eq("user_id", user.id) - .gte("start_date", twelveWeeksAgo.toISOString()) + .gte("start_date", sixMonthsAgo.toISOString()) .order("start_date"), + supabase + .from("hr_zones") + .select("zone_number, zone_name, hr_min, hr_max, color_hex") + .eq("user_id", user.id) + .order("zone_number"), supabase .from("activities") .select("id, name, sport_type, start_date, duration_sec, distance_m, elevation_gain_m, raw_data_json") @@ -48,7 +58,14 @@ export default async function ProgressionPage() { ]) const activities = activitiesRes.data + const zones = zonesRes.data const prActivities = prActivitiesRes.data + const effortProgression = computeEffortProgression( + (activities as any) ?? [], + (zones as any) ?? [], + now, + ) + let vmaStreamEfforts: any[] = [] let koms: any[] = [] let isStravaConnected = false @@ -56,6 +73,7 @@ export default async function ProgressionPage() { try { const token = await ensureValidStravaToken(user.id) isStravaConnected = true + vmaStreamEfforts = await getVmaStreamEfforts(token, (activities as any) ?? []) const { data: conn } = await supabase .from("provider_connections") @@ -77,6 +95,8 @@ export default async function ProgressionPage() { console.warn("Strava token or KOMs retrieval failed:", error) } + const vmaEstimate = estimateVma((activities as any) ?? [], (zones as any) ?? [], now, vmaStreamEfforts) + // Build weekly buckets const weeks: Array<{ label: string @@ -140,6 +160,10 @@ export default async function ProgressionPage() {

Progression

+ + + + {/* Current week zones + polarization */} diff --git a/web/components/progression/effort-progression-card.tsx b/web/components/progression/effort-progression-card.tsx new file mode 100644 index 0000000..c1fb544 --- /dev/null +++ b/web/components/progression/effort-progression-card.tsx @@ -0,0 +1,148 @@ +import { TrendingDown, TrendingUp } from "lucide-react" + +import { Badge } from "@/components/ui/badge" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { + formatPace, + type EffortProgressionResult, + type EffortProgressionZoneSummary, +} from "@/lib/compute/effort-progression" + +type EffortProgressionCardProps = { + progression: EffortProgressionResult +} + +function formatDelta(deltaPct: number | null): string { + if (deltaPct == null || !Number.isFinite(deltaPct)) return "—" + const sign = deltaPct > 0 ? "+" : "" + return `${sign}${deltaPct.toFixed(1)}%` +} + +function confidence(summary: EffortProgressionZoneSummary | null): { + label: string + variant: "default" | "secondary" | "outline" +} { + if (!summary || summary.currentSampleCount < 2 || summary.baselineSampleCount < 2) { + return { label: "Données limitées", variant: "outline" } + } + if (summary.currentSampleCount >= 4 && summary.baselineSampleCount >= 4) { + return { label: "Signal stable", variant: "default" } + } + return { label: "Signal à confirmer", variant: "secondary" } +} + +function ZoneRow({ summary }: { summary: EffortProgressionZoneSummary }) { + const improved = summary.deltaPct != null && summary.deltaPct >= 0 + + return ( +
+
+ + {summary.zoneName.replace(/^Z\d+\s*-\s*/, `Z${summary.zone} `)} +
+ {formatPace(summary.currentPaceSecPerKm)} + + {formatPace(summary.baselinePaceSecPerKm)} + + + {formatDelta(summary.deltaPct)} + +
+ ) +} + +export function EffortProgressionCard({ progression }: EffortProgressionCardProps) { + const zone2 = progression.zone2Summary + const quality = confidence(zone2) + const improved = zone2?.deltaPct != null && zone2.deltaPct >= 0 + const chartBuckets = progression.monthlyZone2.filter((bucket) => bucket.medianPaceSecPerKm != null) + const paces = chartBuckets.map((bucket) => bucket.medianPaceSecPerKm as number) + const fastest = paces.length > 0 ? Math.min(...paces) : null + const slowest = paces.length > 0 ? Math.max(...paces) : null + + return ( + + +
+ Progression à effort égal +
+ Course route + {quality.label} +
+
+
+ + {zone2?.currentPaceSecPerKm != null ? ( + <> +
+
+

Allure actuelle en Zone 2

+
+ + {formatPace(zone2.currentPaceSecPerKm)} + + {zone2.deltaPct != null && ( + + {improved ? : } + {formatDelta(zone2.deltaPct)} + + )} +
+
+
+
Référence ancienne
+
{formatPace(zone2.baselinePaceSecPerKm)}
+
+
+ + {chartBuckets.length > 0 && fastest != null && slowest != null && ( +
+
+ {chartBuckets.map((bucket) => { + const pace = bucket.medianPaceSecPerKm as number + const range = Math.max(1, slowest - fastest) + const height = 34 + ((slowest - pace) / range) * 58 + return ( +
+
+
+
+ {bucket.label} +
+ ) + })} +
+

+ Plus la barre est haute, meilleure est l'allure médiane en Zone 2 sur le mois. +

+
+ )} + +
+
+ Zone + Récent + Ancien + Écart +
+ {progression.zoneSummaries.map((summary) => ( + + ))} +
+ + ) : ( +

+ Pas encore assez de sorties course à pied avec fréquence cardiaque pour comparer l'allure à effort égal. +

+ )} + + + ) +} diff --git a/web/components/progression/vma-estimate-card.tsx b/web/components/progression/vma-estimate-card.tsx new file mode 100644 index 0000000..d9bdc87 --- /dev/null +++ b/web/components/progression/vma-estimate-card.tsx @@ -0,0 +1,42 @@ +import { Info } from "lucide-react" + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import type { VmaEstimate } from "@/lib/compute/vma-estimate" + +type VmaEstimateCardProps = { + estimate: VmaEstimate +} + +function confidenceLabel(confidence: VmaEstimate["confidence"]): string { + if (confidence === "good") return "confiance bonne" + if (confidence === "medium") return "confiance moyenne" + return "confiance faible" +} + +export function VmaEstimateCard({ estimate }: VmaEstimateCardProps) { + return ( + + +
+ VMA estimée +
+ +
+
+ {estimate.tooltip} +
+
+
+ + +
+ + {estimate.valueKmh == null ? "—" : estimate.valueKmh.toFixed(1)} + + km/h +
+

{confidenceLabel(estimate.confidence)}

+
+ + ) +} diff --git a/web/lib/compute/effort-progression.ts b/web/lib/compute/effort-progression.ts new file mode 100644 index 0000000..0986031 --- /dev/null +++ b/web/lib/compute/effort-progression.ts @@ -0,0 +1,149 @@ +export type EffortProgressionActivity = { + sport_type: string + start_date: string + duration_sec: number | null + moving_time_sec: number | null + distance_m: number | null + average_heartrate: number | null + time_in_zones_json?: unknown +} +export type EffortProgressionZone = { zone_number: number; zone_name: string; hr_min: number; hr_max: number | null; color_hex?: string } +export type EffortProgressionBucket = { key: string; label: string; sampleCount: number; medianPaceSecPerKm: number | null } + +export type EffortProgressionZoneSummary = { zone: number; zoneName: string; color: string | null; currentPaceSecPerKm: number | null; baselinePaceSecPerKm: number | null; deltaPct: number | null; currentSampleCount: number; baselineSampleCount: number } + +export type EffortProgressionResult = { monthlyZone2: EffortProgressionBucket[]; zoneSummaries: EffortProgressionZoneSummary[]; zone2Summary: EffortProgressionZoneSummary | null; usableRunCount: number } +type Sample = { date: string; zone: number; paceSecPerKm: number; heartRate: number } +const ZONE_COLORS: Record = { 1: "#90CAF9", 2: "#4CAF50", 3: "#FFC107", 4: "#FF9800", 5: "#F44336" } + +function median(values: number[]): number | null { + if (values.length === 0) return null + const sorted = [...values].sort((a, b) => a - b) + const mid = Math.floor(sorted.length / 2) + return sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2 +} + +function isClassicRun(sportType: string): boolean { + const normalized = sportType.trim().toLowerCase() + return normalized === "run" || normalized === "running" +} + +function zoneFromHr(bpm: number, zones: EffortProgressionZone[]): number { + return zones.find((zone) => bpm >= zone.hr_min && (zone.hr_max == null || bpm < zone.hr_max))?.zone_number ?? 0 +} + +function dominantZone(activity: EffortProgressionActivity): number | null { + if (!Array.isArray(activity.time_in_zones_json)) return null + + const entries = activity.time_in_zones_json + .map((entry) => { + if (entry == null || typeof entry !== "object") return null + const zone = Number((entry as { zone?: unknown }).zone) + const sec = Number((entry as { sec?: unknown }).sec) + return Number.isFinite(zone) && Number.isFinite(sec) && sec > 0 ? { zone, sec } : null + }) + .filter((entry): entry is { zone: number; sec: number } => entry != null) + const totalSec = entries.reduce((total, entry) => total + entry.sec, 0) + if (totalSec === 0) return null + + const dominant = entries.reduce((best, entry) => (entry.sec > best.sec ? entry : best), entries[0]) + return dominant.sec / totalSec >= 0.5 ? dominant.zone : 0 +} + +function monthKey(date: Date): string { + return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}` +} + +function monthLabel(key: string): string { + const [year, month] = key.split("-") + return `${month}/${year.slice(2)}` +} + +function monthlyBuckets(samples: Sample[]): EffortProgressionBucket[] { + const groups = new Map() + for (const sample of samples) { + const key = monthKey(new Date(sample.date)) + groups.set(key, [...(groups.get(key) ?? []), sample]) + } + + return Array.from(groups.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, group]) => ({ + key, + label: monthLabel(key), + sampleCount: group.length, + medianPaceSecPerKm: median(group.map((sample) => sample.paceSecPerKm)), + })) +} + +function windowed(samples: Sample[], latestDate: Date, startDaysAgo: number, endDaysAgo: number): Sample[] { + const start = new Date(latestDate) + const end = new Date(latestDate) + start.setUTCDate(start.getUTCDate() - startDaysAgo) + end.setUTCDate(end.getUTCDate() - endDaysAgo) + return samples.filter((sample) => { + const date = new Date(sample.date) + return date >= start && date < end + }) +} + +function summary(zone: EffortProgressionZone, samples: Sample[], latestDate: Date): EffortProgressionZoneSummary { + const zoneSamples = samples.filter((sample) => sample.zone === zone.zone_number) + const current = windowed(zoneSamples, latestDate, 56, -1) + const baseline = windowed(zoneSamples, latestDate, 168, 84) + const currentPace = median(current.map((sample) => sample.paceSecPerKm)) + const baselinePace = median(baseline.map((sample) => sample.paceSecPerKm)) + + return { + zone: zone.zone_number, + zoneName: zone.zone_name, + color: zone.color_hex ?? ZONE_COLORS[zone.zone_number] ?? null, + currentPaceSecPerKm: currentPace, + baselinePaceSecPerKm: baselinePace, + deltaPct: currentPace != null && baselinePace != null ? ((baselinePace - currentPace) / baselinePace) * 100 : null, + currentSampleCount: current.length, + baselineSampleCount: baseline.length, + } +} + +export function formatPace(paceSecPerKm: number | null): string { + if (paceSecPerKm == null || !Number.isFinite(paceSecPerKm)) return "—" + const rounded = Math.round(paceSecPerKm) + return `${Math.floor(rounded / 60)}:${String(rounded % 60).padStart(2, "0")}/km` +} + +export function computeEffortProgression( + activities: EffortProgressionActivity[], + zones: EffortProgressionZone[], + now: Date = new Date(), +): EffortProgressionResult { + const sortedZones = [...zones].sort((a, b) => a.zone_number - b.zone_number) + const since = new Date(now) + since.setUTCDate(since.getUTCDate() - 180) + + const samples = activities + .filter((activity) => isClassicRun(activity.sport_type)) + .map((activity) => { + const date = new Date(activity.start_date) + const distanceKm = (activity.distance_m ?? 0) / 1000 + const durationSec = activity.moving_time_sec && activity.moving_time_sec > 0 ? activity.moving_time_sec : activity.duration_sec ?? 0 + const heartRate = activity.average_heartrate ?? 0 + const zone = dominantZone(activity) ?? zoneFromHr(heartRate, sortedZones) + const paceSecPerKm = distanceKm > 0 ? durationSec / distanceKm : 0 + return { date: activity.start_date, heartRate, paceSecPerKm, zone, valid: date >= since && date <= now && zone > 0 && distanceKm >= 2 && durationSec >= 600 && paceSecPerKm >= 150 && paceSecPerKm <= 900 } + }) + .filter((sample) => sample.valid) + .map(({ valid: _valid, ...sample }) => sample) + .sort((a, b) => a.date.localeCompare(b.date)) + + const latestDate = samples.length > 0 ? new Date(samples[samples.length - 1].date) : now + const zoneSummaries = sortedZones.map((zone) => summary(zone, samples, latestDate)) + const zone2Samples = samples.filter((sample) => sample.zone === 2) + + return { + monthlyZone2: monthlyBuckets(zone2Samples), + zoneSummaries, + zone2Summary: zoneSummaries.find((item) => item.zone === 2) ?? null, + usableRunCount: samples.length, + } +} diff --git a/web/lib/compute/vma-estimate.ts b/web/lib/compute/vma-estimate.ts new file mode 100644 index 0000000..965a004 --- /dev/null +++ b/web/lib/compute/vma-estimate.ts @@ -0,0 +1,183 @@ +export type VmaActivity = { + sport_type: string + start_date: string + duration_sec: number | null + moving_time_sec: number | null + distance_m: number | null + elevation_gain_m: number | null + average_heartrate: number | null + max_heartrate: number | null +} + +export type VmaZone = { + zone_number: number + hr_min: number + hr_max: number | null +} + +export type VmaEstimate = { + valueKmh: number | null + confidence: "low" | "medium" | "good" + candidateCount: number + tooltip: string +} + +export type VmaStreamEffort = { + durationSec: number + speedKmh: number + averageHeartrate: number | null + elevationGainM: number + date: string +} + +export type VmaStream = { + time: number[] + distance: number[] + velocity?: number[] + heartrate?: number[] + altitude?: number[] + date: string +} + +type Candidate = { valueKmh: number; score: number; source: "activity" | "stream" } + +function isClassicRun(sportType: string): boolean { + const normalized = sportType.trim().toLowerCase() + return normalized === "run" || normalized === "running" +} + +function durationFractionOfVma(minutes: number): number { + if (minutes <= 4) return 1.04 + if (minutes <= 6) return 1.01 + if (minutes <= 8) return 0.99 + if (minutes <= 12) return 0.96 + if (minutes <= 20) return 0.92 + if (minutes <= 30) return 0.88 + if (minutes <= 45) return 0.84 + return 0.8 +} + +function shortEffortFractionOfVma(seconds: number): number { + if (seconds <= 180) return 1.05 + if (seconds <= 240) return 1.03 + if (seconds <= 300) return 1.01 + if (seconds <= 360) return 1 + return 0.97 +} + +function zoneNumberForHr(bpm: number | null, zones: VmaZone[]): number { + if (!bpm) return 0 + return zones.find((zone) => bpm >= zone.hr_min && (zone.hr_max == null || bpm < zone.hr_max))?.zone_number ?? 0 +} + +function recencyScore(date: Date, now: Date): number { + const days = Math.max(0, (now.getTime() - date.getTime()) / 86_400_000) + return Math.max(0.35, 1 - days / 240) +} + +function median(values: number[]): number | null { + if (values.length === 0) return null + const sorted = [...values].sort((a, b) => a - b) + const mid = Math.floor(sorted.length / 2) + return sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2 +} + +function weightedAverage(candidates: Candidate[]): number | null { + const top = [...candidates].sort((a, b) => b.score - a.score).slice(0, 6) + const center = median(top.map((candidate) => candidate.valueKmh)) + if (center == null) return null + const filtered = top.filter((candidate) => Math.abs(candidate.valueKmh - center) <= 2.5) + const scoreSum = filtered.reduce((sum, candidate) => sum + candidate.score, 0) + if (scoreSum === 0) return null + return filtered.reduce((sum, candidate) => sum + candidate.valueKmh * candidate.score, 0) / scoreSum +} + +function streamCandidates(efforts: VmaStreamEffort[], zones: VmaZone[], now: Date): Candidate[] { + const sortedZones = [...zones].sort((a, b) => a.zone_number - b.zone_number) + return efforts.flatMap((effort): Candidate[] => { + const valueKmh = effort.speedKmh / shortEffortFractionOfVma(effort.durationSec) + const elevationPerKm = effort.elevationGainM / Math.max(0.1, effort.speedKmh * (effort.durationSec / 3600)) + if (!Number.isFinite(valueKmh) || valueKmh < 8 || valueKmh > 28 || elevationPerKm > 35) return [] + + const hrZone = zoneNumberForHr(effort.averageHeartrate, sortedZones) + const durationScore = effort.durationSec >= 300 && effort.durationSec <= 360 ? 1 : 0.88 + const intensityScore = hrZone >= 5 ? 1 : hrZone >= 4 ? 0.84 : hrZone >= 3 ? 0.68 : 0.55 + const score = 1.35 * durationScore * intensityScore * recencyScore(new Date(effort.date), now) + return [{ valueKmh, score, source: "stream" }] + }) +} + +export function paceFromKmh(kmh: number | null): string { + if (kmh == null || kmh <= 0) return "—" + const sec = Math.round(3600 / kmh) + return `${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, "0")}/km` +} + +export function bestStreamEfforts(stream: VmaStream, windowsSec: number[] = [180, 240, 300, 360, 480]): VmaStreamEffort[] { + const efforts: VmaStreamEffort[] = [] + for (const windowSec of windowsSec) { + let best: VmaStreamEffort | null = null + let end = 0 + for (let start = 0; start < stream.time.length; start++) { + while (end < stream.time.length && stream.time[end] - stream.time[start] < windowSec) end += 1 + if (end >= stream.time.length) break + + const elapsed = stream.time[end] - stream.time[start] + const distanceM = stream.distance[end] - stream.distance[start] + const speedKmh = (distanceM / elapsed) * 3.6 + const averageHeartrate = stream.heartrate ? median(stream.heartrate.slice(start, end + 1)) : null + const altitudeSlice = stream.altitude?.slice(start, end + 1) ?? [] + const elevationGainM = altitudeSlice.reduce((sum, altitude, index) => { + if (index === 0) return sum + return sum + Math.max(0, altitude - altitudeSlice[index - 1]) + }, 0) + + if (distanceM >= 400 && Number.isFinite(speedKmh) && (!best || speedKmh > best.speedKmh)) { + best = { durationSec: elapsed, speedKmh, averageHeartrate, elevationGainM, date: stream.date } + } + } + if (best) efforts.push(best) + } + return efforts +} + +export function estimateVma( + activities: VmaActivity[], + zones: VmaZone[], + now: Date = new Date(), + streamEfforts: VmaStreamEffort[] = [], +): VmaEstimate { + const sortedZones = [...zones].sort((a, b) => a.zone_number - b.zone_number) + const activityCandidates = activities.flatMap((activity): Candidate[] => { + const date = new Date(activity.start_date) + const durationSec = activity.moving_time_sec && activity.moving_time_sec > 0 ? activity.moving_time_sec : activity.duration_sec ?? 0 + const distanceKm = (activity.distance_m ?? 0) / 1000 + const elevationPerKm = distanceKm > 0 ? (activity.elevation_gain_m ?? 0) / distanceKm : 999 + if (!isClassicRun(activity.sport_type) || durationSec < 180 || distanceKm < 0.8 || elevationPerKm > 35) return [] + + const minutes = durationSec / 60 + const speedKmh = distanceKm / (durationSec / 3600) + const valueKmh = speedKmh / durationFractionOfVma(minutes) + if (!Number.isFinite(valueKmh) || valueKmh < 8 || valueKmh > 28) return [] + + const maxZone = zoneNumberForHr(activity.max_heartrate, sortedZones) + const avgZone = zoneNumberForHr(activity.average_heartrate, sortedZones) + const durationScore = minutes >= 4 && minutes <= 12 ? 1 : minutes <= 30 ? 0.78 : 0.55 + const intensityScore = maxZone >= 5 ? 1 : maxZone >= 4 || avgZone >= 4 ? 0.82 : avgZone >= 3 ? 0.62 : 0.42 + const elevationScore = elevationPerKm <= 12 ? 1 : elevationPerKm <= 25 ? 0.78 : 0.55 + const score = durationScore * intensityScore * elevationScore * recencyScore(date, now) + return score >= 0.22 ? [{ valueKmh, score, source: "activity" }] : [] + }) + const candidates = [...streamCandidates(streamEfforts, sortedZones, now), ...activityCandidates] + + const value = weightedAverage(candidates) + const rounded = value == null ? null : Math.round(value * 10) / 10 + const streamCount = candidates.filter((candidate) => candidate.source === "stream").length + const confidence = streamCount >= 3 || candidates.length >= 6 ? "good" : streamCount >= 2 || candidates.length >= 3 ? "medium" : "low" + const tooltip = + rounded == null + ? "VMA indisponible: pas assez de sorties course route exploitables." + : `Estimation V2 basée sur ${candidates.length} signal${candidates.length > 1 ? "aux" : ""}${streamCount > 0 ? `, dont ${streamCount} meilleur${streamCount > 1 ? "s" : ""} effort${streamCount > 1 ? "s" : ""} Strava` : ""}. Les trails, sorties très vallonnées et données improbables sont ignorés. Allure à 100%: ${paceFromKmh(rounded)}.` + + return { valueKmh: rounded, confidence, candidateCount: candidates.length, tooltip } +} diff --git a/web/lib/server/strava/vma.ts b/web/lib/server/strava/vma.ts new file mode 100644 index 0000000..a4f1890 --- /dev/null +++ b/web/lib/server/strava/vma.ts @@ -0,0 +1,80 @@ +import { bestStreamEfforts, type VmaActivity, type VmaStreamEffort } from "@/lib/compute/vma-estimate" + +const STREAMS_URL = "https://www.strava.com/api/v3/activities/{id}/streams" + +type VmaStravaActivity = VmaActivity & { + provider: string + provider_activity_id: string | null +} + +type StreamPayload = { + time?: { data?: number[] } + distance?: { data?: number[] } + velocity_smooth?: { data?: number[] } + heartrate?: { data?: number[] } + altitude?: { data?: number[] } +} + +function isClassicRun(sportType: string): boolean { + const normalized = sportType.trim().toLowerCase() + return normalized === "run" || normalized === "running" +} + +function candidateActivities(activities: VmaStravaActivity[]): VmaStravaActivity[] { + return activities + .filter((activity) => activity.provider === "strava" && activity.provider_activity_id && isClassicRun(activity.sport_type)) + .filter((activity) => { + const distanceKm = (activity.distance_m ?? 0) / 1000 + const durationSec = activity.moving_time_sec && activity.moving_time_sec > 0 ? activity.moving_time_sec : activity.duration_sec ?? 0 + const elevationPerKm = distanceKm > 0 ? (activity.elevation_gain_m ?? 0) / distanceKm : 999 + return distanceKm >= 2 && durationSec >= 600 && elevationPerKm <= 35 + }) + .sort((a, b) => { + const speedA = (a.distance_m ?? 0) / Math.max(1, a.moving_time_sec ?? a.duration_sec ?? 1) + const speedB = (b.distance_m ?? 0) / Math.max(1, b.moving_time_sec ?? b.duration_sec ?? 1) + return speedB - speedA + }) + .slice(0, 6) +} + +async function fetchStreams(token: string, providerActivityId: string): Promise { + const url = + STREAMS_URL.replace("{id}", providerActivityId) + + "?keys=time,distance,velocity_smooth,heartrate,altitude&key_by_type=true" + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 5000) + try { + const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` }, signal: controller.signal }) + if (!res.ok) return null + return (await res.json()) as StreamPayload + } catch { + return null + } finally { + clearTimeout(timeout) + } +} + +export async function getVmaStreamEfforts( + token: string, + activities: VmaStravaActivity[], +): Promise { + const efforts: VmaStreamEffort[] = [] + for (const activity of candidateActivities(activities)) { + const payload = await fetchStreams(token, activity.provider_activity_id as string) + const time = payload?.time?.data + const distance = payload?.distance?.data + if (!time || !distance || time.length !== distance.length) continue + + efforts.push( + ...bestStreamEfforts({ + time, + distance, + velocity: payload?.velocity_smooth?.data, + heartrate: payload?.heartrate?.data, + altitude: payload?.altitude?.data, + date: activity.start_date, + }), + ) + } + return efforts +}