ActivitySummary
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 }>({
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user