Files
HealthLog/src/hooks/use-workouts.ts
T
Marc-André Bombeck 9b6e237f5f chore(release): v1.4.42 — Knip enforcing, queryKey factory closed, iOS Workouts dedup, Withings off-response classification
Six touch-disjoint waves landed on develop before this release:

- W1-KNIP — exports/types tiers flipped to enforcing-mode on main;
  35 unused exports + 52 unused types triaged to 0/0; shadcn surface
  + zod-infer contract types scoped via knip.json ignore-block.
- W2-ZOD-MULTI-ISSUE — `returnAllZodIssues` shared helper +
  `/api/dashboard/widgets` 422 now returns every Zod issue + worker
  audit-ledger breadcrumb. 41 sibling routes documented for v1.4.43.
- W3-QUERYKEY-LONGTAIL — 40 settings/medications/admin/hooks files
  migrated to the queryKeys factory; 9 new factory entries; ESLint
  rule + test-guard scope extended in lockstep.
- W4-ARCH-HYGIENE — BERLIN_DAY_FORMATTER consolidated across nine
  sites (seven status helpers + bucket-series + bp-in-target);
  Suspense double-comment consolidated; doctor-report sanitiser regex
  rewritten to escape-form so diffs are reviewable; pr-detection-
  worker soft-delete filter; offhost-backup DR-intent comment.
- W5-IOS-WORKOUTS — `dedupeWorkoutBatch` write-time cross-source
  dedup helper for the v1.5 iOS HealthKit observer ingest; dashboard
  tile-strip placeholder parity with the live TrendCard chrome.
- W6-WITHINGS-OFF + ENV-CHECK — Withings off-response classifier
  (transient/reauth/persistent) + WithingsApiError typed throw + the
  `pnpm check-env` CLI. APNs `allOrNone: true` closes the AP-2
  silent-disable gap the wave was conceived to close.

No migration, no env-var change, no API contract break for iOS v0.5.4.
The new `/api/dashboard/widgets` 422 envelope is additively extended.

pnpm test --run green at 4815 passing / 1 skipped.
pnpm typecheck, pnpm lint, pnpm knip (no --include flag) green.
2026-05-21 19:33:07 +02:00

163 lines
4.4 KiB
TypeScript

"use client";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
/**
* 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";
const res = await fetch(url);
if (!res.ok) {
throw new Error(`Failed to load workouts (${res.status})`);
}
const json = (await res.json()) as { data: WorkoutListPayload };
return json.data;
}
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> {
const res = await fetch(`/api/workouts/${encodeURIComponent(id)}`);
if (!res.ok) {
throw new Error(`Failed to load workout (${res.status})`);
}
const json = (await res.json()) as { data: WorkoutDetailPayload };
return json.data;
}
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,
};
}