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