diff --git a/web/__tests__/connections-actions.test.ts b/web/__tests__/connections-actions.test.ts new file mode 100644 index 0000000..9d9e2d8 --- /dev/null +++ b/web/__tests__/connections-actions.test.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +const { revalidatePathMock, syncRecentStravaMock } = vi.hoisted(() => ({ + revalidatePathMock: vi.fn(), + syncRecentStravaMock: vi.fn(), +})) + +vi.mock("next/cache", () => ({ + revalidatePath: revalidatePathMock, +})) + +vi.mock("@/lib/server/strava/sync", () => ({ + importAllStravaHistory: vi.fn(), + importStravaHistory: vi.fn(), + syncRecentStrava: syncRecentStravaMock, +})) + +vi.mock("@/lib/server/garmin/sync", () => ({ + syncGarminMetrics: vi.fn(), +})) + +vi.mock("@/lib/supabase/server", () => ({ + createClient: async () => ({ + auth: { + getUser: async () => ({ data: { user: { id: "user-1" } } }), + }, + }), +})) + +import { syncStrava } from "@/app/(app)/connections/actions" + +describe("connections actions", () => { + beforeEach(() => { + vi.clearAllMocks() + syncRecentStravaMock.mockResolvedValue({ imported: 1, skipped: 0 }) + }) + + it("revalidates progression after a Strava sync", async () => { + await expect(syncStrava()).resolves.toEqual({ synced: 1 }) + + expect(revalidatePathMock).toHaveBeenCalledWith("/connections") + expect(revalidatePathMock).toHaveBeenCalledWith("/dashboard") + expect(revalidatePathMock).toHaveBeenCalledWith("/progression") + }) +}) diff --git a/web/__tests__/middleware.test.ts b/web/__tests__/middleware.test.ts new file mode 100644 index 0000000..27b1432 --- /dev/null +++ b/web/__tests__/middleware.test.ts @@ -0,0 +1,46 @@ +import { NextRequest } from "next/server" +import { beforeEach, describe, expect, it, vi } from "vitest" + +const { getUserMock } = vi.hoisted(() => ({ + getUserMock: vi.fn(), +})) + +vi.mock("@supabase/ssr", () => ({ + createServerClient: () => ({ + auth: { + getUser: getUserMock, + }, + }), +})) + +import { updateSession } from "@/lib/supabase/middleware" + +function request(path: string) { + return new NextRequest(`https://sporttrack.test${path}`) +} + +describe("updateSession", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubEnv("NEXT_PUBLIC_SUPABASE_URL", "https://supabase.test") + vi.stubEnv("NEXT_PUBLIC_SUPABASE_ANON_KEY", "anon-key") + getUserMock.mockResolvedValue({ data: { user: null } }) + }) + + it.each(["/api/cron/garmin", "/api/cron/polar"])( + "allows unauthenticated cron route %s to reach its handler", + async (path) => { + const response = await updateSession(request(path)) + + expect(response.status).toBe(200) + expect(response.headers.get("location")).toBeNull() + }, + ) + + it("redirects unauthenticated app routes to login", async () => { + const response = await updateSession(request("/dashboard")) + + expect(response.status).toBe(307) + expect(response.headers.get("location")).toBe("https://sporttrack.test/login?redirect=%2Fdashboard") + }) +}) diff --git a/web/app/(app)/connections/actions.ts b/web/app/(app)/connections/actions.ts index 6799704..0dbfd14 100644 --- a/web/app/(app)/connections/actions.ts +++ b/web/app/(app)/connections/actions.ts @@ -8,6 +8,12 @@ import { createClient } from "@/lib/supabase/server" const POLAR_FULL_HISTORY_DAYS = 3650 +function revalidateStravaViews() { + revalidatePath("/connections") + revalidatePath("/dashboard") + revalidatePath("/progression") +} + export async function syncStrava(): Promise<{ synced?: number; error?: string }> { const supabase = await createClient() const { @@ -18,8 +24,7 @@ export async function syncStrava(): Promise<{ synced?: number; error?: string }> try { const { imported } = await syncRecentStrava(user.id) - revalidatePath("/connections") - revalidatePath("/dashboard") + revalidateStravaViews() return { synced: imported } } catch (e) { return { error: e instanceof Error ? e.message : "Synchronisation échouée" } @@ -38,8 +43,7 @@ export async function syncStravaHistory( try { const { imported } = await importStravaHistory(user.id, days) - revalidatePath("/connections") - revalidatePath("/dashboard") + revalidateStravaViews() return { synced: imported } } catch (e) { return { error: e instanceof Error ? e.message : "Import historique échoué" } @@ -56,8 +60,7 @@ export async function syncAllStravaHistory(): Promise<{ synced?: number; error?: try { const { imported } = await importAllStravaHistory(user.id) - revalidatePath("/connections") - revalidatePath("/dashboard") + revalidateStravaViews() return { synced: imported } } catch (e) { return { error: e instanceof Error ? e.message : "Import complet échoué" } @@ -119,6 +122,7 @@ export async function disconnectStrava(): Promise<{ success?: boolean; error?: s if (error) return { error: error.message } revalidatePath("/connections") + revalidatePath("/progression") return { success: true } } diff --git a/web/app/(app)/health/page.tsx b/web/app/(app)/health/page.tsx index 69fe09d..50d0c39 100644 --- a/web/app/(app)/health/page.tsx +++ b/web/app/(app)/health/page.tsx @@ -209,7 +209,7 @@ export default async function HealthPage() { {/* Main recovery metrics grid */}
{/* Sommeil */} - +
@@ -231,7 +231,7 @@ export default async function HealthPage() { {/* HRV */} - +
@@ -259,7 +259,7 @@ export default async function HealthPage() { {/* FC de repos */} - +
@@ -281,7 +281,7 @@ export default async function HealthPage() { {/* Body Battery & Stress */} - +
@@ -304,7 +304,7 @@ export default async function HealthPage() {
{/* Secondary metrics summary */} - + @@ -354,7 +354,7 @@ export default async function HealthPage() { {/* Load Context Card (1/3 width on desktop) */}
- + Contexte charge diff --git a/web/app/(app)/progression/page.tsx b/web/app/(app)/progression/page.tsx index 9aa63e3..68af2fb 100644 --- a/web/app/(app)/progression/page.tsx +++ b/web/app/(app)/progression/page.tsx @@ -12,6 +12,8 @@ import { UserPRs } from "@/components/progression/user-prs" import { StravaAchievements } from "@/components/progression/strava-achievements" import { ensureValidStravaToken } from "@/lib/server/strava/tokens" +import { ProgressionAutoRefresh } from "./progression-auto-refresh" + export const metadata: Metadata = { title: "Progression · SportTrack" } function computePolarization(zones: ZoneEntry[]): { low: number; mid: number; high: number } { @@ -132,6 +134,7 @@ export default async function ProgressionPage() { return (
+

Progression

diff --git a/web/app/(app)/progression/progression-auto-refresh.tsx b/web/app/(app)/progression/progression-auto-refresh.tsx new file mode 100644 index 0000000..c2e78a2 --- /dev/null +++ b/web/app/(app)/progression/progression-auto-refresh.tsx @@ -0,0 +1,28 @@ +"use client" + +import { useEffect } from "react" +import { useRouter } from "next/navigation" + +export function ProgressionAutoRefresh() { + const router = useRouter() + + useEffect(() => { + const refreshVisiblePage = () => { + if (document.visibilityState === "visible") { + router.refresh() + } + } + + window.addEventListener("focus", refreshVisiblePage) + document.addEventListener("visibilitychange", refreshVisiblePage) + const interval = window.setInterval(refreshVisiblePage, 60_000) + + return () => { + window.removeEventListener("focus", refreshVisiblePage) + document.removeEventListener("visibilitychange", refreshVisiblePage) + window.clearInterval(interval) + } + }, [router]) + + return null +} diff --git a/web/app/api/strava/callback/route.ts b/web/app/api/strava/callback/route.ts index f416ef0..2656a92 100644 --- a/web/app/api/strava/callback/route.ts +++ b/web/app/api/strava/callback/route.ts @@ -1,4 +1,5 @@ import { createHmac, timingSafeEqual } from "crypto" +import { revalidatePath } from "next/cache" import { NextRequest, NextResponse } from "next/server" function getStateSecret() { @@ -58,6 +59,9 @@ export async function GET(request: NextRequest) { await syncRecentStrava(user_id, { perPage: 30, maxPages: 2 }).catch((syncError) => { console.error("initial strava sync failed", syncError) }) + revalidatePath("/connections") + revalidatePath("/dashboard") + revalidatePath("/progression") } catch (e) { console.error("strava callback failed", e) return NextResponse.redirect(`${baseUrl}/connections?strava=error`) diff --git a/web/app/api/strava/webhook/route.ts b/web/app/api/strava/webhook/route.ts index 1af99c6..57e922f 100644 --- a/web/app/api/strava/webhook/route.ts +++ b/web/app/api/strava/webhook/route.ts @@ -12,6 +12,7 @@ * -F verify_token= */ +import { revalidatePath } from "next/cache" import { NextRequest, NextResponse } from "next/server" import { createServiceClient } from "@/lib/supabase/service" @@ -78,8 +79,15 @@ async function processActivityEvent(event: StravaEvent) { if (event.aspect_type === "delete") { await deleteStravaActivity(conn.user_id, event.object_id) + revalidateStravaViews() return } await syncSingleStravaActivity(conn.user_id, event.object_id) + revalidateStravaViews() +} + +function revalidateStravaViews() { + revalidatePath("/dashboard") + revalidatePath("/progression") } diff --git a/web/lib/supabase/middleware.ts b/web/lib/supabase/middleware.ts index 2d308e6..44b603b 100644 --- a/web/lib/supabase/middleware.ts +++ b/web/lib/supabase/middleware.ts @@ -10,6 +10,8 @@ const PUBLIC_PATHS = [ "/forgot-password", "/reset-password", "/auth/callback", + "/api/cron/garmin", + "/api/cron/polar", "/api/cron/daily-injury", "/api/cron/daily-risk", "/api/strava/callback",