mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
Merge branch 'fix/v1475-cleanup' into release/v1.47.5
# Conflicts: # src/__tests__/baselines/source-file-lines.json # src/__tests__/baselines/source-function-lines.json # src/__tests__/baselines/version-stamp-comments.json
This commit is contained in:
commit
d5dd05b5ac
164 files changed
+1109
-15288
No files matched your search
+1
-1
@@ -23,4 +23,4 @@ client → src/proxy.ts → src/app/<route>/route.ts → src/lib/api-handler.ts
|
||||
|
||||
## Conventions
|
||||
|
||||
See [`../CLAUDE.md`](../CLAUDE.md) for the full set. The load-bearing ones: RSC by default (`"use client"` only when a hook/state/browser API needs it); every API route wraps `apiHandler` and parses its body with Zod `safeParse`; `userId` is always narrowed from session or Bearer, never accepted as a body field; TanStack Query keys come only from `src/lib/query-keys/`; outbound HTTP goes through `src/lib/safe-fetch.ts`.
|
||||
The load-bearing ones: RSC by default (`"use client"` only when a hook/state/browser API needs it); every API route wraps `apiHandler` and parses its body with Zod `safeParse`; `userId` is always narrowed from session or Bearer, never accepted as a body field; TanStack Query keys come only from `src/lib/query-keys/`; outbound HTTP goes through `src/lib/safe-fetch.ts`.
|
||||
@@ -51,7 +51,7 @@ import {
|
||||
stripComments,
|
||||
walkSourceFiles,
|
||||
} from "./helpers/source-files";
|
||||
import { QUEUE_RUNTIME, type QueueRuntime } from "@/lib/jobs/queue-runtime";
|
||||
import { QUEUE_RUNTIME, type QueueRuntime } from "./helpers/queue-runtime";
|
||||
import {
|
||||
COMPLETION_CEILING_MS,
|
||||
COMPREHENSIVE_WARM_CEILING_MS,
|
||||
|
||||
@@ -10,7 +10,23 @@ import {
|
||||
NOT_IN_BACKUP_MODELS,
|
||||
RAW_SQL_TABLES,
|
||||
} from "@/lib/export/backup-plan";
|
||||
import { backupVerdict } from "@/lib/export/backup-verdict";
|
||||
|
||||
type BackupVerdict = "BACKED_UP" | "DERIVED" | "NOT_IN_BACKUP" | "AUTH";
|
||||
|
||||
/**
|
||||
* The verdict for a model, or `null` when the schema carries a model the plan
|
||||
* has never ruled on. The guard below turns that `null` into a failing test:
|
||||
* a new model gets a decision, not a default.
|
||||
*/
|
||||
function backupVerdict(model: string): BackupVerdict | null {
|
||||
if ((BACKED_UP_MODELS as readonly string[]).includes(model)) {
|
||||
return "BACKED_UP";
|
||||
}
|
||||
if (model in DERIVED_MODELS) return "DERIVED";
|
||||
if (model in NOT_IN_BACKUP_MODELS) return "NOT_IN_BACKUP";
|
||||
if (AUTH_MODELS_OUT_OF_SCOPE.includes(model)) return "AUTH";
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every model in the schema has a written verdict about the backup.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
|
||||
"count": 84,
|
||||
"total": 93830,
|
||||
"total": 93802,
|
||||
"entries": {
|
||||
"app/medications/page-client.tsx": 839,
|
||||
"components/charts/mood-chart.tsx": 1166,
|
||||
@@ -13,7 +13,7 @@
|
||||
"components/insights/health-score-card.tsx": 1023,
|
||||
"components/insights/insights-tab-strip.tsx": 1084,
|
||||
"components/measurements/measurement-form.tsx": 815,
|
||||
"components/medications/detail/medication-detail-tabs.tsx": 1157,
|
||||
"components/medications/detail/medication-detail-tabs.tsx": 1129,
|
||||
"components/medications/medication-table.tsx": 853,
|
||||
"components/medications/wizard/wizard-payload.ts": 1414,
|
||||
"components/mood/mood-list.tsx": 1408,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
|
||||
"count": 336,
|
||||
"total": 109328,
|
||||
"total": 109312,
|
||||
"entries": {
|
||||
"app/achievements/page.tsx :: AchievementsPage": 206,
|
||||
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275,
|
||||
@@ -145,10 +145,10 @@
|
||||
"components/measurement-reminders/vorsorge-section.tsx :: VorsorgeSection": 352,
|
||||
"components/measurements/measurement-form.tsx :: MeasurementForm": 496,
|
||||
"components/medications/detail/efficacy/efficacy-chart.tsx :: EfficacyChart": 361,
|
||||
"components/medications/detail/medication-detail-tabs.tsx :: MedicationDetailTabs": 767,
|
||||
"components/medications/detail/medication-detail-tabs.tsx :: MedicationDetailTabs": 760,
|
||||
"components/medications/dose-history-add-dialog.tsx :: LedgerAddDialog": 221,
|
||||
"components/medications/dose-strength-curve.tsx :: DoseStrengthCurve": 266,
|
||||
"components/medications/glp1-medication-card.tsx :: Glp1MedicationCard": 405,
|
||||
"components/medications/glp1-medication-card.tsx :: Glp1MedicationCard": 396,
|
||||
"components/medications/injection-site-picker.tsx :: InjectionSitePicker": 287,
|
||||
"components/medications/intake-history-list-v2.tsx :: IntakeHistoryListV2": 403,
|
||||
"components/medications/intake-import-dialog.tsx :: IntakeImportDialog": 201,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "as never casts in tests, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.",
|
||||
"count": 523,
|
||||
"total": 5459,
|
||||
"total": 5458,
|
||||
"entries": {
|
||||
"__tests__/api/auth/login/mfa-branch.test.ts": 10,
|
||||
"__tests__/api/auth/mfa/verify.test.ts": 9,
|
||||
@@ -496,7 +496,7 @@
|
||||
"lib/medications/scheduling/__tests__/dose-history.test.ts": 1,
|
||||
"lib/medications/scheduling/__tests__/pin-conflict.test.ts": 2,
|
||||
"lib/modules/__tests__/measurement-scope.test.ts": 3,
|
||||
"lib/notifications/__tests__/admin-locale.test.ts": 7,
|
||||
"lib/notifications/__tests__/admin-locale.test.ts": 6,
|
||||
"lib/notifications/__tests__/client-managed-apns.test.ts": 9,
|
||||
"lib/notifications/__tests__/dispatch-localised-cache.test.ts": 5,
|
||||
"lib/notifications/__tests__/dispatcher-global-channel-switch.test.ts": 8,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "tests that read a source file by path, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.",
|
||||
"count": 337,
|
||||
"total": 337,
|
||||
"count": 332,
|
||||
"total": 332,
|
||||
"entries": {
|
||||
"__tests__/account-payload-consumer-guard.test.ts": 1,
|
||||
"__tests__/admin-data-wipe-completeness.test.ts": 1,
|
||||
@@ -87,7 +87,6 @@
|
||||
"__tests__/module-gate-record-scoped.test.ts": 1,
|
||||
"__tests__/mood-backup-completeness.test.ts": 1,
|
||||
"__tests__/mood-prediction-write-surface-guard.test.ts": 1,
|
||||
"__tests__/moodlog-removal-guard.test.ts": 1,
|
||||
"__tests__/next-image-optimizer-guard.test.ts": 1,
|
||||
"__tests__/no-server-actions-guard.test.ts": 1,
|
||||
"__tests__/oidc-native-error-reasons-documented.test.ts": 1,
|
||||
@@ -141,7 +140,6 @@
|
||||
"__tests__/unit-preference-display-guard.test.ts": 1,
|
||||
"__tests__/unit-suite-browser-install-guard.test.ts": 1,
|
||||
"__tests__/utc-day-key-allowlist-guard.test.ts": 1,
|
||||
"__tests__/v1341-locale-parity.test.ts": 1,
|
||||
"__tests__/visit-link-guard.test.ts": 1,
|
||||
"__tests__/workout-canonical-identity-guard.test.ts": 1,
|
||||
"__tests__/write-rate-limit-guard.test.ts": 1,
|
||||
@@ -174,7 +172,6 @@
|
||||
"app/insights/__tests__/page-skeletons.test.ts": 1,
|
||||
"app/insights/sleep/__tests__/empty-state-href.test.ts": 1,
|
||||
"app/measurements/__tests__/add-token-resolution.test.ts": 1,
|
||||
"app/medications/__tests__/detail-page-history-surface.test.ts": 1,
|
||||
"components/__tests__/design-consistency-guards.test.tsx": 1,
|
||||
"components/__tests__/failed-read-never-empty.test.tsx": 1,
|
||||
"components/__tests__/motion-reduce-spin-coverage.test.ts": 1,
|
||||
@@ -182,7 +179,6 @@
|
||||
"components/admin/__tests__/notification-test-failures.test.ts": 1,
|
||||
"components/charts/__tests__/chart-error-state.test.tsx": 1,
|
||||
"components/charts/__tests__/health-chart-overlay-defaults.test.tsx": 1,
|
||||
"components/charts/__tests__/health-chart-target-link-removed.test.ts": 1,
|
||||
"components/charts/__tests__/mood-chart-polish.test.tsx": 1,
|
||||
"components/charts/__tests__/touch-action-guard.test.ts": 1,
|
||||
"components/cycle/__tests__/use-cycle-ring-has-dial.test.tsx": 1,
|
||||
@@ -206,7 +202,6 @@
|
||||
"components/insights/coach-panel/__tests__/open-at-end.test.ts": 1,
|
||||
"components/insights/coach-panel/__tests__/panel-touch-targets.test.ts": 1,
|
||||
"components/insights/coach-panel/__tests__/scroll-to-bottom-button.test.tsx": 1,
|
||||
"components/insights/mood/__tests__/mood-notes-timeline-removed.test.ts": 1,
|
||||
"components/layout/__tests__/auth-shell-coach-hoist.test.ts": 1,
|
||||
"components/layout/__tests__/auth-shell-shared-record-doors.test.tsx": 1,
|
||||
"components/layout/__tests__/offline-banner.test.tsx": 1,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
|
||||
"count": 2883,
|
||||
"total": 9186,
|
||||
"count": 2876,
|
||||
"total": 9172,
|
||||
"entries": {
|
||||
"__tests__/account-payload-consumer-guard.test.ts": 2,
|
||||
"__tests__/acting-account-boundary-guard.test.ts": 7,
|
||||
@@ -68,7 +68,6 @@
|
||||
"__tests__/success-affordance-guard.test.ts": 8,
|
||||
"__tests__/timezone-explicit-formatter-guard.test.ts": 1,
|
||||
"__tests__/unit-preference-display-guard.test.ts": 2,
|
||||
"__tests__/v1341-locale-parity.test.ts": 1,
|
||||
"__tests__/v1391-additive-contract.test.ts": 1,
|
||||
"__tests__/write-rate-limit-guard.test.ts": 1,
|
||||
"app/__tests__/dashboard-analytics-split.test.ts": 2,
|
||||
@@ -654,7 +653,6 @@
|
||||
"app/mcp/__tests__/route.test.ts": 5,
|
||||
"app/measurements/page.tsx": 5,
|
||||
"app/medications/__tests__/category-filter.test.tsx": 2,
|
||||
"app/medications/__tests__/detail-page-history-surface.test.ts": 1,
|
||||
"app/medications/__tests__/list-view-toggle.test.tsx": 5,
|
||||
"app/medications/__tests__/page-prefetch.test.tsx": 1,
|
||||
"app/medications/__tests__/take-all-due-affordance.test.tsx": 1,
|
||||
@@ -735,7 +733,6 @@
|
||||
"components/charts/__tests__/health-chart-polish.test.tsx": 2,
|
||||
"components/charts/__tests__/health-chart-preloaded-coverage.test.tsx": 1,
|
||||
"components/charts/__tests__/health-chart-range.test.tsx": 3,
|
||||
"components/charts/__tests__/health-chart-target-link-removed.test.ts": 1,
|
||||
"components/charts/__tests__/health-chart-vertical-markers.test.ts": 1,
|
||||
"components/charts/__tests__/medication-chart-polish.test.tsx": 1,
|
||||
"components/charts/__tests__/medication-compliance-chart-empty-state-gate.test.tsx": 1,
|
||||
@@ -1001,7 +998,6 @@
|
||||
"components/insights/mood/__tests__/mood-discovered-relations.test.tsx": 3,
|
||||
"components/insights/mood/__tests__/mood-insights-sections-in-target.test.tsx": 1,
|
||||
"components/insights/mood/__tests__/mood-insights-sections-reveal.test.tsx": 1,
|
||||
"components/insights/mood/__tests__/mood-notes-timeline-removed.test.ts": 4,
|
||||
"components/insights/mood/__tests__/mood-tag-bars-muted.test.tsx": 1,
|
||||
"components/insights/mood/mood-better-days.tsx": 2,
|
||||
"components/insights/mood/mood-correlation-cards.tsx": 3,
|
||||
@@ -1127,7 +1123,7 @@
|
||||
"components/medications/__tests__/inventory-section-actions.test.tsx": 1,
|
||||
"components/medications/__tests__/inventory-section-summary.test.tsx": 2,
|
||||
"components/medications/__tests__/log-injection-site-dialog.test.tsx": 1,
|
||||
"components/medications/__tests__/medication-card-glp1.test.tsx": 14,
|
||||
"components/medications/__tests__/medication-card-glp1.test.tsx": 12,
|
||||
"components/medications/__tests__/medication-card-header.test.tsx": 1,
|
||||
"components/medications/__tests__/medication-card-next-due.test.tsx": 4,
|
||||
"components/medications/__tests__/medication-card-symmetry.test.tsx": 4,
|
||||
@@ -1164,7 +1160,7 @@
|
||||
"components/medications/log-injection-site-dialog.tsx": 2,
|
||||
"components/medications/log-intake-dialog.tsx": 4,
|
||||
"components/medications/medication-card-header.tsx": 5,
|
||||
"components/medications/medication-card-menu.tsx": 3,
|
||||
"components/medications/medication-card-menu.tsx": 2,
|
||||
"components/medications/medication-card.tsx": 37,
|
||||
"components/medications/medication-categories-sheet.tsx": 1,
|
||||
"components/medications/medication-category-add-row.tsx": 1,
|
||||
@@ -2367,7 +2363,6 @@
|
||||
"lib/labs/__tests__/note-update.test.ts": 1,
|
||||
"lib/labs/biomarker-catalog.ts": 10,
|
||||
"lib/labs/biomarker-store.ts": 2,
|
||||
"lib/labs/homa-ir.ts": 1,
|
||||
"lib/labs/lab-trend.ts": 1,
|
||||
"lib/labs/local-ocr.ts": 1,
|
||||
"lib/labs/note-update.ts": 1,
|
||||
@@ -2691,7 +2686,7 @@
|
||||
"lib/queries/use-profile-claim.ts": 4,
|
||||
"lib/query-keys/admin.ts": 12,
|
||||
"lib/query-keys/ai-runs.ts": 1,
|
||||
"lib/query-keys/auth.ts": 9,
|
||||
"lib/query-keys/auth.ts": 8,
|
||||
"lib/query-keys/coach.ts": 13,
|
||||
"lib/query-keys/custom-metrics.ts": 1,
|
||||
"lib/query-keys/cycle.ts": 1,
|
||||
@@ -2768,11 +2763,9 @@
|
||||
"lib/sync/cursor.ts": 1,
|
||||
"lib/targets/__tests__/builders.test.ts": 1,
|
||||
"lib/targets/__tests__/glucose-targets.test.ts": 1,
|
||||
"lib/targets/__tests__/mood-stability-label.test.ts": 1,
|
||||
"lib/targets/__tests__/source-link.test.ts": 1,
|
||||
"lib/targets/__tests__/target-unit-display.test.ts": 1,
|
||||
"lib/targets/build-response.ts": 3,
|
||||
"lib/targets/mood-stability-label.ts": 1,
|
||||
"lib/targets/target-unit-display.ts": 2,
|
||||
"lib/targets/types.ts": 2,
|
||||
"lib/targets/vitals-builder.ts": 2,
|
||||
|
||||
@@ -215,7 +215,7 @@ describe("the customise shortcut is withheld inside somebody else's record", ()
|
||||
export function Page() {
|
||||
const { inSharedRecord } = useRecordCapabilities();
|
||||
const wrench = !inSharedRecord ? (
|
||||
<Link href="/settings/layout/vorsorge">Customise</Link>
|
||||
<Link href="/settings/layout/checkups">Customise</Link>
|
||||
) : null;
|
||||
return wrench;
|
||||
}
|
||||
|
||||
File renamed without changes.
@@ -8,7 +8,7 @@
|
||||
* so could every other job that legitimately needs longer: the nightly status
|
||||
* passes, the pre-generation, the record scan, the key rotation, the backups.
|
||||
*
|
||||
* `src/lib/jobs/queue-runtime.ts` declares every queue `short`, `budgeted` or
|
||||
* `helpers/queue-runtime.ts` declares every queue `short`, `budgeted` or
|
||||
* `long`. This guard holds the tree to it:
|
||||
*
|
||||
* - every queue the worker registry declares (`jobs/registry/`) is in the
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
DEFAULT_JOB_EXPIRE_SECONDS,
|
||||
QUEUE_RUNTIME,
|
||||
type QueueRuntime,
|
||||
} from "@/lib/jobs/queue-runtime";
|
||||
} from "./helpers/queue-runtime";
|
||||
import { SCHEDULED_AND_SENT_QUEUES } from "@/lib/jobs/reminder/registrar-shared";
|
||||
import { HEAVY_QUEUES } from "@/lib/jobs/heavy-lane";
|
||||
import type { JobDefinition } from "@/lib/jobs/registry/define-job";
|
||||
|
||||
@@ -22,8 +22,7 @@
|
||||
* that file does, or is listed below by file and function with the reason
|
||||
* its rows cannot reach a prompt. A new reader fails here until it does one
|
||||
* of those. The data routes are not in scope: a module decides what a model
|
||||
* is given, not what the owner can read (`CLAUDE.md`, "An AI capability
|
||||
* gates model work").
|
||||
* is given, not what the owner can read.
|
||||
*
|
||||
* The check is per read, not per file. It used to ask whether a file that
|
||||
* reads the table mentions the gate anywhere, so one ungated read in a file
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
/**
|
||||
* The moodLog bridge stays removed.
|
||||
*
|
||||
* It was retired once before, in pieces: the Settings card went in June, the
|
||||
* cron, webhook, credentials, ledger entry and roughly twenty i18n keys stayed
|
||||
* behind, and for a month the code ran hourly against an upstream that had
|
||||
* stopped answering while nothing on any screen said so. Half-removed was
|
||||
* worse than either finished state.
|
||||
*
|
||||
* This test pins the finished state. It is a structural guard, not a
|
||||
* behavioural one: it fails the moment a moodLog surface reappears, so a
|
||||
* reintroduction has to be a decision someone writes down rather than a merge
|
||||
* that slips through.
|
||||
*
|
||||
* What it deliberately does NOT forbid: the `MOODLOG` mood-entry source value
|
||||
* and its label. Rows the bridge imported are the user's own history, they
|
||||
* keep their provenance, and every read path must go on resolving it.
|
||||
*/
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { allJobDefinitions } from "@/lib/jobs/registry/index";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { INTEGRATION_CADENCE } from "@/lib/integrations/sync-verdict";
|
||||
import { INTEGRATION_DISPLAY_NAMES } from "@/components/settings/integrations/integration-fallback-row";
|
||||
|
||||
const ROOT = path.resolve(__dirname, "../..");
|
||||
|
||||
const LOCALES = [
|
||||
"de",
|
||||
"en",
|
||||
"es",
|
||||
"fr",
|
||||
"it",
|
||||
"pl",
|
||||
"ko",
|
||||
"nl",
|
||||
"pt-BR",
|
||||
] as const;
|
||||
|
||||
/** Every path the retired bridge occupied. */
|
||||
const REMOVED_PATHS = [
|
||||
"src/lib/moodlog",
|
||||
"src/lib/moodlog-secret.ts",
|
||||
"src/lib/jobs/reminder/moodlog-sync.ts",
|
||||
"src/app/api/integrations/moodlog",
|
||||
"src/app/api/settings/moodlog",
|
||||
"src/lib/validations/moodlog.ts",
|
||||
];
|
||||
|
||||
/** Columns migration 0271 dropped. */
|
||||
const REMOVED_SCHEMA_FIELDS = [
|
||||
"moodLogUrlEncrypted",
|
||||
"moodLogApiKeyEncrypted",
|
||||
"moodLogEnabled",
|
||||
"moodLogLastSyncedAt",
|
||||
"moodLogWebhookSecret",
|
||||
"moodLogGlobal",
|
||||
];
|
||||
|
||||
describe("moodLog integration stays removed", () => {
|
||||
it("has no moodLog module, route or validation file", () => {
|
||||
for (const rel of REMOVED_PATHS) {
|
||||
expect(existsSync(path.join(ROOT, rel)), `${rel} is back`).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("declares no moodLog column on the Prisma schema", () => {
|
||||
const schema = readFileSync(
|
||||
path.join(ROOT, "prisma/schema.prisma"),
|
||||
"utf8",
|
||||
);
|
||||
for (const field of REMOVED_SCHEMA_FIELDS) {
|
||||
expect(schema, `${field} is back on the schema`).not.toContain(field);
|
||||
}
|
||||
});
|
||||
|
||||
it("registers no moodlog queue, cron or handler binding", () => {
|
||||
const queues = allJobDefinitions().map((job) => job.queue);
|
||||
expect(queues.length).toBeGreaterThan(100);
|
||||
expect(queues.filter((queue) => /mood-?log/i.test(queue))).toEqual([]);
|
||||
});
|
||||
|
||||
it("carries no moodlog integration key on the status envelope", () => {
|
||||
expect(Object.keys(INTEGRATION_CADENCE)).not.toContain("moodlog");
|
||||
expect(Object.keys(INTEGRATION_DISPLAY_NAMES)).not.toContain("moodlog");
|
||||
});
|
||||
|
||||
it("keeps no orphaned moodLog message key in any locale", () => {
|
||||
for (const locale of LOCALES) {
|
||||
const bundle = JSON.parse(
|
||||
readFileSync(path.join(ROOT, `messages/${locale}.json`), "utf8"),
|
||||
) as {
|
||||
settings: Record<string, unknown>;
|
||||
admin: Record<string, unknown>;
|
||||
};
|
||||
const orphans = [
|
||||
...Object.keys(bundle.settings),
|
||||
...Object.keys(bundle.admin),
|
||||
].filter((key) => key.startsWith("moodLog"));
|
||||
expect(orphans, `${locale} still carries moodLog keys`).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("still resolves the MOODLOG provenance of imported mood rows", () => {
|
||||
// The other half of the contract: removing the bridge must not erase the
|
||||
// history it wrote. The source badge label has to survive the removal.
|
||||
const en = JSON.parse(
|
||||
readFileSync(path.join(ROOT, "messages/en.json"), "utf8"),
|
||||
) as { mood: Record<string, string> };
|
||||
expect(en.mood.sourceMoodlog).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -43,7 +43,7 @@ const SRC = join(process.cwd(), "src");
|
||||
* Every non-test `.ts` / `.tsx` under `src/app/`, which is the whole of the
|
||||
* request-handling tree: API routes, RSC pages, layouts, route handlers.
|
||||
* The generated Prisma client is excluded by construction (it is not under
|
||||
* `app/`) and never read — see CLAUDE.md.
|
||||
* `app/`) and never read.
|
||||
*/
|
||||
function appFiles(): string[] {
|
||||
return walkSourceFiles(SRC, { floor: 3000 })
|
||||
|
||||
@@ -50,6 +50,7 @@ const HISTORICAL_SETTINGS_PATHS: readonly string[] = [
|
||||
"/settings/api",
|
||||
"/settings/mcp",
|
||||
"/settings/gesundheitsakte",
|
||||
"/settings/health-record",
|
||||
"/settings/sharing",
|
||||
"/settings/export",
|
||||
"/settings/advanced",
|
||||
@@ -72,6 +73,7 @@ const HISTORICAL_SETTINGS_PATHS: readonly string[] = [
|
||||
"/settings/layout/illness",
|
||||
"/settings/layout/documents",
|
||||
"/settings/layout/vorsorge",
|
||||
"/settings/layout/checkups",
|
||||
];
|
||||
|
||||
interface Redirect {
|
||||
@@ -151,6 +153,22 @@ describe("historical Settings addresses", () => {
|
||||
expect(unlisted).toEqual([]);
|
||||
});
|
||||
|
||||
it("sends the old German addresses to their English ones", async () => {
|
||||
const redirects = await settingsRedirects();
|
||||
expect(resolve("/settings/gesundheitsakte", redirects)).toBe(
|
||||
"/settings/health-record",
|
||||
);
|
||||
expect(resolve("/settings/sharing", redirects)).toBe(
|
||||
"/settings/health-record",
|
||||
);
|
||||
expect(resolve("/settings/layout/vorsorge", redirects)).toBe(
|
||||
"/settings/layout/checkups",
|
||||
);
|
||||
expect(resolve("/settings/vorsorge", redirects)).toBe(
|
||||
"/settings/layout/checkups",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the v1.44 moves addressable", async () => {
|
||||
const redirects = await settingsRedirects();
|
||||
expect(resolve("/settings/layout/mood", redirects)).toBe(
|
||||
|
||||
@@ -161,7 +161,7 @@ const BARE_REQUIRE_AUTH_FLOOR = 200;
|
||||
* omits the three routes under `src/app/.well-known/` — three request handlers
|
||||
* that a glob-based sweep reports as if they did not exist. Leg (a) pins one of
|
||||
* them by name so this cannot quietly regress. Names only; nothing under
|
||||
* `generated/` is ever read (CLAUDE.md).
|
||||
* `generated/` is ever read.
|
||||
*/
|
||||
function allPaths(): string[] {
|
||||
return readdirSync(SRC, { recursive: true })
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const ROOT = join(__dirname, "../..");
|
||||
const LOCALES = [
|
||||
"en",
|
||||
"de",
|
||||
"es",
|
||||
"fr",
|
||||
"it",
|
||||
"pl",
|
||||
"ko",
|
||||
"nl",
|
||||
"pt-BR",
|
||||
] as const;
|
||||
|
||||
const REQUIRED_KEYS = [
|
||||
// Security: adding a passkey now requires an existing sign-in factor.
|
||||
"settings.passkeyReauth.title",
|
||||
"settings.passkeyReauth.description",
|
||||
"settings.passkeyReauth.methodLabel",
|
||||
"settings.passkeyReauth.methods.password",
|
||||
"settings.passkeyReauth.methods.totp",
|
||||
"settings.passkeyReauth.methods.passkey",
|
||||
"settings.passkeyReauth.methods.webauthn",
|
||||
"settings.passkeyReauth.currentPassword",
|
||||
"settings.passkeyReauth.authenticatorCode",
|
||||
"settings.passkeyReauth.authenticatorPlaceholder",
|
||||
"settings.passkeyReauth.verifyAndAdd",
|
||||
// Integrations/imports: operator policy and Apple ECG terminal outcome.
|
||||
"settings.nightscoutPrivateOperatorTitle",
|
||||
"settings.nightscoutPrivateOperatorDescription",
|
||||
"settings.sections.export.import.appleHealth.ecgResult",
|
||||
// Existing terminal contracts reused by the final v1.34.1 call sites.
|
||||
"insights.assessmentPreparing",
|
||||
"insights.noAnalysisYet",
|
||||
"common.loadFailed",
|
||||
"common.networkError",
|
||||
"settings.googleHealthSyncResult",
|
||||
"settings.googleHealthSyncFailed",
|
||||
// `settings.googleHealthBackfillInProgress` left this list in v1.42.4: the
|
||||
// history-loading line became a sentence variant of the shared integration
|
||||
// card (`settings.integrationCard.reason.dataArrivingHistory`).
|
||||
"settings.syncOutcome.empty",
|
||||
"settings.syncOutcome.partial",
|
||||
"settings.syncOutcome.failed",
|
||||
// Retained score, records, and navigation contracts.
|
||||
// `insights.healthScore.method` was listed obsolete in v1.34.1 because its
|
||||
// only reference was `METRIC_PROVENANCE.HEALTH_SCORE.methodKey`, which the
|
||||
// card then passed as `method={null}` — a dangling reference, not a dead
|
||||
// one. The score card now renders that method paragraph in its disclosure
|
||||
// footer, so the key is a live contract again.
|
||||
"insights.healthScore.method",
|
||||
"insights.healthScore.bandSetter",
|
||||
"insights.healthScore.versionAndNoise",
|
||||
"insights.healthScore.methodVersion",
|
||||
"insights.healthScore.source",
|
||||
"records.allergies.add",
|
||||
"records.allergies.addFirst",
|
||||
"records.family.add",
|
||||
"records.family.addFirst",
|
||||
"admin.shell.sectionsNav",
|
||||
"settings.shell.sectionsNav",
|
||||
] as const;
|
||||
|
||||
const OBSOLETE_KEYS = [
|
||||
"insights.healthScore.algorithmChanged",
|
||||
"insights.healthScore.dismissAlgorithmChanged",
|
||||
"insights.healthScore.composition",
|
||||
"documents.detail.summary.regenerate",
|
||||
"records.profileFacts.correct",
|
||||
// v1.43 — the profile-facts card became the life-circumstances card, whose
|
||||
// sections save on their own.
|
||||
"records.profileFacts.save",
|
||||
"settings.nightscoutPrivateHost",
|
||||
"settings.nightscoutPrivateHostHelp",
|
||||
] as const;
|
||||
|
||||
const NEW_CALL_SITES = {
|
||||
"src/components/settings/security-section/passkey-list-section.tsx": [
|
||||
"settings.passkeyReauth.title",
|
||||
"settings.passkeyReauth.description",
|
||||
"settings.passkeyReauth.methodLabel",
|
||||
"settings.passkeyReauth.methods.password",
|
||||
"settings.passkeyReauth.methods.totp",
|
||||
"settings.passkeyReauth.methods.passkey",
|
||||
"settings.passkeyReauth.methods.webauthn",
|
||||
"settings.passkeyReauth.currentPassword",
|
||||
"settings.passkeyReauth.authenticatorCode",
|
||||
"settings.passkeyReauth.authenticatorPlaceholder",
|
||||
"settings.passkeyReauth.verifyAndAdd",
|
||||
],
|
||||
"src/components/settings/integrations/nightscout-card.tsx": [
|
||||
"settings.nightscoutPrivateOperatorTitle",
|
||||
"settings.nightscoutPrivateOperatorDescription",
|
||||
],
|
||||
"src/components/settings/import-panel/apple-health-import-card.tsx": [
|
||||
"settings.sections.export.import.appleHealth.ecgResult",
|
||||
],
|
||||
} as const;
|
||||
|
||||
type Catalog = Record<string, unknown>;
|
||||
|
||||
function readCatalog(locale: (typeof LOCALES)[number]): Catalog {
|
||||
return JSON.parse(
|
||||
readFileSync(join(ROOT, "messages", `${locale}.json`), "utf8"),
|
||||
) as Catalog;
|
||||
}
|
||||
|
||||
function resolveLeaf(catalog: Catalog, dottedKey: string): string | undefined {
|
||||
let value: unknown = catalog;
|
||||
for (const segment of dottedKey.split(".")) {
|
||||
if (typeof value !== "object" || value === null || !(segment in value)) {
|
||||
return undefined;
|
||||
}
|
||||
value = (value as Record<string, unknown>)[segment];
|
||||
}
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
function placeholders(message: string): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
[...message.matchAll(/\{([A-Za-z_][A-Za-z0-9_]*)\b/g)].map(
|
||||
(match) => match[1],
|
||||
),
|
||||
),
|
||||
].sort();
|
||||
}
|
||||
|
||||
describe("v1.34.1 locale reconciliation", () => {
|
||||
it("wires every newly introduced user-facing state through a literal t() call", () => {
|
||||
for (const [relativePath, keys] of Object.entries(NEW_CALL_SITES)) {
|
||||
const source = readFileSync(join(ROOT, relativePath), "utf8");
|
||||
for (const key of keys) {
|
||||
expect(source, `${relativePath} must translate ${key}`).toMatch(
|
||||
new RegExp(`\\bt\\(\\s*["']${key.replaceAll(".", "\\.")}["']`),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("provides every final contract in all six locales with ICU parity", () => {
|
||||
const catalogs = Object.fromEntries(
|
||||
LOCALES.map((locale) => [locale, readCatalog(locale)]),
|
||||
) as Record<(typeof LOCALES)[number], Catalog>;
|
||||
|
||||
for (const key of REQUIRED_KEYS) {
|
||||
const english = resolveLeaf(catalogs.en, key);
|
||||
expect(english, `messages/en.json is missing ${key}`).toBeTruthy();
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
const translated = resolveLeaf(catalogs[locale], key);
|
||||
expect(
|
||||
translated,
|
||||
`messages/${locale}.json is missing ${key}`,
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
placeholders(translated ?? ""),
|
||||
`messages/${locale}.json changed ICU placeholders for ${key}`,
|
||||
).toEqual(placeholders(english ?? ""));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("removes only the v1.34.1 copy proven obsolete after final call-site inventory", () => {
|
||||
for (const locale of LOCALES) {
|
||||
const catalog = readCatalog(locale);
|
||||
for (const key of OBSOLETE_KEYS) {
|
||||
expect(
|
||||
resolveLeaf(catalog, key),
|
||||
`messages/${locale}.json still contains obsolete ${key}`,
|
||||
).toBeUndefined();
|
||||
}
|
||||
}
|
||||
|
||||
const productionSource = Object.keys(NEW_CALL_SITES)
|
||||
.map((relativePath) => readFileSync(join(ROOT, relativePath), "utf8"))
|
||||
.join("\n");
|
||||
for (const key of OBSOLETE_KEYS) {
|
||||
expect(productionSource).not.toContain(`t("${key}")`);
|
||||
expect(productionSource).not.toContain(`t('${key}')`);
|
||||
}
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -22,4 +22,4 @@ Every endpoint wraps its handler in `apiHandler` (`@/lib/api-handler`), parses a
|
||||
|
||||
## Conventions
|
||||
|
||||
See [`../../CLAUDE.md`](../../CLAUDE.md). The request-flow walk in `.planning/codebase/arch.md` annotates each layer with file:line citations. Related dirs: handlers compose [`../lib/`](../lib/README.md); pages render [`../components/`](../components/README.md).
|
||||
Related dirs: handlers compose [`../lib/`](../lib/README.md); pages render [`../components/`](../components/README.md).
|
||||
@@ -34,6 +34,7 @@ import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { resolveGlucoseUnit, type GlucoseUnit } from "@/lib/glucose";
|
||||
import { glucoseUnitPatchSchema } from "@/lib/validations/user-prefs";
|
||||
import { refreshTextsAfterUnitChange } from "@/lib/insights/unit-change-refresh";
|
||||
import { invalidateUserProfile } from "@/lib/cache/invalidate";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -103,9 +104,11 @@ export const PATCH = apiHandler(async (req: Request) => {
|
||||
},
|
||||
});
|
||||
|
||||
// The status notes and narratives quote glucose in this unit; re-warm
|
||||
// them so today's stored text does not keep the old one.
|
||||
// The targets payload and the status notes and narratives carry glucose in
|
||||
// this unit: evict the cached payloads and re-warm today's stored text so
|
||||
// neither keeps the old one.
|
||||
if (resolveGlucoseUnit(previous?.glucoseUnit) !== next) {
|
||||
invalidateUserProfile(user.id);
|
||||
void refreshTextsAfterUnitChange(user.id);
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,10 @@ describe("GET /api/version", () => {
|
||||
);
|
||||
expect(body.data.license).toBe("PolyForm-Noncommercial-1.0.0");
|
||||
expect(body.data.repository).toContain("github.com");
|
||||
expect(body.data.changelog).toContain("CHANGELOG");
|
||||
// The release notes, not the multi-megabyte CHANGELOG.md.
|
||||
expect(body.data.changelog).toBe(
|
||||
"https://github.com/MBombeck/HealthLog/releases",
|
||||
);
|
||||
expect(body.data.docs).toContain("docs.healthlog.dev");
|
||||
});
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ export const GET = apiHandler(async () => {
|
||||
builtAt,
|
||||
license: "PolyForm-Noncommercial-1.0.0",
|
||||
repository: "https://github.com/MBombeck/HealthLog",
|
||||
changelog: "https://github.com/MBombeck/HealthLog/blob/main/CHANGELOG.md",
|
||||
changelog: "https://github.com/MBombeck/HealthLog/releases",
|
||||
docs: "https://docs.healthlog.dev",
|
||||
offlineGeoEnabled,
|
||||
geoProviderHost,
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function IntakeHistoryRedirectPage({
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
router.replace(`/medications/${id}?tab=verlauf`);
|
||||
router.replace(`/medications/${id}?tab=history`);
|
||||
}, [id, router]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
/**
|
||||
* v1.15.18 — the `/medications/[id]` detail page is the tabbed shell.
|
||||
*
|
||||
* The page owns auth + the medication read and delegates the rest to
|
||||
* `<MedicationDetailTabs>` (the tab strip, `?tab=` URL state, the hero
|
||||
* and the wizard / advanced affordances). Source-level guards (the page
|
||||
* is a client component with `use()` + hooks, so a full render is
|
||||
* heavier than the contract warrants):
|
||||
* - it renders `MedicationDetailTabs` and nothing tab-specific itself;
|
||||
* - it carries no `TodaysDoseCard` (lives only on the list page);
|
||||
* - it hosts neither the wizard nor the modal advanced sheet directly
|
||||
* (those live inside the tab component).
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const source = readFileSync(
|
||||
join(process.cwd(), "src/app/medications/[id]/page.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
describe("medication detail page is the tabbed shell (v1.15.18)", () => {
|
||||
it("delegates to MedicationDetailTabs", () => {
|
||||
expect(source).toContain("MedicationDetailTabs");
|
||||
});
|
||||
|
||||
it("does not import or render TodaysDoseCard", () => {
|
||||
expect(source).not.toContain("TodaysDoseCard");
|
||||
expect(source).not.toContain("todays-dose-card");
|
||||
});
|
||||
|
||||
it("does not host the wizard or the modal advanced sheet directly", () => {
|
||||
expect(source).not.toContain("AdvancedSettingsSheet");
|
||||
expect(source).not.toContain("MedicationWizardDialog");
|
||||
});
|
||||
});
|
||||
@@ -435,7 +435,7 @@ export default function MedicationsPageClient() {
|
||||
|
||||
function openHistory(med: Medication) {
|
||||
seedDetail(med);
|
||||
router.push(`/medications/${med.id}?tab=verlauf`);
|
||||
router.push(`/medications/${med.id}?tab=history`);
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
|
||||
@@ -21,4 +21,4 @@ React components. The UI primitives are shadcn/ui (new-york style, zinc base) ov
|
||||
|
||||
## Conventions
|
||||
|
||||
Kebab-case filenames; React component exports stay PascalCase regardless. (A handful of PascalCase-filename outliers under `medications/` + `onboarding/` are pre-existing drift, noted in [`../../CLAUDE.md`](../../CLAUDE.md).) Every `t()` call needs a matching key in `messages/en.json` — guarded by `i18n-call-site-coverage.test.ts`. Data reads go through TanStack Query with keys from `@/lib/query-keys` and unwrap `(await res.json()).data`. Tests live in [`__tests__/`](__tests__/).
|
||||
Kebab-case filenames; React component exports stay PascalCase regardless. Every `t()` call needs a matching key in `messages/en.json` — guarded by `i18n-call-site-coverage.test.ts`. Data reads go through TanStack Query with keys from `@/lib/query-keys` and unwrap `(await res.json()).data`. Tests live in [`__tests__/`](__tests__/).
|
||||
@@ -320,20 +320,16 @@ describe("marking a dose", () => {
|
||||
|
||||
describe("the medication card menu", () => {
|
||||
const node = (
|
||||
<MedicationCardMenu
|
||||
onEdit={() => {}}
|
||||
onOpenHistory={() => {}}
|
||||
onLogSideEffect={() => {}}
|
||||
/>
|
||||
<MedicationCardMenu onEdit={() => {}} onOpenHistory={() => {}} />
|
||||
);
|
||||
|
||||
it("renders its trigger in the caller's own record", () => {
|
||||
expect(render(OWN_RECORD, node)).toContain("<button");
|
||||
});
|
||||
|
||||
it("keeps a trigger for a delegate who may write", () => {
|
||||
// One item survives for them — noting a side effect — so the menu stays.
|
||||
expect(render(WRITABLE, node)).toContain("<button");
|
||||
it("disappears for a delegate who may write but not manage", () => {
|
||||
// Editing and the history editor are owner work; nothing survives.
|
||||
expect(render(WRITABLE, node)).toBe("");
|
||||
});
|
||||
|
||||
it("disappears entirely for a read-only delegate", () => {
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
/**
|
||||
* v1.16.8 — the in-header "adjust targets" link is gone from the chart
|
||||
* card. The affordance duplicated the page-level target-adjust control
|
||||
* in the insights sub-page header (`<TargetAdjustButton>`); the chart
|
||||
* stays a read surface. Pinned at the source level (same convention as
|
||||
* the auth-shell hoist contract) so the link doesn't quietly return on
|
||||
* a future header rework.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { readSource, readSourceFolder } from "@/__tests__/helpers/source-files";
|
||||
|
||||
const WEIGHT_PAGE_PATH = resolve(
|
||||
__dirname,
|
||||
"../../../app/insights/weight/page.tsx",
|
||||
);
|
||||
const BP_PAGE_PATH = resolve(
|
||||
__dirname,
|
||||
"../../../app/insights/blood-pressure/page.tsx",
|
||||
);
|
||||
const EN_BUNDLE_PATH = resolve(__dirname, "../../../../messages/en.json");
|
||||
|
||||
describe("v1.16.8 — chart-card target-adjust link removed", () => {
|
||||
it("ships no targetSettingsHref prop or header link in the chart", () => {
|
||||
// The component and every part it is built from (header included).
|
||||
const source = [
|
||||
readSource("src/components/charts/health-chart.tsx", "HealthChart"),
|
||||
...readSourceFolder("src/components/charts/health-chart", {
|
||||
floor: 9,
|
||||
}).map(([, text]) => text),
|
||||
].join("\n");
|
||||
expect(source).not.toContain("targetSettingsHref");
|
||||
expect(source).not.toContain("chart-target-settings-link");
|
||||
expect(source).not.toContain("charts.adjustTargets");
|
||||
});
|
||||
|
||||
it("weight and blood-pressure pages no longer pass the link href", () => {
|
||||
for (const path of [WEIGHT_PAGE_PATH, BP_PAGE_PATH]) {
|
||||
const source = readFileSync(path, "utf8");
|
||||
expect(source).not.toContain("targetSettingsHref");
|
||||
}
|
||||
});
|
||||
|
||||
it("the orphaned locale key is gone", () => {
|
||||
const bundle = JSON.parse(readFileSync(EN_BUNDLE_PATH, "utf8")) as {
|
||||
charts: Record<string, unknown>;
|
||||
};
|
||||
expect(bundle.charts.adjustTargets).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -38,7 +38,7 @@ const DAY: DayResponse = {
|
||||
until: "2026-03-31",
|
||||
dayIndex: 64,
|
||||
dayCount: 151,
|
||||
href: "/medications/m1?tab=verlauf",
|
||||
href: "/medications/m1?tab=history",
|
||||
},
|
||||
],
|
||||
values: [
|
||||
|
||||
@@ -321,7 +321,7 @@ export function SinceLastVisit({
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
<Link
|
||||
href={`/settings/gesundheitsakte?reportFrom=${fromDay}&reportTo=${nextDay < today ? nextDay : today}`}
|
||||
href={`/settings/health-record?reportFrom=${fromDay}&reportTo=${nextDay < today ? nextDay : today}`}
|
||||
data-slot="since-last-visit-report"
|
||||
>
|
||||
<FileText className="size-4" aria-hidden="true" />
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { resolveKey } from "@/lib/i18n/resolve-key";
|
||||
|
||||
/**
|
||||
* The illness screens name the vital that drives a recovery gap and label the
|
||||
* day picker of the log sheet through these keys. They were once copied from
|
||||
* English into five catalogs, so a German reader saw "Weight", "Pulse",
|
||||
* "Recovery" and "Day" among translated neighbours. The English leak guard
|
||||
* skips single words, so it cannot see this; the pin below can.
|
||||
*/
|
||||
const MESSAGES_DIR = join(__dirname, "../../../../messages");
|
||||
|
||||
function catalog(locale: string): Record<string, unknown> {
|
||||
return JSON.parse(
|
||||
readFileSync(join(MESSAGES_DIR, `${locale}.json`), "utf8"),
|
||||
) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
const KEYS = [
|
||||
"illness.vital.WEIGHT",
|
||||
"illness.vital.PULSE",
|
||||
"illness.vital.RECOVERY_SCORE",
|
||||
"illness.sheet.day",
|
||||
] as const;
|
||||
|
||||
const english = catalog("en");
|
||||
|
||||
describe("illness labels", () => {
|
||||
for (const locale of [
|
||||
"de",
|
||||
"fr",
|
||||
"es",
|
||||
"it",
|
||||
"pl",
|
||||
"nl",
|
||||
"pt-BR",
|
||||
"ko",
|
||||
"ru",
|
||||
]) {
|
||||
it(`are translated in ${locale}`, () => {
|
||||
const messages = catalog(locale);
|
||||
for (const key of KEYS) {
|
||||
const value = resolveKey(messages, key);
|
||||
expect(value, `${locale} ${key}`).toBeTruthy();
|
||||
expect(value, `${locale} ${key} is still English`).not.toBe(
|
||||
resolveKey(english, key),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -47,7 +47,10 @@ vi.mock("@/components/symptoms/symptoms-section", () => ({
|
||||
SymptomsSection: () => null,
|
||||
}));
|
||||
vi.mock("../new-episode-sheet", () => ({ NewEpisodeSheet: () => null }));
|
||||
vi.mock("../log-day-sheet", () => ({ LogDaySheet: () => null }));
|
||||
vi.mock("../log-day-sheet", () => ({
|
||||
LogDaySheet: () => null,
|
||||
useLastLoggableDay: (today: string) => today,
|
||||
}));
|
||||
vi.mock("@/components/day/day-link", () => ({
|
||||
DAY_LINK_SLOT: "{date}",
|
||||
DayLinkStated: ({ children }: { children: React.ReactNode }) => children,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { lastLoggableDay } from "../log-day-sheet";
|
||||
|
||||
/**
|
||||
* "Log day" on an illness opens on the day it suggests and bounds the picker
|
||||
* there. For a recovered episode that used to be today, so one tap on Save
|
||||
* filed a sick day days after the recovery, and the day view then showed it
|
||||
* with no illness running beside it.
|
||||
*/
|
||||
describe("lastLoggableDay", () => {
|
||||
it("is today while the episode runs", () => {
|
||||
expect(lastLoggableDay("2026-11-12", null, "Europe/Berlin")).toBe(
|
||||
"2026-11-12",
|
||||
);
|
||||
});
|
||||
|
||||
it("is the recovery day once the episode ended before today", () => {
|
||||
expect(
|
||||
lastLoggableDay(
|
||||
"2026-11-12",
|
||||
"2026-11-09T08:00:00.000Z",
|
||||
"Europe/Berlin",
|
||||
),
|
||||
).toBe("2026-11-09");
|
||||
});
|
||||
|
||||
it("stays today when the episode ended today", () => {
|
||||
expect(
|
||||
lastLoggableDay(
|
||||
"2026-11-12",
|
||||
"2026-11-12T08:00:00.000Z",
|
||||
"Europe/Berlin",
|
||||
),
|
||||
).toBe("2026-11-12");
|
||||
});
|
||||
|
||||
it("reads the recovery day in the profile zone", () => {
|
||||
// 23:30 UTC is already the next day in Berlin.
|
||||
expect(
|
||||
lastLoggableDay(
|
||||
"2026-11-12",
|
||||
"2026-11-09T23:30:00.000Z",
|
||||
"Europe/Berlin",
|
||||
),
|
||||
).toBe("2026-11-10");
|
||||
});
|
||||
});
|
||||
@@ -30,7 +30,7 @@ import { canReadBodySites } from "@/hooks/use-body-sites";
|
||||
|
||||
import { IllnessCorrelationCard } from "./illness-correlation-card";
|
||||
import { IllnessDayTimeline } from "./illness-day-timeline";
|
||||
import { LogDaySheet } from "./log-day-sheet";
|
||||
import { LogDaySheet, useLastLoggableDay } from "./log-day-sheet";
|
||||
import { NewEpisodeSheet } from "./new-episode-sheet";
|
||||
import { EpisodeMenu } from "./episode-menu";
|
||||
import { useIllnessEpisode, useResolveEpisode } from "./use-illness";
|
||||
@@ -66,6 +66,7 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
|
||||
const [logOpen, setLogOpen] = useState(false);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const today = todayLocal();
|
||||
const logLastDay = useLastLoggableDay(today, episode?.resolvedAt);
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
@@ -226,7 +227,7 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
|
||||
open={logOpen}
|
||||
onOpenChange={setLogOpen}
|
||||
episodeId={episode.id}
|
||||
date={today}
|
||||
date={logLastDay}
|
||||
onsetDate={episode.onsetAt.slice(0, 10)}
|
||||
/>
|
||||
<NewEpisodeSheet
|
||||
|
||||
@@ -42,7 +42,7 @@ import { cn } from "@/lib/utils";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { applyOrder, useModuleListPrefs } from "@/lib/module-list-prefs";
|
||||
|
||||
import { LogDaySheet } from "./log-day-sheet";
|
||||
import { LogDaySheet, useLastLoggableDay } from "./log-day-sheet";
|
||||
import { NewEpisodeSheet } from "./new-episode-sheet";
|
||||
import { EpisodeMenu } from "./episode-menu";
|
||||
import { IllnessInsightsCard } from "./illness-insights-card";
|
||||
@@ -384,6 +384,7 @@ export function IllnessView() {
|
||||
const [logEpisodeId, setLogEpisodeId] = useState<string | null>(null);
|
||||
|
||||
const logEpisode = episodes?.find((e) => e.id === logEpisodeId) ?? null;
|
||||
const logLastDay = useLastLoggableDay(today, logEpisode?.resolvedAt);
|
||||
|
||||
/**
|
||||
* Group the flat list into active / resolved, nesting flares and recurring
|
||||
@@ -532,7 +533,7 @@ export function IllnessView() {
|
||||
if (!open) setLogEpisodeId(null);
|
||||
}}
|
||||
episodeId={logEpisodeId}
|
||||
date={today}
|
||||
date={logLastDay}
|
||||
onsetDate={logEpisode?.onsetAt.slice(0, 10)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -24,7 +24,12 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
useDisplayTimezone,
|
||||
useFormatters,
|
||||
useTranslations,
|
||||
} from "@/lib/i18n/context";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
|
||||
import { ILLNESS_SYMPTOM_CATALOG } from "./symptom-catalog";
|
||||
@@ -35,7 +40,11 @@ interface LogDaySheetProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
episodeId: string;
|
||||
/** The day to seed the picker with (usually today). */
|
||||
/**
|
||||
* The last day of the episode (`YYYY-MM-DD`): the day the picker opens on
|
||||
* and the latest it offers. Today while the episode runs, its recovery day
|
||||
* once it ended (`useLastLoggableDay`).
|
||||
*/
|
||||
date: string;
|
||||
/**
|
||||
* Episode onset day (`YYYY-MM-DD`) — the earliest day the picker allows, so
|
||||
@@ -45,6 +54,29 @@ interface LogDaySheetProps {
|
||||
onsetDate?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The last day a log can belong to: today, or the recovery day when the
|
||||
* episode ended before today. The recovery day is read in the profile zone,
|
||||
* the zone the server reads it in.
|
||||
*/
|
||||
export function lastLoggableDay(
|
||||
today: string,
|
||||
resolvedAt: string | null | undefined,
|
||||
timeZone: string,
|
||||
): string {
|
||||
if (!resolvedAt) return today;
|
||||
const resolvedDay = userDayKey(new Date(resolvedAt), timeZone);
|
||||
return resolvedDay < today ? resolvedDay : today;
|
||||
}
|
||||
|
||||
/** `lastLoggableDay` in the viewer's profile zone. */
|
||||
export function useLastLoggableDay(
|
||||
today: string,
|
||||
resolvedAt: string | null | undefined,
|
||||
): string {
|
||||
return lastLoggableDay(today, resolvedAt, useDisplayTimezone());
|
||||
}
|
||||
|
||||
/** Mini toggle chip — the shared selection vocabulary. */
|
||||
function Chip({
|
||||
active,
|
||||
@@ -147,8 +179,8 @@ export function LogDaySheet({
|
||||
const { toDisplay, fromDisplay, unitFor } = useUnitDisplay();
|
||||
const feverUnit = unitFor("BODY_TEMPERATURE");
|
||||
|
||||
// The day being logged — seeded from `date` (today), but the picker lets the
|
||||
// user backdate within [onset, today] for the retrospective journal.
|
||||
// The day being logged — seeded from `date` (the episode's last day), and
|
||||
// the picker lets the user backdate within [onset, date] for the journal.
|
||||
const [selectedDate, setSelectedDate] = useState(date);
|
||||
// Re-seed the picker each time the sheet opens (the open transition).
|
||||
const [pickerWasOpen, setPickerWasOpen] = useState(false);
|
||||
|
||||
@@ -114,7 +114,7 @@ export function MedicationEfficacySummary({
|
||||
</div>
|
||||
<InlineAction asChild className="shrink-0">
|
||||
<Link
|
||||
href={`/medications/${med.id}?tab=wirkung`}
|
||||
href={`/medications/${med.id}?tab=effect`}
|
||||
data-slot="insights-medication-efficacy-link"
|
||||
>
|
||||
{t("medications.efficacy.insights.link")}
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const COMPONENT_DIR = join(__dirname, "..");
|
||||
const MOOD_LIST = join(__dirname, "../../../mood/mood-list.tsx");
|
||||
/**
|
||||
* The two modules the mood insights surface is rendered from. The breakdown
|
||||
* cluster lives behind `next/dynamic` in its own file; both are the surface.
|
||||
*/
|
||||
const INSIGHTS_SURFACE_FILES = [
|
||||
join(COMPONENT_DIR, "mood-insights-sections.tsx"),
|
||||
join(COMPONENT_DIR, "mood-insights-breakdowns.tsx"),
|
||||
];
|
||||
|
||||
/**
|
||||
* v1.8.6 — the notes timeline display is removed from the mood insights
|
||||
* surface (note capture on the form stays). These structural guards keep
|
||||
* the surface from regressing back to a notes feed and keep long notes
|
||||
* truncated in the entries table.
|
||||
*/
|
||||
describe("mood notes timeline removal", () => {
|
||||
it("deletes the notes-timeline component file", () => {
|
||||
expect(existsSync(join(COMPONENT_DIR, "mood-notes-timeline.tsx"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("no longer renders the notes timeline from the insights sections", () => {
|
||||
// Both halves of the surface: the eager module that paints the calendar
|
||||
// and the assessment, and the deferred module that carries every
|
||||
// breakdown. A guard reading only one of them would go quiet the moment
|
||||
// the other grew a notes feed.
|
||||
for (const file of INSIGHTS_SURFACE_FILES) {
|
||||
const src = readFileSync(file, "utf8");
|
||||
expect(src).not.toContain("MoodNotesTimeline");
|
||||
expect(src).not.toContain("notesTimeline");
|
||||
expect(src).not.toContain("notesTimelineTitle");
|
||||
}
|
||||
});
|
||||
|
||||
it("mounts the narrative feed in the insights sections", () => {
|
||||
// v1.12.7 — the narrative one-liners now ride the merged "What stands out"
|
||||
// card (`MoodWhatStandsOut`), which renders `MoodNarrativeFeed` internally.
|
||||
// The structural guard tracks the mount point on the sections surface.
|
||||
//
|
||||
// v1.38 — that mount point moved into `mood-insights-breakdowns.tsx` when
|
||||
// the below-the-fold cluster went behind `next/dynamic`. Read as the union
|
||||
// of the two files, so the guard asserts the card is mounted SOMEWHERE on
|
||||
// the surface rather than in one particular file — which is what it was
|
||||
// always about.
|
||||
const src = INSIGHTS_SURFACE_FILES.map((f) => readFileSync(f, "utf8")).join(
|
||||
"\n",
|
||||
);
|
||||
expect(src).toContain("MoodWhatStandsOut");
|
||||
expect(src).toContain("MoodInTargetTile");
|
||||
});
|
||||
|
||||
it("renders the narrative feed inside the merged what-stands-out card", () => {
|
||||
const src = readFileSync(
|
||||
join(COMPONENT_DIR, "mood-what-stands-out.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
expect(src).toContain("MoodNarrativeFeed");
|
||||
expect(src).toContain("MoodDiscoveredRelations");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mood entries table — long-note truncation", () => {
|
||||
const src = readFileSync(MOOD_LIST, "utf8");
|
||||
|
||||
it("caps the note column width to prevent horizontal scroll", () => {
|
||||
expect(src).toContain("max-w-[18rem]");
|
||||
});
|
||||
|
||||
it("clamps the note by default and exposes the full text via the row toggle", () => {
|
||||
// v1.16.8 — the desktop hover tooltip is replaced by an expandable
|
||||
// toggle (works on touch, preserves line breaks via pre-wrap). The
|
||||
// default presentation stays clamped so the table keeps its density.
|
||||
expect(src).toContain("line-clamp-2");
|
||||
expect(src).toContain("mood-note-toggle");
|
||||
expect(src).toContain("whitespace-pre-wrap");
|
||||
expect(src).not.toContain("TooltipContent");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { MeasurementReminder } from "@/hooks/use-measurement-reminders";
|
||||
|
||||
/**
|
||||
* A checkup that is switched off, or that the Coach created, says so in the
|
||||
* card view. The list view used to drop both badges, so a disabled checkup
|
||||
* looked active there; which view a person sees is a remembered preference,
|
||||
* so the same checkup read differently depending on a toggle.
|
||||
*/
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ replace: vi.fn(), push: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { VorsorgeCard } from "../checkups/checkup-card";
|
||||
|
||||
const reminder = {
|
||||
id: "coach-off",
|
||||
label: "Check blood pressure",
|
||||
measurementType: null,
|
||||
intervalDays: 30,
|
||||
rrule: null,
|
||||
anchorDate: null,
|
||||
endsOn: null,
|
||||
origin: "COACH",
|
||||
nextDueAt: null,
|
||||
notifyHour: 9,
|
||||
location: null,
|
||||
lastSatisfiedAt: null,
|
||||
enabled: false,
|
||||
createdAt: "2030-01-01T00:00:00.000Z",
|
||||
updatedAt: "2030-01-01T00:00:00.000Z",
|
||||
} as MeasurementReminder;
|
||||
|
||||
function render(view: "cards" | "list"): string {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
const noop = () => {};
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<VorsorgeCard
|
||||
reminder={reminder}
|
||||
now={Date.parse("2030-02-01T12:00:00.000Z")}
|
||||
view={view}
|
||||
onPrimaryAction={noop}
|
||||
onFileVisit={noop}
|
||||
onRemove={noop}
|
||||
onEdit={noop}
|
||||
onSkip={noop}
|
||||
onSnooze={noop}
|
||||
busy={false}
|
||||
/>
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("checkup state badges", () => {
|
||||
it.each(["cards", "list"] as const)(
|
||||
"show the Coach and switched-off badges in the %s view",
|
||||
(view) => {
|
||||
const html = render(view);
|
||||
expect(html).toContain("Check blood pressure");
|
||||
expect(html).toContain(">Coach<");
|
||||
expect(html).toContain(">Off<");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -28,6 +28,7 @@ const LOCALES = readdirSync(MESSAGES_DIR)
|
||||
const REQUIRED_KEYS = [
|
||||
// Newly added Vorsorge reminder type labels.
|
||||
"measurementReminders.types.WAIST_CIRCUMFERENCE",
|
||||
"measurementReminders.types.BLOOD_KETONES",
|
||||
"measurementReminders.types.WHO5_SCORE",
|
||||
"measurementReminders.types.SCI_SCORE",
|
||||
// Pre-existing screening labels (regression floor).
|
||||
|
||||
@@ -244,6 +244,30 @@ function NextLastSlot({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Coach-origin and switched-off badges, shared by the card and the list
|
||||
* view; `undefined` when neither applies, so no empty row is drawn.
|
||||
*/
|
||||
function checkupStateBadges(
|
||||
reminder: MeasurementReminder,
|
||||
t: (key: string) => string,
|
||||
): React.ReactNode {
|
||||
const isCoach = reminder.origin === "COACH";
|
||||
if (!isCoach && reminder.enabled) return undefined;
|
||||
return (
|
||||
<>
|
||||
{isCoach && (
|
||||
<Badge variant="outline">{t("measurementReminders.originCoach")}</Badge>
|
||||
)}
|
||||
{!reminder.enabled && (
|
||||
<Badge variant="outline">
|
||||
{t("measurementReminders.disabledBadge")}
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The dense list variant of a checkup. */
|
||||
function CheckupListRow({
|
||||
reminder,
|
||||
@@ -251,6 +275,7 @@ function CheckupListRow({
|
||||
categoryLabel,
|
||||
cadenceText,
|
||||
meta,
|
||||
stateBadges,
|
||||
isLinked,
|
||||
isScreening,
|
||||
canManageMeasurements,
|
||||
@@ -265,6 +290,8 @@ function CheckupListRow({
|
||||
categoryLabel: string;
|
||||
cadenceText: string | null;
|
||||
meta: React.ReactNode;
|
||||
/** The same Coach and disabled badges the card view shows. */
|
||||
stateBadges: React.ReactNode;
|
||||
isLinked: boolean;
|
||||
isScreening: boolean;
|
||||
canManageMeasurements: boolean;
|
||||
@@ -283,6 +310,7 @@ function CheckupListRow({
|
||||
<span className="truncate font-medium">
|
||||
{resolveReminderLabel(reminder, t)}
|
||||
</span>
|
||||
{stateBadges}
|
||||
</div>
|
||||
{/* Cadence rides on the metric line, styled like the
|
||||
metric text (no shaded pill). */}
|
||||
@@ -537,7 +565,6 @@ export function VorsorgeCard({
|
||||
: reminder.rrule
|
||||
? t("measurementReminders.cadence.custom")
|
||||
: "";
|
||||
const isCoach = reminder.origin === "COACH";
|
||||
const isLinked = reminder.measurementType != null;
|
||||
// A screening reminder's action starts the check-in, never numeric entry.
|
||||
const isScreening = isScreeningReminderType(reminder.measurementType);
|
||||
@@ -558,20 +585,7 @@ export function VorsorgeCard({
|
||||
? `/measurements?type=${encodeURIComponent(reminder.measurementType as string)}`
|
||||
: undefined;
|
||||
|
||||
// Provenance and disabled badges get a row only when one applies.
|
||||
const hasStateBadges = isCoach || !reminder.enabled;
|
||||
const stateBadges = hasStateBadges ? (
|
||||
<>
|
||||
{isCoach && (
|
||||
<Badge variant="outline">{t("measurementReminders.originCoach")}</Badge>
|
||||
)}
|
||||
{!reminder.enabled && (
|
||||
<Badge variant="outline">
|
||||
{t("measurementReminders.disabledBadge")}
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
) : undefined;
|
||||
const stateBadges = checkupStateBadges(reminder, t);
|
||||
|
||||
const headerActions = canManageMeasurements ? (
|
||||
<CheckupMenu
|
||||
@@ -643,6 +657,7 @@ export function VorsorgeCard({
|
||||
categoryLabel={categoryLabel}
|
||||
cadenceText={cadenceText}
|
||||
meta={skipSnoozeMeta}
|
||||
stateBadges={stateBadges}
|
||||
isLinked={isLinked}
|
||||
isScreening={isScreening}
|
||||
canManageMeasurements={canManageMeasurements}
|
||||
|
||||
@@ -16,6 +16,7 @@ export const TYPE_GROUPS = [
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"PULSE",
|
||||
"BLOOD_GLUCOSE",
|
||||
"BLOOD_KETONES",
|
||||
"OXYGEN_SATURATION",
|
||||
"BODY_TEMPERATURE",
|
||||
],
|
||||
|
||||
@@ -201,7 +201,7 @@ export function VorsorgeSection({
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
>
|
||||
<Link
|
||||
href="/settings/layout/vorsorge"
|
||||
href="/settings/layout/checkups"
|
||||
aria-label={t("measurementReminders.customize")}
|
||||
title={t("measurementReminders.customize")}
|
||||
>
|
||||
|
||||
@@ -111,8 +111,6 @@ describe("medication detail compliance applicability", () => {
|
||||
});
|
||||
|
||||
it("keeps PRN compliance disabled at the query boundary", () => {
|
||||
expect(source).toContain(
|
||||
'enabled: activeTab === "uebersicht" && !asNeeded',
|
||||
);
|
||||
expect(source).toContain('enabled: activeTab === "overview" && !asNeeded');
|
||||
});
|
||||
});
|
||||
@@ -465,49 +465,9 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
|
||||
expect(html).not.toContain("Low stock");
|
||||
});
|
||||
|
||||
it("folds the side-effect quick-log into the same overflow kebab", () => {
|
||||
// v1.7.2 — the side-effect quick-log folds into the SAME overflow
|
||||
// menu as edit / history / advanced, so the GLP-1 header carries one
|
||||
// kebab whether or not the hand-off is wired (parity with the generic
|
||||
// card). SSR doesn't open Radix Portal content, so we assert the
|
||||
// single trigger surfaces and smoke-check the handler payload.
|
||||
const client = makeClient();
|
||||
seedCompliance(client, med7p5.id);
|
||||
seedGlp1Details(client, med7p5.id, {});
|
||||
|
||||
const handler = vi.fn();
|
||||
const html = render(
|
||||
<Glp1MedicationCard
|
||||
medication={med7p5}
|
||||
onEdit={() => {}}
|
||||
onOpenHistory={() => {}}
|
||||
onLogSideEffect={handler}
|
||||
/>,
|
||||
client,
|
||||
);
|
||||
|
||||
// Exactly one kebab trigger with the localised aria-label.
|
||||
const triggers = html.match(/aria-label="More options"/g) ?? [];
|
||||
expect(triggers).toHaveLength(1);
|
||||
// The side-effect button must no longer ride alongside
|
||||
// Eingenommen / Übersprungen in the primary actions row.
|
||||
expect(html).not.toMatch(
|
||||
/class="[^"]*flex[^"]*gap-2[^"]*"[^>]*>[\s\S]*?Log side effect/,
|
||||
);
|
||||
|
||||
// Invoke the supplied handler the way the menu item's onClick
|
||||
// would — pins the prefill payload contract for MoodEntry.
|
||||
handler(med7p5);
|
||||
expect(handler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: "Mounjaro", treatmentClass: "GLP1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("still renders the single kebab when onLogSideEffect is not supplied", () => {
|
||||
// v1.7.2 — the kebab is unconditional now (it always carries edit
|
||||
// / history / advanced); the side-effect item is the only opt-in
|
||||
// part. Mounjaro and Ramipril therefore share the same one-kebab
|
||||
// header shape regardless of the hand-off prop.
|
||||
it("renders the single overflow kebab", () => {
|
||||
// The kebab carries edit and history, so Mounjaro and Ramipril share
|
||||
// the same one-kebab header shape.
|
||||
const client = makeClient();
|
||||
seedCompliance(client, med7p5.id);
|
||||
seedGlp1Details(client, med7p5.id, {});
|
||||
|
||||
@@ -190,9 +190,8 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("Mounjaro keeps the single kebab even when onLogSideEffect is supplied (GLP-1-only)", () => {
|
||||
// The side-effect quick-log folds into the SAME overflow menu, so the
|
||||
// GLP-1 header shape stays byte-symmetric with the generic card: one
|
||||
it("Mounjaro carries the same single kebab as the generic card", () => {
|
||||
// The GLP-1 header shape stays byte-symmetric with the generic card: one
|
||||
// kebab trigger, no extra control.
|
||||
const client = makeClient();
|
||||
seedCompliance(client, mounjaro.id);
|
||||
@@ -203,7 +202,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
|
||||
medication={mounjaro}
|
||||
onEdit={() => {}}
|
||||
onOpenHistory={() => {}}
|
||||
onLogSideEffect={() => {}}
|
||||
/>,
|
||||
client,
|
||||
);
|
||||
@@ -294,7 +292,6 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
|
||||
medication={mounjaro}
|
||||
onEdit={() => {}}
|
||||
onOpenHistory={() => {}}
|
||||
onLogSideEffect={() => {}}
|
||||
/>,
|
||||
client,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
resolveTabSlug,
|
||||
TAB_SLUGS,
|
||||
} from "@/components/medications/detail/tab-slugs";
|
||||
|
||||
/**
|
||||
* The medication tabs carried German slugs in every language
|
||||
* (`?tab=verlauf`). They are English now, and every old link (a bookmark,
|
||||
* a report, a low-stock notification sent before the change) still lands on
|
||||
* the tab it named.
|
||||
*/
|
||||
describe("medication tab slugs", () => {
|
||||
const all = [...TAB_SLUGS];
|
||||
|
||||
it("opens a tab by its own slug", () => {
|
||||
expect(resolveTabSlug("history", all)).toBe("history");
|
||||
expect(resolveTabSlug("supply", all)).toBe("supply");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["uebersicht", "overview"],
|
||||
["wirkung", "effect"],
|
||||
["zeitplan", "schedule"],
|
||||
["bestand", "supply"],
|
||||
["verlauf", "history"],
|
||||
["injektion", "injection"],
|
||||
["erweitert", "advanced"],
|
||||
["erinnerung", "schedule"],
|
||||
["api", "advanced"],
|
||||
] as const)("sends the old ?tab=%s to %s", (legacy, tab) => {
|
||||
expect(resolveTabSlug(legacy, all)).toBe(tab);
|
||||
});
|
||||
|
||||
it("falls back to the overview for a missing, unknown or unavailable tab", () => {
|
||||
expect(resolveTabSlug(null, all)).toBe("overview");
|
||||
expect(resolveTabSlug("constructor", all)).toBe("overview");
|
||||
expect(resolveTabSlug("nope", all)).toBe("overview");
|
||||
expect(
|
||||
resolveTabSlug(
|
||||
"injektion",
|
||||
all.filter((tab) => tab !== "injection"),
|
||||
),
|
||||
).toBe("overview");
|
||||
});
|
||||
|
||||
it("keeps every slug URL-safe ASCII", () => {
|
||||
for (const slug of TAB_SLUGS) expect(slug).toMatch(/^[a-z]+$/);
|
||||
});
|
||||
});
|
||||
@@ -6,12 +6,12 @@
|
||||
* Promotes the detail page from a read-only history view to the
|
||||
* canonical home for ONE medication. The modal `<AdvancedSettingsSheet>`
|
||||
* is dissolved into the Erweitert tab (no more five-card stack in a
|
||||
* sheet) and the separate `/history` route folds into the Verlauf tab
|
||||
* (the route now redirects to `?tab=verlauf`).
|
||||
* sheet) and the separate `/history` route folds into the history tab
|
||||
* (the route now redirects to `?tab=history`).
|
||||
*
|
||||
* Tab state lives in the URL (`?tab=<slug>`), so a tab is deep-linkable,
|
||||
* survives reload, and is reachable from the medications-list card kebab
|
||||
* (History→verlauf). The kebab's Edit arrives as `?edit=1`, which opens
|
||||
* (History). The kebab's Edit arrives as `?edit=1`, which opens
|
||||
* the wizard in edit mode on top of the landing tab. Slugs are ASCII /
|
||||
* locale-independent; the visible labels are i18n.
|
||||
*
|
||||
@@ -20,7 +20,7 @@
|
||||
* switch + the reminder grace), and Übersicht grew from a lone
|
||||
* compliance bar into the status landing surface (next due dose,
|
||||
* reminder state, supply runway — each with a jump link into its owner
|
||||
* tab). Stale `?tab=erinnerung` deep-links land on Zeitplan.
|
||||
* tab). Stale `?tab=erinnerung` deep-links land on the schedule tab.
|
||||
*
|
||||
* Only the active tab body mounts (Radix `Tabs` default) so inactive tabs
|
||||
* do not fetch. The hero stays above the tabs as the always-visible
|
||||
@@ -106,6 +106,12 @@ import { MedicationRelatedSection } from "@/components/medications/detail/medica
|
||||
import type { RelatedLink } from "@/lib/links/related";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import {
|
||||
resolveTabSlug,
|
||||
TAB_LABEL_KEYS,
|
||||
TAB_SLUGS,
|
||||
type TabSlug,
|
||||
} from "@/components/medications/detail/tab-slugs";
|
||||
|
||||
// v1.15.20 — Recharts stays out of the detail page's initial bundle:
|
||||
// both chart bodies load through `next/dynamic` (client-only) with the
|
||||
@@ -202,20 +208,6 @@ export interface MedicationDetailSnapshot {
|
||||
related?: RelatedLink[];
|
||||
}
|
||||
|
||||
// Slugs are ASCII / locale-independent so the URL stays stable across
|
||||
// locales; the visible labels resolve through i18n. The list drives both
|
||||
// the tab strip render and the `?tab=` round-trip validation.
|
||||
const TAB_SLUGS = [
|
||||
"uebersicht",
|
||||
"wirkung",
|
||||
"zeitplan",
|
||||
"bestand",
|
||||
"verlauf",
|
||||
"injektion",
|
||||
"erweitert",
|
||||
] as const;
|
||||
type TabSlug = (typeof TAB_SLUGS)[number];
|
||||
|
||||
/**
|
||||
* Tabs whose entire content is owner-only work: the schedule editor and its
|
||||
* plan history, the supply ledger, the external-token row, and the advanced
|
||||
@@ -225,10 +217,10 @@ type TabSlug = (typeof TAB_SLUGS)[number];
|
||||
const OWNER_ONLY_TABS: ReadonlySet<TabSlug> = new Set([
|
||||
// Schedule times are a MANAGE route, but the tab also carries the reminder
|
||||
// and notification settings, which resolve the caller.
|
||||
"zeitplan",
|
||||
"schedule",
|
||||
// Phase config, export and the per-medication tokens (a credential
|
||||
// surface, never delegated) resolve the caller.
|
||||
"erweitert",
|
||||
"advanced",
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -237,20 +229,7 @@ const OWNER_ONLY_TABS: ReadonlySet<TabSlug> = new Set([
|
||||
* is one; the three above are not, because each mixes in a route that
|
||||
* resolves the caller and would 403 under a switch.
|
||||
*/
|
||||
const MANAGE_TABS: ReadonlySet<TabSlug> = new Set(["bestand"]);
|
||||
|
||||
/**
|
||||
* Legacy slugs that point at a dissolved tab. The Erinnerung tab folded
|
||||
* into Zeitplan in v1.15.20; old deep-links keep landing on the surface
|
||||
* that now owns the reminder controls instead of falling back to the
|
||||
* landing tab.
|
||||
*/
|
||||
const LEGACY_TAB_SLUGS: Record<string, TabSlug> = {
|
||||
erinnerung: "zeitplan",
|
||||
// The API tab folded back into Erweitert in v1.42: two tabs for the
|
||||
// rarely-opened owner settings made a strip that could not fit a phone.
|
||||
api: "erweitert",
|
||||
};
|
||||
const MANAGE_TABS: ReadonlySet<TabSlug> = new Set(["supply"]);
|
||||
|
||||
function snapshotToWizardPayload(
|
||||
med: MedicationDetailSnapshot,
|
||||
@@ -364,26 +343,26 @@ export function MedicationDetailTabs({
|
||||
|
||||
// Only the active tab body mounts, but the tab REGISTRY must agree with
|
||||
// whatever `?tab=` carries — Injektion is absent for non-injectable
|
||||
// medications, so a stale `?tab=injektion` deep-link falls back to the
|
||||
// medications, so a stale `?tab=injection` deep-link falls back to the
|
||||
// landing tab instead of rendering an empty surface.
|
||||
// v1.16.11 — an as-needed medication has no schedule and never
|
||||
// reminds, so the Zeitplan tab (times editor + reminder card +
|
||||
// plan-history) does not apply and is dropped from the registry; a
|
||||
// stale `?tab=zeitplan` deep-link falls back to the landing tab.
|
||||
// stale `?tab=schedule` deep-link falls back to the landing tab.
|
||||
// v1.36.x — four tabs are nothing BUT owner work: the schedule editor, the
|
||||
// supply ledger, the external-token row and the advanced surface that holds
|
||||
// the wizard, the import and the destructive zone. None of it is delegated,
|
||||
// so inside somebody else's record they leave the registry entirely rather
|
||||
// than opening onto controls the server refuses — which also means a stale
|
||||
// `?tab=erweitert` deep link lands on the landing tab, the same way an
|
||||
// `?tab=advanced` deep link lands on the landing tab, the same way an
|
||||
// inapplicable tab already does.
|
||||
const availableTabs = useMemo<TabSlug[]>(
|
||||
() =>
|
||||
TAB_SLUGS.filter(
|
||||
(slug) =>
|
||||
(slug !== "injektion" || isInjectable) &&
|
||||
(slug !== "zeitplan" || !asNeeded) &&
|
||||
(slug !== "wirkung" || hasEfficacyTarget) &&
|
||||
(slug !== "injection" || isInjectable) &&
|
||||
(slug !== "schedule" || !asNeeded) &&
|
||||
(slug !== "effect" || hasEfficacyTarget) &&
|
||||
(!inSharedRecord || !OWNER_ONLY_TABS.has(slug)) &&
|
||||
(canManageMedications || !MANAGE_TABS.has(slug)),
|
||||
),
|
||||
@@ -396,14 +375,7 @@ export function MedicationDetailTabs({
|
||||
],
|
||||
);
|
||||
|
||||
const requestedRaw = searchParams?.get("tab");
|
||||
const requested = (
|
||||
requestedRaw && requestedRaw in LEGACY_TAB_SLUGS
|
||||
? LEGACY_TAB_SLUGS[requestedRaw]
|
||||
: requestedRaw
|
||||
) as TabSlug | null;
|
||||
const activeTab: TabSlug =
|
||||
requested && availableTabs.includes(requested) ? requested : "uebersicht";
|
||||
const activeTab = resolveTabSlug(searchParams?.get("tab"), availableTabs);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldOpenEditFromUrl) {
|
||||
@@ -438,7 +410,7 @@ export function MedicationDetailTabs({
|
||||
},
|
||||
// v1.16.11 — no compliance read for an as-needed medication (the
|
||||
// section is hidden; there is no rate to fetch).
|
||||
enabled: activeTab === "uebersicht" && !asNeeded,
|
||||
enabled: activeTab === "overview" && !asNeeded,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
@@ -468,7 +440,7 @@ export function MedicationDetailTabs({
|
||||
return null;
|
||||
}
|
||||
},
|
||||
enabled: activeTab === "uebersicht",
|
||||
enabled: activeTab === "overview",
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
@@ -549,7 +521,7 @@ export function MedicationDetailTabs({
|
||||
value={slug}
|
||||
data-slot={`medication-tab-${slug}`}
|
||||
>
|
||||
{t(`medications.detail.tabs.${slug}`)}
|
||||
{t(TAB_LABEL_KEYS[slug])}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
@@ -558,7 +530,7 @@ export function MedicationDetailTabs({
|
||||
reminder state and supply runway (each with a jump link into
|
||||
its owner tab), then the compliance readout. All values come
|
||||
from data the page already holds — no new endpoints. */}
|
||||
<TabsContent value="uebersicht" className="space-y-6 pt-2">
|
||||
<TabsContent value="overview" className="space-y-6 pt-2">
|
||||
<MedicationDetailSection
|
||||
titleId="medication-uebersicht-status-heading"
|
||||
title={t("medications.detail.uebersicht.statusTitle")}
|
||||
@@ -583,7 +555,7 @@ export function MedicationDetailTabs({
|
||||
? t("medications.detail.uebersicht.jumpToVerlauf")
|
||||
: t("medications.detail.uebersicht.jumpToZeitplan")
|
||||
}
|
||||
onJump={() => onTabChange(asNeeded ? "verlauf" : "zeitplan")}
|
||||
onJump={() => onTabChange(asNeeded ? "history" : "schedule")}
|
||||
dataSlot="uebersicht-next-dose"
|
||||
/>
|
||||
{/* v1.16.11 — as-needed never reminds; the row would only
|
||||
@@ -597,7 +569,7 @@ export function MedicationDetailTabs({
|
||||
: t("medications.detail.uebersicht.reminderOff")
|
||||
}
|
||||
jumpLabel={t("medications.detail.uebersicht.jumpToZeitplan")}
|
||||
onJump={() => onTabChange("zeitplan")}
|
||||
onJump={() => onTabChange("schedule")}
|
||||
dataSlot="uebersicht-reminder"
|
||||
/>
|
||||
)}
|
||||
@@ -639,7 +611,7 @@ export function MedicationDetailTabs({
|
||||
</>
|
||||
}
|
||||
jumpLabel={t("medications.detail.uebersicht.jumpToBestand")}
|
||||
onJump={() => onTabChange("bestand")}
|
||||
onJump={() => onTabChange("supply")}
|
||||
dataSlot="uebersicht-supply"
|
||||
/>
|
||||
)}
|
||||
@@ -692,10 +664,10 @@ export function MedicationDetailTabs({
|
||||
with a before/after comparison + adherence lane. Strictly
|
||||
descriptive; hidden for one-shot + no-target medications. */}
|
||||
{hasEfficacyTarget && (
|
||||
<TabsContent value="wirkung" className="space-y-6 pt-2">
|
||||
<TabsContent value="effect" className="space-y-6 pt-2">
|
||||
<EfficacyTab
|
||||
medicationId={id}
|
||||
active={activeTab === "wirkung"}
|
||||
active={activeTab === "effect"}
|
||||
isGlp1={isGlp1}
|
||||
medicationName={medication.name}
|
||||
medicationDose={medication.dose}
|
||||
@@ -708,7 +680,7 @@ export function MedicationDetailTabs({
|
||||
notifications switch + the reminder grace, folded in from the
|
||||
dissolved Erinnerung tab). Cadence-kind stays structural (the
|
||||
hero's "Vollständig bearbeiten"). */}
|
||||
<TabsContent value="zeitplan" className="space-y-6 pt-2">
|
||||
<TabsContent value="schedule" className="space-y-6 pt-2">
|
||||
{/* v1.39.1 (#1033) — the intake-tracking switch leads the tab:
|
||||
it decides whether the times below are a dose plan or a
|
||||
record. */}
|
||||
@@ -827,7 +799,7 @@ export function MedicationDetailTabs({
|
||||
</TabsContent>
|
||||
|
||||
{/* BESTAND — inventory readout + register / correct flows. */}
|
||||
<TabsContent value="bestand" className="space-y-4 pt-2">
|
||||
<TabsContent value="supply" className="space-y-4 pt-2">
|
||||
<InventorySection
|
||||
medicationId={id}
|
||||
dosesPerUnit={medication.dosesPerUnit}
|
||||
@@ -841,7 +813,7 @@ export function MedicationDetailTabs({
|
||||
optimistic recompute, edit/add (incl. the late-take "diesem
|
||||
Slot zuordnen?" nudge). CSV import lives under Erweitert →
|
||||
Daten (the DataPortabilityRow), not in this header. */}
|
||||
<TabsContent value="verlauf" className="space-y-6 pt-2">
|
||||
<TabsContent value="history" className="space-y-6 pt-2">
|
||||
<div className="flex justify-end">
|
||||
<Segmented
|
||||
mode="toggle"
|
||||
@@ -887,7 +859,7 @@ export function MedicationDetailTabs({
|
||||
side-effect log; the body-map + adherence detail land next
|
||||
wave. */}
|
||||
{isInjectable && (
|
||||
<TabsContent value="injektion" className="space-y-6 pt-2">
|
||||
<TabsContent value="injection" className="space-y-6 pt-2">
|
||||
{/* Dose-escalation plan: completed + planned titration steps
|
||||
with a "you are here" marker. Renders for every injectable
|
||||
route (not GLP-1-only) since titration is a property of the
|
||||
@@ -916,7 +888,7 @@ export function MedicationDetailTabs({
|
||||
{/* ERWEITERT — Lifecycle → Daten → external API → Gefahrenzone.
|
||||
The API tab folded back in here (v1.42); its group stays
|
||||
collapsed like the other two, and the danger zone stays last. */}
|
||||
<TabsContent value="erweitert" className="space-y-4 pt-2">
|
||||
<TabsContent value="advanced" className="space-y-4 pt-2">
|
||||
<SettingsGroup
|
||||
label={t("medications.detail.erweitert.group.lifecycle")}
|
||||
dataSlot="erweitert-group-lifecycle"
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* The medication detail page's tab slugs, and what an old `?tab=` value maps
|
||||
* to. Slugs are ASCII and the same in every locale so a link stays stable;
|
||||
* the visible labels resolve through i18n.
|
||||
*/
|
||||
export const TAB_SLUGS = [
|
||||
"overview",
|
||||
"effect",
|
||||
"schedule",
|
||||
"supply",
|
||||
"history",
|
||||
"injection",
|
||||
"advanced",
|
||||
] as const;
|
||||
export type TabSlug = (typeof TAB_SLUGS)[number];
|
||||
|
||||
/**
|
||||
* Slugs an old link may still carry. The tabs used to have German slugs in
|
||||
* every language; links in old reports, notifications and bookmarks keep
|
||||
* landing on the same tab. The Erinnerung tab folded into the schedule tab,
|
||||
* and the API tab into the advanced one.
|
||||
*/
|
||||
export const LEGACY_TAB_SLUGS: Record<string, TabSlug> = {
|
||||
uebersicht: "overview",
|
||||
wirkung: "effect",
|
||||
zeitplan: "schedule",
|
||||
bestand: "supply",
|
||||
verlauf: "history",
|
||||
injektion: "injection",
|
||||
erweitert: "advanced",
|
||||
erinnerung: "schedule",
|
||||
api: "advanced",
|
||||
};
|
||||
|
||||
/** The tab labels; the message keys predate the English slugs. */
|
||||
export const TAB_LABEL_KEYS: Record<TabSlug, string> = {
|
||||
overview: "medications.detail.tabs.uebersicht",
|
||||
effect: "medications.detail.tabs.wirkung",
|
||||
schedule: "medications.detail.tabs.zeitplan",
|
||||
supply: "medications.detail.tabs.bestand",
|
||||
history: "medications.detail.tabs.verlauf",
|
||||
injection: "medications.detail.tabs.injektion",
|
||||
advanced: "medications.detail.tabs.erweitert",
|
||||
};
|
||||
|
||||
/**
|
||||
* The tab a `?tab=` value opens: its own slug, or the one an old slug maps
|
||||
* to, when that tab is available for this medication; the overview
|
||||
* otherwise.
|
||||
*/
|
||||
export function resolveTabSlug(
|
||||
raw: string | null | undefined,
|
||||
available: readonly TabSlug[],
|
||||
): TabSlug {
|
||||
const slug =
|
||||
raw && Object.hasOwn(LEGACY_TAB_SLUGS, raw) ? LEGACY_TAB_SLUGS[raw] : raw;
|
||||
return available.find((tab) => tab === slug) ?? "overview";
|
||||
}
|
||||
@@ -174,11 +174,10 @@ interface Glp1MedicationCardProps {
|
||||
onEdit: (med: Glp1Medication) => void;
|
||||
/**
|
||||
* v1.15.18 — navigates to the medication detail page's Verlauf tab
|
||||
* (`/medications/{id}?tab=verlauf`), mirroring the generic medication
|
||||
* (`/medications/{id}?tab=history`), mirroring the generic medication
|
||||
* card. The parent owns the navigation.
|
||||
*/
|
||||
onOpenHistory: (med: Glp1Medication) => void;
|
||||
onLogSideEffect?: (med: Glp1Medication) => void;
|
||||
/**
|
||||
* v1.29.x — briefly ringed when this card is the Today digest's "Log
|
||||
* dose" deep-link target (`/medications?highlight=<id>`).
|
||||
@@ -215,7 +214,6 @@ export function Glp1MedicationCard({
|
||||
medication,
|
||||
onEdit,
|
||||
onOpenHistory,
|
||||
onLogSideEffect,
|
||||
highlighted = false,
|
||||
}: Glp1MedicationCardProps) {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -478,19 +476,11 @@ export function Glp1MedicationCard({
|
||||
// The four former header icon-buttons (open / edit / history /
|
||||
// advanced) collapse into a SINGLE overflow menu so the GLP-1 card
|
||||
// stays symmetric with the generic medication card. The card header
|
||||
// itself links to the detail page (the former chevron target). The
|
||||
// optional side-effect quick-log would fold in as a last menu item
|
||||
// when `onLogSideEffect` is wired, but the medications list page does
|
||||
// not wire it — side-effect logging lives on the detail page's
|
||||
// SideEffectsSection — so the menu renders the same items as the
|
||||
// generic card.
|
||||
// itself links to the detail page (the former chevron target).
|
||||
const headerActions = (
|
||||
<MedicationCardMenu
|
||||
onEdit={() => onEdit(medication)}
|
||||
onOpenHistory={() => onOpenHistory(medication)}
|
||||
onLogSideEffect={
|
||||
onLogSideEffect ? () => onLogSideEffect(medication) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -14,13 +14,8 @@
|
||||
* target as the name tap); the structural wizard lives under the detail
|
||||
* page's Erweitert → Lebenszyklus row.
|
||||
*
|
||||
* The optional `onLogSideEffect` slot exists for the GLP-1 card to fold a
|
||||
* "Log side effect" item into the SAME menu, but it is NOT wired on the
|
||||
* medications list page — both card variants there receive only the
|
||||
* edit / history / advanced handlers, so the item never renders. Side-
|
||||
* effect logging lives on the detail page's `<SideEffectsSection>`. When
|
||||
* the slot is left undefined the two card variants stay byte-symmetric in
|
||||
* the header (one kebab, identical items).
|
||||
* Side-effect logging lives on the detail page's `<SideEffectsSection>`, so
|
||||
* the two card variants carry the same menu (one kebab, identical items).
|
||||
*
|
||||
* The trigger is a 44px ghost icon-button (WCAG 2.5.5); every
|
||||
* `DropdownMenuItem` already carries `min-h-11` from the shadcn
|
||||
@@ -33,14 +28,13 @@
|
||||
*/
|
||||
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { History, MoreVertical, Pencil, Stethoscope } from "lucide-react";
|
||||
import { History, MoreVertical, Pencil } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -48,25 +42,19 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
export interface MedicationCardMenuProps {
|
||||
onEdit: () => void;
|
||||
onOpenHistory: () => void;
|
||||
/** GLP-1-only: folds the side-effect quick-log into the same menu. */
|
||||
onLogSideEffect?: () => void;
|
||||
}
|
||||
|
||||
export function MedicationCardMenu({
|
||||
onEdit,
|
||||
onOpenHistory,
|
||||
onLogSideEffect,
|
||||
}: MedicationCardMenuProps) {
|
||||
const { t } = useTranslations();
|
||||
// v1.36.x — noting a side effect is an admitted delegated write; editing the
|
||||
// medication and opening its history editor are not. When nothing in the
|
||||
// menu survives, the trigger goes with it rather than opening onto an empty
|
||||
// sheet. The card itself is untouched: no wash, no badge, no colour change.
|
||||
const { canWriteDomain, canManageDomain } = useRecordCapabilities();
|
||||
const canAddSideEffect = canWriteDomain("medications");
|
||||
const canManageMedications = canManageDomain("medications");
|
||||
const showSideEffect = onLogSideEffect != null && canAddSideEffect;
|
||||
if (!canManageMedications && !showSideEffect) return null;
|
||||
// Editing the medication and opening its history editor are owner work, not
|
||||
// delegated writes. Without either, the trigger goes too rather than opening
|
||||
// onto an empty menu. The card itself is untouched: no wash, no badge, no
|
||||
// colour change.
|
||||
const { canManageDomain } = useRecordCapabilities();
|
||||
if (!canManageDomain("medications")) return null;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
@@ -89,27 +77,14 @@ export function MedicationCardMenu({
|
||||
className="w-56"
|
||||
data-slot="medication-card-menu"
|
||||
>
|
||||
{canManageMedications && (
|
||||
<>
|
||||
<DropdownMenuItem onClick={onEdit}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
{t("common.edit")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onOpenHistory}>
|
||||
<History className="mr-2 h-4 w-4" />
|
||||
{t("medications.detail.header.historyLabel")}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{showSideEffect && onLogSideEffect && (
|
||||
<>
|
||||
{canManageMedications && <DropdownMenuSeparator />}
|
||||
<DropdownMenuItem onClick={onLogSideEffect}>
|
||||
<Stethoscope className="mr-2 h-4 w-4" />
|
||||
{t("medications.glp1LogSideEffect")}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem onClick={onEdit}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
{t("common.edit")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onOpenHistory}>
|
||||
<History className="mr-2 h-4 w-4" />
|
||||
{t("medications.detail.header.historyLabel")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
@@ -147,7 +147,7 @@ interface MedicationCardProps {
|
||||
onEdit: (med: Medication) => void;
|
||||
/**
|
||||
* v1.15.18 — navigates to the medication detail page's Verlauf tab
|
||||
* (`/medications/{id}?tab=verlauf`). The parent owns the navigation.
|
||||
* (`/medications/{id}?tab=history`). The parent owns the navigation.
|
||||
*/
|
||||
onOpenHistory: (med: Medication) => void;
|
||||
/**
|
||||
|
||||
@@ -111,7 +111,7 @@ describe("<DashboardLayoutSection> — tile + chart split", () => {
|
||||
it("renders both column headers in German", () => {
|
||||
const html = render(<DashboardLayoutSection id="dashboard-layout" />, "de");
|
||||
expect(html).toContain("Kachel");
|
||||
expect(html).toContain("Chart");
|
||||
expect(html).toContain("Diagramm");
|
||||
});
|
||||
|
||||
it("paints both switch slots per widget", () => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/settings/gesundheitsakte",
|
||||
usePathname: () => "/settings/health-record",
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
@@ -43,19 +43,19 @@ describe("<GesundheitsakteSection> — SSR smoke", () => {
|
||||
// v1.18.6 — the visible heading (with the historic id) comes from
|
||||
// the shared frame the route wraps the section in.
|
||||
const html = render(
|
||||
<SettingsSectionFrame slug="gesundheitsakte">
|
||||
<SettingsSectionFrame slug="health-record">
|
||||
<GesundheitsakteSection />
|
||||
</SettingsSectionFrame>,
|
||||
);
|
||||
expect(html).toContain('id="settings-section-gesundheitsakte-title"');
|
||||
expect(html).toContain('id="settings-section-health-record-title"');
|
||||
expect(html).toContain('data-testid="health-record-export-panel"');
|
||||
// Raw key never leaks past i18n.
|
||||
expect(html).not.toContain("settings.sections.gesundheitsakte.");
|
||||
expect(html).not.toContain("settings.sections.health-record.");
|
||||
});
|
||||
|
||||
it("German locale resolves without leaking the raw key", () => {
|
||||
const html = render(<GesundheitsakteSection />, "de");
|
||||
expect(html).toContain('data-testid="health-record-export-panel"');
|
||||
expect(html).not.toContain("settings.sections.gesundheitsakte.");
|
||||
expect(html).not.toContain("settings.sections.health-record.");
|
||||
});
|
||||
});
|
||||
@@ -134,6 +134,19 @@ describe("Settings — GlucoseUnitSelect", () => {
|
||||
expect(componentSource).toMatch(/queryKey: queryKeys\.authMe\(\)/);
|
||||
});
|
||||
|
||||
// The targets panel takes its unit from the targets payload, not from the
|
||||
// account, so a save that only refreshed `/me` left it on the old unit
|
||||
// until the server cache turned over (up to ten minutes). Both ends have
|
||||
// to let go of the old payload.
|
||||
it("refreshes the targets payload on both ends", () => {
|
||||
expect(componentSource).toMatch(/queryKey: queryKeys\.insightsTargets\(\)/);
|
||||
const route = readFileSync(
|
||||
join(process.cwd(), "src/app/api/auth/me/glucose-unit/route.ts"),
|
||||
"utf8",
|
||||
);
|
||||
expect(route).toMatch(/!== next\) \{\s*invalidateUserProfile\(user\.id\);/);
|
||||
});
|
||||
|
||||
it("is actually mounted on the settings page", () => {
|
||||
// A control nobody can reach is the same defect as no control.
|
||||
const accountSection = readFileSync(
|
||||
|
||||
@@ -38,7 +38,7 @@ describe("layout-groups registry", () => {
|
||||
"labs",
|
||||
"illness",
|
||||
"documents",
|
||||
"vorsorge",
|
||||
"checkups",
|
||||
]);
|
||||
// generateStaticParams() shape.
|
||||
expect(LAYOUT_GROUP_IDS.map((module) => ({ module }))).toEqual([
|
||||
@@ -48,13 +48,13 @@ describe("layout-groups registry", () => {
|
||||
{ module: "labs" },
|
||||
{ module: "illness" },
|
||||
{ module: "documents" },
|
||||
{ module: "vorsorge" },
|
||||
{ module: "checkups" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("guards unknown segments", () => {
|
||||
expect(isLayoutGroupId("medications")).toBe(true);
|
||||
expect(isLayoutGroupId("vorsorge")).toBe(true);
|
||||
expect(isLayoutGroupId("checkups")).toBe(true);
|
||||
expect(isLayoutGroupId("nope")).toBe(false);
|
||||
expect(isLayoutGroupId("")).toBe(false);
|
||||
});
|
||||
@@ -68,10 +68,10 @@ describe("layout-groups registry", () => {
|
||||
expect(gate("mood")).toBe("not-a-group");
|
||||
expect(gate("labs")).toBe("labs");
|
||||
expect(gate("illness")).toBe("illness");
|
||||
// dashboard / insights / vorsorge are never gated.
|
||||
// dashboard / insights / checkups are never gated.
|
||||
expect(gate("dashboard")).toBeUndefined();
|
||||
expect(gate("insights")).toBeUndefined();
|
||||
expect(gate("vorsorge")).toBeUndefined();
|
||||
expect(gate("checkups")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("maps every id to a distinct section body and the canonical i18n keys", () => {
|
||||
|
||||
@@ -56,7 +56,7 @@ describe("<LayoutSection> hub", () => {
|
||||
"medications",
|
||||
"labs",
|
||||
"illness",
|
||||
"vorsorge",
|
||||
"checkups",
|
||||
]) {
|
||||
expect(html).toContain(`href="/settings/layout/${id}"`);
|
||||
}
|
||||
@@ -66,12 +66,12 @@ describe("<LayoutSection> hub", () => {
|
||||
const html = render({ medications: false, labs: false });
|
||||
expect(html).not.toContain('href="/settings/layout/medications"');
|
||||
expect(html).not.toContain('href="/settings/layout/labs"');
|
||||
// Other modules unaffected; Vorsorge is never gated.
|
||||
// Other modules unaffected; checkups are never gated.
|
||||
expect(html).toContain('href="/settings/layout/illness"');
|
||||
expect(html).toContain('href="/settings/layout/vorsorge"');
|
||||
expect(html).toContain('href="/settings/layout/checkups"');
|
||||
});
|
||||
|
||||
it("always lists dashboard, insights, and vorsorge (no module gate)", () => {
|
||||
it("always lists dashboard, insights, and checkups (no module gate)", () => {
|
||||
const html = render({
|
||||
medications: false,
|
||||
mood: false,
|
||||
@@ -80,7 +80,7 @@ describe("<LayoutSection> hub", () => {
|
||||
});
|
||||
expect(html).toContain('href="/settings/layout/dashboard"');
|
||||
expect(html).toContain('href="/settings/layout/insights"');
|
||||
expect(html).toContain('href="/settings/layout/vorsorge"');
|
||||
expect(html).toContain('href="/settings/layout/checkups"');
|
||||
// v1.44 — mood is tag management and left Arrangement for Modules.
|
||||
expect(html).not.toContain('href="/settings/layout/mood"');
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@ const LAYOUT_CHILD_SLUGS = new Set([
|
||||
"mood",
|
||||
"labs",
|
||||
"illness",
|
||||
"vorsorge",
|
||||
"checkups",
|
||||
]);
|
||||
|
||||
export function SettingsSectionFrame({
|
||||
|
||||
@@ -120,7 +120,7 @@ describe("SETTINGS_SECTION_SLUGS", () => {
|
||||
// 301-redirects to `/settings/notifications`.
|
||||
// v1.18.0 — `modules` ("Was du trackst") sits right after `account` as
|
||||
// the single front door for enabling/disabling secondary domains.
|
||||
// v1.18.0 — `gesundheitsakte` (the full health-record export) lifts
|
||||
// v1.18.0 — `health-record` (the full health-record export) lifts
|
||||
// out of Export & Import into its own top-level entry, before `export`;
|
||||
// `coach` gathers the Coach preference cards out of the AI section and
|
||||
// sits right after `ai`.
|
||||
@@ -133,7 +133,7 @@ describe("SETTINGS_SECTION_SLUGS", () => {
|
||||
// first-class sections, right after `mood` (the three were standalone
|
||||
// `ModuleSettingsFrame` pages).
|
||||
// v1.18.7 — `sharing` (clinician share links) sits directly after
|
||||
// `gesundheitsakte`, before `export`: minting a read-only link to the
|
||||
// `health-record`, before `export`: minting a read-only link to the
|
||||
// health record belongs next to the health-record export.
|
||||
expect([...SETTINGS_SECTION_SLUGS]).toEqual([
|
||||
"account",
|
||||
@@ -164,7 +164,7 @@ describe("SETTINGS_SECTION_SLUGS", () => {
|
||||
"coach",
|
||||
"api",
|
||||
"mcp",
|
||||
"gesundheitsakte",
|
||||
"health-record",
|
||||
"export",
|
||||
"advanced",
|
||||
// v1.23 — "Data & Privacy" assembles the export / deletion / retention /
|
||||
@@ -246,7 +246,7 @@ describe("<SettingsShell>", () => {
|
||||
api: "connections",
|
||||
mcp: "connections",
|
||||
export: "data",
|
||||
gesundheitsakte: "data",
|
||||
"health-record": "data",
|
||||
privacy: "data",
|
||||
advanced: "data",
|
||||
about: "system",
|
||||
@@ -397,16 +397,16 @@ describe("<SettingsShell>", () => {
|
||||
// reachable. The server-side `/api/export/health-record` route remains
|
||||
// the hard enforcement of the opt-out.
|
||||
expect(renderShell({ active: "account" })).toContain(
|
||||
'href="/settings/gesundheitsakte"',
|
||||
'href="/settings/health-record"',
|
||||
);
|
||||
expect(renderShell({ active: "account", modules: {} })).toContain(
|
||||
'href="/settings/gesundheitsakte"',
|
||||
'href="/settings/health-record"',
|
||||
);
|
||||
const optedOut = renderShell({
|
||||
active: "account",
|
||||
modules: { doctorReport: false },
|
||||
});
|
||||
expect(optedOut).toContain('href="/settings/gesundheitsakte"');
|
||||
expect(optedOut).toContain('href="/settings/health-record"');
|
||||
});
|
||||
|
||||
describe("inside somebody else's record", () => {
|
||||
@@ -507,7 +507,7 @@ describe("<SettingsShell>", () => {
|
||||
// form so we don't accidentally match a parser that double-escapes.
|
||||
expect(html).toContain("API & tokens");
|
||||
// v1.18.0 — the health record is its own top-level entry.
|
||||
expect(html).toContain('href="/settings/gesundheitsakte"');
|
||||
expect(html).toContain('href="/settings/health-record"');
|
||||
expect(html).toContain("Health record");
|
||||
// v1.4.16: the consolidated Export section is a top-level
|
||||
// entry in the sidebar; the link must be present in both locales.
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
* "Freigabe" section) folds in here as a labelled "Sharing" group below the
|
||||
* export panel: a time-boxed read-only link is a sharing face of the SAME
|
||||
* health record. `/settings/sharing` 301-redirects to
|
||||
* `/settings/gesundheitsakte#sharing`. The backing model, the
|
||||
* `/settings/health-record#sharing`. The backing model, the
|
||||
* `/api/share-links` routes, and the public `/c/[token]` view are unchanged.
|
||||
*/
|
||||
|
||||
|
||||
@@ -29,9 +29,10 @@ import { resolveGlucoseUnit, type GlucoseUnit } from "@/lib/glucose";
|
||||
* locale.
|
||||
*
|
||||
* Persistence mirrors the unit-system select: a change PATCHes
|
||||
* `/api/auth/me/glucose-unit` immediately and invalidates `queryKeys.authMe()`
|
||||
* plus `queryKeys.userGlucoseUnit()`, so the dashboard tiles, the glucose
|
||||
* insight page and the targets panel re-render on the next /me refetch.
|
||||
* `/api/auth/me/glucose-unit` immediately and invalidates `queryKeys.authMe()`,
|
||||
* so the dashboard tiles and the glucose insight page re-render on the next
|
||||
* /me refetch, and `queryKeys.insightsTargets()`, whose payload states the
|
||||
* target ranges in the unit it was built with.
|
||||
*/
|
||||
export function GlucoseUnitSelect({
|
||||
isAuthenticated,
|
||||
@@ -66,9 +67,7 @@ export function GlucoseUnitSelect({
|
||||
onSuccess: () => {
|
||||
toastWrittenOutcome("success", t("settings.dashboard.glucoseUnit.saved"));
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.userGlucoseUnit(),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.insightsTargets() });
|
||||
setOptimistic(null);
|
||||
},
|
||||
onError: (err) => {
|
||||
|
||||
@@ -99,9 +99,10 @@ export const LAYOUT_GROUPS: ReadonlyArray<LayoutGroup> = [
|
||||
Body: DocumentsSection,
|
||||
},
|
||||
{
|
||||
id: "vorsorge",
|
||||
titleKey: "settings.sections.layout.vorsorge.title",
|
||||
descriptionKey: "settings.sections.layout.vorsorge.description",
|
||||
// The address read `vorsorge` in every language; that one redirects.
|
||||
id: "checkups",
|
||||
titleKey: "settings.sections.layout.checkups.title",
|
||||
descriptionKey: "settings.sections.layout.checkups.description",
|
||||
Body: VorsorgeSection,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -41,7 +41,7 @@ export const SETTINGS_ANCHOR_FORWARDS: Readonly<
|
||||
"/settings/layout/medications": {
|
||||
"injection-sites": "/settings/modules/medications#injection-sites",
|
||||
},
|
||||
"/settings/layout/vorsorge": {
|
||||
"/settings/layout/checkups": {
|
||||
"screening-hints": "/settings/anamnesis#screening-hints",
|
||||
"vorsorge-reminders": "/settings/notifications#vorsorge-reminders",
|
||||
},
|
||||
|
||||
@@ -110,7 +110,7 @@ const LOADERS: Record<
|
||||
import("@/components/settings/api-section").then((m) => m.ApiSection),
|
||||
mcp: () =>
|
||||
import("@/components/settings/mcp-section").then((m) => m.McpSection),
|
||||
gesundheitsakte: () =>
|
||||
"health-record": () =>
|
||||
import("@/components/settings/gesundheitsakte-section").then(
|
||||
(m) => m.GesundheitsakteSection,
|
||||
),
|
||||
@@ -152,7 +152,7 @@ const SECTIONS: Record<SettingsSectionSlug, ComponentType<SectionProps>> = {
|
||||
}),
|
||||
api: dynamic(LOADERS.api, { loading: () => <SectionLoading /> }),
|
||||
mcp: dynamic(LOADERS.mcp, { loading: () => <SectionLoading /> }),
|
||||
gesundheitsakte: dynamic(LOADERS.gesundheitsakte, {
|
||||
"health-record": dynamic(LOADERS["health-record"], {
|
||||
loading: () => <SectionLoading />,
|
||||
}),
|
||||
export: dynamic(LOADERS.export, { loading: () => <SectionLoading /> }),
|
||||
|
||||
@@ -257,8 +257,8 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
|
||||
// Not module-gated: the health record is a flagship export, and its only
|
||||
// entry point must not vanish behind the `doctorReport` opt-out.
|
||||
{
|
||||
slug: "gesundheitsakte",
|
||||
titleKey: "settings.sections.gesundheitsakte.title",
|
||||
slug: "health-record",
|
||||
titleKey: "settings.sections.health-record.title",
|
||||
icon: FileHeart,
|
||||
group: "data",
|
||||
},
|
||||
|
||||
@@ -23,9 +23,8 @@ type UnitPreference = "metric" | "imperial";
|
||||
* picker rather than in a standalone card (it replaces the retired
|
||||
* `<UnitPreferenceCard>`). Persistence is unchanged: a change PATCHes
|
||||
* `/api/auth/me/unit-preference` immediately and invalidates
|
||||
* `queryKeys.authMe()` + `queryKeys.userUnitPreference()` so every chart
|
||||
* display transform (km/h vs mph, km vs mi) re-renders on the next /me
|
||||
* refetch — no manual reload, no contract change.
|
||||
* `queryKeys.authMe()`, the account payload every chart display transform
|
||||
* (km/h vs mph, km vs mi) reads, so they re-render on the next /me refetch.
|
||||
*/
|
||||
export function UnitPreferenceSelect({
|
||||
isAuthenticated,
|
||||
@@ -59,9 +58,6 @@ export function UnitPreferenceSelect({
|
||||
onSuccess: () => {
|
||||
toastWrittenOutcome("success", t("settings.dashboard.units.saved"));
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.userUnitPreference(),
|
||||
});
|
||||
setOptimistic(null);
|
||||
},
|
||||
onError: (err) => {
|
||||
|
||||
+1
-1
@@ -30,4 +30,4 @@ The server and runtime toolkit. Everything that is not a page, component, or rou
|
||||
|
||||
## Conventions
|
||||
|
||||
Kebab-case filenames. See [`../../CLAUDE.md`](../../CLAUDE.md) "Code conventions" and "Security-relevant patterns"; the critical-files map there cites the exact entry points. Most modules carry tests in a sibling `__tests__/`.
|
||||
Kebab-case filenames. Most modules carry tests in a sibling `__tests__/`.
|
||||
Vendored
+5
-4
@@ -242,10 +242,11 @@ export function invalidateUserHealthScore(userId: string): void {
|
||||
* on the very next read.
|
||||
*
|
||||
* Callers: `/api/user/thresholds` PUT + DELETE, `applyProfileUpdate`
|
||||
* (only when heightCm / dateOfBirth / gender is in the update set), and
|
||||
* `/api/auth/me/source-priority` PUT. The unit-preference toggle is NOT
|
||||
* wired in: it only drives the client-side display transform, no
|
||||
* server-cached payload reads it.
|
||||
* (only when heightCm / dateOfBirth / gender is in the update set),
|
||||
* `/api/auth/me/source-priority` PUT, and `/api/auth/me/glucose-unit` PATCH
|
||||
* (the targets payload states glucose in the account's unit). The
|
||||
* unit-preference toggle is NOT wired in: it only drives the client-side
|
||||
* display transform, no server-cached payload reads it.
|
||||
*/
|
||||
export function invalidateUserProfile(userId: string): void {
|
||||
caches.insightsTargets.deleteByPrefix(userId);
|
||||
|
||||
@@ -171,17 +171,17 @@ export const SETTINGS_CARDS: ReadonlyArray<SettingsCardEntry> = [
|
||||
{ section: "integrations", anchor: "webhook", titleKey: "settings.webhook" },
|
||||
// ── Health record ──
|
||||
{
|
||||
section: "gesundheitsakte",
|
||||
section: "health-record",
|
||||
anchor: "health-record",
|
||||
titleKey: "settings.healthRecord.title",
|
||||
},
|
||||
{
|
||||
section: "gesundheitsakte",
|
||||
section: "health-record",
|
||||
anchor: "share-link-create",
|
||||
titleKey: "settings.sharing.createTitle",
|
||||
},
|
||||
{
|
||||
section: "gesundheitsakte",
|
||||
section: "health-record",
|
||||
anchor: "share-links-active",
|
||||
titleKey: "settings.sharing.activeTitle",
|
||||
},
|
||||
@@ -459,7 +459,7 @@ export const SETTINGS_LAYOUT_PAGES: ReadonlyArray<{
|
||||
titleKey: "settings.sections.layout.documents.title",
|
||||
module: "inboundDocuments",
|
||||
},
|
||||
{ slug: "vorsorge", titleKey: "settings.sections.layout.vorsorge.title" },
|
||||
{ slug: "checkups", titleKey: "settings.sections.layout.checkups.title" },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
/**
|
||||
* The backup verdict for one schema model, read off the lists in
|
||||
* `./backup-plan.ts`. Kept beside it rather than in it so the plan stays the
|
||||
* lists and their reasons, and the one question asked of them lives here.
|
||||
*/
|
||||
import {
|
||||
AUTH_MODELS_OUT_OF_SCOPE,
|
||||
BACKED_UP_MODELS,
|
||||
DERIVED_MODELS,
|
||||
NOT_IN_BACKUP_MODELS,
|
||||
} from "./backup-plan";
|
||||
|
||||
export type BackupVerdict = "BACKED_UP" | "DERIVED" | "NOT_IN_BACKUP" | "AUTH";
|
||||
|
||||
/**
|
||||
* The verdict for a model, or `null` when the schema carries a model this file
|
||||
* has never ruled on. The guard turns that `null` into a failing test, which
|
||||
* is the whole point: a new model gets a decision, not a default.
|
||||
*/
|
||||
export function backupVerdict(model: string): BackupVerdict | null {
|
||||
if ((BACKED_UP_MODELS as readonly string[]).includes(model)) {
|
||||
return "BACKED_UP";
|
||||
}
|
||||
if (model in DERIVED_MODELS) return "DERIVED";
|
||||
if (model in NOT_IN_BACKUP_MODELS) return "NOT_IN_BACKUP";
|
||||
if (AUTH_MODELS_OUT_OF_SCOPE.includes(model)) return "AUTH";
|
||||
return null;
|
||||
}
|
||||
@@ -62,8 +62,7 @@
|
||||
* other twenty-two are admin-console routes, operator diagnostics, aggregators
|
||||
* superseded in place, and the moodLog bridge — surfaces reachable only from
|
||||
* the operator's own browser, which ships with the server and reads no
|
||||
* contract to find out what it talks to. `moodlog-removal-guard.test.ts`
|
||||
* separately holds that bridge removed. Every entry after those five was
|
||||
* contract to find out what it talks to. Every entry after those five was
|
||||
* written by the release that removed the path, which is where a tombstone
|
||||
* belongs.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { METRIC_CATALOG_ENTRIES } from "../metric-catalog";
|
||||
|
||||
/**
|
||||
* The metric catalog offers "Connect a source" for a metric without readings
|
||||
* and the metric's own entry action for one only a person can log. Ketones
|
||||
* shipped without joining the manual list, so their row sent people to the
|
||||
* integrations page, where nothing delivers ketones.
|
||||
*/
|
||||
describe("metric catalog", () => {
|
||||
it("offers ketones as a manual entry, not a source to connect", () => {
|
||||
const ketones = METRIC_CATALOG_ENTRIES.find((e) => e.id === "ketones");
|
||||
expect(ketones).toBeDefined();
|
||||
expect(ketones?.connectable).toBe(false);
|
||||
expect(ketones?.manualHref).toBe("/measurements?add=BLOOD_KETONES");
|
||||
expect(ketones?.manualCtaKey).toBe("insights.bloodKetones.emptyState.cta");
|
||||
});
|
||||
|
||||
it("gives every metric that cannot be connected a way to log it", () => {
|
||||
const manualOnly = METRIC_CATALOG_ENTRIES.filter(
|
||||
(e) => e.kind === "metric" && !e.connectable,
|
||||
);
|
||||
expect(manualOnly.length).toBeGreaterThan(0);
|
||||
for (const entry of manualOnly) {
|
||||
expect(entry.manualHref, entry.id).toBeTruthy();
|
||||
expect(entry.manualCtaKey, entry.id).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -109,12 +109,13 @@ const SOURCE_KEY_OVERRIDES: Partial<Record<SubPageSlug, string>> = {
|
||||
/**
|
||||
* Metrics with no device path at all — manual entry is the only source.
|
||||
* Mood and medications are inherently self-report (no sensor produces a
|
||||
* mood score or an adherence event). Pain, grip strength, and waist-to-
|
||||
* height ratio are ALSO manual-only despite sitting beside device-fed
|
||||
* siblings in their group: none carries an Apple Health mapping
|
||||
* mood score or an adherence event). Pain, grip strength, waist-to-height
|
||||
* ratio and blood ketones are ALSO manual-only despite sitting beside
|
||||
* device-fed siblings in their group: none carries an Apple Health mapping
|
||||
* (`apple-health-mapping.ts`) or a Withings/wearable source — pain is a
|
||||
* 0–10 self-rating, grip strength is read off a hand dynamometer, and
|
||||
* waist-to-height is logged directly (not derived like BMI). Cross-
|
||||
* 0–10 self-rating, grip strength is read off a hand dynamometer,
|
||||
* waist-to-height is logged directly (not derived like BMI), and ketones
|
||||
* come off a fingerstick meter that no integration reads. Cross-
|
||||
* checked against every routed sub-page's `emptyStateCtaType` +
|
||||
* `emptyState.description` copy so this list can't silently drift from
|
||||
* what the metric's own empty state actually offers.
|
||||
@@ -125,6 +126,7 @@ const MANUAL_ONLY_SLUGS: ReadonlySet<SubPageSlug> = new Set([
|
||||
"pain",
|
||||
"grip-strength",
|
||||
"waist-to-height",
|
||||
"ketones",
|
||||
]);
|
||||
|
||||
const MANUAL_CTA: Partial<
|
||||
@@ -147,6 +149,10 @@ const MANUAL_CTA: Partial<
|
||||
href: "/measurements?add=WAIST_TO_HEIGHT",
|
||||
ctaKey: "insights.waistToHeight.emptyState.cta",
|
||||
},
|
||||
ketones: {
|
||||
href: "/measurements?add=BLOOD_KETONES",
|
||||
ctaKey: "insights.bloodKetones.emptyState.cta",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -310,7 +310,7 @@ describe("runMedicationLowStockTick", () => {
|
||||
thresholdDays: 7,
|
||||
unitsRemaining: 5,
|
||||
// Deep link lands on the detail page's Bestand (supply) tab.
|
||||
url: "/medications/med-1?tab=bestand",
|
||||
url: "/medications/med-1?tab=supply",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
COMPREHENSIVE_WARM_CEILING_MS,
|
||||
comprehensiveWarmBudgetMs,
|
||||
} from "../model-time";
|
||||
import { QUEUE_RUNTIME } from "../queue-runtime";
|
||||
import { QUEUE_RUNTIME } from "@/__tests__/helpers/queue-runtime";
|
||||
import { COMPREHENSIVE_MODEL_CALLS } from "@/lib/ai/ai-budgets";
|
||||
import { AI_RESPONSE_TIMEOUT_MAX_SECONDS } from "@/lib/ai/effective-timeout";
|
||||
import type { GenerateOutcome } from "@/lib/insights/comprehensive-generate";
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
RESTORE_DRILL_QUEUE,
|
||||
RESTORE_DRILL_SEND_OPTIONS,
|
||||
} from "@/lib/jobs/restore-drill-queue";
|
||||
import { QUEUE_RUNTIME } from "../queue-runtime";
|
||||
import { QUEUE_RUNTIME } from "@/__tests__/helpers/queue-runtime";
|
||||
|
||||
vi.mock("../report-worker-error", () => ({
|
||||
reportWorkerError: vi.fn().mockResolvedValue(undefined),
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
/**
|
||||
* Round-trip + paging contract for the backup measurement reader.
|
||||
*
|
||||
* Runs without a database: the keyset pager takes a `fetchPage` callback,
|
||||
* so an in-memory fixture stands in for Prisma. This pins the two things
|
||||
* the heap-bounding refactor must not break:
|
||||
* 1. the keyset scan visits every row exactly once, in id order,
|
||||
* across page boundaries and the exact-multiple edge case;
|
||||
* 2. a measurement survives the full serialize → encrypt-blob shape →
|
||||
* `parseBackupPayload` → restore-mapping round-trip unchanged,
|
||||
* including a soft-deleted (tombstoned) row, which both writers keep.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
collectPagedMeasurements,
|
||||
toWeeklyBackupMeasurement,
|
||||
sortWeeklyMeasurementsDesc,
|
||||
type WeeklyMeasurementRow,
|
||||
} from "@/lib/jobs/backup/measurement-page";
|
||||
import { parseBackupPayload } from "@/lib/validations/backup";
|
||||
|
||||
/** Build an id-ascending fixture; `id` is zero-padded so string order == insert order. */
|
||||
function makeRows(n: number): WeeklyMeasurementRow[] {
|
||||
return Array.from({ length: n }, (_, i) => ({
|
||||
id: `m${String(i).padStart(4, "0")}`,
|
||||
type: "WEIGHT",
|
||||
value: 70 + i,
|
||||
unit: "kg",
|
||||
source: "MANUAL",
|
||||
measuredAt: new Date(Date.UTC(2026, 0, 1 + i, 8, 0, 0)),
|
||||
notes: i % 2 === 0 ? `note ${i}` : null,
|
||||
// notesEncrypted left null so readNote returns the plaintext fallback
|
||||
// and the pure test needs no encryption key.
|
||||
notesEncrypted: null,
|
||||
}));
|
||||
}
|
||||
|
||||
/** In-memory keyset fetcher over an id-sorted fixture. */
|
||||
function fakeFetcher(rows: WeeklyMeasurementRow[]) {
|
||||
const sorted = [...rows].sort((a, b) =>
|
||||
a.id < b.id ? -1 : a.id > b.id ? 1 : 0,
|
||||
);
|
||||
return (afterId: string | null, take: number) => {
|
||||
const start = afterId ? sorted.findIndex((r) => r.id === afterId) + 1 : 0;
|
||||
return Promise.resolve(sorted.slice(start, start + take));
|
||||
};
|
||||
}
|
||||
|
||||
describe("collectPagedMeasurements", () => {
|
||||
it("visits every row exactly once, in id order, across pages", async () => {
|
||||
const rows = makeRows(25);
|
||||
const out = await collectPagedMeasurements({
|
||||
fetchPage: fakeFetcher(rows),
|
||||
project: (r) => r.id,
|
||||
pageSize: 10,
|
||||
});
|
||||
expect(out).toEqual(rows.map((r) => r.id));
|
||||
expect(new Set(out).size).toBe(out.length);
|
||||
});
|
||||
|
||||
it("terminates on the exact-multiple boundary without dropping rows", async () => {
|
||||
const rows = makeRows(20);
|
||||
const out = await collectPagedMeasurements({
|
||||
fetchPage: fakeFetcher(rows),
|
||||
project: (r) => r.id,
|
||||
pageSize: 10,
|
||||
});
|
||||
expect(out).toHaveLength(20);
|
||||
expect(out[out.length - 1]).toBe("m0019");
|
||||
});
|
||||
|
||||
it("handles an empty set", async () => {
|
||||
const out = await collectPagedMeasurements({
|
||||
fetchPage: fakeFetcher([]),
|
||||
project: (r) => r.id,
|
||||
pageSize: 10,
|
||||
});
|
||||
expect(out).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("weekly backup measurement round-trip", () => {
|
||||
it("serialize → parseBackupPayload → restore-map equals originals", async () => {
|
||||
const rows: WeeklyMeasurementRow[] = [
|
||||
{
|
||||
id: "m0001",
|
||||
type: "WEIGHT",
|
||||
value: 82.4,
|
||||
unit: "kg",
|
||||
source: "MANUAL",
|
||||
measuredAt: new Date("2026-02-10T07:30:00.000Z"),
|
||||
notes: "morning",
|
||||
notesEncrypted: null,
|
||||
},
|
||||
{
|
||||
id: "m0002",
|
||||
type: "PULSE",
|
||||
value: 58,
|
||||
unit: "bpm",
|
||||
source: "WITHINGS",
|
||||
measuredAt: new Date("2026-02-11T06:00:00.000Z"),
|
||||
notes: null,
|
||||
notesEncrypted: null,
|
||||
},
|
||||
{
|
||||
// A tombstoned row: the weekly writer applies no deletedAt filter,
|
||||
// so a soft-deleted measurement is still backed up. The narrow
|
||||
// weekly projection does not carry deletedAt (the restore does not
|
||||
// recreate it), but the row must still appear in the payload.
|
||||
id: "m0003",
|
||||
type: "BLOOD_GLUCOSE",
|
||||
value: 95,
|
||||
unit: "mg/dL",
|
||||
source: "IMPORT",
|
||||
measuredAt: new Date("2026-02-12T12:00:00.000Z"),
|
||||
notes: "fasting",
|
||||
notesEncrypted: null,
|
||||
},
|
||||
];
|
||||
|
||||
const measurements = sortWeeklyMeasurementsDesc(
|
||||
await collectPagedMeasurements({
|
||||
fetchPage: fakeFetcher(rows),
|
||||
project: toWeeklyBackupMeasurement,
|
||||
pageSize: 2,
|
||||
}),
|
||||
);
|
||||
|
||||
// Same envelope the writer produces, then parse it back exactly as the
|
||||
// admin restore route does.
|
||||
const backupJson = JSON.stringify({
|
||||
schemaVersion: "1",
|
||||
exportedAt: new Date("2026-02-13T00:00:00.000Z").toISOString(),
|
||||
userId: "user-1",
|
||||
measurements,
|
||||
medications: [],
|
||||
intakeEvents: [],
|
||||
moodEntries: [],
|
||||
});
|
||||
const payload = parseBackupPayload(backupJson);
|
||||
|
||||
expect(payload.measurements).toHaveLength(rows.length);
|
||||
|
||||
// measuredAt-desc ordering preserved (newest first).
|
||||
expect(payload.measurements.map((m) => m.measuredAt)).toEqual([
|
||||
"2026-02-12T12:00:00.000Z",
|
||||
"2026-02-11T06:00:00.000Z",
|
||||
"2026-02-10T07:30:00.000Z",
|
||||
]);
|
||||
|
||||
// Every original row survives the round-trip by value (the tombstoned
|
||||
// m0003 included).
|
||||
for (const original of rows) {
|
||||
const restored = payload.measurements.find(
|
||||
(m) => m.measuredAt === original.measuredAt.toISOString(),
|
||||
);
|
||||
expect(restored, `row ${original.id} missing from payload`).toBeDefined();
|
||||
expect(restored!.type).toBe(original.type);
|
||||
expect(restored!.value).toBe(original.value);
|
||||
expect(restored!.unit).toBe(original.unit);
|
||||
expect(restored!.source).toBe(original.source);
|
||||
expect(restored!.notes ?? null).toBe(original.notes ?? null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("offhost backup measurement round-trip", () => {
|
||||
it("full-row JSON dump → parse preserves all columns incl. soft-deleted", async () => {
|
||||
// The off-host DR dump keeps the full row shape (identity projection).
|
||||
// Mirror the writer's select output for a live row and a tombstoned row.
|
||||
const liveRow = {
|
||||
id: "m0001",
|
||||
userId: "user-1",
|
||||
type: "WEIGHT",
|
||||
value: 80.1,
|
||||
valueMin: null,
|
||||
valueMax: null,
|
||||
unit: "kg",
|
||||
source: "MANUAL",
|
||||
measuredAt: new Date("2026-03-01T07:00:00.000Z"),
|
||||
notes: null,
|
||||
notesEncrypted: null,
|
||||
externalId: null,
|
||||
externalSourceVersion: null,
|
||||
glucoseContext: null,
|
||||
sleepStage: null,
|
||||
rhythmClassification: null,
|
||||
deviceType: "scale",
|
||||
syncVersion: 1,
|
||||
deletedAt: null,
|
||||
createdAt: new Date("2026-03-01T07:00:01.000Z"),
|
||||
updatedAt: new Date("2026-03-01T07:00:01.000Z"),
|
||||
};
|
||||
const tombstonedRow = {
|
||||
...liveRow,
|
||||
id: "m0002",
|
||||
value: 81.2,
|
||||
measuredAt: new Date("2026-03-02T07:00:00.000Z"),
|
||||
deletedAt: new Date("2026-03-03T09:00:00.000Z"),
|
||||
};
|
||||
|
||||
const measurements = await collectPagedMeasurements({
|
||||
fetchPage: fakeFetcher([
|
||||
liveRow,
|
||||
tombstonedRow,
|
||||
] as unknown as WeeklyMeasurementRow[]),
|
||||
project: (row) => row,
|
||||
pageSize: 1,
|
||||
});
|
||||
|
||||
const payload = JSON.parse(
|
||||
JSON.stringify({
|
||||
exportedAt: new Date("2026-03-04T00:00:00.000Z").toISOString(),
|
||||
userId: "user-1",
|
||||
measurements,
|
||||
medications: [],
|
||||
intakeEvents: [],
|
||||
moodEntries: [],
|
||||
cycleProfile: null,
|
||||
cycles: [],
|
||||
cycleDayLogs: [],
|
||||
}),
|
||||
) as { measurements: Array<Record<string, unknown>> };
|
||||
|
||||
expect(payload.measurements).toHaveLength(2);
|
||||
|
||||
// The soft-deleted row survives with its tombstone instant intact —
|
||||
// the DR snapshot must include it.
|
||||
const restoredTombstone = payload.measurements.find(
|
||||
(m) => m.id === "m0002",
|
||||
)!;
|
||||
expect(restoredTombstone.deletedAt).toBe("2026-03-03T09:00:00.000Z");
|
||||
|
||||
// Every scalar column the writer selected is present on the dump.
|
||||
const expectedKeys = Object.keys(liveRow).sort();
|
||||
expect(Object.keys(payload.measurements[0]).sort()).toEqual(expectedKeys);
|
||||
});
|
||||
});
|
||||
@@ -1,126 +0,0 @@
|
||||
/**
|
||||
* Keyset-paged measurement reader shared by the two backup writers.
|
||||
*
|
||||
* Both writers — the weekly on-host `DataBackup` snapshot
|
||||
* (`reminder/backup-handlers.ts`) and the nightly off-host DR dump
|
||||
* (`offhost-backup.ts`) — used to load a user's ENTIRE measurement set
|
||||
* as full ORM rows into one array and then run a single
|
||||
* `JSON.stringify` over the whole payload. On a heavy multi-year tenant
|
||||
* that holds every measurement row plus the giant serialized string in
|
||||
* memory at the same instant, which is the dominant heap spike of either
|
||||
* job and scales linearly with history depth.
|
||||
*
|
||||
* This reader walks the set in id-ascending keyset pages: at most one
|
||||
* page of rows is resident at a time, and each page is projected to its
|
||||
* compact backup shape before the next page is fetched. Transient peak
|
||||
* heap is bounded by the page size, not by the size of the user's
|
||||
* history. The accumulated output is the compact projection the payload
|
||||
* needs anyway — not the wide ORM rows.
|
||||
*
|
||||
* Keyset (cursor on `id`) rather than `skip`/`take` offset paging so the
|
||||
* scan stays O(rows) instead of O(rows^2) as the offset grows, and so a
|
||||
* concurrent insert can't shift rows across a page boundary.
|
||||
*/
|
||||
import { readNote } from "@/lib/crypto/note-cipher";
|
||||
|
||||
/**
|
||||
* Page size for the backup keyset scan. Large enough that the per-page
|
||||
* round-trip overhead stays negligible, small enough that a single page
|
||||
* of rows is a bounded slice of any tenant's history.
|
||||
*/
|
||||
export const MEASUREMENT_BACKUP_PAGE_SIZE = 2000;
|
||||
|
||||
/**
|
||||
* Walk a keyset-paged result set, projecting each row to its compact
|
||||
* backup shape as it arrives. `fetchPage` returns the next page of rows
|
||||
* after `afterId` (exclusive, id-ascending), bounded to `take` rows;
|
||||
* returning fewer than `take` rows signals the final page. The generic
|
||||
* stays free of any Prisma dependency so the paging contract is unit
|
||||
* testable without a database.
|
||||
*/
|
||||
export async function collectPagedMeasurements<
|
||||
Row extends { id: string },
|
||||
Out,
|
||||
>(args: {
|
||||
fetchPage: (afterId: string | null, take: number) => Promise<Row[]>;
|
||||
project: (row: Row) => Out;
|
||||
pageSize?: number;
|
||||
}): Promise<Out[]> {
|
||||
const { fetchPage, project } = args;
|
||||
const pageSize = args.pageSize ?? MEASUREMENT_BACKUP_PAGE_SIZE;
|
||||
const out: Out[] = [];
|
||||
let afterId: string | null = null;
|
||||
for (;;) {
|
||||
const page = await fetchPage(afterId, pageSize);
|
||||
if (page.length === 0) break;
|
||||
for (const row of page) out.push(project(row));
|
||||
if (page.length < pageSize) break;
|
||||
afterId = page[page.length - 1].id;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow row shape the weekly snapshot reads — exactly the columns the
|
||||
* admin restore path recreates a measurement from
|
||||
* (`api/admin/backups/[id]/restore`): `type`, `value`, `unit`, `source`,
|
||||
* `measuredAt`, plus the note pair so the decrypted note rides into the
|
||||
* (whole-blob-encrypted) payload. `id` rides along only as the keyset
|
||||
* cursor.
|
||||
*/
|
||||
export interface WeeklyMeasurementRow {
|
||||
id: string;
|
||||
type: string;
|
||||
value: number;
|
||||
unit: string;
|
||||
source: string;
|
||||
measuredAt: Date;
|
||||
notes: string | null;
|
||||
notesEncrypted: Uint8Array | null;
|
||||
}
|
||||
|
||||
/** Compact measurement shape the weekly `DataBackup` JSON carries. */
|
||||
export interface WeeklyBackupMeasurement {
|
||||
type: string;
|
||||
value: number;
|
||||
unit: string;
|
||||
measuredAt: string;
|
||||
source: string;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a narrow measurement row into the weekly backup shape. The
|
||||
* note is decrypted here so the restore re-encrypts on re-insert — the
|
||||
* same contract the previous inline writer used. Shared with the writer
|
||||
* so the round-trip test exercises the real projection.
|
||||
*/
|
||||
export function toWeeklyBackupMeasurement(
|
||||
row: WeeklyMeasurementRow,
|
||||
): WeeklyBackupMeasurement {
|
||||
return {
|
||||
type: row.type,
|
||||
value: row.value,
|
||||
unit: row.unit,
|
||||
measuredAt: row.measuredAt.toISOString(),
|
||||
source: row.source,
|
||||
notes: readNote(row.notesEncrypted, row.notes),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Order the collected weekly measurements newest-first to match the
|
||||
* pre-paging `orderBy: { measuredAt: "desc" }` output. The keyset scan
|
||||
* reads id-ascending for a stable cursor; this restores the historical
|
||||
* array order. Operates on the compact projections, so it does not
|
||||
* reintroduce the wide-row heap spike. ISO-8601 strings sort
|
||||
* lexicographically in chronological order, so a string compare is the
|
||||
* chronological compare.
|
||||
*/
|
||||
export function sortWeeklyMeasurementsDesc(
|
||||
measurements: WeeklyBackupMeasurement[],
|
||||
): WeeklyBackupMeasurement[] {
|
||||
return measurements.sort((a, b) =>
|
||||
a.measuredAt < b.measuredAt ? 1 : a.measuredAt > b.measuredAt ? -1 : 0,
|
||||
);
|
||||
}
|
||||
@@ -46,7 +46,7 @@
|
||||
* provider-less account costs one cheap chain-resolve and no LLM call.
|
||||
*
|
||||
* Recurring pg-boss task — never runs inside an HTTP request and never
|
||||
* shells out to `tsx` (CLAUDE.md DO-NOTs).
|
||||
* shells out to `tsx` (the production image has none).
|
||||
*/
|
||||
import { activeAccount } from "@/lib/account-deletion/active-account";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* polls the read-only GET until it lands.
|
||||
*
|
||||
* Recurring/event pg-boss task — never runs inside an HTTP request and
|
||||
* never shells out to `tsx` (CLAUDE.md DO-NOTs). Registered in the
|
||||
* never shells out to `tsx` (the production image has none). Registered in the
|
||||
* reminder-worker `allQueues` loop + a `boss.work` handler; a queue built
|
||||
* but never registered silently never drains (the v1.4.37 W10 incident),
|
||||
* so a guard test pins the registration.
|
||||
|
||||
@@ -621,7 +621,7 @@ export async function runMedicationLowStockTick(
|
||||
// the service-worker payload's click target. Lands on
|
||||
// the detail page's Bestand (supply) tab, the surface
|
||||
// the alert is about.
|
||||
url: `/medications/${med.id}?tab=bestand`,
|
||||
url: `/medications/${med.id}?tab=supply`,
|
||||
},
|
||||
});
|
||||
if (!outcome.dispatched) {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* so a provider-less / sparse account costs only the context build.
|
||||
*
|
||||
* Recurring pg-boss task — never runs inside an HTTP request, never shells out
|
||||
* to `tsx` (CLAUDE.md DO-NOTs).
|
||||
* to `tsx` (the production image has none).
|
||||
*/
|
||||
import { ACTIVE_ACCOUNT } from "@/lib/account-deletion/active-account";
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { computeHomaIr, classifyHomaIr } from "@/lib/labs/homa-ir";
|
||||
|
||||
describe("computeHomaIr", () => {
|
||||
it("computes the conventional-unit formula (glucose × insulin ÷ 405)", () => {
|
||||
// 90 mg/dL × 5 µIU/mL ÷ 405 = 1.111…
|
||||
expect(computeHomaIr(90, 5)).toBeCloseTo(1.1111, 3);
|
||||
});
|
||||
|
||||
it("matches the SI form within rounding (mmol/L × insulin ÷ 22.5)", () => {
|
||||
// 100 mg/dL = 5.556 mmol/L; SI: 5.556 × 8 ÷ 22.5 ≈ 1.975; conv: 100×8÷405 ≈ 1.975
|
||||
const conv = computeHomaIr(100, 8)!;
|
||||
const si = (5.5556 * 8) / 22.5;
|
||||
expect(conv).toBeCloseTo(si, 2);
|
||||
});
|
||||
|
||||
it("returns null for missing or non-positive inputs", () => {
|
||||
expect(computeHomaIr(null, 5)).toBeNull();
|
||||
expect(computeHomaIr(90, undefined)).toBeNull();
|
||||
expect(computeHomaIr(0, 5)).toBeNull();
|
||||
expect(computeHomaIr(90, -1)).toBeNull();
|
||||
expect(computeHomaIr(Number.NaN, 5)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyHomaIr", () => {
|
||||
it("places values in the documented bands", () => {
|
||||
expect(classifyHomaIr(0.8)).toBe("optimal");
|
||||
expect(classifyHomaIr(1.5)).toBe("intermediate");
|
||||
expect(classifyHomaIr(2.5)).toBe("elevated");
|
||||
expect(classifyHomaIr(3.5)).toBe("high");
|
||||
});
|
||||
});
|
||||
@@ -104,8 +104,7 @@ export const BIOMARKER_CATALOG: readonly BiomarkerSeed[] = [
|
||||
upperBound: 5.7,
|
||||
},
|
||||
// v1.25 — longevity panel: fasting insulin (metabolically-optimal often
|
||||
// cited < 8 µIU/mL; typical reference ~2.5–13). HOMA-IR is derived from
|
||||
// fasting glucose × insulin (see `homa-ir.ts`), not a stored catalog marker.
|
||||
// cited < 8 µIU/mL; typical reference ~2.5–13).
|
||||
{
|
||||
slug: "fasting-insulin",
|
||||
panel: "glucose",
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
/**
|
||||
* v1.25 — HOMA-IR (Homeostatic Model Assessment of Insulin Resistance).
|
||||
*
|
||||
* A computed longevity marker derived from fasting glucose + fasting insulin —
|
||||
* NOT a stored biomarker catalog entry (BMI's derived-value precedent). Server-
|
||||
* authoritative per the parity rule: compute it here, never recompute on the
|
||||
* client. Pure + side-effect-free so it is trivially testable.
|
||||
*
|
||||
* Conventional-unit formula (fasting glucose mg/dL × fasting insulin µIU/mL ÷
|
||||
* 405) and the SI form (mmol/L × µIU/mL ÷ 22.5) are equivalent
|
||||
* (mg/dL ÷ 18 = mmol/L; 405 = 22.5 × 18). Population-dependent interpretive
|
||||
* bands (optimal < 1.0, early IR > 1.9, significant IR > 2.9) are applied at the
|
||||
* display edge, never asserted here. [HOMA-IR, PMC5033570].
|
||||
*/
|
||||
|
||||
/** Interpretive band for a HOMA-IR index value (population-dependent guidance). */
|
||||
export type HomaIrBand = "optimal" | "intermediate" | "elevated" | "high";
|
||||
|
||||
/**
|
||||
* Compute HOMA-IR from fasting glucose (mg/dL) and fasting insulin (µIU/mL).
|
||||
* Returns `null` when either input is missing or non-positive (the index is
|
||||
* undefined for a zero/negative reading).
|
||||
*/
|
||||
export function computeHomaIr(
|
||||
fastingGlucoseMgDl: number | null | undefined,
|
||||
fastingInsulinUiuMl: number | null | undefined,
|
||||
): number | null {
|
||||
if (
|
||||
fastingGlucoseMgDl == null ||
|
||||
fastingInsulinUiuMl == null ||
|
||||
!Number.isFinite(fastingGlucoseMgDl) ||
|
||||
!Number.isFinite(fastingInsulinUiuMl) ||
|
||||
fastingGlucoseMgDl <= 0 ||
|
||||
fastingInsulinUiuMl <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return (fastingGlucoseMgDl * fastingInsulinUiuMl) / 405;
|
||||
}
|
||||
|
||||
/** Place a HOMA-IR value in its descriptive band (general guidance, not a diagnosis). */
|
||||
export function classifyHomaIr(value: number): HomaIrBand {
|
||||
if (value < 1.0) return "optimal";
|
||||
if (value <= 1.9) return "intermediate";
|
||||
if (value <= 2.9) return "elevated";
|
||||
return "high";
|
||||
}
|
||||
@@ -150,7 +150,7 @@ export function redactSecrets(input: string): string {
|
||||
// calendar-feed link (#1350).
|
||||
// Same boundary-aware shape as the `sk-` rule. The
|
||||
// idempotency replay-cache already rejects bodies containing
|
||||
// these prefixes (CLAUDE.md headless-client-API patterns); this
|
||||
// these prefixes; this
|
||||
// is the matching egress guard for log/error surfaces.
|
||||
.replace(/(^|[^A-Za-z0-9])hl[kpresvf]_[A-Fa-f0-9]{32,}/g, "$1[REDACTED]")
|
||||
// v1.22.0 — the MCP OAuth bridge artifacts: the authorization code
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* both directions.
|
||||
*
|
||||
* Everything is built field by field from the parsed body. Nothing is spread
|
||||
* from a request (`CLAUDE.md`, no mass assignment), and the two multi-select
|
||||
* from a request (no mass assignment), and the two multi-select
|
||||
* lists go through the vocabulary module's encoder rather than a local
|
||||
* `JSON.stringify`.
|
||||
*/
|
||||
|
||||
@@ -204,26 +204,6 @@ describe("dispatchLocalisedNotification — pass-through plumbing", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("annotates metadata with preferredChannel when channel is supplied", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
locale: "en",
|
||||
} as never);
|
||||
|
||||
await dispatchLocalisedNotification({
|
||||
userId: "u-en",
|
||||
titleKey: "notifications.admin.testTitle",
|
||||
messageKey: "notifications.admin.testBody",
|
||||
params: { name: "Test" },
|
||||
channel: "telegram",
|
||||
metadata: { source: "test" },
|
||||
});
|
||||
|
||||
expect(dispatchNotificationMock.mock.calls[0][0].metadata).toEqual({
|
||||
source: "test",
|
||||
preferredChannel: "telegram",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not crash when the locale lookup throws (logs warning, falls back to default)", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockRejectedValueOnce(
|
||||
new Error("db down"),
|
||||
|
||||
@@ -21,10 +21,8 @@
|
||||
*
|
||||
* The default `eventType` is `SYSTEM_ALERT` — the only event type that
|
||||
* predates F21 outside of the medication-reminder lane. Callers that
|
||||
* need a different event type pass it explicitly; the `channel` option
|
||||
* is reserved for a future per-channel routing pass and is currently
|
||||
* a no-op (the dispatcher fans the message across every enabled
|
||||
* channel as before).
|
||||
* need a different event type pass it explicitly. The dispatcher fans
|
||||
* the message across every enabled channel.
|
||||
*
|
||||
* v1.4.28 (R1.2 H5) — the locale lookup previously fired
|
||||
* `prisma.user.findUnique` on every call. A 30-second TTL LRU keyed on
|
||||
@@ -50,13 +48,6 @@ interface DispatchLocalisedOptions {
|
||||
titleKey: string;
|
||||
messageKey: string;
|
||||
params?: Record<string, string | number>;
|
||||
/**
|
||||
* Per-channel routing is forward-compat scaffolding. Today the
|
||||
* dispatcher fans out across every enabled channel for the user; the
|
||||
* option is accepted so call-sites can declare intent without
|
||||
* needing a second helper later.
|
||||
*/
|
||||
channel?: "telegram" | "email";
|
||||
/** Event type for preference gating. Defaults to SYSTEM_ALERT. */
|
||||
eventType?: EventType;
|
||||
/** Channel-specific extras forwarded to the dispatcher metadata. */
|
||||
@@ -162,13 +153,6 @@ export async function dispatchLocalisedNotification(
|
||||
);
|
||||
}
|
||||
|
||||
// `channel` is accepted for future per-channel routing; today it
|
||||
// is silently passed through. Suppress unused-var lint by referencing
|
||||
// it on the metadata if present.
|
||||
const metadata = opts.channel
|
||||
? { ...(opts.metadata ?? {}), preferredChannel: opts.channel }
|
||||
: opts.metadata;
|
||||
|
||||
await dispatchNotification({
|
||||
eventType: opts.eventType ?? "SYSTEM_ALERT",
|
||||
userId: opts.userId,
|
||||
@@ -177,7 +161,7 @@ export async function dispatchLocalisedNotification(
|
||||
title,
|
||||
message,
|
||||
renderForRecipient,
|
||||
metadata,
|
||||
metadata: opts.metadata,
|
||||
managedFanoutEvent: opts.managedFanoutEvent,
|
||||
urgent: opts.urgent,
|
||||
});
|
||||
|
||||
@@ -1031,7 +1031,7 @@ const IOS_METADATA_ALLOWLIST = new Set([
|
||||
"phase",
|
||||
"date",
|
||||
// v1.16.11 — MEDICATION_LOW_STOCK deep link + context: the in-app
|
||||
// path the push should open (`/medications/{id}?tab=bestand`) plus
|
||||
// path the push should open (`/medications/{id}?tab=supply`) plus
|
||||
// the runway / threshold figures the iOS handler can surface.
|
||||
"url",
|
||||
"runwayDays",
|
||||
|
||||
@@ -177,7 +177,7 @@ const ALL_STOPS: readonly TourStop[] = [
|
||||
id: "export",
|
||||
targetId: "export-hero",
|
||||
placement: "bottom",
|
||||
route: "/settings/gesundheitsakte",
|
||||
route: "/settings/health-record",
|
||||
titleKey: "onboarding.tour.steps.export.title",
|
||||
bodyKey: "onboarding.tour.steps.export.body",
|
||||
},
|
||||
|
||||
@@ -23,23 +23,6 @@ export const authKeys = {
|
||||
* settings/ai-section, targets/target-edit-sheet).
|
||||
*/
|
||||
userThresholds: () => ["user", "thresholds"] as const,
|
||||
/**
|
||||
* v1.7.0 — Settings → Display metric/imperial control reads its
|
||||
* current value from `GET /api/auth/me/unit-preference`. The PATCH
|
||||
* mutation also invalidates `authMe()` so `useAuth().unitPreference`
|
||||
* (and every chart display transform that keys off it) re-renders
|
||||
* without a manual reload.
|
||||
*/
|
||||
userUnitPreference: () => ["user", "unit-preference"] as const,
|
||||
/**
|
||||
* The mg/dL vs mmol/L glucose display control behind
|
||||
* `GET /api/auth/me/glucose-unit`. A separate axis from the
|
||||
* metric/imperial preference above — metric countries are split on
|
||||
* which one they read glucose in — so it carries its own key. The
|
||||
* PATCH also invalidates `authMe()`, since every glucose surface
|
||||
* resolves the unit from the account payload rather than from here.
|
||||
*/
|
||||
userGlucoseUnit: () => ["user", "glucose-unit"] as const,
|
||||
|
||||
passkeys: () => ["passkeys"] as const,
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ export const SETTINGS_DESTINATION_INVENTORY = {
|
||||
coach: MANAGED_GUARDIAN,
|
||||
api: PERSONAL,
|
||||
mcp: PERSONAL,
|
||||
gesundheitsakte: PERSONAL,
|
||||
"health-record": PERSONAL,
|
||||
export: PERSONAL,
|
||||
advanced: PERSONAL,
|
||||
privacy: PERSONAL,
|
||||
|
||||
@@ -44,9 +44,10 @@
|
||||
// canonical editors; reminder TYPES now live centrally in `notifications`,
|
||||
// each row gated on its module. `/settings/reminders` 301-redirects to
|
||||
// `/settings/notifications` (next.config.ts).
|
||||
// v1.18.0 — per-submodule settings split. `gesundheitsakte` (the full
|
||||
// v1.18.0 — per-submodule settings split. `health-record` (the full
|
||||
// health-record export, always available) lifts out of Export & Import into
|
||||
// its own top-level entry.
|
||||
// its own top-level entry. Its address was the German `gesundheitsakte` in
|
||||
// every language; that one redirects (next.config.ts).
|
||||
export const SETTINGS_SECTION_SLUGS = [
|
||||
"account",
|
||||
// v1.23 — `security` is the dedicated account-security home: second-factor
|
||||
@@ -75,7 +76,7 @@ export const SETTINGS_SECTION_SLUGS = [
|
||||
// `insights`, `medications`, `mood`, `labs`, `illness`, `vorsorge`) and
|
||||
// `sharing` left this list. Their URLs are owned by permanent redirects
|
||||
// in `next.config.ts` (`/settings/<module>` → `/settings/layout/<module>`,
|
||||
// `/settings/sharing` → `/settings/gesundheitsakte#sharing`), so the
|
||||
// `/settings/sharing` → `/settings/health-record#sharing`), so the
|
||||
// dynamic route could never serve them — prerendering eight unreachable
|
||||
// pages and keeping eight dead component wirings. The redirects, the
|
||||
// section components, and the layout-hub subpages are unchanged.
|
||||
@@ -106,7 +107,7 @@ export const SETTINGS_SECTION_SLUGS = [
|
||||
// assistant (Claude.ai / ChatGPT). Sits next to `api` (both are programmatic
|
||||
// access surfaces). Not nav-gated — the card carries its own enable toggle.
|
||||
"mcp",
|
||||
"gesundheitsakte",
|
||||
"health-record",
|
||||
"export",
|
||||
"advanced",
|
||||
// v1.23 — "Data & Privacy": a single surface that assembles the already-
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { moodStabilityLabel } from "../mood-stability-label";
|
||||
|
||||
/**
|
||||
* v1.4.25 — threshold contract for the verbal MOOD_STABILITY label.
|
||||
* The card switches from raw σ to one of three buckets; the rule lives
|
||||
* here so the boundaries can be unit-tested without React.
|
||||
*/
|
||||
|
||||
describe("moodStabilityLabel", () => {
|
||||
it("returns 'stable' for σ below 1.0", () => {
|
||||
expect(moodStabilityLabel(0)).toBe("stable");
|
||||
expect(moodStabilityLabel(0.42)).toBe("stable");
|
||||
expect(moodStabilityLabel(0.999)).toBe("stable");
|
||||
});
|
||||
|
||||
it("returns 'variable' for σ in [1.0, 2.0)", () => {
|
||||
expect(moodStabilityLabel(1.0)).toBe("variable");
|
||||
expect(moodStabilityLabel(1.5)).toBe("variable");
|
||||
expect(moodStabilityLabel(1.999)).toBe("variable");
|
||||
});
|
||||
|
||||
it("returns 'highlyVariable' for σ ≥ 2.0", () => {
|
||||
expect(moodStabilityLabel(2.0)).toBe("highlyVariable");
|
||||
expect(moodStabilityLabel(3.5)).toBe("highlyVariable");
|
||||
});
|
||||
});
|
||||
@@ -1,24 +0,0 @@
|
||||
/**
|
||||
* v1.4.25 — verbal label for the MOOD_STABILITY target headline.
|
||||
*
|
||||
* The card used to render the raw σ value ("0.42 σ"), which most users
|
||||
* cannot interpret. Replace with a three-state verbal label keyed
|
||||
* against translation entries `targets.mood.stability.{stable,
|
||||
* variable,highlyVariable}`.
|
||||
*
|
||||
* Thresholds (maintainer-set, documented in the W3e implementation plan):
|
||||
* σ < 1.0 → "stable" (stabil / stable)
|
||||
* σ < 2.0 → "variable" (schwankend / variable)
|
||||
* σ ≥ 2.0 → "highly-variable" (sehr schwankend / highly variable)
|
||||
*
|
||||
* Pure helper so the threshold logic can be unit-tested in isolation
|
||||
* without React. Returns the stable English key — callers (the card,
|
||||
* the Coach prompt builder) own the locale resolution.
|
||||
*/
|
||||
export type MoodStabilityLabel = "stable" | "variable" | "highlyVariable";
|
||||
|
||||
export function moodStabilityLabel(sigma: number): MoodStabilityLabel {
|
||||
if (sigma < 1.0) return "stable";
|
||||
if (sigma < 2.0) return "variable";
|
||||
return "highlyVariable";
|
||||
}
|
||||
@@ -22,6 +22,7 @@ describe("measurementReminderTypeEnum (V3 — completeness)", () => {
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"PULSE",
|
||||
"BLOOD_GLUCOSE",
|
||||
"BLOOD_KETONES",
|
||||
"OXYGEN_SATURATION",
|
||||
"BODY_TEMPERATURE",
|
||||
// Body composition
|
||||
|
||||
@@ -45,6 +45,8 @@ export const measurementReminderTypeEnum = z
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"PULSE",
|
||||
"BLOOD_GLUCOSE",
|
||||
// Ketones come off the same fingerstick meter as glucose.
|
||||
"BLOOD_KETONES",
|
||||
"OXYGEN_SATURATION",
|
||||
"BODY_TEMPERATURE",
|
||||
// Body composition (one scale reading produces the whole family)
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
* (PHQ-9 / GAD-7; v1.27.9 adds WHO-5 + SCI). The item answers are small
|
||||
* integers whose ceiling is instrument-specific (PHQ/GAD 0–3, SCI 0–4,
|
||||
* WHO-5 0–5); the array length must match the instrument. `userId` is NEVER
|
||||
* a body field — it is narrowed from the session in the handler (CLAUDE.md
|
||||
* convention).
|
||||
* a body field — it is narrowed from the session in the handler.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
import {
|
||||
|
||||
Reference in new issue
Block a user