Health today
{latest ? (
<>
diff --git a/milevault_export/frontend/src/pages/HealthPage.jsx b/milevault_export/frontend/src/pages/HealthPage.jsx
index ae1a002..9196c14 100644
--- a/milevault_export/frontend/src/pages/HealthPage.jsx
+++ b/milevault_export/frontend/src/pages/HealthPage.jsx
@@ -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 (
+
+
+
+ )
+}
+
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 (
π
@@ -341,9 +462,9 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStag
{/* Sleep (wide) + Heart / HRV */}
-
+
-
+
Sleep
{day.sleep_score != null && (
@@ -396,56 +517,58 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStag
) : null}
-
-
Heart & HRV
-
-
Resting HR
-
-
- {day.resting_hr ? Math.round(day.resting_hr) : '--'}
-
-
bpm
+
+
Heart & HRV
+
+
+
Resting HR
+
+
+ {day.resting_hr ? Math.round(day.resting_hr) : '--'}
+
+ bpm
+
+ {avg30?.resting_hr && day.resting_hr && (
+
+ 30d avg {Math.round(avg30.resting_hr)}
+ {day.resting_hr < avg30.resting_hr
+ ? β
+ : day.resting_hr > avg30.resting_hr
+ ? β
+ : null}
+
+ )}
- {avg30?.resting_hr && day.resting_hr && (
-
- 30d avg {Math.round(avg30.resting_hr)} bpm
- {day.resting_hr < avg30.resting_hr
- ? β
- : day.resting_hr > avg30.resting_hr
- ? β
- : null}
-
- )}
-
-
-
HRV
-
-
- {day.hrv_nightly_avg ? Math.round(day.hrv_nightly_avg) : '--'}
-
-
ms
-
+
+
HRV
+
+
+ {day.hrv_nightly_avg ? Math.round(day.hrv_nightly_avg) : '--'}
+
+ ms
+
+
-
- {day.avg_hr_day && (
Avg HR (day)
- {Math.round(day.avg_hr_day)}
- {day.max_hr_day && / {Math.round(day.max_hr_day)} max bpm}
+
+ {day.avg_hr_day ? Math.round(day.avg_hr_day) : '--'}
+
+ {day.max_hr_day && / {Math.round(day.max_hr_day)} max}
- )}
- {day.weight_kg && (
Weight
- {day.weight_kg.toFixed(1)}
- kg
+
+ {day.weight_kg ? day.weight_kg.toFixed(1) : '--'}
+
+ {day.weight_kg && kg}
{day.body_fat_pct && {day.body_fat_pct.toFixed(1)}% fat}
- )}
+
@@ -519,14 +642,16 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStag
{stressLabel &&
{stressLabel}
}
-
+
VO2 Max
-
-
- {(day.vo2max ?? latestVo2max) != null ? (day.vo2max ?? latestVo2max).toFixed(1) : '--'}
-
+
+
- {day.fitness_age &&
Fitness age {day.fitness_age}
}
+ {day.fitness_age &&
Fitness age {day.fitness_age}
}
@@ -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() {
VO2 Max
v.toFixed(1)}
+ connectNulls showDots
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
)}
diff --git a/milevault_export/frontend/src/pages/LoginPage.jsx b/milevault_export/frontend/src/pages/LoginPage.jsx
index b566e7c..a3d5062 100644
--- a/milevault_export/frontend/src/pages/LoginPage.jsx
+++ b/milevault_export/frontend/src/pages/LoginPage.jsx
@@ -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()
diff --git a/milevault_export/frontend/src/pages/ProfilePage.jsx b/milevault_export/frontend/src/pages/ProfilePage.jsx
index a8f1c8d..d6aef12 100644
--- a/milevault_export/frontend/src/pages/ProfilePage.jsx
+++ b/milevault_export/frontend/src/pages/ProfilePage.jsx
@@ -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() {
setHrForm(f => ({ ...f, height_cm: e.target.value }))} />
+
+
+ {['male', 'female'].map(s => (
+
+ ))}
+
+
{(avgRestingHr || healthSummary?.latest?.weight_kg) && (
@@ -268,7 +284,7 @@ export default function ProfilePage() {
{
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() {
setPidForm(f => ({ ...f, client_secret: e.target.value }))} />
+
+ setPidForm(f => ({ ...f, allowed_group: e.target.value }))} />
+
{pocketidConfig?.enabled && (
β PocketID is currently active
)}
diff --git a/milevault_export/frontend/src/pages/SegmentsPage.jsx b/milevault_export/frontend/src/pages/SegmentsPage.jsx
new file mode 100644
index 0000000..aa54fcf
--- /dev/null
+++ b/milevault_export/frontend/src/pages/SegmentsPage.jsx
@@ -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 (
+
+ {/* Main row */}
+
+ {/* Segment mini-map */}
+
+
+
+
+
+
+ {seg.name}
+ {seg.auto_generated && (
+
+ {seg.auto_generated_type || 'auto'}
+
+ )}
+
+
+ {formatSegmentDist(seg.start_distance_m)} β {formatSegmentDist(seg.end_distance_m)}
+ ({formatSegmentDist(seg.end_distance_m - seg.start_distance_m)})
+
+ {/* Times preview row */}
+ {!timesLoading && (
+
+ {bestTime && (
+
+ Best {formatDuration(bestTime)}
+
+ )}
+ {lastTime && lastTime !== bestTime && (
+
+ Last {formatDuration(lastTime)}
+
+ )}
+ {times?.length > 0 && (
+
+ {times.length} run{times.length !== 1 ? 's' : ''}
+
+ )}
+ {times?.length === 0 && (
+ No times yet
+ )}
+
+ )}
+ {timesLoading &&
Loading timesβ¦
}
+
+
+
+ {times?.length > 0 && (
+
+ )}
+
+
+
+
+ {/* Expanded times list */}
+ {expanded && times?.length > 0 && (
+
+ {times.map((t, i) => (
+
+
+ {formatDuration(t.duration_s)}
+
+
+ {t.name}
+
+ {format(new Date(t.date), 'd MMM yyyy')}
+
+ ))}
+
+ )}
+
+ )
+}
+
+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 (
+
+ )
+ }
+
+ 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 (
+
+ )
+}
+
+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 (
+
+
+
Segments
+
+
+ {/* Route tile grid */}
+ {!routes?.length ? (
+
+
No named routes yet. Create one on the Routes page.
+
+ ) : (
+
+ {routes.map(r => (
+
+ ))}
+
+ )}
+
+ {selectedRoute && (
+
+ {/* Route info */}
+
+
+
{selectedRoute.name}
+
+ {selectedRoute.sport_type && {selectedRoute.sport_type}}
+ {selectedRoute.distance_m && Β· {formatDistance(selectedRoute.distance_m)}}
+ {selectedRoute.activity_count > 0 && Β· {selectedRoute.activity_count} runs}
+ {selectedRoute.auto_detected && (auto-detected)}
+
+
+
+
+ {/* Auto-generate controls */}
+
+
Auto-generate segments
+
+
+
+
+
+
+ β₯
+ 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"
+ />
+ %
+
+
+
+
Each auto-generate type (splits, turns, hills) replaces only its own previous segments. Manual segments are always kept.
+
+
+ {/* Segments list */}
+
+
+
Segments
+ {segments?.length > 0 && (
+ {segments.length} segment{segments.length !== 1 ? 's' : ''}
+ )}
+
+
+ {segsLoading &&
Loadingβ¦
}
+
+ {!segsLoading && !segments?.length && (
+
No segments yet. Use auto-generate above or add one manually.
+ )}
+
+ {segments?.map(seg => (
+
+ ))}
+
+
+
+
+ )}
+
+ )
+}
diff --git a/milevault_export/frontend/src/pages/UploadPage.jsx b/milevault_export/frontend/src/pages/UploadPage.jsx
index 62dc1c5..96a5924 100644
--- a/milevault_export/frontend/src/pages/UploadPage.jsx
+++ b/milevault_export/frontend/src/pages/UploadPage.jsx
@@ -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 β³ Processing
+ if (status === 'done') return β Done
+ if (status === 'failed') return β Failed
+ return β Queued
+ }
+
return (
@@ -73,7 +112,7 @@ function UploadZone({ title, description, accept, endpoint, icon }) {
{task.activity_tasks !== undefined && (
{task.activity_tasks} activities queued
)}
- β Queued
+
))}
diff --git a/milevault_export/frontend/src/pages/UsersPage.jsx b/milevault_export/frontend/src/pages/UsersPage.jsx
new file mode 100644
index 0000000..2928c61
--- /dev/null
+++ b/milevault_export/frontend/src/pages/UsersPage.jsx
@@ -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 (
+
+
+
Users
+
+ New users are created in PocketID and provisioned automatically on first passkey sign-in.
+ Each user's data is fully separate.
+
+
+
+
+ {isLoading ? (
+
Loadingβ¦
+ ) : (
+
+ )}
+
+
+ )
+}