Files
HealthLog/src/lib/queries/use-analytics-query.ts
T
Marc-André Bombeck 0c48ecc39c chore(knip): drop fourteen orphaned types + scope contracts ignore-block
Every `z.infer<>` source-of-truth type the residual flagged had no
external consumer:
  - `MetricSource`, `AIRecommendation`, `AIRecommendationRationale`,
    `AICitation`, `AIWarning`, `StoryboardAnnotation` in
    `src/lib/ai/schema.ts`
  - `InsightFinding` in `src/lib/ai/types.ts`
  - `CoachChatRequest` in `src/lib/ai/coach/types.ts`
  - `ApplyProfileInput` in `src/lib/auth/profile-update.ts`
  - `CreateSideEffectInput`, `ListSideEffectsInput` in
    `src/lib/medications/side-effects/validators.ts`
  - `AnalyticsSlicePayload` in `src/lib/queries/use-analytics-query.ts`
  - `WithingsWebhookAuthOutcome` in `src/lib/withings/webhook-handler.ts`

Plus three stragglers — `ThresholdOverride` (the persisted shape is the
sibling `ThresholdOverridesJson`), `ChartRangePreset` + the now-orphaned
`CHART_RANGE_PRESETS` constant it was derived from (no chart reads the
preset list), and the `WebPushChannelConfig` + `ApnsChannelConfig` +
`ChannelConfig` triplet in `src/lib/notifications/types.ts` (the
dispatcher imports the two real channel configs by name; the empty
records and the union added nothing).

`src/lib/validations/**` keeps its `z.infer<>` types — that's the
external API surface the iOS client matches against. Muted via
`ignoreIssues` so the gate can enforce while the contract surface
stays self-documenting.
2026-05-21 19:06:22 +02:00

135 lines
4.9 KiB
TypeScript

"use client";
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import type { DataSummary } from "@/lib/analytics/trends";
/**
* v1.4.33 IW2 — single TanStack-Query wrapper for `/api/analytics`.
*
* Pre-fix, seven mount sites each declared `useQuery({ queryKey:
* ["analytics"], … })` with inconsistent `staleTime` / `enabled` /
* `refetchOnMount` options. TanStack treats the first-mount wins → the
* earliest consumer's options govern the shared cache cell, but every
* route-change re-mount still spins up a fresh subscription tree and
* any consumer without an `enabled: isAuthenticated` short-circuit
* re-issues the network on a back-forward swing. The audit
* (`.planning/round-v1433-audit-perf.md` §1) ranks this as the top-1
* ROI win.
*
* The shared hook:
* - centralises the queryKey via `queryKeys.analytics(slice)`,
* - sets `staleTime: 60_000` so a route swing within a minute is a
* free cache hit,
* - sets `refetchOnMount: false` + `refetchOnWindowFocus: false` so
* route transitions never trigger a refetch storm,
* - defaults to `enabled: isAuthenticated` so the unauthenticated
* surfaces (registration, sign-in) don't spin up a 401-bound
* request,
* - lets the caller opt into the slim `?slice=summaries` server
* branch shipped in IW1 / C1 when the consumer only needs the
* per-type DataSummary headlines (sub-pages, gating helpers,
* onboarding checklist).
*
* The dashboard's mother query keeps the default slice because it also
* reads `bpInTargetPct*` and `glucoseByContext` — those slots stay
* stubbed on the slim path per the IW1 follow-up.
*/
/**
* Discriminator for the server-side slice. `undefined` (the default)
* routes onto the thick payload; `"summaries"` routes onto IW1's
* `?slice=summaries` branch.
*/
export type AnalyticsSlice = "summaries" | undefined;
/**
* The unwrapped `data` field returned by the API. Typed as a wide
* shape so the dashboard's `bpInTargetPct*` / `glucoseByContext`
* consumers can narrow without a runtime cast. The slim slice fills
* the optional thick fields with `null` / empty so call-site reads
* remain undefined-safe.
*/
export interface AnalyticsRawPayload {
summaries: Record<string, DataSummary>;
// Thick-only fields — present on the default slice, stubbed on the
// slim branch. Optional so the type covers both shapes.
bpInTargetPct?: number | null;
bpInTargetPct7d?: number | null;
bpInTargetPct30d?: number | null;
bpInTargetPctAllTime?: number | null;
bpInTargetPctPriorMonth?: number | null;
bpInTargetPctPriorYear?: number | null;
glucoseByContext?: Record<string, unknown>;
correlations?: unknown;
healthScore?: unknown;
sleepStages?: unknown;
/**
* v1.4.34 IW-B — per-type freshness map. The dashboard's tile strip
* reads `lastSeenByType[type]?.daysAgo` and forwards it to each
* `<TrendCard>` so a stale tile renders an "Letzter Wert vor Xd"
* caption instead of disappearing. Present on both the thick default
* and the slim `?slice=summaries` branch.
*/
lastSeenByType?: Record<
string,
{ lastSeenAt: string; daysAgo: number } | null
>;
// Keep the contract open — additive server fields don't need a
// type bump every release.
[key: string]: unknown;
}
export interface UseAnalyticsQueryOptions {
/**
* `"summaries"` — hit IW1's slim slice (2 SQL passes, no
* correlations / health-score / bp-in-target / sleep-stages).
* Omit (or pass `undefined`) for the thick default payload.
*/
slice?: AnalyticsSlice;
/**
* Optional override for the auth gate. Defaults to
* `isAuthenticated`. The Insights mother page can pass extra
* disabled-flag composition without losing the auth gate.
*/
enabled?: boolean;
}
async function fetchAnalytics(
slice: AnalyticsSlice,
): Promise<AnalyticsRawPayload> {
const url =
slice === "summaries" ? "/api/analytics?slice=summaries" : "/api/analytics";
const res = await fetch(url);
if (!res.ok) {
throw new Error(`Failed to load analytics (${res.status})`);
}
const json = (await res.json()) as { data: AnalyticsRawPayload };
return json.data;
}
/**
* Shared hook. Returns the raw `data` payload typed as
* `AnalyticsRawPayload`. Consumers can narrow at the call site (e.g.
* `const summaries = query.data?.summaries;`).
*/
export function useAnalyticsQuery(
options: UseAnalyticsQueryOptions = {},
): UseQueryResult<AnalyticsRawPayload, Error> {
const { isAuthenticated } = useAuth();
const slice = options.slice;
const enabled =
options.enabled !== undefined ? options.enabled : isAuthenticated;
return useQuery({
queryKey: queryKeys.analytics(slice),
queryFn: () => fetchAnalytics(slice),
enabled,
staleTime: 60_000,
refetchOnMount: false,
refetchOnWindowFocus: false,
});
}