mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
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:
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
|
||||
}
|
||||
}
|
||||
@@ -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"];
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" }));
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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")),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
Reference in new issue
Block a user