Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions web/__tests__/groups.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { beforeEach, describe, expect, it, vi } from "vitest"

import { getGroupMember } from "@/lib/server/groups"

type MemberRow = {
group_id: string
user_id: string
role: "admin" | "coach" | "athlete"
target_time_sec: number | null
created_at: string
}

let memberRow: MemberRow | null = null
let memberError: Error | null = null
let filters: Record<string, string> = {}

const supabase = {
from: (table: string) => {
if (table !== "group_members") throw new Error(`Unexpected table ${table}`)

return {
select: () => ({
eq: (column: string, value: string) => {
filters[column] = value
return {
eq: (column: string, value: string) => {
filters[column] = value
return {
maybeSingle: async () => ({ data: memberRow, error: memberError }),
}
},
}
},
}),
}
},
}

describe("getGroupMember", () => {
beforeEach(() => {
memberRow = null
memberError = null
filters = {}
vi.restoreAllMocks()
})

it("loads the current member with group and user filters", async () => {
memberRow = {
group_id: "group-1",
user_id: "user-1",
role: "coach",
target_time_sec: 12_600,
created_at: "2026-06-05T12:00:00Z",
}

await expect(getGroupMember(supabase as any, "group-1", "user-1")).resolves.toEqual(memberRow)
expect(filters).toEqual({ group_id: "group-1", user_id: "user-1" })
})

it("returns null when the membership query fails", async () => {
memberError = new Error("members failed")
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {})

await expect(getGroupMember(supabase as any, "group-1", "user-1")).resolves.toBeNull()
expect(consoleError).toHaveBeenCalledWith("Error fetching group member:", memberError)
})
})
10 changes: 10 additions & 0 deletions web/__tests__/vma-estimate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,16 @@ describe("estimateVma", () => {
expect(result.confidence).toBe("low")
})

it("does not use easy whole-activity runs as VMA candidates", () => {
const result = estimateVma(
[activity({ duration_sec: 3600, moving_time_sec: 3600, distance_m: 10000, average_heartrate: 132, max_heartrate: 145 })],
zones,
new Date("2026-06-01T00:00:00.000Z"),
)

expect(result.valueKmh).toBeNull()
})

