From dfffe6bb3bee1f7110899041e3d16f7478031ad2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 11 May 2026 02:23:43 +0200 Subject: [PATCH] refactor: simplify batch (S-02 to S-04) Three small reuse wins from the W6 simplify review: - openapi/registry.ts swaps the lazy require() for a static import. The ESLint disable + as-cast carried no real benefit; the only callers always loaded the route table anyway. - Extract useCoachPrefs() into a shared hook so the settings sheet and message thread share one queryKey + one fallback shape. Removes the drift between the two existing inline blocks. - Replace the 9-deep wantsX boolean ladder in coach snapshot with a single CoachScopeSource -> MeasurementType[] map. Drives both the WHERE type IN (...) build and the appleHealthBlocks gate, so adding a new metric is one entry instead of three parallel edits. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/message-thread.test.tsx | 8 ++ .../insights/coach-panel/message-thread.tsx | 23 ++---- src/hooks/use-coach-prefs.ts | 36 +++++++++ src/lib/ai/coach/snapshot.ts | 76 ++++++++++--------- src/lib/openapi/registry.ts | 16 ++-- 5 files changed, 99 insertions(+), 60 deletions(-) create mode 100644 src/hooks/use-coach-prefs.ts diff --git a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx index 055dee029..b1cf8ad3a 100644 --- a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx +++ b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx @@ -39,8 +39,16 @@ vi.mock("@/hooks/use-auth", () => ({ // QueryClientProvider; stub the hook to return the legacy defaults so // the existing assertions on the closed-by-default disclosure stay // representative. +// +// v1.4.23 H7 — also stubs `useMutation` for the per-message thumbs +// feedback row inside the assistant bubble. vi.mock("@tanstack/react-query", () => ({ useQuery: () => ({ data: undefined }), + useMutation: () => ({ + mutate: vi.fn(), + isPending: false, + isError: false, + }), })); import { MessageThread } from "../message-thread"; diff --git a/src/components/insights/coach-panel/message-thread.tsx b/src/components/insights/coach-panel/message-thread.tsx index 629801cdc..7a46f4d58 100644 --- a/src/components/insights/coach-panel/message-thread.tsx +++ b/src/components/insights/coach-panel/message-thread.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useMutation } from "@tanstack/react-query"; import { Bot, ChevronRight, @@ -14,10 +14,7 @@ import { import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; import { useAuth } from "@/hooks/use-auth"; -import { - DEFAULT_COACH_PREFS, - type CoachPrefs, -} from "@/lib/validations/coach-prefs"; +import { useCoachPrefs } from "@/hooks/use-coach-prefs"; import { SourceChips } from "./source-chips"; import type { @@ -66,18 +63,10 @@ export function MessageThread({ // v1.4.23 H4 — read the user's Coach prefs so the evidence // disclosure honours `showEvidenceByDefault`. Cached at the - // queryClient level; the settings sheet writes the same key after a - // successful save so the new default takes effect on the next reply - // without a page reload. - const { data: coachPrefs } = useQuery({ - queryKey: ["coach-prefs"], - queryFn: async () => { - const res = await fetch("/api/auth/me/coach-prefs"); - if (!res.ok) return DEFAULT_COACH_PREFS; - const env = (await res.json()) as { data: CoachPrefs }; - return env.data; - }, - }); + // queryClient level via the shared `useCoachPrefs` hook; the + // settings sheet writes the same key after a successful save so the + // new default takes effect on the next reply without a page reload. + const { data: coachPrefs } = useCoachPrefs(); const evidenceDefaultOpen = coachPrefs?.showEvidenceByDefault ?? false; const messages: CoachMessageDTO[] = conversation?.messages ?? []; diff --git a/src/hooks/use-coach-prefs.ts b/src/hooks/use-coach-prefs.ts new file mode 100644 index 000000000..b57f72005 --- /dev/null +++ b/src/hooks/use-coach-prefs.ts @@ -0,0 +1,36 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; + +import { + DEFAULT_COACH_PREFS, + type CoachPrefs, +} from "@/lib/validations/coach-prefs"; + +/** + * v1.4.23 W6 (S-03) — shared accessor for the per-user Coach + * preferences row. Both the Coach drawer's settings sheet and the + * message-thread evidence-disclosure default consume the same + * `/api/auth/me/coach-prefs` payload; before the extraction the two + * call sites duplicated the same `useQuery` block with subtly + * different fallback semantics (one threw on `!ok`, the other returned + * defaults). The hook centralises the cache key, the envelope unwrap, + * and the "treat fetch failure as defaults" stance so the next surface + * (insights cog, settings tab) inherits a single source of truth. + * + * The query is gated on `enabled` so the settings sheet can defer the + * fetch until the sheet actually opens. Callers that always want the + * row (message thread) leave it unset (defaults to true). + */ +export function useCoachPrefs(opts?: { enabled?: boolean }) { + return useQuery({ + queryKey: ["coach-prefs"], + queryFn: async () => { + const res = await fetch("/api/auth/me/coach-prefs"); + if (!res.ok) return DEFAULT_COACH_PREFS; + const env = (await res.json()) as { data: CoachPrefs }; + return env.data; + }, + enabled: opts?.enabled, + }); +} diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index 3999f9ff7..755dca586 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -304,35 +304,38 @@ export async function buildCoachSnapshot( const wantsPulse = sources.has("pulse"); const wantsMood = sources.has("mood"); const wantsCompliance = sources.has("compliance"); - // v1.4.23 — additive Apple Health sources. The default scope leaves - // them off, so non-iOS accounts never pay the SQL `WHERE type IN` - // overhead. iOS callers explicitly include them in `scope.sources`. - const wantsHrv = sources.has("hrv"); - const wantsSleep = sources.has("sleep"); - const wantsRestingHr = sources.has("resting_hr"); - const wantsSteps = sources.has("steps"); - const wantsActiveEnergy = sources.has("active_energy"); - const wantsFlights = sources.has("flights"); - const wantsDistance = sources.has("distance"); - const wantsVo2Max = sources.has("vo2_max"); - const wantsBodyTemp = sources.has("body_temp"); + + // v1.4.23 W6 (S-04) — single source of truth for the + // CoachScopeSource → MeasurementType[] mapping. Drives both the + // SQL `WHERE type IN (…)` build below and the Apple-Health timeline + // block table downstream, so adding a new metric is one entry + // instead of three (boolean + push + appleHealthBlocks row). + // Default Coach scope leaves the Apple Health rows off; non-iOS + // accounts never pay the type-IN overhead because their `sources` + // set never enables them. + const METRIC_TYPES: Record = { + bp: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"], + weight: ["WEIGHT"], + pulse: ["PULSE"], + mood: [], + compliance: [], + hrv: ["HEART_RATE_VARIABILITY"], + sleep: ["SLEEP_DURATION"], + resting_hr: ["RESTING_HEART_RATE"], + steps: ["ACTIVITY_STEPS"], + active_energy: ["ACTIVE_ENERGY_BURNED"], + flights: ["FLIGHTS_CLIMBED"], + distance: ["WALKING_RUNNING_DISTANCE"], + vo2_max: ["VO2_MAX"], + body_temp: ["BODY_TEMPERATURE"], + }; // Single fetch for all measurement types — Prisma's filter pushes // the type list into one SQL `WHERE type IN (…)` so we don't pay // per-metric round-trips. - const wantedTypes: string[] = []; - if (wantsBp) wantedTypes.push("BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"); - if (wantsWeight) wantedTypes.push("WEIGHT"); - if (wantsPulse) wantedTypes.push("PULSE"); - if (wantsHrv) wantedTypes.push("HEART_RATE_VARIABILITY"); - if (wantsSleep) wantedTypes.push("SLEEP_DURATION"); - if (wantsRestingHr) wantedTypes.push("RESTING_HEART_RATE"); - if (wantsSteps) wantedTypes.push("ACTIVITY_STEPS"); - if (wantsActiveEnergy) wantedTypes.push("ACTIVE_ENERGY_BURNED"); - if (wantsFlights) wantedTypes.push("FLIGHTS_CLIMBED"); - if (wantsDistance) wantedTypes.push("WALKING_RUNNING_DISTANCE"); - if (wantsVo2Max) wantedTypes.push("VO2_MAX"); - if (wantsBodyTemp) wantedTypes.push("BODY_TEMPERATURE"); + const wantedTypes = Array.from(sources).flatMap( + (source) => METRIC_TYPES[source] ?? [], + ); const measurementRows = wantedTypes.length > 0 @@ -514,16 +517,21 @@ export async function buildCoachSnapshot( type: string; enabled: boolean; }; + // v1.4.23 W6 (S-04) — `enabled` reads from `sources` directly + // instead of from a parallel ladder of `wantsHrv/...` booleans. The + // `type` field still mirrors `METRIC_TYPES[metric][0]` because each + // Apple Health source maps to exactly one MeasurementType (BP is + // the only fan-out and lives in its own legacy block above). const appleHealthBlocks: AppleHealthBlock[] = [ - { metric: "hrv", snapshotKey: "heartRateVariability", type: "HEART_RATE_VARIABILITY", enabled: wantsHrv }, - { metric: "sleep", snapshotKey: "sleep", type: "SLEEP_DURATION", enabled: wantsSleep }, - { metric: "resting_hr", snapshotKey: "restingHeartRate", type: "RESTING_HEART_RATE", enabled: wantsRestingHr }, - { metric: "steps", snapshotKey: "steps", type: "ACTIVITY_STEPS", enabled: wantsSteps }, - { metric: "active_energy", snapshotKey: "activeEnergy", type: "ACTIVE_ENERGY_BURNED", enabled: wantsActiveEnergy }, - { metric: "flights", snapshotKey: "flightsClimbed", type: "FLIGHTS_CLIMBED", enabled: wantsFlights }, - { metric: "distance", snapshotKey: "walkingRunningDistance", type: "WALKING_RUNNING_DISTANCE", enabled: wantsDistance }, - { metric: "vo2_max", snapshotKey: "vo2Max", type: "VO2_MAX", enabled: wantsVo2Max }, - { metric: "body_temp", snapshotKey: "bodyTemperature", type: "BODY_TEMPERATURE", enabled: wantsBodyTemp }, + { metric: "hrv", snapshotKey: "heartRateVariability", type: "HEART_RATE_VARIABILITY", enabled: sources.has("hrv") }, + { metric: "sleep", snapshotKey: "sleep", type: "SLEEP_DURATION", enabled: sources.has("sleep") }, + { metric: "resting_hr", snapshotKey: "restingHeartRate", type: "RESTING_HEART_RATE", enabled: sources.has("resting_hr") }, + { metric: "steps", snapshotKey: "steps", type: "ACTIVITY_STEPS", enabled: sources.has("steps") }, + { metric: "active_energy", snapshotKey: "activeEnergy", type: "ACTIVE_ENERGY_BURNED", enabled: sources.has("active_energy") }, + { metric: "flights", snapshotKey: "flightsClimbed", type: "FLIGHTS_CLIMBED", enabled: sources.has("flights") }, + { metric: "distance", snapshotKey: "walkingRunningDistance", type: "WALKING_RUNNING_DISTANCE", enabled: sources.has("distance") }, + { metric: "vo2_max", snapshotKey: "vo2Max", type: "VO2_MAX", enabled: sources.has("vo2_max") }, + { metric: "body_temp", snapshotKey: "bodyTemperature", type: "BODY_TEMPERATURE", enabled: sources.has("body_temp") }, ]; for (const block of appleHealthBlocks) { if (!block.enabled) continue; diff --git a/src/lib/openapi/registry.ts b/src/lib/openapi/registry.ts index 2fb0ee2ea..13d7127e5 100644 --- a/src/lib/openapi/registry.ts +++ b/src/lib/openapi/registry.ts @@ -24,6 +24,8 @@ */ import { createDocument, type ZodOpenApiObject } from "zod-openapi"; +import { openApiPaths, openApiComponents } from "./routes"; + export const openApiBase: Pick< ZodOpenApiObject, "openapi" | "info" | "servers" | "tags" @@ -57,17 +59,13 @@ export const openApiBase: Pick< }; /** - * Assemble the full OpenAPI 3.1 document. Imports the route table - * lazily so the base scaffolding stays usable from contexts that - * shouldn't pull every Zod schema in the project (e.g. a hypothetical - * future devtools-only consumer). + * Assemble the full OpenAPI 3.1 document. Routes live in their own + * file so additions don't touch the base scaffolding. The static + * import keeps typecheck-narrowing and tree-shaking intact (W6 S-02 + * removed the lazy-`require` form which had no real consumer and was + * costing an `eslint-disable` + an `as` cast for no benefit). */ export function buildOpenApiDocument(): ReturnType { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { openApiPaths, openApiComponents } = require("./routes") as { - openApiPaths: ZodOpenApiObject["paths"]; - openApiComponents: ZodOpenApiObject["components"]; - }; return createDocument({ ...openApiBase, paths: openApiPaths,