diff --git a/web/__tests__/groups.test.ts b/web/__tests__/groups.test.ts new file mode 100644 index 0000000..3e9f7a4 --- /dev/null +++ b/web/__tests__/groups.test.ts @@ -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 = {} + +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) + }) +}) diff --git a/web/__tests__/vma-estimate.test.ts b/web/__tests__/vma-estimate.test.ts index e4b8990..1d527d2 100644 --- a/web/__tests__/vma-estimate.test.ts +++ b/web/__tests__/vma-estimate.test.ts @@ -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( { diff --git a/web/app/(app)/coaching/[id]/page.tsx b/web/app/(app)/coaching/[id]/page.tsx index 1c9fc0a..f2bf18b 100644 --- a/web/app/(app)/coaching/[id]/page.tsx +++ b/web/app/(app)/coaching/[id]/page.tsx @@ -4,6 +4,7 @@ import { createClient } from "@/lib/supabase/server" import { getGroupActivities, getGroupById, + getGroupMember, getGroupMembers, getGroupPlannedSessions, getGroupTrainingBlocks, @@ -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), diff --git a/web/app/(app)/progression/actions.ts b/web/app/(app)/progression/actions.ts new file mode 100644 index 0000000..afbb774 --- /dev/null +++ b/web/app/(app)/progression/actions.ts @@ -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é" } + } +} diff --git a/web/app/(app)/progression/history-refresh-button.tsx b/web/app/(app)/progression/history-refresh-button.tsx new file mode 100644 index 0000000..4b1af69 --- /dev/null +++ b/web/app/(app)/progression/history-refresh-button.tsx @@ -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 ( + + ) +} diff --git a/web/app/(app)/progression/page.tsx b/web/app/(app)/progression/page.tsx index be877fa..a3a0350 100644 --- a/web/app/(app)/progression/page.tsx +++ b/web/app/(app)/progression/page.tsx @@ -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" } @@ -155,9 +156,12 @@ export default async function ProgressionPage() { return (
-
- -

Progression

+
+
+ +

Progression

+
+
diff --git a/web/lib/compute/vma-estimate.ts b/web/lib/compute/vma-estimate.ts index 6ea6fcc..1a13fb1 100644 --- a/web/lib/compute/vma-estimate.ts +++ b/web/lib/compute/vma-estimate.ts @@ -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 diff --git a/web/lib/server/groups.ts b/web/lib/server/groups.ts index a1bbac8..df28115 100644 --- a/web/lib/server/groups.ts +++ b/web/lib/server/groups.ts @@ -13,6 +13,8 @@ export type GroupMemberWithProfile = { } | null } +export type GroupMember = Omit + export type Group = { id: string name: string @@ -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 { + 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 { const { data, error } = await supabase .from("group_members")