feat(activity): add an activity calendar with personal baseline and goals

A week or month of steps, active minutes, workouts by sport and training
load, each day placed against the person's own spread over the 84 days
before the period and each period against the median of the twelve weeks
or six months before it. Optional weekly and monthly goals live on the
account (activity_goals_json), travel with the backup and are cleared by
a data wipe. The calendar sits on the workouts page; the steps page links
to it. Computed server-side without AI.
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 21:38:32 +02:00
1 parent e1b70537cc
commit 5eb7caebae
40 files changed
+4172 -6

No files matched your search

+49
View File
@@ -0,0 +1,49 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiGet, apiPatch } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import type { ActivityCalendarResponse } from "@/lib/validations/activity-calendar";
import type {
ActivityGoals,
ActivityGoalsInput,
} from "@/lib/validations/activity-goals";
/**
* `GET /api/activity/calendar` for one period. `date` null = the period that
* contains today. The server computes everything, the client only draws it.
*/
export function useActivityCalendar(
period: "week" | "month",
date: string | null,
opts?: { enabled?: boolean },
) {
return useQuery({
queryKey: queryKeys.activityCalendar(period, date),
queryFn: () => {
const params = new URLSearchParams({ period });
if (date) params.set("date", date);
return apiGet<ActivityCalendarResponse>(
`/api/activity/calendar?${params.toString()}`,
);
},
enabled: opts?.enabled,
staleTime: 60_000,
});
}
/** `PATCH /api/activity/goals`; refreshes every calendar cell on success. */
export function useSaveActivityGoals() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (patch: ActivityGoalsInput) =>
apiPatch<ActivityGoals>("/api/activity/goals", patch),
onSuccess: (goals) => {
queryClient.setQueryData(queryKeys.activityGoals(), goals);
void queryClient.invalidateQueries({
queryKey: queryKeys.activityCalendarAll(),
});
},
});
}