Skip to content
Merged
114 changes: 114 additions & 0 deletions web/__tests__/effort-progression.test.ts
Original file line number Diff line number Diff line change
@@ -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>): 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("—")
})
})
89 changes: 89 additions & 0 deletions web/__tests__/vma-estimate.test.ts
Original file line number Diff line number Diff line change
@@ -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>): 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("—")
})
})
12 changes: 12 additions & 0 deletions web/app/(app)/coaching/[id]/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Loader2 } from "lucide-react"

export default function GroupLoading() {
return (
<div className="flex min-h-[50vh] items-center justify-center">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Chargement du groupe...
</div>
</div>
)
}
15 changes: 10 additions & 5 deletions web/app/(app)/coaching/coaching-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -199,10 +199,15 @@ export function CoachingClient({ initialGroups }: CoachingClientProps) {
<span className="text-xs text-muted-foreground font-mono">
Code : <span className="font-semibold text-foreground select-all">{group.invite_code}</span>
</span>
<Link href={`/coaching/${group.id}`} passHref>
<Button size="sm" className="gap-1.5 bg-gradient-to-r from-primary to-violet-600 hover:from-primary/95 hover:to-violet-600/95 text-white">
Accéder <ArrowRight className="h-3.5 w-3.5" />
</Button>
<Link
href={`/coaching/${group.id}`}
className={buttonVariants({
size: "sm",
className:
"gap-1.5 bg-gradient-to-r from-primary to-violet-600 hover:from-primary/95 hover:to-violet-600/95 text-white",
})}
>
Accéder <ArrowRight className="h-3.5 w-3.5" />
</Link>
</CardFooter>
</Card>
Expand Down
34 changes: 29 additions & 5 deletions web/app/(app)/progression/page.tsx
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -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"

Expand All @@ -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")
Expand All @@ -48,14 +58,22 @@ 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

try {
const token = await ensureValidStravaToken(user.id)
isStravaConnected = true
vmaStreamEfforts = await getVmaStreamEfforts(token, (activities as any) ?? [])

const { data: conn } = await supabase
.from("provider_connections")
Expand All @@ -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
Expand Down Expand Up @@ -140,6 +160,10 @@ export default async function ProgressionPage() {
<h1 className="text-xl font-semibold">Progression</h1>
</div>

<VmaEstimateCard estimate={vmaEstimate} />

<EffortProgressionCard progression={effortProgression} />

{/* Current week zones + polarization */}
<Card>
<CardHeader className="pb-2">
Expand Down
Loading
Loading