From c86c7d1a790b8c6a326619ae9781b705e7f962bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 04:15:11 +0200 Subject: [PATCH] =?UTF-8?q?refactor(lint):=20enforce=20the=20lib=20?= =?UTF-8?q?=E2=86=92=20components=20and=20client=20=E2=86=92=20server=20bo?= =?UTF-8?q?undaries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the in-repo rule `healthlog/import-boundaries`: - a file under src/lib may not import src/components or src/app (alias, relative, re-export and dynamic forms, type-only included); - a "use client" file may not make a value import of the database client, the crypto module, the Prisma client entry, the job queue, pg, pg-boss or next/headers. Type-only imports and the Prisma enums entry stay legal. The four lib files that still import upward are listed in the rule's allow map with a reason each; a listed file that stops importing upward is reported, so the map can only shrink. Five of the nine upward imports are gone by moving pure modules to their layer, without behaviour change: - supply-runway estimate → src/lib/medications/supply-runway.ts (read by the low-stock job and the medication list read); - weekday BYDAY tokens → src/lib/medications/scheduling/weekday-tokens.ts (read by the medication extraction prompt); - settings section slugs → src/lib/settings/section-slugs.ts (read by the record-settings classification); - the command palette index → src/components/command-palette/ (it is a client index built from component registries). Proof the rule can fail: an import of @/components/ui/card appended to src/lib/medications/supply-runway.ts reported libUpward, and a planted "use client" file importing @/lib/db reported clientServerOnly; both were removed again. --- e2e/mobile-route-overflow.spec.ts | 2 +- .../__tests__/import-boundaries.test.mjs | 147 +++++++++++++ eslint-plugins/healthlog/import-boundaries.js | 199 ++++++++++++++++++ eslint-plugins/healthlog/index.js | 4 + eslint.config.mjs | 21 ++ src/__tests__/settings-legacy-routes.test.ts | 2 +- src/app/api/medications/[id]/glp1/route.ts | 2 +- src/app/api/medications/[id]/route.ts | 2 +- .../sleep/__tests__/empty-state-href.test.ts | 2 +- src/app/settings/[section]/page.tsx | 2 +- src/components/admin/section-slugs.ts | 2 +- .../__tests__/build-index.test.ts | 2 +- .../__tests__/command-palette.test.tsx | 2 +- .../command-palette/build-index.ts | 4 +- .../command-palette/command-palette.tsx | 2 +- .../command-palette/use-palette-index.ts | 2 +- .../sleep-overview-empty-href.test.ts | 2 +- .../__tests__/medication-form-v15.test.tsx | 6 +- .../detail/medication-detail-tabs.tsx | 2 +- .../medications/glp1-medication-card.tsx | 2 +- .../medications/medication-card.tsx | 2 +- .../medications/medication-table.tsx | 2 +- .../medications/scheduling/cadence-picker.tsx | 4 +- .../medications/scheduling/legacy-bridge.ts | 4 +- .../medications/scheduling/types.ts | 14 +- .../medications/wizard/nl-bridge.ts | 2 +- .../wizard/steps/step6-sub-cadence.tsx | 4 +- .../__tests__/sections-i18n-parity.test.ts | 2 +- .../settings/__tests__/sections.test.tsx | 2 +- .../settings/record-settings-section-gate.tsx | 2 +- .../settings/settings-section-loader.tsx | 2 +- src/components/settings/settings-shell.tsx | 2 +- src/lib/ai/coach/medication-extract-prompt.ts | 2 +- src/lib/jobs/medication-low-stock.ts | 2 +- .../__tests__/supply-runway.test.ts | 2 +- src/lib/medications/list-read.ts | 2 +- .../medications/scheduling/weekday-tokens.ts | 17 ++ .../medications}/supply-runway.ts | 0 src/lib/record-settings/classification.ts | 2 +- .../settings/section-slugs.ts | 0 40 files changed, 429 insertions(+), 49 deletions(-) create mode 100644 eslint-plugins/healthlog/__tests__/import-boundaries.test.mjs create mode 100644 eslint-plugins/healthlog/import-boundaries.js rename src/{lib => components}/command-palette/__tests__/build-index.test.ts (99%) rename src/{lib => components}/command-palette/build-index.ts (98%) rename src/{components => lib}/medications/__tests__/supply-runway.test.ts (99%) create mode 100644 src/lib/medications/scheduling/weekday-tokens.ts rename src/{components/medications/detail => lib/medications}/supply-runway.ts (100%) rename src/{components => lib}/settings/section-slugs.ts (100%) diff --git a/e2e/mobile-route-overflow.spec.ts b/e2e/mobile-route-overflow.spec.ts index a0d0397b8..127ad3a3c 100644 --- a/e2e/mobile-route-overflow.spec.ts +++ b/e2e/mobile-route-overflow.spec.ts @@ -5,7 +5,7 @@ import type { APIRequestContext } from "@playwright/test"; import { ADMIN_SECTION_SLUGS } from "@/components/admin/section-slugs"; import { MODULE_SETTINGS_PAGE_IDS } from "@/components/settings/module-settings-page-ids"; -import { SETTINGS_SECTION_SLUGS } from "@/components/settings/section-slugs"; +import { SETTINGS_SECTION_SLUGS } from "@/lib/settings/section-slugs"; import { expect, test } from "./setup/test"; import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/eslint-plugins/healthlog/__tests__/import-boundaries.test.mjs b/eslint-plugins/healthlog/__tests__/import-boundaries.test.mjs new file mode 100644 index 000000000..992691be5 --- /dev/null +++ b/eslint-plugins/healthlog/__tests__/import-boundaries.test.mjs @@ -0,0 +1,147 @@ +/** + * Unit tests for the `healthlog/import-boundaries` rule: src/lib never + * imports components or app, and a "use client" file never makes a value + * import of a server-only module. Covers alias and relative specifiers, + * type-only imports, dynamic imports, re-exports, the allow map and its + * stale-entry report. + */ + +import { describe, it } from "vitest"; +import { RuleTester } from "eslint"; +import nextTs from "eslint-config-next/typescript"; +import rule from "../import-boundaries.js"; + +// The TypeScript parser the app lints with, taken from the same config so +// `import type` reaches the rule as it does in `pnpm lint`. +const tsParser = nextTs.find((c) => c.languageOptions?.parser).languageOptions + .parser; + +RuleTester.describe = describe; +RuleTester.it = it; +RuleTester.itOnly = it.only; + +const ruleTester = new RuleTester({ + languageOptions: { + parser: tsParser, + ecmaVersion: 2023, + sourceType: "module", + parserOptions: { ecmaFeatures: { jsx: true } }, + }, +}); + +const LIB_FILE = "/repo/src/lib/medications/example.ts"; +const CLIENT_FILE = "/repo/src/components/medications/example.tsx"; + +ruleTester.run("import-boundaries", rule, { + valid: [ + // lib importing lib, a package, and itself relatively. + { code: 'import { a } from "@/lib/x";', filename: LIB_FILE }, + { code: 'import { z } from "zod";', filename: LIB_FILE }, + { code: 'import { b } from "../y";', filename: LIB_FILE }, + // components importing components and lib is the right direction. + { + code: 'import { C } from "@/components/ui/card";', + filename: CLIENT_FILE, + }, + // Tests are out of scope. + { + code: 'import { C } from "@/components/ui/card";', + filename: "/repo/src/lib/medications/__tests__/x.test.ts", + }, + // An allowed lib file that still imports upward. + { + code: 'import { C } from "@/components/ui/card";', + filename: LIB_FILE, + options: [{ allow: { "src/lib/medications/example.ts": "reason" } }], + }, + // A server component (no directive) may import the database client. + { + code: 'import { prisma } from "@/lib/db";', + filename: "/repo/src/app/page.tsx", + }, + // A client file may import types from server modules and the enums entry. + { + code: '"use client";\nimport type { MeasurementType } from "@/generated/prisma/client";', + filename: CLIENT_FILE, + }, + { + code: '"use client";\nimport { type BackupRestoreJobView } from "@/lib/jobs/backup-restore";', + filename: CLIENT_FILE, + }, + { + code: '"use client";\nimport { MeasurementType } from "@/generated/prisma/enums";', + filename: CLIENT_FILE, + }, + // A string that only looks like the directive is not one. + { + code: 'const x = "use client";\nimport { prisma } from "@/lib/db";', + filename: CLIENT_FILE, + }, + ], + invalid: [ + { + code: 'import { C } from "@/components/ui/card";', + filename: LIB_FILE, + errors: [{ messageId: "libUpward" }], + }, + { + code: 'import type { P } from "@/app/settings/page";', + filename: LIB_FILE, + errors: [{ messageId: "libUpward" }], + }, + // Relative specifiers resolve to the same layer check. + { + code: 'import { C } from "../../components/ui/card";', + filename: LIB_FILE, + errors: [{ messageId: "libUpward" }], + }, + { + code: 'export { C } from "@/components/ui/card";', + filename: LIB_FILE, + errors: [{ messageId: "libUpward" }], + }, + { + code: 'const m = await import("@/components/ui/card");', + filename: LIB_FILE, + errors: [{ messageId: "libUpward" }], + }, + // An allowed file that stopped importing upward must leave the list. + { + code: 'import { a } from "@/lib/x";', + filename: LIB_FILE, + options: [{ allow: { "src/lib/medications/example.ts": "reason" } }], + errors: [{ messageId: "staleAllow" }], + }, + // Server-only modules in a client file. + { + code: '"use client";\nimport { prisma } from "@/lib/db";', + filename: CLIENT_FILE, + errors: [{ messageId: "clientServerOnly" }], + }, + { + code: '"use client";\nimport { encrypt } from "@/lib/crypto";', + filename: CLIENT_FILE, + errors: [{ messageId: "clientServerOnly" }], + }, + { + code: '"use client";\nimport { MeasurementType } from "@/generated/prisma/client";', + filename: CLIENT_FILE, + errors: [{ messageId: "clientServerOnly" }], + }, + { + code: '"use client";\nimport { enqueue } from "@/lib/jobs/boss-instance";', + filename: CLIENT_FILE, + errors: [{ messageId: "clientServerOnly" }], + }, + { + code: '"use client";\nimport { cookies } from "next/headers";', + filename: CLIENT_FILE, + errors: [{ messageId: "clientServerOnly" }], + }, + { + code: '"use client";\nimport { Pool } from "pg";', + filename: CLIENT_FILE, + errors: [{ messageId: "clientServerOnly" }], + }, + ], +}); diff --git a/eslint-plugins/healthlog/import-boundaries.js b/eslint-plugins/healthlog/import-boundaries.js new file mode 100644 index 000000000..09f6806bd --- /dev/null +++ b/eslint-plugins/healthlog/import-boundaries.js @@ -0,0 +1,199 @@ +/** + * @fileoverview ESLint rule — import direction between the layers of `src`. + * + * Two boundaries, both checked per import statement so the mistake shows up + * in the editor rather than in a later sweep: + * + * 1. `src/lib/**` never imports from `src/components/**` or `src/app/**`. + * The direction is `app → components → hooks → lib`. A lib module that + * reaches upward drags a component folder into server jobs and makes + * the layers impossible to split. Type-only imports count too: the + * dependency is the same, only erased at build time. + * + * 2. A `"use client"` file never makes a value import of a server-only + * module: the database client, the crypto module, the Prisma client + * entry, the job queue, `pg`, `next/headers`. Type-only imports are + * erased and stay legal, and the Prisma `enums` entry is browser-safe. + * The transitive version of this check is + * `src/__tests__/client-bundle-server-import-guard.test.ts`, which walks + * the whole client graph; this rule is the direct, local half. + * + * Existing lib → components imports are listed in the `allow` option, file + * by file, each with the reason it has not moved yet. A listed file that no + * longer imports upward is reported too, so the list can only shrink. + * + * WHAT THIS DOES NOT CATCH + * + * A dependency through a re-export in a package outside `src`, and a + * `require()` call. Neither occurs in the tree today. + */ + +"use strict"; + +const path = require("node:path"); + +function toPosix(filename) { + return filename.replace(/\\/g, "/"); +} + +function isTestFile(filename) { + return ( + /\/__tests__\//.test(filename) || + /\.(test|spec)\.[cm]?[jt]sx?$/.test(filename) || + /\/__mocks__\//.test(filename) + ); +} + +/** Path of `filename` relative to the repository's `src/`, or null. */ +function srcRelative(filename) { + const idx = filename.lastIndexOf("/src/"); + return idx === -1 ? null : filename.slice(idx + 1); +} + +/** Where a specifier lands inside `src/`, or null for a package. */ +function targetInSrc(spec, filename) { + if (spec.startsWith("@/")) return `src/${spec.slice(2)}`; + if (spec.startsWith(".")) { + const abs = toPosix(path.resolve(path.dirname(filename), spec)); + return srcRelative(abs); + } + return null; +} + +const SERVER_ONLY_PACKAGES = [ + /^pg$/, + /^pg-boss$/, + /^@prisma\//, + /^next\/headers$/, +]; + +const SERVER_ONLY_SRC = [ + /^src\/lib\/db(-session)?$/, + /^src\/lib\/crypto(\/|$)/, + /^src\/lib\/jobs\//, + /^src\/generated\/prisma\/(client|internal|models)(\/|$)/, +]; + +function isServerOnly(spec, filename) { + if (SERVER_ONLY_PACKAGES.some((re) => re.test(spec))) return true; + const target = targetInSrc(spec, filename); + if (!target) return false; + const bare = target.replace(/\.(ts|tsx|js|mjs)$/, ""); + return SERVER_ONLY_SRC.some((re) => re.test(bare)); +} + +function isTypeOnly(node) { + if (node.importKind === "type" || node.exportKind === "type") return true; + const specifiers = node.specifiers ?? []; + return ( + specifiers.length > 0 && + specifiers.every((s) => s.importKind === "type" || s.exportKind === "type") + ); +} + +function hasUseClient(program) { + for (const stmt of program.body) { + if (stmt.type !== "ExpressionStatement" || !stmt.directive) break; + if (stmt.directive === "use client") return true; + } + return false; +} + +/** @type {import("eslint").Rule.RuleModule} */ +const importBoundariesRule = { + meta: { + type: "problem", + docs: { + description: + "Keep src/lib below components and app, and server-only modules out of client files.", + }, + schema: [ + { + type: "object", + properties: { + allow: { + type: "object", + additionalProperties: { type: "string", minLength: 1 }, + }, + }, + additionalProperties: false, + }, + ], + messages: { + libUpward: + "src/lib must not import {{target}}. Move the shared piece into src/lib (or the importer into the component folder); an exception goes into the rule's `allow` map in eslint.config.mjs with its reason.", + staleAllow: + "This file is in the import-boundaries `allow` map but no longer imports from src/components or src/app. Remove the entry so the list keeps shrinking.", + clientServerOnly: + 'A "use client" file must not import {{spec}}: it is server-only and would ship to the browser. Import a type with `import type`, or move the call behind an API route or a server component.', + }, + }, + + create(context) { + const filename = toPosix(context.filename ?? context.getFilename?.() ?? ""); + const rel = srcRelative(filename); + if (!rel || isTestFile(filename)) return {}; + + const allow = (context.options[0] && context.options[0].allow) || {}; + const inLib = rel.startsWith("src/lib/"); + const allowed = inLib && Object.hasOwn(allow, rel); + let upwardSeen = false; + let isClient = false; + + function check(node, source) { + if (!source || typeof source.value !== "string") return; + const spec = source.value; + if (inLib) { + const target = targetInSrc(spec, filename); + if ( + target && + (target.startsWith("src/components/") || + target.startsWith("src/app/")) + ) { + upwardSeen = true; + if (!allowed) { + context.report({ + node: source, + messageId: "libUpward", + data: { target: spec }, + }); + } + } + } + if (isClient && !isTypeOnly(node) && isServerOnly(spec, filename)) { + context.report({ + node: source, + messageId: "clientServerOnly", + data: { spec }, + }); + } + } + + return { + Program(program) { + isClient = hasUseClient(program); + }, + ImportDeclaration(node) { + check(node, node.source); + }, + ExportNamedDeclaration(node) { + if (node.source) check(node, node.source); + }, + ExportAllDeclaration(node) { + check(node, node.source); + }, + ImportExpression(node) { + if (node.source && node.source.type === "Literal") { + check(node, node.source); + } + }, + "Program:exit"(program) { + if (allowed && !upwardSeen) { + context.report({ node: program, messageId: "staleAllow" }); + } + }, + }; + }, +}; + +module.exports = importBoundariesRule; diff --git a/eslint-plugins/healthlog/index.js b/eslint-plugins/healthlog/index.js index ef0b03df6..590d89d7e 100644 --- a/eslint-plugins/healthlog/index.js +++ b/eslint-plugins/healthlog/index.js @@ -21,6 +21,8 @@ * toLocaleString(). * - `no-raw-card-shell` — a card surface is a or a shell * primitive, not bg-card + border + rounded-xl. + * - `import-boundaries` — src/lib never imports components or app, and + * a "use client" file imports no server module. */ "use strict"; @@ -36,6 +38,7 @@ const noUtcDayKey = require("./no-utc-day-key.js"); const noPlanningTags = require("./no-planning-tags.js"); const noUnlocalisedDecimal = require("./no-unlocalised-decimal.js"); const noRawCardShell = require("./no-raw-card-shell.js"); +const importBoundaries = require("./import-boundaries.js"); module.exports = { rules: { @@ -55,5 +58,6 @@ module.exports = { "no-planning-tags": noPlanningTags, "no-unlocalised-decimal": noUnlocalisedDecimal, "no-raw-card-shell": noRawCardShell, + "import-boundaries": importBoundaries, }, }; diff --git a/eslint.config.mjs b/eslint.config.mjs index a6bf8aac0..ce53edc99 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -176,6 +176,27 @@ const eslintConfig = defineConfig([ ], }, ], + // Layer direction: src/lib never imports src/components or src/app, and + // a "use client" file makes no value import of the database client, + // crypto, the Prisma client entry, the job queue, pg or next/headers. + // The files below still import upward; each says why it has not moved. + // A listed file that stops importing upward fails until it is removed, + // so the map only shrinks. See the rule header. + "healthlog/import-boundaries": [ + "error", + { + allow: { + "src/lib/insights/metric-catalog.ts": + "reads SUB_PAGE_TABS, which is declared inside the client tab strip component together with its labels and order", + "src/lib/insights/target-adjust-context.tsx": + "a React provider that renders TargetEditSheet; it belongs under components but has importers across 21 insight pages", + "src/lib/errors/error-toast.tsx": + "a React toast renderer that renders ErrorIdLine; it belongs under components but is imported by most client surfaces", + "src/lib/module-list-prefs.ts": + "a client hook module that raises the shared outcome toast; it moves with the module list settings surface", + }, + }, + ], }, }, // v1.28.17 — every recharts-rendering component funnels through the diff --git a/src/__tests__/settings-legacy-routes.test.ts b/src/__tests__/settings-legacy-routes.test.ts index c04cbe5ad..870b11045 100644 --- a/src/__tests__/settings-legacy-routes.test.ts +++ b/src/__tests__/settings-legacy-routes.test.ts @@ -19,7 +19,7 @@ import { dirname, join } from "node:path"; import { describe, expect, it } from "vitest"; import nextConfig from "../../next.config"; -import { SETTINGS_SECTION_SLUGS } from "@/components/settings/section-slugs"; +import { SETTINGS_SECTION_SLUGS } from "@/lib/settings/section-slugs"; import { LAYOUT_GROUP_IDS } from "@/components/settings/layout-groups"; import { MODULE_SETTINGS_PAGE_IDS } from "@/components/settings/module-settings-page-ids"; import { diff --git a/src/app/api/medications/[id]/glp1/route.ts b/src/app/api/medications/[id]/glp1/route.ts index ffeff1384..eb5781d23 100644 --- a/src/app/api/medications/[id]/glp1/route.ts +++ b/src/app/api/medications/[id]/glp1/route.ts @@ -34,7 +34,7 @@ import { estimateRunwayDays, lowStockTriggerDays, type RunwaySchedule, -} from "@/components/medications/detail/supply-runway"; +} from "@/lib/medications/supply-runway"; import { resolveLowStockRunwayDays, resolveReorderLeadDays, diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index e711a713d..a85ee9561 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -54,7 +54,7 @@ import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { effectiveUnitsPerDose, estimateUnitsRunwayDays, -} from "@/components/medications/detail/supply-runway"; +} from "@/lib/medications/supply-runway"; import { serializeScheduleUnitsPerDose } from "@/lib/medications/schedule-units-dto"; import { hhmmToMinutesOrNull } from "@/lib/medications/scheduling/hhmm"; import { getUserTodayBounds } from "@/lib/tz/local-day"; diff --git a/src/app/insights/sleep/__tests__/empty-state-href.test.ts b/src/app/insights/sleep/__tests__/empty-state-href.test.ts index 58613b6ba..0e3d1f414 100644 --- a/src/app/insights/sleep/__tests__/empty-state-href.test.ts +++ b/src/app/insights/sleep/__tests__/empty-state-href.test.ts @@ -10,7 +10,7 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { isSettingsSectionSlug } from "@/components/settings/section-slugs"; +import { isSettingsSectionSlug } from "@/lib/settings/section-slugs"; describe("sleep empty-state CTA href", () => { it("links to /settings/integrations (a real settings slug)", () => { diff --git a/src/app/settings/[section]/page.tsx b/src/app/settings/[section]/page.tsx index c58be6aeb..5c17bd69f 100644 --- a/src/app/settings/[section]/page.tsx +++ b/src/app/settings/[section]/page.tsx @@ -3,7 +3,7 @@ import { notFound } from "next/navigation"; import { SETTINGS_SECTION_SLUGS, isSettingsSectionSlug, -} from "@/components/settings/section-slugs"; +} from "@/lib/settings/section-slugs"; import { SettingsShell } from "@/components/settings/settings-shell"; import { SettingsSectionLoader, diff --git a/src/components/admin/section-slugs.ts b/src/components/admin/section-slugs.ts index 55ac2e4f7..e9c61c703 100644 --- a/src/components/admin/section-slugs.ts +++ b/src/components/admin/section-slugs.ts @@ -1,6 +1,6 @@ /** * Server-safe constants for the v1.5 admin split. Mirrors the pattern in - * `src/components/settings/section-slugs.ts` so server-rendered code paths + * `src/lib/settings/section-slugs.ts` so server-rendered code paths * (`generateStaticParams()`, the page guard, route-existence tests) can * import the slug list without dragging the `` client bundle * into the server graph. diff --git a/src/lib/command-palette/__tests__/build-index.test.ts b/src/components/command-palette/__tests__/build-index.test.ts similarity index 99% rename from src/lib/command-palette/__tests__/build-index.test.ts rename to src/components/command-palette/__tests__/build-index.test.ts index 7540fd70b..0dfdf379b 100644 --- a/src/lib/command-palette/__tests__/build-index.test.ts +++ b/src/components/command-palette/__tests__/build-index.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import { buildPaletteIndex, type PaletteIndexInput, -} from "@/lib/command-palette/build-index"; +} from "@/components/command-palette/build-index"; const t = (key: string) => key; diff --git a/src/components/command-palette/__tests__/command-palette.test.tsx b/src/components/command-palette/__tests__/command-palette.test.tsx index aeeb64b5f..c8c36fa01 100644 --- a/src/components/command-palette/__tests__/command-palette.test.tsx +++ b/src/components/command-palette/__tests__/command-palette.test.tsx @@ -3,7 +3,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { Activity, Moon, Plus, Smile } from "lucide-react"; import { I18nProvider } from "@/lib/i18n/context"; -import type { PaletteEntry } from "@/lib/command-palette/build-index"; +import type { PaletteEntry } from "@/components/command-palette/build-index"; import type { SearchResponse } from "@/lib/search/contract"; vi.mock("next/navigation", () => ({ diff --git a/src/lib/command-palette/build-index.ts b/src/components/command-palette/build-index.ts similarity index 98% rename from src/lib/command-palette/build-index.ts rename to src/components/command-palette/build-index.ts index 68e2bf471..112c8ac74 100644 --- a/src/lib/command-palette/build-index.ts +++ b/src/components/command-palette/build-index.ts @@ -24,8 +24,8 @@ import { SETTINGS_CARDS, SETTINGS_LAYOUT_PAGES, SETTINGS_MODULE_PAGES, -} from "./settings-cards"; -import { PALETTE_SYNONYMS } from "./synonyms"; +} from "@/lib/command-palette/settings-cards"; +import { PALETTE_SYNONYMS } from "@/lib/command-palette/synonyms"; /** * The command palette's index: every place and action this session may diff --git a/src/components/command-palette/command-palette.tsx b/src/components/command-palette/command-palette.tsx index 14b19727f..148492314 100644 --- a/src/components/command-palette/command-palette.tsx +++ b/src/components/command-palette/command-palette.tsx @@ -30,7 +30,7 @@ import { EMPTY_STATE_ACTIONS, type PaletteEntry, type PaletteGroup, -} from "@/lib/command-palette/build-index"; +} from "@/components/command-palette/build-index"; import { rankEntries } from "@/lib/command-palette/rank"; import { pushRecent, readRecent } from "@/lib/command-palette/recent"; import { scrollToAnchorWhenReady } from "@/lib/dom/scroll-to-anchor"; diff --git a/src/components/command-palette/use-palette-index.ts b/src/components/command-palette/use-palette-index.ts index 3d98e2a0b..32eb9a68d 100644 --- a/src/components/command-palette/use-palette-index.ts +++ b/src/components/command-palette/use-palette-index.ts @@ -14,7 +14,7 @@ import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { buildPaletteIndex, type PaletteEntry, -} from "@/lib/command-palette/build-index"; +} from "@/components/command-palette/build-index"; import { useTranslations } from "@/lib/i18n/context"; import { MODULE_REGISTRY, diff --git a/src/components/insights/__tests__/sleep-overview-empty-href.test.ts b/src/components/insights/__tests__/sleep-overview-empty-href.test.ts index 6a336abaa..441a7b5f2 100644 --- a/src/components/insights/__tests__/sleep-overview-empty-href.test.ts +++ b/src/components/insights/__tests__/sleep-overview-empty-href.test.ts @@ -11,7 +11,7 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { isSettingsSectionSlug } from "@/components/settings/section-slugs"; +import { isSettingsSectionSlug } from "@/lib/settings/section-slugs"; describe("sleep-overview empty-state CTA href", () => { it("links to /settings/integrations (a real settings slug)", () => { diff --git a/src/components/medications/__tests__/medication-form-v15.test.tsx b/src/components/medications/__tests__/medication-form-v15.test.tsx index c598921b7..f68d69598 100644 --- a/src/components/medications/__tests__/medication-form-v15.test.tsx +++ b/src/components/medications/__tests__/medication-form-v15.test.tsx @@ -24,10 +24,8 @@ import { weekdayIndexesToTokens, weekdayIndexToToken, } from "@/components/medications/scheduling/legacy-bridge"; -import { - DEFAULT_SUB_CONTROLS, - type WeekdayToken, -} from "@/components/medications/scheduling/types"; +import { DEFAULT_SUB_CONTROLS } from "@/components/medications/scheduling/types"; +import { type WeekdayToken } from "@/lib/medications/scheduling/weekday-tokens"; describe("weekdayIndexToToken", () => { it("maps Sunday-anchored 0..6 to SU/MO/TU/WE/TH/FR/SA", () => { diff --git a/src/components/medications/detail/medication-detail-tabs.tsx b/src/components/medications/detail/medication-detail-tabs.tsx index 140e60ab6..ff57868ce 100644 --- a/src/components/medications/detail/medication-detail-tabs.tsx +++ b/src/components/medications/detail/medication-detail-tabs.tsx @@ -88,7 +88,7 @@ import { PhaseConfigSheet } from "@/components/medications/sections/phase-config import { SideEffectsSection } from "@/components/medications/side-effects-section"; import { ChartSkeleton } from "@/components/charts/chart-skeleton"; import { TitrationTimeline } from "@/components/medications/titration-timeline"; -import { estimateRunwayDays } from "@/components/medications/detail/supply-runway"; +import { estimateRunwayDays } from "@/lib/medications/supply-runway"; import type { SupplySummary } from "@/lib/medications/inventory/summary"; import { MedicationWizardDialog } from "@/components/medications/wizard/medication-wizard-dialog"; import { parseScheduleRecurrence } from "@/lib/medication-schedule"; diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx index 72a73b447..5024217cd 100644 --- a/src/components/medications/glp1-medication-card.tsx +++ b/src/components/medications/glp1-medication-card.tsx @@ -38,7 +38,7 @@ import { estimateDailyDoseCount, estimateRunwayDays, lowStockTriggerDays, -} from "@/components/medications/detail/supply-runway"; +} from "@/lib/medications/supply-runway"; import { useAuth } from "@/hooks/use-auth"; import { useMedicationComplianceSummary } from "@/lib/queries/use-medication-compliance-summary"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index 7c7a1f5ed..d3033e08b 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -24,7 +24,7 @@ import { estimateDailyDoseCount, estimateRunwayDays, lowStockTriggerDays, -} from "@/components/medications/detail/supply-runway"; +} from "@/lib/medications/supply-runway"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { invalidateKeys, diff --git a/src/components/medications/medication-table.tsx b/src/components/medications/medication-table.tsx index 56f11405c..5795b6d3e 100644 --- a/src/components/medications/medication-table.tsx +++ b/src/components/medications/medication-table.tsx @@ -28,7 +28,7 @@ import { Skeleton } from "@/components/ui/skeleton"; import { MedicationStatusPill } from "@/components/medications/card-parts/medication-status-pill"; import { useWeekdayLabel } from "@/components/medications/card-parts/medication-next-last-slot"; import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant"; -import { estimateRunwayDays } from "@/components/medications/detail/supply-runway"; +import { estimateRunwayDays } from "@/lib/medications/supply-runway"; import { useMedicationIntake } from "@/components/medications/use-medication-intake"; import { reduceCurrentWindowStatus } from "@/lib/medications/window-status"; import { resolveNextDueDayLabel } from "@/lib/medications/next-due-day-label"; diff --git a/src/components/medications/scheduling/cadence-picker.tsx b/src/components/medications/scheduling/cadence-picker.tsx index 4a4271e0b..1ecff45bc 100644 --- a/src/components/medications/scheduling/cadence-picker.tsx +++ b/src/components/medications/scheduling/cadence-picker.tsx @@ -59,9 +59,11 @@ import { type CadenceSubControls, type CadenceValue, DEFAULT_SUB_CONTROLS, +} from "./types"; +import { type WeekdayToken, WEEKDAY_TOKENS, -} from "./types"; +} from "@/lib/medications/scheduling/weekday-tokens"; export interface CadencePickerProps { value: CadenceValue; diff --git a/src/components/medications/scheduling/legacy-bridge.ts b/src/components/medications/scheduling/legacy-bridge.ts index 5af04db0f..7ce92cff9 100644 --- a/src/components/medications/scheduling/legacy-bridge.ts +++ b/src/components/medications/scheduling/legacy-bridge.ts @@ -17,9 +17,11 @@ import { type CadenceSubControls, type CadenceValue, DEFAULT_SUB_CONTROLS, +} from "./types"; +import { type WeekdayToken, WEEKDAY_TOKENS, -} from "./types"; +} from "@/lib/medications/scheduling/weekday-tokens"; /** * Legacy schedule shape the page passes to the form. Sunday-anchored diff --git a/src/components/medications/scheduling/types.ts b/src/components/medications/scheduling/types.ts index 823e42a3f..5df6d4cea 100644 --- a/src/components/medications/scheduling/types.ts +++ b/src/components/medications/scheduling/types.ts @@ -17,6 +17,8 @@ * structured values; the wizard / form maps them to the wire format. */ +import type { WeekdayToken } from "@/lib/medications/scheduling/weekday-tokens"; + /** * The eight cadence kinds the wizard exposes. Maps to the engine's * dispatch tiers in `src/lib/medications/scheduling/recurrence.ts`: @@ -71,18 +73,6 @@ export interface CadenceSubControls { rollingDays: number; } -/** RFC 5545 BYDAY tokens — the order the chip row renders. */ -export const WEEKDAY_TOKENS = [ - "MO", - "TU", - "WE", - "TH", - "FR", - "SA", - "SU", -] as const; -export type WeekdayToken = (typeof WEEKDAY_TOKENS)[number]; - /** Sensible defaults so a brand-new picker has valid sub-controls. */ export const DEFAULT_SUB_CONTROLS: CadenceSubControls = { weekdays: ["MO"], diff --git a/src/components/medications/wizard/nl-bridge.ts b/src/components/medications/wizard/nl-bridge.ts index 9279a2211..685473f9b 100644 --- a/src/components/medications/wizard/nl-bridge.ts +++ b/src/components/medications/wizard/nl-bridge.ts @@ -16,8 +16,8 @@ import { isoStringToDate } from "@/components/medications/scheduling/course-wind import { DEFAULT_SUB_CONTROLS, type CadenceSubControls, - type WeekdayToken, } from "@/components/medications/scheduling/types"; +import { type WeekdayToken } from "@/lib/medications/scheduling/weekday-tokens"; import type { WizardPayload as ExtractorPayload } from "@/components/medications/scheduling/natural-language-extractor"; import type { WizardPayload } from "./wizard-payload"; diff --git a/src/components/medications/wizard/steps/step6-sub-cadence.tsx b/src/components/medications/wizard/steps/step6-sub-cadence.tsx index 259b0e499..e5816b6b9 100644 --- a/src/components/medications/wizard/steps/step6-sub-cadence.tsx +++ b/src/components/medications/wizard/steps/step6-sub-cadence.tsx @@ -5,11 +5,11 @@ import { useMemo } from "react"; import { encodeCadence } from "@/components/medications/scheduling/cadence-picker"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { type CadenceSubControls } from "@/components/medications/scheduling/types"; import { - type CadenceSubControls, type WeekdayToken, WEEKDAY_TOKENS, -} from "@/components/medications/scheduling/types"; +} from "@/lib/medications/scheduling/weekday-tokens"; import { useTranslations } from "@/lib/i18n/context"; import type { StepProps } from "./step1-name"; diff --git a/src/components/settings/__tests__/sections-i18n-parity.test.ts b/src/components/settings/__tests__/sections-i18n-parity.test.ts index 8b6be7cc0..60e0ae128 100644 --- a/src/components/settings/__tests__/sections-i18n-parity.test.ts +++ b/src/components/settings/__tests__/sections-i18n-parity.test.ts @@ -12,7 +12,7 @@ import { describe, expect, it } from "vitest"; import en from "../../../../messages/en.json" with { type: "json" }; import de from "../../../../messages/de.json" with { type: "json" }; -import { SETTINGS_SECTION_SLUGS } from "../section-slugs"; +import { SETTINGS_SECTION_SLUGS } from "@/lib/settings/section-slugs"; type Bag = Record; diff --git a/src/components/settings/__tests__/sections.test.tsx b/src/components/settings/__tests__/sections.test.tsx index e6420d6a3..7433e2e73 100644 --- a/src/components/settings/__tests__/sections.test.tsx +++ b/src/components/settings/__tests__/sections.test.tsx @@ -112,7 +112,7 @@ vi.mock("@/components/settings/thresholds-editor-section", () => ({ })); import { I18nProvider } from "@/lib/i18n/context"; import { SettingsSectionFrame } from "./section-frame-harness"; -import type { SettingsSectionSlug } from "../section-slugs"; +import type { SettingsSectionSlug } from "@/lib/settings/section-slugs"; import { AboutSection } from "../about-section"; import { AccountSection } from "../account-section"; import { AdvancedSection } from "../advanced-section"; diff --git a/src/components/settings/record-settings-section-gate.tsx b/src/components/settings/record-settings-section-gate.tsx index 35d155064..a66a0a387 100644 --- a/src/components/settings/record-settings-section-gate.tsx +++ b/src/components/settings/record-settings-section-gate.tsx @@ -11,7 +11,7 @@ import { classifySettingsDestination } from "@/lib/record-settings/classificatio import { SettingsCardHeader } from "@/components/settings/_card-header"; import { ManagedIntegrationStatus } from "@/components/settings/managed-integration-status"; import { SettingsCard } from "@/components/settings/settings-card"; -import type { SettingsSectionSlug } from "@/components/settings/section-slugs"; +import type { SettingsSectionSlug } from "@/lib/settings/section-slugs"; // The two record-scoped surfaces load on demand. Both are forms that validate // with Zod (the managed settings patch, the allergy and family-history diff --git a/src/components/settings/settings-section-loader.tsx b/src/components/settings/settings-section-loader.tsx index 41aecb597..2c0faae98 100644 --- a/src/components/settings/settings-section-loader.tsx +++ b/src/components/settings/settings-section-loader.tsx @@ -5,7 +5,7 @@ import { useEffect, type ComponentType } from "react"; import { Skeleton } from "@/components/ui/skeleton"; import { SettingsCard } from "@/components/settings/settings-card"; -import type { SettingsSectionSlug } from "@/components/settings/section-slugs"; +import type { SettingsSectionSlug } from "@/lib/settings/section-slugs"; import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls"; /** diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index 29c8b1af2..1ddee6b15 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -55,7 +55,7 @@ import { SETTINGS_SECTION_SLUGS, isSettingsSectionSlug, type SettingsSectionSlug, -} from "./section-slugs"; +} from "@/lib/settings/section-slugs"; import { SettingsHubBackLink } from "./settings-hub-back-link"; import { SettingsMobileNav } from "./settings-mobile-nav"; import { SettingsAnchorForward } from "./settings-anchor-forward"; diff --git a/src/lib/ai/coach/medication-extract-prompt.ts b/src/lib/ai/coach/medication-extract-prompt.ts index bbabd616c..09d735dbe 100644 --- a/src/lib/ai/coach/medication-extract-prompt.ts +++ b/src/lib/ai/coach/medication-extract-prompt.ts @@ -29,7 +29,7 @@ import { z } from "zod/v4"; -import { WEEKDAY_TOKENS } from "@/components/medications/scheduling/types"; +import { WEEKDAY_TOKENS } from "@/lib/medications/scheduling/weekday-tokens"; /** Closed list — matches the design-synthesis dose-unit dropdown. */ export const DOSE_UNITS = [ diff --git a/src/lib/jobs/medication-low-stock.ts b/src/lib/jobs/medication-low-stock.ts index ea44a842d..5c2e8af5c 100644 --- a/src/lib/jobs/medication-low-stock.ts +++ b/src/lib/jobs/medication-low-stock.ts @@ -69,7 +69,7 @@ import { lowStockTriggerDays, supplyRunwayDates, type RunwaySchedule, -} from "@/components/medications/detail/supply-runway"; +} from "@/lib/medications/supply-runway"; import { detectSupplyUnderflow, summariseSupply, diff --git a/src/components/medications/__tests__/supply-runway.test.ts b/src/lib/medications/__tests__/supply-runway.test.ts similarity index 99% rename from src/components/medications/__tests__/supply-runway.test.ts rename to src/lib/medications/__tests__/supply-runway.test.ts index 25513acc4..40424820c 100644 --- a/src/components/medications/__tests__/supply-runway.test.ts +++ b/src/lib/medications/__tests__/supply-runway.test.ts @@ -16,7 +16,7 @@ import { lowStockTriggerDays, supplyRunwayDates, type RunwaySchedule, -} from "@/components/medications/detail/supply-runway"; +} from "@/lib/medications/supply-runway"; function schedule(partial: Partial): RunwaySchedule { return { diff --git a/src/lib/medications/list-read.ts b/src/lib/medications/list-read.ts index 72507873e..a931edb2a 100644 --- a/src/lib/medications/list-read.ts +++ b/src/lib/medications/list-read.ts @@ -24,7 +24,7 @@ import { import { effectiveUnitsPerDose, estimateUnitsRunwayDays, -} from "@/components/medications/detail/supply-runway"; +} from "@/lib/medications/supply-runway"; import { serializeScheduleUnitsPerDose } from "@/lib/medications/schedule-units-dto"; import { computeDisplayDue, diff --git a/src/lib/medications/scheduling/weekday-tokens.ts b/src/lib/medications/scheduling/weekday-tokens.ts new file mode 100644 index 000000000..8a5166107 --- /dev/null +++ b/src/lib/medications/scheduling/weekday-tokens.ts @@ -0,0 +1,17 @@ +/** + * RFC 5545 BYDAY tokens in the order the weekday chip row renders them. + * + * Shared by the scheduling wizard and the server-side medication extraction + * prompt, so it lives beside the recurrence engine rather than in a + * component folder. + */ +export const WEEKDAY_TOKENS = [ + "MO", + "TU", + "WE", + "TH", + "FR", + "SA", + "SU", +] as const; +export type WeekdayToken = (typeof WEEKDAY_TOKENS)[number]; diff --git a/src/components/medications/detail/supply-runway.ts b/src/lib/medications/supply-runway.ts similarity index 100% rename from src/components/medications/detail/supply-runway.ts rename to src/lib/medications/supply-runway.ts diff --git a/src/lib/record-settings/classification.ts b/src/lib/record-settings/classification.ts index fbeda64b2..3512c8204 100644 --- a/src/lib/record-settings/classification.ts +++ b/src/lib/record-settings/classification.ts @@ -1,7 +1,7 @@ import { SETTINGS_SECTION_SLUGS, type SettingsSectionSlug, -} from "@/components/settings/section-slugs"; +} from "@/lib/settings/section-slugs"; import type { AccountAccessLevel, AccountRecordKind, diff --git a/src/components/settings/section-slugs.ts b/src/lib/settings/section-slugs.ts similarity index 100% rename from src/components/settings/section-slugs.ts rename to src/lib/settings/section-slugs.ts