ActivitySummary
This commit is contained in:
@@ -50,7 +50,7 @@ public class IntervalsActivityImporter
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
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;
|
||||
|
||||
@Override
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.peakup.goal;
|
||||
|
||||
import com.peakup.common.AbstractResource;
|
||||
import com.peakup.goal.model.Goal;
|
||||
import com.peakup.goal.service.GoalMetricPeriodTotalService;
|
||||
import com.peakup.goal.service.GoalService;
|
||||
import jakarta.annotation.Nullable;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
@@ -34,6 +35,10 @@ public class GoalResource extends AbstractResource {
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
GoalService goalService;
|
||||
|
||||
@Inject
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
GoalMetricPeriodTotalService goalMetricPeriodTotalService;
|
||||
|
||||
@GET
|
||||
public RestResponse<List<Goal>> getByAthlete(@QueryParam("athleteId") @Nullable final Long athleteId) {
|
||||
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
|
||||
@Path("/{id}")
|
||||
public RestResponse<Void> delete(@PathParam("id") final long id) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.peakup.goal.model.GoalPeriodEnum;
|
||||
import jakarta.annotation.Nullable;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.persistence.criteria.CriteriaBuilder;
|
||||
import jakarta.persistence.criteria.CriteriaDelete;
|
||||
import jakarta.persistence.criteria.CriteriaQuery;
|
||||
import jakarta.persistence.criteria.Root;
|
||||
|
||||
@@ -72,6 +73,14 @@ public class GoalMetricPeriodTotalDAO extends AbstractDAO<GoalMetricPeriodTotal>
|
||||
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(
|
||||
final long athleteId,
|
||||
final GoalMetricEnum metric,
|
||||
|
||||
@@ -88,6 +88,12 @@ public class GoalMetricPeriodTotalService extends AbstractService<GoalMetricPeri
|
||||
return intervalsActivityService.getByAthleteIdBetween(athleteId, weekStart, weekEnd);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteAndRecalculateByAthleteId(final long athleteId) {
|
||||
goalMetricAggregateDAO.deleteByAthleteId(athleteId);
|
||||
recalculateUpToCurrentWeek(athleteId);
|
||||
}
|
||||
|
||||
public void recalculateUpToCurrentWeek(final long athleteId) {
|
||||
final LocalDate today = LocalDate.now(ZoneOffset.UTC);
|
||||
final LocalDate startDate = resolveStartDate(athleteId);
|
||||
|
||||
@@ -19,7 +19,7 @@ quarkus.flyway.clean-at-start=false
|
||||
strava-api/mp-rest/url=https://www.strava.com
|
||||
# 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.tss-high-threshold=70.0
|
||||
peakup.notifications.tss-very-high-threshold=150.0
|
||||
|
||||
@@ -10,6 +10,8 @@ export const goals = {
|
||||
del<void>(`/api/goals/${id}`),
|
||||
getByAthlete: (athleteId?: number): Promise<GoalDo[]> =>
|
||||
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> =>
|
||||
put<GoalDo>(`/api/goals/${id}`, body),
|
||||
};
|
||||
|
||||
@@ -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() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<GoalDo, Error, { id: number; body: GoalDo }>({
|
||||
|
||||
@@ -292,10 +292,10 @@ function DayCard({
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{dayActivities.map((a) => {
|
||||
const isSelected = a.activityId === selectedActivityId
|
||||
const isSelected = a.id === selectedActivityId
|
||||
return (
|
||||
<li
|
||||
key={String(a.activityId)}
|
||||
key={String(a.id)}
|
||||
onClick={() => onActivityClick(a)}
|
||||
className={[
|
||||
'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]
|
||||
|
||||
const selectedActivity = (selectedActivityId !== null
|
||||
? activities.find((a) => a.activityId === selectedActivityId)
|
||||
? activities.find((a) => a.id === selectedActivityId)
|
||||
: undefined) ?? lastWorkout
|
||||
|
||||
const selectedMapStream = selectedActivity != null
|
||||
? (weekActivityMapStreams[String(selectedActivity.activityId)] ?? null)
|
||||
? (weekActivityMapStreams[String(selectedActivity.id)] ?? null)
|
||||
: null
|
||||
|
||||
const selectedTimeSeries = selectedActivity != null
|
||||
? (weekActivityStreams[String(selectedActivity.activityId)] ?? [])
|
||||
? (weekActivityStreams[String(selectedActivity.id)] ?? [])
|
||||
: []
|
||||
|
||||
const latest = [...activities]
|
||||
@@ -567,7 +567,7 @@ export function Dashboard({athleteId}: { athleteId: number }) {
|
||||
day={day}
|
||||
activities={selectedWeekActivities}
|
||||
selectedActivityId={selectedActivityId}
|
||||
onActivityClick={(activity) => setSelectedActivityId(activity.activityId)}
|
||||
onActivityClick={(activity) => setSelectedActivityId(activity.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -150,6 +150,9 @@ export function Settings({ athleteId }: { athleteId: number }) {
|
||||
const [errors, setErrors] = useState<AllErrors>(initAllErrors)
|
||||
const [saving, setSaving] = 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(() => {
|
||||
setValues(initAllValues(goalList))
|
||||
@@ -162,6 +165,21 @@ export function Settings({ athleteId }: { athleteId: number }) {
|
||||
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() {
|
||||
// Validate everything first
|
||||
const newErrors = initAllErrors()
|
||||
@@ -263,6 +281,40 @@ export function Settings({ athleteId }: { athleteId: number }) {
|
||||
</div>
|
||||
</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>
|
||||
)
|
||||
|
||||
@@ -22,7 +22,8 @@ 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 {
|
||||
export function summaryNum(valuesForSummary: Record<string, string> | null, key: string): number | null {
|
||||
if (valuesForSummary === null) return null
|
||||
const raw = valuesForSummary[key]
|
||||
if (raw === undefined) return null
|
||||
const parsed = parseFloat(raw)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
INSERT INTO athlete (id, first_name, last_name, login, weight, sex, 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'),
|
||||
(91, 'Mirja', 'Schnabel', 'mirja', 50, NULL, '2026-01-17 16:44:40.548585+00', '2026-01-17 16:44:40.548585+00');
|
||||
INSERT INTO athlete (id, first_name, last_name, login, weight, sex, email, created_at, modified_at)
|
||||
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, 'mirja.schnabel@gmail.com', '2026-01-17 16:44:40.548585+00', '2026-01-17 16:44:40.548585+00');
|
||||
|
||||
-- Strava Connectors
|
||||
INSERT INTO strava_connector (id, athlete_id, strava_athlete_id, access_token, refresh_token, expires_at, client_id,
|
||||
|
||||
Reference in New Issue
Block a user