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
45 changes: 45 additions & 0 deletions web/__tests__/connections-actions.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
46 changes: 46 additions & 0 deletions web/__tests__/middleware.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
16 changes: 10 additions & 6 deletions web/app/(app)/connections/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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" }
Expand All @@ -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é" }
Expand All @@ -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é" }
Expand Down Expand Up @@ -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 }
}

Expand Down
12 changes: 6 additions & 6 deletions web/app/(app)/health/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ export default async function HealthPage() {
{/* Main recovery metrics grid */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{/* Sommeil */}
<Card className="relative border border-border bg-card/50 backdrop-blur-sm transition-all hover:bg-card">
<Card className="relative overflow-visible border border-border bg-card/50 backdrop-blur-sm transition-all hover:z-20 hover:bg-card">
<CardContent className="pt-4 flex flex-col justify-between h-full min-h-[110px]">
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span className="flex items-center gap-1.5 font-medium">
Expand All @@ -231,7 +231,7 @@ export default async function HealthPage() {
</Card>

{/* HRV */}
<Card className="relative border border-border bg-card/50 backdrop-blur-sm transition-all hover:bg-card">
<Card className="relative overflow-visible border border-border bg-card/50 backdrop-blur-sm transition-all hover:z-20 hover:bg-card">
<CardContent className="pt-4 flex flex-col justify-between h-full min-h-[110px]">
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span className="flex items-center gap-1.5 font-medium">
Expand Down Expand Up @@ -259,7 +259,7 @@ export default async function HealthPage() {
</Card>

{/* FC de repos */}
<Card className="relative border border-border bg-card/50 backdrop-blur-sm transition-all hover:bg-card">
<Card className="relative overflow-visible border border-border bg-card/50 backdrop-blur-sm transition-all hover:z-20 hover:bg-card">
<CardContent className="pt-4 flex flex-col justify-between h-full min-h-[110px]">
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span className="flex items-center gap-1.5 font-medium">
Expand All @@ -281,7 +281,7 @@ export default async function HealthPage() {
</Card>

{/* Body Battery & Stress */}
<Card className="relative border border-border bg-card/50 backdrop-blur-sm transition-all hover:bg-card">
<Card className="relative overflow-visible border border-border bg-card/50 backdrop-blur-sm transition-all hover:z-20 hover:bg-card">
<CardContent className="pt-4 flex flex-col justify-between h-full min-h-[110px]">
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span className="flex items-center gap-1.5 font-medium">
Expand All @@ -304,7 +304,7 @@ export default async function HealthPage() {
</div>

{/* Secondary metrics summary */}
<Card className="border border-border bg-card/30 backdrop-blur-sm">
<Card className="relative overflow-visible border border-border bg-card/30 backdrop-blur-sm">
<CardHeader className="py-2.5 px-4 border-b border-border/40">
<CardTitle className="text-xs font-semibold uppercase tracking-wider text-muted-foreground flex items-center gap-1.5">
<TrendingUp className="h-3.5 w-3.5" />
Expand Down Expand Up @@ -354,7 +354,7 @@ export default async function HealthPage() {

{/* Load Context Card (1/3 width on desktop) */}
<div>
<Card className="h-full">
<Card className="relative h-full overflow-visible">
<CardHeader className="pb-2 border-b border-border/40">
<CardTitle className="text-sm font-semibold tracking-wide text-muted-foreground uppercase">
Contexte charge
Expand Down
3 changes: 3 additions & 0 deletions web/app/(app)/progression/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 } {
Expand Down Expand Up @@ -132,6 +134,7 @@ 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>
Expand Down
28 changes: 28 additions & 0 deletions web/app/(app)/progression/progression-auto-refresh.tsx
Original file line number Diff line number Diff line change
@@ -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
}
4 changes: 4 additions & 0 deletions web/app/api/strava/callback/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createHmac, timingSafeEqual } from "crypto"
import { revalidatePath } from "next/cache"
import { NextRequest, NextResponse } from "next/server"

function getStateSecret() {
Expand Down Expand Up @@ -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`)
Expand Down
8 changes: 8 additions & 0 deletions web/app/api/strava/webhook/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
* -F verify_token=<STRAVA_WEBHOOK_VERIFY_TOKEN>
*/

import { revalidatePath } from "next/cache"
import { NextRequest, NextResponse } from "next/server"

import { createServiceClient } from "@/lib/supabase/service"
Expand Down Expand Up @@ -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")
}
2 changes: 2 additions & 0 deletions web/lib/supabase/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading