diff --git a/supabase/migrations/20260525122151_app_feedback.sql b/supabase/migrations/20260525122151_app_feedback.sql new file mode 100644 index 0000000..e1d1199 --- /dev/null +++ b/supabase/migrations/20260525122151_app_feedback.sql @@ -0,0 +1,18 @@ +-- app_feedback: user feedback submissions (bugs, features, other) with RLS +create table if not exists public.app_feedback ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users on delete cascade, + feedback_type text not null check (feedback_type in ('bug', 'feature', 'other')), + message text not null, + created_at timestamptz default now() not null +); + +alter table public.app_feedback enable row level security; + +drop policy if exists "users_insert_own_feedback" on public.app_feedback; +create policy "users_insert_own_feedback" on public.app_feedback + for insert with check (auth.uid() = user_id); + +drop policy if exists "users_select_own_feedback" on public.app_feedback; +create policy "users_select_own_feedback" on public.app_feedback + for select using (auth.uid() = user_id); diff --git a/supabase/migrations/20260525125310_polar_config.sql b/supabase/migrations/20260525125310_polar_config.sql new file mode 100644 index 0000000..c7242bf --- /dev/null +++ b/supabase/migrations/20260525125310_polar_config.sql @@ -0,0 +1,19 @@ +-- Support for Polar Flow connection. +-- Update check constraint on provider_connections to support 'polar'. +alter table public.provider_connections + drop constraint if exists provider_connections_provider_check; + +alter table public.provider_connections + add constraint provider_connections_provider_check + check (provider in ('strava', 'terra', 'garmin', 'polar')); + +-- Single-row configuration table for Polar App credentials. +create table if not exists public.polar_config ( + id int primary key default 1 check (id = 1), + client_id text not null default '', + client_secret text not null default '', + updated_at timestamptz default now() not null +); + +-- Seed default empty row for Polar configuration. +insert into public.polar_config (id) values (1) on conflict (id) do nothing; diff --git a/web/app/(app)/connections/actions.ts b/web/app/(app)/connections/actions.ts index df1ac67..1ae4bac 100644 --- a/web/app/(app)/connections/actions.ts +++ b/web/app/(app)/connections/actions.ts @@ -119,3 +119,42 @@ export async function disconnectStrava(): Promise<{ success?: boolean; error?: s revalidatePath("/connections") return { success: true } } + +export async function disconnectPolar(): Promise<{ success?: boolean; error?: string }> { + const supabase = await createClient() + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) return { error: "Non authentifié" } + + const { error } = await supabase + .from("provider_connections") + .update({ is_active: false }) + .eq("user_id", user.id) + .eq("provider", "polar") + + if (error) return { error: error.message } + + revalidatePath("/connections") + return { success: true } +} + +export async function syncPolarHistory(days = 30): Promise<{ synced?: number; error?: string }> { + const supabase = await createClient() + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) return { error: "Non authentifié" } + + try { + const { syncPolarMetrics } = await import("@/lib/server/polar/sync") + const synced = await syncPolarMetrics(user.id, days) + revalidatePath("/connections") + revalidatePath("/dashboard") + return { synced } + } catch (e) { + return { error: e instanceof Error ? e.message : "Synchronisation Polar échouée" } + } +} diff --git a/web/app/(app)/connections/connections-client.tsx b/web/app/(app)/connections/connections-client.tsx index bfc9241..51b6145 100644 --- a/web/app/(app)/connections/connections-client.tsx +++ b/web/app/(app)/connections/connections-client.tsx @@ -11,7 +11,9 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { disconnectStrava, disconnectTerra, + disconnectPolar, syncGarminHistory, + syncPolarHistory, syncAllStravaHistory, syncStrava, syncStravaHistory, @@ -189,7 +191,7 @@ export function TerraCard({ connected, providerUserId, lastSyncAt }: TerraCardPr ))} Connecter mon appareil @@ -372,7 +374,7 @@ export function StravaCard({ Connectez votre compte Strava pour importer automatiquement vos activités.

Connecter Strava @@ -383,3 +385,110 @@ export function StravaCard({ ) } + +export function PolarCard({ connected, providerUserId, lastSyncAt }: TerraCardProps) { + const router = useRouter() + const [syncing, setSyncing] = useState(false) + const [disconnecting, setDisconnecting] = useState(false) + + const lastSyncLabel = lastSyncAt + ? new Intl.DateTimeFormat("fr-FR", { + dateStyle: "short", + timeStyle: "short", + }).format(new Date(lastSyncAt)) + : "Jamais" + + async function handleDisconnect() { + setDisconnecting(true) + const result = await disconnectPolar() + setDisconnecting(false) + if (result.error) { + toast.error(result.error) + } else { + toast.success("Polar déconnecté") + router.refresh() + } + } + + return ( + + +
+
+ P +
+
+ Polar Flow + Montres Polar — sommeil, récupération, FC repos +
+
+ + {connected ? "Connecté" : "Non connecté"} + +
+ + + {connected ? ( + <> +
+
+

ID Utilisateur Polar

+

{providerUserId ?? "connecté"}

+
+
+

Dernière actualisation

+

{lastSyncLabel}

+
+
+ +
+

Données synchronisées

+

FC repos · HRV nocturne · Score sommeil · Durée de sommeil

+
+ +
+ + +
+ + ) : ( +
+

+ Connectez votre compte Polar Flow pour importer les données de récupération et de sommeil de votre montre Polar. +

+
+ Connecter Polar + +
+ )} + + + ) +} diff --git a/web/app/(app)/connections/page.tsx b/web/app/(app)/connections/page.tsx index b5b9945..a532ed2 100644 --- a/web/app/(app)/connections/page.tsx +++ b/web/app/(app)/connections/page.tsx @@ -2,50 +2,62 @@ import type { Metadata } from "next" import { createClient } from "@/lib/supabase/server" -import { GarminCard, StravaCard, TerraCard } from "./connections-client" +import { GarminCard, StravaCard, TerraCard, PolarCard } from "./connections-client" export const metadata: Metadata = { title: "Mes connexions · SportTrack" } export default async function ConnectionsPage({ searchParams, }: { - searchParams: Promise<{ strava?: string; terra?: string }> + searchParams: Promise<{ strava?: string; terra?: string; polar?: string }> }) { - const { strava, terra } = await searchParams + const { strava, terra, polar } = await searchParams const supabase = await createClient() const { data: { user }, } = await supabase.auth.getUser() - const [{ data: stravaConn }, { data: terraConn }, { data: garminConn }, { count: activitiesCount }] = - await Promise.all([ - supabase - .from("provider_connections") - .select("provider_user_id,last_sync_at,is_active") - .eq("user_id", user!.id) - .eq("provider", "strava") - .eq("is_active", true) - .maybeSingle(), - supabase - .from("provider_connections") - .select("provider_user_id,last_sync_at,is_active") - .eq("user_id", user!.id) - .eq("provider", "terra") - .eq("is_active", true) - .maybeSingle(), - supabase - .from("provider_connections") - .select("provider_user_id,last_sync_at,is_active") - .eq("user_id", user!.id) - .eq("provider", "garmin") - .eq("is_active", true) - .maybeSingle(), - supabase - .from("activities") - .select("id", { count: "exact", head: true }) - .eq("user_id", user!.id) - .eq("provider", "strava"), - ]) + const [ + { data: stravaConn }, + { data: terraConn }, + { data: garminConn }, + { data: polarConn }, + { count: activitiesCount }, + ] = await Promise.all([ + supabase + .from("provider_connections") + .select("provider_user_id,last_sync_at,is_active") + .eq("user_id", user!.id) + .eq("provider", "strava") + .eq("is_active", true) + .maybeSingle(), + supabase + .from("provider_connections") + .select("provider_user_id,last_sync_at,is_active") + .eq("user_id", user!.id) + .eq("provider", "terra") + .eq("is_active", true) + .maybeSingle(), + supabase + .from("provider_connections") + .select("provider_user_id,last_sync_at,is_active") + .eq("user_id", user!.id) + .eq("provider", "garmin") + .eq("is_active", true) + .maybeSingle(), + supabase + .from("provider_connections") + .select("provider_user_id,last_sync_at,is_active") + .eq("user_id", user!.id) + .eq("provider", "polar") + .eq("is_active", true) + .maybeSingle(), + supabase + .from("activities") + .select("id", { count: "exact", head: true }) + .eq("user_id", user!.id) + .eq("provider", "strava"), + ]) return (
@@ -81,6 +93,18 @@ export default async function ConnectionsPage({
) : null} + {polar === "connected" ? ( +
+ Polar est connecté. Une première synchronisation des données de santé est en cours. +
+ ) : null} + + {polar === "error" ? ( +
+ La connexion Polar a échoué. Vérifiez la configuration du Client ID / Secret développeur Polar puis réessayez. +
+ ) : null} + + + if (error) return { error: error.message } - revalidatePath("/injuries") + revalidatePath("/health") return {} } @@ -56,7 +56,7 @@ export async function updateInjuryEndDate( if (error) return { error: error.message } - revalidatePath("/injuries") + revalidatePath("/health") return {} } @@ -73,6 +73,6 @@ export async function deleteInjury(injuryId: string): Promise { if (error) return { error: error.message } - revalidatePath("/injuries") + revalidatePath("/health") return {} } diff --git a/web/app/(app)/health/health-chart.tsx b/web/app/(app)/health/health-chart.tsx new file mode 100644 index 0000000..91c3f1b --- /dev/null +++ b/web/app/(app)/health/health-chart.tsx @@ -0,0 +1,146 @@ +"use client" + +import { useState } from "react" +import { format, parseISO } from "date-fns" +import { fr } from "date-fns/locale" +import { + LineChart, + Line, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, + CartesianGrid, +} from "recharts" + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" + +type DailyMetric = { + metric_date: string + resting_hr: number | null + hrv_rmssd: number | null +} + +interface HealthChartProps { + data: DailyMetric[] +} + +function CustomTooltip({ + active, + payload, + label, + type, +}: { + active?: boolean + payload?: any[] + label?: string + type: "hr" | "hrv" +}) { + if (!active || !payload?.length) return null + + const value = payload[0].value + const dateStr = label ? format(parseISO(label), "d MMMM yyyy", { locale: fr }) : "" + + return ( +
+

{dateStr}

+

+ + {type === "hr" ? "FC repos : " : "VFC (HRV) : "} + {value} + + {type === "hr" ? " bpm" : " ms"} + +

+
+ ) +} + +export function HealthChart({ data }: HealthChartProps) { + const [activeTab, setActiveTab] = useState<"hr" | "hrv">("hr") + + // Filter metrics that have the active metric populated to avoid empty points + const chartData = data + .map((d) => ({ + date: d.metric_date, + value: activeTab === "hr" ? d.resting_hr : d.hrv_rmssd, + })) + .filter((d) => d.value !== null) + + const hasData = chartData.length > 0 + + return ( + + +
+ + 📈 Tendances (30 jours) + +
+ + +
+
+
+ + {!hasData ? ( +
+ Données insuffisantes sur les 30 derniers jours.
+ Synchronisez votre montre connectée pour afficher le graphique. +
+ ) : ( + + + + format(parseISO(tick), "d MMM", { locale: fr })} + /> + + } /> + + + + )} +
+
+ ) +} diff --git a/web/app/(app)/injuries/injury-actions.tsx b/web/app/(app)/health/injury-actions.tsx similarity index 100% rename from web/app/(app)/injuries/injury-actions.tsx rename to web/app/(app)/health/injury-actions.tsx diff --git a/web/app/(app)/injuries/injury-form-toggle.tsx b/web/app/(app)/health/injury-form-toggle.tsx similarity index 100% rename from web/app/(app)/injuries/injury-form-toggle.tsx rename to web/app/(app)/health/injury-form-toggle.tsx diff --git a/web/app/(app)/injuries/injury-form.tsx b/web/app/(app)/health/injury-form.tsx similarity index 100% rename from web/app/(app)/injuries/injury-form.tsx rename to web/app/(app)/health/injury-form.tsx diff --git a/web/app/(app)/health/page.tsx b/web/app/(app)/health/page.tsx new file mode 100644 index 0000000..daee537 --- /dev/null +++ b/web/app/(app)/health/page.tsx @@ -0,0 +1,427 @@ +import type { Metadata } from "next" +import { format, parseISO } from "date-fns" +import { fr } from "date-fns/locale" +import { + AlertTriangle, + CheckCircle2, + HeartPulse, + ShieldCheck, + Moon, + Activity, + Heart, + Zap, + Gauge, + Wind, + Droplet, + TrendingUp, + Info, +} from "lucide-react" + +import { createClient } from "@/lib/supabase/server" +import { Badge } from "@/components/ui/badge" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { EmptyState } from "@/components/ui/empty-state" +import { getAcwrContext } from "@/lib/server/injuries/acwr" + +import { InjuryActions } from "./injury-actions" +import { InjuryFormToggle } from "./injury-form-toggle" +import { HealthChart } from "./health-chart" + +export const metadata: Metadata = { title: "Santé · SportTrack" } + +type Injury = { + id: string + body_zone: string + injury_type: string | null + severity: number | null + start_date: string + end_date: string | null + description: string | null + treatment: string | null +} + +const SEVERITY_CONFIG: Record = { + 1: { label: "Légère", className: "bg-yellow-100 text-yellow-800 border-yellow-200 dark:bg-yellow-950/30 dark:text-yellow-400 dark:border-yellow-900/50" }, + 2: { label: "Modérée", className: "bg-orange-100 text-orange-800 border-orange-200 dark:bg-orange-950/30 dark:text-orange-400 dark:border-orange-900/50" }, + 3: { label: "Sévère", className: "bg-red-100 text-red-800 border-red-200 dark:bg-red-950/30 dark:text-red-400 dark:border-red-900/50" }, +} + +const INJURY_TYPE_LABELS: Record = { + muscular: "Musculaire", + tendinous: "Tendineux", + bone: "Osseux", + ligament: "Ligamentaire", + other: "Autre", +} + +const HRV_STATUS_MAP: Record = { + balanced: { label: "Équilibrée", className: "bg-emerald-100 text-emerald-800 border-emerald-200 dark:bg-emerald-950/30 dark:text-emerald-400 dark:border-emerald-800/50" }, + low: { label: "Basse", className: "bg-amber-100 text-amber-800 border-amber-200 dark:bg-amber-950/30 dark:text-amber-400 dark:border-amber-800/50" }, + unbalanced: { label: "Déséquilibrée", className: "bg-orange-100 text-orange-800 border-orange-200 dark:bg-orange-950/30 dark:text-orange-400 dark:border-orange-800/50" }, + poor: { label: "Mauvaise", className: "bg-red-100 text-red-800 border-red-200 dark:bg-red-950/30 dark:text-red-400 dark:border-red-800/50" }, + no_status: { label: "Pas de statut", className: "bg-muted text-muted-foreground border-border" }, +} + +function formatZone(zone: string): string { + return zone.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()) +} + +function formatSleepDuration(minutes: number | null): string { + if (minutes == null) return "–" + const h = Math.floor(minutes / 60) + const m = minutes % 60 + if (h > 0) return `${h}h${m.toString().padStart(2, "0")}` + return `${m}m` +} + +function getLatestValue(metrics: any[] | null, key: string): T | null { + if (!metrics) return null + for (let i = metrics.length - 1; i >= 0; i--) { + if (metrics[i][key] != null) { + return metrics[i][key] as T + } + } + return null +} + +function HelpTooltip({ content }: { content: string }) { + return ( +
+ +
+
+

{content}

+
+
+ ) +} + +function InjuryCard({ injury }: { injury: Injury }) { + const isActive = !injury.end_date + const sev = injury.severity ? SEVERITY_CONFIG[injury.severity] : null + const startFmt = format(parseISO(injury.start_date), "d MMM yyyy", { locale: fr }) + const endFmt = injury.end_date + ? format(parseISO(injury.end_date), "d MMM yyyy", { locale: fr }) + : null + + return ( + + +
+
+ {isActive ? ( + + ) : ( + + )} +
+

{formatZone(injury.body_zone)}

+

+ {startFmt} + {endFmt ? ` → ${endFmt}` : " → en cours"} +

+
+
+
+ {sev && ( + + {sev.label} + + )} + {injury.injury_type && ( + + {INJURY_TYPE_LABELS[injury.injury_type] ?? injury.injury_type} + + )} + +
+
+ + {injury.description && ( +

{injury.description}

+ )} + {injury.treatment && ( +

+ Traitement : {injury.treatment} +

+ )} +
+
+ ) +} + +export default async function HealthPage() { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return null + + const today = new Date().toISOString().slice(0, 10) + const thirtyDaysAgo = new Date() + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30) + const thirtyDaysAgoStr = thirtyDaysAgo.toISOString().slice(0, 10) + + // Promise.all to fetch metrics, injuries and ACWR in parallel + const [dailyMetricsResult, injuriesResult, acwr] = await Promise.all([ + supabase + .from("daily_metrics") + .select("metric_date, resting_hr, hrv_rmssd, hrv_status, sleep_score, sleep_duration_min, sleep_deep_min, sleep_rem_min, sleep_light_min, sleep_awake_min, body_battery_morning, body_battery_evening, training_readiness, stress_score_avg, spo2_avg, respiration_avg, vo2max_estimated") + .eq("user_id", user.id) + .gte("metric_date", thirtyDaysAgoStr) + .order("metric_date", { ascending: true }), + supabase + .from("injuries") + .select("id, body_zone, injury_type, severity, start_date, end_date, description, treatment") + .eq("user_id", user.id) + .order("start_date", { ascending: false }), + getAcwrContext(user.id), + ]) + + const dailyMetrics = dailyMetricsResult.data ?? [] + const injuries = injuriesResult.data ?? [] + + // Extract active vs historical injuries + const active = injuries.filter((i) => !i.end_date || i.end_date >= today) + const historical = injuries.filter((i) => i.end_date && i.end_date < today) + + // Get the latest values for key recovery metrics + const sleepScore = getLatestValue(dailyMetrics, "sleep_score") + const sleepDuration = getLatestValue(dailyMetrics, "sleep_duration_min") + const hrv = getLatestValue(dailyMetrics, "hrv_rmssd") + const hrvStatus = getLatestValue(dailyMetrics, "hrv_status") + const restingHr = getLatestValue(dailyMetrics, "resting_hr") + const bodyBattery = getLatestValue(dailyMetrics, "body_battery_morning") + const stressScore = getLatestValue(dailyMetrics, "stress_score_avg") + const spo2 = getLatestValue(dailyMetrics, "spo2_avg") + const respiration = getLatestValue(dailyMetrics, "respiration_avg") + const vo2max = getLatestValue(dailyMetrics, "vo2max_estimated") + + return ( +
+ {/* Page Header */} +
+
+ +

