Files
HealthLog/src/hooks/use-workouts.ts
T
Marc-André Bombeck a8149e0bdf fix(ux): honest deletes, stable hydration and tidy numbers after release review
Server and first client render now agree everywhere query state used to
leak into the chrome, checkboxes no longer nest inside buttons, delete
confirmations admit the undo they offer, raw step counts drop their
decimals, the truncated unit label wraps instead, streaks know their
singular, the medication header speaks the user's date format, a failed
coach turn keeps the user's message and says what went wrong, settings
status notes follow a language switch, the locale only flips once its
bundle actually loaded, and the last raw api fetches in hooks move onto
the shared client.
2026-06-11 03:30:18 +02:00

156 lines
4.2 KiB
TypeScript

"use client";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
/**
* v1.4.32 — TanStack Query wrapper for `GET /api/workouts`.
*
* Consumers:
* - `/insights/workouts` — the workout list page reads the most
* recent N workouts and renders them as one-line rows.
* - `<RecentWorkoutsTile>` — the dashboard tile reads the top 3.
*
* Cache key shape:
* - `["workouts", "recent", { limit, since }]` — keyed on the
* pagination + filter inputs so the recent-tile and the list page
* keep separate cache slots without colliding.
* - `["workouts", id]` — single-workout detail (the workout-detail
* page consumes this separately via `useWorkoutDetail`).
*/
export interface WorkoutListEntry {
id: string;
sportType: string;
startedAt: string;
endedAt: string;
durationSec: number;
distanceM: number | null;
activeEnergyKcal: number | null;
avgHr: number | null;
maxHr: number | null;
source: string;
externalId: string | null;
}
export interface WorkoutListMeta {
total: number;
limit: number;
offset: number;
droppedDuplicates: number;
}
export interface WorkoutListPayload {
workouts: WorkoutListEntry[];
meta: WorkoutListMeta;
}
export interface UseWorkoutsOptions {
/** Page size — defaults to 50, capped at 200 by the server. */
limit?: number;
/** Page offset — defaults to 0. */
offset?: number;
/**
* ISO timestamp — only workouts with `startedAt >= since` are
* returned. The dashboard tile passes a 7-day window; the list page
* leaves it open so the user can scroll back through history.
*/
since?: string;
/** Optional sport-type filter. Pass through the canonical string. */
sportType?: string;
}
async function fetchWorkouts(
opts: UseWorkoutsOptions,
): Promise<WorkoutListPayload> {
const params = new URLSearchParams();
if (opts.limit != null) params.set("limit", String(opts.limit));
if (opts.offset != null) params.set("offset", String(opts.offset));
if (opts.since) params.set("since", opts.since);
if (opts.sportType) params.set("sportType", opts.sportType);
const query = params.toString();
const url = query ? `/api/workouts?${query}` : "/api/workouts";
return apiGet<WorkoutListPayload>(url);
}
export interface UseWorkoutsResult {
data: WorkoutListPayload | undefined;
isLoading: boolean;
isEmpty: boolean;
error: Error | null;
}
export function useWorkouts(opts: UseWorkoutsOptions = {}): UseWorkoutsResult {
const { isAuthenticated } = useAuth();
const query = useQuery({
queryKey: queryKeys.workoutsRecentList({
limit: opts.limit,
offset: opts.offset,
since: opts.since,
sportType: opts.sportType,
}),
queryFn: () => fetchWorkouts(opts),
enabled: isAuthenticated,
staleTime: 60 * 1000,
});
const isEmpty =
Boolean(isAuthenticated) &&
query.data !== undefined &&
query.data.workouts.length === 0;
return {
data: query.data,
isLoading: query.isLoading,
isEmpty,
error: query.error as Error | null,
};
}
export interface WorkoutDetailPayload extends WorkoutListEntry {
minHr: number | null;
stepCount: number | null;
elevationM: number | null;
pauseDurationSec: number | null;
metadata: unknown;
route:
| {
geometry: unknown;
sampleTimestamps: string[] | null;
}
| null;
canonicalId: string;
}
async function fetchWorkoutDetail(id: string): Promise<WorkoutDetailPayload> {
return apiGet<WorkoutDetailPayload>(
`/api/workouts/${encodeURIComponent(id)}`,
);
}
export interface UseWorkoutDetailResult {
data: WorkoutDetailPayload | undefined;
isLoading: boolean;
error: Error | null;
}
export function useWorkoutDetail(id: string): UseWorkoutDetailResult {
const { isAuthenticated } = useAuth();
const query = useQuery({
queryKey: queryKeys.workoutDetail(id),
queryFn: () => fetchWorkoutDetail(id),
enabled: isAuthenticated && id.length > 0,
staleTime: 60 * 1000,
});
return {
data: query.data,
isLoading: query.isLoading,
error: query.error as Error | null,
};
}