mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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 ?? [];
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user