mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
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.
163 lines
4.4 KiB
TypeScript
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,
|
|
};
|
|
}
|