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
// 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";
@@ -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 ?? [];
+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 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<CoachScopeSource, string[]> = {
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;
+7 -9
View File
@@ -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<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({
...openApiBase,
paths: openApiPaths,