Mobile-responsive UI: bottom tab nav, stacked dashboard, page fixes for phones
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 10s

- Layout: sidebar hidden below md; mobile top header + bottom tab bar
  (Dashboard/Activities/Health/Routes) with a More slide-up sheet holding
  Records/Import/Profile/Users, Garmin sync progress and sign-out
- Dashboard: single-column widget stack on phones ordered from the saved
  desktop layout (stat cards pair into 2-col grid); drag-edit stays
  desktop-only; grid conditionally mounted so WidthProvider never
  measures a hidden container
- New useMediaQuery/useIsMobile hook (matchMedia, md breakpoint)
- ActivityDetail: 2-col stats on phones, wrapping map toolbar, Height
  control hidden on small screens
- Activities: compact distance/time/pace line on mobile rows
- Records/Users: horizontally scrollable tables; route records hide
  Pace/Date columns below sm
- Profile forms single-column on phones; Routes expanded card stacks;
  Health flex-wrap fixes; p-4 md:p-6 page padding; h-dvh for mobile
  browser chrome
- vite.config: VITE_PROXY_TARGET override for host-side dev

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 20:52:10 +01:00
parent 7673452bdb
commit e7123ee5db
13 changed files with 245 additions and 68 deletions
+121 -18
View File
@@ -1,13 +1,13 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Outlet, NavLink, useNavigate } from 'react-router-dom' import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom'
import { useAuthStore } from '../../hooks/useAuth' import { useAuthStore } from '../../hooks/useAuth'
import { useSyncStore, syncProgressPct } from '../../hooks/useSync' import { useSyncStore, syncProgressPct } from '../../hooks/useSync'
const nav = [ const nav = [
{ to: '/', label: 'Dashboard', icon: '📊', exact: true }, { to: '/', label: 'Dashboard', icon: '📊', exact: true, mobilePrimary: true },
{ to: '/activities', label: 'Activities', icon: '🏃' }, { to: '/activities', label: 'Activities', icon: '🏃', mobilePrimary: true },
{ to: '/health', label: 'Health', icon: '❤️' }, { to: '/health', label: 'Health', icon: '❤️', mobilePrimary: true },
{ to: '/routes', label: 'Routes', icon: '🗺️' }, { to: '/routes', label: 'Routes', icon: '🗺️', mobilePrimary: true },
{ to: '/records', label: 'Records', icon: '🏆' }, { to: '/records', label: 'Records', icon: '🏆' },
{ to: '/upload', label: 'Import', icon: '⬆️' }, { to: '/upload', label: 'Import', icon: '⬆️' },
{ to: '/profile', label: 'Profile', icon: '⚙️' }, { to: '/profile', label: 'Profile', icon: '⚙️' },
@@ -17,14 +17,19 @@ const nav = [
export default function Layout() { export default function Layout() {
const { user, logout } = useAuthStore() const { user, logout } = useAuthStore()
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation()
const { inProgress, status, startPolling, stopPolling } = useSyncStore() const { inProgress, status, startPolling, stopPolling } = useSyncStore()
const [collapsed, setCollapsed] = useState(() => localStorage.getItem('navCollapsed') === '1') const [collapsed, setCollapsed] = useState(() => localStorage.getItem('navCollapsed') === '1')
const [moreOpen, setMoreOpen] = useState(false)
useEffect(() => { useEffect(() => {
startPolling() startPolling()
return () => stopPolling() return () => stopPolling()
}, []) }, [])
// Close the mobile "More" sheet on navigation
useEffect(() => { setMoreOpen(false) }, [location.pathname])
const toggleCollapsed = () => { const toggleCollapsed = () => {
setCollapsed(c => { setCollapsed(c => {
const next = !c const next = !c
@@ -39,10 +44,28 @@ export default function Layout() {
} }
const role = user?.is_admin ? 'Administrator' : 'Member' const role = user?.is_admin ? 'Administrator' : 'Member'
const visibleNav = nav.filter(({ adminOnly }) => !adminOnly || user?.is_admin)
const primaryNav = visibleNav.filter(i => i.mobilePrimary)
const moreNav = visibleNav.filter(i => !i.mobilePrimary)
const moreActive = moreNav.some(i => location.pathname.startsWith(i.to))
const syncProgress = (
<div className="space-y-1.5">
<div className="flex items-center gap-2 text-xs text-blue-400">
<span className="inline-block w-2 h-2 rounded-full bg-blue-400 animate-pulse" />
Garmin sync
</div>
<div className="h-1.5 bg-gray-800 rounded-full overflow-hidden">
<div className="h-full bg-blue-500 rounded-full transition-all duration-700"
style={{ width: `${syncProgressPct(status)}%` }} />
</div>
<p className="text-xs text-gray-500 truncate">{status || 'Starting sync…'}</p>
</div>
)
return ( return (
<div className="flex h-screen overflow-hidden bg-gray-950"> <div className="flex flex-col md:flex-row h-dvh overflow-hidden bg-gray-950">
<aside className={`${collapsed ? 'w-16' : 'w-56'} flex-shrink-0 bg-gray-900 border-r border-gray-800 flex flex-col transition-[width] duration-200`}> <aside className={`${collapsed ? 'w-16' : 'w-56'} hidden md:flex flex-shrink-0 bg-gray-900 border-r border-gray-800 flex-col transition-[width] duration-200`}>
<div className={`flex items-center border-b border-gray-800 px-3 py-5 ${collapsed ? 'justify-center' : 'justify-between'}`}> <div className={`flex items-center border-b border-gray-800 px-3 py-5 ${collapsed ? 'justify-center' : 'justify-between'}`}>
{!collapsed && ( {!collapsed && (
<h1 className="text-lg font-bold text-white tracking-tight"> <h1 className="text-lg font-bold text-white tracking-tight">
@@ -57,7 +80,7 @@ export default function Layout() {
</div> </div>
<nav className="flex-1 py-4 overflow-y-auto"> <nav className="flex-1 py-4 overflow-y-auto">
{nav.filter(({ adminOnly }) => !adminOnly || user?.is_admin).map(({ to, label, icon, exact }) => ( {visibleNav.map(({ to, label, icon, exact }) => (
<NavLink key={to} to={to} end={exact} title={collapsed ? label : undefined} <NavLink key={to} to={to} end={exact} title={collapsed ? label : undefined}
className={({ isActive }) => className={({ isActive }) =>
`flex items-center gap-3 py-2.5 text-sm transition-colors ${collapsed ? 'justify-center px-0' : 'px-4'} ${ `flex items-center gap-3 py-2.5 text-sm transition-colors ${collapsed ? 'justify-center px-0' : 'px-4'} ${
@@ -73,16 +96,8 @@ export default function Layout() {
</nav> </nav>
{inProgress && !collapsed && ( {inProgress && !collapsed && (
<div className="px-4 py-3 border-t border-gray-800 space-y-1.5"> <div className="px-4 py-3 border-t border-gray-800">
<div className="flex items-center gap-2 text-xs text-blue-400"> {syncProgress}
<span className="inline-block w-2 h-2 rounded-full bg-blue-400 animate-pulse" />
Garmin sync
</div>
<div className="h-1.5 bg-gray-800 rounded-full overflow-hidden">
<div className="h-full bg-blue-500 rounded-full transition-all duration-700"
style={{ width: `${syncProgressPct(status)}%` }} />
</div>
<p className="text-xs text-gray-500 truncate">{status || 'Starting sync…'}</p>
</div> </div>
)} )}
{inProgress && collapsed && ( {inProgress && collapsed && (
@@ -125,9 +140,97 @@ export default function Layout() {
</div> </div>
</aside> </aside>
{/* Mobile top header */}
<header className="md:hidden flex items-center justify-between px-4 py-3 bg-gray-900 border-b border-gray-800">
<h1 className="text-lg font-bold text-white tracking-tight">
<span className="text-blue-400">Mile</span>Vault
</h1>
<div className="flex items-center gap-3">
{inProgress && (
<span className="inline-block w-2.5 h-2.5 rounded-full bg-blue-400 animate-pulse"
title={`Garmin sync: ${status || 'starting…'}`} />
)}
{user && (
<span className="w-8 h-8 rounded-full bg-blue-600/20 text-blue-300 flex items-center justify-center text-sm font-semibold uppercase">
{user.username?.[0] || '?'}
</span>
)}
</div>
</header>
<main className="flex-1 overflow-y-auto"> <main className="flex-1 overflow-y-auto">
<Outlet /> <Outlet />
</main> </main>
{/* Mobile bottom tab bar — z above Leaflet controls (~1000) */}
<nav className="md:hidden relative z-[1100] flex items-stretch bg-gray-900 border-t border-gray-800 pb-[env(safe-area-inset-bottom)]">
{primaryNav.map(({ to, label, icon, exact }) => (
<NavLink key={to} to={to} end={exact}
className={({ isActive }) =>
`flex-1 flex flex-col items-center gap-0.5 py-2 text-[10px] transition-colors ${
isActive ? 'text-blue-400' : 'text-gray-500'
}`
}>
<span className="text-lg leading-none">{icon}</span>
{label}
</NavLink>
))}
<button onClick={() => setMoreOpen(true)}
className={`relative flex-1 flex flex-col items-center gap-0.5 py-2 text-[10px] transition-colors ${
moreActive ? 'text-blue-400' : 'text-gray-500'
}`}>
<span className="text-lg leading-none"></span>
More
{inProgress && (
<span className="absolute top-1.5 right-1/2 translate-x-4 inline-block w-2 h-2 rounded-full bg-blue-400 animate-pulse" />
)}
</button>
</nav>
{/* Mobile "More" slide-up sheet */}
{moreOpen && (
<div className="md:hidden fixed inset-0 z-[1200]">
<div className="absolute inset-0 bg-black/60" onClick={() => setMoreOpen(false)} />
<div className="absolute bottom-0 inset-x-0 bg-gray-900 rounded-t-2xl border-t border-gray-800 p-4 pb-[calc(1rem+env(safe-area-inset-bottom))]">
<div className="flex justify-center mb-3">
<span className="w-10 h-1 rounded-full bg-gray-700" />
</div>
{moreNav.map(({ to, label, icon, exact }) => (
<NavLink key={to} to={to} end={exact} onClick={() => setMoreOpen(false)}
className={({ isActive }) =>
`flex items-center gap-3 px-2 py-3 rounded-lg text-sm transition-colors ${
isActive ? 'bg-blue-600/20 text-blue-400' : 'text-gray-300 hover:bg-gray-800'
}`
}>
<span>{icon}</span>
{label}
</NavLink>
))}
{inProgress && (
<div className="mt-3 pt-3 border-t border-gray-800">
{syncProgress}
</div>
)}
{user && (
<div className="mt-3 pt-3 border-t border-gray-800 flex items-center gap-2.5">
<span className="w-8 h-8 flex-shrink-0 rounded-full bg-blue-600/20 text-blue-300 flex items-center justify-center text-sm font-semibold uppercase">
{user.username?.[0] || '?'}
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-white truncate">{user.username}</p>
<p className={`text-xs ${user.is_admin ? 'text-amber-400' : 'text-gray-500'}`}>{role}</p>
</div>
<button onClick={handleLogout}
className="text-sm text-gray-400 hover:text-red-400 transition-colors px-2 py-1">
Sign out
</button>
</div>
)}
</div>
</div>
)}
</div> </div>
) )
} }
+16
View File
@@ -0,0 +1,16 @@
import { useState, useEffect } from 'react'
export function useMediaQuery(query) {
const [matches, setMatches] = useState(() => window.matchMedia(query).matches)
useEffect(() => {
const mql = window.matchMedia(query)
const onChange = e => setMatches(e.matches)
mql.addEventListener('change', onChange)
setMatches(mql.matches)
return () => mql.removeEventListener('change', onChange)
}, [query])
return matches
}
// Matches Tailwind's md breakpoint — keep CSS (md:) and JS forks in agreement.
export const useIsMobile = () => !useMediaQuery('(min-width: 768px)')
+7 -3
View File
@@ -41,7 +41,7 @@ export default function ActivitiesPage() {
const clearDateFilter = () => navigate('/activities') const clearDateFilter = () => navigate('/activities')
return ( return (
<div className="p-6"> <div className="p-4 md:p-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-bold text-white">Activities</h1> <h1 className="text-2xl font-bold text-white">Activities</h1>
<Link <Link
@@ -54,7 +54,7 @@ export default function ActivitiesPage() {
{/* YTD stats */} {/* YTD stats */}
{ytdStats && ( {ytdStats && (
<div className="flex gap-4 mb-4 text-sm"> <div className="flex flex-wrap gap-x-4 gap-y-1 mb-4 text-sm">
{ytdStats.running_km > 0 && ( {ytdStats.running_km > 0 && (
<span className="text-blue-400">🏃 {ytdStats.running_km.toFixed(0)} km this year</span> <span className="text-blue-400">🏃 {ytdStats.running_km.toFixed(0)} km this year</span>
)} )}
@@ -100,7 +100,7 @@ export default function ActivitiesPage() {
<Link <Link
key={activity.id} key={activity.id}
to={`/activities/${activity.id}`} to={`/activities/${activity.id}`}
className="flex items-center gap-4 bg-gray-900 hover:bg-gray-800 border border-gray-800 hover:border-gray-700 rounded-xl p-4 transition-all group" className="flex items-center gap-3 p-3 sm:gap-4 sm:p-4 bg-gray-900 hover:bg-gray-800 border border-gray-800 hover:border-gray-700 rounded-xl transition-all group"
> >
{/* Sport indicator */} {/* Sport indicator */}
<div <div
@@ -116,6 +116,10 @@ export default function ActivitiesPage() {
{activity.name} {activity.name}
</p> </p>
<p className="text-xs text-gray-500 mt-0.5">{formatDate(activity.start_time)}</p> <p className="text-xs text-gray-500 mt-0.5">{formatDate(activity.start_time)}</p>
{/* Compact metrics line — the full metrics column is hidden below sm */}
<p className="sm:hidden text-xs text-gray-400 mt-0.5 truncate">
{formatDistance(activity.distance_m)} · {formatDuration(activity.duration_s)} · {formatPace(activity.avg_speed_ms, activity.sport_type)}
</p>
</div> </div>
{/* Metrics */} {/* Metrics */}
+10 -8
View File
@@ -120,20 +120,20 @@ export default function ActivityDetailPage() {
if (!activity) return null if (!activity) return null
return ( return (
<div className="p-6 space-y-6"> <div className="p-4 md:p-6 space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div className="min-w-0">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<span className="text-2xl">{sportIcon(activity.sport_type)}</span> <span className="text-2xl">{sportIcon(activity.sport_type)}</span>
<h1 className="text-2xl font-bold text-white">{activity.name}</h1> <h1 className="text-2xl font-bold text-white break-words min-w-0">{activity.name}</h1>
</div> </div>
<p className="text-sm text-gray-500">{formatDateTime(activity.start_time)}</p> <p className="text-sm text-gray-500">{formatDateTime(activity.start_time)}</p>
</div> </div>
</div> </div>
{/* Stats — all on one row */} {/* Stats — all on one row */}
<div className="grid grid-cols-5 lg:grid-cols-10 gap-3"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 lg:grid-cols-10 gap-3">
<StatCard label="Distance" value={formatDistance(activity.distance_m)} /> <StatCard label="Distance" value={formatDistance(activity.distance_m)} />
<StatCard label="Time" value={formatDuration(activity.moving_time_s ?? activity.duration_s)} <StatCard label="Time" value={formatDuration(activity.moving_time_s ?? activity.duration_s)}
sub={activity.moving_time_s ? 'moving' : undefined} /> sub={activity.moving_time_s ? 'moving' : undefined} />
@@ -164,8 +164,8 @@ export default function ActivityDetailPage() {
{activity.polyline && activity.distance_m > 0 ? ( {activity.polyline && activity.distance_m > 0 ? (
<div className="bg-gray-900 rounded-xl overflow-hidden border border-gray-800"> <div className="bg-gray-900 rounded-xl overflow-hidden border border-gray-800">
{/* Map toolbar */} {/* Map toolbar */}
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-800"> <div className="flex flex-wrap items-center justify-between gap-y-2 px-4 py-2 border-b border-gray-800">
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-gray-500">Map style:</span> <span className="text-xs text-gray-500">Map style:</span>
{['dark', 'street', 'satellite'].map(t => ( {['dark', 'street', 'satellite'].map(t => (
<button <button
@@ -202,7 +202,8 @@ export default function ActivityDetailPage() {
{label} {label}
</button> </button>
))} ))}
<span className="text-xs text-gray-500 ml-2">Height:</span> <div className="hidden sm:flex items-center gap-2 ml-2">
<span className="text-xs text-gray-500">Height:</span>
{[280, 420, 560].map(h => ( {[280, 420, 560].map(h => (
<button <button
key={h} key={h}
@@ -216,6 +217,7 @@ export default function ActivityDetailPage() {
))} ))}
</div> </div>
</div> </div>
</div>
{segCreate && ( {segCreate && (
<div className="flex flex-wrap items-center gap-3 px-4 py-2 border-b border-gray-800 bg-green-900/10 text-xs"> <div className="flex flex-wrap items-center gap-3 px-4 py-2 border-b border-gray-800 bg-green-900/10 text-xs">
<span className="text-green-400"> <span className="text-green-400">
@@ -272,7 +274,7 @@ export default function ActivityDetailPage() {
{/* Metric timeline */} {/* Metric timeline */}
<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">
<div className="flex items-center justify-between mb-4"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-4">
<h3 className="text-sm font-medium text-gray-300">Activity Timeline</h3> <h3 className="text-sm font-medium text-gray-300">Activity Timeline</h3>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{METRICS.filter(m => availableMetrics.has(m.key)).map(({ key, label, color }) => ( {METRICS.filter(m => availableMetrics.has(m.key)).map(({ key, label, color }) => (
+44 -1
View File
@@ -9,6 +9,7 @@ import 'react-grid-layout/css/styles.css'
import 'react-resizable/css/styles.css' import 'react-resizable/css/styles.css'
import { startOfWeek, format, subWeeks, eachWeekOfInterval, subDays, addDays } from 'date-fns' import { startOfWeek, format, subWeeks, eachWeekOfInterval, subDays, addDays } from 'date-fns'
import api from '../utils/api' import api from '../utils/api'
import { useIsMobile } from '../hooks/useMediaQuery'
import StatCard from '../components/ui/StatCard' import StatCard from '../components/ui/StatCard'
import ActivityMap from '../components/activity/ActivityMap' import ActivityMap from '../components/activity/ActivityMap'
import { import {
@@ -513,6 +514,7 @@ export default function DashboardPage() {
}) })
// ── Layout state ────────────────────────────────────────────────────────── // ── Layout state ──────────────────────────────────────────────────────────
const isMobile = useIsMobile()
const [editMode, setEditMode] = useState(false) const [editMode, setEditMode] = useState(false)
const [addOpen, setAddOpen] = useState(false) const [addOpen, setAddOpen] = useState(false)
const [layout, setLayout] = useState(() => buildLayout(null)) const [layout, setLayout] = useState(() => buildLayout(null))
@@ -554,6 +556,11 @@ export default function DashboardPage() {
} }
const removeWidget = (id) => { const next = layout.filter(l => l.i !== id); setLayout(next); persist(next) } const removeWidget = (id) => { const next = layout.filter(l => l.i !== id); setLayout(next); persist(next) }
// Editing is desktop-only; drop out of edit mode if the viewport shrinks mid-edit.
useEffect(() => {
if (isMobile && editMode) { setEditMode(false); setAddOpen(false) }
}, [isMobile])
const finishEditing = () => { persist(layout); setEditMode(false); setAddOpen(false) } const finishEditing = () => { persist(layout); setEditMode(false); setAddOpen(false) }
const resetLayout = () => { const def = attachMins(DEFAULT_LAYOUT); setLayout(def); persist(def) } const resetLayout = () => { const def = attachMins(DEFAULT_LAYOUT); setLayout(def); persist(def) }
@@ -579,8 +586,40 @@ export default function DashboardPage() {
const presentIds = new Set(layout.map(l => l.i)) const presentIds = new Set(layout.map(l => l.i))
const available = Object.keys(WIDGETS).filter(id => !presentIds.has(id)) const available = Object.keys(WIDGETS).filter(id => !presentIds.has(id))
// Single-column stack for phones: saved desktop layout read top-to-bottom,
// left-to-right; consecutive stat cards pair up into a 2-column grid.
const renderMobileStack = () => {
const sorted = [...layout].filter(l => WIDGETS[l.i]).sort((a, b) => a.y - b.y || a.x - b.x)
const groups = []
for (const l of sorted) {
const last = groups[groups.length - 1]
if (STAT_DEFS[l.i] && last?.stats) last.items.push(l)
else groups.push({ stats: !!STAT_DEFS[l.i], items: [l] })
}
// Content-driven widgets size themselves; chart widgets need the explicit
// height the grid normally provides (rowHeight=80, margin=16) or their
// ResponsiveContainers collapse.
const autoHeight = new Set(['sleepDetail', 'prs'])
return ( return (
<div className="p-6"> <div className="space-y-4">
{groups.map((g, idx) =>
g.stats ? (
<div key={idx} className="grid grid-cols-2 gap-3">
{g.items.map(l => <div key={l.i}>{renderWidget(l.i)}</div>)}
</div>
) : (
<div key={g.items[0].i}
style={autoHeight.has(g.items[0].i) ? undefined : { height: g.items[0].h * 80 + (g.items[0].h - 1) * 16 }}>
{renderWidget(g.items[0].i)}
</div>
)
)}
</div>
)
}
return (
<div className="p-4 md:p-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-bold text-white">Dashboard</h1> <h1 className="text-2xl font-bold text-white">Dashboard</h1>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -607,6 +646,7 @@ export default function DashboardPage() {
{editMode && ( {editMode && (
<button onClick={resetLayout} className="text-xs text-gray-400 hover:text-white transition-colors">Reset layout</button> <button onClick={resetLayout} className="text-xs text-gray-400 hover:text-white transition-colors">Reset layout</button>
)} )}
{!isMobile && (
<button <button
onClick={() => (editMode ? finishEditing() : setEditMode(true))} onClick={() => (editMode ? finishEditing() : setEditMode(true))}
className={`text-sm font-medium px-3 py-1.5 rounded-lg transition-colors ${ className={`text-sm font-medium px-3 py-1.5 rounded-lg transition-colors ${
@@ -614,6 +654,7 @@ export default function DashboardPage() {
}`}> }`}>
{editMode ? '✓ Done' : '✎ Edit dashboard'} {editMode ? '✓ Done' : '✎ Edit dashboard'}
</button> </button>
)}
<Link to="/upload" className="text-sm text-blue-400 hover:text-blue-300 transition-colors">+ Import data</Link> <Link to="/upload" className="text-sm text-blue-400 hover:text-blue-300 transition-colors">+ Import data</Link>
</div> </div>
</div> </div>
@@ -622,6 +663,7 @@ export default function DashboardPage() {
<p className="text-xs text-gray-500 mb-3">Drag to move, drag a corner to resize, or remove a widget with . Add widgets from the menu. Changes save automatically.</p> <p className="text-xs text-gray-500 mb-3">Drag to move, drag a corner to resize, or remove a widget with . Add widgets from the menu. Changes save automatically.</p>
)} )}
{isMobile ? renderMobileStack() : (
<Grid <Grid
className="layout" className="layout"
layout={layout} layout={layout}
@@ -647,6 +689,7 @@ export default function DashboardPage() {
</div> </div>
))} ))}
</Grid> </Grid>
)}
</div> </div>
) )
} }
+3 -3
View File
@@ -488,7 +488,7 @@ function DailySnapshot({ day, snapshotWeight, avg30, intradayHr, bodyBattery, bb
</span> </span>
)} )}
</div> </div>
<div className="flex items-end gap-3"> <div className="flex flex-wrap items-end gap-3">
<span className="text-4xl font-bold text-white tracking-tight"> <span className="text-4xl font-bold text-white tracking-tight">
{formatSleep(day.sleep_duration_s)} {formatSleep(day.sleep_duration_s)}
</span> </span>
@@ -995,7 +995,7 @@ export default function HealthPage() {
const selDateForCharts = selectedDay?.date const selDateForCharts = selectedDay?.date
return ( return (
<div className="p-6 space-y-8"> <div className="p-4 md:p-6 space-y-6 md:space-y-8">
<h1 className="text-2xl font-bold text-white">Health</h1> <h1 className="text-2xl font-bold text-white">Health</h1>
<DailySnapshot <DailySnapshot
@@ -1024,7 +1024,7 @@ export default function HealthPage() {
<h2 className="text-base font-semibold text-gray-300">Trends</h2> <h2 className="text-base font-semibold text-gray-300">Trends</h2>
<p className="text-xs text-gray-600">Click any point to load that day above</p> <p className="text-xs text-gray-600">Click any point to load that day above</p>
</div> </div>
<div className="flex gap-1.5"> <div className="flex flex-wrap gap-1.5">
{RANGES.map(({ label, days }, i) => { {RANGES.map(({ label, days }, i) => {
const disabled = i > maxEnabledRangeIdx const disabled = i > maxEnabledRangeIdx
return ( return (
+1 -1
View File
@@ -42,7 +42,7 @@ export default function LoginPage() {
} }
return ( return (
<div className="min-h-screen bg-gray-950 flex items-center justify-center px-4"> <div className="min-h-dvh bg-gray-950 flex items-center justify-center px-4">
<div className="w-full max-w-sm"> <div className="w-full max-w-sm">
<div className="text-center mb-8"> <div className="text-center mb-8">
<h1 className="text-3xl font-bold text-white"> <h1 className="text-3xl font-bold text-white">
+3 -3
View File
@@ -189,7 +189,7 @@ export default function ProfilePage() {
const effectiveMaxHr = profile?.max_heart_rate || profile?.estimated_max_hr const effectiveMaxHr = profile?.max_heart_rate || profile?.estimated_max_hr
return ( return (
<div className="p-6 max-w-2xl space-y-6"> <div className="p-4 md:p-6 max-w-2xl space-y-6">
<h1 className="text-2xl font-bold text-white">Profile & Settings</h1> <h1 className="text-2xl font-bold text-white">Profile & Settings</h1>
{/* HR & Measurements */} {/* HR & Measurements */}
@@ -205,7 +205,7 @@ export default function ProfilePage() {
)} )}
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Field label="Max heart rate (bpm)" hint="Best from a sprint test or hard race"> <Field label="Max heart rate (bpm)" hint="Best from a sprint test or hard race">
<Input type="number" value={hrForm.max_heart_rate} placeholder="e.g. 185" min={100} max={250} <Input type="number" value={hrForm.max_heart_rate} placeholder="e.g. 185" min={100} max={250}
onChange={e => setHrForm(f => ({ ...f, max_heart_rate: e.target.value }))} /> onChange={e => setHrForm(f => ({ ...f, max_heart_rate: e.target.value }))} />
@@ -235,7 +235,7 @@ export default function ProfilePage() {
</Field> </Field>
</div> </div>
<div className="grid grid-cols-2 gap-4 pt-3 border-t border-gray-800"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4 pt-3 border-t border-gray-800">
<Field label="Goal weight (kg)" hint="Shown as a target line on the weight trend chart"> <Field label="Goal weight (kg)" hint="Shown as a target line on the weight trend chart">
<Input type="number" value={hrForm.goal_weight_kg} placeholder="e.g. 72" min={20} max={500} <Input type="number" value={hrForm.goal_weight_kg} placeholder="e.g. 72" min={20} max={500}
onChange={e => setHrForm(f => ({ ...f, goal_weight_kg: e.target.value }))} /> onChange={e => setHrForm(f => ({ ...f, goal_weight_kg: e.target.value }))} />
+12 -6
View File
@@ -44,7 +44,7 @@ function DistancePRs() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex gap-2"> <div className="flex flex-wrap gap-2">
{SPORTS.map(s => ( {SPORTS.map(s => (
<button <button
key={s} key={s}
@@ -69,6 +69,7 @@ function DistancePRs() {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden"> <div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="text-xs text-gray-500 border-b border-gray-800 bg-gray-900/80"> <tr className="text-xs text-gray-500 border-b border-gray-800 bg-gray-900/80">
@@ -108,6 +109,7 @@ function DistancePRs() {
</tbody> </tbody>
</table> </table>
</div> </div>
</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">
{selectedDistance && history ? ( {selectedDistance && history ? (
@@ -169,6 +171,7 @@ function RouteRecords() {
return ( return (
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden"> <div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="text-xs text-gray-500 border-b border-gray-800 bg-gray-900/80"> <tr className="text-xs text-gray-500 border-b border-gray-800 bg-gray-900/80">
@@ -176,8 +179,8 @@ function RouteRecords() {
<th className="text-left px-3 py-3 font-medium">Route</th> <th className="text-left px-3 py-3 font-medium">Route</th>
<th className="text-right px-3 py-3 font-medium">Distance</th> <th className="text-right px-3 py-3 font-medium">Distance</th>
<th className="text-right px-3 py-3 font-medium">Best time</th> <th className="text-right px-3 py-3 font-medium">Best time</th>
<th className="text-right px-3 py-3 font-medium">Pace</th> <th className="hidden sm:table-cell text-right px-3 py-3 font-medium">Pace</th>
<th className="text-right px-3 py-3 font-medium">Date</th> <th className="hidden sm:table-cell text-right px-3 py-3 font-medium">Date</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -200,10 +203,10 @@ function RouteRecords() {
<td className="px-3 py-3 text-right font-mono text-yellow-400 font-semibold"> <td className="px-3 py-3 text-right font-mono text-yellow-400 font-semibold">
{formatDuration(rec.duration_s)} {formatDuration(rec.duration_s)}
</td> </td>
<td className="px-3 py-3 text-right text-gray-400 text-xs"> <td className="hidden sm:table-cell px-3 py-3 text-right text-gray-400 text-xs">
{formatPace(rec.avg_speed_ms, rec.sport_type)} {formatPace(rec.avg_speed_ms, rec.sport_type)}
</td> </td>
<td className="px-3 py-3 text-right text-gray-400 text-xs"> <td className="hidden sm:table-cell px-3 py-3 text-right text-gray-400 text-xs">
{formatDate(rec.start_time)} {formatDate(rec.start_time)}
</td> </td>
</tr> </tr>
@@ -211,6 +214,7 @@ function RouteRecords() {
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
) )
} }
@@ -255,6 +259,7 @@ function SegmentRecords() {
return ( return (
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden"> <div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="text-xs text-gray-500 border-b border-gray-800 bg-gray-900/80"> <tr className="text-xs text-gray-500 border-b border-gray-800 bg-gray-900/80">
@@ -303,6 +308,7 @@ function SegmentRecords() {
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
) )
} }
@@ -310,7 +316,7 @@ export default function RecordsPage() {
const [tab, setTab] = useState('Distance PRs') const [tab, setTab] = useState('Distance PRs')
return ( return (
<div className="p-6 space-y-6"> <div className="p-4 md:p-6 space-y-6">
<h1 className="text-2xl font-bold text-white">Records</h1> <h1 className="text-2xl font-bold text-white">Records</h1>
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
+4 -4
View File
@@ -110,9 +110,9 @@ function RouteDetail({ selected, setSelected }) {
return ( return (
<div className="col-span-full bg-gray-900 rounded-xl border border-gray-800 p-5 space-y-4"> <div className="col-span-full bg-gray-900 rounded-xl border border-gray-800 p-5 space-y-4">
<div className="flex items-start justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3 sm:gap-4">
<div className="flex gap-4 items-start min-w-0"> <div className="flex flex-col sm:flex-row gap-4 items-start min-w-0 w-full sm:w-auto">
<div className="w-56 h-40 flex-shrink-0 rounded-lg overflow-hidden border border-gray-800"> <div className="w-full sm:w-56 h-40 flex-shrink-0 rounded-lg overflow-hidden border border-gray-800">
{selected.reference_polyline {selected.reference_polyline
? <ActivityMap polyline={selected.reference_polyline} sportType={selected.sport_type} colorMode="solid" /> ? <ActivityMap polyline={selected.reference_polyline} sportType={selected.sport_type} colorMode="solid" />
: <RouteMap polyline={selected.reference_polyline} className="w-full h-full" sportType={selected.sport_type} />} : <RouteMap polyline={selected.reference_polyline} className="w-full h-full" sportType={selected.sport_type} />}
@@ -262,7 +262,7 @@ export default function RoutesPage() {
}) })
return ( return (
<div className="p-6 space-y-6"> <div className="p-4 md:p-6 space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-white">Named Routes</h1> <h1 className="text-2xl font-bold text-white">Named Routes</h1>
+1 -1
View File
@@ -142,7 +142,7 @@ function UploadZone({ title, description, accept, endpoint, icon }) {
export default function UploadPage() { export default function UploadPage() {
return ( return (
<div className="p-6 space-y-6"> <div className="p-4 md:p-6 space-y-6">
<div> <div>
<h1 className="text-2xl font-bold text-white">Import Data</h1> <h1 className="text-2xl font-bold text-white">Import Data</h1>
<p className="text-gray-500 text-sm mt-1"> <p className="text-gray-500 text-sm mt-1">
+3 -1
View File
@@ -30,7 +30,7 @@ export default function UsersPage() {
} }
return ( return (
<div className="p-6 max-w-3xl space-y-6"> <div className="p-4 md:p-6 max-w-3xl space-y-6">
<div> <div>
<h1 className="text-2xl font-bold text-white">Users</h1> <h1 className="text-2xl font-bold text-white">Users</h1>
<p className="text-xs text-gray-500 mt-1"> <p className="text-xs text-gray-500 mt-1">
@@ -43,6 +43,7 @@ export default function UsersPage() {
{isLoading ? ( {isLoading ? (
<p className="p-5 text-sm text-gray-500">Loading…</p> <p className="p-5 text-sm text-gray-500">Loading…</p>
) : ( ) : (
<div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="text-left text-xs text-gray-500 border-b border-gray-800"> <tr className="text-left text-xs text-gray-500 border-b border-gray-800">
@@ -91,6 +92,7 @@ export default function UsersPage() {
})} })}
</tbody> </tbody>
</table> </table>
</div>
)} )}
</div> </div>
</div> </div>
+2 -1
View File
@@ -6,7 +6,8 @@ export default defineConfig({
server: { server: {
proxy: { proxy: {
'/api': { '/api': {
target: 'http://backend:8000', // 'backend' only resolves inside docker compose; override for host-side dev
target: process.env.VITE_PROXY_TARGET || 'http://backend:8000',
changeOrigin: true, changeOrigin: true,
}, },
}, },