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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-11 02:23:43 +02:00
1 parent 3ee6dab6fa
commit dfffe6bb3b
5 files changed
+99 -60

No files matched your search

@@ -39,8 +39,16 @@ vi.mock("@/hooks/use-auth", () => ({
// QueryClientProvider; stub the hook to return the legacy defaults so // QueryClientProvider; stub the hook to return the legacy defaults so
// the existing assertions on the closed-by-default disclosure stay // the existing assertions on the closed-by-default disclosure stay
// representative. // representative.
//
// v1.4.23 H7 — also stubs `useMutation` for the per-message thumbs
// feedback row inside the assistant bubble.
vi.mock("@tanstack/react-query", () => ({ vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: undefined }), useQuery: () => ({ data: undefined }),
useMutation: () => ({
mutate: vi.fn(),
isPending: false,
isError: false,
}),
})); }));
import { MessageThread } from "../message-thread"; import { MessageThread } from "../message-thread";
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { import {
Bot, Bot,
ChevronRight, ChevronRight,
@@ -14,10 +14,7 @@ import {
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context"; import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { import { useCoachPrefs } from "@/hooks/use-coach-prefs";
DEFAULT_COACH_PREFS,
type CoachPrefs,
} from "@/lib/validations/coach-prefs";
import { SourceChips } from "./source-chips"; import { SourceChips } from "./source-chips";
import type { import type {
@@ -66,18 +63,10 @@ export function MessageThread({
// v1.4.23 H4 — read the user's Coach prefs so the evidence // v1.4.23 H4 — read the user's Coach prefs so the evidence
// disclosure honours `showEvidenceByDefault`. Cached at the // disclosure honours `showEvidenceByDefault`. Cached at the
// queryClient level; the settings sheet writes the same key after a // queryClient level via the shared `useCoachPrefs` hook; the
// successful save so the new default takes effect on the next reply // settings sheet writes the same key after a successful save so the
// without a page reload. // new default takes effect on the next reply without a page reload.
const { data: coachPrefs } = useQuery({ const { data: coachPrefs } = useCoachPrefs();
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;
},
});
const evidenceDefaultOpen = coachPrefs?.showEvidenceByDefault ?? false; const evidenceDefaultOpen = coachPrefs?.showEvidenceByDefault ?? false;
const messages: CoachMessageDTO[] = conversation?.messages ?? []; const messages: CoachMessageDTO[] = conversation?.messages ?? [];
+36
View File
@@ -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<CoachPrefs>({
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,
});
}
+42 -34
View File
@@ -304,35 +304,38 @@ export async function buildCoachSnapshot(
const wantsPulse = sources.has("pulse"); const wantsPulse = sources.has("pulse");
const wantsMood = sources.has("mood"); const wantsMood = sources.has("mood");
const wantsCompliance = sources.has("compliance"); 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` // v1.4.23 W6 (S-04) — single source of truth for the
// overhead. iOS callers explicitly include them in `scope.sources`. // CoachScopeSource → MeasurementType[] mapping. Drives both the
const wantsHrv = sources.has("hrv"); // SQL `WHERE type IN (…)` build below and the Apple-Health timeline
const wantsSleep = sources.has("sleep"); // block table downstream, so adding a new metric is one entry
const wantsRestingHr = sources.has("resting_hr"); // instead of three (boolean + push + appleHealthBlocks row).
const wantsSteps = sources.has("steps"); // Default Coach scope leaves the Apple Health rows off; non-iOS
const wantsActiveEnergy = sources.has("active_energy"); // accounts never pay the type-IN overhead because their `sources`
const wantsFlights = sources.has("flights"); // set never enables them.
const wantsDistance = sources.has("distance"); const METRIC_TYPES: Record<CoachScopeSource, string[]> = {
const wantsVo2Max = sources.has("vo2_max"); bp: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"],
const wantsBodyTemp = sources.has("body_temp"); 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 // Single fetch for all measurement types — Prisma's filter pushes
// the type list into one SQL `WHERE type IN (…)` so we don't pay // the type list into one SQL `WHERE type IN (…)` so we don't pay
// per-metric round-trips. // per-metric round-trips.
const wantedTypes: string[] = []; const wantedTypes = Array.from(sources).flatMap(
if (wantsBp) wantedTypes.push("BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"); (source) => METRIC_TYPES[source] ?? [],
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 measurementRows = const measurementRows =
wantedTypes.length > 0 wantedTypes.length > 0
@@ -514,16 +517,21 @@ export async function buildCoachSnapshot(
type: string; type: string;
enabled: boolean; 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[] = [ const appleHealthBlocks: AppleHealthBlock[] = [
{ metric: "hrv", snapshotKey: "heartRateVariability", type: "HEART_RATE_VARIABILITY", enabled: wantsHrv }, { metric: "hrv", snapshotKey: "heartRateVariability", type: "HEART_RATE_VARIABILITY", enabled: sources.has("hrv") },
{ metric: "sleep", snapshotKey: "sleep", type: "SLEEP_DURATION", enabled: wantsSleep }, { metric: "sleep", snapshotKey: "sleep", type: "SLEEP_DURATION", enabled: sources.has("sleep") },
{ metric: "resting_hr", snapshotKey: "restingHeartRate", type: "RESTING_HEART_RATE", enabled: wantsRestingHr }, { metric: "resting_hr", snapshotKey: "restingHeartRate", type: "RESTING_HEART_RATE", enabled: sources.has("resting_hr") },
{ metric: "steps", snapshotKey: "steps", type: "ACTIVITY_STEPS", enabled: wantsSteps }, { metric: "steps", snapshotKey: "steps", type: "ACTIVITY_STEPS", enabled: sources.has("steps") },
{ metric: "active_energy", snapshotKey: "activeEnergy", type: "ACTIVE_ENERGY_BURNED", enabled: wantsActiveEnergy }, { metric: "active_energy", snapshotKey: "activeEnergy", type: "ACTIVE_ENERGY_BURNED", enabled: sources.has("active_energy") },
{ metric: "flights", snapshotKey: "flightsClimbed", type: "FLIGHTS_CLIMBED", enabled: wantsFlights }, { metric: "flights", snapshotKey: "flightsClimbed", type: "FLIGHTS_CLIMBED", enabled: sources.has("flights") },
{ metric: "distance", snapshotKey: "walkingRunningDistance", type: "WALKING_RUNNING_DISTANCE", enabled: wantsDistance }, { metric: "distance", snapshotKey: "walkingRunningDistance", type: "WALKING_RUNNING_DISTANCE", enabled: sources.has("distance") },
{ metric: "vo2_max", snapshotKey: "vo2Max", type: "VO2_MAX", enabled: wantsVo2Max }, { metric: "vo2_max", snapshotKey: "vo2Max", type: "VO2_MAX", enabled: sources.has("vo2_max") },
{ metric: "body_temp", snapshotKey: "bodyTemperature", type: "BODY_TEMPERATURE", enabled: wantsBodyTemp }, { metric: "body_temp", snapshotKey: "bodyTemperature", type: "BODY_TEMPERATURE", enabled: sources.has("body_temp") },
]; ];
for (const block of appleHealthBlocks) { for (const block of appleHealthBlocks) {
if (!block.enabled) continue; if (!block.enabled) continue;
+7 -9
View File
@@ -24,6 +24,8 @@
*/ */
import { createDocument, type ZodOpenApiObject } from "zod-openapi"; import { createDocument, type ZodOpenApiObject } from "zod-openapi";
import { openApiPaths, openApiComponents } from "./routes";
export const openApiBase: Pick< export const openApiBase: Pick<
ZodOpenApiObject, ZodOpenApiObject,
"openapi" | "info" | "servers" | "tags" "openapi" | "info" | "servers" | "tags"
@@ -57,17 +59,13 @@ export const openApiBase: Pick<
}; };
/** /**
* Assemble the full OpenAPI 3.1 document. Imports the route table * Assemble the full OpenAPI 3.1 document. Routes live in their own
* lazily so the base scaffolding stays usable from contexts that * file so additions don't touch the base scaffolding. The static
* shouldn't pull every Zod schema in the project (e.g. a hypothetical * import keeps typecheck-narrowing and tree-shaking intact (W6 S-02
* future devtools-only consumer). * 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<typeof createDocument> { export function buildOpenApiDocument(): ReturnType<typeof createDocument> {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { openApiPaths, openApiComponents } = require("./routes") as {
openApiPaths: ZodOpenApiObject["paths"];
openApiComponents: ZodOpenApiObject["components"];
};
return createDocument({ return createDocument({
...openApiBase, ...openApiBase,
paths: openApiPaths, paths: openApiPaths,