Merge branch 'release/v1.47.3' into release/v1.47.4

# Conflicts:
#	src/__tests__/baselines/source-file-lines.json
#	src/__tests__/baselines/source-function-lines.json
#	src/__tests__/baselines/test-as-never.json
#	src/__tests__/baselines/version-stamp-comments.json
This commit is contained in:
Marc-André Bombeck committed 2026-11-12 13:27:52 +01:00
commit 031379d966
384 files changed
+7908 -5710

No files matched your search

@@ -1,18 +1,18 @@
{
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 84,
"total": 94006,
"total": 93856,
"entries": {
"app/medications/page-client.tsx": 839,
"components/charts/mood-chart.tsx": 1166,
"components/cycle/log-day-sheet.tsx": 1639,
"components/insights/coach-panel/chat-bubble.tsx": 822,
"components/insights/coach-panel/chat-bubble.tsx": 821,
"components/insights/coach-panel/coach-conversation.tsx": 1278,
"components/insights/coach-panel/use-coach.ts": 1486,
"components/insights/derived/vitals-dashboard.tsx": 1033,
"components/insights/health-score-card.tsx": 1023,
"components/insights/insights-tab-strip.tsx": 1087,
"components/measurements/measurement-form.tsx": 812,
"components/measurements/measurement-form.tsx": 815,
"components/medications/detail/medication-detail-tabs.tsx": 1157,
"components/medications/medication-table.tsx": 853,
"components/medications/wizard/wizard-payload.ts": 1414,
@@ -25,12 +25,12 @@
"lib/ai/coach/coach-prose-grounding.ts": 1091,
"lib/ai/coach/persistence.ts": 1218,
"lib/ai/coach/results/metric-table-tool.ts": 1102,
"lib/ai/coach/snapshot.ts": 1346,
"lib/ai/coach/snapshot.ts": 1331,
"lib/ai/coach/stream-events.ts": 811,
"lib/ai/coach/types.ts": 1008,
"lib/ai/codex-client.ts": 907,
"lib/ai/prompts/insight-generator.ts": 1115,
"lib/ai/provider.ts": 1628,
"lib/ai/provider.ts": 1497,
"lib/analytics/score/reader.ts": 998,
"lib/analytics/sleep-night.ts": 1345,
"lib/analytics/summaries-slice.ts": 1178,
@@ -41,7 +41,7 @@
"lib/daily/today-overview.ts": 838,
"lib/dashboard-layout.ts": 977,
"lib/dashboard/snapshot.ts": 1550,
"lib/day/records.ts": 1174,
"lib/day/records.ts": 1172,
"lib/export/backup-plan.ts": 838,
"lib/export/coach-backup.ts": 1070,
"lib/export/profile-backup.ts": 1019,
@@ -56,7 +56,7 @@
"lib/import/health-connect/import.ts": 1689,
"lib/import/unzip-export-xml.ts": 825,
"lib/insights/comprehensive-aggregator.ts": 868,
"lib/insights/comprehensive-generate.ts": 1674,
"lib/insights/comprehensive-generate.ts": 1670,
"lib/insights/correlation-channel-series.ts": 1339,
"lib/insights/correlation-discovery.ts": 1240,
"lib/insights/correlations.ts": 1026,
@@ -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": 343,
"total": 111120,
"count": 337,
"total": 109501,
"entries": {
"app/achievements/page.tsx :: AchievementsPage": 206,
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275,
@@ -36,7 +36,7 @@
"app/api/nutrients/batch/route.ts :: postBatch": 304,
"app/api/onboarding/complete/route.ts :: completeNeedsFlow": 218,
"app/api/record-settings/[family]/route.ts :: apiHandler(arg) #2": 262,
"app/api/user/ai-provider/route.ts :: apiHandler(arg) #2": 248,
"app/api/user/ai-provider/route.ts :: apiHandler(arg) #2": 224,
"app/api/withings/callback/route.ts :: apiHandler(arg)": 247,
"app/auth/login/page.tsx :: LoginPage": 561,
"app/auth/register/page.tsx :: RegisterPage": 283,
@@ -45,28 +45,28 @@
"app/insights/catalog/page.tsx :: MetricCatalogPage": 204,
"app/insights/medications/page.tsx :: InsightsMedikamentePage": 265,
"app/insights/page-client.tsx :: InsightsPageClient": 375,
"app/labs/page.tsx :: LabsPage": 228,
"app/labs/page.tsx :: LabsPage": 216,
"app/medications/page-client.tsx :: MedicationsPageClient": 590,
"app/notifications/page.tsx :: NotificationsPage": 327,
"components/activity/activity-calendar-card.tsx :: ActivityCalendarCard": 470,
"components/admin/admin-shell.tsx :: AdminShell": 254,
"components/admin/api-token-overview-section.tsx :: ApiTokenOverviewSection": 212,
"components/admin/app-log-preview-section.tsx :: AppLogPreviewSection": 341,
"components/admin/central-codex-section.tsx :: CentralCodexSection": 241,
"components/admin/encryption-section.tsx :: EncryptionSection": 264,
"components/admin/central-codex-section.tsx :: CentralCodexSection": 228,
"components/admin/encryption-section.tsx :: EncryptionSection": 261,
"components/admin/general-settings-section.tsx :: GeneralSettingsSection": 220,
"components/admin/glitchtip-section.tsx :: GlitchtipSection": 233,
"components/admin/glitchtip-section.tsx :: GlitchtipSection": 231,
"components/admin/host-metrics-chart.tsx :: HostMetricsChart": 232,
"components/admin/invite-tokens-section.tsx :: InviteTokensSection": 525,
"components/admin/login-overview-section.tsx :: LoginOverviewSection": 503,
"components/admin/pulse-day-mean-card.tsx :: PulseDayMeanCard": 305,
"components/admin/reminders-section.tsx :: RemindersSection": 309,
"components/admin/user-management-section.tsx :: UserManagementSection": 622,
"components/admin/pulse-day-mean-card.tsx :: PulseDayMeanCard": 302,
"components/admin/reminders-section.tsx :: RemindersSection": 306,
"components/admin/user-management-section.tsx :: UserManagementSection": 630,
"components/admin/web-push-vapid-section.tsx :: WebPushVapidSection": 217,
"components/auth/claim-profile.tsx :: ClaimProfile": 429,
"components/auth/mfa-login-step.tsx :: MfaLoginStep": 215,
"components/charts/compliance-heatmap.tsx :: ComplianceHeatmap": 325,
"components/charts/health-chart.tsx :: HealthChart": 470,
"components/charts/health-chart.tsx :: HealthChart": 462,
"components/charts/medication-compliance-chart.tsx :: MedicationComplianceChart": 543,
"components/charts/mood-chart.tsx :: MoodChart": 896,
"components/charts/mood-heatmap.tsx :: MoodHeatmap": 296,
@@ -75,16 +75,16 @@
"components/command-palette/command-palette.tsx :: PaletteBody": 333,
"components/custom-metrics/custom-metric-chart.tsx :: CustomMetricChart": 228,
"components/custom-metrics/custom-metric-detail.tsx :: CustomMetricDetail": 277,
"components/custom-metrics/custom-metric-form.tsx :: CustomMetricForm": 214,
"components/custom-metrics/custom-metric-history-list.tsx :: CustomMetricHistoryList": 302,
"components/custom-metrics/custom-metric-form.tsx :: CustomMetricForm": 210,
"components/custom-metrics/custom-metric-history-list.tsx :: CustomMetricHistoryList": 298,
"components/cycle/bbt-chart.tsx :: BbtChart": 244,
"components/cycle/cycle-calendar.tsx :: CycleCalendar": 292,
"components/cycle/cycle-settings.tsx :: CycleSettings": 228,
"components/cycle/cycle-view.tsx :: CycleView": 407,
"components/cycle/cycle-view.tsx :: CycleView": 405,
"components/cycle/log-day-sheet.tsx :: LogDaySheet": 753,
"components/daily/today-hero.tsx :: TodayHero": 488,
"components/dashboard/dashboard-header.tsx :: DashboardHeader": 215,
"components/dashboard/medication-intake-quick-add.tsx :: MedicationIntakeQuickAdd": 384,
"components/dashboard/medication-intake-quick-add.tsx :: MedicationIntakeQuickAdd": 383,
"components/day/day-layer.tsx :: DayLayer": 358,
"components/day/day-view.tsx :: DayView": 309,
"components/documents/document-ai-section.tsx :: DocumentAiSection": 262,
@@ -94,17 +94,15 @@
"components/documents/document-facts-review.tsx :: DocumentFactsSection": 242,
"components/documents/document-facts-review.tsx :: FactRow": 261,
"components/documents/document-filter-bar.tsx :: DocumentFilterBar": 348,
"components/documents/document-record-links.tsx :: DocumentRecordLinks": 202,
"components/documents/document-timeline.tsx :: DocumentTimeline": 314,
"components/documents/documents-view.tsx :: DocumentsView": 303,
"components/documents/sources/document-source-picker.tsx :: DocumentSourcePicker": 563,
"components/illness/log-day-sheet.tsx :: LogDaySheet": 265,
"components/illness/new-episode-sheet.tsx :: NewEpisodeSheet": 264,
"components/insights/coach-panel/attachment-picker.tsx :: AttachmentPicker": 208,
"components/insights/coach-panel/chat-bubble.tsx :: ChatBubbleImpl": 419,
"components/insights/coach-panel/chat-bubble.tsx :: ChatBubbleImpl": 417,
"components/insights/coach-panel/coach-conversation.tsx :: CoachConversation": 1002,
"components/insights/coach-panel/coach-input.tsx :: CoachInput": 259,
"components/insights/coach-panel/coach-model-picker.tsx :: CoachModelPicker": 212,
"components/insights/coach-panel/coach-model-picker.tsx :: CoachModelPicker": 208,
"components/insights/coach-panel/conversations-panel.tsx :: ConversationsPanel": 317,
"components/insights/coach-panel/message-thread.tsx :: MessageThread": 390,
"components/insights/coach-panel/sources-rail.tsx :: SourcesRail": 203,
@@ -130,13 +128,13 @@
"components/insights/sub-page-shell.tsx :: SubPageShell": 266,
"components/insights/trends-row.tsx :: TrendsRow": 215,
"components/labs/biomarker-form.tsx :: BiomarkerForm": 229,
"components/labs/biomarker-manager.tsx :: BiomarkerManager": 242,
"components/labs/biomarker-merge-sheet.tsx :: BiomarkerMergeSheet": 239,
"components/labs/biomarker-manager.tsx :: BiomarkerManager": 240,
"components/labs/biomarker-merge-sheet.tsx :: BiomarkerMergeSheet": 238,
"components/labs/lab-biomarker-chart.tsx :: LabBiomarkerChart": 370,
"components/labs/lab-biomarker-detail.tsx :: LabBiomarkerDetail": 481,
"components/labs/lab-form.tsx :: LabForm": 473,
"components/labs/lab-history-list.tsx :: LabHistoryList": 534,
"components/labs/ocr-review-dialog.tsx :: OcrReviewDialog": 359,
"components/labs/lab-biomarker-detail.tsx :: LabBiomarkerDetail": 476,
"components/labs/lab-form.tsx :: LabForm": 474,
"components/labs/lab-history-list.tsx :: LabHistoryList": 527,
"components/labs/ocr-review-dialog.tsx :: OcrReviewDialog": 356,
"components/labs/ocr-row-editor.tsx :: OcrRowEditor": 378,
"components/layout/auth-shell.tsx :: AuthShell": 483,
"components/layout/bottom-nav.tsx :: BottomNav": 262,
@@ -146,45 +144,41 @@
"components/links/entity-link-picker.tsx :: EntityLinkPicker": 392,
"components/measurement-reminders/vorsorge-dashboard-card.tsx :: VorsorgeDashboardCard": 215,
"components/measurement-reminders/vorsorge-section.tsx :: VorsorgeSection": 352,
"components/measurements/measurement-form.tsx :: MeasurementForm": 493,
"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/dose-history-add-dialog.tsx :: LedgerAddDialog": 223,
"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/injection-site-picker.tsx :: InjectionSitePicker": 287,
"components/medications/intake-history-editable.tsx :: IntakeHistoryEditable": 224,
"components/medications/intake-history-list-v2.tsx :: IntakeHistoryListV2": 403,
"components/medications/intake-import-dialog.tsx :: IntakeImportDialog": 208,
"components/medications/intake-import-dialog.tsx :: IntakeImportDialog": 201,
"components/medications/log-intake-dialog.tsx :: LogIntakeDialog": 267,
"components/medications/medication-card.tsx :: MedicationCard": 413,
"components/medications/medication-table.tsx :: MedicationTableRowItem": 377,
"components/medications/scheduling/natural-language-extractor.tsx :: NaturalLanguageExtractor": 214,
"components/medications/scheduling/natural-language-extractor.tsx :: NaturalLanguageExtractor": 211,
"components/medications/scheduling/schedule-history-timeline.tsx :: EraDialog": 219,
"components/medications/scheduling/schedule-history-timeline.tsx :: ScheduleHistoryTimeline": 254,
"components/medications/sections/api-tokens-row.tsx :: ApiTokensRow": 296,
"components/medications/sections/api-tokens-row.tsx :: ApiTokensRow": 294,
"components/medications/sections/composition-section.tsx :: CompositionEditor": 362,
"components/medications/sections/inventory-dialogs.tsx :: AddInventoryDialog": 238,
"components/medications/sections/inventory-section.tsx :: InventorySection": 368,
"components/medications/side-effects-section.tsx :: SideEffectsSection": 516,
"components/medications/side-effects-section.tsx :: SideEffectsSection": 518,
"components/medications/wizard/medication-wizard-dialog.tsx :: WizardDialogShell": 516,
"components/medications/wizard/steps/step3-dose.tsx :: Step3Dose": 222,
"components/medications/wizard/steps/step8-summary.tsx :: Step8Summary": 230,
"components/mental-health/mental-wellbeing.tsx :: MentalWellbeing": 202,
"components/mood/manage/tag-groups-card.tsx :: TagGroupsCard": 256,
"components/mood/manage/tag-manager-card.tsx :: TagManagerCard": 270,
"components/mood/mood-form.tsx :: MoodForm": 513,
"components/mood/manage/tag-groups-card.tsx :: TagGroupsCard": 252,
"components/mood/manage/tag-manager-card.tsx :: TagManagerCard": 261,
"components/mood/mood-form.tsx :: MoodForm": 514,
"components/mood/mood-list.tsx :: MoodList": 1109,
"components/mood/mood-tag-picker.tsx :: MoodTagPicker": 257,
"components/onboarding/confirm-screen.tsx :: ConfirmScreen": 255,
"components/onboarding/done-screen.tsx :: DoneScreen": 408,
"components/onboarding/confirm-screen.tsx :: ConfirmScreen": 251,
"components/onboarding/getting-started-checklist.tsx :: GettingStartedChecklist": 423,
"components/onboarding/tour.tsx :: OnboardingTour": 544,
"components/practitioners/practitioner-list.tsx :: PractitionerList": 230,
"components/practitioners/practitioner-list.tsx :: PractitionerList": 225,
"components/provenance/source-provenance-view.tsx :: SourceProvenanceView": 363,
"components/records/allergy-form.tsx :: AllergyForm": 225,
"components/records/allergy-form.tsx :: AllergyForm": 222,
"components/records/audit-c-panel.tsx :: AuditCPanel": 285,
"components/records/free-text-take-over.tsx :: FreeTextTakeOver": 201,
"components/records/life-circumstances-manager.tsx :: SectionBody": 472,
"components/settings/about-section.tsx :: AboutSection": 236,
"components/settings/access/grant-invite-card.tsx :: GrantInviteCard": 311,
@@ -192,16 +186,16 @@
"components/settings/access/managed-profile-edit-form.tsx :: ManagedProfileEditFields": 208,
"components/settings/access/managed-profile-guardians.tsx :: ManagedProfileGuardians": 234,
"components/settings/account-section/index.tsx :: AccountSection": 659,
"components/settings/ai/codex-provider-form.tsx :: CodexProviderForm": 280,
"components/settings/ai/fallback-chain-card.tsx :: FallbackChainCard": 228,
"components/settings/ai/runtime-actions-row.tsx :: RuntimeActionsRow": 282,
"components/settings/ai/codex-provider-form.tsx :: CodexProviderForm": 270,
"components/settings/ai/fallback-chain-card.tsx :: FallbackChainCard": 224,
"components/settings/ai/runtime-actions-row.tsx :: RuntimeActionsRow": 284,
"components/settings/api-section.tsx :: ApiTokensCard": 269,
"components/settings/coach-nudge-card.tsx :: CoachNudgeCard": 251,
"components/settings/coach-prefs-section.tsx :: CoachPrefsSection": 350,
"components/settings/environment-section.tsx :: EnvironmentSection": 666,
"components/settings/import-panel/apple-health-import-card.tsx :: AppleHealthImportCard": 298,
"components/settings/import-panel/csv-import-card.tsx :: CsvImportCard": 212,
"components/settings/import-panel/dose-history-import-card.tsx :: DoseHistoryImportCard": 225,
"components/settings/import-panel/dose-history-import-card.tsx :: DoseHistoryImportCard": 222,
"components/settings/import-panel/health-connect-import-card.tsx :: HealthConnectImportCard": 293,
"components/settings/import-panel/record-csv-import-card.tsx :: RecordCsvImportSheet": 458,
"components/settings/insights-pill-order-section.tsx :: InsightsPillOrderSection": 255,
@@ -215,8 +209,8 @@
"components/settings/mood-reminder-card.tsx :: MoodReminderCard": 234,
"components/settings/report-selection/selection-panel.tsx :: HealthRecordExportPanel": 370,
"components/settings/score-section.tsx :: ScoreSection": 383,
"components/settings/security-section/passkey-list-section.tsx :: PasskeyListSection": 390,
"components/settings/security-section/security-keys-card.tsx :: SecurityKeysCard": 288,
"components/settings/security-section/passkey-list-section.tsx :: PasskeyListSection": 395,
"components/settings/security-section/security-keys-card.tsx :: SecurityKeysCard": 286,
"components/settings/security-section/totp-card.tsx :: TotpCard": 533,
"components/settings/security-sessions-card.tsx :: SecuritySessionsCard": 245,
"components/settings/settings-shell.tsx :: SettingsShell": 254,
@@ -228,7 +222,7 @@
"components/symptoms/symptom-entry-form.tsx :: SymptomEntryForm": 317,
"components/symptoms/symptoms-section.tsx :: SymptomsSection": 205,
"components/targets/range-bar.tsx :: RangeBar": 220,
"components/targets/target-edit-sheet.tsx :: TargetEditSheetBody": 474,
"components/targets/target-edit-sheet.tsx :: TargetEditSheetBody": 473,
"components/timeline/life-event-form.tsx :: LifeEventForm": 281,
"components/timeline/timeline-chart.tsx :: ChartBody": 236,
"components/timeline/timeline-chronicle.tsx :: TimelineChronicle": 246,
@@ -237,13 +231,13 @@
"components/vaccinations/catalog-picker.tsx :: CatalogPicker": 221,
"components/vaccinations/custom-vaccine-sheet.tsx :: CustomVaccineSheet": 246,
"components/vaccinations/vaccination-form.tsx :: VaccinationForm": 218,
"components/workouts/manual-workout-form.tsx :: ManualWorkoutForm": 323,
"components/workouts/manual-workout-form.tsx :: ManualWorkoutForm": 321,
"hooks/use-sheet-drag.ts :: createSheetDrag": 312,
"lib/activity/calendar-math.ts :: buildActivityCalendar": 204,
"lib/ai/anthropic-client.ts :: generateCompletion": 294,
"lib/ai/coach/cycle-snapshot.ts :: buildCycleSnapshotBlock": 202,
"lib/ai/coach/glp1-snapshot.ts :: buildGlp1SnapshotBlock": 218,
"lib/ai/coach/snapshot.ts :: buildCoachSnapshotImpl": 880,
"lib/ai/coach/snapshot.ts :: buildCoachSnapshotImpl": 867,
"lib/ai/coach/tools/loop.ts :: runCoachToolLoop": 438,
"lib/ai/coach/turn/model.ts :: runTurnModel": 534,
"lib/ai/coach/turn/pipeline.ts :: runTurn": 274,
@@ -269,7 +263,7 @@
"lib/doctor-report-pdf/clinical-records-notes-section.ts :: buildClinicalRecordsNotesSection": 648,
"lib/doctor-report-pdf/measurements-charts-section.ts :: buildMeasurementsChartsSection": 384,
"lib/doctor-report-pdf/medication-mood-wellness-section.ts :: buildMedicationMoodWellnessSection": 509,
"lib/doctor-report/collect.ts :: collectDoctorReportData": 667,
"lib/doctor-report/collect.ts :: collectDoctorReportData": 665,
"lib/documents/fenced-chat.ts :: streamFencedReply": 309,
"lib/documents/ingest.ts :: ingestDocument": 272,
"lib/export/backup-json-scan.ts :: scanBackupJson": 225,
@@ -287,8 +281,8 @@
"lib/import/labs-csv.ts :: importLabsCsv": 305,
"lib/import/labs-csv.ts :: importLabsCsv > flush": 227,
"lib/insights/comprehensive-aggregator.ts :: buildFromRollups": 217,
"lib/insights/comprehensive-generate.ts :: generateComprehensiveInsight": 876,
"lib/insights/features.ts :: extractFeatures": 796,
"lib/insights/comprehensive-generate.ts :: generateComprehensiveInsight": 872,
"lib/insights/features.ts :: extractFeatures": 794,
"lib/insights/mood-aggregates.ts :: fetchMoodAggregates": 246,
"lib/insights/status-provider.ts :: runStatusCompletion": 215,
"lib/integrations/connection-health-load.ts :: loadConnectionFacts": 229,
@@ -307,7 +301,7 @@
"lib/jobs/measurement-reminder.ts :: runMeasurementReminderTick": 286,
"lib/jobs/medication-intake-import.ts :: processNextChunk": 220,
"lib/jobs/medication-intake-import.ts :: processNextChunk > client.$transaction(arg)": 212,
"lib/jobs/medication-low-stock.ts :: runMedicationLowStockTick": 286,
"lib/jobs/medication-low-stock.ts :: runMedicationLowStockTick": 283,
"lib/jobs/oura-backfill.ts :: runOuraBackfillForUser": 236,
"lib/jobs/reaction-line.ts :: runReactionLine": 337,
"lib/jobs/reminder/medication-reminder-check.ts :: handleReminderCheck": 592,
@@ -337,14 +331,14 @@
"lib/record-activity/activity-verb.ts :: recordActivityVerbLine": 261,
"lib/strava/sync.ts :: runStravaSync": 219,
"lib/support/report.ts :: collectSupportReportFacts": 332,
"lib/targets/build-response.ts :: buildTargetsResponse": 330,
"lib/targets/vitals-builder.ts :: buildVitalTargets": 324,
"lib/targets/build-response.ts :: buildTargetsResponse": 332,
"lib/targets/vitals-builder.ts :: buildVitalTargets": 326,
"lib/telegram-webhook-handlers.ts :: handleCallback": 590,
"lib/telegram-webhook-handlers.ts :: handleTextMessage": 337,
"lib/telegram-webhook-handlers.ts :: handleTextMessage": 322,
"lib/withings/sync-activity.ts :: syncUserActivity": 377,
"lib/withings/sync-ecg.ts :: syncUserEcg": 202,
"lib/withings/sync-sleep.ts :: syncUserSleep": 414,
"lib/withings/sync.ts :: syncUserMeasurements": 337,
"proxy.ts :: proxy": 467
"proxy.ts :: proxy": 470
}
}
+21 -21
View File
@@ -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": 522,
"total": 5466,
"total": 5448,
"entries": {
"__tests__/api/auth/login/mfa-branch.test.ts": 10,
"__tests__/api/auth/mfa/verify.test.ts": 9,
@@ -33,8 +33,8 @@
"app/api/admin/settings/module-availability/__tests__/route.test.ts": 13,
"app/api/admin/settings/web-push-vapid/generate/__tests__/route.test.ts": 4,
"app/api/admin/status/__tests__/route.test.ts": 3,
"app/api/admin/users/__tests__/route.test.ts": 3,
"app/api/admin/users/[id]/__tests__/route.test.ts": 8,
"app/api/admin/users/__tests__/route.test.ts": 3,
"app/api/ai/test/__tests__/route.test.ts": 26,
"app/api/analytics/__tests__/route.test.ts": 25,
"app/api/auth/claim/__tests__/route.test.ts": 13,
@@ -55,24 +55,24 @@
"app/api/auth/passkey/login-verify/__tests__/native-token.test.ts": 7,
"app/api/auth/passkey/login-verify/__tests__/oidc-only.test.ts": 4,
"app/api/auth/passkey/login-verify/__tests__/refusals.test.ts": 6,
"app/api/auth/passkeys/__tests__/route.test.ts": 3,
"app/api/auth/passkeys/[id]/__tests__/route.test.ts": 17,
"app/api/auth/passkeys/__tests__/route.test.ts": 3,
"app/api/auth/password/__tests__/route.test.ts": 5,
"app/api/auth/register/__tests__/route.test.ts": 25,
"app/api/biomarkers/__tests__/error-contract.test.ts": 3,
"app/api/biomarkers/[id]/__tests__/delete-record-auth.test.ts": 3,
"app/api/biomarkers/[id]/__tests__/route.test.ts": 23,
"app/api/biomarkers/__tests__/error-contract.test.ts": 3,
"app/api/coach/plans/__tests__/route.test.ts": 23,
"app/api/coach/reminders/__tests__/route.test.ts": 15,
"app/api/coach/suggested-actions/__tests__/route.test.ts": 7,
"app/api/consent/ai/__tests__/route.test.ts": 10,
"app/api/consent/ai/latest/__tests__/route.test.ts": 42,
"app/api/consent/ai/web/__tests__/route.test.ts": 6,
"app/api/custom-metrics/__tests__/route.test.ts": 14,
"app/api/custom-metrics/[id]/__tests__/route.test.ts": 23,
"app/api/custom-metrics/[id]/entries/__tests__/note-encryption.test.ts": 13,
"app/api/custom-metrics/[id]/entries/__tests__/route.test.ts": 12,
"app/api/custom-metrics/[id]/entries/__tests__/soft-delete-restore.test.ts": 6,
"app/api/custom-metrics/__tests__/route.test.ts": 14,
"app/api/cycle/__tests__/external-id-floor.test.ts": 9,
"app/api/cycle/calendar/__tests__/day-entries.test.ts": 9,
"app/api/cycle/calendar/__tests__/verdict-round-trip.test.ts": 26,
@@ -85,9 +85,8 @@
"app/api/dashboard/summary/__tests__/module-gate.test.ts": 6,
"app/api/dashboard/summary/__tests__/route.test.ts": 86,
"app/api/dashboard/widgets/__tests__/route.test.ts": 49,
"app/api/devices/__tests__/route.test.ts": 32,
"app/api/devices/[id]/__tests__/route.test.ts": 10,
"app/api/documents/inbound/__tests__/route.test.ts": 62,
"app/api/devices/__tests__/route.test.ts": 32,
"app/api/documents/inbound/[id]/__tests__/route.test.ts": 56,
"app/api/documents/inbound/[id]/chat/__tests__/route.test.ts": 42,
"app/api/documents/inbound/[id]/extract/__tests__/route.test.ts": 38,
@@ -96,6 +95,7 @@
"app/api/documents/inbound/[id]/suggest/__tests__/route.test.ts": 12,
"app/api/documents/inbound/[id]/summary/__tests__/route.test.ts": 31,
"app/api/documents/inbound/[id]/thumbnail/__tests__/route.test.ts": 5,
"app/api/documents/inbound/__tests__/route.test.ts": 62,
"app/api/documents/inbound/layout/__tests__/route.test.ts": 11,
"app/api/documents/inbound/reindex/__tests__/route.test.ts": 9,
"app/api/export/__tests__/measurement-streaming.test.ts": 9,
@@ -118,8 +118,8 @@
"app/api/illness/episodes/[id]/day-logs/__tests__/route.test.ts": 8,
"app/api/illness/episodes/[id]/restore/__tests__/route.test.ts": 9,
"app/api/import/__tests__/route.test.ts": 47,
"app/api/import/apple-health-export/__tests__/route.test.ts": 8,
"app/api/import/apple-health-export/[jobId]/status/__tests__/route.test.ts": 4,
"app/api/import/apple-health-export/__tests__/route.test.ts": 8,
"app/api/import/csv/__tests__/route.test.ts": 4,
"app/api/import/health-connect-export/__tests__/route.test.ts": 2,
"app/api/ingest/medication/__tests__/route.test.ts": 28,
@@ -134,14 +134,14 @@
"app/api/insights/correlations/__tests__/route.test.ts": 12,
"app/api/insights/derived/__tests__/route.test.ts": 12,
"app/api/insights/derived/batch/__tests__/route.test.ts": 21,
"app/api/insights/ecg/__tests__/route.test.ts": 5,
"app/api/insights/ecg/[id]/__tests__/route.test.ts": 5,
"app/api/insights/ecg/__tests__/route.test.ts": 5,
"app/api/insights/generate/__tests__/route.test.ts": 54,
"app/api/insights/layout/__tests__/route.test.ts": 23,
"app/api/insights/metric-status/__tests__/route.test.ts": 7,
"app/api/insights/mood-status/__tests__/route.test.ts": 10,
"app/api/insights/patterns/__tests__/route.test.ts": 1,
"app/api/insights/patterns/[id]/__tests__/route.test.ts": 4,
"app/api/insights/patterns/__tests__/route.test.ts": 1,
"app/api/insights/pregenerate/__tests__/route.test.ts": 4,
"app/api/insights/provider-chain/__tests__/put.test.ts": 4,
"app/api/insights/provider-chain/__tests__/route.test.ts": 13,
@@ -163,12 +163,12 @@
"app/api/mcp/oauth/__tests__/token.test.ts": 14,
"app/api/mcp/tokens/__tests__/route.test.ts": 3,
"app/api/measurement-categories/__tests__/route.test.ts": 3,
"app/api/measurements/[id]/__tests__/route.test.ts": 8,
"app/api/measurements/__tests__/arrival-wiring.test.ts": 7,
"app/api/measurements/__tests__/group-by-day.test.ts": 10,
"app/api/measurements/__tests__/put-duplicate-timestamp.test.ts": 9,
"app/api/measurements/__tests__/range-aggregation-route.test.ts": 10,
"app/api/measurements/__tests__/zod-multi-issue.test.ts": 2,
"app/api/measurements/[id]/__tests__/route.test.ts": 8,
"app/api/measurements/batch/__tests__/aggregated-hr-wire.test.ts": 9,
"app/api/measurements/batch/__tests__/duplicate-prefilter.test.ts": 1,
"app/api/measurements/batch/__tests__/hr-bucket-observability.test.ts": 3,
@@ -185,31 +185,31 @@
"app/api/measurements/series-batch/__tests__/route.test.ts": 10,
"app/api/measurements/series/__tests__/route.test.ts": 96,
"app/api/measurements/series/__tests__/unknown-query-param-open.test.ts": 6,
"app/api/medications/__tests__/route.test.ts": 43,
"app/api/medications/[id]/__tests__/route.test.ts": 52,
"app/api/medications/[id]/compliance/__tests__/route.test.ts": 18,
"app/api/medications/[id]/dose-history/__tests__/route.test.ts": 15,
"app/api/medications/[id]/glp1/__tests__/route.test.ts": 47,
"app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts": 63,
"app/api/medications/[id]/intake/__tests__/route.test.ts": 45,
"app/api/medications/[id]/intake/__tests__/source-provenance.test.ts": 12,
"app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts": 63,
"app/api/medications/[id]/intake/import/__tests__/route.test.ts": 9,
"app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts": 24,
"app/api/medications/__tests__/route.test.ts": 43,
"app/api/medications/compliance/__tests__/route.test.ts": 10,
"app/api/medications/extract/__tests__/route.test.ts": 38,
"app/api/medications/extract/__tests__/route.test.ts": 37,
"app/api/medications/intake/__tests__/route.test.ts": 59,
"app/api/medications/intake/bulk/__tests__/dose-arrival-scope.test.ts": 9,
"app/api/medications/intake/bulk/__tests__/route.test.ts": 68,
"app/api/medications/intake/dose-history-import/__tests__/route.test.ts": 7,
"app/api/medications/layout/__tests__/route.test.ts": 17,
"app/api/mental-health/assessments/__tests__/route.test.ts": 12,
"app/api/mental-health/assessments/[id]/__tests__/route.test.ts": 7,
"app/api/mental-health/assessments/__tests__/route.test.ts": 12,
"app/api/meta/capabilities/__tests__/route.test.ts": 1,
"app/api/monitoring/glitchtip/__tests__/route.test.ts": 2,
"app/api/monitoring/umami-script/__tests__/route.test.ts": 2,
"app/api/monitoring/umami/[...path]/__tests__/route.test.ts": 4,
"app/api/mood-entries/__tests__/route.test.ts": 6,
"app/api/mood-entries/[id]/__tests__/route.test.ts": 8,
"app/api/mood-entries/__tests__/route.test.ts": 6,
"app/api/mood-entries/bulk-delete/__tests__/route.test.ts": 10,
"app/api/mood-entries/bulk/__tests__/route.test.ts": 17,
"app/api/mood-entries/restore/__tests__/route.test.ts": 11,
@@ -235,14 +235,14 @@
"app/api/sleep/rhythm/__tests__/route.test.ts": 6,
"app/api/sync/changes/__tests__/module-gate.test.ts": 7,
"app/api/sync/state/__tests__/route.test.ts": 8,
"app/api/telegram/webhook/__tests__/route.test.ts": 43,
"app/api/telegram/webhook/__tests__/route.test.ts": 41,
"app/api/tokens/documents/__tests__/route.test.ts": 25,
"app/api/tokens/measurements/__tests__/route.test.ts": 25,
"app/api/tokens/workouts/__tests__/route.test.ts": 25,
"app/api/user/ai-provider/__tests__/legacy-private-host.test.ts": 2,
"app/api/user/ai-provider/__tests__/route.test.ts": 12,
"app/api/user/avatar/__tests__/route.test.ts": 13,
"app/api/user/avatar/[id]/__tests__/route.test.ts": 5,
"app/api/user/avatar/__tests__/route.test.ts": 13,
"app/api/user/profile/__tests__/route.test.ts": 29,
"app/api/user/thresholds/__tests__/route.test.ts": 7,
"app/api/whoop/callback/__tests__/route.test.ts": 2,
@@ -250,8 +250,8 @@
"app/api/withings/callback/__tests__/route.test.ts": 9,
"app/api/withings/connect/__tests__/route.test.ts": 4,
"app/api/withings/credentials/__tests__/route.test.ts": 4,
"app/api/withings/webhook/__tests__/route.test.ts": 26,
"app/api/withings/webhook/[token]/__tests__/route.test.ts": 8,
"app/api/withings/webhook/__tests__/route.test.ts": 26,
"app/api/workouts/__tests__/batch-create.test.ts": 13,
"app/api/workouts/__tests__/canonical-dedup.test.ts": 20,
"app/api/workouts/batch/__tests__/unstable-external-id.test.ts": 4,
@@ -299,7 +299,7 @@
"lib/ai/__tests__/provider-reasoning-effort.test.ts": 5,
"lib/ai/__tests__/provider-response-timeout.test.ts": 2,
"lib/ai/__tests__/provider.test.ts": 35,
"lib/ai/__tests__/resolve-chain.test.ts": 12,
"lib/ai/__tests__/resolve-chain.test.ts": 2,
"lib/ai/__tests__/server-provider-health.test.ts": 1,
"lib/ai/capabilities/__tests__/egress.test.ts": 1,
"lib/ai/capabilities/__tests__/gate.test.ts": 1,
@@ -384,7 +384,7 @@
"lib/import/__tests__/record-import-route.test.ts": 1,
"lib/insights/__tests__/background-reasoning-wire.test.ts": 2,
"lib/insights/__tests__/biomarker-status.test.ts": 23,
"lib/insights/__tests__/blood-pressure-status.test.ts": 32,
"lib/insights/__tests__/blood-pressure-status.test.ts": 27,
"lib/insights/__tests__/bmi-status.test.ts": 21,
"lib/insights/__tests__/briefing-provider-budget.test.ts": 2,
"lib/insights/__tests__/comprehensive-briefing-reroll.test.ts": 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": 2885,
"total": 9193,
"count": 2877,
"total": 9165,
"entries": {
"__tests__/account-payload-consumer-guard.test.ts": 2,
"__tests__/acting-account-boundary-guard.test.ts": 7,
@@ -174,9 +174,9 @@
"app/api/auth/refresh/route.ts": 2,
"app/api/auth/register/__tests__/route.test.ts": 5,
"app/api/auth/register/route.ts": 10,
"app/api/biomarkers/__tests__/error-contract.test.ts": 1,
"app/api/biomarkers/[id]/__tests__/route.test.ts": 2,
"app/api/biomarkers/[id]/route.ts": 6,
"app/api/biomarkers/__tests__/error-contract.test.ts": 1,
"app/api/biomarkers/route.ts": 4,
"app/api/c/[token]/unlock/__tests__/route.test.ts": 1,
"app/api/c/[token]/unlock/route.ts": 1,
@@ -186,12 +186,12 @@
"app/api/coach/about-me/questions/__tests__/route-module-gate.test.ts": 1,
"app/api/coach/about-me/questions/route.ts": 3,
"app/api/coach/about-me/route.ts": 4,
"app/api/coach/plans/__tests__/route.test.ts": 1,
"app/api/coach/plans/[id]/route.ts": 2,
"app/api/coach/plans/__tests__/route.test.ts": 1,
"app/api/coach/plans/route.ts": 1,
"app/api/coach/reminder-suggestions/route.ts": 1,
"app/api/coach/reminders/__tests__/route.test.ts": 1,
"app/api/coach/reminders/[id]/route.ts": 1,
"app/api/coach/reminders/__tests__/route.test.ts": 1,
"app/api/coach/reminders/route.ts": 1,
"app/api/coach/suggested-actions/__tests__/route.test.ts": 1,
"app/api/coach/suggested-actions/route.ts": 1,
@@ -199,9 +199,9 @@
"app/api/consent/ai/latest/__tests__/route.test.ts": 3,
"app/api/consent/ai/latest/route.ts": 3,
"app/api/consent/ai/route.ts": 3,
"app/api/custom-metrics/[id]/entries/[entryId]/route.ts": 2,
"app/api/custom-metrics/[id]/entries/__tests__/note-encryption.test.ts": 1,
"app/api/custom-metrics/[id]/entries/__tests__/soft-delete-restore.test.ts": 1,
"app/api/custom-metrics/[id]/entries/[entryId]/route.ts": 2,
"app/api/custom-metrics/[id]/entries/route.ts": 4,
"app/api/custom-metrics/[id]/route.ts": 1,
"app/api/custom-metrics/route.ts": 1,
@@ -211,8 +211,8 @@
"app/api/cycle/day-logs/[id]/route.ts": 4,
"app/api/cycle/day-logs/route.ts": 1,
"app/api/cycle/period/route.ts": 4,
"app/api/cycle/symptoms/custom/__tests__/custom-crud.test.ts": 1,
"app/api/cycle/symptoms/custom/[key]/route.ts": 1,
"app/api/cycle/symptoms/custom/__tests__/custom-crud.test.ts": 1,
"app/api/cycle/symptoms/custom/route.ts": 3,
"app/api/dashboard/chart-overlay-prefs/__tests__/route.test.ts": 1,
"app/api/dashboard/chart-overlay-prefs/route.ts": 2,
@@ -222,11 +222,10 @@
"app/api/dashboard/summary/route.ts": 5,
"app/api/dashboard/widgets/__tests__/route.test.ts": 4,
"app/api/dashboard/widgets/route.ts": 14,
"app/api/devices/__tests__/route.test.ts": 1,
"app/api/devices/[id]/__tests__/route.test.ts": 1,
"app/api/devices/[id]/route.ts": 1,
"app/api/devices/__tests__/route.test.ts": 1,
"app/api/devices/route.ts": 4,
"app/api/documents/inbound/__tests__/route.test.ts": 1,
"app/api/documents/inbound/[id]/__tests__/route.test.ts": 1,
"app/api/documents/inbound/[id]/chat/__tests__/route.test.ts": 2,
"app/api/documents/inbound/[id]/chat/route.ts": 1,
@@ -241,6 +240,7 @@
"app/api/documents/inbound/[id]/suggest/route.ts": 2,
"app/api/documents/inbound/[id]/summary/route.ts": 2,
"app/api/documents/inbound/[id]/thumbnail/route.ts": 1,
"app/api/documents/inbound/__tests__/route.test.ts": 1,
"app/api/documents/inbound/reindex/route.ts": 1,
"app/api/documents/inbound/route.ts": 2,
"app/api/documents/inbound/usage/route.ts": 1,
@@ -259,10 +259,10 @@
"app/api/export/mood/route.ts": 2,
"app/api/family-history/[id]/route.ts": 2,
"app/api/family-history/route.ts": 1,
"app/api/fhir/Patient/$everything/route.ts": 1,
"app/api/fhir/__tests__/observation-series.test.ts": 1,
"app/api/fhir/__tests__/route.test.ts": 1,
"app/api/fhir/metadata/route.ts": 1,
"app/api/fhir/Patient/$everything/route.ts": 1,
"app/api/fitbit/callback/route.ts": 1,
"app/api/fitbit/connect/route.ts": 1,
"app/api/fitbit/credentials/route.ts": 1,
@@ -290,7 +290,12 @@
"app/api/insights/bmi-status/route.ts": 2,
"app/api/insights/breathing-screening/route.ts": 2,
"app/api/insights/cards/__tests__/route.test.ts": 2,
"app/api/insights/cards/route.ts": 9,
"app/api/insights/cards/route.ts": 8,
"app/api/insights/chat/[id]/attachments/[documentId]/route.ts": 1,
"app/api/insights/chat/[id]/attachments/__tests__/route.test.ts": 1,
"app/api/insights/chat/[id]/attachments/route.ts": 1,
"app/api/insights/chat/[id]/messages/[messageId]/trail/__tests__/route.test.ts": 1,
"app/api/insights/chat/[id]/route.ts": 1,
"app/api/insights/chat/__tests__/dialog-harness.ts": 1,
"app/api/insights/chat/__tests__/golden-transcripts.test.ts": 1,
"app/api/insights/chat/__tests__/route-activity.test.ts": 1,
@@ -302,19 +307,14 @@
"app/api/insights/chat/__tests__/route-snapshot-once.test.ts": 2,
"app/api/insights/chat/__tests__/route-tool-mode.test.ts": 4,
"app/api/insights/chat/__tests__/route-workout-scope.test.ts": 1,
"app/api/insights/chat/[id]/attachments/__tests__/route.test.ts": 1,
"app/api/insights/chat/[id]/attachments/[documentId]/route.ts": 1,
"app/api/insights/chat/[id]/attachments/route.ts": 1,
"app/api/insights/chat/[id]/messages/[messageId]/trail/__tests__/route.test.ts": 1,
"app/api/insights/chat/[id]/route.ts": 1,
"app/api/insights/chat/fenced/__tests__/route.test.ts": 1,
"app/api/insights/chat/fenced/route.ts": 1,
"app/api/insights/chat/messages/[id]/feedback/route.ts": 3,
"app/api/insights/chat/route.ts": 9,
"app/api/insights/coach-read/route.ts": 2,
"app/api/insights/coach/facts/[id]/route.ts": 2,
"app/api/insights/coach/facts/__tests__/route-memory.test.ts": 1,
"app/api/insights/coach/facts/__tests__/route.test.ts": 1,
"app/api/insights/coach/facts/[id]/route.ts": 2,
"app/api/insights/coach/facts/route.ts": 1,
"app/api/insights/coach/nudge-status/route.ts": 1,
"app/api/insights/coach/seen/route.ts": 1,
@@ -325,12 +325,12 @@
"app/api/insights/derived/batch/__tests__/route.test.ts": 3,
"app/api/insights/derived/batch/route.ts": 6,
"app/api/insights/derived/route.ts": 5,
"app/api/insights/ecg/__tests__/route.test.ts": 1,
"app/api/insights/ecg/[id]/__tests__/route.test.ts": 1,
"app/api/insights/ecg/[id]/route.ts": 2,
"app/api/insights/ecg/__tests__/route.test.ts": 1,
"app/api/insights/ecg/route.ts": 1,
"app/api/insights/feedback/route.ts": 2,
"app/api/insights/generate/__tests__/route.test.ts": 12,
"app/api/insights/generate/__tests__/route.test.ts": 11,
"app/api/insights/glp1-plateau/route.ts": 2,
"app/api/insights/glp1-timeline/route.ts": 4,
"app/api/insights/health-status/route.ts": 3,
@@ -354,7 +354,7 @@
"app/api/insights/rhythm-events/route.ts": 2,
"app/api/insights/settings/route.ts": 1,
"app/api/insights/targets/__tests__/route.test.ts": 1,
"app/api/insights/targets/route.ts": 2,
"app/api/insights/targets/route.ts": 1,
"app/api/insights/weight-status/route.ts": 2,
"app/api/integrations/healthkit/__tests__/route.test.ts": 4,
"app/api/integrations/healthkit/route.ts": 3,
@@ -366,8 +366,8 @@
"app/api/internal/deploy-webhook/route.ts": 1,
"app/api/internal/web-vitals/__tests__/route.test.ts": 1,
"app/api/internal/web-vitals/route.ts": 1,
"app/api/labs/__tests__/error-contract.test.ts": 1,
"app/api/labs/[id]/route.ts": 7,
"app/api/labs/__tests__/error-contract.test.ts": 1,
"app/api/labs/ocr/capability/route.ts": 1,
"app/api/labs/ocr/commit/__tests__/route.test.ts": 2,
"app/api/labs/ocr/commit/route.ts": 4,
@@ -387,13 +387,13 @@
"app/api/measurement-reminders/[id]/skip/route.ts": 1,
"app/api/measurement-reminders/[id]/snooze/route.ts": 1,
"app/api/measurement-reminders/route.ts": 3,
"app/api/measurements/[id]/__tests__/route.test.ts": 2,
"app/api/measurements/[id]/route.ts": 16,
"app/api/measurements/__tests__/arrival-wiring.test.ts": 1,
"app/api/measurements/__tests__/group-by-day.test.ts": 8,
"app/api/measurements/__tests__/put-duplicate-timestamp.test.ts": 1,
"app/api/measurements/__tests__/range-aggregation-route.test.ts": 3,
"app/api/measurements/__tests__/zod-multi-issue.test.ts": 4,
"app/api/measurements/[id]/__tests__/route.test.ts": 2,
"app/api/measurements/[id]/route.ts": 16,
"app/api/measurements/batch/__tests__/aggregated-hr-wire.test.ts": 1,
"app/api/measurements/batch/__tests__/duplicate-prefilter.test.ts": 2,
"app/api/measurements/batch/__tests__/hr-bucket-observability.test.ts": 1,
@@ -410,7 +410,6 @@
"app/api/measurements/series-batch/route.ts": 1,
"app/api/measurements/series/__tests__/route.test.ts": 5,
"app/api/measurements/series/route.ts": 6,
"app/api/medications/__tests__/route.test.ts": 10,
"app/api/medications/[id]/__tests__/route.test.ts": 8,
"app/api/medications/[id]/api-endpoint/route.ts": 2,
"app/api/medications/[id]/cadence/route.ts": 10,
@@ -422,10 +421,10 @@
"app/api/medications/[id]/dose-history/route.ts": 14,
"app/api/medications/[id]/glp1/__tests__/route.test.ts": 3,
"app/api/medications/[id]/glp1/route.ts": 8,
"app/api/medications/[id]/intake/__tests__/route.test.ts": 11,
"app/api/medications/[id]/intake/__tests__/source-provenance.test.ts": 1,
"app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts": 9,
"app/api/medications/[id]/intake/[eventId]/route.ts": 4,
"app/api/medications/[id]/intake/__tests__/route.test.ts": 11,
"app/api/medications/[id]/intake/__tests__/source-provenance.test.ts": 1,
"app/api/medications/[id]/intake/bulk-delete/route.ts": 7,
"app/api/medications/[id]/intake/import/__tests__/route.test.ts": 2,
"app/api/medications/[id]/intake/import/route.ts": 6,
@@ -433,11 +432,12 @@
"app/api/medications/[id]/inventory/[itemId]/route.ts": 8,
"app/api/medications/[id]/inventory/route.ts": 4,
"app/api/medications/[id]/phase-config/route.ts": 4,
"app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts": 1,
"app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts": 9,
"app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts": 1,
"app/api/medications/[id]/schedule-revisions/route.ts": 3,
"app/api/medications/[id]/side-effects/[logId]/route.ts": 2,
"app/api/medications/[id]/side-effects/route.ts": 5,
"app/api/medications/__tests__/route.test.ts": 10,
"app/api/medications/categories/[key]/route.ts": 1,
"app/api/medications/categories/route.ts": 1,
"app/api/medications/compliance/__tests__/route.test.ts": 2,
@@ -454,9 +454,9 @@
"app/api/medications/route.ts": 2,
"app/api/mental-health/assessments/__tests__/route.test.ts": 2,
"app/api/mental-health/assessments/route.ts": 6,
"app/api/mood-entries/__tests__/route.test.ts": 7,
"app/api/mood-entries/[id]/__tests__/route.test.ts": 4,
"app/api/mood-entries/[id]/route.ts": 22,
"app/api/mood-entries/__tests__/route.test.ts": 7,
"app/api/mood-entries/bulk-delete/__tests__/route.test.ts": 1,
"app/api/mood-entries/bulk-delete/route.ts": 4,
"app/api/mood-entries/bulk/__tests__/route.test.ts": 5,
@@ -546,12 +546,12 @@
"app/api/withings/connect/__tests__/route.test.ts": 1,
"app/api/withings/connect/route.ts": 3,
"app/api/withings/status/route.ts": 1,
"app/api/withings/webhook/__tests__/route.test.ts": 1,
"app/api/withings/webhook/[token]/__tests__/route.test.ts": 1,
"app/api/withings/webhook/__tests__/route.test.ts": 1,
"app/api/withings/webhook/route.ts": 2,
"app/api/workouts/[id]/route.ts": 2,
"app/api/workouts/__tests__/batch-create.test.ts": 3,
"app/api/workouts/__tests__/canonical-dedup.test.ts": 3,
"app/api/workouts/[id]/route.ts": 2,
"app/api/workouts/batch/route.ts": 3,
"app/api/workouts/route.ts": 2,
"app/auth/claim/page.tsx": 1,
@@ -642,7 +642,7 @@
"app/insights/walking-heart-rate/page.tsx": 1,
"app/insights/walking-speed/page.tsx": 1,
"app/insights/walking-steadiness/page.tsx": 1,
"app/insights/weight/page.tsx": 8,
"app/insights/weight/page.tsx": 5,
"app/insights/workouts/page-client.tsx": 2,
"app/insights/wrist-temperature/page.tsx": 1,
"app/invite/[token]/__tests__/page.test.tsx": 1,
@@ -653,20 +653,20 @@
"app/loading.tsx": 2,
"app/mcp/__tests__/route.test.ts": 5,
"app/measurements/page.tsx": 5,
"app/medications/[id]/history/page.tsx": 1,
"app/medications/[id]/page.tsx": 2,
"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,
"app/medications/[id]/history/page.tsx": 1,
"app/medications/[id]/page.tsx": 2,
"app/medications/new/page.tsx": 1,
"app/medications/page-client.tsx": 50,
"app/mental-wellbeing/page.tsx": 1,
"app/mood/page-client.tsx": 2,
"app/not-found.tsx": 2,
"app/onboarding/__tests__/page.test.tsx": 1,
"app/onboarding/[step]/page.tsx": 1,
"app/onboarding/__tests__/page.test.tsx": 1,
"app/onboarding/handover/page.tsx": 1,
"app/onboarding/page.tsx": 2,
"app/privacy/__tests__/page.test.tsx": 1,
@@ -868,7 +868,6 @@
"components/insights/__tests__/metric-stat-strip.test.tsx": 3,
"components/insights/__tests__/metric-target-summary-unit-preference.test.tsx": 1,
"components/insights/__tests__/metric-target-summary.test.tsx": 8,
"components/insights/__tests__/personal-record-badge.test.tsx": 1,
"components/insights/__tests__/rhythm-events-card.test.tsx": 1,
"components/insights/__tests__/sleep-stage-stacked-bar.test.tsx": 5,
"components/insights/__tests__/sub-page-shell.test.tsx": 4,
@@ -1030,7 +1029,6 @@
"components/insights/nutrients/micronutrients-card.tsx": 3,
"components/insights/nutrients/types.ts": 4,
"components/insights/period-narrative-card.tsx": 1,
"components/insights/personal-record-badge.tsx": 2,
"components/insights/prose-blocks.tsx": 1,
"components/insights/recovery/recovery-metric-block.tsx": 2,
"components/insights/recovery/recovery-section.tsx": 3,
@@ -1070,7 +1068,7 @@
"components/labs/lab-biomarker-chart.tsx": 3,
"components/labs/lab-biomarker-detail.tsx": 13,
"components/labs/lab-form.tsx": 6,
"components/labs/lab-history-list.tsx": 4,
"components/labs/lab-history-list.tsx": 3,
"components/labs/lab-list.tsx": 5,
"components/labs/lab-reference-range-bar.tsx": 1,
"components/labs/lab-trend-card.tsx": 1,
@@ -1124,7 +1122,6 @@
"components/medications/__tests__/intake-edit-dialog-seed.test.tsx": 1,
"components/medications/__tests__/intake-history-list-v2-row-actions.test.tsx": 1,
"components/medications/__tests__/intake-history-list-v2.test.tsx": 4,
"components/medications/__tests__/intake-history-preview-bulk-toolbar.test.tsx": 2,
"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,
@@ -1160,7 +1157,6 @@
"components/medications/glp1-medication-card.tsx": 36,
"components/medications/injection-site-picker.tsx": 2,
"components/medications/intake-edit-dialog.tsx": 2,
"components/medications/intake-history-editable.tsx": 2,
"components/medications/intake-history-list-v2.tsx": 13,
"components/medications/intake-import-dialog.tsx": 1,
"components/medications/log-injection-site-dialog.tsx": 2,
@@ -1197,7 +1193,6 @@
"components/medications/sections/courses-section.tsx": 1,
"components/medications/sections/data-portability-row.tsx": 1,
"components/medications/sections/destructive-zone-section.tsx": 5,
"components/medications/sections/intake-history-preview.tsx": 3,
"components/medications/sections/intake-tracking-section.tsx": 1,
"components/medications/sections/inventory-dialogs.tsx": 3,
"components/medications/sections/inventory-section.tsx": 8,
@@ -1254,7 +1249,6 @@
"components/onboarding/__tests__/anamnesis-card.test.tsx": 1,
"components/onboarding/__tests__/confirm-failure-recovery.test.ts": 1,
"components/onboarding/__tests__/confirm-screen.test.tsx": 1,
"components/onboarding/__tests__/done-screen.test.tsx": 1,
"components/onboarding/__tests__/first-result-screen.test.tsx": 2,
"components/onboarding/__tests__/getting-started-checklist.test.tsx": 1,
"components/onboarding/__tests__/question-screen.test.tsx": 1,
@@ -1265,7 +1259,7 @@
"components/onboarding/baseline-fields.tsx": 1,
"components/onboarding/baseline-form.tsx": 3,
"components/onboarding/confirm-screen.tsx": 5,
"components/onboarding/done-screen.tsx": 2,
"components/onboarding/done-screen.tsx": 1,
"components/onboarding/first-result-screen.tsx": 3,
"components/onboarding/getting-started-checklist.tsx": 13,
"components/onboarding/onboarding-shell.tsx": 1,
@@ -1631,7 +1625,6 @@
"lib/ai/coach/eval/red-team.ts": 3,
"lib/ai/coach/eval/run-case.ts": 1,
"lib/ai/coach/eval/scenarios.ts": 10,
"lib/ai/coach/experiment-flag.ts": 1,
"lib/ai/coach/facts.ts": 6,
"lib/ai/coach/follow-ups/__tests__/continue.test.ts": 1,
"lib/ai/coach/follow-ups/__tests__/derive.test.ts": 1,
@@ -1660,7 +1653,7 @@
"lib/ai/coach/memory/trail-read.ts": 1,
"lib/ai/coach/memory/turn-writes.ts": 1,
"lib/ai/coach/persistence.ts": 37,
"lib/ai/coach/plans.ts": 5,
"lib/ai/coach/plans.ts": 4,
"lib/ai/coach/prompt-cache-key.ts": 1,
"lib/ai/coach/reference-grounding.ts": 1,
"lib/ai/coach/reminders.ts": 2,
@@ -1960,10 +1953,10 @@
"lib/doctor-report-helpers.ts": 1,
"lib/doctor-report-pdf-core.ts": 4,
"lib/doctor-report-pdf/clinical-records-notes-section.ts": 6,
"lib/doctor-report-pdf/header-profile-section.ts": 3,
"lib/doctor-report-pdf/header-profile-section.ts": 2,
"lib/doctor-report-pdf/measurements-charts-section.ts": 3,
"lib/doctor-report-pdf/medication-mood-wellness-section.ts": 7,
"lib/doctor-report-types.ts": 21,
"lib/doctor-report-types.ts": 20,
"lib/doctor-report/__tests__/leaf-gating-sweep.test.ts": 1,
"lib/doctor-report/clinical-records.ts": 2,
"lib/doctor-report/collect.ts": 4,
@@ -2202,7 +2195,7 @@
"lib/insights/exclude-filter.ts": 1,
"lib/insights/feature-blocks.ts": 5,
"lib/insights/feature-cache.ts": 2,
"lib/insights/features.ts": 34,
"lib/insights/features.ts": 33,
"lib/insights/general-status.ts": 5,
"lib/insights/glp1-plateau.ts": 2,
"lib/insights/graded-series.ts": 3,
@@ -2293,7 +2286,6 @@
"lib/jobs/coach-message-cleanup.ts": 1,
"lib/jobs/coach-nudge-ai.ts": 3,
"lib/jobs/coach-nudge.ts": 17,
"lib/jobs/coach-plan-review.ts": 1,
"lib/jobs/coach-reminder-sweep.ts": 2,
"lib/jobs/cycle-reminder.ts": 2,
"lib/jobs/data-arrival.ts": 2,
@@ -2330,7 +2322,7 @@
"lib/jobs/med-notes-encryption-backfill.ts": 2,
"lib/jobs/medication-intake-import.ts": 1,
"lib/jobs/medication-inventory-expire.ts": 1,
"lib/jobs/medication-low-stock.ts": 13,
"lib/jobs/medication-low-stock.ts": 12,
"lib/jobs/mood-reminder.ts": 4,
"lib/jobs/note-encryption-backfill.ts": 2,
"lib/jobs/oidc-handoff-cleanup.ts": 1,
@@ -2602,7 +2594,7 @@
"lib/notifications/senders/telegram.ts": 4,
"lib/notifications/senders/web-push.ts": 3,
"lib/notifications/senders/webhook.ts": 1,
"lib/notifications/types.ts": 21,
"lib/notifications/types.ts": 20,
"lib/nutrients/__tests__/catalog.test.ts": 1,
"lib/nutrients/catalog.ts": 8,
"lib/oauth/signed-state.ts": 1,
@@ -2779,7 +2771,7 @@
"lib/targets/types.ts": 2,
"lib/targets/vitals-builder.ts": 2,
"lib/telegram-cleanup.ts": 1,
"lib/telegram-webhook-handlers.ts": 17,
"lib/telegram-webhook-handlers.ts": 16,
"lib/telegram.ts": 2,
"lib/time-window-format.ts": 1,
"lib/timeline/__tests__/lane-access.test.ts": 1,
@@ -77,13 +77,13 @@ const ALLOWLIST: readonly Allowed[] = [
file: "components/insights/coach-panel/attachment-picker.tsx",
limit: 50,
reason:
"Search-backed picker: the query narrows the whole document vault server-side.",
"Search-backed picker: the query narrows the whole document vault server-side, and a cut list says so and points to the search.",
},
{
file: "components/documents/document-picker-sheet.tsx",
limit: 50,
reason:
"Search-backed picker (episode links and share links): the query narrows the whole document vault server-side.",
"Search-backed picker (episode links and share links): the query narrows the whole document vault server-side, and a cut list says so and points to the search.",
},
{
file: "hooks/use-mood-day.ts",
@@ -154,9 +154,10 @@ describe("AI consent is granted only by the person's own tap", () => {
}
}
}
// Non-zero floors: a matcher that stops matching is red, not green.
expect(callers).toBeGreaterThanOrEqual(3);
expect(sites).toBeGreaterThanOrEqual(3);
// Non-zero floors: a matcher that stops matching is red, not green. Two
// callers today: the consent request and the document-reading prompt.
expect(callers).toBeGreaterThanOrEqual(2);
expect(sites).toBeGreaterThanOrEqual(2);
});
it("keeps both grant routes refusing a request without the confirmation", () => {
@@ -49,15 +49,15 @@ const FACTORY = "lib/query-keys/index.ts";
* match — the wrapper carries the refetch internally, so they are compliant by
* construction and correctly invisible here.
*
* The cycle / encounter / vaccination / illness families joined the matcher
* alongside the original three: the encounter and illness bundles now carry a
* daily-read key directly, the vaccination bundle's reminder root feeds the
* Today rail, and cycle rides along so a future daily-read key added to its
* The cycle / encounter / vaccination / illness / workout families joined the
* matcher alongside the original three: the encounter, illness and workout
* bundles carry a daily-read key directly, the vaccination bundle's reminder
* root feeds the Today rail, and cycle rides along so a future daily-read key added to its
* bundle cannot ship an unpaired site silently (its current sites are
* allowlisted below with the reason).
*/
const INVALIDATION =
/invalidateKeys\(\s*[^,]+,\s*\[?\s*(?:\.\.\.)?\s*(?:medicationDependentKeys|measurementDependentKeys|moodDependentKeys|cycleDependentKeys|encounterDependentKeys|vaccinationDependentKeys|illnessDependentKeys)\b/g;
/invalidateKeys\(\s*[^,]+,\s*\[?\s*(?:\.\.\.)?\s*(?:medicationDependentKeys|measurementDependentKeys|moodDependentKeys|cycleDependentKeys|encounterDependentKeys|vaccinationDependentKeys|illnessDependentKeys|workoutDependentKeys)\b/g;
// Match the CALL form only (open paren), never the bare import specifier —
// otherwise the `import { refetchInactiveDailyReads }` line would inflate the
// count and let a file drop one paired call while still "passing".
@@ -234,7 +234,6 @@ describe("daily-reads refetch guard", () => {
const routedThroughHelper = [
"components/medications/take-all-due.ts",
"components/medications/intake-edit-dialog.tsx",
"components/medications/intake-history-editable.tsx",
"components/medications/intake-import-dialog.tsx",
"components/medications/sections/destructive-zone-section.tsx",
"components/medications/use-medication-intake.ts",
@@ -471,17 +471,15 @@ const REGISTRY: DestructiveEntry[] = [
destroys:
"a visit with its reason, outcome and the links it collected — and, for a booked one, the appointment reminder that would have nudged about it",
triggers: ["components/encounters/encounter-sheet.tsx"],
// The route soft-deletes and a restore route exists, but nothing in the
// product reaches it yet, so from a person's side the visit is gone. This
// becomes "restorable" the day a surface offers the undo, not before.
recovery: "tombstoned-no-restore",
// The delete toast offers the Undo that calls the restore route.
recovery: "restorable",
confirm: ["AlertDialog"],
},
{
file: "hooks/use-practitioners.ts",
destroys: "one address-book entry",
triggers: ["components/practitioners/practitioner-list.tsx"],
recovery: "tombstoned-no-restore",
recovery: "restorable",
confirm: ["AlertDialog"],
},
{
@@ -489,9 +487,8 @@ const REGISTRY: DestructiveEntry[] = [
destroys:
"one logged dose with its lot, site and the document links it collected — the booster reminder it once satisfied is deliberately not rewound",
triggers: ["components/vaccinations/vaccination-sheet.tsx"],
// The route soft-deletes and a restore route exists, but no surface offers
// the undo yet, so from a person's side the dose is gone.
recovery: "tombstoned-no-restore",
// The delete toast offers the Undo that calls the restore route.
recovery: "restorable",
confirm: ["AlertDialog"],
},
@@ -530,13 +527,6 @@ const REGISTRY: DestructiveEntry[] = [
recovery: "permanent",
confirm: ["AlertDialog"],
},
{
file: "components/medications/intake-history-editable.tsx",
destroys: "one intake event, or every selected intake event",
triggers: ["components/medications/intake-history-list-v2.tsx"],
recovery: "tombstoned-no-restore",
confirm: ["AlertDialog"],
},
{
file: "components/medications/dose-history/use-dose-history-ledger.ts",
destroys: "one intake event",
@@ -45,8 +45,9 @@ const REGISTRY = "lib/measurements/display-transform.ts";
/**
* The one module allowed to import the unrounded escape hatch. The target
* adapter needs the unrounded pound edge of a canonical guardrail so it can
* round it INWARD — rounding to the nearest tenth first gives 66.1 lb for the
* 30 kg floor, which inverts to 29.98 kg and the server rejects it.
* round it INWARD: rounding to the nearest tenth first can put the pound
* edge outside the kilogram window, and the server rejects what converts
* back from it.
*/
const UNROUNDED_IMPORTERS = ["lib/targets/target-unit-display.ts"];
-49
View File
@@ -15,7 +15,6 @@
* - insights.emptyState.* (B4 — per-metric empty states)
* - notifications.admin.* (B5 — admin notification messages)
* - notifications.user.* (B5 — user-facing Telegram test body)
* - personalRecords + workouts (BL-P4-8 — PR + Workout strings)
*/
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
@@ -46,18 +45,6 @@ for (const locale of LOCALES) {
FLAT_BY_LOCALE.set(locale, flat);
}
function keysForPrefix(prefix: string): Set<string> {
// Source of truth: EN. The locale-integrity test ensures every
// locale carries the same key shape, so EN's view is canonical.
const enFlat = FLAT_BY_LOCALE.get("en");
if (!enFlat) throw new Error("EN bundle did not load");
const set = new Set<string>();
for (const k of enFlat.keys()) {
if (k === prefix || k.startsWith(`${prefix}.`)) set.add(k);
}
return set;
}
const REQUIRED_KEY_SETS: Array<{
label: string;
required: string[];
@@ -143,39 +130,3 @@ describe("v1.4.27 B6 i18n drift-guard — required keys present in every locale"
});
}
});
describe("v1.4.27 B6 i18n drift-guard — PR + Workout strings stay in lockstep", () => {
// BL-P4-8: PR and Workout strings were the trigger for the
// drift-guard ask. Personal-record badge / tooltip live under
// `insights.personalRecord.*`. Workout strings are not yet in the
// bundles — the v1.4.23 batch ingest landed the API, the UI
// surface ships in v1.4.28 / v1.5. The drift-guard keeps the
// namespace anchor live so the workout copy work in v1.4.28 lands
// with parity from the first commit.
const prKeys = [...keysForPrefix("insights.personalRecord")];
it("insights.personalRecord namespace has at least one key (sanity)", () => {
expect(
prKeys.length,
"insights.personalRecord.* keys exist in en.json",
).toBeGreaterThan(0);
});
it.each(LOCALES)(
"messages/%s.json carries every insights.personalRecord.* key with a non-empty value",
(locale) => {
const flat = FLAT_BY_LOCALE.get(locale)!;
const missing: string[] = [];
const empty: string[] = [];
for (const key of prKeys) {
const v = flat.get(key);
if (v === undefined) missing.push(key);
else if (v.trim() === "") empty.push(key);
}
expect(
{ missing, empty },
`Locale ${locale} drifted from the personal-record key set.`,
).toEqual({ missing: [], empty: [] });
},
);
});
@@ -111,12 +111,31 @@ export function intakeFacts(source: string, fileName: string): IntakeFacts {
callsHelper: false,
};
// A create form posts through the function `useIdempotentPost()` returns
// (one key per opened form), bound to a local name.
const posters = new Set<string>();
const findPosters = (node: ts.Node): void => {
if (
ts.isVariableDeclaration(node) &&
ts.isIdentifier(node.name) &&
node.initializer &&
ts.isCallExpression(node.initializer) &&
ts.isIdentifier(node.initializer.expression) &&
(aliases.get(node.initializer.expression.text) ??
node.initializer.expression.text) === "useIdempotentPost"
) {
posters.add(node.name.text);
}
node.forEachChild(findPosters);
};
parsed.forEachChild(findPosters);
const visit = (node: ts.Node): void => {
if (ts.isCallExpression(node)) {
const callee = node.expression;
if (ts.isIdentifier(callee)) {
const name = aliases.get(callee.text) ?? callee.text;
if (name === "apiPost") {
if (name === "apiPost" || posters.has(callee.text)) {
const url = urlText(node);
if (url !== null && url.endsWith("/intake")) facts.postsIntake = true;
}
@@ -444,12 +444,10 @@ const V143_FIELDS: ResolvedFieldEntry[] = [
{
field: "GET /api/personal-records benchmark",
props: ["awards.ts#benchmark"],
readers: [
{
file: "src/components/insights/personal-record-badge.tsx",
pattern: /fresh\.benchmark/,
},
],
iosOnly: {
reason:
"the band was added for the native records screen (ios #116 item 5); no web surface reads `/api/personal-records` since the never-mounted badge that did was deleted",
},
},
{
field: "GET /api/mood/insights patterns",
@@ -59,7 +59,7 @@ function emptyReport(): DoctorReportData {
end: "2026-01-31T00:00:00.000Z",
},
patient: {
username: null,
name: null,
dateOfBirth: null,
gender: null,
heightCm: null,
@@ -156,11 +156,10 @@ const DISPOSITIONS: Record<StructuredLeafId, Disposition> = {
kind: "rendered",
data: {
patient: {
username: "shared-account",
name: "A. Patient",
dateOfBirth: "1979-04-02T00:00:00.000Z",
gender: "FEMALE",
heightCm: 171,
fullName: "A. Patient",
},
},
marker: "A. Patient",
+2 -7
View File
@@ -1553,7 +1553,7 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
},
"app/api/insights/targets/route.ts": {
domain: "record",
why: "The record's resolved targets. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.",
why: "The record's resolved targets: the bands and reference ranges the dashboard snapshot already paints on the whole-record grant, read the same way, with no provider on the path. GET only; nothing is written here.",
},
"app/api/insights/ecg/route.ts": {
domain: "record",
@@ -2099,11 +2099,6 @@ const DELEGABLE_MANAGE_ROUTES: Record<string, ManageEntry> = {
conditions: [],
why: "A score's daily course, read from the same rows the score pages and the day view read. Deterministic, no provider, nothing enqueued.",
},
"app/api/insights/targets/route.ts": {
domain: "record",
conditions: [],
why: "The record's resolved targets.",
},
"app/api/insights/ecg/route.ts": {
domain: "record",
conditions: [],
@@ -2305,7 +2300,7 @@ const ACTOR_ROUTES: Record<string, string> = {
* make, or a managed profile's guardian's, never an adult record's delegate's.
* 276 -> 278.
*/
const FROZEN_ENTRY_COUNT = 278;
const FROZEN_ENTRY_COUNT = 277;
/**
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
@@ -216,6 +216,7 @@ const PINNED_AFFORDANCES: Record<
"src/components/documents/sources/document-source-picker.tsx": {
"text-success": 1,
},
"src/components/encounters/encounter-sheet.tsx": { "toast.success": 1 },
"src/components/error-details.tsx": { CheckCircle2: 2, "text-success": 1 },
"src/components/illness/episode-menu.tsx": {
CheckCircle2: 2,
@@ -284,9 +285,6 @@ const PINNED_AFFORDANCES: Record<
"toast.success": 4,
},
"src/components/medications/intake-edit-dialog.tsx": { "toast.success": 1 },
"src/components/medications/intake-history-editable.tsx": {
"toast.success": 2,
},
"src/components/medications/intake-history-list-v2.tsx": {
"text-success": 1,
},
@@ -347,8 +345,10 @@ const PINNED_AFFORDANCES: Record<
"src/components/symptoms/symptom-entry-form.tsx": { "toast.success": 1 },
"src/components/mood/mood-list.tsx": { "toast.success": 3 },
"src/components/onboarding/done-screen.tsx": { CheckCircle2: 2 },
"src/components/practitioners/practitioner-list.tsx": { "toast.success": 1 },
"src/components/records/allergy-form.tsx": { "toast.success": 1 },
"src/components/records/allergy-manager.tsx": { "toast.success": 1 },
"src/components/vaccinations/vaccination-sheet.tsx": { "toast.success": 1 },
"src/components/records/conditions-manager.tsx": { "toast.success": 1 },
"src/components/records/family-history-form.tsx": { "toast.success": 1 },
"src/components/records/family-history-manager.tsx": { "toast.success": 1 },
+7 -1
View File
@@ -37,6 +37,8 @@ import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { generateTotpSecret, buildOtpauthUri } from "@/lib/auth/mfa/totp";
import { checkCookieEnrollmentProof } from "@/lib/auth/existing-factor-proof";
const TOTP_ALREADY_ACTIVE_CODE = "auth.mfa.already_active";
export const dynamic = "force-dynamic";
const SETUP_RATE_LIMIT = 5;
@@ -64,7 +66,11 @@ export const POST = apiHandler(async (req: Request) => {
// live secret out from under the user's authenticator.
if (user.totpConfirmedAt) {
annotate({ action: { name: "auth.mfa.totp.setup.already_active" } });
throw new HttpError(409, "A second factor is already active");
throw new HttpError(
409,
"A second factor is already active",
TOTP_ALREADY_ACTIVE_CODE,
);
}
if (auth.transport === "cookie") {
@@ -6,6 +6,7 @@ import {
import { createMfaAuthenticationOptions } from "@/lib/auth/mfa/webauthn";
import {
RECENT_PROOF_MAX_AGE_MS,
REPROOF_FAILED_CODE,
REPROOF_REQUIRED_CODE,
isSecondFactorProof,
recordReproofFailure,
@@ -29,8 +30,12 @@ import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { stepUpMintSchema } from "@/lib/validations/step-up";
// A wrong or missing proof, not an ended session: the code keeps the web from
// reading the bare 401 as "sign in again".
const proofRequired = () =>
apiError("Fresh existing-factor proof required", 401);
apiError("Fresh existing-factor proof required", 401, {
errorCode: REPROOF_FAILED_CODE,
});
export const POST = apiHandler(async (request: NextRequest) => {
const { user, session } = await requireCookieAuth();
@@ -13,6 +13,7 @@ import {
import { NextRequest } from "next/server";
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { REPROOF_FAILED_CODE } from "@/lib/auth/existing-factor-proof";
export const POST = apiHandler(async (request: NextRequest) => {
const { user, session } = await requireCookieAuth();
@@ -40,7 +41,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
(freshSession.mfaVerifiedAt &&
freshSession.mfaVerifiedAt.getTime() < Date.now() - 5 * 60 * 1000)
) {
return apiError("Fresh reauthentication proof required", 401);
return apiError("Fresh reauthentication proof required", 401, {
errorCode: REPROOF_FAILED_CODE,
});
}
const claim = await consumeRegistrationChallenge({
@@ -49,12 +52,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
sessionId: session.id,
});
if (!claim.ok) {
return apiError(
claim.reason === "wrong_context"
? "Fresh reauthentication proof required"
: "Registration challenge invalid or expired",
claim.reason === "wrong_context" ? 401 : 400,
);
return claim.reason === "wrong_context"
? apiError("Fresh reauthentication proof required", 401, {
errorCode: REPROOF_FAILED_CODE,
})
: apiError("Registration challenge invalid or expired", 400);
}
let verification;
+1
View File
@@ -126,6 +126,7 @@ export const DELETE = apiHandler(
return apiError(
"Cannot delete. At least one authentication method must remain",
400,
{ errorCode: "auth.passkey.last_method" },
);
}
+4 -1
View File
@@ -40,6 +40,7 @@ import {
rateLimitHeaders,
} from "@/lib/rate-limit";
import {
REPROOF_FAILED_CODE,
REPROOF_LIMIT,
REPROOF_WINDOW_MS,
reproofBucket,
@@ -132,7 +133,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
action: { name: "auth.stepup.mint.failed" },
meta: { method: parsed.data.method, reason: failure },
});
return apiError("Verification failed", 401);
return apiError("Verification failed", 401, {
errorCode: REPROOF_FAILED_CODE,
});
}
const { token, expiresAt } = await mintStepUpElevation({
@@ -225,6 +225,7 @@ describe("PATCH /api/custom-metrics/[id]", () => {
const res = await PATCH(patchReq({ name: "Other" }), params("cm-1"));
expect(res.status).toBe(409);
expect((await res.json()).meta.errorCode).toBe("customMetric.name_taken");
expect(prisma.customMetric.update).not.toHaveBeenCalled();
});
});
+3 -1
View File
@@ -111,7 +111,9 @@ export const PATCH = apiHandler(
select: { id: true },
});
if (clash) {
return apiError("A custom metric with this name already exists", 409);
return apiError("A custom metric with this name already exists", 409, {
errorCode: "customMetric.name_taken",
});
}
}
@@ -134,6 +134,9 @@ describe("POST /api/custom-metrics", () => {
const res = await POST(postReq({ name: "Grip strength", unit: "kg" }));
expect(res.status).toBe(409);
// The form names the clash by this code: a 409 without it can be the
// first save still running under the same key.
expect((await res.json()).meta.errorCode).toBe("customMetric.name_taken");
expect(prisma.customMetric.create).not.toHaveBeenCalled();
});
+3 -1
View File
@@ -100,7 +100,9 @@ async function postCustomMetric(request: NextRequest) {
select: { id: true, deletedAt: true },
});
if (existing && existing.deletedAt === null) {
return apiError("A custom metric with this name already exists", 409);
return apiError("A custom metric with this name already exists", 409, {
errorCode: "customMetric.name_taken",
});
}
// Field-by-field assignment — never spread `parsed.data`.
+16 -5
View File
@@ -19,7 +19,7 @@ import { prisma } from "@/lib/db";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
import { readDayMeanSeries } from "@/lib/insights/derived/baseline";
import { getBpTargets } from "@/lib/analytics/bp-targets";
import { resolveEffectiveBpTargets } from "@/lib/analytics/effective-range";
import { isBpReadingInTarget } from "@/lib/analytics/bp-in-target";
import {
generateAlerts,
@@ -72,6 +72,17 @@ interface InsightCard {
export const dynamic = "force-dynamic";
/**
* The profile fields BMI and the BP band read: the person's own BP target
* where one is set, the age default otherwise.
*/
const PROFILE_SELECT = {
heightCm: true,
dateOfBirth: true,
gender: true,
thresholdsJson: true,
} as const;
export const GET = apiHandler(async () => {
// v1.37.0 — MANAGE-level read: computed over the whole record, with no
// provider anywhere on the path.
@@ -85,7 +96,7 @@ export const GET = apiHandler(async () => {
await Promise.all([
prisma.user.findUnique({
where: { id: user.id },
select: { heightCm: true, dateOfBirth: true },
select: PROFILE_SELECT,
}),
// WEIGHT + BP are manual entries (low volume, and BP needs exact-
// timestamp pairing below for `bpPctInTarget`), so a bounded raw read
@@ -168,14 +179,14 @@ export const GET = apiHandler(async () => {
}
let bpPctInTarget: number | null = null;
const bpTargets = getBpTargets(dbUser?.dateOfBirth ?? null);
const bpTargets =
dbUser && resolveEffectiveBpTargets(dbUser, dbUser.thresholdsJson);
if (bpTargets) {
const sysData = byType("BLOOD_PRESSURE_SYS");
const diaData = byType("BLOOD_PRESSURE_DIA");
const pairs = pairByTimestamp(sysData, diaData, 5 * 60_000);
if (pairs.length > 0) {
// v1.4.16 — one-sided ceiling semantics with hypotension
// floor. See lib/analytics/bp-in-target.ts.
// One-sided ceiling with a hypotension floor (`bp-in-target.ts`).
const inTarget = pairs.filter((p) =>
isBpReadingInTarget(p.a, p.b, bpTargets),
).length;
@@ -4715,7 +4715,7 @@ exports[`coach chat golden transcripts > 15 an unknown learn link is scrubbed, a
}
`;
exports[`coach chat golden transcripts > 16a coach.provider.none via an empty chain and no legacy provider 1`] = `
exports[`coach chat golden transcripts > 16a coach.provider.none via an empty chain 1`] = `
{
"body": null,
"calls": [
@@ -4757,342 +4757,6 @@ exports[`coach chat golden transcripts > 16a coach.provider.none via an empty ch
}
`;
exports[`coach chat golden transcripts > 16b an empty chain served by the legacy provider 1`] = `
{
"body": null,
"calls": [
{
"call": "createConversation",
},
{
"call": "appendMessage",
"content": "How is my blood pressure?",
"metricSource": null,
"model": null,
"providerType": null,
"role": "user",
"tokensUsed": null,
},
{
"call": "storeDeterministicFacts",
},
{
"call": "enqueueCoachMemoryRefresh",
},
{
"call": "assertConsentForChain",
"chain": [
"admin-openai",
],
},
{
"amount": 600,
"call": "reserveBudget",
"cap": 2000000,
"dateKey": "2026-09-26",
"owner": "user",
"surface": "coach",
},
{
"call": "runStreamingRawCompletionWithFallback",
"maxTokens": 600,
"messages": [
"user:70927bca288b:747",
],
"providers": [
"admin-openai",
],
"signal": true,
"surface": "coach",
"system": "af01480eff5e:1404",
"temperature": 0.4,
"timeoutMs": 180000,
},
{
"call": "annotate",
"metaKeys": [
"deltas",
],
"name": "coach.stream.deltas",
},
{
"actual": 42,
"attribution": {
"reservedOwner": "user",
"servedBy": "local",
},
"cached": 5,
"call": "reconcileSpend",
"dateKey": "2026-09-26",
"reserved": 600,
},
{
"call": "appendMessage",
"content": "Your systolic averaged 128 mmHg over the window.",
"metricSource": {
"activity": [
{
"count": 1,
"durationMs": 0,
"id": "a1",
"label": "Reading your record summary",
"labelKey": "coach.step.snapshot",
"phase": "fetch",
"round": 1,
"status": "done",
"stepRef": "s1",
},
{
"durationMs": 0,
"id": "a2",
"label": "Thinking…",
"labelKey": "insights.coach.activity.thinking",
"phase": "thinking",
"round": 1,
"status": "done",
},
{
"id": "a3",
"label": "Writing the answer…",
"labelKey": "insights.coach.activity.answer",
"phase": "answer",
"round": 1,
"status": "done",
},
],
"groundedFigures": [
128,
82,
46,
105,
35.9375,
56.09756097560976,
],
"metrics": [
"bp",
],
"steps": [
{
"count": 1,
"domain": "snapshot",
"id": "s1",
"label": "Reading your record summary",
"labelKey": "coach.step.snapshot",
"status": "done",
"tool": "snapshot",
},
],
"windows": [
"last30days",
],
},
"model": "m-local",
"providerType": "local",
"role": "assistant",
"tokensUsed": 42,
},
{
"call": "annotate",
"metaKeys": [
"activityEntries",
"clusterCount",
"conversationId",
"historyTurns",
"promptChars",
"promptVersion",
"provider",
"snapshotSent",
"stop",
"tokens",
"toolMode",
"toolsCalled",
"trailStored",
],
"name": "insights.coach.replied",
},
],
"contentType": "text/event-stream; charset=utf-8",
"frames": [
{
"step": {
"count": 1,
"domain": "snapshot",
"id": "s1",
"label": "Reading your record summary",
"labelKey": "coach.step.snapshot",
"status": "done",
"tool": "snapshot",
},
"type": "step",
},
{
"activity": {
"count": 1,
"id": "a1",
"label": "Reading your record summary",
"labelKey": "coach.step.snapshot",
"phase": "fetch",
"round": 1,
"status": "running",
"stepRef": "s1",
},
"type": "activity",
},
{
"activity": {
"count": 1,
"durationMs": 0,
"id": "a1",
"label": "Reading your record summary",
"labelKey": "coach.step.snapshot",
"phase": "fetch",
"round": 1,
"status": "done",
"stepRef": "s1",
},
"type": "activity",
},
{
"activity": {
"id": "a2",
"label": "Thinking…",
"labelKey": "insights.coach.activity.thinking",
"phase": "thinking",
"round": 1,
"status": "running",
},
"type": "activity",
},
{
"activity": {
"durationMs": 0,
"id": "a2",
"label": "Thinking…",
"labelKey": "insights.coach.activity.thinking",
"phase": "thinking",
"round": 1,
"status": "done",
},
"type": "activity",
},
{
"activity": {
"id": "a3",
"label": "Writing the answer…",
"labelKey": "insights.coach.activity.answer",
"phase": "answer",
"round": 1,
"status": "running",
},
"type": "activity",
},
{
"token": "Your ",
"type": "token",
},
{
"token": "systolic ",
"type": "token",
},
{
"token": "averaged ",
"type": "token",
},
{
"token": "128 ",
"type": "token",
},
{
"token": "mmHg ",
"type": "token",
},
{
"token": "over ",
"type": "token",
},
{
"token": "the ",
"type": "token",
},
{
"token": "window.",
"type": "token",
},
{
"metricSource": {
"activity": [
{
"count": 1,
"durationMs": 0,
"id": "a1",
"label": "Reading your record summary",
"labelKey": "coach.step.snapshot",
"phase": "fetch",
"round": 1,
"status": "done",
"stepRef": "s1",
},
{
"durationMs": 0,
"id": "a2",
"label": "Thinking…",
"labelKey": "insights.coach.activity.thinking",
"phase": "thinking",
"round": 1,
"status": "done",
},
{
"id": "a3",
"label": "Writing the answer…",
"labelKey": "insights.coach.activity.answer",
"phase": "answer",
"round": 1,
"status": "done",
},
],
"groundedFigures": [
128,
82,
46,
105,
35.9375,
56.09756097560976,
],
"metrics": [
"bp",
],
"steps": [
{
"count": 1,
"domain": "snapshot",
"id": "s1",
"label": "Reading your record summary",
"labelKey": "coach.step.snapshot",
"status": "done",
"tool": "snapshot",
},
],
"windows": [
"last30days",
],
},
"type": "provenance",
},
{
"conversationId": "c-new",
"messageId": "m2",
"type": "done",
"usage": {
"model": "m-local",
"totalTokens": 42,
},
},
],
"status": 200,
"thrown": null,
}
`;
exports[`coach chat golden transcripts > 16c coach.provider.none from the capability gate 1`] = `
{
"body": null,
@@ -119,7 +119,6 @@ export const m = {
resolveServerLocale: vi.fn(),
runStreamingRawCompletionWithFallback: vi.fn(),
resolveProviderChain: vi.fn(),
resolveProvider: vi.fn(),
assertConsentForChain: vi.fn(),
appendMessage: vi.fn(),
createConversation: vi.fn(),
@@ -201,7 +200,6 @@ export function resetGolden(): void {
m.resolveProviderChain.mockResolvedValue([
{ providerType: "anthropic", instance: {} },
]);
m.resolveProvider.mockResolvedValue({ type: "none" });
m.assertConsentForChain.mockImplementation(
async (args: { chain: Array<{ providerType: string }> }) => {
record("assertConsentForChain", {
@@ -81,7 +81,6 @@ vi.mock("@/lib/ai/provider-runner", () => ({
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProviderChain: h.m.resolveProviderChain,
resolveProvider: h.m.resolveProvider,
}));
vi.mock("@/lib/ai/consent-guard", () => ({
assertConsentForChain: h.m.assertConsentForChain,
@@ -416,21 +415,8 @@ describe("coach chat golden transcripts", () => {
});
// 16 ── no provider ──────────────────────────────────────────────────────
it("16a coach.provider.none via an empty chain and no legacy provider", async () => {
it("16a coach.provider.none via an empty chain", async () => {
m.resolveProviderChain.mockResolvedValue([]);
m.resolveProvider.mockResolvedValue({ type: "none" });
await golden({ message: "How is my blood pressure?" });
});
it("16b an empty chain served by the legacy provider", async () => {
m.resolveProviderChain.mockResolvedValue([]);
m.resolveProvider.mockResolvedValue({
type: "openai",
supportsTools: false,
});
m.runStreamingRawCompletionWithFallback.mockImplementation(
streamingResult("Your systolic averaged 128 mmHg over the window."),
);
await golden({ message: "How is my blood pressure?" });
});
@@ -93,11 +93,7 @@ vi.mock("@/lib/ai/provider", async () => {
);
return {
...actual,
resolveProvider: vi.fn(),
// v1.4.16: the route consults the multi-provider chain
// first; the existing single-provider mocks return an empty chain
// so the route falls through to `resolveProvider()` and the legacy
// test fixtures keep behaving exactly as before.
// Empty unless a test hands the route a provider (`serveFrom`).
resolveProviderChain: vi.fn(async () => []),
};
});
@@ -167,7 +163,7 @@ import { NextRequest } from "next/server";
import { GET, POST, resolveInsightsRateLimit } from "../route";
import { enqueueStatusRefillForUser } from "@/lib/insights/comprehensive-generate";
import { resolveProvider } from "@/lib/ai/provider";
import { resolveProviderChain } from "@/lib/ai/provider";
import { checkRateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/db";
import { hasUsableStatusProvider } from "@/lib/insights/status-provider";
@@ -214,8 +210,17 @@ beforeEach(() => {
});
});
type ChainEntries = Awaited<ReturnType<typeof resolveProviderChain>>;
/** The chain the route resolves: one operator-key entry serving `instance`. */
function serveFrom(instance: unknown) {
vi.mocked(resolveProviderChain).mockResolvedValue([
{ providerType: "admin-openai", instance },
] as unknown as ChainEntries);
}
function makeWorkingProvider() {
vi.mocked(resolveProvider).mockResolvedValue({
serveFrom({
type: "openai",
generateCompletion: vi.fn(async () => ({
content: JSON.stringify({
@@ -230,7 +235,7 @@ function makeWorkingProvider() {
providerType: "openai",
model: "gpt-4",
})),
} as unknown as Awaited<ReturnType<typeof resolveProvider>>);
});
}
interface ApiErrorEnvelope {
@@ -253,13 +258,12 @@ function jsonRequest(body: unknown = {}): Request {
function makeProviderThatThrows(
err: Error & { httpStatus?: number; bodyExcerpt?: string },
) {
vi.mocked(resolveProvider).mockResolvedValue({
serveFrom({
type: "openai",
generateCompletion: vi.fn(async () => {
throw err;
}),
// The route only calls generateCompletion; pad the type for TS.
} as unknown as Awaited<ReturnType<typeof resolveProvider>>);
});
}
describe("POST /api/insights/generate — daily token ceiling", () => {
@@ -314,7 +318,8 @@ describe("POST /api/insights/generate — a provider the health ledger benched",
httpStatus: 401,
});
makeProviderThatThrows(err);
const provider = await vi.mocked(resolveProvider)("u-1");
const [{ instance: provider }] =
await vi.mocked(resolveProviderChain)("u-1");
const body = JSON.stringify({ force: true });
const request = new NextRequest("http://localhost/api/insights/generate", {
method: "POST",
@@ -894,7 +899,7 @@ describe("GET /api/insights/generate — read-only advisor read", () => {
expect(body.data.cached).toBe(true);
expect(body.data.insights).toEqual(cached);
// No completion is ever run on the read path.
expect(resolveProvider).not.toHaveBeenCalled();
expect(resolveProviderChain).not.toHaveBeenCalled();
// A fresh cache (just now) does not trigger a warm — and the payload
// says so, so the client never starts a convergence poll.
expect(enqueueForceWarm).not.toHaveBeenCalled();
@@ -919,7 +924,7 @@ describe("GET /api/insights/generate — read-only advisor read", () => {
userId: "u-1",
locale: "en",
});
expect(resolveProvider).not.toHaveBeenCalled();
expect(resolveProviderChain).not.toHaveBeenCalled();
// The stale serve is honest: `revalidating: true` rides on the
// payload so the client polls (bounded) until the warm lands.
const body = (await res.json()) as { data: { revalidating: boolean } };
@@ -953,7 +958,7 @@ describe("GET /api/insights/generate — read-only advisor read", () => {
userId: "u-1",
locale: "en",
});
expect(resolveProvider).not.toHaveBeenCalled();
expect(resolveProviderChain).not.toHaveBeenCalled();
});
it("serves a fresh cache whose tag matches the reader's language", async () => {
@@ -48,6 +48,7 @@ export const GET = apiHandler(async () => {
biomarkerId: true,
unit: true,
value: true,
sourceValue: true,
referenceLow: true,
referenceHigh: true,
sourceReferenceLow: true,
@@ -65,6 +66,7 @@ export const GET = apiHandler(async () => {
biomarkerId: r.biomarkerId,
unit: r.unit,
value: r.value as number,
sourceValue: r.sourceValue,
referenceLow: r.referenceLow,
referenceHigh: r.referenceHigh,
sourceReferenceLow: r.sourceReferenceLow,
@@ -238,6 +238,48 @@ describe("PUT /api/insights/provider-chain", () => {
]);
});
it("drops the effort of a gateway the person switches off", async () => {
// A disabled gateway entry with an effort is the gateway form's holder,
// which the picked gateway still serves behind; a switch-off is not.
storedChain([
{
providerType: "openai-compatible",
priority: 1,
enabled: true,
reasoningEffort: "low",
},
]);
await put([{ providerType: "openai-compatible", enabled: false }]);
expect(persisted()).toEqual([
{ providerType: "openai-compatible", priority: 1, enabled: false },
]);
});
it("keeps the holder's effort when the chain is saved around it", async () => {
storedChain([
{ providerType: "local", priority: 1, enabled: true },
{
providerType: "openai-compatible",
priority: 2,
enabled: false,
reasoningEffort: "low",
},
]);
await put([
{ providerType: "openai-compatible", enabled: false },
{ providerType: "local", enabled: true },
]);
expect(persisted()).toEqual([
{
providerType: "openai-compatible",
priority: 1,
enabled: false,
reasoningEffort: "low",
},
{ providerType: "local", priority: 2, enabled: true },
]);
});
it("clears it on null", async () => {
storedChain([
{
+19 -9
View File
@@ -118,18 +118,28 @@ export const PUT = apiHandler(async (request: NextRequest) => {
where: { id: user.id },
select: { aiProviderChain: true },
});
const storedChain = parseProviderChain(stored?.aiProviderChain ?? null);
const storedEffort = new Map(
parseProviderChain(stored?.aiProviderChain ?? null).map((e) => [
e.providerType,
e.reasoningEffort ?? null,
]),
storedChain.map((e) => [e.providerType, e.reasoningEffort ?? null]),
);
// A disabled gateway entry with an effort is the holder the gateway form
// writes, which the picked gateway still serves behind
// (`servesPickedGateway`). Switching the gateway off here is the person's
// word, so its effort goes with the switch.
const gatewayWasOn =
storedChain.find((e) => e.providerType === "openai-compatible")?.enabled !==
false;
const normalised = parsed.data.chain.map((entry, idx) => {
const reasoningEffort = isReasoningProviderType(entry.providerType)
? entry.reasoningEffort === undefined
? (storedEffort.get(entry.providerType) ?? null)
: entry.reasoningEffort
: null;
const switchedOff =
entry.providerType === "openai-compatible" &&
!entry.enabled &&
gatewayWasOn;
const reasoningEffort =
!isReasoningProviderType(entry.providerType) || switchedOff
? null
: entry.reasoningEffort === undefined
? (storedEffort.get(entry.providerType) ?? null)
: entry.reasoningEffort;
return {
providerType: entry.providerType,
priority: idx + 1,
+4 -3
View File
@@ -15,9 +15,10 @@ import { buildTargetsResponse } from "@/lib/targets/build-response";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
// v1.37.0 — MANAGE-level read: computed over the whole record, with no
// provider anywhere on the path.
const { user } = await requireRecordAuth("manage", "record");
// A read of the whole record, with no provider anywhere on the path, at the
// level the dashboard snapshot answers: the weight band and the BMI chart
// read it inside a shared record, and a read or write grant got a 403.
const { user } = await requireRecordAuth("read", "record");
// v1.16.8 — stale-while-revalidate read. The cold build is the multi-query
// walk in `buildTargetsResponse`; past the 60 s fresh TTL the prior body
// serves immediately while ONE background rebuild warms the cell. Writes
@@ -156,6 +156,18 @@ describe("POST /api/measurements/bulk-delete", () => {
});
});
it("leaves a computed score out of the selection it deletes", async () => {
// The read is scoped like the write, so a computed row never reaches it.
vi.mocked(prisma.measurement.findMany).mockResolvedValue([]);
vi.mocked(prisma.measurement.updateMany).mockResolvedValue({ count: 0 });
await POST(postReq({ ids: ["score-1"] }));
expect(
vi.mocked(prisma.measurement.findMany).mock.calls[0][0]?.where,
).toMatchObject({ source: { not: "COMPUTED" } });
});
it("collapses the rollup recompute to the unique (type, day) set, not per-row", async () => {
// 4 deleted rows: 2× WEIGHT on the same UTC day, 1× WEIGHT next day,
// 1× PULSE same day as the first → 3 distinct (type, day) keys.
@@ -81,9 +81,17 @@ async function postBulkDelete(request: NextRequest): Promise<Response> {
// other half and the cuff's pulse, `bloodPressureSiblings`) into the same
// statement, so the shared `deletedAt` lets the restore route bring the
// reading back whole.
//
// A computed score (recovery, strain, stress) is skipped: the server
// rewrites it from its inputs, and a tombstone kept the recompute out.
const { affected, count } = await prisma.$transaction(async (tx) => {
const selected = await tx.measurement.findMany({
where: { id: { in: ids }, userId: user.id, deletedAt: null },
where: {
id: { in: ids },
userId: user.id,
deletedAt: null,
source: { not: "COMPUTED" },
},
select: { id: true, type: true, source: true, measuredAt: true },
});
const siblings = await bloodPressureSiblings(tx, user.id, selected, "live");
@@ -51,7 +51,6 @@ vi.mock("@/lib/ai/coach/budget", () => ({
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProvider: vi.fn(async () => ({ type: "none" })),
resolveProviderChain: vi.fn(async () => []),
}));
@@ -82,7 +81,7 @@ import {
reconcileSpend,
resolveDailyCap,
} from "@/lib/ai/coach/budget";
import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider";
import { resolveProviderChain } from "@/lib/ai/provider";
import {
runRawCompletionWithFallback,
AllProvidersFailedError,
@@ -382,9 +381,6 @@ describe("POST /api/medications/extract — validation + provider errors", () =>
it("returns 503 when no provider is configured for the user", async () => {
vi.mocked(requireAuth).mockResolvedValue(AUTH_OK as never);
vi.mocked(resolveProviderChain).mockResolvedValueOnce([]);
vi.mocked(resolveProvider).mockResolvedValueOnce({
type: "none",
} as never);
// The status stays 503 (a shipped client may branch on it) and gains the
// code that says what it means.
+8 -12
View File
@@ -72,7 +72,7 @@ import {
runRawCompletionWithFallback,
} from "@/lib/ai/provider-runner";
import { spentOnFailure } from "@/lib/ai/reasoning/answerless";
import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider";
import { resolveProviderChain } from "@/lib/ai/provider";
import { singleUserTurn } from "@/lib/ai/types";
import {
@@ -198,17 +198,13 @@ async function handleExtract(request: NextRequest): Promise<Response> {
// surface a structured 503 — the wizard hides the overlay.
const chain = await resolveProviderChain(userId);
if (chain.length === 0) {
const legacy = await resolveProvider(userId);
if (legacy.type === "none") {
annotate({ action: { name: "medications.extract.no-provider" } });
throw new AiUnavailableError(
"medicationExtract",
"no_provider",
null,
MEDICATION_EXTRACT_NO_PROVIDER,
);
}
chain.push({ providerType: "admin-openai", instance: legacy });
annotate({ action: { name: "medications.extract.no-provider" } });
throw new AiUnavailableError(
"medicationExtract",
"no_provider",
null,
MEDICATION_EXTRACT_NO_PROVIDER,
);
}
// The wire. The runner may cascade through every entry, so each one is
+13 -8
View File
@@ -84,20 +84,25 @@ export const GET = apiHandler(async () => {
globallyEnabled: globalToggles[ch.type] ?? true,
}));
// Admin-only events (backup health) are listed for admins only, and an
// event nothing sends is listed for nobody: a switch for either would
// switch nothing. A stored row for an unlisted event stays in the table and
// is not served.
const eventTypes: string[] = eventTypesForRole(user.role);
const preferences = channels.flatMap((ch) =>
ch.preferences.map((p) => ({
channelId: p.channelId,
eventType: p.eventType,
enabled: p.enabled,
})),
ch.preferences
.filter((p) => eventTypes.includes(p.eventType))
.map((p) => ({
channelId: p.channelId,
eventType: p.eventType,
enabled: p.enabled,
})),
);
return apiSuccess({
channels: channelData,
preferences,
// Admin-only events (backup health) are listed for admins only: nobody
// else is ever sent one, so a switch for it would switch nothing.
eventTypes: eventTypesForRole(user.role),
eventTypes,
// What an event does on a channel with no stored row, resolved for this
// account by the same function the dispatcher asks. A client renders a
// switch without a row from this map, never from its own copy of the
@@ -90,6 +90,20 @@ describe("POST /api/onboarding/complete", () => {
);
});
it("stores a date of birth from the iOS app as its day in the owner's zone", async () => {
vi.mocked(getSession).mockResolvedValue({
...SESSION_OK,
user: { ...SESSION_OK.user, timezone: "Europe/Berlin" },
} as unknown as Awaited<ReturnType<typeof getSession>>);
// 14 May picked at 00:30 in Berlin.
const res = await POST(req({ dateOfBirth: "1960-05-13T23:30:00Z" }));
expect(res.status).toBe(200);
const data = vi.mocked(prisma.user.update).mock.calls[0]![0]!.data as {
dateOfBirth: Date;
};
expect(data.dateOfBirth.toISOString()).toBe("1960-05-14T12:00:00.000Z");
});
it("still refuses an unsupported gender", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await POST(req({ gender: "diverse" }));
+8 -4
View File
@@ -29,6 +29,8 @@ import { readManagedProfileForGuardian } from "@/lib/managed-profiles/lifecycle"
import { writeRecordModulePreferences } from "@/lib/record-settings/modules";
import { NextRequest } from "next/server";
import { onboardingCompleteSchema } from "@/lib/validations/onboarding";
import { dateOfBirthForStorage } from "@/lib/tz/date-only";
import { resolveStoredTimezone } from "@/lib/tz/resolver";
/**
* Complete the onboarding flow. Saves optional profile data and marks
@@ -84,10 +86,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
if (result.data.dateOfBirth) {
const dob = new Date(result.data.dateOfBirth);
if (!isNaN(dob.getTime())) {
data.dateOfBirth = dob;
}
// The calendar day in the owner's zone: the iOS app sends an instant.
const dob = dateOfBirthForStorage(
result.data.dateOfBirth,
await resolveStoredTimezone(user.timezone),
);
if (dob) data.dateOfBirth = dob;
}
if (result.data.gender) {
@@ -97,6 +97,8 @@ vi.mock("@/lib/medications/dose-day-refresh", () => ({
refreshDoseSlot: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/modules/gate", () => ({ isModuleEnabled: vi.fn() }));
import { POST, GET } from "../route";
import { prisma } from "@/lib/db";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
@@ -112,6 +114,7 @@ import {
} from "@/lib/mood/create-from-telegram";
import { scheduleTelegramAutoDelete } from "@/lib/telegram-cleanup";
import { satisfyReminder } from "@/lib/measurement-reminders/satisfy";
import { isModuleEnabled } from "@/lib/modules/gate";
const ORIGINAL_SECRET = process.env.TELEGRAM_WEBHOOK_SECRET;
@@ -129,11 +132,14 @@ const MEDICATION = { id: "med-1", name: "Ramipril" };
* schedule projection (distinguishable by `select.schedules`); every other
* `medication.findFirst` in the webhook wants the bare id/name shape. The
* default projection carries no schedules, so the generic dispatch tests
* exercise the ad-hoc (standalone create) path unchanged.
* exercise the ad-hoc (standalone create) path unchanged. The dose lookup
* (`findTelegramDoseMedication`) reads every match by id or name through
* `findMany`; a list read (no id, no name) finds nothing unless a test says.
*/
function wireMedicationFindFirst(
schedules: unknown[] = [],
scheduleRevisions: unknown[] = [],
medication: Record<string, unknown> = MEDICATION,
) {
vi.mocked(prisma.medication.findFirst).mockImplementation(((args: {
select?: Record<string, unknown>;
@@ -149,8 +155,16 @@ function wireMedicationFindFirst(
scheduleRevisions,
});
}
return Promise.resolve(MEDICATION);
return Promise.resolve(medication);
}) as never);
vi.mocked(prisma.medication.findMany).mockImplementation(((args: {
where?: Record<string, unknown>;
}) =>
Promise.resolve(
args?.where && ("id" in args.where || "name" in args.where)
? [medication]
: [],
)) as never);
}
beforeEach(() => {
@@ -162,8 +176,8 @@ beforeEach(() => {
resetAt: Date.now() + 60_000,
} as never);
vi.mocked(prisma.user.findFirst).mockResolvedValue(TG_USER as never);
vi.mocked(isModuleEnabled).mockResolvedValue(true);
wireMedicationFindFirst();
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medication.update).mockResolvedValue({} as never);
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue(null);
vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValue({} as never);
@@ -300,6 +314,7 @@ describe("Telegram webhook — callback dispatch", () => {
expect(res.status).toBe(200);
expect(prisma.medication.findFirst).not.toHaveBeenCalled();
expect(prisma.medication.findMany).not.toHaveBeenCalled();
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
expect(prisma.medication.update).not.toHaveBeenCalled();
expect(deleteMessage).not.toHaveBeenCalled();
@@ -464,7 +479,7 @@ describe("Telegram webhook — callback dispatch", () => {
});
it("rejects 'taken' for an inactive medication and acks with the localised error", async () => {
vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce(null);
vi.mocked(prisma.medication.findMany).mockResolvedValueOnce([]);
const res = await POST(tgRequest(callbackUpdate("taken:med-1")));
expect(res.status).toBe(200);
@@ -482,7 +497,7 @@ describe("Telegram webhook — callback dispatch", () => {
});
it("'snooze' for a missing medication acks with medicationNotFound (no update)", async () => {
vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce(null);
vi.mocked(prisma.medication.findMany).mockResolvedValueOnce([]);
const res = await POST(tgRequest(callbackUpdate("snooze:med-X:60")));
expect(res.status).toBe(200);
expect(prisma.medication.update).not.toHaveBeenCalled();
@@ -637,7 +652,7 @@ describe("Telegram webhook — text-message dispatch", () => {
});
it("/add with no active medications replies with the 'no active' message", async () => {
vi.mocked(prisma.medication.findMany).mockResolvedValueOnce([] as never);
vi.mocked(prisma.medication.findMany).mockResolvedValueOnce([]);
const res = await POST(
tgRequest({
update_id: 203,
@@ -670,8 +685,8 @@ describe("Telegram webhook — text-message dispatch", () => {
}),
);
expect(res.status).toBe(200);
// markMedicationTaken → findFirst on active medication
expect(prisma.medication.findFirst).toHaveBeenCalled();
// The list, then markMedicationTaken's lookup of the one it found.
expect(prisma.medication.findMany).toHaveBeenCalledTimes(2);
expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1);
});
@@ -712,15 +727,6 @@ describe("Telegram webhook — text-message dispatch", () => {
});
it("'taken Ramipril' (text command) creates an intake event for the matched medication", async () => {
vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce({
id: "med-1",
} as never);
// Second findFirst inside markMedicationTaken
vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce({
id: "med-1",
name: "Ramipril",
} as never);
const res = await POST(
tgRequest({
update_id: 206,
@@ -762,8 +768,8 @@ describe("Telegram webhook — text-message dispatch", () => {
});
it("'taken UnknownMed' replies with the medication-not-found message and writes nothing", async () => {
// First findFirst (lookup by name) returns null
vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce(null);
// The lookup by name finds nothing.
vi.mocked(prisma.medication.findMany).mockResolvedValueOnce([]);
const res = await POST(
tgRequest({
@@ -907,6 +913,29 @@ describe("Telegram webhook — slot convergence (v1.16.9)", () => {
});
});
it("books the reminder's own dose on the last day of a course after it ended", async () => {
// The course ran through 9 June; yesterday's 09:00 reminder is confirmed
// the next morning. The dose belongs to a day the course covered.
wireMedicationFindFirst([scheduleRow], [], {
...MEDICATION,
dose: "5 mg",
startsOn: null,
endsOn: new Date("2026-06-09T00:00:00.000Z"),
endedAt: null,
});
vi.mocked(prisma.telegramReminderMessage.findFirst).mockResolvedValue({
date: "2026-06-09",
timeOfDay: "09:00",
} as never);
await POST(tgRequest(callbackUpdate("taken:med-1")));
expect(prisma.$transaction).toHaveBeenCalled();
expect(vi.mocked(answerTelegramCallbackQuery).mock.calls[0][2]).not.toMatch(
/not found or inactive/i,
);
});
it("a take converges via band attribution when no reminder row matches", async () => {
vi.mocked(prisma.telegramReminderMessage.findFirst).mockResolvedValue(null);
const res = await POST(tgRequest(callbackUpdate("taken:med-1")));
@@ -1219,7 +1248,7 @@ describe("POST /api/telegram/webhook — record-only medications", () => {
it("the 'taken' keyword resolves tracked medications only", async () => {
await POST(tgRequest(textUpdate("taken Ramipril")));
const where = whereOf(vi.mocked(prisma.medication.findFirst).mock.calls[0]);
const where = whereOf(vi.mocked(prisma.medication.findMany).mock.calls[0]);
expect(where).toMatchObject({ active: true, trackIntake: true });
});
@@ -1227,7 +1256,7 @@ describe("POST /api/telegram/webhook — record-only medications", () => {
await POST(tgRequest(callbackUpdate("taken:med-1")));
// The gate lookup: a record-only medication resolves to nothing here,
// so no dose is written (later lookups only bind an admitted dose).
const calls = vi.mocked(prisma.medication.findFirst).mock.calls;
const calls = vi.mocked(prisma.medication.findMany).mock.calls;
expect(calls.length).toBeGreaterThan(0);
expect(whereOf(calls[0])).toMatchObject({
id: "med-1",
@@ -1236,3 +1265,86 @@ describe("POST /api/telegram/webhook — record-only medications", () => {
});
});
});
/**
* With the Medications module switched off the bot offers no dose and books
* none: the web shows no medication surface and the reminder jobs are
* silent, so a reminder button left in the chat, the /add list and the
* "taken" keyword do nothing either.
*/
describe("POST /api/telegram/webhook with the Medications module off", () => {
function textUpdate(text: string) {
return {
update_id: 400,
message: { message_id: 40, text, chat: { id: 7777 }, from: { id: 7777 } },
};
}
beforeEach(() => {
vi.mocked(isModuleEnabled).mockResolvedValue(false);
});
it.each(["taken:med-1", "skip:med-1", "add:med-1"])(
"the %s button books nothing",
async (data) => {
const res = await POST(tgRequest(callbackUpdate(data)));
expect(res.status).toBe(200);
expect(isModuleEnabled).toHaveBeenCalledWith("user-1", "medications");
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
expect(prisma.$transaction).not.toHaveBeenCalled();
},
);
it("the snooze button snoozes nothing", async () => {
await POST(tgRequest(callbackUpdate("snooze:med-1:60")));
expect(prisma.medication.update).not.toHaveBeenCalled();
expect(vi.mocked(answerTelegramCallbackQuery).mock.calls[0][2]).toMatch(
/not found/i,
);
});
it("/add offers nothing", async () => {
await POST(tgRequest(textUpdate("/add")));
expect(prisma.medication.findMany).not.toHaveBeenCalled();
expect(vi.mocked(sendTelegramMessage).mock.calls[0][2]).toMatch(
/no active medications/i,
);
});
it("the 'taken' keyword records nothing", async () => {
await POST(tgRequest(textUpdate("taken Ramipril")));
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
expect(prisma.$transaction).not.toHaveBeenCalled();
});
});
/**
* A reminder button left in the chat takes no dose for a course that has
* since ended, which would also take a unit off the stock. The list side is
* pinned in `src/lib/__tests__/telegram-dose-medications.test.ts`.
*/
describe("POST /api/telegram/webhook on an ended course", () => {
const DAY_MS = 24 * 60 * 60 * 1000;
const ENDED = {
...MEDICATION,
dose: "5 mg",
startsOn: new Date(Date.now() - 20 * DAY_MS),
endsOn: new Date(Date.now() - 3 * DAY_MS),
endedAt: null,
};
it("the taken button on an ended course books nothing", async () => {
wireMedicationFindFirst([], [], ENDED);
await POST(tgRequest(callbackUpdate("taken:med-1")));
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
expect(prisma.$transaction).not.toHaveBeenCalled();
expect(vi.mocked(answerTelegramCallbackQuery).mock.calls[0][2]).toMatch(
/not found or inactive/i,
);
});
it("the skip button on an ended course books nothing", async () => {
wireMedicationFindFirst([], [], ENDED);
await POST(tgRequest(callbackUpdate("skip:med-1")));
expect(prisma.$transaction).not.toHaveBeenCalled();
});
});
@@ -751,6 +751,24 @@ describe("PATCH /api/user/ai-provider — reasoning setting (#1126)", () => {
]);
});
it("switches the gateway's entry on when the gateway form saves it as the pick", async () => {
// The disabled holder above, written for a picked gateway, read as the
// person switching off the provider they had just picked.
storedChain(null);
await patch(
patchRequest({
provider: "OPENAI_COMPATIBLE",
compatReasoningEffort: "low",
}),
);
const chain = written()?.aiProviderChain as Array<{
providerType: string;
}>;
expect(
chain.find((e) => e.providerType === "openai-compatible"),
).toMatchObject({ enabled: true, reasoningEffort: "low" });
});
it("does not touch the chain when Default is restated on a default chain", async () => {
storedChain(null);
await patch(
+53 -31
View File
@@ -16,6 +16,7 @@ import {
providerWorkAuthorityForRecord,
} from "@/lib/sharing/provider-work-authority";
import { annotate } from "@/lib/logging/context";
import type { z } from "zod/v4";
import { aiProviderPatchSchema } from "@/lib/validations/ai-provider";
import {
chainWithReasoningEffort,
@@ -191,6 +192,51 @@ export const GET = apiHandler(async () => {
});
});
/**
* #1126 — the chain to write after the Local or gateway form set its entry's
* reasoning effort, or undefined when nothing changed. Stored on the entry in
* `aiProviderChain` rather than in a column of their own, so the setting is
* per entry and a chain can mix a thinking model with one that does not know
* the field. See `chainWithReasoningEffort` for how a default chain, or a
* provider absent from the chain, holds the value.
*/
async function chainAfterReasoningSave(
userId: string,
body: Pick<
z.infer<typeof aiProviderPatchSchema>,
"localReasoningEffort" | "compatReasoningEffort"
>,
pickedNow: unknown,
): Promise<unknown> {
const current = await prisma.user.findUnique({
where: { id: userId },
select: { aiProviderChain: true, aiProvider: true },
});
let chain: unknown = current?.aiProviderChain ?? null;
if (body.localReasoningEffort !== undefined) {
chain = chainWithReasoningEffort(chain, "local", body.localReasoningEffort);
}
if (body.compatReasoningEffort !== undefined) {
// Saving the gateway form picks the gateway, so its entry is on: a
// disabled one read as the person switching off what they had just picked.
const picked = pickedNow !== undefined ? pickedNow : current?.aiProvider;
chain = chainWithReasoningEffort(
chain,
"openai-compatible",
body.compatReasoningEffort,
String(picked ?? "").toUpperCase() === "OPENAI_COMPATIBLE",
);
}
// Written only when it changed: restating Default on a chain that was
// never customised must not freeze today's default chain into the row.
const before = serializeProviderChain(
parseProviderChain(current?.aiProviderChain ?? null),
);
return serializeProviderChain(parseProviderChain(chain)) !== before
? chain
: undefined;
}
export const PATCH = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "user.ai-provider.update" } });
@@ -265,6 +311,7 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
return apiError(
"Base URL points to an internal/private host. The operator must allow its exact origin on this instance via AI_PRIVATE_ORIGINS (e.g. http://ollama.lan:11434) for a self-hosted Ollama / LM Studio.",
422,
{ errorCode: "ai_provider.private_host" },
);
}
updates.aiBaseUrl = trimmed;
@@ -289,6 +336,7 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
return apiError(
"Base URL points to an internal/private host. The operator must allow its exact origin on this instance via AI_PRIVATE_ORIGINS (e.g. http://litellm.lan:4000) for a self-hosted gateway.",
422,
{ errorCode: "ai_provider.private_host" },
);
}
updates.aiCompatBaseUrl = trimmed;
@@ -335,42 +383,16 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
updates.aiResponseTimeoutSeconds = body.responseTimeoutSeconds;
}
// ── #1126 — reasoning settings of the Local and gateway entries ──
// Stored on the entry in `aiProviderChain` rather than in a column of their
// own, so the setting is per entry and a chain can mix a thinking model with
// one that does not know the field. See `chainWithReasoningEffort` for how a
// default chain, or a provider absent from the chain, holds the value.
const reasoningTouched =
body.localReasoningEffort !== undefined ||
body.compatReasoningEffort !== undefined;
if (reasoningTouched) {
const current = await prisma.user.findUnique({
where: { id: user.id },
select: { aiProviderChain: true },
});
let chain: unknown = current?.aiProviderChain ?? null;
if (body.localReasoningEffort !== undefined) {
chain = chainWithReasoningEffort(
chain,
"local",
body.localReasoningEffort,
);
}
if (body.compatReasoningEffort !== undefined) {
chain = chainWithReasoningEffort(
chain,
"openai-compatible",
body.compatReasoningEffort,
);
}
// Written only when it changed: restating Default on a chain that was
// never customised must not freeze today's default chain into the row.
const before = serializeProviderChain(
parseProviderChain(current?.aiProviderChain ?? null),
const chain = await chainAfterReasoningSave(
user.id,
body,
updates.aiProvider,
);
if (serializeProviderChain(parseProviderChain(chain)) !== before) {
updates.aiProviderChain = chain;
}
if (chain !== undefined) updates.aiProviderChain = chain;
}
if (Object.keys(updates).length === 0 && !reasoningTouched) {
@@ -96,7 +96,7 @@ beforeEach(() => {
describe("PUT /api/user/thresholds — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
// WEIGHT bounds are min=30, max=300. Sending min > max + a value
// WEIGHT bounds are min=1, max=300. Sending min > max + a value
// out of bounds on PULSE forces two range errors.
const res = await PUT(
putReq({
@@ -97,7 +97,7 @@ function report(): DoctorReportData {
end: "2026-01-31T00:00:00.000Z",
},
patient: {
username: null,
name: null,
dateOfBirth: null,
gender: null,
heightCm: null,
@@ -18,9 +18,9 @@ vi.mock("@/hooks/use-ai-capability", () => ({
useAiCapabilityAnswer: () => capability.answer,
}));
const plan = (id: string, status: string) => ({
const plan = (id: string, status: string, metric = "WEIGHT") => ({
id,
metric: "WEIGHT",
metric,
ifCue: `cue ${id}`,
thenAction: `action ${id}`,
target: null,
@@ -35,7 +35,7 @@ vi.mock("@/hooks/use-coach-plans", () => ({
data: [
plan("p-proposed", "proposed"),
plan("p-active", "active"),
plan("p-met", "met"),
plan("p-met", "met", "mood vibes"),
],
isError: false,
isLoading: false,
@@ -48,12 +48,16 @@ vi.mock("@/hooks/use-coach-plans", () => ({
}));
import { I18nProvider } from "@/lib/i18n/context";
import { primeMessages } from "@/lib/i18n/load-locale";
import deMessages from "../../../../../messages/de.json";
import CoachPlansPage from "../page";
function render(): string {
primeMessages("de", deMessages);
function render(locale: "en" | "de" = "en"): string {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<I18nProvider initialLocale={locale}>
<CoachPlansPage />
</I18nProvider>,
);
@@ -90,4 +94,12 @@ describe("/coach/plans", () => {
expect(count(html, "coach-plan-delete")).toBe(1);
expect(html).not.toContain("you can still read and delete your plans");
});
it("names the metric in the reader's language, and an unknown key as written", () => {
capability.answer = { available: true };
const html = render("de");
expect(html.split("<span>Gewicht</span>").length - 1).toBe(2);
expect(html).toContain("<span>mood vibes</span>");
expect(html).not.toContain("WEIGHT");
});
});
+2 -1
View File
@@ -23,6 +23,7 @@ import {
type CoachPlanDTO,
} from "@/hooks/use-coach-plans";
import { useAiCapabilityAnswer } from "@/hooks/use-ai-capability";
import { planMetricLabel } from "@/lib/ai/coach/plan-metric";
import { useTranslations } from "@/lib/i18n/context";
import { formatDateOrRelative } from "@/lib/format";
import { cn } from "@/lib/utils";
@@ -109,7 +110,7 @@ function CoachPlansBody({ readOnly }: { readOnly: boolean }) {
</p>
) : null}
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span className="uppercase">{plan.metric}</span>
<span>{planMetricLabel(plan.metric, t)}</span>
<span>{t(`coach.plans.status.${plan.status}`)}</span>
<span>{formatDateOrRelative(plan.updatedAt, t)}</span>
{plan.reviewDate ? (
@@ -0,0 +1,101 @@
/**
* `/insights/bmi` decides from a height whether it can show anything. It used
* the height of the person at the keyboard, so inside somebody else's record
* a viewer without a height of their own was asked to set it, and a viewer
* with one hid that the record has none.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
const shellProps = vi.fn();
vi.mock("@/components/insights/sub-page-shell", () => ({
SubPageShell: (props: Record<string, unknown>) => {
shellProps(props);
return null;
},
}));
vi.mock("@/lib/i18n/context", () => ({
useTranslations: () => ({ t: (key: string) => key, locale: "en" }),
}));
const state = {
viewerHeight: null as number | null,
inSharedRecord: true,
recordHeight: 118 as number | null,
bmiRange: { min: 18.5, max: 24.9 } as { min: number; max: number } | null,
};
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
isAuthenticated: true,
user: { heightCm: state.viewerHeight, timezone: "UTC" },
}),
}));
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => ({ inSharedRecord: state.inSharedRecord }),
}));
vi.mock("@/hooks/use-insights-analytics", () => ({
useInsightsAnalytics: () => ({ isEmpty: false }),
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: ({ enabled }: { enabled?: boolean }) => ({
data:
enabled === false
? undefined
: {
targets: [{ type: "BMI", range: state.bmiRange }],
profile: { heightCm: state.recordHeight },
},
}),
}));
import InsightsBmiPage from "../page";
/** True when the page rendered its "set your height" state. */
function asksForHeight(): boolean {
renderToStaticMarkup(<InsightsBmiPage />);
const props = shellProps.mock.calls.at(-1)![0] as { coachLaunch?: boolean };
// The chart branch launches the Coach from the shell; the empty states do not.
return props.coachLaunch !== true;
}
describe("/insights/bmi height gate", () => {
beforeEach(() => {
shellProps.mockClear();
state.viewerHeight = null;
state.inSharedRecord = true;
state.recordHeight = 118;
state.bmiRange = { min: 18.5, max: 24.9 };
});
it("reads the record's height inside somebody else's record", () => {
expect(asksForHeight()).toBe(false);
state.viewerHeight = 180;
state.recordHeight = null;
expect(asksForHeight()).toBe(true);
});
it("reads the account's own height in one's own record", () => {
state.inSharedRecord = false;
state.viewerHeight = 180;
expect(asksForHeight()).toBe(false);
state.viewerHeight = null;
expect(asksForHeight()).toBe(true);
});
it("draws the adult bands only where the record's BMI range is named", () => {
const chartBands = () => {
renderToStaticMarkup(<InsightsBmiPage />);
const { children } = shellProps.mock.calls.at(-1)![0] as {
children: Array<{ props?: { valueBands?: unknown } }>;
};
return children.find(
(child) => child?.props && "valueBands" in child.props,
)?.props?.valueBands;
};
expect(chartBands()).toBeDefined();
// A child's record: the server names no BMI range.
state.bmiRange = null;
expect(chartBands()).toBeUndefined();
});
});
+29 -2
View File
@@ -1,9 +1,13 @@
"use client";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { Ruler } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
@@ -45,6 +49,29 @@ export default function InsightsBmiPage() {
const { isEmpty } = useInsightsAnalytics("BMI");
// `useAuth()` is the person at the keyboard. Inside somebody else's record
// the height that decides is the record's, from the route that knows which
// record is on screen (the reference panel below reads the same cell).
const { inSharedRecord } = useRecordCapabilities();
const { data: targets } = useQuery({
queryKey: queryKeys.insightsTargets(),
queryFn: () =>
apiGet<{
targets: ReadonlyArray<{ type: string; range: unknown }>;
profile?: { heightCm: number | null };
}>("/api/insights/targets"),
enabled: isAuthenticated,
});
const heightCm = inSharedRecord
? (targets?.profile?.heightCm ?? null)
: (user?.heightCm ?? null);
const heightKnown = !inSharedRecord || targets !== undefined;
// The adult bands only where the server names the BMI range: it names none
// for a child's record, which they do not describe.
const bmiBands = targets?.targets.find((entry) => entry.type === "BMI")?.range
? BMI_BANDS
: undefined;
// v1.4.27 — BMI is derived from WEIGHT. When no weight readings
// exist yet, the existing "set your height" branch can never compute
// anything useful either; surface the empty-state CTA pointing at
@@ -75,7 +102,7 @@ export default function InsightsBmiPage() {
);
}
if (!user?.heightCm) {
if (heightKnown && !heightCm) {
return (
<SubPageShell
title={t("insights.bmiSectionTitle")}
@@ -119,7 +146,7 @@ export default function InsightsBmiPage() {
colors={["var(--chart-5)"]}
unit="kg/m²"
valueMode="bmi"
valueBands={BMI_BANDS}
valueBands={bmiBands}
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
showDataTable
@@ -0,0 +1,105 @@
/**
* `/insights/weight` shades the band of the record on screen. It used to take
* the height of the person at the keyboard (`useAuth()`) and their own target
* (`/api/user/thresholds`, refused under a switch), so a parent of 180 cm on
* a child's record saw the parent's healthy band behind the child's weights.
*
* `SubPageShell` is stubbed to a probe so the test reads the band the page
* hands the chart, not rendered pixels.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
const shellProps = vi.fn();
vi.mock("@/components/insights/sub-page-shell", () => ({
SubPageShell: (props: Record<string, unknown>) => {
shellProps(props);
return null;
},
}));
vi.mock("@/lib/i18n/context", () => ({
useTranslations: () => ({ t: (key: string) => key, locale: "en" }),
}));
// The viewer: 180 cm, signed in, looking at somebody else's record.
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
isAuthenticated: true,
user: { heightCm: 180, timezone: "UTC", unitPreference: "metric" },
}),
useAccountOnceMounted: () => null,
}));
vi.mock("@/hooks/use-insights-analytics", () => ({
useInsightsAnalytics: () => ({
data: { summaries: { WEIGHT: { count: 10 } } },
isLoading: false,
isEmpty: false,
}),
}));
// The record on screen: 118 cm, no target of its own.
const targets = {
current: {
targets: [
{ type: "WEIGHT", range: { min: 25.8, max: 34.7 }, source: "WHO BMI" },
],
profile: { heightCm: 118 },
} as unknown,
};
vi.mock("@tanstack/react-query", () => ({
useQuery: ({ queryKey }: { queryKey: readonly unknown[] }) => ({
data: JSON.stringify(queryKey).includes("targets")
? targets.current
: undefined,
isLoading: false,
error: null,
}),
}));
import InsightsGewichtPage from "../page";
import { buildWeightRangeFromHeight } from "@/lib/analytics/value-bands";
function chartBands(): Array<{ min: number; max: number }> | undefined {
renderToStaticMarkup(<InsightsGewichtPage />);
const props = shellProps.mock.calls.at(-1)![0] as { children: unknown };
const children = Array.isArray(props.children)
? props.children
: [props.children];
const chart = children.find(
(el) =>
el &&
typeof el === "object" &&
Array.isArray((el as { props?: { types?: unknown } }).props?.types),
) as { props: { valueBands?: Array<{ min: number; max: number }> } };
return chart.props.valueBands;
}
describe("/insights/weight band", () => {
beforeEach(() => shellProps.mockClear());
it("shades the WHO band for the record's height, not the viewer's", () => {
const bands = chartBands();
const record = buildWeightRangeFromHeight(118);
const viewer = buildWeightRangeFromHeight(180);
expect(bands).toContainEqual(
expect.objectContaining({ min: record.greenMin, max: record.greenMax }),
);
expect(bands).not.toContainEqual(
expect.objectContaining({ min: viewer.greenMin, max: viewer.greenMax }),
);
});
it("shades no band when the record's targets are not readable", () => {
targets.current = undefined;
try {
expect(chartBands()).toBeUndefined();
} finally {
targets.current = {
targets: [],
profile: { heightCm: 118 },
};
}
});
});
@@ -0,0 +1,57 @@
/**
* The weight band belongs to the record on screen. It used to be built from
* the signed-in person's height, so a parent of 180 cm looking at a child's
* record saw a healthy band of about 60 to 81 kg behind the child's weights.
*/
import { describe, expect, it } from "vitest";
import { buildWeightRangeFromHeight } from "@/lib/analytics/value-bands";
import { selectWeightBandRange } from "../weight-band";
describe("selectWeightBandRange", () => {
it("builds the WHO band from the record's height", () => {
const range = selectWeightBandRange({
targets: [
{ type: "WEIGHT", range: { min: 59.9, max: 81 }, source: "WHO BMI" },
],
profile: { heightCm: 180 },
});
expect(range).toEqual(buildWeightRangeFromHeight(180));
});
it("paints no adult band behind a child's weights", () => {
// A 118 cm child: the server names no WHO range for a record under 18,
// where the adult band read a normal 21 kg as underweight.
expect(
selectWeightBandRange({
targets: [{ type: "WEIGHT", range: null, source: "WHO BMI" }],
profile: { heightCm: 118 },
}),
).toBeNull();
});
it("paints the record's own target with the override wings", () => {
const range = selectWeightBandRange({
targets: [
{ type: "WEIGHT", range: { min: 70, max: 80 }, source: "Custom" },
],
profile: { heightCm: 180 },
});
expect(range).toEqual({
greenMin: 70,
greenMax: 80,
orangeMin: 68.5,
orangeMax: 81.5,
});
});
it("paints nothing without a target or a height, or before the read lands", () => {
expect(
selectWeightBandRange({
targets: [{ type: "WEIGHT", range: null, source: "WHO BMI" }],
profile: { heightCm: null },
}),
).toBeNull();
expect(selectWeightBandRange(null)).toBeNull();
});
});
+18 -39
View File
@@ -23,11 +23,9 @@ import { MetricCorrelationCard } from "@/components/insights/metric-correlation-
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import {
buildBandsFromTrafficRange,
buildWeightBandsFromHeight,
type TrafficRange,
} from "@/lib/analytics/value-bands";
import { METRIC_BOUNDS } from "@/lib/analytics/effective-range";
import { buildBandsFromTrafficRange } from "@/lib/analytics/value-bands";
import { selectWeightBandRange, type WeightTargetsSlice } from "./weight-band";
/**
* v1.4.25 — `/insights/weight`.
@@ -37,8 +35,8 @@ import {
* (`chartKey="weight"`) lets the user toggle trend lines + comparison
* overlay independently from the dashboard weight card.
*
* v1.34 — the bands follow the user's own weight target when one is set,
* and fall back to the height-derived WHO band only when it is not.
* The bands follow the record's own weight target when one is set, and fall
* back to the WHO band for the record's height only when it is not.
*
* v1.4.28 — analytics fetch + empty-state
* branch now consume `useInsightsAnalytics()` + `<MetricEmptyState>`.
@@ -56,19 +54,13 @@ export default function InsightsGewichtPage() {
} = useInsightsAnalytics("WEIGHT");
const weightSummary = analytics?.summaries?.WEIGHT ?? null;
// v1.34 — the user's own weight target, read through the shared
// `queryKeys.userThresholds()` cell the targets editor also subscribes to, so
// editing the target repaints this chart's band without a reload.
const { data: thresholds } = useQuery({
queryKey: queryKeys.userThresholds(),
queryFn: async () => {
return apiGet<{
effective: Record<
string,
{ range: TrafficRange | null; isOverride: boolean }
>;
}>("/api/user/thresholds");
},
// The record's own target and height, from the route that knows which
// record is on screen (see `selectWeightBandRange`). Shares the
// `insightsTargets()` cell with the reference panel below; the targets
// editor invalidates it on save, so the band repaints without a reload.
const { data: targets } = useQuery({
queryKey: queryKeys.insightsTargets(),
queryFn: () => apiGet<WeightTargetsSlice>("/api/insights/targets"),
enabled: isAuthenticated,
});
@@ -136,27 +128,14 @@ export default function InsightsGewichtPage() {
);
}
// v1.34 — the shaded zone follows the user's OWN target when they set one.
// The page used to shade the height-derived WHO band unconditionally, so
// someone who had entered a target on `/targets` kept reading a chart that
// ignored the answer. `/api/user/thresholds` already resolves the override
// (with its orange wings) through `getEffectiveRange`, so the page reads the
// resolved band rather than re-deriving one. No target and no height → no
// bands, unchanged.
const overrideRange = thresholds?.effective?.WEIGHT?.isOverride
? (thresholds.effective.WEIGHT.range ?? null)
: null;
const weightBands = overrideRange
? buildBandsFromTrafficRange(overrideRange, {
lowerBound: 30,
// No target and no height → no bands.
const weightRange = selectWeightBandRange(targets ?? null);
const weightBands = weightRange
? buildBandsFromTrafficRange(weightRange, {
lowerBound: METRIC_BOUNDS.WEIGHT.min,
upperBound: 250,
})
: user?.heightCm
? buildWeightBandsFromHeight(user.heightCm, {
lowerBound: 30,
upperBound: 250,
})
: undefined;
: undefined;
// The bands are kg bounds → scale into the display unit so the shaded zone
// tracks the converted line (factor-only; weight carries no offset).
const displayBands =
+45
View File
@@ -0,0 +1,45 @@
import { getEffectiveRange } from "@/lib/analytics/effective-range";
import {
buildWeightRangeFromHeight,
type TrafficRange,
} from "@/lib/analytics/value-bands";
/** The slice of `GET /api/insights/targets` the weight band needs. */
export interface WeightTargetsSlice {
targets: ReadonlyArray<{
type: string;
range: { min: number; max: number } | null;
source: string;
}>;
profile?: { heightCm: number | null };
}
/**
* The weight band for the record on screen, as the server resolved it.
*
* The page used to read the person at the keyboard: their height from
* `useAuth()` and their own target from `/api/user/thresholds`, which
* refuses inside somebody else's record. A parent on a child's record saw a
* band built from the parent's height. `/api/insights/targets` resolves the
* record's own target and height, and the reference panel under the chart
* reads the same payload. The record's own target (`source: "Custom"`) gets
* the wings `getEffectiveRange` gives every override; otherwise the band is
* the height-derived WHO one. Where the server names no range there is none:
* no target and no height, or a child's record, which the adult WHO band does
* not describe.
*/
export function selectWeightBandRange(
data: WeightTargetsSlice | null,
): TrafficRange | null {
const weight = data?.targets.find((entry) => entry.type === "WEIGHT");
if (!data || !weight?.range) return null;
if (weight.source === "Custom") {
return getEffectiveRange(
"WEIGHT",
{ heightCm: null, dateOfBirth: null, gender: null },
{ WEIGHT: weight.range },
).range;
}
const heightCm = data.profile?.heightCm ?? null;
return heightCm ? buildWeightRangeFromHeight(heightCm) : null;
}
+3 -15
View File
@@ -37,7 +37,7 @@ import { useMounted } from "@/hooks/use-mounted";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { invalidateKeys, labDependentKeys, queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import type { LabResultListResponse } from "@/components/labs/types";
import {
@@ -231,18 +231,14 @@ export default function LabsPage() {
footerSlot={addFooterEl}
onSuccess={() => {
setDialogOpen(false);
queryClient.invalidateQueries({
queryKey: queryKeys.labResults(),
});
void invalidateKeys(queryClient, labDependentKeys);
}}
// v1.30.1 — "Save & add another" keeps the sheet open; the
// form clears the reading itself, this callback just refreshes
// the list underneath so the just-saved row is there once the
// user is done and closes the sheet.
onSavedKeepOpen={() => {
queryClient.invalidateQueries({
queryKey: queryKeys.labResults(),
});
void invalidateKeys(queryClient, labDependentKeys);
}}
onCancel={() => setDialogOpen(false)}
/>
@@ -255,14 +251,6 @@ export default function LabsPage() {
mode={ocrCapability.data.mode}
pdfSupported={ocrCapability.data.pdfSupported}
consentRequired={labsOcr.reason === "consent_required"}
onCommitted={() => {
queryClient.invalidateQueries({
queryKey: queryKeys.labResults(),
});
queryClient.invalidateQueries({
queryKey: queryKeys.biomarkers(),
});
}}
/>
) : null}
@@ -143,8 +143,8 @@ describe("NotificationsPage", () => {
},
],
preferences: [],
eventTypes: ["MOOD_REMINDER", "PERSONAL_RECORD"],
eventDefaults: { MOOD_REMINDER: true, PERSONAL_RECORD: false },
eventTypes: ["MOOD_REMINDER", "DAILY_BRIEFING"],
eventDefaults: { MOOD_REMINDER: true, DAILY_BRIEFING: false },
};
const html = render();
const state = (label: string) =>
@@ -152,10 +152,10 @@ describe("NotificationsPage", () => {
`aria-label="${label} - Telegram"[^>]*data-state="(checked|unchecked)"|data-state="(checked|unchecked)"[^>]*aria-label="${label} - Telegram"`,
).exec(html);
const mood = state("Mood reminder");
const record = state("Personal record");
const briefing = state("Daily briefing");
expect(mood, "mood switch rendered").not.toBeNull();
expect(mood?.[1] ?? mood?.[2]).toBe("checked");
expect(record?.[1] ?? record?.[2]).toBe("unchecked");
expect(briefing?.[1] ?? briefing?.[2]).toBe("unchecked");
});
it("a stored row still wins over the default", () => {
+4 -19
View File
@@ -12,6 +12,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { PasswordInput as SharedPasswordInput } from "@/components/ui/password-input";
import { apiGet, apiPut } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
/**
* Re-export the canonical password input so admin sections have access to
@@ -323,29 +324,13 @@ export function useUpdateSettings() {
toast.success(t("common.saved"));
},
onError: (err) => {
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.settingsSaveError"),
{ cause: err },
);
showErrorToast(localizedApiError(err, t, "admin.settingsSaveError"), {
cause: err,
});
},
});
}
export async function getApiErrorMessage(response: Response): Promise<string> {
const fallback = `HTTP ${response.status}`;
try {
const json = (await response.json()) as { error?: string };
if (typeof json?.error === "string" && json.error.trim().length > 0) {
return json.error;
}
} catch {
return fallback;
}
return fallback;
}
/**
* Shared `auth.*` audit-action label map. Two surfaces consume it
* today (`/admin/login-overview` and the dashboard recent-activity
@@ -21,6 +21,7 @@ import {
queryKeys,
} from "@/lib/query-keys";
import { apiGet, apiPut } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { PasswordInput } from "./_shared";
/**
@@ -90,10 +91,9 @@ export function AiServerKeySection() {
toast.success(t("admin.aiServerKey.saved"));
},
onError: (error: unknown) => {
showErrorToast(
error instanceof Error ? error.message : t("admin.aiServerKey.error"),
{ cause: error },
);
showErrorToast(localizedApiError(error, t, "admin.aiServerKey.error"), {
cause: error,
});
},
});
+4 -6
View File
@@ -35,6 +35,7 @@ import {
type AiUsageLevel,
} from "@/lib/ai/usage-level";
import { apiGet, apiPut } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { useTranslations } from "@/lib/i18n/context";
import {
aiInputDependentKeys,
@@ -122,12 +123,9 @@ function AiUsageForm({ bounds }: { bounds: AiUsageBounds }) {
toast.success(t("common.saved"));
},
onError: (err) => {
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.settingsSaveError"),
{ cause: err },
);
showErrorToast(localizedApiError(err, t, "admin.settingsSaveError"), {
cause: err,
});
},
});
+4 -6
View File
@@ -15,6 +15,7 @@ import {
} from "@/lib/query-keys";
import { SettingsToggle } from "./_shared";
import { apiGet, apiPut } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import {
@@ -105,12 +106,9 @@ function useUpdateAssistantFlags() {
toast.success(t("common.saved"));
},
onError: (err) => {
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.settingsSaveError"),
{ cause: err },
);
showErrorToast(localizedApiError(err, t, "admin.settingsSaveError"), {
cause: err,
});
client.invalidateQueries({
queryKey: queryKeys.adminAssistantFlags(),
});
@@ -7,14 +7,13 @@ import {
ReproofCancelledError,
throwIfReproofRequired,
} from "@/components/settings/security-section/use-recent-proof";
import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
import { apiFetchRaw, apiPost, throwApiError } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { BackupRow } from "@/types/backups";
import { getApiErrorMessage } from "../_shared";
/** "Run backup now": enqueues the pg-boss `data-backup` job. */
export function useRunBackup() {
const { t } = useTranslations();
@@ -34,9 +33,7 @@ export function useRunBackup() {
},
onError: (err) => {
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.section.backups.runFailed"),
localizedApiError(err, t, "admin.section.backups.runFailed"),
{ cause: err },
);
},
@@ -62,9 +59,7 @@ export function useBackupDownload(
await apiFetchRaw(`/api/admin/backups/${row.id}/download`),
),
);
if (!res.ok) {
throw new Error(await getApiErrorMessage(res));
}
if (!res.ok) await throwApiError(res);
// Use the server-provided Content-Disposition filename if present,
// otherwise fall back to a deterministic client-side name. The
// server filename is more accurate (uses the actual createdAt).
@@ -86,9 +81,7 @@ export function useBackupDownload(
} catch (err) {
if (err instanceof ReproofCancelledError) return;
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.section.backups.downloadFailed"),
localizedApiError(err, t, "admin.section.backups.downloadFailed"),
{ cause: err },
);
} finally {
@@ -5,6 +5,7 @@ import { toast } from "sonner";
import type { useRecentProof } from "@/components/settings/security-section/use-recent-proof";
import { ReproofCancelledError } from "@/components/settings/security-section/use-recent-proof";
import { ApiError, apiGet, apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { showErrorToast } from "@/lib/errors/error-toast";
import type {
MissingBackupSection,
@@ -188,9 +189,7 @@ export function useBackupRestores(
}
setRefusedSections([]);
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.section.backups.restoreFailed"),
localizedApiError(err, t, "admin.section.backups.restoreFailed"),
{ cause: err },
);
},
@@ -5,6 +5,7 @@ import { toast } from "sonner";
import type { useRecentProof } from "@/components/settings/security-section/use-recent-proof";
import { ReproofCancelledError } from "@/components/settings/security-section/use-recent-proof";
import { ApiError, apiFetch, apiGet } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -167,9 +168,7 @@ export function useBackupUpload(
return;
}
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.section.backups.uploadFailed"),
localizedApiError(err, t, "admin.section.backups.uploadFailed"),
{ cause: err },
);
},
+24 -36
View File
@@ -28,7 +28,8 @@ import { Button } from "@/components/ui/button";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import { apiDelete, apiGet, apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import {
@@ -66,21 +67,20 @@ export function CentralCodexSection() {
async function handleConnect() {
setDevicePolling(true);
try {
const res = await apiFetchRaw("/api/admin/central-codex/device-start", {
method: "POST",
});
const json = await res.json();
if (!res.ok) throw new Error(json.error || t("admin.centralCodex.error"));
const data = await apiPost<{
userCode: string;
verificationUrl: string;
intervalSeconds: number;
}>("/api/admin/central-codex/device-start");
setDeviceCode({
userCode: json.data.userCode,
verificationUrl: json.data.verificationUrl,
intervalSeconds: json.data.intervalSeconds,
userCode: data.userCode,
verificationUrl: data.verificationUrl,
intervalSeconds: data.intervalSeconds,
});
} catch (err) {
showErrorToast(
err instanceof Error ? err.message : t("admin.centralCodex.error"),
{ cause: err },
);
showErrorToast(localizedApiError(err, t, "admin.centralCodex.error"), {
cause: err,
});
setDevicePolling(false);
}
}
@@ -92,15 +92,11 @@ export function CentralCodexSection() {
async function tick() {
try {
const res = await apiFetchRaw("/api/admin/central-codex/device-poll", {
method: "POST",
});
const json = await res.json();
const data = await apiPost<{ status?: string } | null>(
"/api/admin/central-codex/device-poll",
);
if (cancelled) return;
if (!res.ok) {
throw new Error(json.error || t("admin.centralCodex.error"));
}
if (json.data?.status === "connected") {
if (data?.status === "connected") {
setDeviceCode(null);
setDevicePolling(false);
toast.success(t("admin.centralCodex.connected"));
@@ -113,10 +109,9 @@ export function CentralCodexSection() {
if (!cancelled) setTimeout(tick, intervalMs);
} catch (err) {
if (cancelled) return;
showErrorToast(
err instanceof Error ? err.message : t("admin.centralCodex.error"),
{ cause: err },
);
showErrorToast(localizedApiError(err, t, "admin.centralCodex.error"), {
cause: err,
});
setDeviceCode(null);
setDevicePolling(false);
}
@@ -133,23 +128,16 @@ export function CentralCodexSection() {
async function handleDisconnect() {
setDisconnecting(true);
try {
const res = await apiFetchRaw("/api/admin/central-codex", {
method: "DELETE",
});
if (!res.ok) {
const json = await res.json();
throw new Error(json.error || t("admin.centralCodex.error"));
}
await apiDelete("/api/admin/central-codex");
toast.success(t("admin.centralCodex.disconnected"));
queryClient.invalidateQueries({
queryKey: queryKeys.adminCentralCodex(),
});
void invalidateKeys(queryClient, aiInputDependentKeys);
} catch (err) {
showErrorToast(
err instanceof Error ? err.message : t("admin.centralCodex.error"),
{ cause: err },
);
showErrorToast(localizedApiError(err, t, "admin.centralCodex.error"), {
cause: err,
});
} finally {
setDisconnecting(false);
}
+5 -2
View File
@@ -22,6 +22,7 @@ import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { apiDelete } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { randomId } from "@/lib/random-id";
import {
recentProofErrorMessage,
@@ -81,8 +82,10 @@ export function DangerZoneSection() {
}),
);
},
onError: (err: Error) => {
setWipeMsg(recentProofErrorMessage(err, err.message));
onError: (err) => {
setWipeMsg(
recentProofErrorMessage(err, localizedApiError(err, t, "common.error")),
);
},
});
+2 -3
View File
@@ -7,6 +7,7 @@ import { ConfirmButton } from "@/components/ui/confirm-button";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { showErrorToast } from "@/lib/errors/error-toast";
import { apiDelete } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { formatDate } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -90,9 +91,7 @@ function DeleteUserButton({ user }: { user: AdminUser }) {
onError: (err) => {
const message = recentProofErrorMessage(
err,
err instanceof Error && err.message
? err.message
: t("admin.section.users.deleteUserFailed"),
localizedApiError(err, t, "admin.section.users.deleteUserFailed"),
);
if (message) showErrorToast(message, { cause: err });
},
+11 -9
View File
@@ -43,8 +43,13 @@ import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import { getApiErrorMessage } from "./_shared";
import {
ApiError,
apiFetchRaw,
apiGet,
throwApiError,
} from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import {
recentProofErrorMessage,
throwIfReproofRequired,
@@ -133,12 +138,7 @@ export function EncryptionSection() {
}),
),
);
if (!res.ok) {
if (res.status === 401) {
throw new Error(t("admin.section.encryption.stepUpNeeded"));
}
throw new Error(await getApiErrorMessage(res));
}
if (!res.ok) await throwApiError(res);
return res.json();
},
onSuccess: () => {
@@ -150,7 +150,9 @@ export function EncryptionSection() {
onError: (err: Error) => {
const message = recentProofErrorMessage(
err,
err.message || t("admin.section.encryption.rotateFailed"),
err instanceof ApiError && err.status === 401
? t("admin.section.encryption.stepUpNeeded")
: localizedApiError(err, t, "admin.section.encryption.rotateFailed"),
);
if (message === null) return;
showErrorToast(message, { cause: err });
+2 -3
View File
@@ -20,6 +20,7 @@ import {
ConfiguredBadge,
} from "./_shared";
import { apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { queryKeys } from "@/lib/query-keys";
export function GlitchtipSection() {
@@ -113,9 +114,7 @@ export function GlitchtipSection() {
},
onError: (error) => {
showErrorToast(
error instanceof Error
? error.message
: t("admin.monitoringTestFailed"),
localizedApiError(error, t, "admin.monitoringTestFailed"),
{ cause: error },
);
void queryClient.invalidateQueries({
@@ -33,12 +33,12 @@ import {
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import { apiFetchRaw, apiGet, throwApiError } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { formatBytes } from "@/lib/format/bytes";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { MeasurementMaintenanceStatus } from "@/lib/jobs/measurement-maintenance-status";
import { queryKeys } from "@/lib/query-keys";
import { getApiErrorMessage } from "./_shared";
const ENDPOINT = "/api/admin/maintenance/measurements";
@@ -70,7 +70,7 @@ export function MeasurementMaintenanceCard() {
headers: { "content-type": "application/json" },
body: JSON.stringify({ vacuum: true, reindex: true }),
});
if (!res.ok) throw new Error(await getApiErrorMessage(res));
if (!res.ok) await throwApiError(res);
return ((await res.json()) as { data: { enqueued: boolean } }).data;
},
onSuccess: ({ enqueued }) => {
@@ -80,9 +80,9 @@ export function MeasurementMaintenanceCard() {
queryKey: queryKeys.adminMeasurementMaintenance(),
});
},
onError: (err: Error) => {
onError: (err) => {
showErrorToast(
err.message || t("admin.section.maintenance.startFailed"),
localizedApiError(err, t, "admin.section.maintenance.startFailed"),
{ cause: err },
);
},
@@ -23,6 +23,7 @@ import {
import type { ModuleKey } from "@/lib/modules/registry";
import { SettingsToggle } from "./_shared";
import { apiGet, apiPatch } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { InlineAction } from "@/components/ui/inline-action";
/**
@@ -76,12 +77,9 @@ function useUpdateModuleAvailability() {
toast.success(t("common.saved"));
},
onError: (err) => {
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.modules.saveError"),
{ cause: err },
);
showErrorToast(localizedApiError(err, t, "admin.modules.saveError"), {
cause: err,
});
client.invalidateQueries({
queryKey: queryKeys.adminModuleAvailability(),
});
@@ -8,6 +8,7 @@ import { Badge } from "@/components/ui/badge";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { showErrorToast } from "@/lib/errors/error-toast";
import { apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { formatDate } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -41,9 +42,7 @@ export function PendingDeletionControls({ user }: { user: AdminUser }) {
},
onError: (err) => {
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.section.users.undoDeletionFailed"),
localizedApiError(err, t, "admin.section.users.undoDeletionFailed"),
{ cause: err },
);
},
+23 -20
View File
@@ -32,7 +32,13 @@ import {
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import {
ApiError,
apiFetchRaw,
apiGet,
throwApiError,
} from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -77,23 +83,7 @@ export function PulseDayMeanCard() {
headers: { "content-type": "application/json" },
body: JSON.stringify({ action }),
});
if (!res.ok) {
let code: string | undefined;
try {
const body = (await res.json()) as { meta?: { errorCode?: string } };
code = body.meta?.errorCode;
} catch {
// No envelope: the generic message below.
}
// The route's two refusals each have their own message.
throw new Error(
code === "maintenance.pulseDayMean.busy"
? t("admin.section.pulseDayMean.error.busy")
: code === "maintenance.pulseDayMean.dryRunRequired"
? t("admin.section.pulseDayMean.error.dryRunRequired")
: t("admin.section.pulseDayMean.error.generic"),
);
}
if (!res.ok) await throwApiError(res);
const { enqueued } = (
(await res.json()) as { data: { enqueued: boolean } }
).data;
@@ -117,8 +107,21 @@ export function PulseDayMeanCard() {
queryKey: queryKeys.adminPulseDayMean(),
});
},
onError: (err: Error) => {
showErrorToast(err.message, { cause: err });
// The route's two refusals each have their own message.
onError: (err) => {
const code = err instanceof ApiError ? err.meta?.errorCode : undefined;
showErrorToast(
code === "maintenance.pulseDayMean.busy"
? t("admin.section.pulseDayMean.error.busy")
: code === "maintenance.pulseDayMean.dryRunRequired"
? t("admin.section.pulseDayMean.error.dryRunRequired")
: localizedApiError(
err,
t,
"admin.section.pulseDayMean.error.generic",
),
{ cause: err },
);
},
});
+12 -7
View File
@@ -32,6 +32,7 @@ import { NativeSelect } from "@/components/ui/native-select";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ApiError, apiFetch, apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -264,11 +265,14 @@ function CheckMyCopy({ kit }: { kit: RecoveryKit }) {
setCheckResult(data.matches);
setCandidate("");
},
onError: (err: Error) => {
onError: (err) => {
setCheckResult(null);
showErrorToast(err.message || t("admin.recoveryKit.checkFailed"), {
cause: err,
});
showErrorToast(
localizedApiError(err, t, "admin.recoveryKit.checkFailed"),
{
cause: err,
},
);
},
});
@@ -395,9 +399,10 @@ export function RecoveryKitCard() {
await invalidate();
return;
}
showErrorToast(err.message || t("admin.recoveryKit.confirmFailed"), {
cause: err,
});
showErrorToast(
localizedApiError(err, t, "admin.recoveryKit.confirmFailed"),
{ cause: err },
);
},
});
+5 -7
View File
@@ -23,6 +23,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { useAdminSettings, useUpdateSettings } from "./_shared";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import {
describeChannelTestFailures,
type ChannelTestResult,
@@ -163,9 +164,7 @@ export function RemindersSection() {
},
onError: (error) => {
showErrorToast(
error instanceof Error
? error.message
: t("admin.notificationTestFailed"),
localizedApiError(error, t, "admin.notificationTestFailed"),
{ cause: error },
);
},
@@ -201,10 +200,9 @@ export function RemindersSection() {
toast.success(data?.message ?? t("admin.reminderCheckSuccess"));
},
onError: (error) => {
showErrorToast(
error instanceof Error ? error.message : t("admin.reminderCheckFailed"),
{ cause: error },
);
showErrorToast(localizedApiError(error, t, "admin.reminderCheckFailed"), {
cause: error,
});
},
});
+2 -3
View File
@@ -19,6 +19,7 @@ import {
ConfiguredBadge,
} from "./_shared";
import { apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
export function UmamiSection() {
const { t } = useTranslations();
@@ -52,9 +53,7 @@ export function UmamiSection() {
},
onError: (error) => {
showErrorToast(
error instanceof Error
? error.message
: t("admin.monitoringTestFailed"),
localizedApiError(error, t, "admin.monitoringTestFailed"),
{ cause: error },
);
},
@@ -41,7 +41,8 @@ import { queryKeys } from "@/lib/query-keys";
import { type AdminUser, PasswordInput } from "./_shared";
import { PendingDeletionControls } from "./pending-deletion-controls";
import { DeleteUserControl } from "./delete-user-control";
import { apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
import { ApiError, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import {
recentProofErrorMessage,
useRecentProof,
@@ -109,12 +110,9 @@ export function UserManagementSection() {
toast.success(t("common.saved"));
},
onError: (err) => {
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.settingsSaveError"),
{ cause: err },
);
showErrorToast(localizedApiError(err, t, "admin.settingsSaveError"), {
cause: err,
});
},
});
@@ -132,8 +130,17 @@ export function UserManagementSection() {
setResetMsg(t("admin.passwordReset"));
setResetPassword("");
},
onError: (err: Error) => {
setResetMsg(recentProofErrorMessage(err, err.message));
// The server words its password verdict (too weak, found in a breach) in
// the account's language; any other refusal reads through the catalogue.
onError: (err) => {
setResetMsg(
recentProofErrorMessage(
err,
err instanceof ApiError && err.status === 422
? err.message
: localizedApiError(err, t, "common.error"),
),
);
},
});
@@ -156,9 +163,7 @@ export function UserManagementSection() {
},
onError: (err) => {
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.section.users.forceLogoutFailed"),
localizedApiError(err, t, "admin.section.users.forceLogoutFailed"),
{ cause: err },
);
},
@@ -560,7 +565,11 @@ export function UserManagementSection() {
</div>
{updateUser.isError && (
<p className="text-destructive text-sm">
{(updateUser.error as Error).message}
{localizedApiError(
updateUser.error,
t,
"admin.settingsSaveError",
)}
</p>
)}
<SettingsCardActions>
@@ -0,0 +1,59 @@
/**
* The BMI chart divides the weights on screen by the height of the record on
* screen. It used to know only the viewer's height, so inside another
* person's record it drew nothing at all.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const h = vi.hoisted(() => ({
inSharedRecord: false,
recordHeightCm: 120 as number | null,
enabled: [] as boolean[],
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ isAuthenticated: true, user: { heightCm: 180 } }),
}));
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => ({ inSharedRecord: h.inSharedRecord }),
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: (options: { enabled: boolean }) => {
h.enabled.push(options.enabled);
return options.enabled
? { data: { profile: { heightCm: h.recordHeightCm } } }
: { data: undefined };
},
}));
import { useBmiDivisor } from "../health-chart/use-bmi-divisor";
describe("useBmiDivisor", () => {
beforeEach(() => {
h.inSharedRecord = false;
h.recordHeightCm = 120;
h.enabled.length = 0;
});
it("uses the viewer's own height in their own record", () => {
expect(useBmiDivisor("bmi")).toBeCloseTo(3.24);
expect(h.enabled).toEqual([false]);
});
it("uses the record's height inside somebody else's record", () => {
h.inSharedRecord = true;
expect(useBmiDivisor("bmi")).toBeCloseTo(1.44);
});
it("draws no BMI when the record has no height", () => {
h.inSharedRecord = true;
h.recordHeightCm = null;
expect(useBmiDivisor("bmi")).toBeNull();
});
it("asks for nothing outside the BMI view", () => {
h.inSharedRecord = true;
expect(useBmiDivisor("raw")).toBeNull();
expect(h.enabled).toEqual([false]);
});
});
+2 -10
View File
@@ -19,7 +19,6 @@ import {
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { useChartOverlayPrefs } from "@/hooks/use-chart-overlay-prefs";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useViewportWidth } from "@/hooks/use-viewport-width";
import { axisTickUnitSuffix } from "@/lib/charts/axis-unit";
import { makeBucketLabelFormatters } from "@/lib/charts/bucket-label";
@@ -61,6 +60,7 @@ import {
useHealthChartLabels,
useHealthChartOverlays,
} from "./health-chart/use-health-chart-overlays";
import { useBmiDivisor } from "./health-chart/use-bmi-divisor";
import { useHealthChartSeries } from "./health-chart/use-health-chart-series";
import { useHealthChartView } from "./health-chart/use-health-chart-view";
import { resolveMiniRangePoints } from "./mini-window";
@@ -157,15 +157,7 @@ export function HealthChart({
// Viewport-aware tick density: 4 to 10 labels, whatever the point count.
const viewportWidth = useViewportWidth();
// `useAuth()` is the person at the keyboard, and under a switch that is the
// delegate: dividing the owner's weights by the delegate's height squared
// gives a plausible BMI that is somebody else's. Inside a shared record the
// chart draws no BMI rather than a wrong one.
const { inSharedRecord } = useRecordCapabilities();
const bmiDivisor =
valueMode === "bmi" && !inSharedRecord && user?.heightCm
? (user.heightCm / 100) ** 2
: null;
const bmiDivisor = useBmiDivisor(valueMode);
const { data, isLoading, isError, error, isPlaceholderData, refetch } =
useHealthChartSeries({
@@ -0,0 +1,36 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
/**
* The BMI view's divisor: the height of the record on screen, in metres,
* squared. `useAuth()` is the person at the keyboard, and under a switch that
* is the delegate, so inside somebody else's record the height comes from
* `/api/insights/targets`, the route that knows which record is on screen
* (the BMI page and the reference panel read the same cell). No height, no
* BMI.
*/
export function useBmiDivisor(
valueMode: "raw" | "bmi" | undefined,
): number | null {
const { isAuthenticated, user } = useAuth();
const { inSharedRecord } = useRecordCapabilities();
const { data: targets } = useQuery({
queryKey: queryKeys.insightsTargets(),
queryFn: () =>
apiGet<{ profile?: { heightCm: number | null } }>(
"/api/insights/targets",
),
enabled: isAuthenticated && valueMode === "bmi" && inSharedRecord,
});
if (valueMode !== "bmi") return null;
const heightCm = inSharedRecord
? (targets?.profile?.heightCm ?? null)
: (user?.heightCm ?? null);
return heightCm ? (heightCm / 100) ** 2 : null;
}
@@ -42,11 +42,10 @@ const FULL_RECORD = {
end: "2026-01-31T00:00:00.000Z",
},
patient: {
username: "shared-account",
name: "A. Patient",
dateOfBirth: "1980-01-01T00:00:00.000Z",
gender: "FEMALE",
heightCm: 170,
fullName: "A. Patient",
},
practiceName: null,
measurements: {},
@@ -27,7 +27,7 @@ function makeReport(
end: "2026-01-31T00:00:00.000Z",
},
patient: {
username: "tester",
name: "tester",
dateOfBirth: null,
gender: null,
heightCm: null,
@@ -72,6 +72,7 @@ function render(
| "selection"
| "timeFormat"
| "dateFormat"
| "timezone"
>
>,
) {
@@ -501,6 +502,45 @@ describe("<ClinicianView>", () => {
expect(html).not.toContain('<img src="/c/hls_tok/d/doc-b"');
expect(html).toContain("Referral letter");
});
it("prints the date of birth on its own day west of UTC", () => {
const html = render(
makeReport({
patient: {
name: "A. Patient",
// Stored at UTC midnight: 13 May, 20:00 in New York.
dateOfBirth: "1960-05-14T00:00:00.000Z",
gender: null,
heightCm: null,
},
}),
{ timezone: "America/New_York", locale: "en" },
);
expect(html).toContain("05/14/1960");
expect(html).not.toContain("05/13/1960");
});
it("dates a vaccination on its own day west of UTC", () => {
const html = render(
makeReport({
immunizations: [
{
// A dose date at UTC midnight: the evening before in New York.
occurredAt: "2026-04-11T00:00:00.000Z",
antigenSlug: null,
vaccineName: "Influenza",
lotNumber: null,
site: null,
practitionerName: null,
series: [],
},
],
}),
{ timezone: "America/New_York", locale: "en" },
);
expect(html).toContain("04/11/2026");
expect(html).not.toContain("04/10/2026");
});
});
/**
+12 -2
View File
@@ -30,6 +30,7 @@
* document list in `./documents-list`, the downloads in `./download-actions`.
*/
import type { DoctorReportData } from "@/lib/doctor-report-data";
import { formatDate } from "@/lib/date-format";
import { formatLabValue, type LabNumberFormat } from "@/lib/labs/format-value";
import {
makeFormatters,
@@ -133,6 +134,15 @@ interface ClinicianViewProps {
dateFormat: DateFormatPreference;
}
/**
* A date-only value (a date of birth, a dose date) as its calendar date in
* the owner's date order, the same in every zone. Read as an instant in the
* owner's zone it fell on the previous day west of UTC.
*/
function calendarDateIn(dateFormat: DateFormatPreference, locale: Locale) {
return (iso: string) => formatDate(iso.slice(0, 10), dateFormat, locale);
}
export function ClinicianView({
t,
label,
@@ -199,7 +209,7 @@ export function ClinicianView({
t={t}
report={report}
scope={scope}
fmtDate={fmtDate}
fmtCalendarDate={calendarDateIn(dateFormat, locale)}
/>
{/* ── measurements ─────────────────────────────────────── */}
@@ -298,7 +308,7 @@ export function ClinicianView({
t={t}
report={report}
scope={scope}
fmtDate={fmtDate}
fmtCalendarDate={calendarDateIn(dateFormat, locale)}
/>
<AllergiesSection t={t} report={report} scope={scope} />
@@ -302,12 +302,13 @@ export function ImmunizationsSection({
t,
report,
scope,
fmtDate,
fmtCalendarDate,
}: {
t: Translate;
report: DoctorReportData;
scope: LeafScope;
fmtDate: (iso: string) => string;
/** A dose date is a calendar date at UTC midnight, never an instant. */
fmtCalendarDate: (iso: string) => string;
}) {
const doses = report.immunizations ?? [];
@@ -342,7 +343,7 @@ export function ImmunizationsSection({
: (dose.vaccineName ?? "—")
}
value={compose([
fmtDate(dose.occurredAt),
fmtCalendarDate(dose.occurredAt),
doseDisplay(dose.series),
dose.lotNumber
? `${t("doctorReport.immunizationsColLot")} ${dose.lotNumber}`
@@ -48,15 +48,15 @@ export function PatientIdentitySection({
t,
report,
scope,
fmtDate,
fmtCalendarDate,
}: {
t: Translate;
report: DoctorReportData;
scope: LeafScope;
fmtDate: (iso: string) => string;
fmtCalendarDate: (iso: string) => string;
}) {
const patient = report.patient;
const name = patient.fullName ?? patient.username ?? null;
const name = patient.name;
const genderKey = patient.gender
? GENDER_LABEL_KEYS[patient.gender]
: undefined;
@@ -67,7 +67,7 @@ export function PatientIdentitySection({
if (patient.dateOfBirth) {
rows.push({
label: t("doctorReport.dateOfBirth"),
value: fmtDate(patient.dateOfBirth),
value: fmtCalendarDate(patient.dateOfBirth),
});
}
if (genderKey) {
@@ -11,7 +11,8 @@ import { DateTimeField } from "@/components/ui/date-time-field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { ApiError, apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { useIdempotentPost } from "@/lib/api/use-idempotent-post";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -55,6 +56,7 @@ export function CustomMetricEntryForm({
const { t } = useTranslations();
const formId = useId();
const queryClient = useQueryClient();
const post = useIdempotentPost();
const [value, setValue] = useState("");
const [measuredAt, setMeasuredAt] = useState(() => nowLocal());
@@ -85,7 +87,7 @@ export function CustomMetricEntryForm({
setSubmitting(true);
try {
await apiPost<CustomMetricEntryDto>(
await post<CustomMetricEntryDto>(
`/api/custom-metrics/${customMetricId}/entries`,
body,
);
@@ -99,11 +101,7 @@ export function CustomMetricEntryForm({
});
onSuccess?.();
} catch (err) {
const message =
err instanceof ApiError
? err.message
: t("customMetrics.entry.saveError");
setError(message);
setError(localizedApiError(err, t, "customMetrics.entry.saveError"));
} finally {
setSubmitting(false);
}
@@ -10,7 +10,9 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { ApiError, apiPatch, apiPost } from "@/lib/api/api-fetch";
import { ApiError, apiPatch } from "@/lib/api/api-fetch";
import { useIdempotentPost } from "@/lib/api/use-idempotent-post";
import { localizedApiError } from "@/lib/api/localized-error";
import { useTranslations } from "@/lib/i18n/context";
import type { CustomMetricDto } from "./types";
@@ -33,6 +35,18 @@ function parseInteger(raw: string): number | null {
return Number.isInteger(n) && n >= 0 ? n : null;
}
/**
* Update the metric, or define a new one under one Idempotency-Key until the
* create lands, so a retry after a timeout is not a second metric.
*/
function useSaveCustomMetric(existing: CustomMetricDto | undefined) {
const post = useIdempotentPost();
return (body: unknown) =>
existing
? apiPatch<CustomMetricDto>(`/api/custom-metrics/${existing.id}`, body)
: post<CustomMetricDto>("/api/custom-metrics", body);
}
interface CustomMetricFormProps {
/** When set the form edits this metric; otherwise it defines a new one. */
existing?: CustomMetricDto;
@@ -46,6 +60,17 @@ interface CustomMetricFormProps {
footerSlot?: HTMLElement | null;
}
/**
* The sentence for a refused save. A name clash is read by its code: a 409
* can also be the first save still running under the same key.
*/
function saveRefusal(err: unknown, t: (key: string) => string): string {
return err instanceof ApiError &&
err.meta?.errorCode === "customMetric.name_taken"
? t("customMetrics.form.nameTaken")
: localizedApiError(err, t, "customMetrics.form.saveError");
}
/**
* v1.25.5 — define or edit a custom metric.
*
@@ -79,6 +104,7 @@ export function CustomMetricForm({
);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const save = useSaveCustomMetric(existing);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
@@ -109,12 +135,7 @@ export function CustomMetricForm({
setSubmitting(true);
try {
const saved = existing
? await apiPatch<CustomMetricDto>(
`/api/custom-metrics/${existing.id}`,
body,
)
: await apiPost<CustomMetricDto>("/api/custom-metrics", body);
const saved = await save(body);
toast.success(
existing
? t("customMetrics.form.updatedToast")
@@ -122,11 +143,7 @@ export function CustomMetricForm({
);
onSuccess?.(saved);
} catch (err) {
const message =
err instanceof ApiError
? err.message
: t("customMetrics.form.saveError");
setError(message);
setError(saveRefusal(err, t));
} finally {
setSubmitting(false);
}
@@ -22,7 +22,8 @@ import {
} from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { useTableSort } from "@/hooks/use-table-sort";
import { ApiError, apiDelete, apiPatch, apiPost } from "@/lib/api/api-fetch";
import { apiDelete, apiPatch, apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { formatDateShort } from "@/lib/format";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -140,11 +141,7 @@ export function CustomMetricHistoryList({
closeEdit();
},
onError: (err) => {
setEditError(
err instanceof ApiError
? err.message
: t("customMetrics.entry.editError"),
);
setEditError(localizedApiError(err, t, "customMetrics.entry.editError"));
},
});
@@ -42,7 +42,11 @@ const idleQuery = {
const mutationStub = { mutate: vi.fn(), isPending: false, isError: false };
vi.mock("../use-cycle", () => ({
localYmd: () => "2026-07-01",
useCycleWindow: () => ({
today: "2026-07-01",
from: "2026-04-02",
to: "2026-12-28",
}),
useCycleCalendar: () => errorQuery,
useCycleHistory: () => idleQuery,
useCycleInsights: () => idleQuery,
@@ -95,7 +95,7 @@ const idleQuery = {
const mutationStub = { mutate: vi.fn(), isPending: false, isError: false };
vi.mock("../use-cycle", () => ({
localYmd: () => TODAY,
useCycleWindow: () => ({ today: TODAY, from: TODAY, to: TODAY }),
useCycleCalendar: () => calendarQuery,
useCycleHistory: () => idleQuery,
useCycleInsights: () => idleQuery,
@@ -16,14 +16,14 @@ import { describe, expect, it } from "vitest";
import { ApiError } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import { localYmd, useCycleRingDial } from "../use-cycle";
import { DEFAULT_TIMEZONE, shiftDateKey, userDayKey } from "@/lib/tz/format";
import { useCycleRingDial } from "../use-cycle";
/** The ring's window, around today in the display zone (Berlin when unset). */
function window(): [string, string] {
const from = new Date();
from.setDate(from.getDate() - 90);
const to = new Date();
to.setDate(to.getDate() + 180);
return [localYmd(from), localYmd(to)];
const today = userDayKey(new Date(), DEFAULT_TIMEZONE);
return [shiftDateKey(today, -90), shiftDateKey(today, 180)];
}
function Probe({ enabled }: { enabled: boolean }) {
@@ -0,0 +1,33 @@
/**
* The cycle surfaces take "today" from the record's display zone, the zone
* the server keys cycle days in. They used to read the device clock, so with
* a phone in another zone than the profile the highlighted day and the day a
* log lands on sat one apart from the server's around midnight.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@/lib/i18n/context", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/i18n/context")>()),
// UTC+14: already the next calendar day for most of the world's afternoon.
useDisplayTimezone: () => "Pacific/Kiritimati",
}));
import { useCycleWindow } from "../use-cycle";
function Probe() {
const { today, from, to } = useCycleWindow();
return <span>{`${from}|${today}|${to}`}</span>;
}
afterEach(() => vi.useRealTimers());
describe("useCycleWindow", () => {
it("dates today in the display zone, not on the device clock", () => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2026-07-01T12:00:00.000Z"));
expect(renderToStaticMarkup(<Probe />)).toContain(
"2026-04-03|2026-07-02|2026-12-29",
);
});
});
@@ -1,6 +1,5 @@
"use client";
import { useMemo } from "react";
import Link from "next/link";
import { ArrowRight, Droplets } from "lucide-react";
@@ -8,7 +7,11 @@ import { useTranslations } from "@/lib/i18n/context";
import { SectionHeading } from "@/components/ui/section-heading";
import { CyclePhaseHeadline } from "./cycle-phase-crosstab";
import { PHASE_HUE } from "./phase-tokens";
import { localYmd, useCycleCalendar, useCycleInsights } from "./use-cycle";
import {
useCycleCalendar,
useCycleInsights,
useCycleWindow,
} from "./use-cycle";
import { QueryErrorRow } from "@/components/ui/query-error-row";
/**
@@ -37,20 +40,12 @@ import { QueryErrorRow } from "@/components/ui/query-error-row";
* a broken or empty-framed state on the overview.
*/
/** YYYY-MM-DD for `n` days from now in the local tz (shares `localYmd`). */
function shiftToday(days: number): string {
const d = new Date();
d.setDate(d.getDate() + days);
return localYmd(d);
}
export function CycleInsightSummaryCard() {
const { t } = useTranslations();
// Mirror the cycle-view window so the cache key is shared (no extra request
// when the user later opens the cycle page in the same session).
const from = useMemo(() => shiftToday(-90), []);
const to = useMemo(() => shiftToday(180), []);
const { from, to } = useCycleWindow();
const calendar = useCycleCalendar(from, to);
const insights = useCycleInsights();
+2 -11
View File
@@ -27,7 +27,7 @@ import { CycleSettingsTab } from "./cycle-settings-tab";
import { PHASE_HUE } from "./phase-tokens";
import { monthOf, monthWindowOutside } from "./calendar-window";
import {
localYmd,
useCycleWindow,
useCycleCalendar,
useCycleHistory,
useCycleInsights,
@@ -50,13 +50,6 @@ import { ListCapNotice } from "@/components/ui/list-cap-notice";
* re-triggers the moment.
*/
/** YYYY-MM-DD for `n` days from now in the local tz (shares `localYmd`). */
function shiftToday(days: number): string {
const d = new Date();
d.setDate(d.getDate() + days);
return localYmd(d);
}
/** The four cycle tabs, in render order. The deep-link value is validated
* against this set so a junk `?tab=` falls back to the calendar tab. */
// The BBT chart is the only recharts consumer on /cycle — load it through
@@ -114,9 +107,7 @@ export function CycleView() {
// a later param change does not force-switch a tab the user is reading).
const [tab, setTab] = useState<CycleTab>(initialTab);
const today = useMemo(() => shiftToday(0), []);
const from = useMemo(() => shiftToday(-90), []);
const to = useMemo(() => shiftToday(180), []);
const { today, from, to } = useCycleWindow();
const calendar = useCycleCalendar(from, to);
const history = useCycleHistory();
+20 -23
View File
@@ -21,7 +21,8 @@ import {
apiPatch,
apiPost,
} from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { useDisplayTimezone, useTranslations } from "@/lib/i18n/context";
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
import { randomId } from "@/lib/random-id";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { useCalendarDate } from "@/hooks/use-calendar-date";
@@ -72,16 +73,23 @@ export interface CycleInsightsResponse {
}
/**
* The local-timezone `YYYY-MM-DD` for a Date (default: now). Cycle day-keys
* are tz-anchored on the server; the client must derive the key from the
* user's wall-clock day, never from `toISOString()` (which is UTC and rolls
* the date over near midnight).
* Today, and the window the cycle page, its ring and its summary card read
* (90 days back, 180 ahead), in the record's display zone. The server keys
* cycle days, "today" included, in the profile zone; the device clock put
* the highlighted day and the day a log lands on one apart from it when a
* phone sat in another zone near midnight. One window, so the three readers
* share one cache entry.
*/
export function localYmd(d: Date = new Date()): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
export function useCycleWindow(): { today: string; from: string; to: string } {
const timeZone = useDisplayTimezone();
return useMemo(() => {
const today = userDayKey(new Date(), timeZone);
return {
today,
from: shiftDateKey(today, -90),
to: shiftDateKey(today, 180),
};
}, [timeZone]);
}
/**
@@ -115,20 +123,9 @@ export function useCycleCalendar(from: string, to: string, enabled = true) {
});
}
/** YYYY-MM-DD for `days` from today in the local zone. */
function localYmdFromToday(days: number): string {
const d = new Date();
d.setDate(d.getDate() + days);
return localYmd(d);
}
/**
* The calendar window the cycle ring reads: the cycle page's own window, so
* the ring, the page and the summary card share one cache entry.
*/
/** The calendar window the cycle ring reads: the cycle page's own. */
export function useCycleRingCalendar(enabled = true) {
const from = useMemo(() => localYmdFromToday(-90), []);
const to = useMemo(() => localYmdFromToday(180), []);
const { from, to } = useCycleWindow();
return useCycleCalendar(from, to, enabled);
}
@@ -393,21 +393,22 @@ describe("submit invalidation — the quick-add is a capture-picker surface, not
);
it("routes the intake submit through the blessed invalidateMedicationReads (source pin)", () => {
expect(componentSrc).toContain(
"await invalidateMedicationReads(queryClient);",
);
expect(componentSrc).not.toContain(
"invalidateKeys(queryClient, medicationDependentKeys)",
);
// The submit handler pairs the blessed helper with the inline
// compliance-chart fan-out AFTER the intake POST landed.
// AFTER the intake POST landed, and after the sheet was told it is done:
// the save does not wait for the refetches before it closes.
expect(componentSrc).toMatch(
/async function handleSubmit\([\s\S]*?apiPost[\s\S]*?await invalidateMedicationReads\(queryClient\);[\s\S]*?complianceChartInline\(\)/,
/async function handleSubmit\([\s\S]*?await post<[\s\S]*?onSuccess\?\.\(\);\s*(?:\/\/[^\n]*\n\s*)*void invalidateMedicationReads\(queryClient\);/,
);
// The bundle carries every inline compliance chart, so no second sweep.
expect(medicationDependentKeys).toContainEqual(
queryKeys.complianceChartInline(),
);
});
it("forces the inactive Today hero + dashboard reads to refetch after submit", async () => {
// The helper the submit path awaits — asserted directly, same shape as
// The helper the submit path calls, asserted directly, same shape as
// the take-all-due suite: the bundle fans out (N keys), then the two
// daily reads are invalidated with `refetchType: "inactive"`, which is
// what actually refetches them while unmounted.
@@ -27,7 +27,9 @@ import {
} from "@/lib/medications/default-medication";
import { useAuth } from "@/hooks/use-auth";
import { useActiveRecordName } from "@/hooks/use-record-capabilities";
import { ApiError, apiGet, apiPost } from "@/lib/api/api-fetch";
import { apiGet } from "@/lib/api/api-fetch";
import { useIdempotentPost } from "@/lib/api/use-idempotent-post";
import { localizedApiError } from "@/lib/api/localized-error";
import {
intakeToastOptions,
runUndoIntake,
@@ -118,6 +120,7 @@ export function MedicationIntakeQuickAdd({
}: MedicationIntakeQuickAddProps) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const post = useIdempotentPost();
// v1.16.9 — the auto-pick reasons in the PROFILE timezone (the same
// source the cards use); Berlin stays the last-resort fallback so
// logged-out mounts behave unchanged.
@@ -225,7 +228,7 @@ export function MedicationIntakeQuickAdd({
// v1.30.1 — the route returns the created event
// (`apiSuccess(event, 201)`), same shape the card's take/skip path
// already reads; the id drives the new Undo action below.
const created = await apiPost<{ id?: string } | undefined>(
const created = await post<{ id?: string } | undefined>(
`/api/medications/${medicationId}/intake`,
{
takenAt: timestamp,
@@ -235,13 +238,6 @@ export function MedicationIntakeQuickAdd({
);
const eventId = created?.id;
await invalidateMedicationReads(queryClient);
// Fan-out to every inline compliance chart key (one per medication)
// so the detail-page tile refreshes when the user re-enters it.
await queryClient.invalidateQueries({
queryKey: queryKeys.complianceChartInline(),
});
// v1.36.x — the shared decision, not a fourth copy of it. This surface
// was left out of the collapse that took the other three: it offered a
// delegate an Undo hitting `DELETE /api/medications/{id}/intake/{id}`,
@@ -272,11 +268,16 @@ export function MedicationIntakeQuickAdd({
toast.success(t("common.saved"));
}
onSuccess?.();
// The dose is stored; the sheet does not wait for the refetches. The
// bundle covers every inline compliance chart as well.
void invalidateMedicationReads(queryClient);
} catch (err) {
setError(
err instanceof ApiError && err.message
? err.message
: t("dashboard.medicationIntakeQuickAdd.saveError"),
localizedApiError(
err,
t,
"dashboard.medicationIntakeQuickAdd.saveError",
),
);
} finally {
setLoading(false);
@@ -0,0 +1,34 @@
/**
* Confirming a document's facts creates medications, conditions and lab
* values. The confirm used to evict only the roots of those lists, so the
* dashboard, the Today digest, adherence, nutrients, the day and the timeline
* kept showing the record without them.
*/
import { QueryClient } from "@tanstack/react-query";
import { afterEach, describe, expect, it } from "vitest";
import { invalidateConfirmedFactReads } from "../use-document-facts";
import { queryKeys } from "@/lib/query-keys";
const client = new QueryClient();
afterEach(() => client.clear());
describe("invalidateConfirmedFactReads", () => {
it.each([
["the dashboard snapshot", queryKeys.dashboardSnapshot()],
["the Today digest", queryKeys.dailyDigest()],
["the nutrient reads", queryKeys.nutrientsRoot()],
["an open day", queryKeys.day("2026-11-10")],
["the timeline", queryKeys.timelineRoot()],
["the lab results", queryKeys.labResults()],
["the condition journal", queryKeys.illness()],
["the document", queryKeys.inboundDocument("doc-1")],
])("marks %s stale", async (_label, key) => {
client.setQueryData(key, { seeded: true });
await invalidateConfirmedFactReads(client, "doc-1");
expect(client.getQueryState(key)?.isInvalidated).toBe(true);
});
});
@@ -0,0 +1,84 @@
/**
* The document picker reads one page of the vault, the newest fifty. With a
* longer vault it listed those and said nothing, so an older report seemed
* not to exist unless one already knew to search for it.
*
* ResponsiveSheet portals its body, which static markup does not render, so it
* is mocked down to a wrapper that always shows its children.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
const page = vi.hoisted(() => ({ nextCursor: null as string | null }));
vi.mock("@/components/ui/responsive-sheet", () => ({
ResponsiveSheet: ({ children }: { children: React.ReactNode }) => (
<div data-slot="mock-sheet">{children}</div>
),
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: {
// A cut page is a full one.
documents: Array.from({ length: page.nextCursor ? 50 : 3 }, (_, i) => ({
id: `d${i}`,
kind: "LAB_RESULT",
title: `Blood panel ${i}`,
filename: null,
documentDate: "2026-10-01",
createdAt: "2026-10-01T08:00:00.000Z",
})),
nextCursor: page.nextCursor,
},
isPending: false,
isError: false,
}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import {
DocumentPickerCapNotice,
DocumentPickerSheet,
} from "../document-picker-sheet";
function render() {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<DocumentPickerSheet
open
onOpenChange={() => undefined}
title="Link documents"
description="Pick documents"
searchPlaceholder="Search"
emptyLabel="No documents"
isPicked={() => false}
onToggle={() => undefined}
/>
</I18nProvider>,
);
}
describe("DocumentPickerSheet", () => {
it("says the list is cut, and how to reach the rest", () => {
page.nextCursor = "d49";
const html = render();
expect(html).toContain('data-slot="list-cap-notice"');
expect(html).toContain("Showing the newest 50");
});
it("says nothing when every document is listed", () => {
page.nextCursor = null;
expect(render()).not.toContain('data-slot="list-cap-notice"');
});
it("asks for a narrower search, not a search, while one runs", () => {
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<DocumentPickerCapNotice cut count={50} searching />
</I18nProvider>,
);
expect(html).toContain("Narrow the search");
expect(html).not.toContain("Search to find");
});
});
@@ -18,7 +18,11 @@ import {
import { DOCUMENT_PURGE_GRACE_DAYS } from "@/lib/documents/inbound-constants";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
import {
documentDependentKeys,
invalidateKeys,
queryKeys,
} from "@/lib/query-keys";
import type {
DocumentSourceSystemValue,
InboundDocumentDetailDto,
@@ -265,7 +269,7 @@ function useDocumentWrites({
// A link written from this side changes what the visit's and the
// dose's own views list, so their reads refresh with the vault's.
void invalidateKeys(queryClient, [
queryKeys.documents(),
...documentDependentKeys,
...(input.encounterIds !== undefined ? [queryKeys.encounters()] : []),
...(input.vaccinationIds !== undefined
? [queryKeys.vaccinations()]
@@ -282,7 +286,7 @@ function useDocumentWrites({
mutationFn: (id: string) =>
apiPost(`/api/documents/inbound/${id}/restore`, {}),
onSuccess: () => {
void invalidateKeys(queryClient, [queryKeys.documents()]);
void invalidateKeys(queryClient, documentDependentKeys);
},
onError: (error) => {
const reason =
@@ -303,7 +307,7 @@ function useDocumentWrites({
mutationFn: (id: string) => apiDelete(`/api/documents/inbound/${id}`),
onSuccess: (_data, id) => {
onOpenChange(false);
void invalidateKeys(queryClient, [queryKeys.documents()]);
void invalidateKeys(queryClient, documentDependentKeys);
toast.success(t("documents.toast.deleted"), {
description: t("documents.toast.deletedHint", {
days: DOCUMENT_PURGE_GRACE_DAYS,
@@ -2,9 +2,10 @@
/**
* The vault document picker sheet: a `ResponsiveSheet` (bottom sheet on
* phones) with a debounced search over the person's own vault — the same
* recall path as the vault page, one page (50) of matches, the search input
* as the way to an older document — and one toggle row per document.
* phones) with a debounced search over the person's own vault (the same
* recall path as the vault page, one page of 50 matches with a line that
* says when more exist, the search input as the way to an older document)
* and one toggle row per document.
*
* Two pickers used to carry this whole body as copies: linking documents to
* an illness episode (each toggle writes at once) and choosing documents for
@@ -17,6 +18,7 @@ import { Check, Search } from "lucide-react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useDocumentDate } from "@/components/documents/use-document-date";
import { ListCapNotice } from "@/components/ui/list-cap-notice";
import { DOCUMENT_KIND_ICONS } from "@/components/documents/document-kind-meta";
import { EmptyState } from "@/components/ui/empty-state";
import { Input } from "@/components/ui/input";
@@ -97,6 +99,8 @@ export function DocumentPickerSheet({
});
const documents = list.data?.documents ?? [];
// More match than one page: say so, and that the search reaches the rest.
const cut = Boolean(list.data?.nextCursor);
return (
<ResponsiveSheet
@@ -188,7 +192,40 @@ export function DocumentPickerSheet({
})}
</ul>
)}
<DocumentPickerCapNotice
cut={cut && !list.isError}
count={documents.length}
searching={query !== ""}
/>
</div>
</ResponsiveSheet>
);
}
/**
* The line under a picker list cut at one page: the search reaches the rest,
* and while one runs, a narrower one does. Shared by the vault picker and
* the Coach's attachment picker.
*/
export function DocumentPickerCapNotice({
cut,
count,
searching,
}: {
cut: boolean;
count: number;
searching: boolean;
}) {
const { t } = useTranslations();
return (
<ListCapNotice
cap={{ truncated: cut, limit: count }}
message={t(
searching
? "documents.picker.cappedSearching"
: "documents.picker.capped",
{ count: String(count) },
)}
/>
);
}
@@ -46,6 +46,7 @@ import { useVaccinations } from "@/components/vaccinations/use-vaccinations";
import { useAuth } from "@/hooks/use-auth";
import { apiGet } from "@/lib/api/api-fetch";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { useCalendarDate } from "@/hooks/use-calendar-date";
import { queryKeys } from "@/lib/query-keys";
import type { EncounterDTO, EncounterListDTO } from "@/lib/encounters/dto";
import type { EncounterKind } from "@/generated/prisma/client";
@@ -161,6 +162,7 @@ export function DocumentRecordLinks({
}) {
const { t } = useTranslations();
const format = useFormatters();
const calendarDate = useCalendarDate();
const { user } = useAuth();
const vaccinationsOn = user?.modules?.vaccinations !== false;
const anchor = doc.documentDate ?? doc.reportDate;
@@ -186,10 +188,7 @@ export function DocumentRecordLinks({
const [visitIds, setVisitIds] = useState<string[] | null>(null);
const [doseIds, setDoseIds] = useState<string[] | null>(null);
const labels = {
suggested: t("links.picker.suggested"),
date: (iso: string) => format.date(iso),
};
const suggested = t("links.picker.suggested");
const visitOptions = recordOptions(
[...encounters.items]
@@ -203,7 +202,7 @@ export function DocumentRecordLinks({
href: visitHref(visit.id),
})),
anchor,
labels,
{ suggested, date: format.date },
);
const doseOptions = recordOptions(
(vaccinations.data?.vaccinations ?? []).map((dose) => ({
@@ -213,7 +212,7 @@ export function DocumentRecordLinks({
href: doseHref(dose.id),
})),
anchor,
labels,
{ suggested, date: calendarDate }, // a dose date is a calendar date
);
const showDoses = vaccinationsOn && doc.vaccinationLinks !== null;
@@ -324,7 +323,7 @@ export function DocumentRecordLinks({
</span>
{link.occurredAt ? (
<span className="text-muted-foreground shrink-0">
{format.date(link.occurredAt)}
{calendarDate(link.occurredAt)}
</span>
) : null}
</Link>
Loaded 100 of 384 files, more files were not shown because too many files have changed in this diff. Show more