mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
refactor(lint): enforce the lib → components and client → server boundaries
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.
This commit is contained in:
1 parent
fdd0f50130
commit
c86c7d1a79
40 files changed
+429
-49
No files matched your search
@@ -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";
|
||||
|
||||
@@ -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" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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;
|
||||
@@ -21,6 +21,8 @@
|
||||
* toLocaleString().
|
||||
* - `no-raw-card-shell` — a card surface is a <Card> 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,
|
||||
},
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
estimateRunwayDays,
|
||||
lowStockTriggerDays,
|
||||
type RunwaySchedule,
|
||||
} from "@/components/medications/detail/supply-runway";
|
||||
} from "@/lib/medications/supply-runway";
|
||||
import {
|
||||
resolveLowStockRunwayDays,
|
||||
resolveReorderLeadDays,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 `<AdminShell>` client bundle
|
||||
* into the server graph.
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
+2
-2
@@ -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
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
/**
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -69,7 +69,7 @@ import {
|
||||
lowStockTriggerDays,
|
||||
supplyRunwayDates,
|
||||
type RunwaySchedule,
|
||||
} from "@/components/medications/detail/supply-runway";
|
||||
} from "@/lib/medications/supply-runway";
|
||||
import {
|
||||
detectSupplyUnderflow,
|
||||
summariseSupply,
|
||||
|
||||
+1
-1
@@ -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>): RunwaySchedule {
|
||||
return {
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
File renamed without changes.
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
SETTINGS_SECTION_SLUGS,
|
||||
type SettingsSectionSlug,
|
||||
} from "@/components/settings/section-slugs";
|
||||
} from "@/lib/settings/section-slugs";
|
||||
import type {
|
||||
AccountAccessLevel,
|
||||
AccountRecordKind,
|
||||
|
||||
File renamed without changes.
Reference in new issue
Block a user