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
18 changes: 18 additions & 0 deletions supabase/migrations/20260525122151_app_feedback.sql
Original file line number Diff line number Diff line change
@@ -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);
19 changes: 19 additions & 0 deletions supabase/migrations/20260525125310_polar_config.sql
Original file line number Diff line number Diff line change
@@ -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;
39 changes: 39 additions & 0 deletions web/app/(app)/connections/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
}
}
113 changes: 111 additions & 2 deletions web/app/(app)/connections/connections-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import {
disconnectStrava,
disconnectTerra,
disconnectPolar,
syncGarminHistory,
syncPolarHistory,
syncAllStravaHistory,
syncStrava,
syncStravaHistory,
Expand Down Expand Up @@ -189,7 +191,7 @@ export function TerraCard({ connected, providerUserId, lastSyncAt }: TerraCardPr
))}
</div>
<a
href="/settings"
href="/connections/terra/connect"
className="inline-flex items-center justify-center rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground shadow hover:bg-primary/90"
>
Connecter mon appareil
Expand Down Expand Up @@ -372,7 +374,7 @@ export function StravaCard({
Connectez votre compte Strava pour importer automatiquement vos activités.
</p>
<a
href="/settings"
href="/connections/strava/connect"
className="inline-flex items-center justify-center rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground shadow hover:bg-primary/90"
>
Connecter Strava
Expand All @@ -383,3 +385,110 @@ export function StravaCard({
</Card>
)
}

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 (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-red-600 text-white font-bold text-sm">
P
</div>
<div>
<CardTitle className="text-lg">Polar Flow</CardTitle>
<CardDescription>Montres Polar — sommeil, récupération, FC repos</CardDescription>
</div>
</div>
<Badge variant={connected ? "default" : "secondary"}>
{connected ? "Connecté" : "Non connecté"}
</Badge>
</CardHeader>

<CardContent className="space-y-4">
{connected ? (
<>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-muted-foreground">ID Utilisateur Polar</p>
<p className="font-medium truncate">{providerUserId ?? "connecté"}</p>
</div>
<div>
<p className="text-muted-foreground">Dernière actualisation</p>
<p className="font-medium">{lastSyncLabel}</p>
</div>
</div>

<div className="rounded-md bg-muted/50 p-3 text-xs text-muted-foreground space-y-1">
<p className="font-medium text-foreground">Données synchronisées</p>
<p>FC repos · HRV nocturne · Score sommeil · Durée de sommeil</p>
</div>

<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={async () => {
setSyncing(true)
const result = await syncPolarHistory(30)
setSyncing(false)
if (result.error) {
toast.error(result.error)
} else {
toast.success(`${result.synced ?? 0} journée(s) Polar synchronisée(s)`)
router.refresh()
}
}}
disabled={syncing || disconnecting}
>
{syncing ? "Synchronisation…" : "Importer 30 jours"}
</Button>
<Button
size="sm"
variant="destructive"
onClick={handleDisconnect}
disabled={syncing || disconnecting}
>
{disconnecting ? "Déconnexion…" : "Déconnecter"}
</Button>
</div>
</>
) : (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Connectez votre compte Polar Flow pour importer les données de récupération et de sommeil de votre montre Polar.
</p>
<a
href="/connections/polar/connect"
className="inline-flex items-center justify-center rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground shadow hover:bg-primary/90"
>
Connecter Polar
</a>
</div>
)}
</CardContent>
</Card>
)
}
94 changes: 62 additions & 32 deletions web/app/(app)/connections/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="mx-auto max-w-2xl space-y-6">
Expand Down Expand Up @@ -81,6 +93,18 @@ export default async function ConnectionsPage({
</div>
) : null}

{polar === "connected" ? (
<div className="rounded-md border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-900">
Polar est connecté. Une première synchronisation des données de santé est en cours.
</div>
) : null}

{polar === "error" ? (
<div className="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-900">
La connexion Polar a échoué. Vérifiez la configuration du Client ID / Secret développeur Polar puis réessayez.
</div>
) : null}

<StravaCard
connected={!!stravaConn}
providerUserId={stravaConn?.provider_user_id}
Expand All @@ -94,6 +118,12 @@ export default async function ConnectionsPage({
lastSyncAt={garminConn?.last_sync_at}
/>

<PolarCard
connected={!!polarConn}
providerUserId={polarConn?.provider_user_id}
lastSyncAt={polarConn?.last_sync_at}
/>

<TerraCard
connected={!!terraConn}
providerUserId={terraConn?.provider_user_id}
Expand Down
Loading
Loading