mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
perf(bundle): keep zod out of the documents and Coach client routes
Both routes reached the schema library through a constant: the list of document kinds, the list and bulk limits, the attachment cap, the Coach data clusters and preference defaults, the conversation title length, the scope source list and the report leaf order. Each sat in a schema module, so importing the number shipped the library. The constants now live in zod-free modules (lib/documents/inbound-constants, lib/ai/coach/prefs-constants, lib/ai/coach/scope-sources, lib/report-selection/catalogue) and the schema modules re-export them for server code; the Coach page checks its scope parameter against the closed list instead of parsing it. A guard walks each route's static client graph with the TypeScript parser and fails on a zod import. Two guards follow earlier commits in this branch: the fetch-limit allowlist names the shared document picker sheet, and the card density test states a floor on the cards it finds.
This commit is contained in:
1 parent
3bae93256d
commit
d0677adac9
26 files changed
+421
-190
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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<string, boolean>();
|
||||
const parent = new Map<string, string>();
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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<CoachDataCluster> =
|
||||
coachDataClusterEnum.options;
|
||||
const CLUSTER_OPTIONS: ReadonlyArray<CoachDataCluster> = COACH_DATA_CLUSTERS;
|
||||
|
||||
/**
|
||||
* The lookback options, narrowest first. The choice is a limit
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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-
|
||||
|
||||
@@ -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";
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<Record<CoachStepReason, string>> =
|
||||
invalid_arguments: "coach.step.reason.invalidArguments",
|
||||
};
|
||||
|
||||
const SCOPE_SOURCES: ReadonlySet<string> = new Set(
|
||||
coachScopeSourceSchema.options,
|
||||
);
|
||||
const SCOPE_SOURCES: ReadonlySet<string> = new Set(COACH_SCOPE_SOURCES);
|
||||
|
||||
/**
|
||||
* A domain's display name. A measurement-backed scope source reuses the
|
||||
|
||||
@@ -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<CoachDataCluster> = [
|
||||
"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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -424,3 +424,16 @@ export function leafGroup(leaf: ReportLeafId): ReportGroupId {
|
||||
? STRUCTURED_LEAF_GROUP[leaf]
|
||||
: MEASUREMENT_LEAF_GROUP[leaf];
|
||||
}
|
||||
|
||||
const LEAF_ORDER = new Map<ReportLeafId, number>(
|
||||
ALL_LEAF_IDS.map((leaf, index) => [leaf, index]),
|
||||
);
|
||||
|
||||
/** Stable ordering helper, exported for the panel's own state folding. */
|
||||
export function orderLeaves(
|
||||
leaves: Iterable<ReportLeafId>,
|
||||
): readonly ReportLeafId[] {
|
||||
return [...new Set(leaves)].sort(
|
||||
(a, b) => (LEAF_ORDER.get(a) ?? 0) - (LEAF_ORDER.get(b) ?? 0),
|
||||
);
|
||||
}
|
||||
@@ -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<typeof reportSelectionSchema>;
|
||||
|
||||
const LEAF_ORDER = new Map<ReportLeafId, number>(
|
||||
ALL_LEAF_IDS.map((leaf, index) => [leaf, index]),
|
||||
);
|
||||
|
||||
function mint(leaves: Iterable<ReportLeafId>): ReportSelection {
|
||||
const set = new Set<ReportLeafId>(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<ReportLeafId>,
|
||||
): 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 };
|
||||
@@ -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<typeof coachDefaultWindowEnum>;
|
||||
* 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<typeof coachDataClusterEnum>;
|
||||
|
||||
/**
|
||||
* 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<CoachDataCluster> = [
|
||||
"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<typeof coachPrefsSchema>;
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -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<typeof documentListQuerySchema>;
|
||||
|
||||
// ─── 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
|
||||
|
||||
Reference in new issue
Block a user