ActivitySummary

This commit is contained in:
2026-05-04 08:44:04 +02:00
parent 9a32ed88b3
commit f8ad8199b5
11 changed files with 106 additions and 12 deletions

View File

@@ -50,7 +50,7 @@ public class IntervalsActivityImporter
@SuppressWarnings("NullAway.Init") @SuppressWarnings("NullAway.Init")
IntervalsActivityImporter self; // CDI proxy — enables @Transactional on persistSingle() IntervalsActivityImporter self; // CDI proxy — enables @Transactional on persistSingle()
@ConfigProperty(name = "peakup.stream-import.delay-ms", defaultValue = "1000") @ConfigProperty(name = "peakup.stream-import.delay-ms", defaultValue = "200")
long streamImportDelayMs; long streamImportDelayMs;
@Override @Override

View File

@@ -2,6 +2,7 @@ package com.peakup.goal;
import com.peakup.common.AbstractResource; import com.peakup.common.AbstractResource;
import com.peakup.goal.model.Goal; import com.peakup.goal.model.Goal;
import com.peakup.goal.service.GoalMetricPeriodTotalService;
import com.peakup.goal.service.GoalService; import com.peakup.goal.service.GoalService;
import jakarta.annotation.Nullable; import jakarta.annotation.Nullable;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
@@ -34,6 +35,10 @@ public class GoalResource extends AbstractResource {
@SuppressWarnings("NullAway.Init") @SuppressWarnings("NullAway.Init")
GoalService goalService; GoalService goalService;
@Inject
@SuppressWarnings("NullAway.Init")
GoalMetricPeriodTotalService goalMetricPeriodTotalService;
@GET @GET
public RestResponse<List<Goal>> getByAthlete(@QueryParam("athleteId") @Nullable final Long athleteId) { public RestResponse<List<Goal>> getByAthlete(@QueryParam("athleteId") @Nullable final Long athleteId) {
if (athleteId == null) { if (athleteId == null) {
@@ -68,6 +73,16 @@ public class GoalResource extends AbstractResource {
} }
} }
@POST
@Path("/metric-totals/recalculate")
public RestResponse<Void> recalculateMetricTotals(@QueryParam("athleteId") @Nullable final Long athleteId) {
if (athleteId == null) {
throw new BadRequestException("athleteId query parameter is required");
}
goalMetricPeriodTotalService.deleteAndRecalculateByAthleteId(athleteId);
return RestResponse.noContent();
}
@DELETE @DELETE
@Path("/{id}") @Path("/{id}")
public RestResponse<Void> delete(@PathParam("id") final long id) { public RestResponse<Void> delete(@PathParam("id") final long id) {

View File

@@ -8,6 +8,7 @@ import com.peakup.goal.model.GoalPeriodEnum;
import jakarta.annotation.Nullable; import jakarta.annotation.Nullable;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
import jakarta.persistence.criteria.CriteriaBuilder; import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaDelete;
import jakarta.persistence.criteria.CriteriaQuery; import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.Root; import jakarta.persistence.criteria.Root;
@@ -72,6 +73,14 @@ public class GoalMetricPeriodTotalDAO extends AbstractDAO<GoalMetricPeriodTotal>
return em.createQuery(cq).getResultList(); return em.createQuery(cq).getResultList();
} }
public void deleteByAthleteId(final long athleteId) {
final CriteriaBuilder cb = em.getCriteriaBuilder();
final CriteriaDelete<GoalMetricPeriodTotal> cd = cb.createCriteriaDelete(GoalMetricPeriodTotal.class);
final Root<GoalMetricPeriodTotal> root = cd.from(GoalMetricPeriodTotal.class);
cd.where(cb.equal(root.get(GoalMetricPeriodTotal_.athleteId), athleteId));
em.createQuery(cd).executeUpdate();
}
public List<GoalMetricPeriodTotal> findByAthleteAndMetricAndPeriod( public List<GoalMetricPeriodTotal> findByAthleteAndMetricAndPeriod(
final long athleteId, final long athleteId,
final GoalMetricEnum metric, final GoalMetricEnum metric,

View File

@@ -88,6 +88,12 @@ public class GoalMetricPeriodTotalService extends AbstractService<GoalMetricPeri
return intervalsActivityService.getByAthleteIdBetween(athleteId, weekStart, weekEnd); return intervalsActivityService.getByAthleteIdBetween(athleteId, weekStart, weekEnd);
} }
@Transactional
public void deleteAndRecalculateByAthleteId(final long athleteId) {
goalMetricAggregateDAO.deleteByAthleteId(athleteId);
recalculateUpToCurrentWeek(athleteId);
}
public void recalculateUpToCurrentWeek(final long athleteId) { public void recalculateUpToCurrentWeek(final long athleteId) {
final LocalDate today = LocalDate.now(ZoneOffset.UTC); final LocalDate today = LocalDate.now(ZoneOffset.UTC);
final LocalDate startDate = resolveStartDate(athleteId); final LocalDate startDate = resolveStartDate(athleteId);

View File

@@ -19,7 +19,7 @@ quarkus.flyway.clean-at-start=false
strava-api/mp-rest/url=https://www.strava.com strava-api/mp-rest/url=https://www.strava.com
# intervals.icu # intervals.icu
intervals-api/mp-rest/url=https://intervals.icu intervals-api/mp-rest/url=https://intervals.icu
peakup.stream-import.delay-ms=1000 peakup.stream-import.delay-ms=200
peakup.notifications.enabled=false peakup.notifications.enabled=false
peakup.notifications.tss-high-threshold=70.0 peakup.notifications.tss-high-threshold=70.0
peakup.notifications.tss-very-high-threshold=150.0 peakup.notifications.tss-very-high-threshold=150.0

View File

@@ -10,6 +10,8 @@ export const goals = {
del<void>(`/api/goals/${id}`), del<void>(`/api/goals/${id}`),
getByAthlete: (athleteId?: number): Promise<GoalDo[]> => getByAthlete: (athleteId?: number): Promise<GoalDo[]> =>
get<GoalDo[]>(`/api/goals${buildQuery({ athleteId })}`), get<GoalDo[]>(`/api/goals${buildQuery({ athleteId })}`),
recalculateMetricTotals: (athleteId?: number): Promise<void> =>
post<void>(`/api/goals/metric-totals/recalculate${buildQuery({ athleteId })}`),
update: (id: number, body: GoalDo): Promise<GoalDo> => update: (id: number, body: GoalDo): Promise<GoalDo> =>
put<GoalDo>(`/api/goals/${id}`, body), put<GoalDo>(`/api/goals/${id}`, body),
}; };

View File

@@ -129,6 +129,15 @@ export function useGoalsGetByAthlete(athleteId?: number) {
}); });
} }
export function useGoalsRecalculateMetricTotals() {
const queryClient = useQueryClient();
return useMutation<void, Error, number>({
mutationFn: (athleteId) =>
goals.recalculateMetricTotals(athleteId),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['goals'] }),
});
}
export function useGoalsUpdate() { export function useGoalsUpdate() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<GoalDo, Error, { id: number; body: GoalDo }>({ return useMutation<GoalDo, Error, { id: number; body: GoalDo }>({

View File

@@ -292,10 +292,10 @@ function DayCard({
) : ( ) : (
<ul className="space-y-1"> <ul className="space-y-1">
{dayActivities.map((a) => { {dayActivities.map((a) => {
const isSelected = a.activityId === selectedActivityId const isSelected = a.id === selectedActivityId
return ( return (
<li <li
key={String(a.activityId)} key={String(a.id)}
onClick={() => onActivityClick(a)} onClick={() => onActivityClick(a)}
className={[ className={[
'flex items-center gap-1.5 rounded px-1 py-0.5 cursor-pointer transition-colors', 'flex items-center gap-1.5 rounded px-1 py-0.5 cursor-pointer transition-colors',
@@ -429,15 +429,15 @@ export function Dashboard({athleteId}: { athleteId: number }) {
.sort((a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime())[0] .sort((a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime())[0]
const selectedActivity = (selectedActivityId !== null const selectedActivity = (selectedActivityId !== null
? activities.find((a) => a.activityId === selectedActivityId) ? activities.find((a) => a.id === selectedActivityId)
: undefined) ?? lastWorkout : undefined) ?? lastWorkout
const selectedMapStream = selectedActivity != null const selectedMapStream = selectedActivity != null
? (weekActivityMapStreams[String(selectedActivity.activityId)] ?? null) ? (weekActivityMapStreams[String(selectedActivity.id)] ?? null)
: null : null
const selectedTimeSeries = selectedActivity != null const selectedTimeSeries = selectedActivity != null
? (weekActivityStreams[String(selectedActivity.activityId)] ?? []) ? (weekActivityStreams[String(selectedActivity.id)] ?? [])
: [] : []
const latest = [...activities] const latest = [...activities]
@@ -567,7 +567,7 @@ export function Dashboard({athleteId}: { athleteId: number }) {
day={day} day={day}
activities={selectedWeekActivities} activities={selectedWeekActivities}
selectedActivityId={selectedActivityId} selectedActivityId={selectedActivityId}
onActivityClick={(activity) => setSelectedActivityId(activity.activityId)} onActivityClick={(activity) => setSelectedActivityId(activity.id)}
/> />
))} ))}
</div> </div>

View File

@@ -150,6 +150,9 @@ export function Settings({ athleteId }: { athleteId: number }) {
const [errors, setErrors] = useState<AllErrors>(initAllErrors) const [errors, setErrors] = useState<AllErrors>(initAllErrors)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false) const [saved, setSaved] = useState(false)
const [recalculating, setRecalculating] = useState(false)
const [recalculated, setRecalculated] = useState(false)
const [recalcError, setRecalcError] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
setValues(initAllValues(goalList)) setValues(initAllValues(goalList))
@@ -162,6 +165,21 @@ export function Settings({ athleteId }: { athleteId: number }) {
setSaved(false) setSaved(false)
} }
async function handleRecalculate() {
setRecalculating(true)
setRecalculated(false)
setRecalcError(null)
try {
await goals.recalculateMetricTotals(athleteId)
await qc.invalidateQueries({ queryKey: queryKeys.goals.getByAthlete(athleteId) })
setRecalculated(true)
} catch {
setRecalcError('Recalculation failed — please retry')
} finally {
setRecalculating(false)
}
}
async function handleSave() { async function handleSave() {
// Validate everything first // Validate everything first
const newErrors = initAllErrors() const newErrors = initAllErrors()
@@ -263,6 +281,40 @@ export function Settings({ athleteId }: { athleteId: number }) {
</div> </div>
</section> </section>
<section className="flex flex-col gap-4">
<div className="border-b border-zinc-100 pb-3">
<h2 className="text-base font-semibold text-zinc-900">Data</h2>
<p className="text-sm text-zinc-400 mt-0.5">
Maintenance operations for your training data.
</p>
</div>
<div className="border border-zinc-200 rounded-sm bg-white p-6 flex flex-col gap-3">
<div>
<h3 className="text-sm font-semibold text-zinc-900">Recalculate Metric Period Totals</h3>
<p className="text-xs text-zinc-400 mt-0.5">
Deletes all aggregated metric totals and recalculates them from your activity history.
</p>
</div>
<div className="flex items-center gap-4">
<button
onClick={handleRecalculate}
disabled={recalculating}
className={[
'rounded-sm px-5 py-2 text-sm font-semibold transition-colors',
'bg-gradient-to-r from-red-500 to-orange-500 text-white',
'hover:from-red-600 hover:to-orange-600',
recalculating ? 'opacity-50 cursor-not-allowed' : '',
].join(' ')}
>
{recalculating ? 'Recalculating…' : 'Recalculate'}
</button>
{recalculated && <span className="text-xs text-zinc-500">Done</span>}
{recalcError && <span className="text-xs text-red-500">{recalcError}</span>}
</div>
</div>
</section>
</div> </div>
</div> </div>
) )

View File

@@ -22,7 +22,8 @@ export function formatPace(mps: number): string {
return `${minute}:${String(second).padStart(2, '0')} /km` return `${minute}:${String(second).padStart(2, '0')} /km`
} }
export function summaryNum(valuesForSummary: Record<string, string>, key: string): number | null { export function summaryNum(valuesForSummary: Record<string, string> | null, key: string): number | null {
if (valuesForSummary === null) return null
const raw = valuesForSummary[key] const raw = valuesForSummary[key]
if (raw === undefined) return null if (raw === undefined) return null
const parsed = parseFloat(raw) const parsed = parseFloat(raw)

View File

@@ -1,6 +1,6 @@
INSERT INTO athlete (id, first_name, last_name, login, weight, sex, created_at, modified_at) INSERT INTO athlete (id, first_name, last_name, login, weight, sex, email, created_at, modified_at)
VALUES (90, 'Ingo', 'Schnabel', 'ingo', 93, NULL, '2025-12-29 18:40:14.968397+00', '2025-12-29 18:40:14.968397+00'), VALUES (90, 'Ingo', 'Schnabel', 'ingo', 93, NULL, 'ingo.schnabel@gmail.com', '2025-12-29 18:40:14.968397+00', '2025-12-29 18:40:14.968397+00'),
(91, 'Mirja', 'Schnabel', 'mirja', 50, NULL, '2026-01-17 16:44:40.548585+00', '2026-01-17 16:44:40.548585+00'); (91, 'Mirja', 'Schnabel', 'mirja', 50, NULL, 'mirja.schnabel@gmail.com', '2026-01-17 16:44:40.548585+00', '2026-01-17 16:44:40.548585+00');
-- Strava Connectors -- Strava Connectors
INSERT INTO strava_connector (id, athlete_id, strava_athlete_id, access_token, refresh_token, expires_at, client_id, INSERT INTO strava_connector (id, athlete_id, strava_athlete_id, access_token, refresh_token, expires_at, client_id,