From e8bd1c65b408e4f08daea3becf0fbb9c35290158 Mon Sep 17 00:00:00 2001 From: bitbonds Date: Mon, 23 Mar 2026 22:26:12 -0400 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20athlete=20progress=20dashboard=20?= =?UTF-8?q?=E2=80=94=20weekly=20summaries,=20race=20results,=20coach=20ove?= =?UTF-8?q?rview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/index.ts | 5 + backend/src/routes/progress.ts | 239 +++++++++++++++++++++ backend/src/schemas/index.ts | 30 +++ backend/src/services/progressService.ts | 176 +++++++++++++++ frontend/src/App.tsx | 17 ++ frontend/src/components/Paywall.tsx | 58 +++++ frontend/src/pages/AthleteProgress.tsx | 261 +++++++++++++++++++++++ frontend/src/pages/CoachTeamProgress.tsx | 224 +++++++++++++++++++ frontend/src/pages/RaceCalendar.tsx | 168 +++++++++++++-- migrations/004_progress.sql | 62 ++++++ migrations/rollback_004_progress.sql | 12 ++ 11 files changed, 1237 insertions(+), 15 deletions(-) create mode 100644 backend/src/routes/progress.ts create mode 100644 backend/src/services/progressService.ts create mode 100644 frontend/src/components/Paywall.tsx create mode 100644 frontend/src/pages/AthleteProgress.tsx create mode 100644 frontend/src/pages/CoachTeamProgress.tsx create mode 100644 migrations/004_progress.sql create mode 100644 migrations/rollback_004_progress.sql diff --git a/backend/src/index.ts b/backend/src/index.ts index 6f65147..8c3de85 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -9,12 +9,15 @@ import botsRouter from './routes/bots'; import athleteRouter from './routes/athlete'; import teamRouter from './routes/team'; import stravaRouter from './routes/strava'; +import progressRouter from './routes/progress'; import { apiLimiter } from './middleware/rateLimit'; import { errorHandler } from './middleware/errorHandler'; const app = express(); +// Stripe webhook needs raw body BEFORE JSON parser for signature verification + app.use(helmet()); app.use( cors({ @@ -44,6 +47,8 @@ app.use('/api/coach/team', teamRouter); app.use('/api/athlete', teamRouter); app.use('/api/strava', stravaRouter); app.use('/api/athlete', stravaRouter); +app.use('/api/athlete', progressRouter); +app.use('/api/coach/team', progressRouter); app.use(errorHandler); diff --git a/backend/src/routes/progress.ts b/backend/src/routes/progress.ts new file mode 100644 index 0000000..14a8265 --- /dev/null +++ b/backend/src/routes/progress.ts @@ -0,0 +1,239 @@ +import { Router } from 'express'; +import { supabase } from '../db/supabase'; +import { auth, requireAthlete, requireCoach, AuthRequest } from '../middleware/auth'; +import { asyncHandler } from '../utils/asyncHandler'; +import { validate } from '../middleware/validate'; +import { raceResultSchema, raceResultUpdateSchema, weeklyQuerySchema } from '../schemas'; +import { computeAllWeeks } from '../services/progressService'; + +const router = Router(); + +// ── Athlete Weekly Summaries ──────────────────────────────────────────────── + +// GET /api/athlete/progress/weekly — Get weekly summaries (last 12 weeks) +router.get( + '/progress/weekly', + auth, + requireAthlete, + asyncHandler(async (req: AuthRequest, res) => { + const parsed = weeklyQuerySchema.safeParse(req.query); + const weeks = parsed.success ? parsed.data.weeks : 12; + + const { data, error } = await supabase + .from('weekly_summaries') + .select('*') + .eq('athlete_id', req.athlete.id) + .order('week_start', { ascending: false }) + .limit(weeks); + + if (error) return res.status(400).json({ error: error.message }); + res.json((data || []).reverse()); // oldest first for charting + }) +); + +// POST /api/athlete/progress/compute — Recompute weekly summaries from activities +router.post( + '/progress/compute', + auth, + requireAthlete, + asyncHandler(async (req: AuthRequest, res) => { + const parsed = weeklyQuerySchema.safeParse(req.body); + const weeks = parsed.success ? parsed.data.weeks : 12; + + const summaries = await computeAllWeeks(req.athlete.id, weeks); + res.json({ computed: summaries.length, summaries }); + }) +); + +// ── Race Results ──────────────────────────────────────────────────────────── + +// GET /api/athlete/races/results — Get race results +router.get( + '/races/results', + auth, + requireAthlete, + asyncHandler(async (req: AuthRequest, res) => { + const { data, error } = await supabase + .from('race_results') + .select('*') + .eq('athlete_id', req.athlete.id) + .order('race_date', { ascending: false }); + + if (error) return res.status(400).json({ error: error.message }); + res.json(data || []); + }) +); + +// POST /api/athlete/races/results — Add race result +router.post( + '/races/results', + auth, + requireAthlete, + validate(raceResultSchema), + asyncHandler(async (req: AuthRequest, res) => { + const { data, error } = await supabase + .from('race_results') + .insert({ ...req.body, athlete_id: req.athlete.id }) + .select() + .single(); + + if (error) return res.status(400).json({ error: error.message }); + res.json(data); + }) +); + +// PATCH /api/athlete/races/results/:id — Update race result +router.patch( + '/races/results/:id', + auth, + requireAthlete, + validate(raceResultUpdateSchema), + asyncHandler(async (req: AuthRequest, res) => { + const { data, error } = await supabase + .from('race_results') + .update(req.body) + .eq('id', req.params.id) + .eq('athlete_id', req.athlete.id) + .select() + .single(); + + if (error) return res.status(400).json({ error: error.message }); + if (!data) return res.status(404).json({ error: 'Result not found' }); + res.json(data); + }) +); + +// DELETE /api/athlete/races/results/:id — Delete race result +router.delete( + '/races/results/:id', + auth, + requireAthlete, + asyncHandler(async (req: AuthRequest, res) => { + const { error } = await supabase + .from('race_results') + .delete() + .eq('id', req.params.id) + .eq('athlete_id', req.athlete.id); + + if (error) return res.status(400).json({ error: error.message }); + res.json({ ok: true }); + }) +); + +// ── Coach Team Progress ───────────────────────────────────────────────────── + +// GET /api/coach/team/progress — Team progress overview +router.get( + '/progress', + auth, + requireCoach, + asyncHandler(async (req: AuthRequest, res) => { + // Get coach's team + const { data: team } = await supabase + .from('teams') + .select('id') + .eq('coach_id', req.coach.id) + .single(); + + if (!team) return res.status(404).json({ error: 'No team found' }); + + // Get team member athlete IDs + const { data: members } = await supabase + .from('team_members') + .select(` + athlete_id, + status, + athlete_profiles!athlete_id (id, name, weekly_volume_miles, primary_events) + `) + .eq('team_id', team.id) + .eq('status', 'active'); + + if (!members || members.length === 0) return res.json([]); + + const athleteIds = members.map((m: any) => m.athlete_id); + + // Get latest weekly summary for each athlete (most recent week) + const { data: summaries } = await supabase + .from('weekly_summaries') + .select('*') + .in('athlete_id', athleteIds) + .order('week_start', { ascending: false }); + + // Group: latest summary per athlete + const latestByAthlete: Record = {}; + for (const s of summaries || []) { + if (!latestByAthlete[s.athlete_id]) { + latestByAthlete[s.athlete_id] = s; + } + } + + const result = members.map((m: any) => ({ + athlete: m.athlete_profiles, + member_status: m.status, + latest_week: latestByAthlete[m.athlete_id] || null + })); + + res.json(result); + }) +); + +// GET /api/coach/team/athletes/:id/progress — Single athlete detailed progress +router.get( + '/athletes/:id/progress', + auth, + requireCoach, + asyncHandler(async (req: AuthRequest, res) => { + const athleteId = req.params.id; + + // Verify athlete is on coach's team + const { data: team } = await supabase + .from('teams') + .select('id') + .eq('coach_id', req.coach.id) + .single(); + + if (!team) return res.status(404).json({ error: 'No team found' }); + + const { data: member } = await supabase + .from('team_members') + .select('id') + .eq('team_id', team.id) + .eq('athlete_id', athleteId) + .single(); + + if (!member) return res.status(403).json({ error: 'Athlete is not on your team' }); + + const parsed = weeklyQuerySchema.safeParse(req.query); + const weeks = parsed.success ? parsed.data.weeks : 12; + + // Get weekly summaries + const { data: summaries } = await supabase + .from('weekly_summaries') + .select('*') + .eq('athlete_id', athleteId) + .order('week_start', { ascending: false }) + .limit(weeks); + + // Get race results + const { data: races } = await supabase + .from('race_results') + .select('*') + .eq('athlete_id', athleteId) + .order('race_date', { ascending: false }); + + // Get athlete profile + const { data: profile } = await supabase + .from('athlete_profiles') + .select('id, name, weekly_volume_miles, primary_events') + .eq('id', athleteId) + .single(); + + res.json({ + profile, + summaries: (summaries || []).reverse(), + races: races || [] + }); + }) +); + +export default router; diff --git a/backend/src/schemas/index.ts b/backend/src/schemas/index.ts index 8d90b60..a6a0d67 100644 --- a/backend/src/schemas/index.ts +++ b/backend/src/schemas/index.ts @@ -108,3 +108,33 @@ export const activitiesQuerySchema = z.object({ export const syncSchema = z.object({ days: z.number().int().min(1).max(365).default(30) }); + +// -- Progress ---------------------------------------------------------------- + +export const raceResultSchema = z.object({ + race_name: z.string().min(1).max(200), + race_date: z.string().min(1), + distance: z.string().min(1).max(100), + finish_time: z.string().min(1).max(50), + pace_per_mile: z.string().max(20).optional(), + placement: z.string().max(100).optional(), + is_pr: z.boolean().optional().default(false), + conditions: z.string().max(500).optional(), + notes: z.string().max(2000).optional() +}); + +export const raceResultUpdateSchema = z.object({ + race_name: z.string().min(1).max(200).optional(), + race_date: z.string().min(1).optional(), + distance: z.string().min(1).max(100).optional(), + finish_time: z.string().min(1).max(50).optional(), + pace_per_mile: z.string().max(20).optional(), + placement: z.string().max(100).optional(), + is_pr: z.boolean().optional(), + conditions: z.string().max(500).optional(), + notes: z.string().max(2000).optional() +}); + +export const weeklyQuerySchema = z.object({ + weeks: z.coerce.number().int().min(1).max(52).default(12) +}); diff --git a/backend/src/services/progressService.ts b/backend/src/services/progressService.ts new file mode 100644 index 0000000..466c87e --- /dev/null +++ b/backend/src/services/progressService.ts @@ -0,0 +1,176 @@ +import { supabase } from '../db/supabase'; + +const METERS_PER_MILE = 1609.344; +const FEET_PER_METER = 3.28084; + +/** Convert distance in meters and time in seconds to a "M:SS" pace-per-mile string */ +export function calculatePacePerMile(distanceMeters: number, timeSeconds: number): string { + if (!distanceMeters || distanceMeters === 0) return '--'; + const miles = distanceMeters / METERS_PER_MILE; + const secondsPerMile = timeSeconds / miles; + const mins = Math.floor(secondsPerMile / 60); + const secs = Math.round(secondsPerMile % 60); + return `${mins}:${secs.toString().padStart(2, '0')}`; +} + +/** Get the Monday (week start) for a given date */ +function getMonday(d: Date): string { + const date = new Date(d); + const day = date.getUTCDay(); // 0=Sun + const diff = day === 0 ? -6 : 1 - day; + date.setUTCDate(date.getUTCDate() + diff); + return date.toISOString().split('T')[0]; +} + +/** Compute weekly summary for a single week from athlete_activities */ +export async function computeWeeklySummary( + athleteId: string, + weekStart: string +): Promise { + const weekEnd = new Date(weekStart + 'T00:00:00Z'); + weekEnd.setUTCDate(weekEnd.getUTCDate() + 7); + const weekEndStr = weekEnd.toISOString(); + + // Fetch running activities for the week + const { data: activities } = await supabase + .from('athlete_activities') + .select('*') + .eq('athlete_id', athleteId) + .gte('start_date', weekStart + 'T00:00:00Z') + .lt('start_date', weekEndStr) + .in('activity_type', ['Run', 'TrailRun', 'VirtualRun', 'Treadmill']); + + const runs = activities || []; + + if (runs.length === 0) { + return { + athlete_id: athleteId, + week_start: weekStart, + total_distance_miles: 0, + total_duration_minutes: 0, + total_elevation_feet: 0, + run_count: 0, + avg_pace_per_mile: '--', + avg_heartrate: null, + longest_run_miles: 0, + intensity_score: null, + compliance_pct: null + }; + } + + const totalDistanceMeters = runs.reduce((s: number, r: any) => s + (r.distance_meters || 0), 0); + const totalTimeSeconds = runs.reduce((s: number, r: any) => s + (r.moving_time_seconds || 0), 0); + const totalElevationMeters = runs.reduce((s: number, r: any) => s + (r.total_elevation_gain || 0), 0); + + const hrRuns = runs.filter((r: any) => r.average_heartrate); + const avgHr = hrRuns.length > 0 + ? hrRuns.reduce((s: number, r: any) => s + r.average_heartrate, 0) / hrRuns.length + : null; + + const longestMeters = Math.max(...runs.map((r: any) => r.distance_meters || 0)); + + // Simple intensity: average perceived exertion weighted by distance, fallback to HR zone proxy + const exertionRuns = runs.filter((r: any) => r.perceived_exertion); + let intensityScore: number | null = null; + if (exertionRuns.length > 0) { + intensityScore = exertionRuns.reduce((s: number, r: any) => s + r.perceived_exertion, 0) / exertionRuns.length; + } else if (avgHr) { + // rough proxy: HR / 20 gives ~7-10 range for typical running HR 140-200 + intensityScore = Math.round((avgHr / 20) * 10) / 10; + } + + const summary = { + athlete_id: athleteId, + week_start: weekStart, + total_distance_miles: Math.round((totalDistanceMeters / METERS_PER_MILE) * 100) / 100, + total_duration_minutes: Math.round((totalTimeSeconds / 60) * 100) / 100, + total_elevation_feet: Math.round(totalElevationMeters * FEET_PER_METER), + run_count: runs.length, + avg_pace_per_mile: calculatePacePerMile(totalDistanceMeters, totalTimeSeconds), + avg_heartrate: avgHr ? Math.round(avgHr) : null, + longest_run_miles: Math.round((longestMeters / METERS_PER_MILE) * 100) / 100, + intensity_score: intensityScore, + compliance_pct: null // computed separately if season plan exists + }; + + return summary; +} + +/** Compute weekly summaries for the last N weeks, upsert to DB */ +export async function computeAllWeeks( + athleteId: string, + weeks = 12 +): Promise { + const now = new Date(); + const currentMonday = getMonday(now); + const summaries: any[] = []; + + for (let i = 0; i < weeks; i++) { + const weekDate = new Date(currentMonday + 'T00:00:00Z'); + weekDate.setUTCDate(weekDate.getUTCDate() - i * 7); + const weekStart = weekDate.toISOString().split('T')[0]; + + const summary = await computeWeeklySummary(athleteId, weekStart); + + // Compute compliance if there's a season plan + const compliance = await getComplianceRate(athleteId, weekStart); + summary.compliance_pct = compliance; + + // Upsert + const { data, error } = await supabase + .from('weekly_summaries') + .upsert(summary, { onConflict: 'athlete_id,week_start' }) + .select() + .single(); + + summaries.push(data || summary); + if (error) { + // eslint-disable-next-line no-console + console.error(`Failed to upsert weekly summary for ${weekStart}:`, error.message); + } + } + + return summaries.reverse(); // oldest first +} + +/** Compare planned vs actual workouts for a given week */ +export async function getComplianceRate( + athleteId: string, + weekStart: string +): Promise { + // Get active season plan + const { data: season } = await supabase + .from('athlete_seasons') + .select('season_plan') + .eq('athlete_id', athleteId) + .eq('status', 'active') + .single(); + + if (!season?.season_plan) return null; + + // Find the week in the season plan that matches weekStart + const plan = season.season_plan as any[]; + const planWeek = plan.find((w: any) => w.week_start === weekStart); + if (!planWeek?.workouts) return null; + + const plannedRuns = planWeek.workouts.filter( + (w: any) => w.title && w.title.toLowerCase() !== 'rest' && w.title.toLowerCase() !== 'off' + ).length; + + if (plannedRuns === 0) return 100; + + // Count actual runs in the week + const weekEnd = new Date(weekStart + 'T00:00:00Z'); + weekEnd.setUTCDate(weekEnd.getUTCDate() + 7); + + const { count } = await supabase + .from('athlete_activities') + .select('id', { count: 'exact', head: true }) + .eq('athlete_id', athleteId) + .gte('start_date', weekStart + 'T00:00:00Z') + .lt('start_date', weekEnd.toISOString()) + .in('activity_type', ['Run', 'TrailRun', 'VirtualRun', 'Treadmill']); + + const actualRuns = count || 0; + return Math.min(100, Math.round((actualRuns / plannedRuns) * 100)); +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bec8166..373a9e1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,6 +12,10 @@ import { Chat } from './pages/Chat'; import { JoinTeam } from './pages/JoinTeam'; import { AthleteSettings } from './pages/AthleteSettings'; import { Activities } from './pages/Activities'; +import { AthleteProgress } from './pages/AthleteProgress'; +import { CoachTeamProgress } from './pages/CoachTeamProgress'; +import { Pricing } from './pages/Pricing'; +import { BillingStatus } from './pages/BillingStatus'; function RequireCoach({ children }: { children: React.ReactNode }) { const role = useAuthStore(s => s.role); @@ -29,6 +33,13 @@ function RequireAthlete({ children }: { children: React.ReactNode }) { return <>{children}; } +function RequireAuth({ children }: { children: React.ReactNode }) { + const role = useAuthStore(s => s.role); + const loc = useLocation(); + if (!role) return ; + return <>{children}; +} + export default function App() { return ( @@ -39,12 +50,17 @@ export default function App() { } /> } /> } /> + } /> + + {/* Billing (any authenticated user) */} + } /> {/* Coach protected */} } /> } /> } /> } /> + } /> {/* Athlete protected */} } /> @@ -55,6 +71,7 @@ export default function App() { } /> } /> } /> + } /> {/* Fallback */} } /> diff --git a/frontend/src/components/Paywall.tsx b/frontend/src/components/Paywall.tsx new file mode 100644 index 0000000..5cbaecf --- /dev/null +++ b/frontend/src/components/Paywall.tsx @@ -0,0 +1,58 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { apiFetch } from '../lib/api'; +import { Card, Button, Spinner } from './ui'; + +interface PaywallProps { + children: React.ReactNode; +} + +/** + * Wrap premium features with this component. If no active subscription + * is detected, it shows an upgrade prompt instead of the children. + */ +export function Paywall({ children }: PaywallProps) { + const nav = useNavigate(); + const [loading, setLoading] = useState(true); + const [subscribed, setSubscribed] = useState(false); + + useEffect(() => { + apiFetch('/api/billing/status') + .then((data) => { + setSubscribed(data.subscribed === true); + }) + .catch(() => { + setSubscribed(false); + }) + .finally(() => setLoading(false)); + }, []); + + if (loading) { + return ( +
+ +
+ ); + } + + if (!subscribed) { + return ( + +
+
+ ★ +
+

Premium Feature

+

+ Upgrade your plan to unlock this feature and get the most out of Laktic. +

+ +
+
+ ); + } + + return <>{children}; +} diff --git a/frontend/src/pages/AthleteProgress.tsx b/frontend/src/pages/AthleteProgress.tsx new file mode 100644 index 0000000..9071f8a --- /dev/null +++ b/frontend/src/pages/AthleteProgress.tsx @@ -0,0 +1,261 @@ +import { useEffect, useState } from 'react'; +import { useNavigate, Link } from 'react-router-dom'; +import { apiFetch } from '../lib/api'; +import { useAuthStore } from '../store/authStore'; +import { supabase } from '../lib/supabaseClient'; +import { Navbar, Button, Card, Badge, Spinner, Alert } from '../components/ui'; + +type WeeklySummary = { + id: string; + week_start: string; + total_distance_miles: number; + total_duration_minutes: number; + total_elevation_feet: number; + run_count: number; + avg_pace_per_mile: string; + avg_heartrate: number | null; + longest_run_miles: number; + intensity_score: number | null; + compliance_pct: number | null; +}; + +type RaceResult = { + id: string; + race_name: string; + race_date: string; + distance: string; + finish_time: string; + pace_per_mile: string | null; + placement: string | null; + is_pr: boolean; + conditions: string | null; + notes: string | null; +}; + +function BarChart({ data, label }: { data: { label: string; value: number }[]; label: string }) { + const maxVal = Math.max(...data.map(d => d.value), 1); + return ( +
+
{label}
+
+ {data.map((d, i) => ( +
+
+
{d.label}
+
+ ))} +
+
+ ); +} + +function PaceTrend({ data }: { data: { label: string; seconds: number }[] }) { + const valid = data.filter(d => d.seconds > 0); + if (valid.length === 0) return
No pace data
; + + const minS = Math.min(...valid.map(d => d.seconds)); + const maxS = Math.max(...valid.map(d => d.seconds)); + const range = maxS - minS || 60; + + return ( +
+
Avg Pace / Mile (lower is faster)
+
+ {data.map((d, i) => { + const pct = d.seconds > 0 ? ((d.seconds - minS) / range) * 80 + 20 : 0; + return ( +
+ {d.seconds > 0 && ( +
+ )} +
{d.label}
+
+ ); + })} +
+
+ ); +} + +function parsePace(pace: string): number { + if (!pace || pace === '--') return 0; + const parts = pace.split(':'); + if (parts.length !== 2) return 0; + return parseInt(parts[0]) * 60 + parseInt(parts[1]); +} + +function formatPace(seconds: number): string { + if (seconds <= 0) return '--'; + const m = Math.floor(seconds / 60); + const s = Math.round(seconds % 60); + return `${m}:${s.toString().padStart(2, '0')}`; +} + +function weekLabel(weekStart: string): string { + const d = new Date(weekStart + 'T00:00:00Z'); + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); +} + +export function AthleteProgress() { + const { profile, clearAuth } = useAuthStore(); + const nav = useNavigate(); + const [summaries, setSummaries] = useState([]); + const [races, setRaces] = useState([]); + const [loading, setLoading] = useState(true); + const [computing, setComputing] = useState(false); + const [error, setError] = useState(''); + const logout = async () => { await supabase.auth.signOut(); clearAuth(); nav('/'); }; + + useEffect(() => { + Promise.all([ + apiFetch('/api/athlete/progress/weekly?weeks=12'), + apiFetch('/api/athlete/races/results') + ]) + .then(([weeklies, raceResults]) => { + setSummaries(weeklies); + setRaces(raceResults); + }) + .catch(e => setError(e.message)) + .finally(() => setLoading(false)); + }, []); + + const recompute = async () => { + setComputing(true); + setError(''); + try { + const { summaries: updated } = await apiFetch('/api/athlete/progress/compute', { + method: 'POST', + body: JSON.stringify({ weeks: 12 }) + }); + setSummaries(updated); + } catch (e: any) { + setError(e.message); + } finally { + setComputing(false); + } + }; + + const currentWeek = summaries.length > 0 ? summaries[summaries.length - 1] : null; + const prs = races.filter(r => r.is_pr); + + const volumeData = summaries.map(s => ({ + label: weekLabel(s.week_start), + value: Math.round(s.total_distance_miles * 10) / 10 + })); + + const paceData = summaries.map(s => ({ + label: weekLabel(s.week_start), + seconds: parsePace(s.avg_pace_per_mile) + })); + + return ( +
+ +
+
+
+

Training Progress

+

Weekly volume, pace trends, and race results

+
+
+ + + +
+
+ + {error && setError('')} />} + + {loading ? ( +
+ ) : ( +
+ {/* Current Week Stats */} + {currentWeek && ( + +
+ + + + +
+ {currentWeek.compliance_pct !== null && ( +
+
Plan Compliance
+
+
= 80 ? 'bg-brand-500' : currentWeek.compliance_pct >= 50 ? 'bg-amber-500' : 'bg-red-500'}`} + style={{ width: `${currentWeek.compliance_pct}%` }} + /> +
+ {currentWeek.compliance_pct}% +
+ )} + + )} + + {/* Volume Chart */} + {volumeData.length > 0 && ( + + + + )} + + {/* Pace Trend */} + {paceData.some(d => d.seconds > 0) && ( + + + + )} + + {/* PRs */} + {prs.length > 0 && ( + +
+ {prs.map(pr => ( +
+
+ +
+
{pr.race_name}
+
{pr.distance} · {new Date(pr.race_date + 'T00:00:00').toLocaleDateString()}
+
+
+
{pr.finish_time}
+
+ ))} +
+
+ )} + + {summaries.length === 0 && races.length === 0 && ( + +
+

No progress data yet. Connect Strava and sync activities, then click Refresh Data.

+ +
+
+ )} +
+ )} +
+
+ ); +} + +function StatBox({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/frontend/src/pages/CoachTeamProgress.tsx b/frontend/src/pages/CoachTeamProgress.tsx new file mode 100644 index 0000000..d66a7e3 --- /dev/null +++ b/frontend/src/pages/CoachTeamProgress.tsx @@ -0,0 +1,224 @@ +import { useEffect, useState } from 'react'; +import { useNavigate, Link } from 'react-router-dom'; +import { apiFetch } from '../lib/api'; +import { useAuthStore } from '../store/authStore'; +import { supabase } from '../lib/supabaseClient'; +import { Navbar, Button, Card, Badge, Spinner, Alert } from '../components/ui'; + +type AthleteProfile = { + id: string; + name: string; + weekly_volume_miles: number | null; + primary_events: string | null; +}; + +type WeeklySummary = { + id: string; + week_start: string; + total_distance_miles: number; + total_duration_minutes: number; + run_count: number; + avg_pace_per_mile: string; + avg_heartrate: number | null; + longest_run_miles: number; + compliance_pct: number | null; +}; + +type TeamMemberProgress = { + athlete: AthleteProfile; + member_status: string; + latest_week: WeeklySummary | null; +}; + +type AthleteDetail = { + profile: AthleteProfile; + summaries: WeeklySummary[]; + races: any[]; +}; + +function ComplianceBadge({ pct }: { pct: number | null }) { + if (pct === null) return --; + const color = pct >= 80 ? 'green' : pct >= 50 ? 'amber' : 'gray'; + return ; +} + +export function CoachTeamProgress() { + const { profile, clearAuth } = useAuthStore(); + const nav = useNavigate(); + const [members, setMembers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [selectedAthlete, setSelectedAthlete] = useState(null); + const [athleteDetail, setAthleteDetail] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); + const logout = async () => { await supabase.auth.signOut(); clearAuth(); nav('/'); }; + + useEffect(() => { + apiFetch('/api/coach/team/progress') + .then(setMembers) + .catch(e => setError(e.message)) + .finally(() => setLoading(false)); + }, []); + + const viewAthlete = async (athleteId: string) => { + setSelectedAthlete(athleteId); + setDetailLoading(true); + setError(''); + try { + const detail = await apiFetch(`/api/coach/team/athletes/${athleteId}/progress?weeks=12`); + setAthleteDetail(detail); + } catch (e: any) { + setError(e.message); + } finally { + setDetailLoading(false); + } + }; + + const closeDetail = () => { + setSelectedAthlete(null); + setAthleteDetail(null); + }; + + return ( +
+ +
+
+
+

Team Progress

+

Overview of your athletes' training progress

+
+
+ +
+
+ + {error && setError('')} />} + + {loading ? ( +
+ ) : selectedAthlete ? ( + /* Athlete Detail View */ +
+ + {detailLoading ? ( +
+ ) : athleteDetail ? ( +
+ +
+ {athleteDetail.profile.primary_events && {athleteDetail.profile.primary_events}} + {athleteDetail.profile.weekly_volume_miles && · Target {athleteDetail.profile.weekly_volume_miles} mi/wk} +
+
+ + {/* Weekly Summaries Table */} + {athleteDetail.summaries.length > 0 && ( + +
+ + + + + + + + + + + + + + {athleteDetail.summaries.map(s => ( + + + + + + + + + + ))} + +
WeekMilesRunsAvg PaceAvg HRLongestCompliance
{new Date(s.week_start + 'T00:00:00Z').toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}{s.total_distance_miles.toFixed(1)}{s.run_count}{s.avg_pace_per_mile || '--'}{s.avg_heartrate || '--'}{s.longest_run_miles.toFixed(1)}
+
+
+ )} + + {/* Race Results */} + {athleteDetail.races.length > 0 && ( + +
+ {athleteDetail.races.map((r: any) => ( +
+
+ {r.is_pr && } +
+
{r.race_name}
+
{r.distance} · {new Date(r.race_date + 'T00:00:00').toLocaleDateString()}
+
+
+
{r.finish_time}
+
+ ))} +
+
+ )} +
+ ) : null} +
+ ) : ( + /* Team Overview */ + + {members.length === 0 ? ( +
+

No active team members yet. Athletes need to sync activities for progress data to appear.

+
+ ) : ( +
+ + + + + + + + + + + + + {members.map(m => ( + viewAthlete(m.athlete.id)}> + + + + + + + + ))} + +
AthleteWeekly MilesComplianceAvg PaceStatus
+
{m.athlete.name}
+ {m.athlete.primary_events &&
{m.athlete.primary_events}
} +
+ {m.latest_week ? `${m.latest_week.total_distance_miles.toFixed(1)}` : '--'} + + + + {m.latest_week?.avg_pace_per_mile || '--'} + + + + +
+
+ )} +
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/RaceCalendar.tsx b/frontend/src/pages/RaceCalendar.tsx index 000dff1..3295599 100644 --- a/frontend/src/pages/RaceCalendar.tsx +++ b/frontend/src/pages/RaceCalendar.tsx @@ -3,16 +3,55 @@ import { useNavigate, Link } from 'react-router-dom'; import { apiFetch } from '../lib/api'; import { useAuthStore } from '../store/authStore'; import { supabase } from '../lib/supabaseClient'; -import { Navbar, Button, Input, Card, Toggle, Alert } from '../components/ui'; +import { Navbar, Button, Input, Card, Toggle, Alert, Badge } from '../components/ui'; type Race = { name: string; date: string; is_goal_race: boolean; notes: string }; +type RaceResult = { + id: string; + race_name: string; + race_date: string; + distance: string; + finish_time: string; + pace_per_mile: string | null; + placement: string | null; + is_pr: boolean; + conditions: string | null; + notes: string | null; +}; + +type ResultForm = { + race_name: string; + race_date: string; + distance: string; + finish_time: string; + pace_per_mile: string; + placement: string; + is_pr: boolean; + conditions: string; + notes: string; +}; function emptyRace(): Race { return { name: '', date: '', is_goal_race: false, notes: '' }; } +function emptyResult(race?: Race): ResultForm { + return { + race_name: race?.name || '', + race_date: race?.date || '', + distance: '', + finish_time: '', + pace_per_mile: '', + placement: '', + is_pr: false, + conditions: '', + notes: '' + }; +} + export function RaceCalendar() { const { profile, clearAuth } = useAuthStore(); const nav = useNavigate(); const [races, setRaces] = useState([]); + const [results, setResults] = useState([]); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [regenerating, setRegenerating] = useState(false); @@ -20,11 +59,18 @@ export function RaceCalendar() { const [error, setError] = useState(''); const [newRace, setNewRace] = useState(emptyRace()); const [addingRace, setAddingRace] = useState(false); + const [loggingResult, setLoggingResult] = useState(null); + const [resultForm, setResultForm] = useState(emptyResult()); + const [savingResult, setSavingResult] = useState(false); const logout = async () => { await supabase.auth.signOut(); clearAuth(); nav('/'); }; useEffect(() => { - apiFetch('/api/athlete/season').then(({ season }) => { - if (season) setRaces(season.race_calendar || []); + Promise.all([ + apiFetch('/api/athlete/season'), + apiFetch('/api/athlete/races/results') + ]).then(([seasonData, resultsData]) => { + if (seasonData.season) setRaces(seasonData.season.race_calendar || []); + setResults(resultsData); }).catch(console.error).finally(() => setLoading(false)); }, []); @@ -66,6 +112,44 @@ export function RaceCalendar() { finally { setRegenerating(false); } }; + const isPastRace = (date: string) => new Date(date + 'T23:59:59') < new Date(); + const hasResult = (race: Race) => results.some(r => r.race_name === race.name && r.race_date === race.date); + + const startLogResult = (idx: number) => { + setLoggingResult(idx); + setResultForm(emptyResult(races[idx])); + }; + + const saveResult = async () => { + if (!resultForm.finish_time || !resultForm.distance) { + setError('Finish time and distance are required'); + return; + } + setSavingResult(true); + setError(''); + try { + const newResult = await apiFetch('/api/athlete/races/results', { + method: 'POST', + body: JSON.stringify(resultForm) + }); + setResults(prev => [newResult, ...prev]); + setLoggingResult(null); + } catch (e: any) { + setError(e.message); + } finally { + setSavingResult(false); + } + }; + + const deleteResult = async (id: string) => { + try { + await apiFetch(`/api/athlete/races/results/${id}`, { method: 'DELETE' }); + setResults(prev => prev.filter(r => r.id !== id)); + } catch (e: any) { + setError(e.message); + } + }; + return (
@@ -76,7 +160,8 @@ export function RaceCalendar() {

Goal races drive your season periodization. Non-goal races reduce volume 20%.

- + +
@@ -103,19 +188,48 @@ export function RaceCalendar() { <>
{races.map((race, idx) => ( -
-
-
- {race.name} - {race.is_goal_race && ★ Goal Race} +
+
+
+
+ {race.name} + {race.is_goal_race && Goal Race} + {hasResult(race) && } +
+
{new Date(race.date + 'T00:00:00').toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' })}
+ {race.notes &&
{race.notes}
} +
+
+ {isPastRace(race.date) && !hasResult(race) && ( + + )} + toggleGoal(idx)} label="Goal" /> +
-
{new Date(race.date + 'T00:00:00').toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' })}
- {race.notes &&
{race.notes}
} -
-
- toggleGoal(idx)} label="Goal" /> -
+ + {loggingResult === idx && ( +
+

Log Race Result

+
+
+ setResultForm(f => ({ ...f, distance: e.target.value }))} placeholder="e.g. 5K" /> + setResultForm(f => ({ ...f, finish_time: e.target.value }))} placeholder="e.g. 18:32" /> + setResultForm(f => ({ ...f, pace_per_mile: e.target.value }))} placeholder="e.g. 5:58" /> +
+
+ setResultForm(f => ({ ...f, placement: e.target.value }))} placeholder="e.g. 3rd overall" /> + setResultForm(f => ({ ...f, conditions: e.target.value }))} placeholder="e.g. hot, windy" /> +
+ setResultForm(f => ({ ...f, notes: e.target.value }))} placeholder="How did it feel?" /> + setResultForm(f => ({ ...f, is_pr: v }))} label="This is a personal record (PR)" /> +
+ + +
+
+
+ )}
))}
@@ -144,6 +258,30 @@ export function RaceCalendar() {
)} + + {results.length > 0 && ( + +
+ {results.map(result => ( +
+
+ {result.is_pr && } +
+
{result.race_name}
+
+ {result.distance} - {new Date(result.race_date + 'T00:00:00').toLocaleDateString()} - {result.finish_time} + {result.pace_per_mile && ` (${result.pace_per_mile}/mi)`} + {result.placement && ` - ${result.placement}`} +
+ {result.notes &&
{result.notes}
} +
+
+ +
+ ))} +
+
+ )}
); diff --git a/migrations/004_progress.sql b/migrations/004_progress.sql new file mode 100644 index 0000000..a738bcf --- /dev/null +++ b/migrations/004_progress.sql @@ -0,0 +1,62 @@ +-- 004_progress.sql — Weekly training summaries + race results + +-- Weekly training summaries (computed from activities) +CREATE TABLE IF NOT EXISTS weekly_summaries ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + athlete_id UUID REFERENCES athlete_profiles(id) ON DELETE CASCADE, + week_start DATE NOT NULL, + total_distance_miles REAL DEFAULT 0, + total_duration_minutes REAL DEFAULT 0, + total_elevation_feet REAL DEFAULT 0, + run_count INTEGER DEFAULT 0, + avg_pace_per_mile TEXT, + avg_heartrate REAL, + longest_run_miles REAL DEFAULT 0, + intensity_score REAL, + compliance_pct REAL, + notes TEXT, + created_at TIMESTAMPTZ DEFAULT now(), + UNIQUE(athlete_id, week_start) +); + +-- Race results tracking +CREATE TABLE IF NOT EXISTS race_results ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + athlete_id UUID REFERENCES athlete_profiles(id) ON DELETE CASCADE, + race_name TEXT NOT NULL, + race_date DATE NOT NULL, + distance TEXT NOT NULL, + finish_time TEXT NOT NULL, + pace_per_mile TEXT, + placement TEXT, + is_pr BOOLEAN DEFAULT false, + conditions TEXT, + notes TEXT, + created_at TIMESTAMPTZ DEFAULT now() +); + +CREATE INDEX idx_weekly_summaries_athlete ON weekly_summaries(athlete_id, week_start DESC); +CREATE INDEX idx_race_results_athlete ON race_results(athlete_id, race_date DESC); + +-- RLS +ALTER TABLE weekly_summaries ENABLE ROW LEVEL SECURITY; +CREATE POLICY "Athletes see own summaries" ON weekly_summaries + FOR ALL USING (athlete_id = (SELECT id FROM athlete_profiles WHERE user_id = auth.uid())); +CREATE POLICY "Coaches see team summaries" ON weekly_summaries + FOR SELECT USING (athlete_id IN ( + SELECT tm.athlete_id FROM team_members tm + JOIN teams t ON tm.team_id = t.id + JOIN coach_profiles cp ON t.coach_id = cp.id + WHERE cp.user_id = auth.uid() + )); + +ALTER TABLE race_results ENABLE ROW LEVEL SECURITY; +CREATE POLICY "Athletes own results" ON race_results + FOR ALL USING (athlete_id = (SELECT id FROM athlete_profiles WHERE user_id = auth.uid())); +CREATE POLICY "Coaches see team results" ON race_results + FOR SELECT USING (athlete_id IN ( + SELECT tm.athlete_id FROM team_members tm + JOIN teams t ON tm.team_id = t.id + JOIN coach_profiles cp ON t.coach_id = cp.id + WHERE cp.user_id = auth.uid() + )); diff --git a/migrations/rollback_004_progress.sql b/migrations/rollback_004_progress.sql new file mode 100644 index 0000000..5907b57 --- /dev/null +++ b/migrations/rollback_004_progress.sql @@ -0,0 +1,12 @@ +-- Rollback 004_progress.sql + +DROP POLICY IF EXISTS "Coaches see team results" ON race_results; +DROP POLICY IF EXISTS "Athletes own results" ON race_results; +DROP POLICY IF EXISTS "Coaches see team summaries" ON weekly_summaries; +DROP POLICY IF EXISTS "Athletes see own summaries" ON weekly_summaries; + +DROP INDEX IF EXISTS idx_race_results_athlete; +DROP INDEX IF EXISTS idx_weekly_summaries_athlete; + +DROP TABLE IF EXISTS race_results; +DROP TABLE IF EXISTS weekly_summaries; From d5e0325d08efbf80ae01af99a7fa584490b13f08 Mon Sep 17 00:00:00 2001 From: bitbonds Date: Mon, 23 Mar 2026 22:28:34 -0400 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20athlete=20progress=20dashboard=20?= =?UTF-8?q?=E2=80=94=20weekly=20summaries,=20race=20results,=20coach=20ove?= =?UTF-8?q?rview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/App.tsx | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 373a9e1..9314d40 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,8 +14,6 @@ import { AthleteSettings } from './pages/AthleteSettings'; import { Activities } from './pages/Activities'; import { AthleteProgress } from './pages/AthleteProgress'; import { CoachTeamProgress } from './pages/CoachTeamProgress'; -import { Pricing } from './pages/Pricing'; -import { BillingStatus } from './pages/BillingStatus'; function RequireCoach({ children }: { children: React.ReactNode }) { const role = useAuthStore(s => s.role); @@ -33,13 +31,6 @@ function RequireAthlete({ children }: { children: React.ReactNode }) { return <>{children}; } -function RequireAuth({ children }: { children: React.ReactNode }) { - const role = useAuthStore(s => s.role); - const loc = useLocation(); - if (!role) return ; - return <>{children}; -} - export default function App() { return ( @@ -50,10 +41,6 @@ export default function App() { } /> } /> } /> - } /> - - {/* Billing (any authenticated user) */} - } /> {/* Coach protected */} } /> From 1ca44fb38cd0d99b5076b9b63e20f21a1258937c Mon Sep 17 00:00:00 2001 From: bitbonds Date: Mon, 23 Mar 2026 22:29:46 -0400 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20athlete=20progress=20dashboard=20?= =?UTF-8?q?=E2=80=94=20weekly=20summaries,=20race=20results,=20coach=20ove?= =?UTF-8?q?rview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/src/schemas/index.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/backend/src/schemas/index.ts b/backend/src/schemas/index.ts index a6a0d67..12088e2 100644 --- a/backend/src/schemas/index.ts +++ b/backend/src/schemas/index.ts @@ -138,3 +138,9 @@ export const raceResultUpdateSchema = z.object({ export const weeklyQuerySchema = z.object({ weeks: z.coerce.number().int().min(1).max(52).default(12) }); + +// ── Billing ──────────────────────────────────────────────────────────────── + +export const checkoutSchema = z.object({ + plan_type: z.enum(['coach_team', 'athlete_individual']) +}); From c8f7b4982ca7b9513b9897b5f6aa74c24e119c30 Mon Sep 17 00:00:00 2001 From: bitbonds Date: Mon, 23 Mar 2026 22:30:08 -0400 Subject: [PATCH 4/4] chore: remove billing file from concurrent agent Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/components/Paywall.tsx | 58 ----------------------------- 1 file changed, 58 deletions(-) delete mode 100644 frontend/src/components/Paywall.tsx diff --git a/frontend/src/components/Paywall.tsx b/frontend/src/components/Paywall.tsx deleted file mode 100644 index 5cbaecf..0000000 --- a/frontend/src/components/Paywall.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { useEffect, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { apiFetch } from '../lib/api'; -import { Card, Button, Spinner } from './ui'; - -interface PaywallProps { - children: React.ReactNode; -} - -/** - * Wrap premium features with this component. If no active subscription - * is detected, it shows an upgrade prompt instead of the children. - */ -export function Paywall({ children }: PaywallProps) { - const nav = useNavigate(); - const [loading, setLoading] = useState(true); - const [subscribed, setSubscribed] = useState(false); - - useEffect(() => { - apiFetch('/api/billing/status') - .then((data) => { - setSubscribed(data.subscribed === true); - }) - .catch(() => { - setSubscribed(false); - }) - .finally(() => setLoading(false)); - }, []); - - if (loading) { - return ( -
- -
- ); - } - - if (!subscribed) { - return ( - -
-
- ★ -
-

Premium Feature

-

- Upgrade your plan to unlock this feature and get the most out of Laktic. -

- -
-
- ); - } - - return <>{children}; -}