Dashboard polish: bounded drag push, equal-height stats, dark featured map, sport-coloured weekly bars
Build and push images / validate (push) Successful in 3s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-backend (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 9s

- Prevent widgets flying down the page when dragging (preventCollision)
- Stat widgets fill their cell height so cards with/without sub-text align
- Featured-activity map defaults to dark tiles
- Weekly distance bars stacked and coloured by activity type, with a legend

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 23:31:04 +01:00
parent 491660fc6b
commit bb09c37b3d
2 changed files with 49 additions and 22 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ const accentColors = {
export default function StatCard({ label, value, accent = 'default', sub }) {
return (
<div className="bg-gray-800/60 rounded-xl p-3 border border-gray-700/50">
<div className="bg-gray-800/60 rounded-xl p-3 border border-gray-700/50 h-full flex flex-col justify-center">
<p className="text-xs text-gray-500 mb-1">{label}</p>
<p className={`text-lg font-semibold ${accentColors[accent]}`}>{value}</p>
{sub && <p className="text-xs text-gray-600 mt-0.5">{sub}</p>}
+48 -21
View File
@@ -13,7 +13,7 @@ import StatCard from '../components/ui/StatCard'
import ActivityMap from '../components/activity/ActivityMap'
import {
formatDuration, formatDistance, formatHeartRate, formatElevation,
formatDate, sportIcon, formatSleep,
formatDate, sportIcon, sportColor, formatSleep,
} from '../utils/format'
import { BB_INFERRED_COLOR, BB_INFERRED_LABEL, bbLevelColor, inferBBType } from '../utils/bodyBattery'
@@ -278,35 +278,62 @@ function SleepDetail({ health }) {
)
}
const sportLabel = s => (s ? s.charAt(0).toUpperCase() + s.slice(1) : 'Other')
function WeeklyChart({ activities }) {
const navigate = useNavigate()
const data = useMemo(() => {
if (!activities?.length) return []
const { data, sports } = useMemo(() => {
if (!activities?.length) return { data: [], sports: [] }
// Sports present, ordered by total distance (largest stacks at the bottom).
const totals = {}
for (const a of activities) totals[a.sport_type] = (totals[a.sport_type] || 0) + (a.distance_m || 0)
const sports = Object.keys(totals).sort((x, y) => totals[y] - totals[x])
const now = new Date()
const weeks = eachWeekOfInterval({ start: subWeeks(startOfWeek(now), 7), end: startOfWeek(now) })
return weeks.map(weekStart => {
const data = weeks.map(weekStart => {
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: format(weekStart, 'MMM d'), km: parseFloat(km.toFixed(2)), weekStartISO: format(weekStart, 'yyyy-MM-dd'), weekEndISO: format(weekEnd, 'yyyy-MM-dd') }
const row = { week: format(weekStart, 'MMM d'), weekStartISO: format(weekStart, 'yyyy-MM-dd'), weekEndISO: format(weekEnd, 'yyyy-MM-dd') }
for (const s of sports) row[s] = 0
for (const a of activities) {
const t = new Date(a.start_time)
if (t >= weekStart && t < weekEnd) row[a.sport_type] += (a.distance_m || 0) / 1000
}
for (const s of sports) row[s] = +row[s].toFixed(2)
return row
})
return { data, sports }
}, [activities])
return (
<Card title="Weekly distance (km)">
{data.length ? (
<ResponsiveContainer width="100%" height="100%" minHeight={100}>
<BarChart data={data} margin={{ top: 4, right: 4, bottom: 4, left: 0 }} barSize={20}
onClick={e => { const p = e?.activePayload?.[0]?.payload; if (p) navigate(`/activities?from=${p.weekStartISO}&to=${p.weekEndISO}`) }}
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={tooltipStyle} 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>
<div className="flex flex-col h-full">
<div className="flex-1 min-h-0">
<ResponsiveContainer width="100%" height="100%" minHeight={100}>
<BarChart data={data} margin={{ top: 4, right: 4, bottom: 4, left: 0 }} barSize={20}
onClick={e => { const p = e?.activePayload?.[0]?.payload; if (p) navigate(`/activities?from=${p.weekStartISO}&to=${p.weekEndISO}`) }}
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={tooltipStyle} cursor={{ fill: 'rgba(255,255,255,0.06)' }}
formatter={(v, name) => [`${(+v).toFixed(1)} km`, sportLabel(name)]} />
{sports.map((s, i) => (
<Bar key={s} dataKey={s} stackId="dist" fill={sportColor(s)} isAnimationActive={false}
radius={i === sports.length - 1 ? [3, 3, 0, 0] : [0, 0, 0, 0]} />
))}
</BarChart>
</ResponsiveContainer>
</div>
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-2">
{sports.map(s => (
<div key={s} className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: sportColor(s) }} />
<span className="text-xs text-gray-400">{sportLabel(s)}</span>
</div>
))}
</div>
</div>
) : (
<div className="flex items-center justify-center h-full text-gray-600 text-sm">No activities yet</div>
)}
@@ -333,7 +360,7 @@ function FeaturedActivity({ activity, segments }) {
<div className="grid grid-cols-1 lg:grid-cols-3 flex-1 min-h-0">
<div className="lg:col-span-2 min-h-[180px] bg-gray-950">
{activity.polyline
? <ActivityMap polyline={activity.polyline} sportType={activity.sport_type} colorMode="solid" />
? <ActivityMap polyline={activity.polyline} sportType={activity.sport_type} colorMode="solid" mapType="dark" />
: <div className="flex items-center justify-center h-full text-gray-600 text-sm">No GPS track</div>}
</div>
<div className="grid grid-cols-2 lg:grid-cols-1 gap-px bg-gray-800/50 content-start">
@@ -603,7 +630,7 @@ export default function DashboardPage() {
isResizable={editMode}
onLayoutChange={handleLayoutChange}
compactType={null}
preventCollision={false}
preventCollision
draggableCancel=".widget-delete"
>
{layout.filter(l => WIDGETS[l.i]).map(l => (