Santé & Récupération

+
+ +
+ + {/* Main recovery metrics grid */} +
+ {/* Sommeil */} + + +
+ + + Sommeil + + +
+
+ + {sleepScore !== null ? `${sleepScore}` : "–"} + + {sleepScore !== null && /100} +
+

+ Durée : {formatSleepDuration(sleepDuration)} +

+
+
+ + {/* HRV */} + + +
+ + + VFC / HRV + + +
+
+ + {hrv !== null ? `${Math.round(hrv)}` : "–"} + + {hrv !== null && ms} +
+
+ {hrvStatus ? ( + + {HRV_STATUS_MAP[hrvStatus]?.label || hrvStatus} + + ) : ( + + )} +
+
+
+ + {/* FC de repos */} + + +
+ + + FC repos + + +
+
+ + {restingHr !== null ? `${Math.round(restingHr)}` : "–"} + + {restingHr !== null && bpm} +
+

+ Reflète la récupération globale +

+
+
+ + {/* Body Battery & Stress */} + + +
+ + + Energie / Stress + + +
+
+ + {bodyBattery !== null ? `${Math.round(bodyBattery)}` : "–"} + + {bodyBattery !== null && /100 (Battery)} +
+

+ Stress moyen : {stressScore !== null ? `${Math.round(stressScore)}/100` : "–"} +

