diff --git a/src/__tests__/client-fetch-limit-guard.test.ts b/src/__tests__/client-fetch-limit-guard.test.ts index 34d100f06..c9d73875a 100644 --- a/src/__tests__/client-fetch-limit-guard.test.ts +++ b/src/__tests__/client-fetch-limit-guard.test.ts @@ -77,16 +77,10 @@ const ALLOWLIST: readonly Allowed[] = [ "Search-backed picker: the query narrows the whole document vault server-side.", }, { - file: "components/settings/share-document-picker.tsx", + file: "components/documents/document-picker-sheet.tsx", limit: 50, reason: - "Search-backed picker: the query narrows the whole document vault server-side.", - }, - { - file: "components/documents/document-link-picker.tsx", - limit: 50, - reason: - "Search-backed picker: the query narrows the whole document vault server-side.", + "Search-backed picker (episode links and share links): the query narrows the whole document vault server-side.", }, { file: "hooks/use-mood-day.ts", diff --git a/src/__tests__/client-route-zod-guard.test.ts b/src/__tests__/client-route-zod-guard.test.ts new file mode 100644 index 000000000..e50290239 --- /dev/null +++ b/src/__tests__/client-route-zod-guard.test.ts @@ -0,0 +1,149 @@ +/** + * The documents vault and the Coach load no schema library in the browser. + * + * Both routes reached zod through a constant: the list of document kinds, the + * bulk limit, the attachment cap, the Coach data clusters, a conversation + * title length, the ordering of report leaves. Each of those sat in a schema + * module, so importing the number imported the library with it, on two of the + * heaviest client routes (up to 89 KB gzip per route). The constants live in + * zod-free modules now, re-exported by the schema modules for server code, and + * the server parses what the client sends anyway. + * + * What this walks: the static import graph from each route's `page.tsx`, with + * TypeScript's own parser. From the first `"use client"` module on, every + * module it reaches statically ships to the browser; none of them may import + * `zod`. Type-only imports are skipped (they vanish at build). A dynamic + * `import()` is skipped too: it is a separate chunk, loaded when the code + * that asks for it runs (the chart runtime is one), and not part of the + * route's first load. That is the limit of this guard, stated: a lazily + * loaded chunk may still carry zod. + */ +import { describe, expect, it } from "vitest"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join, normalize } from "node:path"; +import ts from "typescript"; + +const ROOT = join(__dirname, "..", ".."); + +function resolve(spec: string, from: string): string | null { + let base: string; + if (spec.startsWith("@/")) base = join(ROOT, "src", spec.slice(2)); + else if (spec.startsWith(".")) base = normalize(join(dirname(from), spec)); + else return null; + for (const ext of ["", ".ts", ".tsx", "/index.ts", "/index.tsx"]) { + const candidate = base + ext; + if (/\.tsx?$/.test(candidate) && existsSync(candidate)) return candidate; + } + return null; +} + +/** Value-level static imports and re-exports of one module. */ +function staticImports(source: string, file: string): string[] { + const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, false); + const specs: string[] = []; + for (const node of sf.statements) { + if (ts.isImportDeclaration(node)) { + const clause = node.importClause; + if (clause?.isTypeOnly) continue; + const named = clause?.namedBindings; + if ( + clause && + !clause.name && + named && + ts.isNamedImports(named) && + named.elements.length > 0 && + named.elements.every((e) => e.isTypeOnly) + ) { + continue; + } + specs.push((node.moduleSpecifier as ts.StringLiteral).text); + } else if (ts.isExportDeclaration(node) && node.moduleSpecifier) { + if (node.isTypeOnly) continue; + const clause = node.exportClause; + if ( + clause && + ts.isNamedExports(clause) && + clause.elements.length > 0 && + clause.elements.every((e) => e.isTypeOnly) + ) { + continue; + } + specs.push((node.moduleSpecifier as ts.StringLiteral).text); + } + } + return specs; +} + +function isClient(source: string): boolean { + return /^\s*["']use client["']/.test(source); +} + +/** Client modules reached from `entry` that import zod, with one path each. */ +function clientZodImports(entry: string): { file: string; path: string[] }[] { + const seen = new Map(); + const parent = new Map(); + const stack: [string, boolean][] = [[join(ROOT, entry), false]]; + const hits: string[] = []; + let walked = 0; + while (stack.length > 0) { + const [file, inClient] = stack.pop()!; + const prior = seen.get(file); + if (prior === true || (prior === false && !inClient)) continue; + const source = readFileSync(file, "utf8"); + const client = inClient || isClient(source); + seen.set(file, client); + walked += 1; + for (const spec of staticImports(source, file)) { + if (client && (spec === "zod" || spec.startsWith("zod/"))) { + hits.push(file); + } + const next = resolve(spec, file); + if (!next) continue; + if (!parent.has(next)) parent.set(next, file); + stack.push([next, client]); + } + } + if (walked < 20) + throw new Error(`walked only ${walked} modules from ${entry}`); + return [...new Set(hits)].map((file) => { + const path: string[] = []; + let cursor: string | undefined = file; + while (cursor && path.length < 10) { + path.push(cursor.slice(ROOT.length + 1)); + cursor = parent.get(cursor); + } + return { file: file.slice(ROOT.length + 1), path }; + }); +} + +describe("client routes without the schema library", () => { + for (const route of [ + "src/app/documents/page.tsx", + "src/app/coach/page.tsx", + "src/app/coach/conversations/page.tsx", + ]) { + it(`${route} ships no zod in its static client graph`, () => { + const hits = clientZodImports(route); + expect( + hits.map((h) => h.path.join(" <- ")), + "Move the constant the client needs into a zod-free module (see src/lib/documents/inbound-constants.ts) and re-export it from the schema module.", + ).toEqual([]); + }); + } + + it("sees a zod import where one is", () => { + // The schema module is zod by definition; walked as a client entry it + // must be reported, or the walk above proves nothing. + const probe = join(ROOT, "src/lib/validations/inbound-documents.ts"); + const source = readFileSync(probe, "utf8"); + expect(staticImports(source, probe).some((s) => s.startsWith("zod"))).toBe( + true, + ); + expect( + staticImports( + 'import type { A } from "zod";\nimport { type B } from "zod";\nimport { c } from "zod/v4";\nexport { d } from "./x";\nexport type { E } from "./y";', + "probe.ts", + ), + ).toEqual(["zod/v4", "./x"]); + }); +}); diff --git a/src/app/coach/page-client.tsx b/src/app/coach/page-client.tsx index 0e1d404b1..8c08143a6 100644 --- a/src/app/coach/page-client.tsx +++ b/src/app/coach/page-client.tsx @@ -14,7 +14,8 @@ import { useAuth } from "@/hooks/use-auth"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; -import { coachScopeSourceSchema } from "@/lib/ai/coach/types"; +import { COACH_SCOPE_SOURCES } from "@/lib/ai/coach/scope-sources"; +import type { CoachScopeSource } from "@/lib/ai/coach/types"; /** * v1.12.0 (Coach v2 #6) — full-page Coach conversation. @@ -117,12 +118,13 @@ function CoachPageBody() { // chat (`?doc=`) keeps its own established scope. const freshChat = deepLinkedId === null && seedDocumentId === null; const rawScope = freshChat ? searchParams.get("scope") : null; - const scopeResult = rawScope - ? coachScopeSourceSchema.safeParse(rawScope) - : null; - const launchScope: CoachLaunchScope | null = scopeResult?.success - ? { metric: scopeResult.data } - : null; + // A closed-set check, not a schema parse: the route stays free of the + // schema library for one string comparison. + const launchScope: CoachLaunchScope | null = + rawScope !== null && + (COACH_SCOPE_SOURCES as readonly string[]).includes(rawScope) + ? { metric: rawScope as CoachScopeSource } + : null; const seedPrefill = freshChat ? searchParams.get("ask") : null; // Read once: the panel consumes it on mount, then the URL loses it. diff --git a/src/components/documents/document-ai-section.tsx b/src/components/documents/document-ai-section.tsx index 36beed24d..61c819b94 100644 --- a/src/components/documents/document-ai-section.tsx +++ b/src/components/documents/document-ai-section.tsx @@ -36,13 +36,13 @@ import { Button } from "@/components/ui/button"; import type { AiRunPhase } from "@/hooks/use-ai-run"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; -import { - isAiReadSource, - type DocumentContentIndexSourceValue, - type DocumentIndexOutcomeValue, - type DocumentSuggestionDto, - type DocumentSummaryMode, +import type { + DocumentContentIndexSourceValue, + DocumentIndexOutcomeValue, + DocumentSuggestionDto, + DocumentSummaryMode, } from "@/lib/validations/inbound-documents"; +import { isAiReadSource } from "@/lib/documents/inbound-constants"; import { AiRunPhaseNote, diff --git a/src/components/documents/document-detail-sheet.tsx b/src/components/documents/document-detail-sheet.tsx index 1426fcb91..824a85b9e 100644 --- a/src/components/documents/document-detail-sheet.tsx +++ b/src/components/documents/document-detail-sheet.tsx @@ -75,14 +75,14 @@ import { useFencedObjectUrl, } from "@/hooks/use-fenced-object-url"; import { cn } from "@/lib/utils"; -import { - INBOUND_DOCUMENT_KINDS, - type DocumentSourceSystemValue, - type DocumentSuggestionDto, - type DocumentSummaryMode, - type InboundDocumentDetailDto, - type InboundDocumentKindValue, +import type { + DocumentSourceSystemValue, + DocumentSuggestionDto, + DocumentSummaryMode, + InboundDocumentDetailDto, + InboundDocumentKindValue, } from "@/lib/validations/inbound-documents"; +import { INBOUND_DOCUMENT_KINDS } from "@/lib/documents/inbound-constants"; import { DocumentAiSection } from "./document-ai-section"; import { DocumentEncounterSuggestion } from "./document-encounter-suggestion"; import { DocumentRecordLinks } from "./document-record-links"; diff --git a/src/components/documents/documents-view.tsx b/src/components/documents/documents-view.tsx index 43ab0612d..187ce48a0 100644 --- a/src/components/documents/documents-view.tsx +++ b/src/components/documents/documents-view.tsx @@ -61,15 +61,15 @@ import { } from "@/lib/insights/coach-launch-context"; import { invalidateKeys, queryKeys } from "@/lib/query-keys"; import { useDocumentsLayout } from "@/lib/queries/use-documents-layout"; -import { - DOCUMENT_BULK_MAX_IDS, - type DocumentBulkAction, - type DocumentBulkResultDto, - type DocumentUsageDto, - type InboundDocumentDetailDto, - type InboundDocumentDto, - type InboundDocumentKindValue, +import type { + DocumentBulkAction, + DocumentBulkResultDto, + DocumentUsageDto, + InboundDocumentDetailDto, + InboundDocumentDto, + InboundDocumentKindValue, } from "@/lib/validations/inbound-documents"; +import { DOCUMENT_BULK_MAX_IDS } from "@/lib/documents/inbound-constants"; import { DocumentBulkBar } from "./document-bulk-bar"; import { DocumentDetailSheet } from "./document-detail-sheet"; import { DocumentShareSheet } from "./document-share-sheet"; diff --git a/src/components/documents/vault-utils.ts b/src/components/documents/vault-utils.ts index e7c0f3653..b87e3cfaf 100644 --- a/src/components/documents/vault-utils.ts +++ b/src/components/documents/vault-utils.ts @@ -7,13 +7,15 @@ * and byte formatting. Kept out of the client components so they stay * trivially unit-testable without a render harness. */ +import type { + DocumentLinkedProcedureDto, + InboundDocumentDto, + InboundDocumentKindValue, +} from "@/lib/validations/inbound-documents"; import { DOCUMENT_LIST_DEFAULT_LIMIT, INBOUND_DOCUMENT_KINDS, - type DocumentLinkedProcedureDto, - type InboundDocumentDto, - type InboundDocumentKindValue, -} from "@/lib/validations/inbound-documents"; +} from "@/lib/documents/inbound-constants"; import type { DocumentVaultFilters } from "@/lib/query-keys/documents"; import { userDayKey } from "@/lib/tz/format"; diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index 733e78050..429c23c9b 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -19,7 +19,7 @@ import { apiDelete, apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; import type { CoachFollowUp, CoachScope } from "@/lib/ai/coach/types"; import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context"; import type { InboundDocumentDetailDto } from "@/lib/validations/inbound-documents"; -import { MAX_COACH_ATTACHMENTS } from "@/lib/validations/inbound-documents"; +import { MAX_COACH_ATTACHMENTS } from "@/lib/documents/inbound-constants"; import { useModuleEnabled } from "@/hooks/use-module-enabled"; import { parseUploadResponse, diff --git a/src/components/insights/coach-panel/coach-reasoning-field.tsx b/src/components/insights/coach-panel/coach-reasoning-field.tsx index 8c97d8600..4218d1c25 100644 --- a/src/components/insights/coach-panel/coach-reasoning-field.tsx +++ b/src/components/insights/coach-panel/coach-reasoning-field.tsx @@ -38,7 +38,7 @@ import { type ReasoningLevel, } from "@/lib/ai/reasoning/levels"; import type { CoachReasoningState } from "@/lib/ai/reasoning/resolve"; -import { coachReasoningLevel } from "@/lib/validations/coach-prefs"; +import { coachReasoningLevel } from "@/lib/ai/coach/prefs-constants"; export type { CoachReasoningState }; diff --git a/src/components/insights/coach-panel/conversation-rename.tsx b/src/components/insights/coach-panel/conversation-rename.tsx index a55664613..ec2c771ff 100644 --- a/src/components/insights/coach-panel/conversation-rename.tsx +++ b/src/components/insights/coach-panel/conversation-rename.tsx @@ -6,7 +6,7 @@ import { Check, Loader2, Pencil, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { COACH_CONVERSATION_TITLE_MAX } from "@/lib/ai/coach/types"; +import { COACH_CONVERSATION_TITLE_MAX } from "@/lib/ai/coach/scope-sources"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; diff --git a/src/components/insights/coach-panel/sources-rail.tsx b/src/components/insights/coach-panel/sources-rail.tsx index fb4b825da..a19bf827f 100644 --- a/src/components/insights/coach-panel/sources-rail.tsx +++ b/src/components/insights/coach-panel/sources-rail.tsx @@ -22,13 +22,15 @@ import { HISTORY_REACH_DAYS, lookbackText, } from "@/lib/ai/coach/history-reach"; +import type { + CoachDefaultWindow, + CoachPrefs, +} from "@/lib/validations/coach-prefs"; import { DEFAULT_COACH_CLUSTERS, - coachDataClusterEnum, + COACH_DATA_CLUSTERS, type CoachDataCluster, - type CoachDefaultWindow, - type CoachPrefs, -} from "@/lib/validations/coach-prefs"; +} from "@/lib/ai/coach/prefs-constants"; /** * v1.7.2 — "What I can see" rail, backed by the persisted Coach prefs. @@ -48,8 +50,7 @@ import { * member count beneath each label spells out the scope so the user can * see how much data each toggle pulls in. */ -const CLUSTER_OPTIONS: ReadonlyArray = - coachDataClusterEnum.options; +const CLUSTER_OPTIONS: ReadonlyArray = COACH_DATA_CLUSTERS; /** * The lookback options, narrowest first. The choice is a limit diff --git a/src/components/settings/coach-prefs-section.tsx b/src/components/settings/coach-prefs-section.tsx index ae4ec9cca..06b99a0d9 100644 --- a/src/components/settings/coach-prefs-section.tsx +++ b/src/components/settings/coach-prefs-section.tsx @@ -46,16 +46,18 @@ import { import { useAiCapability, useCoachReasoning } from "@/hooks/use-ai-capability"; import { COACH_FOLLOW_UP_UI_KEYS } from "@/lib/ai/coach/dialog-keys"; import { HISTORY_REACH_DAYS, lookbackText } from "@/lib/ai/coach/history-reach"; +import type { + CoachExcludeMetric, + CoachPrefs, + CoachTone, + CoachVerbosity, +} from "@/lib/validations/coach-prefs"; import { DEFAULT_COACH_CLUSTERS, DEFAULT_COACH_PREFS, - coachDataClusterEnum, + COACH_DATA_CLUSTERS, coachReasoningLevel, - type CoachExcludeMetric, - type CoachPrefs, - type CoachTone, - type CoachVerbosity, -} from "@/lib/validations/coach-prefs"; +} from "@/lib/ai/coach/prefs-constants"; const EXCLUDE_OPTIONS: CoachExcludeMetric[] = [ "bp", @@ -89,7 +91,7 @@ function sourcesSummary( ): string { const params = { count: (prefs.dataClusters ?? DEFAULT_COACH_CLUSTERS).length, - total: coachDataClusterEnum.options.length, + total: COACH_DATA_CLUSTERS.length, }; const window = prefs.defaultWindow; if (HISTORY_REACH_DAYS[window] === null) { diff --git a/src/components/settings/report-selection/selection-panel.tsx b/src/components/settings/report-selection/selection-panel.tsx index 7cebc2ebd..78ca5edf5 100644 --- a/src/components/settings/report-selection/selection-panel.tsx +++ b/src/components/settings/report-selection/selection-panel.tsx @@ -63,7 +63,7 @@ import { parseSavedProfile, SAVED_PROFILE_FALLBACK, } from "@/lib/report-selection/profile-shape"; -import { orderLeaves } from "@/lib/report-selection/selection"; +import { orderLeaves } from "@/lib/report-selection/catalogue"; import { ReportScopePicker } from "./report-scope-picker"; import { ScopeSummary } from "./scope-summary"; diff --git a/src/components/settings/share-link-create-form.tsx b/src/components/settings/share-link-create-form.tsx index 90e6cd695..583e26877 100644 --- a/src/components/settings/share-link-create-form.tsx +++ b/src/components/settings/share-link-create-form.tsx @@ -36,7 +36,7 @@ import { import { ReportScopePicker } from "@/components/settings/report-selection/report-scope-picker"; import { ScopeSummary } from "@/components/settings/report-selection/scope-summary"; import type { ReportLeafId } from "@/lib/report-selection/catalogue"; -import { orderLeaves } from "@/lib/report-selection/selection"; +import { orderLeaves } from "@/lib/report-selection/catalogue"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiPost } from "@/lib/api/api-fetch"; diff --git a/src/components/ui/__tests__/card-density.test.tsx b/src/components/ui/__tests__/card-density.test.tsx index fae11d68e..48fa18c97 100644 --- a/src/components/ui/__tests__/card-density.test.tsx +++ b/src/components/ui/__tests__/card-density.test.tsx @@ -44,5 +44,13 @@ describe("compact density is the prop, not a class list", () => { ), ); expect(hits).toEqual([]); + // The prop is where the compact cards went; a walk that finds none of + // them read the wrong tree. + const users = walkSourceFiles(src, { floor: 2500 }).filter( + (p) => + p.endsWith(".tsx") && + readFileSync(join(src, p), "utf8").includes('density="compact"'), + ); + expect(users.length).toBeGreaterThanOrEqual(20); }); }); diff --git a/src/hooks/use-coach-prefs.ts b/src/hooks/use-coach-prefs.ts index fc1bdb366..025e0cf40 100644 --- a/src/hooks/use-coach-prefs.ts +++ b/src/hooks/use-coach-prefs.ts @@ -9,10 +9,8 @@ import { withBaseToken, isConflict, } from "@/lib/api/optimistic-token"; -import { - DEFAULT_COACH_PREFS, - type CoachPrefs, -} from "@/lib/validations/coach-prefs"; +import type { CoachPrefs } from "@/lib/validations/coach-prefs"; +import { DEFAULT_COACH_PREFS } from "@/lib/ai/coach/prefs-constants"; /** * v1.32.22 — the cached Coach-prefs row carries the optimistic- diff --git a/src/lib/ai/coach/clusters.ts b/src/lib/ai/coach/clusters.ts index 0dfba9374..9f52a58c4 100644 --- a/src/lib/ai/coach/clusters.ts +++ b/src/lib/ai/coach/clusters.ts @@ -19,7 +19,7 @@ import { DEFAULT_COACH_CLUSTERS, type CoachDataCluster, -} from "@/lib/validations/coach-prefs"; +} from "@/lib/ai/coach/prefs-constants"; import type { CoachScopeSource } from "./types"; /** diff --git a/src/lib/ai/coach/dialog-keys.ts b/src/lib/ai/coach/dialog-keys.ts index 5ed803f7d..091b17a52 100644 --- a/src/lib/ai/coach/dialog-keys.ts +++ b/src/lib/ai/coach/dialog-keys.ts @@ -15,16 +15,16 @@ */ import type { Locale } from "@/lib/i18n/config"; import { pluralKey } from "@/lib/i18n/plural"; -import { - coachScopeSourceSchema, - type CoachFollowUpKind, - type CoachMethodEntry, - type CoachResultGranularity, - type CoachResultPeriod, - type CoachScopeWindow, - type CoachStepDomain, - type CoachStepReason, +import type { + CoachFollowUpKind, + CoachMethodEntry, + CoachResultGranularity, + CoachResultPeriod, + CoachScopeWindow, + CoachStepDomain, + CoachStepReason, } from "@/lib/ai/coach/types"; +import { COACH_SCOPE_SOURCES } from "@/lib/ai/coach/scope-sources"; // ── Steps ───────────────────────────────────────────────────────────────── @@ -70,9 +70,7 @@ export const COACH_STEP_REASON_KEYS: Readonly> = invalid_arguments: "coach.step.reason.invalidArguments", }; -const SCOPE_SOURCES: ReadonlySet = new Set( - coachScopeSourceSchema.options, -); +const SCOPE_SOURCES: ReadonlySet = new Set(COACH_SCOPE_SOURCES); /** * A domain's display name. A measurement-backed scope source reuses the diff --git a/src/lib/ai/coach/prefs-constants.ts b/src/lib/ai/coach/prefs-constants.ts new file mode 100644 index 000000000..fde317c4e --- /dev/null +++ b/src/lib/ai/coach/prefs-constants.ts @@ -0,0 +1,66 @@ +/** + * The Coach preference values a client component needs, without the schema + * library: the data clusters, the cluster and preference defaults, the + * reasoning-level reader. `src/lib/validations/coach-prefs.ts` builds its zod + * schemas from these and re-exports them for server code. + */ +import { + DEFAULT_REASONING_LEVEL, + type ReasoningLevel, +} from "@/lib/ai/reasoning/levels"; +import type { CoachPrefs } from "@/lib/validations/coach-prefs"; + +/** + * The data clusters the Coach may read. The Coach applies the selected + * clusters when a turn does not carry an explicit `scope.sources` list, then + * subtracts `excludeMetrics` as a post-filter. + */ +export const COACH_DATA_CLUSTERS = [ + "cardio", + "body", + "activity", + "workouts", + "sleep", + "mood", + "glucose", + "medication", + "mobility", + "environment", +] as const; +export type CoachDataCluster = (typeof COACH_DATA_CLUSTERS)[number]; + +/** + * Clusters enabled when the user has never touched the cluster picker + * (`dataClusters === undefined`). These four reproduce today's legacy + * five domains: cardio carries BP + pulse, body carries weight, mood + * and medication map straight through. The additive members riding + * inside cardio/body (HRV, resting HR, body-composition) only surface + * when the user actually has rows for them — empty blocks are dropped — + * so a web-only account stays close to the legacy 5-domain output while + * an iOS account quietly gains the extra signals it already stores. + */ +export const DEFAULT_COACH_CLUSTERS: ReadonlyArray = [ + "cardio", + "body", + "mood", + "medication", +]; + +/** + * Default preferences applied when the user has never opened the + * settings cog. Equivalent to `coachPrefsSchema.parse({})` but + * returned as a plain object so call sites can compare references + * (e.g., short-circuit a snapshot rebuild when prefs match defaults). + */ +export const DEFAULT_COACH_PREFS: CoachPrefs = { + tone: "warm", + verbosity: "default", + excludeMetrics: [], + showEvidenceByDefault: false, + defaultWindow: "allTime", +}; + +/** The person's reasoning level; an absent key is the default, `medium`. */ +export function coachReasoningLevel(prefs: CoachPrefs): ReasoningLevel { + return prefs.reasoning ?? DEFAULT_REASONING_LEVEL; +} diff --git a/src/lib/ai/coach/scope-sources.ts b/src/lib/ai/coach/scope-sources.ts index faab9bc3c..e070a61bf 100644 --- a/src/lib/ai/coach/scope-sources.ts +++ b/src/lib/ai/coach/scope-sources.ts @@ -59,3 +59,10 @@ export const COACH_SCOPE_SOURCES = [ // Workout model (read from the `Workout` table, not `Measurement`). "workouts", ] as const; + +/** + * Maximum user-visible length for generated and explicitly renamed + * conversation titles. Here rather than in the schema module so the rename + * field reads it without the schema library. + */ +export const COACH_CONVERSATION_TITLE_MAX = 80; diff --git a/src/lib/ai/coach/types.ts b/src/lib/ai/coach/types.ts index 3c4f564b5..f6bf822e2 100644 --- a/src/lib/ai/coach/types.ts +++ b/src/lib/ai/coach/types.ts @@ -919,8 +919,7 @@ export interface CoachConversationAttachmentDTO { title: string | null; } -/** Maximum user-visible length for generated and explicitly renamed titles. */ -export const COACH_CONVERSATION_TITLE_MAX = 80; +export { COACH_CONVERSATION_TITLE_MAX } from "./scope-sources"; export interface CoachConversationDTO { id: string; diff --git a/src/lib/documents/inbound-constants.ts b/src/lib/documents/inbound-constants.ts new file mode 100644 index 000000000..be54767d5 --- /dev/null +++ b/src/lib/documents/inbound-constants.ts @@ -0,0 +1,56 @@ +/** + * The document-vault constants a client component needs, without the schema + * library: the kind labels, the content-index sources, the list and bulk + * limits, the coach attachment cap. `src/lib/validations/inbound-documents.ts` + * builds its zod schemas from these and re-exports them, so server code keeps + * its import; client code imports from here (a guard holds the documents and + * Coach routes to that). + */ + +/** The document-kind labels (no interpretation — a label only). */ +export const INBOUND_DOCUMENT_KINDS = [ + "DOCTOR_REPORT", + "DISCHARGE_LETTER", + "LAB_RESULT", + "IMAGING", + "PRESCRIPTION", + "REFERRAL", + "INSURANCE", + "VACCINATION", + "SICK_NOTE", + "OTHER", +] as const; +export type InboundDocumentKindValue = (typeof INBOUND_DOCUMENT_KINDS)[number]; + +/** + * How a document's content index was produced (mirrors the server `source` + * column). `vision` means an AI provider read the original (the AI-first path); + * every other value is a provider-free local extraction. The UI reads it to + * tell an AI-read document from a locally-indexed one — the former already had + * the richer read, the latter can still be offered "Read with AI". + */ +export const DOCUMENT_CONTENT_INDEX_SOURCES = [ + "vision", + "text-ocr", + "local-pdf", + "local-ocr", +] as const; +export type DocumentContentIndexSourceValue = + (typeof DOCUMENT_CONTENT_INDEX_SOURCES)[number]; + +/** True when the index was produced by an AI provider reading the original. */ +export function isAiReadSource( + source: DocumentContentIndexSourceValue | string | null, +): boolean { + return source === "vision"; +} + +export const DOCUMENT_LIST_DEFAULT_LIMIT = 50; + +/** Max document ids one bulk request may touch. */ +export const DOCUMENT_BULK_MAX_IDS = 100; + +/** Max documents attachable to one coach conversation. Bounds prompt size + the + * numeric-grounding union; five labelled documents is already past the point a + * chat answer stays useful. */ +export const MAX_COACH_ATTACHMENTS = 5; diff --git a/src/lib/report-selection/catalogue.ts b/src/lib/report-selection/catalogue.ts index b0c71e16d..8d622dd9f 100644 --- a/src/lib/report-selection/catalogue.ts +++ b/src/lib/report-selection/catalogue.ts @@ -424,3 +424,16 @@ export function leafGroup(leaf: ReportLeafId): ReportGroupId { ? STRUCTURED_LEAF_GROUP[leaf] : MEASUREMENT_LEAF_GROUP[leaf]; } + +const LEAF_ORDER = new Map( + ALL_LEAF_IDS.map((leaf, index) => [leaf, index]), +); + +/** Stable ordering helper, exported for the panel's own state folding. */ +export function orderLeaves( + leaves: Iterable, +): readonly ReportLeafId[] { + return [...new Set(leaves)].sort( + (a, b) => (LEAF_ORDER.get(a) ?? 0) - (LEAF_ORDER.get(b) ?? 0), + ); +} diff --git a/src/lib/report-selection/selection.ts b/src/lib/report-selection/selection.ts index 50adaaf96..d484e5221 100644 --- a/src/lib/report-selection/selection.ts +++ b/src/lib/report-selection/selection.ts @@ -14,7 +14,12 @@ */ import { z } from "zod/v4"; -import { isReportLeafId, type ReportLeafId, ALL_LEAF_IDS } from "./catalogue"; +import { + isReportLeafId, + orderLeaves, + type ReportLeafId, + ALL_LEAF_IDS, +} from "./catalogue"; declare const brand: unique symbol; @@ -40,10 +45,6 @@ export const reportSelectionSchema = z export type ReportSelectionInput = z.infer; -const LEAF_ORDER = new Map( - ALL_LEAF_IDS.map((leaf, index) => [leaf, index]), -); - function mint(leaves: Iterable): ReportSelection { const set = new Set(leaves); const ordered = ALL_LEAF_IDS.filter((leaf) => set.has(leaf)); @@ -121,11 +122,6 @@ export function isEmptySelection(selection: ReportSelection): boolean { return selection.leaves.length === 0; } -/** Stable ordering helper, exported for the panel's own state folding. */ -export function orderLeaves( - leaves: Iterable, -): readonly ReportLeafId[] { - return [...new Set(leaves)].sort( - (a, b) => (LEAF_ORDER.get(a) ?? 0) - (LEAF_ORDER.get(b) ?? 0), - ); -} +// The ordering helper lives beside the catalogue it orders, so a client +// panel that only folds its own state does not pull the schema library in. +export { orderLeaves }; diff --git a/src/lib/validations/coach-prefs.ts b/src/lib/validations/coach-prefs.ts index c7ad30325..34551646d 100644 --- a/src/lib/validations/coach-prefs.ts +++ b/src/lib/validations/coach-prefs.ts @@ -14,11 +14,22 @@ */ import { z } from "zod/v4"; +import { REASONING_LEVELS } from "@/lib/ai/reasoning/levels"; import { - DEFAULT_REASONING_LEVEL, - REASONING_LEVELS, - type ReasoningLevel, -} from "@/lib/ai/reasoning/levels"; + COACH_DATA_CLUSTERS, + DEFAULT_COACH_PREFS, +} from "@/lib/ai/coach/prefs-constants"; + +// The values a client component needs live in a zod-free module, so the +// Coach and the settings pages do not pull the schema library in with them. +// Re-exported here for the server code that imports them from the schema. +export { + COACH_DATA_CLUSTERS, + DEFAULT_COACH_CLUSTERS, + DEFAULT_COACH_PREFS, + coachReasoningLevel, + type CoachDataCluster, +} from "@/lib/ai/coach/prefs-constants"; /** * Tone presets the Coach system-prompt prefix toggles between. v1.4.22 @@ -99,36 +110,7 @@ export type CoachDefaultWindow = z.infer; * The cluster set is the opt-in source of truth going forward; * `excludeMetrics` stays valid for back-compat and only narrows. */ -export const coachDataClusterEnum = z.enum([ - "cardio", - "body", - "activity", - "workouts", - "sleep", - "mood", - "glucose", - "medication", - "mobility", - "environment", -]); -export type CoachDataCluster = z.infer; - -/** - * Clusters enabled when the user has never touched the cluster picker - * (`dataClusters === undefined`). These four reproduce today's legacy - * five domains: cardio carries BP + pulse, body carries weight, mood - * and medication map straight through. The additive members riding - * inside cardio/body (HRV, resting HR, body-composition) only surface - * when the user actually has rows for them — empty blocks are dropped — - * so a web-only account stays close to the legacy 5-domain output while - * an iOS account quietly gains the extra signals it already stores. - */ -export const DEFAULT_COACH_CLUSTERS: ReadonlyArray = [ - "cardio", - "body", - "mood", - "medication", -]; +export const coachDataClusterEnum = z.enum(COACH_DATA_CLUSTERS); /** * v1.18.1 — Coach cadence-suggestion state. Persisted @@ -216,25 +198,6 @@ export const coachPrefsSchema = z.object({ export type CoachPrefs = z.infer; -/** - * Default preferences applied when the user has never opened the - * settings cog. Equivalent to `coachPrefsSchema.parse({})` but - * returned as a plain object so call sites can compare references - * (e.g., short-circuit a snapshot rebuild when prefs match defaults). - */ -export const DEFAULT_COACH_PREFS: CoachPrefs = { - tone: "warm", - verbosity: "default", - excludeMetrics: [], - showEvidenceByDefault: false, - defaultWindow: "allTime", -}; - -/** The person's reasoning level; an absent key is the default, `medium`. */ -export function coachReasoningLevel(prefs: CoachPrefs): ReasoningLevel { - return prefs.reasoning ?? DEFAULT_REASONING_LEVEL; -} - /** * Parse a row's `coachPrefsJson` Json blob into a typed `CoachPrefs`, * falling back to defaults when the row is null. Keeps the call sites at diff --git a/src/lib/validations/inbound-documents.ts b/src/lib/validations/inbound-documents.ts index 32e41d25b..253ff3bc3 100644 --- a/src/lib/validations/inbound-documents.ts +++ b/src/lib/validations/inbound-documents.ts @@ -25,9 +25,33 @@ * it is always narrowed from the session. */ import { z } from "zod/v4"; +import { + DOCUMENT_BULK_MAX_IDS, + DOCUMENT_CONTENT_INDEX_SOURCES, + DOCUMENT_LIST_DEFAULT_LIMIT, + INBOUND_DOCUMENT_KINDS, + MAX_COACH_ATTACHMENTS, + type DocumentContentIndexSourceValue, + type InboundDocumentKindValue, +} from "@/lib/documents/inbound-constants"; import type { AiCapabilityState } from "@/lib/ai/capabilities/types"; +// The constants and label lists a client needs live in a zod-free module, so +// a component that wants the list of document kinds does not pull the schema +// library into its route. Re-exported here for the server code that already +// imports them from the schema module. +export { + DOCUMENT_BULK_MAX_IDS, + DOCUMENT_CONTENT_INDEX_SOURCES, + DOCUMENT_LIST_DEFAULT_LIMIT, + INBOUND_DOCUMENT_KINDS, + MAX_COACH_ATTACHMENTS, + isAiReadSource, + type DocumentContentIndexSourceValue, + type InboundDocumentKindValue, +} from "@/lib/documents/inbound-constants"; + /** Max facts a single document may stage / confirm. A dense letter is ~40. */ export const INBOUND_MAX_FACTS = 120; @@ -47,44 +71,6 @@ export const INBOUND_FACT_TYPES = [ ] as const; export type InboundFactType = (typeof INBOUND_FACT_TYPES)[number]; -/** The document-kind labels (no interpretation — a label only). */ -export const INBOUND_DOCUMENT_KINDS = [ - "DOCTOR_REPORT", - "DISCHARGE_LETTER", - "LAB_RESULT", - "IMAGING", - "PRESCRIPTION", - "REFERRAL", - "INSURANCE", - "VACCINATION", - "SICK_NOTE", - "OTHER", -] as const; -export type InboundDocumentKindValue = (typeof INBOUND_DOCUMENT_KINDS)[number]; - -/** - * How a document's content index was produced (mirrors the server `source` - * column). `vision` means an AI provider read the original (the AI-first path); - * every other value is a provider-free local extraction. The UI reads it to - * tell an AI-read document from a locally-indexed one — the former already had - * the richer read, the latter can still be offered "Read with AI". - */ -export const DOCUMENT_CONTENT_INDEX_SOURCES = [ - "vision", - "text-ocr", - "local-pdf", - "local-ocr", -] as const; -export type DocumentContentIndexSourceValue = - (typeof DOCUMENT_CONTENT_INDEX_SOURCES)[number]; - -/** True when the index was produced by an AI provider reading the original. */ -export function isAiReadSource( - source: DocumentContentIndexSourceValue | string | null, -): boolean { - return source === "vision"; -} - /** * Narrow the free-String DB `source` column to the DTO union, or `null` for an * absent / unrecognised value. The column is a free String server-side (adding @@ -880,7 +866,6 @@ export const DOCUMENT_LIST_SORTS = [ export type DocumentListSort = (typeof DOCUMENT_LIST_SORTS)[number]; export const DOCUMENT_LIST_MAX_LIMIT = 100; -export const DOCUMENT_LIST_DEFAULT_LIMIT = 50; /** * The library list query: title/filename search, category filter, a @@ -917,9 +902,6 @@ export type DocumentListQuery = z.infer; // ─── Vault: bulk actions ─────────────────────────────────────────────────── -/** Max document ids one bulk request may touch. */ -export const DOCUMENT_BULK_MAX_IDS = 100; - export const DOCUMENT_BULK_ACTIONS = [ "setKind", "linkEpisode", @@ -1110,11 +1092,6 @@ export type DocumentChatHistoryQuery = z.infer< // ─── Fenced multi-document coach chat (S7) ────────────────────────────────── -/** Max documents attachable to one coach conversation. Bounds prompt size + the - * numeric-grounding union; five labelled documents is already past the point a - * chat answer stays useful. */ -export const MAX_COACH_ATTACHMENTS = 5; - /** * Request body for `POST /api/insights/chat/fenced`. `.strict()` — the schema * REFUSES the tool-mode fields (`scope` / `guidedQuestion` / `prefill`) and