ActivitySummary
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
package com.peakup.activity.mapper;
|
||||
|
||||
import com.peakup.activity.model.ActivitySummary;
|
||||
import com.peakup.activity.model.IntervalsActivity;
|
||||
import jakarta.annotation.Nullable;
|
||||
import org.mapstruct.AfterMapping;
|
||||
import org.mapstruct.Mapper;
|
||||
import org.mapstruct.Mapping;
|
||||
import org.mapstruct.MappingConstants;
|
||||
import org.mapstruct.MappingTarget;
|
||||
import org.mapstruct.ReportingPolicy;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@SuppressWarnings("NullableProblems")
|
||||
@Mapper(
|
||||
componentModel = MappingConstants.ComponentModel.CDI,
|
||||
unmappedTargetPolicy = ReportingPolicy.ERROR,
|
||||
unmappedSourcePolicy = ReportingPolicy.IGNORE
|
||||
)
|
||||
public interface ActivitySummaryMapper {
|
||||
|
||||
private static void putIfNonZero(final Map<String, String> map, final String key, final @Nullable Double value) {
|
||||
if (value != null && value != 0.0) {
|
||||
map.put(key, String.valueOf(value));
|
||||
}
|
||||
}
|
||||
|
||||
@Mapping(target = "activityId", expression = "java(source.getId() != null ? source.getId() : 0L)")
|
||||
@Mapping(target = "valuesForSummary", ignore = true)
|
||||
ActivitySummary toSummary(IntervalsActivity source);
|
||||
|
||||
@AfterMapping
|
||||
default void populateValuesForSummary(@MappingTarget final ActivitySummary target, final IntervalsActivity source) {
|
||||
final Map<String, String> values = new HashMap<>();
|
||||
putIfNonZero(values, "distanceM", source.getDistanceM());
|
||||
putIfNonZero(values, "averageHeartrateBpm", source.getAverageHeartrateBpm());
|
||||
putIfNonZero(values, "averagePowerWatt", source.getAveragePowerWatt());
|
||||
putIfNonZero(values, "normalizedPowerWatt", source.getNormalizedPowerWatt());
|
||||
putIfNonZero(values, "trainingLoad", source.getTrainingLoad());
|
||||
putIfNonZero(values, "ctl", source.getCtl());
|
||||
putIfNonZero(values, "atl", source.getAtl());
|
||||
putIfNonZero(values, "averageSpeedMps", source.getAverageSpeedMps());
|
||||
putIfNonZero(values, "paceMps", source.getPaceMps());
|
||||
target.setValuesForSummary(values);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
@com.peakup.common.annotation.NonNull
|
||||
package com.peakup.activity.mapper;
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.peakup.activity.model;
|
||||
|
||||
import com.peakup.common.model.AbstractDataObject;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
public class ActivitySummary extends AbstractDataObject {
|
||||
|
||||
public ActivityTypeEnum activityType;
|
||||
public Instant startTime;
|
||||
public Long movingTimeS;
|
||||
public Double calories;
|
||||
private long activityId;
|
||||
private Long athleteId;
|
||||
private String name;
|
||||
private Map<String, String> valuesForSummary;
|
||||
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
public ActivitySummary() {
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ package com.peakup.dashboard.model;
|
||||
|
||||
import com.peakup.activity.model.ActivityMapStreamPoint;
|
||||
import com.peakup.activity.model.ActivityStreamPoint;
|
||||
import com.peakup.activity.model.IntervalsActivity;
|
||||
import com.peakup.activity.model.ActivitySummary;
|
||||
import com.peakup.common.model.AbstractDataObject;
|
||||
import com.peakup.goal.model.Goal;
|
||||
import com.peakup.goal.model.GoalMetricPeriodTotal;
|
||||
@@ -20,7 +20,7 @@ public class Dashboard extends AbstractDataObject {
|
||||
|
||||
private int calendarWeek;
|
||||
private int year;
|
||||
private List<IntervalsActivity> activities;
|
||||
private List<ActivitySummary> activities;
|
||||
private List<Goal> goals;
|
||||
private List<GoalMetricPeriodTotal> goalMetricPeriodTotals;
|
||||
private Map<Long, List<ActivityMapStreamPoint>> weekActivityMapStreams;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.peakup.dashboard.service;
|
||||
|
||||
import com.peakup.activity.mapper.ActivitySummaryMapper;
|
||||
import com.peakup.activity.model.ActivityMapStreamPoint;
|
||||
import com.peakup.activity.model.ActivityStreamPoint;
|
||||
import com.peakup.activity.model.ActivitySummary;
|
||||
import com.peakup.activity.model.IntervalsActivity;
|
||||
import com.peakup.activity.service.ActivityMapStreamService;
|
||||
import com.peakup.activity.service.ActivityStreamService;
|
||||
@@ -46,6 +48,10 @@ public class DashboardService {
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
GoalMetricPeriodTotalService goalMetricPeriodTotalService;
|
||||
|
||||
@Inject
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
ActivitySummaryMapper activitySummaryMapper;
|
||||
|
||||
@Transactional
|
||||
public Dashboard getDashboard(final long athleteId, final int calendarWeek, final int year) {
|
||||
final LocalDate selectedWeekMonday = LocalDate.now()
|
||||
@@ -64,11 +70,14 @@ public class DashboardService {
|
||||
resolveWeekActivityMapStreams(activities, selectedWeekMonday);
|
||||
final Map<Long, List<ActivityStreamPoint>> weekActivityStreams =
|
||||
resolveWeekActivityStreams(activities, selectedWeekMonday);
|
||||
final List<ActivitySummary> activitySummaries = activities.stream()
|
||||
.map(activitySummaryMapper::toSummary)
|
||||
.toList();
|
||||
|
||||
return new Dashboard(
|
||||
calendarWeek,
|
||||
year,
|
||||
activities,
|
||||
activitySummaries,
|
||||
goalService.getByAthleteId(athleteId),
|
||||
goalMetricPeriodTotalService.getByAthleteAndWeek(athleteId, year, calendarWeek),
|
||||
weekActivityMapStreams,
|
||||
|
||||
@@ -84,11 +84,11 @@ class ArchitectureTest {
|
||||
* Passes vacuously today — enforces the rule as mappers are added (see §3 connector structure).
|
||||
*/
|
||||
@Test
|
||||
void mappersInConnectorMapperPackage() {
|
||||
void mappersInMapperPackage() {
|
||||
classes().that().haveSimpleNameEndingWith("Mapper")
|
||||
.and().resideInAPackage("com.peakup..")
|
||||
.should().resideInAPackage("com.peakup.connector..mapper..")
|
||||
.because("Mapper classes must live in connector/<name>/mapper/")
|
||||
.should().resideInAPackage("com.peakup..mapper..")
|
||||
.because("Mapper classes must live in /<name>/mapper/")
|
||||
.allowEmptyShould(true)
|
||||
.check(CLASSES);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import {format} from 'date-fns'
|
||||
import {useEffect, useRef, useState} from 'react'
|
||||
import type {ActivityMapStreamPointDo} from '@/types/ActivityMapStreamPointDo'
|
||||
import type {ActivityStreamPointDo} from '@/types/ActivityStreamPointDo'
|
||||
import type {IntervalsActivityDo} from '@/types/IntervalsActivityDo'
|
||||
import {formatDistance, formatDuration, formatPace, formatSpeed, sportIcon} from '@/utils/activity'
|
||||
import type {ActivitySummaryDo} from '@/types/ActivitySummaryDo'
|
||||
import {formatDistance, formatDuration, formatPace, formatSpeed, sportIcon, summaryNum} from '@/utils/activity'
|
||||
|
||||
type PointInfo = {
|
||||
speed: number
|
||||
@@ -207,31 +207,36 @@ export function LastWorkoutCard({
|
||||
mapStream,
|
||||
timeSeries = [],
|
||||
}: {
|
||||
activity: IntervalsActivityDo
|
||||
activity: ActivitySummaryDo
|
||||
mapStream: ActivityMapStreamPointDo[] | null
|
||||
timeSeries?: ActivityStreamPointDo[]
|
||||
}) {
|
||||
const startDate = new Date(activity.startTime)
|
||||
const dateLabel = format(startDate, 'EEE d MMM yyyy · HH:mm')
|
||||
const hasMap = (isBike(activity.type) || isRun(activity.type)) && mapStream != null && mapStream.length > 0
|
||||
const hasMap = (isBike(activity.activityType) || isRun(activity.activityType)) && mapStream != null && mapStream.length > 0
|
||||
|
||||
const tssValue = activity.trainingLoad > 0 ? Math.round(activity.trainingLoad) : null
|
||||
const trainingLoad = summaryNum(activity.valuesForSummary, 'trainingLoad')
|
||||
const tssValue = trainingLoad != null && trainingLoad > 0 ? Math.round(trainingLoad) : null
|
||||
const kcal = activity.calories > 0 ? Math.round(activity.calories) : null
|
||||
const heartrate = activity.averageHeartrateBpm > 0 ? Math.round(activity.averageHeartrateBpm) : null
|
||||
const power = activity.averagePowerWatt > 0 ? Math.round(activity.averagePowerWatt) : null
|
||||
const averageHeartrateBpm = summaryNum(activity.valuesForSummary, 'averageHeartrateBpm')
|
||||
const heartrate = averageHeartrateBpm != null && averageHeartrateBpm > 0 ? Math.round(averageHeartrateBpm) : null
|
||||
const averagePowerWatt = summaryNum(activity.valuesForSummary, 'averagePowerWatt')
|
||||
const power = averagePowerWatt != null && averagePowerWatt > 0 ? Math.round(averagePowerWatt) : null
|
||||
|
||||
const speedPaceLabel = isRun(activity.type) ? 'Avg Pace' : 'Avg Speed'
|
||||
const speedPaceValue = isRun(activity.type)
|
||||
? (activity.paceMps != null && activity.paceMps > 0 ? formatPace(activity.paceMps) : formatPace(activity.averageSpeedMps))
|
||||
: formatSpeed(activity.averageSpeedMps)
|
||||
const speedPaceLabel = isRun(activity.activityType) ? 'Avg Pace' : 'Avg Speed'
|
||||
const paceMps = summaryNum(activity.valuesForSummary, 'paceMps')
|
||||
const averageSpeedMps = summaryNum(activity.valuesForSummary, 'averageSpeedMps') ?? 0
|
||||
const speedPaceValue = isRun(activity.activityType)
|
||||
? (paceMps != null && paceMps > 0 ? formatPace(paceMps) : formatPace(averageSpeedMps))
|
||||
: formatSpeed(averageSpeedMps)
|
||||
|
||||
const sportBadgeLabel = isBike(activity.type) ? 'Ride' : isRun(activity.type) ? 'Run' : null
|
||||
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.type) ? [{label: 'Avg Power', value: power != null ? `${power} W` : '—'}] : []),
|
||||
...(isBike(activity.activityType) ? [{label: 'Avg Power', value: power != null ? `${power} W` : '—'}] : []),
|
||||
{label: 'kCal', value: kcal != null ? kcal.toLocaleString() : '—'},
|
||||
]
|
||||
|
||||
@@ -240,7 +245,7 @@ export function LastWorkoutCard({
|
||||
{/* 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">
|
||||
<span className="text-2xl leading-none shrink-0">{sportIcon(activity.type)}</span>
|
||||
<span className="text-2xl leading-none shrink-0">{sportIcon(activity.activityType)}</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-zinc-900 leading-tight truncate">{activity.name}</p>
|
||||
<p className="text-xs text-zinc-400 mt-0.5">{dateLabel}</p>
|
||||
@@ -261,12 +266,12 @@ export function LastWorkoutCard({
|
||||
<ActivityRouteMap
|
||||
points={mapStream!}
|
||||
timeSeries={timeSeries}
|
||||
isRun={isRun(activity.type)}
|
||||
isBike={isBike(activity.type)}
|
||||
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.type)}</span>
|
||||
<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>
|
||||
)}
|
||||
@@ -281,7 +286,7 @@ export function LastWorkoutCard({
|
||||
<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(activity.distanceM)}</p>
|
||||
<p className="text-2xl font-bold text-zinc-900 tabular-nums leading-none">{formatDistance(summaryNum(activity.valuesForSummary, 'distanceM') ?? 0)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -15,12 +15,12 @@ import {useSearchParams} from 'react-router-dom'
|
||||
import {Area, ComposedChart, Line, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts'
|
||||
import type {MouseHandlerDataParam} from 'recharts'
|
||||
import {useDashboardGetDashboard} from '@/api/hooks'
|
||||
import type {IntervalsActivityDo} from '@/types/IntervalsActivityDo'
|
||||
import type {ActivitySummaryDo} from '@/types/ActivitySummaryDo'
|
||||
import type {GoalDo} from '@/types/GoalDo'
|
||||
import type {GoalMetricEnum} from '@/types/GoalMetricEnum'
|
||||
import type {GoalMetricPeriodTotalDo} from '@/types/GoalMetricPeriodTotalDo'
|
||||
import type {GoalPeriodEnum} from '@/types/GoalPeriodEnum'
|
||||
import {formatDistance, formatDuration, sportIcon} from '@/utils/activity'
|
||||
import {formatDistance, formatDuration, sportIcon, summaryNum} from '@/utils/activity'
|
||||
import {LastWorkoutCard} from '@/components/dashboard/LastWorkoutCard'
|
||||
|
||||
// ── Week helpers ──────────────────────────────────────────────────────────────
|
||||
@@ -218,17 +218,18 @@ type LoadPoint = {
|
||||
tsb: number | null
|
||||
}
|
||||
|
||||
function buildLoadData(activities: IntervalsActivityDo[], anchor: Date): LoadPoint[] {
|
||||
function buildLoadData(activities: ActivitySummaryDo[], anchor: Date): LoadPoint[] {
|
||||
const anchorWeekStart = startOfISOWeek(anchor)
|
||||
const weekMondays: Date[] = Array.from({length: 12}, (_, i) =>
|
||||
addWeeks(anchorWeekStart, i - 11),
|
||||
)
|
||||
const tssMap: Record<number, number> = {}
|
||||
activities.forEach((a) => {
|
||||
if (a.trainingLoad <= 0) return
|
||||
const trainingLoad = summaryNum(a.valuesForSummary, 'trainingLoad')
|
||||
if (trainingLoad === null || trainingLoad <= 0) return
|
||||
const monday = startOfISOWeek(new Date(a.startTime))
|
||||
const key = monday.getTime()
|
||||
tssMap[key] = (tssMap[key] ?? 0) + Math.round(a.trainingLoad)
|
||||
tssMap[key] = (tssMap[key] ?? 0) + Math.round(trainingLoad)
|
||||
})
|
||||
const sorted = [...activities].sort(
|
||||
(a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime(),
|
||||
@@ -240,8 +241,10 @@ function buildLoadData(activities: IntervalsActivityDo[], anchor: Date): LoadPoi
|
||||
const key = monday.getTime()
|
||||
for (let i = sorted.length - 1; i >= 0; i--) {
|
||||
if (new Date(sorted[i].startTime) <= weekEnd) {
|
||||
lastCtl = Math.round(sorted[i].ctl)
|
||||
lastAtl = Math.round(sorted[i].atl)
|
||||
const ctl = summaryNum(sorted[i].valuesForSummary, 'ctl')
|
||||
const atl = summaryNum(sorted[i].valuesForSummary, 'atl')
|
||||
lastCtl = ctl !== null ? Math.round(ctl) : null
|
||||
lastAtl = atl !== null ? Math.round(atl) : null
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -261,9 +264,9 @@ function DayCard({
|
||||
onActivityClick,
|
||||
}: {
|
||||
day: Date
|
||||
activities: IntervalsActivityDo[]
|
||||
activities: ActivitySummaryDo[]
|
||||
selectedActivityId: number | null
|
||||
onActivityClick: (activity: IntervalsActivityDo) => void
|
||||
onActivityClick: (activity: ActivitySummaryDo) => void
|
||||
}) {
|
||||
const today = isToday(day)
|
||||
const dayActivities = activities.filter((a) => isSameDay(new Date(a.startTime), day))
|
||||
@@ -289,10 +292,10 @@ function DayCard({
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{dayActivities.map((a) => {
|
||||
const isSelected = a.id === selectedActivityId
|
||||
const isSelected = a.activityId === selectedActivityId
|
||||
return (
|
||||
<li
|
||||
key={a.originId}
|
||||
key={String(a.activityId)}
|
||||
onClick={() => onActivityClick(a)}
|
||||
className={[
|
||||
'flex items-center gap-1.5 rounded px-1 py-0.5 cursor-pointer transition-colors',
|
||||
@@ -301,14 +304,14 @@ function DayCard({
|
||||
: 'hover:bg-zinc-50',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-sm leading-none">{sportIcon(a.type)}</span>
|
||||
<span className="text-sm leading-none">{sportIcon(a.activityType)}</span>
|
||||
<div className="min-w-0">
|
||||
<p className={[
|
||||
'text-xs font-medium truncate leading-none',
|
||||
isSelected ? 'text-orange-600' : 'text-zinc-700',
|
||||
].join(' ')}>{a.name}</p>
|
||||
<p className="text-xs text-zinc-400">
|
||||
{formatDistance(a.distanceM)} · {formatDuration(a.movingTimeS)}
|
||||
{formatDistance(summaryNum(a.valuesForSummary, 'distanceM') ?? 0)} · {formatDuration(a.movingTimeS)}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
@@ -426,24 +429,26 @@ export function Dashboard({athleteId}: { athleteId: number }) {
|
||||
.sort((a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime())[0]
|
||||
|
||||
const selectedActivity = (selectedActivityId !== null
|
||||
? activities.find((a) => a.id === selectedActivityId)
|
||||
? activities.find((a) => a.activityId === selectedActivityId)
|
||||
: undefined) ?? lastWorkout
|
||||
|
||||
const selectedMapStream = selectedActivity?.id != null
|
||||
? (weekActivityMapStreams[String(selectedActivity.id)] ?? null)
|
||||
const selectedMapStream = selectedActivity != null
|
||||
? (weekActivityMapStreams[String(selectedActivity.activityId)] ?? null)
|
||||
: null
|
||||
|
||||
const selectedTimeSeries = selectedActivity?.id != null
|
||||
? (weekActivityStreams[String(selectedActivity.id)] ?? [])
|
||||
const selectedTimeSeries = selectedActivity != null
|
||||
? (weekActivityStreams[String(selectedActivity.activityId)] ?? [])
|
||||
: []
|
||||
|
||||
const latest = [...activities]
|
||||
.filter((a) => new Date(a.startTime) <= wEnd)
|
||||
.sort((a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime())[0]
|
||||
|
||||
const ctl = latest?.ctl?.toFixed(0) ?? '—'
|
||||
const atl = latest?.atl?.toFixed(0) ?? '—'
|
||||
const tsb = latest ? ((latest.ctl ?? 0) - (latest.atl ?? 0)).toFixed(0) : '—'
|
||||
const latestCtl = latest != null ? summaryNum(latest.valuesForSummary, 'ctl') : null
|
||||
const latestAtl = latest != null ? summaryNum(latest.valuesForSummary, 'atl') : null
|
||||
const ctl = latestCtl != null ? latestCtl.toFixed(0) : '—'
|
||||
const atl = latestAtl != null ? latestAtl.toFixed(0) : '—'
|
||||
const tsb = latestCtl != null && latestAtl != null ? (latestCtl - latestAtl).toFixed(0) : '—'
|
||||
|
||||
const loadData = buildLoadData(activities, wStart)
|
||||
|
||||
@@ -562,7 +567,7 @@ export function Dashboard({athleteId}: { athleteId: number }) {
|
||||
day={day}
|
||||
activities={selectedWeekActivities}
|
||||
selectedActivityId={selectedActivityId}
|
||||
onActivityClick={(activity) => setSelectedActivityId(activity.id ?? null)}
|
||||
onActivityClick={(activity) => setSelectedActivityId(activity.activityId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
15
peakup-frontend/src/types/ActivitySummaryDo.ts
Normal file
15
peakup-frontend/src/types/ActivitySummaryDo.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// GENERATED — do not edit manually.
|
||||
|
||||
import type { AbstractDataObjectDo } from './AbstractDataObjectDo';
|
||||
import type { ActivityTypeEnum } from './ActivityTypeEnum';
|
||||
|
||||
export interface ActivitySummaryDo extends AbstractDataObjectDo {
|
||||
activityType: ActivityTypeEnum;
|
||||
startTime: string;
|
||||
movingTimeS: number;
|
||||
calories: number;
|
||||
activityId: number;
|
||||
athleteId: number;
|
||||
name: string;
|
||||
valuesForSummary: Record<string, string>;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// GENERATED — do not edit manually.
|
||||
|
||||
import type { AbstractDataObjectDo } from './AbstractDataObjectDo';
|
||||
import type { IntervalsActivityDo } from './IntervalsActivityDo';
|
||||
import type { ActivitySummaryDo } from './ActivitySummaryDo';
|
||||
import type { GoalDo } from './GoalDo';
|
||||
import type { GoalMetricPeriodTotalDo } from './GoalMetricPeriodTotalDo';
|
||||
import type { ActivityMapStreamPointDo } from './ActivityMapStreamPointDo';
|
||||
@@ -10,7 +10,7 @@ import type { ActivityStreamPointDo } from './ActivityStreamPointDo';
|
||||
export interface DashboardDo extends AbstractDataObjectDo {
|
||||
calendarWeek: number;
|
||||
year: number;
|
||||
activities: IntervalsActivityDo[];
|
||||
activities: ActivitySummaryDo[];
|
||||
goals: GoalDo[];
|
||||
goalMetricPeriodTotals: GoalMetricPeriodTotalDo[];
|
||||
weekActivityMapStreams: Record<string, ActivityMapStreamPointDo[]>;
|
||||
|
||||
@@ -22,6 +22,13 @@ export function formatPace(mps: number): string {
|
||||
return `${minute}:${String(second).padStart(2, '0')} /km`
|
||||
}
|
||||
|
||||
export function summaryNum(valuesForSummary: Record<string, string>, key: string): number | null {
|
||||
const raw = valuesForSummary[key]
|
||||
if (raw === undefined) return null
|
||||
const parsed = parseFloat(raw)
|
||||
return isNaN(parsed) ? null : parsed
|
||||
}
|
||||
|
||||
export function sportIcon(type: string): string {
|
||||
const lowered = type.toLowerCase()
|
||||
if (lowered.includes('ride') || lowered.includes('cycling')) return '🚴'
|
||||
|
||||
Reference in New Issue
Block a user