+
+
+
+ + {/* Secondary metrics summary */} + + + + + Autres indicateurs physiologiques + + + +
+
+ + SpO2 moyen + +
+

+ {spo2 !== null ? `${spo2.toFixed(1)}%` : "–"} +

+
+
+
+ + Respiration + +
+

+ {respiration !== null ? `${respiration.toFixed(1)} cpm` : "–"} +

+
+
+
+ + VO2 Max + +
+

+ {vo2max !== null ? `${vo2max.toFixed(0)}` : "–"} +

+
+
+
+ + {/* Middle Grid - Trend Chart and Training Load Context */} +
+ {/* Trend Graph (2/3 width on desktop) */} +
+ +
+ + {/* Load Context Card (1/3 width on desktop) */} +
+ + + + Contexte charge + + + +
+ + ACWR + + + {acwr.acwr.toFixed(2)} +
+
+ Charge aiguë (7j) + {acwr.acute_load_7d} +
+
+ Charge chronique (28j) + {acwr.chronic_load_28d} +
+
+
+
+
+ + {/* Injuries Registry Section */} +
+

+ Registre des blessures +

+ {active.length === 0 && historical.length === 0 ? ( + + ) : ( +
+ {/* Active Injuries */} +
+

+ En cours ({active.length}) +

+ {active.length === 0 ? ( +

Aucune blessure active signalée.

+ ) : ( +
+ {active.map((i) => )} +
+ )} +
+ + {/* Historical Injuries */} + {historical.length > 0 && ( +
+

+ Historique ({historical.length}) +

+
+ {historical.map((i) => )} +
+
+ )} +
+ )} +
+
+ ) +} diff --git a/web/app/(app)/injuries/page.tsx b/web/app/(app)/injuries/page.tsx deleted file mode 100644 index be89df1..0000000 --- a/web/app/(app)/injuries/page.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import type { Metadata } from "next" -import { format, parseISO } from "date-fns" -import { fr } from "date-fns/locale" -import { AlertTriangle, CheckCircle2, HeartPulse, ShieldCheck } from "lucide-react" - -import { createClient } from "@/lib/supabase/server" -import { Badge } from "@/components/ui/badge" -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -import { EmptyState } from "@/components/ui/empty-state" -import { getAcwrContext } from "@/lib/server/injuries/acwr" - -import { InjuryActions } from "./injury-actions" -import { InjuryFormToggle } from "./injury-form-toggle" - -export const metadata: Metadata = { title: "Blessures · SportTrack" } - -type Injury = { - id: string - body_zone: string - injury_type: string | null - severity: number | null - start_date: string - end_date: string | null - description: string | null - treatment: string | null -} - -const SEVERITY_CONFIG: Record = { - 1: { label: "Légère", className: "bg-yellow-100 text-yellow-800 border-yellow-200" }, - 2: { label: "Modérée", className: "bg-orange-100 text-orange-800 border-orange-200" }, - 3: { label: "Sévère", className: "bg-red-100 text-red-800 border-red-200" }, -} - -const INJURY_TYPE_LABELS: Record = { - muscular: "Musculaire", - tendinous: "Tendineux", - bone: "Osseux", - ligament: "Ligamentaire", - other: "Autre", -} - -function formatZone(zone: string): string { - return zone.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()) -} - -function InjuryCard({ injury }: { injury: Injury }) { - const isActive = !injury.end_date - const sev = injury.severity ? SEVERITY_CONFIG[injury.severity] : null - const startFmt = format(parseISO(injury.start_date), "d MMM yyyy", { locale: fr }) - const endFmt = injury.end_date - ? format(parseISO(injury.end_date), "d MMM yyyy", { locale: fr }) - : null - - return ( - - -
-
- {isActive ? ( - - ) : ( - - )} -
-

{formatZone(injury.body_zone)}

-

- {startFmt} - {endFmt ? ` → ${endFmt}` : " → en cours"} -

-
-
-
- {sev && ( - - {sev.label} - - )} - {injury.injury_type && ( - - {INJURY_TYPE_LABELS[injury.injury_type] ?? injury.injury_type} - - )} - -
-
- - {injury.description && ( -

{injury.description}

- )} - {injury.treatment && ( -

- Traitement : {injury.treatment} -

- )} -
-
- ) -} - -export default async function InjuriesPage() { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return null - - const today = new Date().toISOString().slice(0, 10) - - const { data: injuries } = await supabase - .from("injuries") - .select("id, body_zone, injury_type, severity, start_date, end_date, description, treatment") - .eq("user_id", user.id) - .order("start_date", { ascending: false }) - - const acwr = await getAcwrContext(user.id) - const active = (injuries ?? []).filter((i) => !i.end_date || i.end_date >= today) - const historical = (injuries ?? []).filter((i) => i.end_date && i.end_date < today) - - return ( -
-
-
- -

Blessures

-
- -
- - - - Contexte charge - - -
-

ACWR

-

{acwr.acwr.toFixed(2)}

-
-
-

Charge 7j

-

{acwr.acute_load_7d}

-
-
-

Charge 28j

-

{acwr.chronic_load_28d}

-
-
-
- - {active.length === 0 && historical.length === 0 ? ( - - ) : ( - <> - {/* Blessures actives */} -
-

- En cours ({active.length}) -

- {active.length === 0 ? ( -

Aucune blessure active.

- ) : ( - active.map((i) => ) - )} -
- - {/* Historique */} - {historical.length > 0 && ( -
-

- Historique ({historical.length}) -

- {historical.map((i) => )} -
- )} - - )} -
- ) -} diff --git a/web/app/(app)/settings/feedback-actions.ts b/web/app/(app)/settings/feedback-actions.ts new file mode 100644 index 0000000..59867c8 --- /dev/null +++ b/web/app/(app)/settings/feedback-actions.ts @@ -0,0 +1,43 @@ +"use server" + +import { revalidatePath } from "next/cache" +import { createClient } from "@/lib/supabase/server" + +export async function submitAppFeedback( + formData: FormData, +): Promise<{ success?: boolean; error?: string }> { + const supabase = await createClient() + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) return { error: "Non authentifié" } + + const feedbackType = formData.get("feedback_type") as string + const message = (formData.get("message") as string)?.trim() ?? "" + + if (!feedbackType || !["bug", "feature", "other"].includes(feedbackType)) { + return { error: "Type de retour invalide." } + } + + if (!message) { + return { error: "Le message ne peut pas être vide." } + } + + if (message.length > 5000) { + return { error: "Le message est trop long (maximum 5000 caractères)." } + } + + const { error } = await supabase.from("app_feedback").insert({ + user_id: user.id, + feedback_type: feedbackType as "bug" | "feature" | "other", + message, + }) + + if (error) { + return { error: error.message } + } + + revalidatePath("/settings") + return { success: true } +} diff --git a/web/app/(app)/settings/feedback-form.tsx b/web/app/(app)/settings/feedback-form.tsx new file mode 100644 index 0000000..9f0179b --- /dev/null +++ b/web/app/(app)/settings/feedback-form.tsx @@ -0,0 +1,139 @@ +"use client" + +import { useState, useTransition } from "react" +import { toast } from "sonner" +import { Bug, Lightbulb, MessageSquare, Send } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" + +import { submitAppFeedback } from "./feedback-actions" + +export function FeedbackForm() { + const [feedbackType, setFeedbackType] = useState<"bug" | "feature" | "other">("bug") + const [message, setMessage] = useState("") + const [isPending, startTransition] = useTransition() + + function handleSubmit(e: React.FormEvent) { + e.preventDefault() + if (!message.trim()) { + toast.error("Veuillez saisir un message.") + return + } + + const formData = new FormData() + formData.set("feedback_type", feedbackType) + formData.set("message", message) + + startTransition(async () => { + const result = await submitAppFeedback(formData) + if (result.error) { + toast.error(result.error) + } else { + toast.success("Merci ! Votre retour a bien été transmis.") + setMessage("") + } + }) + } + + const types = [ + { + key: "bug", + label: "Bug / Problème", + icon: Bug, + color: "text-red-500", + bgColor: "bg-red-50/50 dark:bg-red-950/20 border-red-200 dark:border-red-900/30", + }, + { + key: "feature", + label: "Idée / Amélioration", + icon: Lightbulb, + color: "text-yellow-500", + bgColor: "bg-yellow-50/50 dark:bg-yellow-950/20 border-yellow-200 dark:border-yellow-900/30", + }, + { + key: "other", + label: "Autre remarque", + icon: MessageSquare, + color: "text-blue-500", + bgColor: "bg-blue-50/50 dark:bg-blue-950/20 border-blue-200 dark:border-blue-900/30", + }, + ] + + return ( + + + + + Donner votre avis / Signaler un bug + + + Partagez vos impressions sur SportTrack, suggérez une nouvelle fonctionnalité ou signalez un bug. + + + +
+ {/* Type selector */} +
+ +
+ {types.map((t) => { + const isSelected = feedbackType === t.key + const Icon = t.icon + return ( + + ) + })} +
+
+ + {/* Message content */} +
+ +
+