it("weights short stream efforts into the estimate", () => {
const efforts = bestStreamEfforts(
{
Expand Down
9 changes: 5 additions & 4 deletions web/app/(app)/coaching/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createClient } from "@/lib/supabase/server"
import {
getGroupActivities,
getGroupById,
getGroupMember,
getGroupMembers,
getGroupPlannedSessions,
getGroupTrainingBlocks,
Expand Down Expand Up @@ -41,16 +42,16 @@ export default async function GroupPage({
const group = await getGroupById(supabase, groupId)
if (!group) notFound()

// 2. Charger les membres et vérifier l'appartenance
const members = await getGroupMembers(supabase, groupId)
const currentMember = members.find((m) => m.user_id === user.id)
// 2. Vérifier l'appartenance avec une requête dédiée au membre courant
const currentMember = await getGroupMember(supabase, groupId, user.id)
if (!currentMember) {
// Si l'utilisateur n'est pas membre, retour à l'accueil coaching
redirect("/coaching")
}

// 3. Charger le reste des données du groupe
const [activities, groupSessions, groupBlocks] = await Promise.all([
const [members, activities, groupSessions, groupBlocks] = await Promise.all([
getGroupMembers(supabase, groupId),
getGroupActivities(supabase, groupId),
getGroupPlannedSessions(supabase, groupId),
getGroupTrainingBlocks(supabase, groupId),
Expand Down
25 changes: 25 additions & 0 deletions web/app/(app)/progression/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"use server"

import { revalidatePath } from "next/cache"

import { importAllStravaHistory } from "@/lib/server/strava/sync"
import { createClient } from "@/lib/supabase/server"

export async function refreshProgressionHistory(): Promise<{ synced?: number; error?: string }> {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()

if (!user) return { error: "Non authentifié" }

try {
const { imported } = await importAllStravaHistory(user.id)
revalidatePath("/progression")
revalidatePath("/dashboard")
revalidatePath("/activities")
return { synced: imported }
} catch (error) {
return { error: error instanceof Error ? error.message : "Import historique échoué" }
}
}
36 changes: 36 additions & 0 deletions web/app/(app)/progression/history-refresh-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"use client"

import { useState } from "react"
import { useRouter } from "next/navigation"
import { Database, Loader2 } from "lucide-react"
import { toast } from "sonner"

import { Button } from "@/components/ui/button"

import { refreshProgressionHistory } from "./actions"

export function HistoryRefreshButton() {
const router = useRouter()
const [loading, setLoading] = useState(false)

const handleRefresh = async () => {
setLoading(true)
const result = await refreshProgressionHistory()
setLoading(false)

if (result.error) {
toast.error(result.error)
return
}

toast.success(`Historique Strava importé (${result.synced ?? 0} activité${result.synced === 1 ? "" : "s"})`)
router.refresh()
}

return (
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={loading} className="gap-1.5">
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Database className="h-3.5 w-3.5" />}
{loading ? "Import en cours..." : "Recalculer l'historique"}
</Button>
)
}
10 changes: 7 additions & 3 deletions web/app/(app)/progression/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { estimateVma } from "@/lib/compute/vma-estimate"
import { ensureValidStravaToken } from "@/lib/server/strava/tokens"
import { getVmaStreamEfforts } from "@/lib/server/strava/vma"

import { HistoryRefreshButton } from "./history-refresh-button"
import { ProgressionAutoRefresh } from "./progression-auto-refresh"

export const metadata: Metadata = { title: "Progression · SportTrack" }
Expand Down Expand Up @@ -155,9 +156,12 @@ export default async function ProgressionPage() {
return (
<div className="mx-auto max-w-3xl space-y-6">
<ProgressionAutoRefresh />
<div className="flex items-center gap-2">
<LineChart className="h-5 w-5 text-primary" />
<h1 className="text-xl font-semibold">Progression</h1>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2">
<LineChart className="h-5 w-5 text-primary" />
<h1 className="text-xl font-semibold">Progression</h1>
</div>
<HistoryRefreshButton />
</div>

<VmaEstimateCard estimate={vmaEstimate} />
Expand Down
4 changes: 4 additions & 0 deletions web/lib/compute/vma-estimate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,10 @@ export function estimateVma(

const maxZone = zoneNumberForHr(activity.max_heartrate, sortedZones)
const avgZone = zoneNumberForHr(activity.average_heartrate, sortedZones)
const hasHeartRate = activity.max_heartrate != null || activity.average_heartrate != null
const looksHard = maxZone >= 4 || avgZone >= 3 || (!hasHeartRate && minutes <= 35)
if (!looksHard) return []

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
Expand Down
21 changes: 21 additions & 0 deletions web/lib/server/groups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export type GroupMemberWithProfile = {
} | null
}

export type GroupMember = Omit<GroupMemberWithProfile, "profiles">

export type Group = {
id: string
name: string
Expand All @@ -39,6 +41,25 @@ export async function getGroupById(supabase: SupabaseClient, groupId: string): P
return data
}

export async function getGroupMember(
supabase: SupabaseClient,
groupId: string,
userId: string
): Promise<GroupMember | null> {
const { data, error } = await supabase
.from("group_members")
.select("group_id, user_id, role, target_time_sec, created_at")
.eq("group_id", groupId)
.eq("user_id", userId)
.maybeSingle()

if (error) {
console.error("Error fetching group member:", error)
return null
}
return data
}

export async function getGroupMembers(supabase: SupabaseClient, groupId: string): Promise<GroupMemberWithProfile[]> {
const { data, error } = await supabase
.from("group_members")
Expand Down
Loading