ActivitySummary

This commit is contained in:
2026-04-25 22:56:09 +02:00
parent 0d7b563220
commit bcad934b70
11 changed files with 167 additions and 48 deletions

View File

@@ -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);
}
}

View File

@@ -0,0 +1,2 @@
@com.peakup.common.annotation.NonNull
package com.peakup.activity.mapper;

View File

@@ -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() {
}
}

View File

@@ -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;

View File

@@ -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,

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -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>

View 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>;
}

View File

@@ -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[]>;

View File

@@ -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 '🚴'