Layout
This commit is contained in:
@@ -8,14 +8,6 @@ import type {ActivityStreamPointDo} from '@/types/ActivityStreamPointDo'
|
||||
import type {ActivitySummaryDo} from '@/types/ActivitySummaryDo'
|
||||
import {formatDistance, formatDuration, formatPace, formatSpeed, sportIcon, summaryNum} from '@/utils/activity'
|
||||
|
||||
type PointInfo = {
|
||||
speed: number
|
||||
power: number
|
||||
hr: number
|
||||
grade: number
|
||||
distance: number
|
||||
} | null
|
||||
|
||||
function isBike(type: string): boolean {
|
||||
const lowered = type.toLowerCase()
|
||||
return lowered.includes('ride') || lowered.includes('cycling')
|
||||
@@ -32,16 +24,27 @@ function tssColor(tss: number): string {
|
||||
return 'text-red-600'
|
||||
}
|
||||
|
||||
type PointInfo = {speed: number; power: number; hr: number; distance: number} | null
|
||||
|
||||
function HoverRow({label, value}: {label: string; value: string}) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-[9px] font-semibold tracking-widest text-zinc-400 uppercase">{label}</p>
|
||||
<p className="text-sm font-bold text-zinc-800 tabular-nums leading-tight">{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ActivityRouteMap({
|
||||
points,
|
||||
timeSeries,
|
||||
isRun,
|
||||
isBike,
|
||||
isRunActivity,
|
||||
isBikeActivity,
|
||||
}: {
|
||||
points: ActivityMapStreamPointDo[]
|
||||
timeSeries: ActivityStreamPointDo[]
|
||||
isRun: boolean
|
||||
isBike: boolean
|
||||
isRunActivity: boolean
|
||||
isBikeActivity: boolean
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const mapRef = useRef<L.Map | null>(null)
|
||||
@@ -103,7 +106,6 @@ function ActivityRouteMap({
|
||||
speed: tsPoint.velocityMps ?? 0,
|
||||
power: tsPoint.watts ?? 0,
|
||||
hr: tsPoint.heartrateBpm ?? 0,
|
||||
grade: tsPoint.gradeSmooth ?? 0,
|
||||
distance: tsPoint.distanceM ?? 0,
|
||||
} : null
|
||||
|
||||
@@ -125,14 +127,11 @@ function ActivityRouteMap({
|
||||
</div>
|
||||
<div className="px-4 pb-3 space-y-3">
|
||||
<HoverRow
|
||||
label={isRun ? 'Pace' : 'Speed'}
|
||||
value={isRun ? formatPace(pointInfo.speed) : formatSpeed(pointInfo.speed)}
|
||||
label={isRunActivity ? 'Pace' : 'Speed'}
|
||||
value={isRunActivity ? formatPace(pointInfo.speed) : formatSpeed(pointInfo.speed)}
|
||||
/>
|
||||
<HoverRow
|
||||
label="Distance"
|
||||
value={formatDistance(pointInfo.distance)}
|
||||
/>
|
||||
{isBike && pointInfo.power > 0 && (
|
||||
<HoverRow label="Distance" value={formatDistance(pointInfo.distance)}/>
|
||||
{isBikeActivity && pointInfo.power > 0 && (
|
||||
<HoverRow label="Power" value={`${Math.round(pointInfo.power)} W`}/>
|
||||
)}
|
||||
{pointInfo.hr > 0 && (
|
||||
@@ -223,35 +222,11 @@ function ActivityRouteMap({
|
||||
)
|
||||
}
|
||||
|
||||
function HoverRow({label, value}: {label: string; value: string}) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-[9px] font-semibold tracking-widest text-zinc-400 uppercase">{label}</p>
|
||||
<p className="text-sm font-bold text-zinc-800 tabular-nums leading-tight">{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type MetricItem = {
|
||||
label: string
|
||||
value: string
|
||||
colorClass?: string
|
||||
}
|
||||
|
||||
function MetricCell({label, value, colorClass = 'text-zinc-800'}: MetricItem) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold tracking-widest text-zinc-400 uppercase">{label}</p>
|
||||
<p className={`text-sm font-semibold tabular-nums ${colorClass}`}>{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ActivityOverviewCard({
|
||||
activity,
|
||||
mapStream,
|
||||
timeSeries = [],
|
||||
}: {
|
||||
activity,
|
||||
mapStream,
|
||||
timeSeries = [],
|
||||
}: {
|
||||
activity: ActivitySummaryDo
|
||||
mapStream: ActivityMapStreamPointDo[] | null
|
||||
timeSeries?: ActivityStreamPointDo[]
|
||||
@@ -277,16 +252,9 @@ export function ActivityOverviewCard({
|
||||
|
||||
const sportBadgeLabel = isBike(activity.activityType) ? 'Ride' : isRun(activity.activityType) ? 'Run' : null
|
||||
|
||||
const metrics: MetricItem[] = [
|
||||
{label: speedPaceLabel, value: speedPaceValue},
|
||||
{label: 'Avg HR', value: heartrate != null ? `${heartrate} bpm` : '—'},
|
||||
{label: 'TSS', value: String(tssValue ?? '—'), colorClass: tssValue != null ? tssColor(tssValue) : 'text-zinc-800'},
|
||||
...(isBike(activity.activityType) ? [{label: 'Avg Power', value: power != null ? `${power} W` : '—'}] : []),
|
||||
{label: 'kCal', value: kcal != null ? kcal.toLocaleString() : '—'},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="border border-zinc-200 rounded-xl bg-white shadow-md hover:shadow-xl hover:-translate-y-0.5 transition-all duration-200 overflow-hidden">
|
||||
<div
|
||||
className="border border-zinc-200 rounded-xl bg-white shadow-md hover:shadow-xl hover:-translate-y-0.5 transition-all duration-200 overflow-hidden">
|
||||
{/* Header – always full-width */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-zinc-100">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
@@ -297,61 +265,80 @@ export function ActivityOverviewCard({
|
||||
</div>
|
||||
</div>
|
||||
{sportBadgeLabel != null && (
|
||||
<span className="shrink-0 ml-3 px-3 py-1 text-xs font-bold text-white rounded-full bg-gradient-to-r from-red-500 to-orange-500 shadow-sm">
|
||||
<span
|
||||
className="shrink-0 ml-3 px-3 py-1 text-xs font-bold text-white rounded-full bg-gradient-to-r from-red-500 to-orange-500 shadow-sm">
|
||||
{sportBadgeLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Body – stacked on mobile, two-column on lg+ */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[65%_35%]">
|
||||
{/* Left: map or indoor placeholder */}
|
||||
<div className="h-[280px] lg:h-[420px]">
|
||||
{hasMap ? (
|
||||
<ActivityRouteMap
|
||||
points={mapStream!}
|
||||
timeSeries={timeSeries}
|
||||
isRun={isRun(activity.activityType)}
|
||||
isBike={isBike(activity.activityType)}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gradient-to-br from-zinc-50 to-zinc-100 flex flex-col items-center justify-center gap-2">
|
||||
<span className="text-6xl opacity-20">{sportIcon(activity.activityType)}</span>
|
||||
<p className="text-sm font-semibold text-zinc-400 tracking-wide">Indoor Trainer Ride</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: metrics panel */}
|
||||
<div className="flex flex-col justify-between p-6 border-t border-zinc-100 lg:border-t-0 lg:border-l lg:border-zinc-100">
|
||||
{/* Hero row: Moving Time + Distance */}
|
||||
<div>
|
||||
<div className="mb-1">
|
||||
<p className="text-[10px] font-semibold tracking-widest text-zinc-400 uppercase mb-1.5">Moving Time · Distance</p>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<p className="text-2xl font-bold text-zinc-900 tabular-nums leading-none">{formatDuration(activity.movingTimeS)}</p>
|
||||
<span className="text-zinc-300 text-lg font-light">•</span>
|
||||
<p className="text-2xl font-bold text-zinc-900 tabular-nums leading-none">{formatDistance(summaryNum(activity.valuesForSummary, 'distanceM') ?? 0)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-zinc-100 my-5"/>
|
||||
|
||||
{/* 2-column metrics grid */}
|
||||
<div className="grid grid-cols-2 gap-x-5 gap-y-5">
|
||||
{metrics.map((metric, i) => (
|
||||
<div key={metric.label} className={i >= 2 ? 'pt-4 border-t border-zinc-100' : ''}>
|
||||
<MetricCell {...metric}/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Body – full-width map + metrics below slider */}
|
||||
<div className="aspect-square">
|
||||
{hasMap ? (
|
||||
<ActivityRouteMap
|
||||
points={mapStream!}
|
||||
timeSeries={timeSeries}
|
||||
isRunActivity={isRun(activity.activityType)}
|
||||
isBikeActivity={isBike(activity.activityType)}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="w-full h-full bg-gradient-to-br from-zinc-50 to-zinc-100 flex flex-col items-center justify-center gap-2">
|
||||
<span className="text-6xl opacity-20">{sportIcon(activity.activityType)}</span>
|
||||
<p className="text-sm font-semibold text-zinc-400 tracking-wide">Indoor Trainer Ride</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Metrics – always below the map/slider */}
|
||||
<div className="border-t border-zinc-100">
|
||||
{/* Hero row: Time · Distance · TSS */}
|
||||
<div className="grid grid-cols-3 divide-x divide-zinc-100">
|
||||
<div className="px-4 py-3 text-center">
|
||||
<p className="text-[9px] font-bold tracking-widest text-zinc-400 uppercase mb-1">Time</p>
|
||||
<p className="text-base font-bold tabular-nums text-zinc-900 leading-none">{formatDuration(activity.movingTimeS)}</p>
|
||||
</div>
|
||||
<div className="px-4 py-3 text-center">
|
||||
<p className="text-[9px] font-bold tracking-widest text-zinc-400 uppercase mb-1">Distance</p>
|
||||
<p className="text-base font-bold tabular-nums text-zinc-900 leading-none">{formatDistance(activity.distanceM)}</p>
|
||||
</div>
|
||||
<div className="px-4 py-3 text-center">
|
||||
<p className="text-[9px] font-bold tracking-widest text-zinc-400 uppercase mb-1">TSS</p>
|
||||
<p className={`text-base font-bold tabular-nums leading-none ${tssValue != null ? tssColor(tssValue) : 'text-zinc-900'}`}>
|
||||
{tssValue ?? '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Secondary metrics */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 border-t border-zinc-100">
|
||||
{[
|
||||
{label: speedPaceLabel, value: speedPaceValue},
|
||||
{label: 'Avg HR', value: heartrate != null ? `${heartrate} bpm` : '—'},
|
||||
...(isBike(activity.activityType) ? [{
|
||||
label: 'Avg Power',
|
||||
value: power != null ? `${power} W` : '—'
|
||||
}] : []),
|
||||
{label: 'kCal', value: kcal != null ? kcal.toLocaleString() : '—'},
|
||||
].map((metric, i) => (
|
||||
<div
|
||||
key={metric.label}
|
||||
className={[
|
||||
'px-4 py-2.5',
|
||||
i % 2 === 0 ? 'border-r border-zinc-100' : '',
|
||||
i >= 2 ? 'border-t border-zinc-100 sm:border-t-0' : '',
|
||||
].join(' ')}
|
||||
>
|
||||
<p className="text-[9px] font-bold tracking-widest text-zinc-400 uppercase mb-0.5">{metric.label}</p>
|
||||
<p className="text-sm font-semibold tabular-nums text-zinc-800">{metric.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer – always full-width */}
|
||||
<div className="px-5 pb-5 pt-4">
|
||||
<button className="w-full py-3 rounded-lg text-sm font-bold text-white bg-gradient-to-r from-red-500 to-orange-500 hover:from-red-600 hover:to-orange-600 transition-all shadow-sm hover:shadow-md">
|
||||
<button
|
||||
className="w-full py-3 rounded-lg text-sm font-bold text-white bg-gradient-to-r from-red-500 to-orange-500 hover:from-red-600 hover:to-orange-600 transition-all shadow-sm hover:shadow-md">
|
||||
View Details →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -129,7 +129,7 @@ function GoalPeriodColumn({title, subtitle, goals, actuals}: {
|
||||
actuals: Actuals
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-zinc-100 p-5 flex flex-col gap-4">
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-zinc-100 p-4 flex flex-col gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-bold text-zinc-900">{title}</p>
|
||||
<p className="text-[11px] text-zinc-400 mt-0.5">{subtitle}</p>
|
||||
@@ -154,14 +154,61 @@ function GoalPeriodColumn({title, subtitle, goals, actuals}: {
|
||||
|
||||
// ── DayCard ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function DayCard({day, activities, selectedActivityId, onActivityClick}: {
|
||||
function DayCard({day, activities, selectedActivityId, onActivityClick, expanded = false}: {
|
||||
day: Date
|
||||
activities: ActivitySummaryDo[]
|
||||
selectedActivityId: number | null
|
||||
onActivityClick: (activity: ActivitySummaryDo) => void
|
||||
expanded?: boolean
|
||||
}) {
|
||||
const today = isToday(day)
|
||||
const dayActivities = activities.filter((a) => isSameDay(new Date(a.startTime), day))
|
||||
|
||||
if (expanded) {
|
||||
return (
|
||||
<div className={[
|
||||
'rounded-xl border p-4',
|
||||
today ? 'border-orange-200 bg-orange-50/70' : 'bg-white border-zinc-100 shadow-sm',
|
||||
].join(' ')}>
|
||||
<div className="mb-3">
|
||||
<p className={['text-sm font-bold uppercase tracking-wide', today ? 'gradient-text' : 'text-zinc-700'].join(' ')}>
|
||||
{format(day, 'EEEE')}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-400">{format(day, 'd MMMM')}</p>
|
||||
</div>
|
||||
{dayActivities.length === 0 ? (
|
||||
<p className="text-sm text-zinc-300">Rest day</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{dayActivities.map((a) => {
|
||||
const isSelected = a.id === selectedActivityId
|
||||
return (
|
||||
<div
|
||||
key={String(a.id)}
|
||||
onClick={() => onActivityClick(a)}
|
||||
className={[
|
||||
'rounded-lg p-2.5 cursor-pointer transition-colors',
|
||||
isSelected ? 'bg-orange-100' : 'bg-zinc-50 hover:bg-zinc-100',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-lg leading-none">{sportIcon(a.activityType)}</span>
|
||||
<p className={['text-sm font-semibold truncate', isSelected ? 'text-orange-600' : 'text-zinc-800'].join(' ')}>
|
||||
{a.name}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-400 tabular-nums">
|
||||
{formatDistance(a.distanceM)} · {formatDuration(a.movingTimeS)}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={[
|
||||
'rounded-xl border flex flex-col p-2.5 min-h-27.5',
|
||||
@@ -238,9 +285,9 @@ function DailyBreakdown({weekDays, activities, selectedActivityId, onActivityCli
|
||||
<button
|
||||
onClick={() => setMobileDayIndex((i) => Math.max(0, i - 1))}
|
||||
disabled={mobileDayIndex === 0}
|
||||
className="p-2 rounded-xl bg-white border border-zinc-100 shadow-sm text-zinc-400 hover:text-zinc-700 disabled:opacity-25 disabled:cursor-not-allowed transition-colors shrink-0"
|
||||
className="w-11 h-11 flex items-center justify-center rounded-xl bg-white border border-zinc-100 shadow-sm text-zinc-400 hover:text-zinc-700 disabled:opacity-25 disabled:cursor-not-allowed transition-colors shrink-0"
|
||||
>
|
||||
<ChevronLeft size={18}/>
|
||||
<ChevronLeft size={20}/>
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<DayCard
|
||||
@@ -248,14 +295,15 @@ function DailyBreakdown({weekDays, activities, selectedActivityId, onActivityCli
|
||||
activities={activities}
|
||||
selectedActivityId={selectedActivityId}
|
||||
onActivityClick={onActivityClick}
|
||||
expanded
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setMobileDayIndex((i) => Math.min(weekDays.length - 1, i + 1))}
|
||||
disabled={mobileDayIndex === weekDays.length - 1}
|
||||
className="p-2 rounded-xl bg-white border border-zinc-100 shadow-sm text-zinc-400 hover:text-zinc-700 disabled:opacity-25 disabled:cursor-not-allowed transition-colors shrink-0"
|
||||
className="w-11 h-11 flex items-center justify-center rounded-xl bg-white border border-zinc-100 shadow-sm text-zinc-400 hover:text-zinc-700 disabled:opacity-25 disabled:cursor-not-allowed transition-colors shrink-0"
|
||||
>
|
||||
<ChevronRight size={18}/>
|
||||
<ChevronRight size={20}/>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
@@ -287,29 +335,6 @@ function TrendArrow({delta}: {delta: number}) {
|
||||
)
|
||||
}
|
||||
|
||||
// ── MetricCard ────────────────────────────────────────────────────────────────
|
||||
|
||||
function MetricCard({label, value, unit, sub, valueColor, trend}: {
|
||||
label: string
|
||||
value: string | number
|
||||
unit?: string
|
||||
sub?: string
|
||||
valueColor?: string
|
||||
trend?: number | null
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-zinc-100 p-5">
|
||||
<p className="text-[10px] font-bold tracking-widest text-zinc-400 uppercase mb-3">{label}</p>
|
||||
<p className="text-3xl font-bold leading-none" style={{color: valueColor ?? '#18181b'}}>
|
||||
{value}
|
||||
{unit && <span className="text-sm font-normal text-zinc-400 ml-1">{unit}</span>}
|
||||
{trend != null && <TrendArrow delta={trend}/>}
|
||||
</p>
|
||||
{sub && <p className="text-xs text-zinc-400 mt-1">{sub}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Load chart helpers ────────────────────────────────────────────────────────
|
||||
|
||||
type LoadPoint = {
|
||||
@@ -589,68 +614,96 @@ export function Dashboard({athleteId}: { athleteId: number }) {
|
||||
<section className="flex flex-col gap-4">
|
||||
<p className="text-[11px] font-bold tracking-widest text-zinc-400 uppercase">Training Analytics</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<MetricCard label="Fitness (CTL)" value={ctl} unit="TSS/day" sub="Chronic Training Load" valueColor="#ef4444" trend={ctlTrend}/>
|
||||
<MetricCard label="Fatigue (ATL)" value={atl} unit="TSS/day" sub="Acute Training Load" valueColor="#fdba74" trend={atlTrend}/>
|
||||
<MetricCard
|
||||
label="Form (TSB)"
|
||||
value={tsb}
|
||||
sub={Number(tsb) > 0 ? 'Fresh — ready to race' : 'Fatigued — building fitness'}
|
||||
valueColor="#52525b"
|
||||
trend={tsbTrend}
|
||||
/>
|
||||
{/* CTL / ATL / TSB — single compact card */}
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-zinc-100 p-4">
|
||||
<p className="text-[10px] font-bold tracking-widest text-zinc-400 uppercase mb-3">Fitness State</p>
|
||||
<div className="grid grid-cols-3 divide-x divide-zinc-100">
|
||||
<div className="pr-4">
|
||||
<p className="text-[9px] font-bold tracking-widest text-zinc-400 uppercase mb-1">Fitness</p>
|
||||
<p className="text-2xl font-bold tabular-nums leading-none" style={{color: '#ef4444'}}>
|
||||
{ctl}
|
||||
<span className="text-xs font-normal text-zinc-400 ml-1">CTL</span>
|
||||
{ctlTrend != null && <TrendArrow delta={ctlTrend}/>}
|
||||
</p>
|
||||
<p className="text-[10px] text-zinc-400 mt-1">TSS/day</p>
|
||||
</div>
|
||||
<div className="px-4">
|
||||
<p className="text-[9px] font-bold tracking-widest text-zinc-400 uppercase mb-1">Fatigue</p>
|
||||
<p className="text-2xl font-bold tabular-nums leading-none" style={{color: '#fdba74'}}>
|
||||
{atl}
|
||||
<span className="text-xs font-normal text-zinc-400 ml-1">ATL</span>
|
||||
{atlTrend != null && <TrendArrow delta={atlTrend}/>}
|
||||
</p>
|
||||
<p className="text-[10px] text-zinc-400 mt-1">TSS/day</p>
|
||||
</div>
|
||||
<div className="pl-4">
|
||||
<p className="text-[9px] font-bold tracking-widest text-zinc-400 uppercase mb-1">Form</p>
|
||||
<p className="text-2xl font-bold tabular-nums leading-none" style={{color: '#52525b'}}>
|
||||
{tsb}
|
||||
<span className="text-xs font-normal text-zinc-400 ml-1">TSB</span>
|
||||
{tsbTrend != null && <TrendArrow delta={tsbTrend}/>}
|
||||
</p>
|
||||
<p className="text-[10px] text-zinc-400 mt-1">
|
||||
{Number(tsb) > 0 ? 'Fresh' : 'Fatigued'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-zinc-100 p-5">
|
||||
<p className="text-[10px] font-bold tracking-widest text-zinc-400 uppercase mb-5">Weekly Training Load (TSS)</p>
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-zinc-100 p-4">
|
||||
<p className="text-[10px] font-bold tracking-widest text-zinc-400 uppercase mb-4">Weekly Training Load (TSS)</p>
|
||||
{loadData.some((d) => d.tss > 0) ? (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<ComposedChart data={loadData} margin={{left: -20, right: 10}} onClick={handleChartClick(loadData)} style={{cursor: 'pointer'}}>
|
||||
<defs>
|
||||
<linearGradient id="tssGrad" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stopColor="#ef4444"/>
|
||||
<stop offset="100%" stopColor="#f97316"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="tssAreaGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#ef4444" stopOpacity={0.12}/>
|
||||
<stop offset="100%" stopColor="#f97316" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis dataKey="week" tick={{fontSize: 10, fill: '#a1a1aa'}} axisLine={false} tickLine={false}/>
|
||||
<YAxis tick={{fontSize: 10, fill: '#a1a1aa'}} axisLine={false} tickLine={false}/>
|
||||
<Tooltip content={<LoadTooltip/>}/>
|
||||
<Area type="monotone" dataKey="tss" fill="url(#tssAreaGrad)" stroke="none"/>
|
||||
<Line type="monotone" dataKey="tss" stroke="url(#tssGrad)" strokeWidth={2} dot={false} name="TSS"/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="h-32 md:h-50">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={loadData} margin={{left: -20, right: 10}} onClick={handleChartClick(loadData)} style={{cursor: 'pointer'}}>
|
||||
<defs>
|
||||
<linearGradient id="tssGrad" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stopColor="#ef4444"/>
|
||||
<stop offset="100%" stopColor="#f97316"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="tssAreaGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#ef4444" stopOpacity={0.12}/>
|
||||
<stop offset="100%" stopColor="#f97316" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis dataKey="week" tick={{fontSize: 10, fill: '#a1a1aa'}} axisLine={false} tickLine={false}/>
|
||||
<YAxis tick={{fontSize: 10, fill: '#a1a1aa'}} axisLine={false} tickLine={false}/>
|
||||
<Tooltip content={<LoadTooltip/>}/>
|
||||
<Area type="monotone" dataKey="tss" fill="url(#tssAreaGrad)" stroke="none"/>
|
||||
<Line type="monotone" dataKey="tss" stroke="url(#tssGrad)" strokeWidth={2} dot={false} name="TSS"/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-50 flex items-center justify-center text-zinc-400 text-sm">No training data yet</div>
|
||||
<div className="h-32 md:h-50 flex items-center justify-center text-zinc-400 text-sm">No training data yet</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-zinc-100 p-5">
|
||||
<p className="text-[10px] font-bold tracking-widest text-zinc-400 uppercase mb-5">Fitness, Fatigue & Form (CTL / ATL / TSB)</p>
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-zinc-100 p-4">
|
||||
<p className="text-[10px] font-bold tracking-widest text-zinc-400 uppercase mb-4">Fitness, Fatigue & Form (CTL / ATL / TSB)</p>
|
||||
{ctlLoadData.some((d) => d.ctl !== null) ? (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<ComposedChart data={ctlLoadData} margin={{left: -20, right: 0}} onClick={handleChartClick(ctlLoadData)} style={{cursor: 'pointer'}}>
|
||||
<defs>
|
||||
<linearGradient id="tssAreaGrad2" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#ef4444" stopOpacity={0.12}/>
|
||||
<stop offset="100%" stopColor="#f97316" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis dataKey="week" tick={{fontSize: 10, fill: '#a1a1aa'}} axisLine={false} tickLine={false} padding={{left: 20, right: 20}}/>
|
||||
<YAxis yAxisId="left" domain={ctlDomain} tick={{fontSize: 10, fill: '#a1a1aa'}} axisLine={false} tickLine={false}/>
|
||||
<YAxis yAxisId="right" orientation="right" tick={{fontSize: 10, fill: '#a1a1aa'}} axisLine={false} tickLine={false}/>
|
||||
<Tooltip content={<LoadTooltip/>}/>
|
||||
<Area yAxisId="left" type="monotone" dataKey="ctl" fill="url(#tssAreaGrad2)" stroke="none" connectNulls/>
|
||||
<Line yAxisId="left" type="monotone" dataKey="ctl" stroke="#ef4444" strokeWidth={2} dot={false} name="CTL" connectNulls/>
|
||||
<Line yAxisId="left" type="monotone" dataKey="atl" stroke="#fdba74" strokeWidth={1.5} strokeDasharray="5 3" dot={false} name="ATL" connectNulls/>
|
||||
<Line yAxisId="right" type="monotone" dataKey="tsb" stroke="#52525b" strokeWidth={1.5} strokeDasharray="3 3" dot={false} name="TSB" connectNulls/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="h-32 md:h-50">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={ctlLoadData} margin={{left: -20, right: 0}} onClick={handleChartClick(ctlLoadData)} style={{cursor: 'pointer'}}>
|
||||
<defs>
|
||||
<linearGradient id="tssAreaGrad2" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#ef4444" stopOpacity={0.12}/>
|
||||
<stop offset="100%" stopColor="#f97316" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis dataKey="week" tick={{fontSize: 10, fill: '#a1a1aa'}} axisLine={false} tickLine={false} padding={{left: 20, right: 20}}/>
|
||||
<YAxis yAxisId="left" domain={ctlDomain} tick={{fontSize: 10, fill: '#a1a1aa'}} axisLine={false} tickLine={false}/>
|
||||
<YAxis yAxisId="right" orientation="right" tick={{fontSize: 10, fill: '#a1a1aa'}} axisLine={false} tickLine={false}/>
|
||||
<Tooltip content={<LoadTooltip/>}/>
|
||||
<Area yAxisId="left" type="monotone" dataKey="ctl" fill="url(#tssAreaGrad2)" stroke="none" connectNulls/>
|
||||
<Line yAxisId="left" type="monotone" dataKey="ctl" stroke="#ef4444" strokeWidth={2} dot={false} name="CTL" connectNulls/>
|
||||
<Line yAxisId="left" type="monotone" dataKey="atl" stroke="#fdba74" strokeWidth={1.5} strokeDasharray="5 3" dot={false} name="ATL" connectNulls/>
|
||||
<Line yAxisId="right" type="monotone" dataKey="tsb" stroke="#52525b" strokeWidth={1.5} strokeDasharray="3 3" dot={false} name="TSB" connectNulls/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-50 flex items-center justify-center text-zinc-400 text-sm">No fitness data yet</div>
|
||||
<div className="h-32 md:h-50 flex items-center justify-center text-zinc-400 text-sm">No fitness data yet</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user