Multi-user via PocketID: account linking, group gating, admin user management
PocketID OIDC already auto-provisioned users keyed by pocketid_sub, and the data layer was already fully user-scoped. This adds the missing pieces for running real multi-user: - auth.py callback: link by email to an existing un-linked account (so the admin keeps their data when first signing in by passkey), collision-safe username generation, and request the `groups` scope. - Group gating: optional pocketid_allowed_group (admin-config or POCKETID_ALLOWED_GROUP env); users lacking the group are rejected at the callback and redirected to /login?auth_error=not_authorized. - New admin users API (app/api/users.py): list users, promote/demote admin (guards against demoting/locking out the last admin or yourself), and delete a user with ordered bulk deletes of all their data + on-disk files. - ProfilePage: allowed-group field; LoginPage: rejected-login message; Layout: admin-only Users nav; new UsersPage. Resync milevault_export to current source (it had drifted many features behind — missing garmin_sync, npm-ci Dockerfile and @polyline-codec that broke its own CI) and add POCKETID_ALLOWED_GROUP to .env.example. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Link, useSearchParams, useNavigate } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { format } from 'date-fns'
|
||||
import api from '../utils/api'
|
||||
import {
|
||||
formatDuration, formatDistance, formatPace, formatHeartRate,
|
||||
@@ -10,24 +11,38 @@ import {
|
||||
const SPORTS = ['all', 'running', 'cycling', 'hiking', 'walking']
|
||||
|
||||
export default function ActivitiesPage() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const [sport, setSport] = useState('all')
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const fromParam = searchParams.get('from')
|
||||
const toParam = searchParams.get('to')
|
||||
|
||||
const { data: activities, isLoading } = useQuery({
|
||||
queryKey: ['activities', sport, page],
|
||||
queryKey: ['activities', sport, page, fromParam, toParam],
|
||||
queryFn: () =>
|
||||
api.get('/activities/', {
|
||||
params: {
|
||||
sport_type: sport === 'all' ? undefined : sport,
|
||||
page,
|
||||
per_page: 20,
|
||||
from_date: fromParam ? new Date(fromParam).toISOString() : undefined,
|
||||
to_date: toParam ? new Date(toParam + 'T23:59:59').toISOString() : undefined,
|
||||
},
|
||||
}).then(r => r.data),
|
||||
})
|
||||
|
||||
const { data: ytdStats } = useQuery({
|
||||
queryKey: ['ytd-stats'],
|
||||
queryFn: () => api.get('/activities/stats/ytd').then(r => r.data),
|
||||
})
|
||||
|
||||
const clearDateFilter = () => navigate('/activities')
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-2xl font-bold text-white">Activities</h1>
|
||||
<Link
|
||||
to="/upload"
|
||||
@@ -37,6 +52,28 @@ export default function ActivitiesPage() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* YTD stats */}
|
||||
{ytdStats && (
|
||||
<div className="flex gap-4 mb-4 text-sm">
|
||||
{ytdStats.running_km > 0 && (
|
||||
<span className="text-blue-400">🏃 {ytdStats.running_km.toFixed(0)} km this year</span>
|
||||
)}
|
||||
{ytdStats.cycling_km > 0 && (
|
||||
<span className="text-orange-400">🚴 {ytdStats.cycling_km.toFixed(0)} km this year</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Date filter chip */}
|
||||
{fromParam && (
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="text-xs bg-blue-600/20 text-blue-300 border border-blue-500/30 px-3 py-1 rounded-full">
|
||||
Week of {format(new Date(fromParam), 'MMM d, yyyy')}
|
||||
</span>
|
||||
<button onClick={clearDateFilter} className="text-xs text-gray-500 hover:text-gray-300 transition-colors">✕ Clear</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sport filter */}
|
||||
<div className="flex gap-2 mb-6 flex-wrap">
|
||||
{SPORTS.map(s => (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
|
||||
import { startOfWeek, format, subWeeks, eachWeekOfInterval, subDays } from 'date-fns'
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, AreaChart, Area } from 'recharts'
|
||||
import { startOfWeek, format, subWeeks, eachWeekOfInterval, subDays, addDays } from 'date-fns'
|
||||
import api from '../utils/api'
|
||||
import StatCard from '../components/ui/StatCard'
|
||||
import {
|
||||
@@ -9,7 +9,69 @@ import {
|
||||
formatDate, sportIcon, formatSleep,
|
||||
} from '../utils/format'
|
||||
|
||||
function bbLevelColor(level) {
|
||||
if (level == null) return '#6b7280'
|
||||
if (level >= 75) return '#3b82f6'
|
||||
if (level >= 50) return '#22c55e'
|
||||
if (level >= 25) return '#f59e0b'
|
||||
return '#ef4444'
|
||||
}
|
||||
|
||||
function MiniBodyBattery({ bb }) {
|
||||
if (!bb?.end_level && !bb?.charged) return null
|
||||
const { charged, drained, start_level, end_level, values } = bb
|
||||
const color = bbLevelColor(end_level)
|
||||
const sparkData = Array.isArray(values)
|
||||
? values.map(([ts, level]) => ({ ts, level }))
|
||||
: []
|
||||
return (
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 h-full">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-medium text-gray-300">Body Battery</h3>
|
||||
<Link to="/health" className="text-xs text-blue-400 hover:underline">View →</Link>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-3 flex-wrap">
|
||||
{end_level != null && (
|
||||
<span className="text-3xl font-bold" style={{ color }}>{Math.round(end_level)}</span>
|
||||
)}
|
||||
{charged != null && (
|
||||
<span className="text-sm font-semibold text-green-400">+{charged}</span>
|
||||
)}
|
||||
{drained != null && (
|
||||
<span className="text-sm font-semibold text-orange-400">-{drained}</span>
|
||||
)}
|
||||
</div>
|
||||
{start_level != null && end_level != null && (
|
||||
<p className="text-xs text-gray-500 mt-1">{start_level} → {end_level} today</p>
|
||||
)}
|
||||
{sparkData.length >= 2 && (
|
||||
<div className="mt-3">
|
||||
<ResponsiveContainer width="100%" height={60}>
|
||||
<AreaChart data={sparkData} margin={{ top: 2, right: 0, bottom: 0, left: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="bbGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor={color} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<Area type="monotone" dataKey="level" stroke={color} strokeWidth={1.5}
|
||||
fill="url(#bbGrad)" dot={false} isAnimationActive={false} />
|
||||
<Tooltip
|
||||
contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 6, fontSize: 11 }}
|
||||
labelFormatter={ts => format(new Date(ts), 'HH:mm')}
|
||||
formatter={v => [`${Math.round(v)}`, 'Battery']}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WeeklyChart({ activities }) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
if (!activities?.length) return (
|
||||
<div className="flex items-center justify-center h-36 text-gray-600 text-sm">No activities yet</div>
|
||||
)
|
||||
@@ -23,26 +85,39 @@ function WeeklyChart({ activities }) {
|
||||
|
||||
const data = weeks.map(weekStart => {
|
||||
const weekKey = format(weekStart, 'MMM d')
|
||||
const weekEnd = new Date(weekStart)
|
||||
weekEnd.setDate(weekEnd.getDate() + 7)
|
||||
const weekEnd = addDays(weekStart, 7)
|
||||
const km = activities
|
||||
.filter(a => {
|
||||
const t = new Date(a.start_time)
|
||||
return t >= weekStart && t < weekEnd
|
||||
})
|
||||
.reduce((s, a) => s + (a.distance_m || 0) / 1000, 0)
|
||||
return { week: weekKey, km: parseFloat(km.toFixed(2)) }
|
||||
return {
|
||||
week: weekKey,
|
||||
km: parseFloat(km.toFixed(2)),
|
||||
weekStartISO: format(weekStart, 'yyyy-MM-dd'),
|
||||
weekEndISO: format(weekEnd, 'yyyy-MM-dd'),
|
||||
}
|
||||
})
|
||||
|
||||
const handleBarClick = (entry) => {
|
||||
if (entry?.activePayload?.[0]?.payload) {
|
||||
const { weekStartISO, weekEndISO } = entry.activePayload[0].payload
|
||||
navigate(`/activities?from=${weekStartISO}&to=${weekEndISO}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={140}>
|
||||
<BarChart data={data} margin={{ top: 4, right: 4, bottom: 4, left: 0 }} barSize={20}>
|
||||
<BarChart data={data} margin={{ top: 4, right: 4, bottom: 4, left: 0 }} barSize={20}
|
||||
onClick={handleBarClick} style={{ cursor: 'pointer' }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
|
||||
<XAxis dataKey="week" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={28}
|
||||
tickFormatter={v => `${v.toFixed(0)}`} />
|
||||
<Tooltip contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }}
|
||||
formatter={(v) => [`${v.toFixed(1)} km`, 'Distance']} />
|
||||
formatter={(v) => [`${v.toFixed(1)} km`, 'Distance']}
|
||||
cursor={{ fill: 'rgba(59,130,246,0.1)' }} />
|
||||
<Bar dataKey="km" fill="#3b82f6" radius={[3, 3, 0, 0]} isAnimationActive={false} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
@@ -73,8 +148,12 @@ export default function DashboardPage() {
|
||||
queryFn: () => api.get('/records/', { params: { sport_type: 'running' } }).then(r => r.data),
|
||||
})
|
||||
|
||||
const { data: ytdStats } = useQuery({
|
||||
queryKey: ['ytd-stats'],
|
||||
queryFn: () => api.get('/activities/stats/ytd').then(r => r.data),
|
||||
})
|
||||
|
||||
const latest = healthSummary?.latest
|
||||
const totalDistance = recentActivities?.reduce((s, a) => s + (a.distance_m || 0), 0) ?? 0
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
@@ -84,19 +163,23 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<StatCard label="Recent activities" value={recentActivities?.length ?? 0} />
|
||||
<StatCard label="Total distance" value={formatDistance(totalDistance)} accent="blue" />
|
||||
<StatCard label="Running this year" value={ytdStats ? `${ytdStats.running_km.toFixed(0)} km` : '--'} accent="blue" />
|
||||
<StatCard label="Cycling this year" value={ytdStats ? `${ytdStats.cycling_km.toFixed(0)} km` : '--'} accent="orange" />
|
||||
<StatCard label="Resting HR" value={formatHeartRate(latest?.resting_hr)} accent="red" />
|
||||
<StatCard label="Sleep" value={formatSleep(latest?.sleep_duration_s)} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
<div className="lg:col-span-2 bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Weekly distance (km)</h3>
|
||||
<WeeklyChart activities={allActivities} />
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 space-y-3">
|
||||
<div className="lg:col-span-1">
|
||||
<MiniBodyBattery bb={latest?.body_battery} />
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-1 bg-gray-900 rounded-xl border border-gray-800 p-4 space-y-3">
|
||||
<h3 className="text-sm font-medium text-gray-300">Health today</h3>
|
||||
{latest ? (
|
||||
<>
|
||||
|
||||
@@ -9,14 +9,135 @@ import api from '../utils/api'
|
||||
import { formatSleep, sportIcon } from '../utils/format'
|
||||
|
||||
const RANGES = [
|
||||
{ label: '1W', days: 7 },
|
||||
{ label: '2W', days: 14 },
|
||||
{ label: '1M', days: 30 },
|
||||
{ label: '3M', days: 90 },
|
||||
{ label: '6M', days: 180 },
|
||||
{ label: '1Y', days: 365 },
|
||||
{ label: '1W', days: 7 },
|
||||
{ label: '2W', days: 14 },
|
||||
{ label: '1M', days: 30 },
|
||||
{ label: '3M', days: 90 },
|
||||
{ label: '6M', days: 180 },
|
||||
{ label: '1Y', days: 365 },
|
||||
{ label: '3Y', days: 1095 },
|
||||
{ label: '5Y', days: 1825 },
|
||||
]
|
||||
|
||||
// ── VO2 Max gauge ────────────────────────────────────────────────────────────
|
||||
|
||||
// Garmin/Cooper Institute VO2 max thresholds
|
||||
// [maxAge, [fair_min, good_min, excellent_min, superior_min]]
|
||||
// value < fair_min → Poor; >= superior_min → Superior
|
||||
const VO2_MALE = [
|
||||
[29, [41.7, 45.4, 51.1, 55.4]],
|
||||
[39, [40.5, 44.0, 48.3, 54.0]],
|
||||
[49, [38.5, 42.4, 46.4, 52.5]],
|
||||
[59, [35.6, 39.2, 43.4, 48.9]],
|
||||
[69, [32.3, 35.5, 39.5, 45.7]],
|
||||
[Infinity, [29.4, 32.3, 36.7, 42.1]],
|
||||
]
|
||||
const VO2_FEMALE = [
|
||||
[29, [36.1, 39.5, 43.9, 49.6]],
|
||||
[39, [34.4, 37.8, 42.4, 47.4]],
|
||||
[49, [33.0, 36.3, 39.7, 45.3]],
|
||||
[59, [30.1, 33.0, 36.7, 41.1]],
|
||||
[69, [27.5, 30.0, 33.0, 37.8]],
|
||||
[Infinity, [25.9, 28.1, 30.9, 36.7]],
|
||||
]
|
||||
const VO2_CATEGORIES = [
|
||||
{ label: 'Poor', color: '#ef4444' },
|
||||
{ label: 'Fair', color: '#f97316' },
|
||||
{ label: 'Good', color: '#22c55e' },
|
||||
{ label: 'Excellent', color: '#3b82f6' },
|
||||
{ label: 'Superior', color: '#a855f7' },
|
||||
]
|
||||
|
||||
function getVo2Category(value, age, sex) {
|
||||
const table = sex === 'female' ? VO2_FEMALE : VO2_MALE
|
||||
const row = table.find(([maxAge]) => age <= maxAge) || table[table.length - 1]
|
||||
const thresholds = row[1]
|
||||
// thresholds are lower-bounds: count how many the value meets or exceeds
|
||||
const idx = thresholds.reduce((n, t) => value >= t ? n + 1 : n, 0)
|
||||
return VO2_CATEGORIES[idx]
|
||||
}
|
||||
|
||||
function Vo2MaxGauge({ value, birthYear, biologicalSex }) {
|
||||
const MIN = 30, MAX = 70
|
||||
// cx/cy = centre of the semicircle; arc goes left→top→right (sweep=1, clockwise in SVG)
|
||||
const cx = 70, cy = 74, r = 50, sw = 11
|
||||
|
||||
const age = birthYear ? new Date().getFullYear() - birthYear : 40
|
||||
|
||||
// Standard-math angle: PI = left (VO2 30), 0 = right (VO2 70)
|
||||
const toAngle = v => Math.PI * (1 - Math.max(0, Math.min(1, (v - MIN) / (MAX - MIN))))
|
||||
|
||||
// SVG coordinates for a VO2 value at a given radius from centre
|
||||
const toXY = (v, radius = r) => {
|
||||
const a = toAngle(v)
|
||||
return [cx + radius * Math.cos(a), cy - radius * Math.sin(a)]
|
||||
}
|
||||
|
||||
// Arc path from VO2 v1 to v2; sweep=1 → clockwise = upper semicircle in SVG
|
||||
const arc = (v1, v2, radius = r) => {
|
||||
const [x1, y1] = toXY(v1, radius)
|
||||
const [x2, y2] = toXY(v2, radius)
|
||||
const large = 0 // gauge spans 180°, so no segment ever exceeds 180°
|
||||
return `M ${x1.toFixed(2)} ${y1.toFixed(2)} A ${radius} ${radius} 0 ${large} 1 ${x2.toFixed(2)} ${y2.toFixed(2)}`
|
||||
}
|
||||
|
||||
// ACSM category boundaries for this user's age/sex
|
||||
const table = biologicalSex === 'female' ? VO2_FEMALE : VO2_MALE
|
||||
const row = table.find(([maxAge]) => age <= maxAge) || table[table.length - 1]
|
||||
const thresholds = row[1]
|
||||
const bounds = [MIN, ...thresholds, MAX] // 6 boundary values for 5 colour bands
|
||||
|
||||
const cat = value != null ? getVo2Category(value, age, biologicalSex) : null
|
||||
|
||||
// White arrow: tip lands exactly at the arc centre-line at the value's angle;
|
||||
// base extends outside the track — unambiguously marks the precise position.
|
||||
const arrowPts = value != null ? (() => {
|
||||
const a = toAngle(Math.max(MIN, Math.min(MAX, value)))
|
||||
const tipR = r // tip at centre of the coloured track
|
||||
const baseR = r + sw / 2 + 9 // base well outside the outer edge
|
||||
const s = 0.09 // half-spread ≈ 5° — narrow for precision
|
||||
const tipX = cx + tipR * Math.cos(a), tipY = cy - tipR * Math.sin(a)
|
||||
const b1x = cx + baseR * Math.cos(a + s), b1y = cy - baseR * Math.sin(a + s)
|
||||
const b2x = cx + baseR * Math.cos(a - s), b2y = cy - baseR * Math.sin(a - s)
|
||||
return `${tipX.toFixed(1)},${tipY.toFixed(1)} ${b1x.toFixed(1)},${b1y.toFixed(1)} ${b2x.toFixed(1)},${b2y.toFixed(1)}`
|
||||
})() : null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<svg width="140" height="92" viewBox="0 0 140 92">
|
||||
{/* Dark background track, slightly wider than the colour bands */}
|
||||
<path d={arc(MIN, MAX)} stroke="#1f2937" strokeWidth={sw + 4} fill="none" strokeLinecap="butt" />
|
||||
|
||||
{/* Full-brightness ACSM colour bands */}
|
||||
{VO2_CATEGORIES.map((c, i) => {
|
||||
const v1 = Math.max(bounds[i], MIN)
|
||||
const v2 = Math.min(bounds[i + 1], MAX)
|
||||
if (v2 <= v1) return null
|
||||
return (
|
||||
<path key={i} d={arc(v1, v2)}
|
||||
stroke={c.color} strokeWidth={sw} fill="none" strokeLinecap="butt" />
|
||||
)
|
||||
})}
|
||||
|
||||
{/* White arrow: tip at exact value position on arc, base pointing outward */}
|
||||
{arrowPts && <polygon points={arrowPts} fill="white" />}
|
||||
|
||||
{/* VO2 number, coloured by category */}
|
||||
<text x={cx} y={cy - 6} textAnchor="middle" dominantBaseline="middle"
|
||||
fontSize="21" fontWeight="700" fill={cat?.color ?? '#6b7280'}>
|
||||
{value != null ? value.toFixed(1) : '--'}
|
||||
</text>
|
||||
|
||||
{/* Category label */}
|
||||
<text x={cx} y={cy + 11} textAnchor="middle" dominantBaseline="middle"
|
||||
fontSize="9" fill="#9ca3af">
|
||||
{cat?.label ?? (biologicalSex ? '' : 'Set sex in profile')}
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const tooltipStyle = {
|
||||
background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12,
|
||||
}
|
||||
@@ -304,7 +425,7 @@ function NavArrow({ onClick, disabled, children }) {
|
||||
)
|
||||
}
|
||||
|
||||
function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStages, activities, latestVo2max, onOlder, onNewer, hasOlder, hasNewer }) {
|
||||
function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStages, activities, latestVo2max, birthYear, biologicalSex, onOlder, onNewer, hasOlder, hasNewer }) {
|
||||
if (!day) return (
|
||||
<div className="text-center py-10 text-gray-600">
|
||||
<p className="text-3xl mb-2">📊</p>
|
||||
@@ -341,9 +462,9 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStag
|
||||
</div>
|
||||
|
||||
{/* Sleep (wide) + Heart / HRV */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
|
||||
<div className="lg:col-span-2 bg-gray-900 rounded-xl border border-gray-800 p-5 space-y-3">
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-gray-300">Sleep</h3>
|
||||
{day.sleep_score != null && (
|
||||
@@ -396,56 +517,58 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStag
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5 space-y-4">
|
||||
<h3 className="text-sm font-medium text-gray-300">Heart & HRV</h3>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-0.5">Resting HR</p>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-3xl font-bold text-rose-400">
|
||||
{day.resting_hr ? Math.round(day.resting_hr) : '--'}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">bpm</span>
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Heart & HRV</h3>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-0.5">Resting HR</p>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-3xl font-bold text-rose-400">
|
||||
{day.resting_hr ? Math.round(day.resting_hr) : '--'}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">bpm</span>
|
||||
</div>
|
||||
{avg30?.resting_hr && day.resting_hr && (
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
30d avg {Math.round(avg30.resting_hr)}
|
||||
{day.resting_hr < avg30.resting_hr
|
||||
? <span className="text-green-400 ml-1">↓</span>
|
||||
: day.resting_hr > avg30.resting_hr
|
||||
? <span className="text-red-400 ml-1">↑</span>
|
||||
: null}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{avg30?.resting_hr && day.resting_hr && (
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
30d avg {Math.round(avg30.resting_hr)} bpm
|
||||
{day.resting_hr < avg30.resting_hr
|
||||
? <span className="text-green-400 ml-1">↓</span>
|
||||
: day.resting_hr > avg30.resting_hr
|
||||
? <span className="text-red-400 ml-1">↑</span>
|
||||
: null}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-0.5">HRV</p>
|
||||
<div className="flex items-baseline gap-1.5 flex-wrap">
|
||||
<span className="text-3xl font-bold text-violet-400">
|
||||
{day.hrv_nightly_avg ? Math.round(day.hrv_nightly_avg) : '--'}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">ms</span>
|
||||
<HrvBadge status={day.hrv_status} />
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-0.5">HRV</p>
|
||||
<div className="flex items-baseline gap-1.5 flex-wrap">
|
||||
<span className="text-3xl font-bold text-violet-400">
|
||||
{day.hrv_nightly_avg ? Math.round(day.hrv_nightly_avg) : '--'}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">ms</span>
|
||||
</div>
|
||||
<div className="mt-0.5"><HrvBadge status={day.hrv_status} /></div>
|
||||
</div>
|
||||
</div>
|
||||
{day.avg_hr_day && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-0.5">Avg HR (day)</p>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-xl font-semibold text-orange-400">{Math.round(day.avg_hr_day)}</span>
|
||||
{day.max_hr_day && <span className="text-xs text-gray-500">/ {Math.round(day.max_hr_day)} max bpm</span>}
|
||||
<span className="text-xl font-semibold text-orange-400">
|
||||
{day.avg_hr_day ? Math.round(day.avg_hr_day) : '--'}
|
||||
</span>
|
||||
{day.max_hr_day && <span className="text-xs text-gray-500">/ {Math.round(day.max_hr_day)} max</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{day.weight_kg && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-0.5">Weight</p>
|
||||
<div className="flex items-baseline gap-1.5 flex-wrap">
|
||||
<span className="text-xl font-semibold text-emerald-400">{day.weight_kg.toFixed(1)}</span>
|
||||
<span className="text-xs text-gray-500">kg</span>
|
||||
<span className="text-xl font-semibold text-emerald-400">
|
||||
{day.weight_kg ? day.weight_kg.toFixed(1) : '--'}
|
||||
</span>
|
||||
{day.weight_kg && <span className="text-xs text-gray-500">kg</span>}
|
||||
{day.body_fat_pct && <span className="text-xs text-gray-500">{day.body_fat_pct.toFixed(1)}% fat</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -519,14 +642,16 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStag
|
||||
{stressLabel && <p className="text-xs text-gray-500 mt-1">{stressLabel}</p>}
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 flex flex-col">
|
||||
<p className="text-xs text-gray-500 mb-1">VO2 Max</p>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-2xl font-bold text-blue-400">
|
||||
{(day.vo2max ?? latestVo2max) != null ? (day.vo2max ?? latestVo2max).toFixed(1) : '--'}
|
||||
</span>
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Vo2MaxGauge
|
||||
value={(day.vo2max ?? latestVo2max) ?? null}
|
||||
birthYear={birthYear}
|
||||
biologicalSex={biologicalSex}
|
||||
/>
|
||||
</div>
|
||||
{day.fitness_age && <p className="text-xs text-gray-500 mt-1">Fitness age {day.fitness_age}</p>}
|
||||
{day.fitness_age && <p className="text-xs text-gray-500 mt-1 text-center">Fitness age {day.fitness_age}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -637,12 +762,17 @@ export default function HealthPage() {
|
||||
queryFn: () => api.get('/health-metrics/summary').then(r => r.data),
|
||||
})
|
||||
|
||||
const { data: profile } = useQuery({
|
||||
queryKey: ['profile'],
|
||||
queryFn: () => api.get('/profile/').then(r => r.data),
|
||||
})
|
||||
|
||||
// Full history for snapshot navigation.
|
||||
// Key starts with ['health-metrics'] so UploadPage invalidation hits it automatically.
|
||||
const { data: allDays } = useQuery({
|
||||
queryKey: ['health-metrics', 'all'],
|
||||
queryFn: () =>
|
||||
api.get('/health-metrics/', { params: { limit: 365 } })
|
||||
api.get('/health-metrics/', { params: { limit: 2000 } })
|
||||
.then(r => r.data.map(d => ({ ...d, date: d10(d.date) }))),
|
||||
})
|
||||
|
||||
@@ -721,6 +851,8 @@ export default function HealthPage() {
|
||||
sleepStages={intradayData?.sleep_stages}
|
||||
activities={dayActivities}
|
||||
latestVo2max={latestVo2max}
|
||||
birthYear={profile?.birth_year}
|
||||
biologicalSex={profile?.biological_sex}
|
||||
onOlder={goOlder}
|
||||
onNewer={goNewer}
|
||||
hasOlder={selectedIdx >= 0 && selectedIdx < allDaysSorted.length - 1}
|
||||
@@ -857,6 +989,7 @@ export default function HealthPage() {
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-3">VO2 Max</h3>
|
||||
<MetricChart data={metrics} dataKey="vo2max" color="#3b82f6"
|
||||
formatter={v => v.toFixed(1)}
|
||||
connectNulls showDots
|
||||
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -7,7 +7,12 @@ import api from '../utils/api'
|
||||
export default function LoginPage() {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const authError = new URLSearchParams(window.location.search).get('auth_error')
|
||||
const [error, setError] = useState(
|
||||
authError === 'not_authorized'
|
||||
? "Your account isn't permitted to access MileVault — ask the admin to add you to the allowed group."
|
||||
: ''
|
||||
)
|
||||
const { login, isLoading } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ export default function ProfilePage() {
|
||||
}, [recentMetrics])
|
||||
|
||||
// HR / measurements form
|
||||
const [hrForm, setHrForm] = useState({ max_heart_rate: '', birth_year: '', height_cm: '' })
|
||||
const [hrForm, setHrForm] = useState({ max_heart_rate: '', birth_year: '', height_cm: '', biological_sex: '' })
|
||||
const [hrSaved, setHrSaved] = useState(false)
|
||||
const [hrZoneRecalc, setHrZoneRecalc] = useState(false)
|
||||
const maxHrChangedRef = useRef(false)
|
||||
@@ -83,6 +83,7 @@ export default function ProfilePage() {
|
||||
max_heart_rate: profile.max_heart_rate || '',
|
||||
birth_year: profile.birth_year || '',
|
||||
height_cm: profile.height_cm || '',
|
||||
biological_sex: profile.biological_sex || '',
|
||||
})
|
||||
}, [profile])
|
||||
|
||||
@@ -204,10 +205,10 @@ export default function ProfilePage() {
|
||||
}
|
||||
|
||||
// PocketID config
|
||||
const [pidForm, setPidForm] = useState({ issuer: '', client_id: '', client_secret: '' })
|
||||
const [pidForm, setPidForm] = useState({ issuer: '', client_id: '', client_secret: '', allowed_group: '' })
|
||||
const [pidSaved, setPidSaved] = useState(false)
|
||||
useEffect(() => {
|
||||
if (pocketidConfig) setPidForm({ issuer: pocketidConfig.issuer || '', client_id: pocketidConfig.client_id || '', client_secret: '' })
|
||||
if (pocketidConfig) setPidForm({ issuer: pocketidConfig.issuer || '', client_id: pocketidConfig.client_id || '', client_secret: '', allowed_group: pocketidConfig.allowed_group || '' })
|
||||
}, [pocketidConfig])
|
||||
const savePocketID = useMutation({
|
||||
mutationFn: data => api.post('/profile/pocketid-config', data).then(r => r.data),
|
||||
@@ -246,6 +247,21 @@ export default function ProfilePage() {
|
||||
<Input type="number" value={hrForm.height_cm} placeholder="e.g. 178" min={50} max={300}
|
||||
onChange={e => setHrForm(f => ({ ...f, height_cm: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label="Biological sex" hint="Used for VO2 max fitness category thresholds">
|
||||
<div className="flex gap-2">
|
||||
{['male', 'female'].map(s => (
|
||||
<button key={s} type="button"
|
||||
onClick={() => setHrForm(f => ({ ...f, biological_sex: f.biological_sex === s ? '' : s }))}
|
||||
className={`flex-1 py-2 rounded-lg text-sm border transition-colors capitalize ${
|
||||
hrForm.biological_sex === s
|
||||
? 'bg-blue-600 border-blue-600 text-white'
|
||||
: 'border-gray-700 text-gray-400 hover:text-white'
|
||||
}`}>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{(avgRestingHr || healthSummary?.latest?.weight_kg) && (
|
||||
@@ -268,7 +284,7 @@ export default function ProfilePage() {
|
||||
<SaveButton
|
||||
onClick={() => {
|
||||
const data = Object.fromEntries(
|
||||
Object.entries(hrForm).filter(([,v]) => v !== '').map(([k,v]) => [k, parseFloat(v)])
|
||||
Object.entries(hrForm).filter(([,v]) => v !== '').map(([k,v]) => [k, k === 'biological_sex' ? v : parseFloat(v)])
|
||||
)
|
||||
maxHrChangedRef.current = data.max_heart_rate !== undefined && data.max_heart_rate !== profile?.max_heart_rate
|
||||
updateProfile.mutate(data)
|
||||
@@ -455,6 +471,10 @@ export default function ProfilePage() {
|
||||
<Input type="password" value={pidForm.client_secret} placeholder="••••••••"
|
||||
onChange={e => setPidForm(f => ({ ...f, client_secret: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label="Allowed PocketID group" hint="Only members of this PocketID group may sign in. Leave blank to allow all.">
|
||||
<Input value={pidForm.allowed_group} placeholder="e.g. milevault-users"
|
||||
onChange={e => setPidForm(f => ({ ...f, allowed_group: e.target.value }))} />
|
||||
</Field>
|
||||
{pocketidConfig?.enabled && (
|
||||
<p className="text-xs text-green-400">✓ PocketID is currently active</p>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { format } from 'date-fns'
|
||||
import api from '../utils/api'
|
||||
import { formatDuration, formatDistance } from '../utils/format'
|
||||
import RouteMiniMap from '../components/ui/RouteMiniMap'
|
||||
|
||||
function formatSegmentDist(m) {
|
||||
if (m == null) return '--'
|
||||
return m >= 1000 ? `${(m / 1000).toFixed(2)} km` : `${Math.round(m)} m`
|
||||
}
|
||||
|
||||
function SegmentRow({ seg, routeId, routePolyline, sportType }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data: times, isLoading: timesLoading } = useQuery({
|
||||
queryKey: ['segment-times', routeId, seg.id],
|
||||
queryFn: () => api.get(`/routes/${routeId}/segments/${seg.id}/times`).then(r => r.data),
|
||||
})
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: () => api.delete(`/routes/${routeId}/segments/${seg.id}`),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['segments', routeId] }),
|
||||
})
|
||||
|
||||
const bestTime = times?.length ? Math.min(...times.map(t => t.duration_s)) : null
|
||||
const lastTime = times?.[0]?.duration_s ?? null
|
||||
|
||||
return (
|
||||
<div className="border border-gray-800 rounded-lg overflow-hidden">
|
||||
{/* Main row */}
|
||||
<div className="flex items-center gap-3 p-3">
|
||||
{/* Segment mini-map */}
|
||||
<div className="flex-shrink-0">
|
||||
<RouteMiniMap
|
||||
polyline={routePolyline}
|
||||
sportType={sportType}
|
||||
width={72}
|
||||
height={56}
|
||||
segmentStartM={seg.start_distance_m}
|
||||
segmentEndM={seg.end_distance_m}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium text-white truncate">{seg.name}</span>
|
||||
{seg.auto_generated && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-800 text-gray-500">
|
||||
{seg.auto_generated_type || 'auto'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
{formatSegmentDist(seg.start_distance_m)} – {formatSegmentDist(seg.end_distance_m)}
|
||||
<span className="ml-2 text-gray-600">({formatSegmentDist(seg.end_distance_m - seg.start_distance_m)})</span>
|
||||
</p>
|
||||
{/* Times preview row */}
|
||||
{!timesLoading && (
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
{bestTime && (
|
||||
<span className="text-xs font-mono text-yellow-400">
|
||||
Best {formatDuration(bestTime)}
|
||||
</span>
|
||||
)}
|
||||
{lastTime && lastTime !== bestTime && (
|
||||
<span className="text-xs font-mono text-gray-400">
|
||||
Last {formatDuration(lastTime)}
|
||||
</span>
|
||||
)}
|
||||
{times?.length > 0 && (
|
||||
<span className="text-xs text-gray-600">
|
||||
{times.length} run{times.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
{times?.length === 0 && (
|
||||
<span className="text-xs text-gray-600">No times yet</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{timesLoading && <p className="text-xs text-gray-600 mt-1">Loading times…</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{times?.length > 0 && (
|
||||
<button
|
||||
onClick={() => setExpanded(v => !v)}
|
||||
className="text-xs text-blue-400 hover:text-blue-300 transition-colors px-2 py-1 rounded border border-blue-500/30 hover:border-blue-400/50"
|
||||
>
|
||||
{expanded ? 'Hide' : 'All'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => deleteMut.mutate()}
|
||||
disabled={deleteMut.isPending}
|
||||
className="text-xs text-gray-600 hover:text-red-400 transition-colors"
|
||||
title="Delete segment"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded times list */}
|
||||
{expanded && times?.length > 0 && (
|
||||
<div className="border-t border-gray-800 px-3 pb-3 pt-2 space-y-1">
|
||||
{times.map((t, i) => (
|
||||
<div key={t.activity_id} className="flex items-center gap-3 text-xs">
|
||||
<span className={`font-mono font-semibold w-14 ${t.duration_s === bestTime ? 'text-yellow-400' : 'text-gray-300'}`}>
|
||||
{formatDuration(t.duration_s)}
|
||||
</span>
|
||||
<Link to={`/activities/${t.activity_id}`} className="text-gray-500 hover:text-blue-400 transition-colors truncate">
|
||||
{t.name}
|
||||
</Link>
|
||||
<span className="text-gray-700 flex-shrink-0">{format(new Date(t.date), 'd MMM yyyy')}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NewSegmentForm({ routeId, onCreated }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [name, setName] = useState('')
|
||||
const [startKm, setStartKm] = useState('')
|
||||
const [endKm, setEndKm] = useState('')
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const mut = useMutation({
|
||||
mutationFn: (data) => api.post(`/routes/${routeId}/segments`, data).then(r => r.data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['segments', routeId] })
|
||||
setName(''); setStartKm(''); setEndKm(''); setOpen(false)
|
||||
if (onCreated) onCreated()
|
||||
},
|
||||
})
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="w-full text-left text-xs text-blue-400 hover:text-blue-300 border border-dashed border-blue-500/30 hover:border-blue-400/50 rounded-lg px-3 py-2 transition-colors"
|
||||
>
|
||||
+ Add segment manually
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault()
|
||||
const start = parseFloat(startKm) * 1000
|
||||
const end = parseFloat(endKm) * 1000
|
||||
if (!name || isNaN(start) || isNaN(end) || end <= start) return
|
||||
mut.mutate({ name, start_distance_m: start, end_distance_m: end })
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="border border-gray-700 rounded-lg p-3 space-y-2">
|
||||
<p className="text-xs text-gray-400 font-medium">New segment</p>
|
||||
<input
|
||||
type="text" placeholder="Name (e.g. The big hill)"
|
||||
value={name} onChange={e => setName(e.target.value)}
|
||||
className="w-full bg-gray-800 border border-gray-700 text-white text-sm rounded px-3 py-1.5 focus:outline-none focus:border-blue-500"
|
||||
required
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="number" placeholder="Start (km)" step="0.01" min="0"
|
||||
value={startKm} onChange={e => setStartKm(e.target.value)}
|
||||
className="flex-1 bg-gray-800 border border-gray-700 text-white text-sm rounded px-3 py-1.5 focus:outline-none focus:border-blue-500"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="number" placeholder="End (km)" step="0.01" min="0"
|
||||
value={endKm} onChange={e => setEndKm(e.target.value)}
|
||||
className="flex-1 bg-gray-800 border border-gray-700 text-white text-sm rounded px-3 py-1.5 focus:outline-none focus:border-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" disabled={mut.isPending}
|
||||
className="flex-1 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm py-1.5 rounded transition-colors">
|
||||
{mut.isPending ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button type="button" onClick={() => setOpen(false)}
|
||||
className="px-4 text-sm text-gray-500 hover:text-gray-300 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SegmentsPage() {
|
||||
const [selectedRouteId, setSelectedRouteId] = useState(null)
|
||||
const [autoGenLoading, setAutoGenLoading] = useState(null)
|
||||
const [hillGradient, setHillGradient] = useState(5)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data: routes } = useQuery({
|
||||
queryKey: ['routes'],
|
||||
queryFn: () => api.get('/routes/').then(r => r.data),
|
||||
})
|
||||
|
||||
const selectedRoute = routes?.find(r => r.id === selectedRouteId)
|
||||
|
||||
const { data: segments, isLoading: segsLoading } = useQuery({
|
||||
queryKey: ['segments', selectedRouteId],
|
||||
queryFn: () => api.get(`/routes/${selectedRouteId}/segments`).then(r => r.data),
|
||||
enabled: !!selectedRouteId,
|
||||
})
|
||||
|
||||
const autoGenMut = useMutation({
|
||||
mutationFn: ({ type, opts }) =>
|
||||
api.post(`/routes/${selectedRouteId}/segments/auto`, { type, ...opts }).then(r => r.data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['segments', selectedRouteId] })
|
||||
setAutoGenLoading(null)
|
||||
},
|
||||
onError: (err) => {
|
||||
alert(err?.response?.data?.detail || 'Auto-generate failed')
|
||||
setAutoGenLoading(null)
|
||||
},
|
||||
})
|
||||
|
||||
const handleAutoGen = (type, opts = {}) => {
|
||||
setAutoGenLoading(type)
|
||||
autoGenMut.mutate({ type, opts })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-white">Segments</h1>
|
||||
</div>
|
||||
|
||||
{/* Route tile grid */}
|
||||
{!routes?.length ? (
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-6">
|
||||
<p className="text-sm text-gray-600">No named routes yet. <Link to="/routes" className="text-blue-400 hover:underline">Create one on the Routes page.</Link></p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{routes.map(r => (
|
||||
<button
|
||||
key={r.id}
|
||||
onClick={() => setSelectedRouteId(r.id === selectedRouteId ? null : r.id)}
|
||||
className={`text-left rounded-xl border p-2 transition-colors ${
|
||||
selectedRouteId === r.id
|
||||
? 'border-blue-500 bg-blue-900/20'
|
||||
: 'border-gray-800 bg-gray-900 hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<RouteMiniMap
|
||||
polyline={r.reference_polyline}
|
||||
sportType={r.sport_type}
|
||||
width="100%"
|
||||
height={80}
|
||||
/>
|
||||
<p className="text-xs font-medium text-white mt-2 truncate">{r.name}</p>
|
||||
<div className="flex items-center justify-between mt-0.5">
|
||||
{r.distance_m && (
|
||||
<p className="text-xs text-gray-500">{(r.distance_m / 1000).toFixed(1)} km</p>
|
||||
)}
|
||||
{r.activity_count > 0 && (
|
||||
<p className="text-xs text-gray-500">{r.activity_count} run{r.activity_count !== 1 ? 's' : ''}</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedRoute && (
|
||||
<div className="space-y-4">
|
||||
{/* Route info */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">{selectedRoute.name}</h2>
|
||||
<p className="text-xs text-gray-500">
|
||||
{selectedRoute.sport_type && <span className="capitalize">{selectedRoute.sport_type}</span>}
|
||||
{selectedRoute.distance_m && <span> · {formatDistance(selectedRoute.distance_m)}</span>}
|
||||
{selectedRoute.activity_count > 0 && <span> · {selectedRoute.activity_count} runs</span>}
|
||||
{selectedRoute.auto_detected && <span className="ml-1 text-gray-600">(auto-detected)</span>}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auto-generate controls */}
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 space-y-3">
|
||||
<p className="text-xs font-medium text-gray-400">Auto-generate segments</p>
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<button
|
||||
onClick={() => handleAutoGen('1km')}
|
||||
disabled={autoGenLoading === '1km'}
|
||||
className="text-sm px-3 py-1.5 rounded-lg bg-blue-600/20 text-blue-300 border border-blue-500/30 hover:bg-blue-600/30 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{autoGenLoading === '1km' ? 'Generating…' : '📏 1 km splits'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAutoGen('turns')}
|
||||
disabled={autoGenLoading === 'turns'}
|
||||
className="text-sm px-3 py-1.5 rounded-lg bg-purple-600/20 text-purple-300 border border-purple-500/30 hover:bg-purple-600/30 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{autoGenLoading === 'turns' ? 'Generating…' : '↩️ Detect turns'}
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleAutoGen('hills', { gradient_pct: hillGradient })}
|
||||
disabled={autoGenLoading === 'hills'}
|
||||
className="text-sm px-3 py-1.5 rounded-lg bg-green-600/20 text-green-300 border border-green-500/30 hover:bg-green-600/30 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{autoGenLoading === 'hills' ? 'Generating…' : '⛰️ Detect hills'}
|
||||
</button>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-gray-500">≥</span>
|
||||
<input
|
||||
type="number" min="1" max="30" step="1"
|
||||
value={hillGradient}
|
||||
onChange={e => setHillGradient(parseInt(e.target.value) || 5)}
|
||||
className="w-12 bg-gray-800 border border-gray-700 text-white text-xs rounded px-2 py-1 text-center focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
<span className="text-xs text-gray-500">%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600">Each auto-generate type (splits, turns, hills) replaces only its own previous segments. Manual segments are always kept.</p>
|
||||
</div>
|
||||
|
||||
{/* Segments list */}
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-gray-300">Segments</h3>
|
||||
{segments?.length > 0 && (
|
||||
<span className="text-xs text-gray-600">{segments.length} segment{segments.length !== 1 ? 's' : ''}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{segsLoading && <p className="text-sm text-gray-600">Loading…</p>}
|
||||
|
||||
{!segsLoading && !segments?.length && (
|
||||
<p className="text-sm text-gray-600">No segments yet. Use auto-generate above or add one manually.</p>
|
||||
)}
|
||||
|
||||
{segments?.map(seg => (
|
||||
<SegmentRow
|
||||
key={seg.id}
|
||||
seg={seg}
|
||||
routeId={selectedRouteId}
|
||||
routePolyline={selectedRoute.reference_polyline}
|
||||
sportType={selectedRoute.sport_type}
|
||||
/>
|
||||
))}
|
||||
|
||||
<NewSegmentForm routeId={selectedRouteId} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,38 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import api from '../utils/api'
|
||||
|
||||
function UploadZone({ title, description, accept, endpoint, icon }) {
|
||||
const [tasks, setTasks] = useState([])
|
||||
const queryClient = useQueryClient()
|
||||
const intervalsRef = useRef({})
|
||||
|
||||
const pollTask = useCallback((taskId) => {
|
||||
if (intervalsRef.current[taskId]) return
|
||||
const intervalId = setInterval(async () => {
|
||||
try {
|
||||
const { data } = await api.get(`/upload/task/${taskId}`)
|
||||
if (data.status === 'SUCCESS' || data.status === 'FAILURE') {
|
||||
clearInterval(intervalsRef.current[taskId])
|
||||
delete intervalsRef.current[taskId]
|
||||
setTasks(ts => ts.map(t =>
|
||||
t.task_id === taskId ? { ...t, status: data.status === 'SUCCESS' ? 'done' : 'failed' } : t
|
||||
))
|
||||
if (data.status === 'SUCCESS') {
|
||||
queryClient.invalidateQueries({ queryKey: ['activities'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['health-summary'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['health-metrics'] })
|
||||
}
|
||||
}
|
||||
} catch { /* ignore transient poll errors */ }
|
||||
}, 2000)
|
||||
intervalsRef.current[taskId] = intervalId
|
||||
}, [queryClient])
|
||||
|
||||
useEffect(() => {
|
||||
return () => { Object.values(intervalsRef.current).forEach(clearInterval) }
|
||||
}, [])
|
||||
|
||||
const upload = useMutation({
|
||||
mutationFn: async (file) => {
|
||||
@@ -16,7 +44,11 @@ function UploadZone({ title, description, accept, endpoint, icon }) {
|
||||
return { file: file.name, ...data }
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setTasks(t => [...t, { ...data, status: 'queued' }])
|
||||
const task = { ...data, status: data.task_id ? 'processing' : 'queued' }
|
||||
setTasks(t => [...t, task])
|
||||
if (data.task_id) {
|
||||
pollTask(data.task_id)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -30,6 +62,13 @@ function UploadZone({ title, description, accept, endpoint, icon }) {
|
||||
multiple: true,
|
||||
})
|
||||
|
||||
function StatusBadge({ status }) {
|
||||
if (status === 'processing') return <span className="ml-2 text-blue-400 animate-pulse">⏳ Processing</span>
|
||||
if (status === 'done') return <span className="ml-2 text-green-400">✓ Done</span>
|
||||
if (status === 'failed') return <span className="ml-2 text-red-400">✗ Failed</span>
|
||||
return <span className="ml-2 text-green-400">✓ Queued</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
@@ -73,7 +112,7 @@ function UploadZone({ title, description, accept, endpoint, icon }) {
|
||||
{task.activity_tasks !== undefined && (
|
||||
<span className="text-gray-500 ml-2">{task.activity_tasks} activities queued</span>
|
||||
)}
|
||||
<span className="ml-2 text-green-400">✓ Queued</span>
|
||||
<StatusBadge status={task.status} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import api from '../utils/api'
|
||||
import { useAuthStore } from '../hooks/useAuth'
|
||||
|
||||
export default function UsersPage() {
|
||||
const qc = useQueryClient()
|
||||
const { user: me } = useAuthStore()
|
||||
|
||||
const { data: users, isLoading } = useQuery({
|
||||
queryKey: ['users'],
|
||||
queryFn: () => api.get('/users/').then(r => r.data),
|
||||
})
|
||||
|
||||
const setAdmin = useMutation({
|
||||
mutationFn: ({ id, is_admin }) => api.patch(`/users/${id}`, { is_admin }).then(r => r.data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['users'] }),
|
||||
onError: e => alert(e.response?.data?.detail || 'Failed to update user'),
|
||||
})
|
||||
|
||||
const deleteUser = useMutation({
|
||||
mutationFn: id => api.delete(`/users/${id}`).then(r => r.data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['users'] }),
|
||||
onError: e => alert(e.response?.data?.detail || 'Failed to delete user'),
|
||||
})
|
||||
|
||||
const handleDelete = u => {
|
||||
if (confirm(`Delete ${u.username} and ALL of their data (activities, routes, health, records)? This cannot be undone.`)) {
|
||||
deleteUser.mutate(u.id)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-3xl space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Users</h1>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
New users are created in PocketID and provisioned automatically on first passkey sign-in.
|
||||
Each user's data is fully separate.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
|
||||
{isLoading ? (
|
||||
<p className="p-5 text-sm text-gray-500">Loading…</p>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-gray-500 border-b border-gray-800">
|
||||
<th className="px-4 py-3 font-medium">User</th>
|
||||
<th className="px-4 py-3 font-medium">Sign-in</th>
|
||||
<th className="px-4 py-3 font-medium text-right">Activities</th>
|
||||
<th className="px-4 py-3 font-medium text-center">Admin</th>
|
||||
<th className="px-4 py-3 font-medium text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users?.map(u => {
|
||||
const isMe = u.id === me?.id
|
||||
return (
|
||||
<tr key={u.id} className="border-b border-gray-800/60 last:border-0">
|
||||
<td className="px-4 py-3">
|
||||
<div className="text-white">@{u.username}{isMe && <span className="text-gray-500"> (you)</span>}</div>
|
||||
{u.email && <div className="text-xs text-gray-500">{u.email}</div>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-400">
|
||||
{u.has_passkey ? '🔑 Passkey' : '🔒 Password'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-gray-300">{u.activity_count}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={u.is_admin}
|
||||
disabled={isMe || setAdmin.isPending}
|
||||
onChange={e => setAdmin.mutate({ id: u.id, is_admin: e.target.checked })}
|
||||
className="w-4 h-4 accent-blue-500 disabled:opacity-40"
|
||||
title={isMe ? "You can't change your own admin status" : ''}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={() => handleDelete(u)}
|
||||
disabled={isMe || deleteUser.isPending}
|
||||
className="text-red-400 hover:text-red-300 disabled:opacity-30 disabled:cursor-not-allowed text-xs transition-colors"
|
||||
title={isMe ? "You can't delete your own account" : ''}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user