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
co-authored by Claude Fable 5
parent 7673452bdb
commit e7123ee5db
13 changed files with 245 additions and 68 deletions
+51 -8
View File
@@ -9,6 +9,7 @@ import 'react-grid-layout/css/styles.css'
import 'react-resizable/css/styles.css'
import { startOfWeek, format, subWeeks, eachWeekOfInterval, subDays, addDays } from 'date-fns'
import api from '../utils/api'
import { useIsMobile } from '../hooks/useMediaQuery'
import StatCard from '../components/ui/StatCard'
import ActivityMap from '../components/activity/ActivityMap'
import {
@@ -513,6 +514,7 @@ export default function DashboardPage() {
})
// ── Layout state ──────────────────────────────────────────────────────────
const isMobile = useIsMobile()
const [editMode, setEditMode] = useState(false)
const [addOpen, setAddOpen] = useState(false)
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) }
// 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 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 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 (
<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-6">
<div className="p-4 md:p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-bold text-white">Dashboard</h1>
<div className="flex items-center gap-3">
@@ -607,13 +646,15 @@ export default function DashboardPage() {
{editMode && (
<button onClick={resetLayout} className="text-xs text-gray-400 hover:text-white transition-colors">Reset layout</button>
)}
<button
onClick={() => (editMode ? finishEditing() : setEditMode(true))}
className={`text-sm font-medium px-3 py-1.5 rounded-lg transition-colors ${
editMode ? 'bg-blue-600 hover:bg-blue-500 text-white' : 'bg-gray-800 hover:bg-gray-700 text-gray-200'
}`}>
{editMode ? '✓ Done' : '✎ Edit dashboard'}
</button>
{!isMobile && (
<button
onClick={() => (editMode ? finishEditing() : setEditMode(true))}
className={`text-sm font-medium px-3 py-1.5 rounded-lg transition-colors ${
editMode ? 'bg-blue-600 hover:bg-blue-500 text-white' : 'bg-gray-800 hover:bg-gray-700 text-gray-200'
}`}>
{editMode ? '✓ Done' : '✎ Edit dashboard'}
</button>
)}
<Link to="/upload" className="text-sm text-blue-400 hover:text-blue-300 transition-colors">+ Import data</Link>
</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>
)}
{isMobile ? renderMobileStack() : (
<Grid
className="layout"
layout={layout}
@@ -647,6 +689,7 @@ export default function DashboardPage() {
</div>
))}
</Grid>
)}
</div>
)
}