Dashboard polish: bounded drag push, equal-height stats, dark featured map, sport-coloured weekly bars
- 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:
@@ -9,7 +9,7 @@ const accentColors = {
|
|||||||
|
|
||||||
export default function StatCard({ label, value, accent = 'default', sub }) {
|
export default function StatCard({ label, value, accent = 'default', sub }) {
|
||||||
return (
|
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-xs text-gray-500 mb-1">{label}</p>
|
||||||
<p className={`text-lg font-semibold ${accentColors[accent]}`}>{value}</p>
|
<p className={`text-lg font-semibold ${accentColors[accent]}`}>{value}</p>
|
||||||
{sub && <p className="text-xs text-gray-600 mt-0.5">{sub}</p>}
|
{sub && <p className="text-xs text-gray-600 mt-0.5">{sub}</p>}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import StatCard from '../components/ui/StatCard'
|
|||||||
import ActivityMap from '../components/activity/ActivityMap'
|
import ActivityMap from '../components/activity/ActivityMap'
|
||||||
import {
|
import {
|
||||||
formatDuration, formatDistance, formatHeartRate, formatElevation,
|
formatDuration, formatDistance, formatHeartRate, formatElevation,
|
||||||
formatDate, sportIcon, formatSleep,
|
formatDate, sportIcon, sportColor, formatSleep,
|
||||||
} from '../utils/format'
|
} from '../utils/format'
|
||||||
import { BB_INFERRED_COLOR, BB_INFERRED_LABEL, bbLevelColor, inferBBType } from '../utils/bodyBattery'
|
import { BB_INFERRED_COLOR, BB_INFERRED_LABEL, bbLevelColor, inferBBType } from '../utils/bodyBattery'
|
||||||
|
|
||||||
@@ -278,24 +278,37 @@ function SleepDetail({ health }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sportLabel = s => (s ? s.charAt(0).toUpperCase() + s.slice(1) : 'Other')
|
||||||
|
|
||||||
function WeeklyChart({ activities }) {
|
function WeeklyChart({ activities }) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const data = useMemo(() => {
|
const { data, sports } = useMemo(() => {
|
||||||
if (!activities?.length) return []
|
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 now = new Date()
|
||||||
const weeks = eachWeekOfInterval({ start: subWeeks(startOfWeek(now), 7), end: startOfWeek(now) })
|
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 weekEnd = addDays(weekStart, 7)
|
||||||
const km = activities
|
const row = { week: format(weekStart, 'MMM d'), weekStartISO: format(weekStart, 'yyyy-MM-dd'), weekEndISO: format(weekEnd, 'yyyy-MM-dd') }
|
||||||
.filter(a => { const t = new Date(a.start_time); return t >= weekStart && t < weekEnd })
|
for (const s of sports) row[s] = 0
|
||||||
.reduce((s, a) => s + (a.distance_m || 0) / 1000, 0)
|
for (const a of activities) {
|
||||||
return { week: format(weekStart, 'MMM d'), km: parseFloat(km.toFixed(2)), weekStartISO: format(weekStart, 'yyyy-MM-dd'), weekEndISO: format(weekEnd, 'yyyy-MM-dd') }
|
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])
|
}, [activities])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card title="Weekly distance (km)">
|
<Card title="Weekly distance (km)">
|
||||||
{data.length ? (
|
{data.length ? (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex-1 min-h-0">
|
||||||
<ResponsiveContainer width="100%" height="100%" minHeight={100}>
|
<ResponsiveContainer width="100%" height="100%" minHeight={100}>
|
||||||
<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={e => { const p = e?.activePayload?.[0]?.payload; if (p) navigate(`/activities?from=${p.weekStartISO}&to=${p.weekEndISO}`) }}
|
onClick={e => { const p = e?.activePayload?.[0]?.payload; if (p) navigate(`/activities?from=${p.weekStartISO}&to=${p.weekEndISO}`) }}
|
||||||
@@ -303,10 +316,24 @@ function WeeklyChart({ activities }) {
|
|||||||
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
|
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
|
||||||
<XAxis dataKey="week" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={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)}`} />
|
<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)' }} />
|
<Tooltip contentStyle={tooltipStyle} cursor={{ fill: 'rgba(255,255,255,0.06)' }}
|
||||||
<Bar dataKey="km" fill="#3b82f6" radius={[3, 3, 0, 0]} isAnimationActive={false} />
|
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>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</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>
|
<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="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">
|
<div className="lg:col-span-2 min-h-[180px] bg-gray-950">
|
||||||
{activity.polyline
|
{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 className="flex items-center justify-center h-full text-gray-600 text-sm">No GPS track</div>}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-1 gap-px bg-gray-800/50 content-start">
|
<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}
|
isResizable={editMode}
|
||||||
onLayoutChange={handleLayoutChange}
|
onLayoutChange={handleLayoutChange}
|
||||||
compactType={null}
|
compactType={null}
|
||||||
preventCollision={false}
|
preventCollision
|
||||||
draggableCancel=".widget-delete"
|
draggableCancel=".widget-delete"
|
||||||
>
|
>
|
||||||
{layout.filter(l => WIDGETS[l.i]).map(l => (
|
{layout.filter(l => WIDGETS[l.i]).map(l => (
|
||||||
|
|||||||
Reference in New Issue
Block a user