mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
Merge remote-tracking branch 'origin/main' into release/v1.47.5
# 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 # src/__tests__/sharing-surface-guard.test.ts
This commit is contained in:
commit
5ac998e4f0
112 files changed
+1816
-593
No files matched your search
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
|
||||
"count": 84,
|
||||
"total": 93809,
|
||||
"total": 93815,
|
||||
"entries": {
|
||||
"app/medications/page-client.tsx": 839,
|
||||
"components/charts/mood-chart.tsx": 1166,
|
||||
@@ -82,7 +82,7 @@
|
||||
"lib/search/search-records.ts": 996,
|
||||
"lib/sharing/grants.ts": 806,
|
||||
"lib/support/report.ts": 903,
|
||||
"lib/telegram-webhook-handlers.ts": 1313,
|
||||
"lib/telegram-webhook-handlers.ts": 1319,
|
||||
"lib/validations/inbound-documents.ts": 1018,
|
||||
"lib/withings/sync-sleep.ts": 884,
|
||||
"lib/withings/sync.ts": 933,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
|
||||
"count": 336,
|
||||
"total": 109383,
|
||||
"total": 109357,
|
||||
"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": 250,
|
||||
"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": 567,
|
||||
"app/auth/register/page.tsx :: RegisterPage": 285,
|
||||
@@ -171,7 +171,7 @@
|
||||
"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/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": 225,
|
||||
@@ -330,8 +330,8 @@
|
||||
"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": 582,
|
||||
"lib/telegram-webhook-handlers.ts :: handleTextMessage": 322,
|
||||
"lib/withings/sync-activity.ts :: syncUserActivity": 377,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "as never casts in tests, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.",
|
||||
"count": 523,
|
||||
"total": 5458,
|
||||
"total": 5456,
|
||||
"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,16 +185,16 @@
|
||||
"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": 37,
|
||||
"app/api/medications/intake/__tests__/route.test.ts": 59,
|
||||
@@ -202,14 +202,14 @@
|
||||
"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,
|
||||
@@ -236,14 +236,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,
|
||||
@@ -251,8 +251,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,
|
||||
|
||||
@@ -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": 2872,
|
||||
"total": 9153,
|
||||
"total": 9152,
|
||||
"entries": {
|
||||
"__tests__/account-payload-consumer-guard.test.ts": 2,
|
||||
"__tests__/acting-account-boundary-guard.test.ts": 7,
|
||||
@@ -172,9 +172,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,
|
||||
@@ -184,12 +184,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,
|
||||
@@ -197,9 +197,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,
|
||||
@@ -209,8 +209,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,
|
||||
@@ -220,11 +220,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,
|
||||
@@ -239,6 +238,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,
|
||||
@@ -257,10 +257,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,
|
||||
@@ -289,6 +289,11 @@
|
||||
"app/api/insights/breathing-screening/route.ts": 2,
|
||||
"app/api/insights/cards/__tests__/route.test.ts": 2,
|
||||
"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,
|
||||
@@ -300,19 +305,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,
|
||||
@@ -323,9 +323,9 @@
|
||||
"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": 11,
|
||||
@@ -352,7 +352,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,
|
||||
@@ -364,8 +364,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,
|
||||
@@ -385,13 +385,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,
|
||||
@@ -408,7 +408,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,
|
||||
@@ -420,10 +419,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,
|
||||
@@ -431,11 +430,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,
|
||||
@@ -452,9 +452,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,
|
||||
@@ -544,12 +544,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,
|
||||
@@ -651,19 +651,19 @@
|
||||
"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__/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,
|
||||
|
||||
@@ -469,17 +469,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"],
|
||||
},
|
||||
{
|
||||
@@ -487,9 +485,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"],
|
||||
},
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ 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 can put the pound
|
||||
* 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.
|
||||
*/
|
||||
|
||||
@@ -1557,7 +1557,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",
|
||||
@@ -2108,11 +2108,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: [],
|
||||
@@ -2315,9 +2310,9 @@ const ACTOR_ROUTES: Record<string, string> = {
|
||||
* 276 -> 278.
|
||||
*
|
||||
* The Insights presence read joins the record list and the manage literal:
|
||||
* the tab strip's gate over the overview, counts and flags only. 278 -> 280.
|
||||
* the tab strip's gate over the overview, counts and flags only. 277 -> 279.
|
||||
*/
|
||||
const FROZEN_ENTRY_COUNT = 280;
|
||||
const FROZEN_ENTRY_COUNT = 279;
|
||||
|
||||
/**
|
||||
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -132,7 +132,9 @@ 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[] = [],
|
||||
@@ -155,6 +157,14 @@ function wireMedicationFindFirst(
|
||||
}
|
||||
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(() => {
|
||||
@@ -168,7 +178,6 @@ beforeEach(() => {
|
||||
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);
|
||||
@@ -305,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();
|
||||
@@ -469,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);
|
||||
|
||||
@@ -487,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();
|
||||
@@ -642,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,
|
||||
@@ -675,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);
|
||||
});
|
||||
|
||||
@@ -717,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,
|
||||
@@ -767,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({
|
||||
@@ -912,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")));
|
||||
@@ -1224,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 });
|
||||
});
|
||||
|
||||
@@ -1232,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",
|
||||
@@ -1248,7 +1272,7 @@ describe("POST /api/telegram/webhook — record-only medications", () => {
|
||||
* silent, so a reminder button left in the chat, the /add list and the
|
||||
* "taken" keyword do nothing either.
|
||||
*/
|
||||
describe("POST /api/telegram/webhook — Medications module off", () => {
|
||||
describe("POST /api/telegram/webhook with the Medications module off", () => {
|
||||
function textUpdate(text: string) {
|
||||
return {
|
||||
update_id: 400,
|
||||
@@ -1299,7 +1323,7 @@ describe("POST /api/telegram/webhook — Medications module off", () => {
|
||||
* 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 — ended course", () => {
|
||||
describe("POST /api/telegram/webhook on an ended course", () => {
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const ENDED = {
|
||||
...MEDICATION,
|
||||
|
||||
@@ -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" } });
|
||||
@@ -337,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) {
|
||||
|
||||
@@ -23,6 +23,7 @@ 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: () => ({
|
||||
@@ -41,7 +42,10 @@ vi.mock("@tanstack/react-query", () => ({
|
||||
data:
|
||||
enabled === false
|
||||
? undefined
|
||||
: { profile: { heightCm: state.recordHeight } },
|
||||
: {
|
||||
targets: [{ type: "BMI", range: state.bmiRange }],
|
||||
profile: { heightCm: state.recordHeight },
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -61,6 +65,7 @@ describe("/insights/bmi height gate", () => {
|
||||
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", () => {
|
||||
@@ -77,4 +82,20 @@ describe("/insights/bmi height gate", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -56,15 +56,21 @@ export default function InsightsBmiPage() {
|
||||
const { data: targets } = useQuery({
|
||||
queryKey: queryKeys.insightsTargets(),
|
||||
queryFn: () =>
|
||||
apiGet<{ profile?: { heightCm: number | null } }>(
|
||||
"/api/insights/targets",
|
||||
),
|
||||
enabled: isAuthenticated && inSharedRecord,
|
||||
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
|
||||
@@ -140,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
|
||||
|
||||
@@ -12,11 +12,22 @@ describe("selectWeightBandRange", () => {
|
||||
it("builds the WHO band from the record's height", () => {
|
||||
const range = selectWeightBandRange({
|
||||
targets: [
|
||||
{ type: "WEIGHT", range: { min: 25.8, max: 34.7 }, source: "WHO BMI" },
|
||||
{ type: "WEIGHT", range: { min: 59.9, max: 81 }, source: "WHO BMI" },
|
||||
],
|
||||
profile: { heightCm: 118 },
|
||||
profile: { heightCm: 180 },
|
||||
});
|
||||
expect(range).toEqual(buildWeightRangeFromHeight(118));
|
||||
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", () => {
|
||||
|
||||
@@ -24,14 +24,16 @@ export interface WeightTargetsSlice {
|
||||
* 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, and with neither there is none.
|
||||
* 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 {
|
||||
if (!data) return null;
|
||||
const weight = data.targets.find((entry) => entry.type === "WEIGHT");
|
||||
if (weight?.source === "Custom" && weight.range) {
|
||||
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 },
|
||||
|
||||
@@ -76,6 +76,17 @@ function metricFields(existing: CustomMetricDto | undefined) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
@@ -150,11 +161,7 @@ export function CustomMetricForm({
|
||||
);
|
||||
onSuccess?.(saved);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof ApiError && err.status === 409
|
||||
? t("customMetrics.form.nameTaken")
|
||||
: localizedApiError(err, t, "customMetrics.form.saveError"),
|
||||
);
|
||||
setError(saveRefusal(err, t));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
@@ -408,7 +408,7 @@ describe("submit invalidation — the quick-add is a capture-picker surface, not
|
||||
});
|
||||
|
||||
it("forces the inactive Today hero + dashboard reads to refetch after submit", async () => {
|
||||
// The helper the submit path calls — 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.
|
||||
|
||||
@@ -37,7 +37,10 @@ vi.mock("@tanstack/react-query", () => ({
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { DocumentPickerSheet } from "../document-picker-sheet";
|
||||
import {
|
||||
DocumentPickerCapNotice,
|
||||
DocumentPickerSheet,
|
||||
} from "../document-picker-sheet";
|
||||
|
||||
function render() {
|
||||
return renderToStaticMarkup(
|
||||
@@ -60,12 +63,22 @@ 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="document-picker-capped"');
|
||||
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="document-picker-capped"');
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
/**
|
||||
* 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 with a line that
|
||||
* says when more exist, the search input as the way to an older 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
|
||||
@@ -18,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";
|
||||
@@ -191,17 +192,40 @@ export function DocumentPickerSheet({
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
{cut && !list.isError ? (
|
||||
<p
|
||||
data-slot="document-picker-capped"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("documents.picker.capped", {
|
||||
count: String(documents.length),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
<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) },
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -7,9 +7,9 @@ import type { CorrelationResult } from "@/lib/insights/correlations";
|
||||
* v1.4.20 — `<CorrelationCard>` rendering.
|
||||
*
|
||||
* Tests cover:
|
||||
* 1. OK status renders title + interpretation + source chip + disabled CTA.
|
||||
* 1. OK status renders title + interpretation + source chip.
|
||||
* 2. Insufficient status renders the empty-state instead.
|
||||
* 3. CTA is `disabled` and exposes the "Coming soon" tooltip via `title`.
|
||||
* 3. The card offers no placeholder action, only ones that work.
|
||||
* 4. Confidence chip surfaces high / moderate / low.
|
||||
* 5. Locale-aware copy (EN / DE).
|
||||
*/
|
||||
|
||||
@@ -56,14 +56,14 @@ describe("AttachmentPicker", () => {
|
||||
it("says the list is cut, and how to reach the rest", () => {
|
||||
page.nextCursor = "d49";
|
||||
const html = render();
|
||||
expect(html).toContain('data-slot="coach-attachment-picker-capped"');
|
||||
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;
|
||||
const html = render();
|
||||
expect(html).not.toContain('data-slot="coach-attachment-picker-capped"');
|
||||
expect(html).not.toContain('data-slot="list-cap-notice"');
|
||||
expect(
|
||||
html.match(/data-slot="coach-attachment-picker-item"/g),
|
||||
).toHaveLength(3);
|
||||
|
||||
@@ -15,6 +15,7 @@ import { cn } from "@/lib/utils";
|
||||
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
|
||||
|
||||
import { DOCUMENT_KIND_ICONS } from "@/components/documents/document-kind-meta";
|
||||
import { DocumentPickerCapNotice } from "@/components/documents/document-picker-sheet";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
|
||||
@@ -266,16 +267,11 @@ export function AttachmentPicker({
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
{cut && !list.isError ? (
|
||||
<p
|
||||
data-slot="coach-attachment-picker-capped"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("documents.picker.capped", {
|
||||
count: String(list.data?.documents.length ?? 0),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
<DocumentPickerCapNotice
|
||||
cut={cut && !list.isError}
|
||||
count={list.data?.documents.length ?? 0}
|
||||
searching={query !== ""}
|
||||
/>
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
|
||||
@@ -61,10 +61,15 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { MeasurementList } from "../measurement-list";
|
||||
import type { Measurement } from "../list/list-model";
|
||||
import type { UnitDisplay } from "@/hooks/use-unit-display";
|
||||
import { convertGlucose, toCanonicalMgdl } from "@/lib/glucose";
|
||||
import { getUnitForType } from "@/lib/measurements/unit-map";
|
||||
import { editSeedValue, storedValueFor } from "../list/use-measurement-edit";
|
||||
import {
|
||||
editSeedValue,
|
||||
outOfRangeMessage,
|
||||
storedValueFor,
|
||||
} from "../list/use-measurement-edit";
|
||||
import { rowDisplayFor } from "../list/use-measurement-row-display";
|
||||
|
||||
/** A metric reader on the given glucose unit, as the list's row display. */
|
||||
@@ -140,3 +145,35 @@ describe("MeasurementList — blood-glucose display unit", () => {
|
||||
expect(storedValueFor(display, glucose(95.48), 95.48)).toBe(95.48);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the out-of-range sentence", () => {
|
||||
const t = ((key: string, params?: Record<string, unknown>) =>
|
||||
`${key} ${JSON.stringify(params)}`) as unknown as Parameters<
|
||||
typeof outOfRangeMessage
|
||||
>[2];
|
||||
const reading = (type: string, unit: string): Measurement => ({
|
||||
id: "m1",
|
||||
type,
|
||||
value: 1,
|
||||
unit,
|
||||
source: "MANUAL",
|
||||
measuredAt: "2026-11-10T12:00:00.000Z",
|
||||
notes: null,
|
||||
});
|
||||
const shows = (unit: string) => ({
|
||||
rowDisplay: (m: { value: number }) => ({ value: m.value, unit }),
|
||||
});
|
||||
|
||||
it("names the unit where the type has one", () => {
|
||||
expect(
|
||||
outOfRangeMessage(shows("kg"), reading("WEIGHT", "kg"), t, String),
|
||||
).toMatch(/^measurements\.valueOutOfRange \{.*"unit":"kg"/);
|
||||
});
|
||||
|
||||
it("leaves the unit out for a score, which has no label", () => {
|
||||
// "Enter a value between 0 and 10 ." read with a space before the stop.
|
||||
expect(
|
||||
outOfRangeMessage(shows(""), reading("PAIN_NRS", "score"), t, String),
|
||||
).toBe('measurements.valueOutOfRangeNoUnit {"min":"0","max":"10"}');
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* A computed score offers neither edit nor delete in the measurement list;
|
||||
* a reading of the person's own, or one a device or provider sent, keeps
|
||||
* both.
|
||||
* A computed score offers neither edit nor delete in the measurement list,
|
||||
* and no selection checkbox for the bulk delete; a reading of the person's
|
||||
* own, or one a device or provider sent, keeps all three.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
@@ -15,6 +15,8 @@ vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { Measurement } from "../list-model";
|
||||
import { MeasurementCardList } from "../measurement-card-list";
|
||||
import { MeasurementTable } from "../measurement-table";
|
||||
import {
|
||||
MeasurementRowActions,
|
||||
type MeasurementRowContext,
|
||||
@@ -73,3 +75,63 @@ describe("MeasurementRowActions", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("the row checkbox", () => {
|
||||
const listRows: MeasurementRowContext = {
|
||||
...rows,
|
||||
display: {
|
||||
rowDisplay: (m: Measurement) => ({ value: m.value, unit: m.unit }),
|
||||
rowIdentityAttrs: () => ({}),
|
||||
} as unknown as MeasurementRowContext["display"],
|
||||
};
|
||||
const reading = (id: string, source: string): Measurement => ({
|
||||
id,
|
||||
type: "RECOVERY_SCORE",
|
||||
value: 85,
|
||||
unit: "score",
|
||||
source,
|
||||
measuredAt: "2026-11-10T12:00:00.000Z",
|
||||
notes: null,
|
||||
});
|
||||
const checkboxes = (html: string) =>
|
||||
html.split('aria-label="Select row"').length - 1;
|
||||
|
||||
it("is offered for a reading and not for a computed score, on both layouts", () => {
|
||||
const measurements = [reading("m1", "MANUAL"), reading("m2", "COMPUTED")];
|
||||
const wrap = (node: React.ReactNode) =>
|
||||
renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
expect(
|
||||
checkboxes(
|
||||
wrap(
|
||||
<MeasurementCardList measurements={measurements} rows={listRows} />,
|
||||
),
|
||||
),
|
||||
).toBe(1);
|
||||
expect(
|
||||
checkboxes(
|
||||
wrap(
|
||||
<MeasurementTable
|
||||
measurements={measurements}
|
||||
rows={listRows}
|
||||
filters={
|
||||
{
|
||||
sortBy: "measuredAt",
|
||||
sortDir: "desc",
|
||||
toggleSort: () => {},
|
||||
} as unknown as Parameters<typeof MeasurementTable>[0]["filters"]
|
||||
}
|
||||
list={
|
||||
{
|
||||
selectAll: "none",
|
||||
pageIds: [],
|
||||
onToggleSelectAll: () => {},
|
||||
} as unknown as Parameters<typeof MeasurementTable>[0]["list"]
|
||||
}
|
||||
/>,
|
||||
),
|
||||
),
|
||||
).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { DayDrillDown, SleepNightCaption } from "./day-drill-down";
|
||||
import {
|
||||
MeasurementRowActions,
|
||||
isServerComputed,
|
||||
rowState,
|
||||
typeLabel,
|
||||
type MeasurementRowContext,
|
||||
@@ -83,7 +84,7 @@ function MeasurementCard({
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
{!isGrouped && canManage && (
|
||||
{!isGrouped && !isServerComputed(m) && canManage && (
|
||||
// The checkbox owns its 44 px hit area through an `after`
|
||||
// hit-slop inside a plain layout `<div>`: a wrapper `<button>`
|
||||
// would nest a button in a button, invalid markup that failed
|
||||
|
||||
@@ -23,6 +23,7 @@ import { MEASUREMENT_TYPE_COLORS as TYPE_COLORS } from "../measurement-list-meta
|
||||
import { DayDrillDown, SleepNightCaption } from "./day-drill-down";
|
||||
import {
|
||||
MeasurementRowActions,
|
||||
isServerComputed,
|
||||
rowState,
|
||||
typeLabel,
|
||||
type MeasurementRowContext,
|
||||
@@ -154,8 +155,9 @@ function MeasurementTableRow({
|
||||
data-display-unit={isGrouped ? m.unit : rowDisplay(m).unit}
|
||||
>
|
||||
<TableCell className="pl-4">
|
||||
{/* A grouped row is not individually deletable: no checkbox. */}
|
||||
{!isGrouped && canManage && (
|
||||
{/* A grouped row is not individually deletable, and a computed score
|
||||
is the server's to rewrite: no checkbox for either. */}
|
||||
{!isGrouped && !isServerComputed(m) && canManage && (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => onToggleRow(m.id)}
|
||||
|
||||
@@ -103,20 +103,20 @@ export function editValueRange(
|
||||
type Translate = ReturnType<typeof useTranslations>["t"];
|
||||
|
||||
/** The sentence for a value outside its band, in the unit the input shows. */
|
||||
function outOfRangeMessage(
|
||||
export function outOfRangeMessage(
|
||||
display: Pick<MeasurementRowDisplay, "rowDisplay">,
|
||||
measurement: Measurement,
|
||||
t: Translate,
|
||||
formatNumber: (value: number) => string,
|
||||
): string {
|
||||
const range = editValueRange(display, measurement);
|
||||
return range
|
||||
? t("measurements.valueOutOfRange", {
|
||||
min: formatNumber(range.min),
|
||||
max: formatNumber(range.max),
|
||||
unit: range.unit,
|
||||
})
|
||||
: t("measurements.invalidValue");
|
||||
if (!range) return t("measurements.invalidValue");
|
||||
const bounds = { min: formatNumber(range.min), max: formatNumber(range.max) };
|
||||
// A score, rating, count or ratio has no unit label, which left a space
|
||||
// before the full stop.
|
||||
return range.unit
|
||||
? t("measurements.valueOutOfRange", { ...bounds, unit: range.unit })
|
||||
: t("measurements.valueOutOfRangeNoUnit", bounds);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from "@/lib/query-keys";
|
||||
|
||||
import { PAGE_SIZE, isGroupedRow, type Measurement } from "./list-model";
|
||||
import { isServerComputed } from "./row-actions";
|
||||
import type { MeasurementListFilters } from "./use-measurement-list-filters";
|
||||
|
||||
/**
|
||||
@@ -178,9 +179,10 @@ export function useMeasurementListData({
|
||||
});
|
||||
|
||||
// Synthetic day and night rows collapse many raw rows behind a `dayKey`
|
||||
// and are not individually deletable, so they are not selectable.
|
||||
// and are not individually deletable, and a computed score is rewritten
|
||||
// by the server, so neither is selectable.
|
||||
const pageIds = (data?.measurements ?? [])
|
||||
.filter((m) => !isGroupedRow(m))
|
||||
.filter((m) => !isGroupedRow(m) && !isServerComputed(m))
|
||||
.map((m) => m.id);
|
||||
const selectAll = selectAllState(selectedIds, pageIds);
|
||||
const selectedOnPage = selectedCountOnPage(selectedIds, pageIds);
|
||||
|
||||
@@ -117,11 +117,11 @@ describe("<DoneScreen> AI line", () => {
|
||||
);
|
||||
expect(line).not.toBeNull();
|
||||
expect(line![1]).toContain("AI is optional");
|
||||
expect(line![1]).toContain('href="/settings/ai"');
|
||||
// No sample briefing, no provider ladder, no one-tap offer.
|
||||
expect(html).not.toContain("Example");
|
||||
expect(html).not.toContain("Three ways to turn it on");
|
||||
expect(html).not.toMatch(/<button[^>]*>[^<]*Turn on/);
|
||||
// Body copy, not meta: the line is read, so it is not muted.
|
||||
expect(line![0]).not.toContain("text-muted-foreground");
|
||||
// One way on, the link, and no one-tap offer beside it.
|
||||
expect(html.split('href="/settings/ai"')).toHaveLength(2);
|
||||
expect(line![1]).not.toContain("<button");
|
||||
});
|
||||
|
||||
it("is left out when AI cannot be set up here", () => {
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
type BaselineFieldErrors,
|
||||
} from "@/components/onboarding/baseline-fields";
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
import { dateOfBirthKey } from "@/lib/tz/date-only";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import {
|
||||
resolveHeightUnitAdapter,
|
||||
type HeightDraft,
|
||||
@@ -59,6 +61,8 @@ interface BaselineInitialValues {
|
||||
heightCm: number | null;
|
||||
dateOfBirth: string | null;
|
||||
gender: string | null;
|
||||
/** The owner's zone, which a date of birth from the iOS app is read in. */
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
interface BaselineFormState {
|
||||
@@ -133,8 +137,12 @@ export function BaselineForm({
|
||||
const [form, setForm] = useState<BaselineFormState>(() => ({
|
||||
displayName: "",
|
||||
height: heightAdapter.toDraft(initial.heightCm),
|
||||
// The payload's ISO instant, as the date field's YYYY-MM-DD.
|
||||
dateOfBirth: initial.dateOfBirth ? initial.dateOfBirth.slice(0, 10) : "",
|
||||
dateOfBirth: initial.dateOfBirth
|
||||
? dateOfBirthKey(
|
||||
new Date(initial.dateOfBirth),
|
||||
initial.timezone ?? DEFAULT_TIMEZONE,
|
||||
)
|
||||
: "",
|
||||
gender: initial.gender ?? "",
|
||||
}));
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
@@ -35,6 +35,8 @@ import {
|
||||
questionsToPassBeforeConfirm,
|
||||
} from "@/lib/onboarding/wizard-steps";
|
||||
|
||||
const NO_BASELINE = { heightCm: null, dateOfBirth: null, gender: null };
|
||||
|
||||
/**
|
||||
* v1.39 — the confirm screen.
|
||||
*
|
||||
@@ -334,11 +336,7 @@ export function ConfirmScreen({
|
||||
) : (
|
||||
<BaselineForm
|
||||
backHref={back ? screenHref(back) : undefined}
|
||||
initial={{
|
||||
heightCm: user?.heightCm ?? null,
|
||||
dateOfBirth: user?.dateOfBirth ?? null,
|
||||
gender: user?.gender ?? null,
|
||||
}}
|
||||
initial={user ?? NO_BASELINE}
|
||||
onConfirmed={finish}
|
||||
confirming={finishing}
|
||||
demoMode={demoMode}
|
||||
|
||||
@@ -123,10 +123,7 @@ export function DoneScreen({ state }: { state: OnboardingStateDto }) {
|
||||
</div>
|
||||
|
||||
{showAiLine ? (
|
||||
<p
|
||||
data-slot="onboarding-ai-line"
|
||||
className="text-muted-foreground max-w-md text-sm"
|
||||
>
|
||||
<p data-slot="onboarding-ai-line" className="max-w-md text-sm">
|
||||
{t("onboarding.done.aiOptional")}{" "}
|
||||
<InlineAction asChild size="sm" className="underline">
|
||||
<Link href="/settings/ai">{t("onboarding.done.aiSetupCta")}</Link>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* The delete now confirms with an Undo that calls the restore.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MutationObserver, QueryClient } from "@tanstack/react-query";
|
||||
|
||||
const toastSuccess = vi.hoisted(() => vi.fn());
|
||||
const errorToast = vi.hoisted(() => vi.fn());
|
||||
@@ -16,13 +17,9 @@ type Mutations = Parameters<typeof deleteWithUndo>[0];
|
||||
|
||||
const t = (key: string) => key;
|
||||
|
||||
function mutations(restoreResult: Promise<unknown>) {
|
||||
const remove = {
|
||||
mutate: vi.fn((_id: string, options: { onSuccess?: () => void }) =>
|
||||
options.onSuccess?.(),
|
||||
),
|
||||
};
|
||||
const restore = { mutateAsync: vi.fn(() => restoreResult) };
|
||||
function mutations(restoreResult: () => Promise<unknown>) {
|
||||
const remove = { mutateAsync: vi.fn(async () => ({ deleted: true })) };
|
||||
const restore = { mutateAsync: vi.fn(restoreResult) };
|
||||
return { remove, restore, all: { remove, restore } as unknown as Mutations };
|
||||
}
|
||||
|
||||
@@ -33,11 +30,12 @@ beforeEach(() => {
|
||||
|
||||
describe("deleteWithUndo", () => {
|
||||
it("confirms the delete with an Undo that restores the entry", async () => {
|
||||
const m = mutations(Promise.resolve({ id: "p1" }));
|
||||
const m = mutations(async () => ({ id: "p1" }));
|
||||
|
||||
deleteWithUndo(m.all, "p1", t);
|
||||
|
||||
expect(m.remove.mutate).toHaveBeenCalledWith("p1", expect.anything());
|
||||
expect(m.remove.mutateAsync).toHaveBeenCalledWith("p1");
|
||||
await vi.waitFor(() => expect(toastSuccess).toHaveBeenCalled());
|
||||
const [message, options] = toastSuccess.mock.calls[0]!;
|
||||
expect(message).toBe("practitioners.deleted");
|
||||
expect(options.action.label).toBe("common.undo");
|
||||
@@ -48,9 +46,12 @@ describe("deleteWithUndo", () => {
|
||||
});
|
||||
|
||||
it("says so when the restore fails", async () => {
|
||||
const m = mutations(Promise.reject(new Error("offline")));
|
||||
const m = mutations(async () => {
|
||||
throw new Error("offline");
|
||||
});
|
||||
|
||||
deleteWithUndo(m.all, "p1", t);
|
||||
await vi.waitFor(() => expect(toastSuccess).toHaveBeenCalled());
|
||||
toastSuccess.mock.calls[0]![1].action.onClick();
|
||||
|
||||
await vi.waitFor(() =>
|
||||
@@ -60,4 +61,26 @@ describe("deleteWithUndo", () => {
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("offers an Undo for each of two quick deletes", async () => {
|
||||
// One mutation observer for the list, as `useMutation` keeps it: the
|
||||
// callbacks of an earlier `mutate` are dropped once a later one starts.
|
||||
const observer = new MutationObserver(new QueryClient(), {
|
||||
mutationFn: async (id: string) => ({ id }),
|
||||
});
|
||||
const remove = {
|
||||
mutate: (id: string, options?: object) =>
|
||||
void observer.mutate(id, options).catch(() => undefined),
|
||||
mutateAsync: (id: string) => observer.mutate(id),
|
||||
};
|
||||
const restore = { mutateAsync: vi.fn(async () => ({})) };
|
||||
const all = { remove, restore } as unknown as Mutations;
|
||||
|
||||
deleteWithUndo(all, "p1", t);
|
||||
deleteWithUndo(all, "p2", t);
|
||||
|
||||
await vi.waitFor(() => expect(toastSuccess).toHaveBeenCalledTimes(2));
|
||||
for (const call of toastSuccess.mock.calls) call[1].action.onClick();
|
||||
expect(restore.mutateAsync.mock.calls).toEqual([["p1"], ["p2"]]);
|
||||
});
|
||||
});
|
||||
@@ -46,15 +46,17 @@ import { PractitionerSheet } from "./practitioner-sheet";
|
||||
|
||||
/**
|
||||
* Delete an entry, with an Undo: the delete is soft, and the restore brings
|
||||
* the entry back.
|
||||
* the entry back. Each delete answers on its own promise: the callbacks
|
||||
* `mutate` takes fire for the latest call only, so of two quick deletes only
|
||||
* the second offered an Undo.
|
||||
*/
|
||||
export function deleteWithUndo(
|
||||
{ remove, restore }: ReturnType<typeof usePractitionerMutations>,
|
||||
id: string,
|
||||
t: (key: string) => string,
|
||||
) {
|
||||
remove.mutate(id, {
|
||||
onSuccess: () =>
|
||||
remove.mutateAsync(id).then(
|
||||
() =>
|
||||
toast.success(t("practitioners.deleted"), {
|
||||
action: {
|
||||
label: t("common.undo"),
|
||||
@@ -66,9 +68,9 @@ export function deleteWithUndo(
|
||||
),
|
||||
},
|
||||
}),
|
||||
onError: (failure) =>
|
||||
(failure) =>
|
||||
showErrorToast(t("practitioners.deleteFailed"), { cause: failure }),
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
export function PractitionerList({ enabled = true }: { enabled?: boolean }) {
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
/**
|
||||
* Settings lists the Coach entry only while the Coach can answer, like the
|
||||
* sidebar and the bottom bar, and Settings → AI then keeps the stored Coach
|
||||
* memory in reach.
|
||||
*
|
||||
* Both used to read the raw module map, which answers the operator's switch
|
||||
* and Hide Coach but not a missing provider or missing consent: the settings
|
||||
* nav kept a Coach entry the sidebar had already dropped. The two now read
|
||||
* the same map (`useNavModules`), so the memory's own entry and the fallback
|
||||
* under Settings → AI cannot both go.
|
||||
*
|
||||
* The shell's module filter runs after hydration only, so `useMounted` is
|
||||
* mocked to the hydrated answer.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
const h = vi.hoisted(() => ({ coachAvailable: true }));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => "/settings/ai",
|
||||
useRouter: () => ({ replace: () => {}, push: () => {} }),
|
||||
}));
|
||||
vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true }));
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: "u1", role: "USER", modules: { coach: true } },
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/hooks/use-ai-capability", () => ({
|
||||
useAiCapability: () => ({
|
||||
available: h.coachAvailable,
|
||||
reason: h.coachAvailable ? null : "no_provider",
|
||||
onDeviceAllowed: true,
|
||||
}),
|
||||
}));
|
||||
// Settings → AI's cards have their own suites; only the memory slot is
|
||||
// under test here.
|
||||
vi.mock("@/components/ai/ai-consent-request", () => ({
|
||||
AiConsentRequest: () => null,
|
||||
}));
|
||||
vi.mock("@/components/settings/ai/ai-insights-card", () => ({
|
||||
AiInsightsCard: () => null,
|
||||
}));
|
||||
vi.mock("@/components/settings/ai/operator-off-notice", () => ({
|
||||
OperatorOffNotice: () => null,
|
||||
}));
|
||||
vi.mock("@/components/insights/ai-setup-hint", () => ({
|
||||
AiSetupHint: () => null,
|
||||
}));
|
||||
vi.mock("@/components/settings/coach-memory-section", () => ({
|
||||
StoredCoachMemory: () => <div data-slot="stored-coach-memory" />,
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { AiSection } from "../ai-section";
|
||||
import { SettingsShell } from "../settings-shell";
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("the Coach entry in Settings", () => {
|
||||
beforeEach(() => {
|
||||
h.coachAvailable = true;
|
||||
});
|
||||
|
||||
it("is listed while the Coach can answer, and the memory lives there", () => {
|
||||
expect(render(<SettingsShell active="ai">body</SettingsShell>)).toContain(
|
||||
'href="/settings/coach"',
|
||||
);
|
||||
expect(render(<AiSection />)).not.toContain(
|
||||
'data-slot="stored-coach-memory"',
|
||||
);
|
||||
});
|
||||
|
||||
it("goes when the Coach cannot answer, and Settings → AI keeps the memory", () => {
|
||||
h.coachAvailable = false;
|
||||
expect(
|
||||
render(<SettingsShell active="ai">body</SettingsShell>),
|
||||
).not.toContain('href="/settings/coach"');
|
||||
expect(render(<AiSection />)).toContain('data-slot="stored-coach-memory"');
|
||||
});
|
||||
});
|
||||
@@ -52,7 +52,7 @@ import {
|
||||
} from "./account-section-utils";
|
||||
import { AvatarSection } from "./avatar-section";
|
||||
import { SetupCard } from "./setup-card";
|
||||
import { dateOnlyKey } from "@/lib/tz/date-only";
|
||||
import { dateOfBirthKey } from "@/lib/tz/date-only";
|
||||
import { translateCodeOrNull } from "@/lib/i18n/translate-code";
|
||||
|
||||
export { resolveInitialTimezone } from "./account-section-utils";
|
||||
@@ -149,7 +149,7 @@ export function AccountSection() {
|
||||
displayName: user.displayName ?? "",
|
||||
height: heightAdapter.toDraft(user.heightCm),
|
||||
dateOfBirth: user.dateOfBirth
|
||||
? dateOnlyKey(new Date(user.dateOfBirth))
|
||||
? dateOfBirthKey(new Date(user.dateOfBirth), user.timezone)
|
||||
: "",
|
||||
gender: user.gender ?? "",
|
||||
fullName: user.fullName ?? "",
|
||||
|
||||
@@ -25,12 +25,10 @@ import { StoredCoachMemory } from "@/components/settings/coach-memory-section";
|
||||
import { AiSetupHint } from "@/components/insights/ai-setup-hint";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useNavModules } from "@/hooks/use-nav-modules";
|
||||
import { isSurfaceVisible } from "@/lib/modules/surface";
|
||||
|
||||
export function AiSection() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const navModules = useNavModules();
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
// v1.16.6 — the auth query can resolve before this boundary
|
||||
// hydrates; every `disabled={!isAuthenticated}` binding in the child
|
||||
// cards would then disagree with the SSR HTML (React #418). Gate the
|
||||
@@ -48,9 +46,11 @@ export function AiSection() {
|
||||
// nothing below reads as broken), and the one calm setup hint while no
|
||||
// provider is configured, or the consent question while the operator's
|
||||
// provider waits on it. Below it: what the Coach stored, while Settings →
|
||||
// Coach is not listed (the settings nav reads the same map), so it can
|
||||
// always be read and deleted.
|
||||
const coachSettingsReachable = isSurfaceVisible("settings:coach", navModules);
|
||||
// Coach is not reachable, so it can always be read and deleted.
|
||||
const coachSettingsReachable = isSurfaceVisible(
|
||||
"settings:coach",
|
||||
user?.modules,
|
||||
);
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<OperatorOffNotice />
|
||||
|
||||
@@ -89,7 +89,7 @@ export function CodexProviderForm({
|
||||
intervalSeconds: data.intervalSeconds,
|
||||
});
|
||||
} catch (err) {
|
||||
setMsg(localizedApiError(err, t, "settings.savingError"));
|
||||
setMsg(localizedApiError(err, t, "settings.codexConnectionFailed"));
|
||||
setMsgType("error");
|
||||
setDevicePolling(false);
|
||||
}
|
||||
@@ -117,7 +117,7 @@ export function CodexProviderForm({
|
||||
if (!cancelled) setTimeout(tick, intervalMs);
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
setMsg(localizedApiError(err, t, "settings.savingError"));
|
||||
setMsg(localizedApiError(err, t, "settings.codexConnectionFailed"));
|
||||
setMsgType("error");
|
||||
setDeviceCode(null);
|
||||
setDevicePolling(false);
|
||||
|
||||
@@ -127,8 +127,8 @@ export const LOCAL_MODEL_PRESETS = [
|
||||
export const CUSTOM_MODEL_SENTINEL = "__custom__";
|
||||
|
||||
/**
|
||||
* Map UI provider tag → the `aiProvider` enum understood by the
|
||||
* legacy single-result `resolveProvider()` resolver. `codex` and
|
||||
* Map UI provider tag → the stored `aiProvider` enum, the provider picked
|
||||
* under Settings, AI. `codex` and
|
||||
* `admin-openai` aren't user-level provider columns (they're handled
|
||||
* by Codex OAuth + admin-key fallback), so they map to null and the
|
||||
* legacy column is left blank.
|
||||
|
||||
@@ -31,7 +31,10 @@ import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { waitForMedicationIntakeImport } from "@/lib/medications/intake-import-poll";
|
||||
import {
|
||||
MedicationIntakeImportTimeout,
|
||||
waitForMedicationIntakeImport,
|
||||
} from "@/lib/medications/intake-import-poll";
|
||||
import { invalidateMedicationReads } from "@/lib/query-keys";
|
||||
|
||||
import { DoseHistoryColumnRulings } from "./dose-history-columns";
|
||||
@@ -93,6 +96,17 @@ function looksLikeJson(text: string): boolean {
|
||||
return head === "[" || head === "{";
|
||||
}
|
||||
|
||||
/**
|
||||
* The sentence for an import that did not report a result. A failed run or
|
||||
* a transport failure wrote nothing; a job still running when the watch gave
|
||||
* up may still write, so it must not read as nothing written.
|
||||
*/
|
||||
function importFailureKey(err: unknown): string {
|
||||
return err instanceof MedicationIntakeImportTimeout
|
||||
? "settings.sections.export.import.doseHistory.stillRunning"
|
||||
: "settings.sections.export.import.doseHistory.failed";
|
||||
}
|
||||
|
||||
export function DoseHistoryImportCard() {
|
||||
const { t } = useTranslations();
|
||||
const fatalMessageFor = useFatalMessage();
|
||||
@@ -191,12 +205,11 @@ export function DoseHistoryImportCard() {
|
||||
skippedDetailsOmitted: result.skippedDetailsOmitted,
|
||||
});
|
||||
void invalidateMedicationReads(queryClient);
|
||||
} catch {
|
||||
} catch (err) {
|
||||
// A failed job's reason is the worker's English and adds nothing;
|
||||
// anything else is a transport failure. Either way the run did not
|
||||
// write, and the card says so rather than leaving the last verdict on
|
||||
// screen.
|
||||
setError(t("settings.sections.export.import.doseHistory.failed"));
|
||||
// anything else is a transport failure. The card says what that means
|
||||
// rather than leaving the last verdict on screen.
|
||||
setError(t(importFailureKey(err)));
|
||||
setVerdict(null);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
|
||||
@@ -47,7 +47,6 @@ import { cn } from "@/lib/utils";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { SUB_SHELL_GRID_FLOOR } from "@/components/layout/shell-metrics";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useNavModules } from "@/hooks/use-nav-modules";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { isSurfaceVisible } from "@/lib/modules/surface";
|
||||
@@ -367,8 +366,8 @@ export function SettingsShell({
|
||||
const activeSlug = deriveActiveSlug(pathname, active);
|
||||
|
||||
// v1.18.0 — per-submodule entries are listed only when their module
|
||||
// is enabled, from `useNavModules()` as in the sidebar (Coach follows its
|
||||
// AI capability; Settings → AI then lists the stored memory). Fail OPEN
|
||||
// is enabled. Read from the resolved `useAuth().user.modules` map (the
|
||||
// same map the Module hub, nav, and Insights pills gate off). Fail OPEN
|
||||
// (`!== false`): a missing key, or a not-yet-resolved `/me` payload,
|
||||
// reads as enabled so an entry never silently disappears. A section's
|
||||
// owner comes from the surface map (`settings:<slug>`); sections with no
|
||||
@@ -391,7 +390,7 @@ export function SettingsShell({
|
||||
// effect (which the lint rule flags as a cascading render), and no risk of
|
||||
// the two passes disagreeing.
|
||||
const hydrated = useMounted();
|
||||
const modules = useNavModules();
|
||||
const modules = user?.modules;
|
||||
const activeRecord = user?.accountAccess?.active ?? null;
|
||||
const allVisibleSections = SETTINGS_SECTIONS.filter(
|
||||
(section) =>
|
||||
|
||||
@@ -8,14 +8,17 @@ import { cn } from "@/lib/utils";
|
||||
* The line under a list the server cut at its ceiling. Renders nothing for a
|
||||
* complete list, so a surface passes the cap it read (`apiGetList`) and
|
||||
* never decides on its own whether to show it. Meta tier: one muted sentence
|
||||
* at the `text-xs` floor.
|
||||
* at the `text-xs` floor. `message` replaces the generic sentence where the
|
||||
* surface can say how to reach the rest (a search).
|
||||
*/
|
||||
export function ListCapNotice({
|
||||
cap,
|
||||
className,
|
||||
message,
|
||||
}: {
|
||||
cap: ListCap | null | undefined;
|
||||
className?: string;
|
||||
message?: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
if (!cap?.truncated) return null;
|
||||
@@ -24,7 +27,7 @@ export function ListCapNotice({
|
||||
data-slot="list-cap-notice"
|
||||
className={cn("text-muted-foreground text-xs", className)}
|
||||
>
|
||||
{t("common.listCapped", { count: String(cap.limit) })}
|
||||
{message ?? t("common.listCapped", { count: String(cap.limit) })}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { QueryClient, QueryObserver } from "@tanstack/react-query";
|
||||
|
||||
import { invalidateReminderReads } from "@/hooks/use-measurement-reminders";
|
||||
import {
|
||||
invalidateReminderReads,
|
||||
putReminderRow,
|
||||
type MeasurementReminder,
|
||||
} from "@/hooks/use-measurement-reminders";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
/**
|
||||
@@ -71,3 +75,23 @@ describe("invalidateReminderReads — the Vorsorge daily-rail freshness contract
|
||||
client.clear();
|
||||
});
|
||||
});
|
||||
|
||||
describe("putReminderRow", () => {
|
||||
it("shows the answered row at once, so it no longer reads as due", () => {
|
||||
const client = new QueryClient();
|
||||
const due = {
|
||||
id: "r1",
|
||||
nextDueAt: "2026-11-11T08:00:00.000Z",
|
||||
} as MeasurementReminder;
|
||||
const other = { id: "r2" } as MeasurementReminder;
|
||||
client.setQueryData(queryKeys.measurementReminders(), [due, other]);
|
||||
|
||||
const done = { ...due, nextDueAt: "2027-11-11T08:00:00.000Z" };
|
||||
putReminderRow(client, done);
|
||||
|
||||
expect(client.getQueryData(queryKeys.measurementReminders())).toEqual([
|
||||
done,
|
||||
other,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -115,10 +115,29 @@ export function useMeasurementReminders(enabled = true) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the row a satisfy, skip or snooze answered with into the cached list.
|
||||
* The button re-enables as the call settles, and with only the refetch behind
|
||||
* it the row still read as due, so a second tap marked it done twice.
|
||||
*/
|
||||
export function putReminderRow(
|
||||
qc: QueryClient,
|
||||
reminder: MeasurementReminder,
|
||||
): void {
|
||||
qc.setQueryData<MeasurementReminder[]>(
|
||||
queryKeys.measurementReminders(),
|
||||
(rows) => rows?.map((row) => (row.id === reminder.id ? reminder : row)),
|
||||
);
|
||||
}
|
||||
|
||||
export function useMeasurementReminderMutations() {
|
||||
const qc = useQueryClient();
|
||||
// Not returned: a mutation would wait for every refetch before it settles.
|
||||
const invalidate = () => void invalidateReminderReads(qc);
|
||||
const answered = (reminder: MeasurementReminder) => {
|
||||
putReminderRow(qc, reminder);
|
||||
invalidate();
|
||||
};
|
||||
|
||||
const create = useMutation({
|
||||
mutationKey: queryKeys.measurementReminderCreate(),
|
||||
@@ -150,7 +169,7 @@ export function useMeasurementReminderMutations() {
|
||||
mutationKey: queryKeys.measurementReminderSatisfy(),
|
||||
mutationFn: (id: string) =>
|
||||
apiPost<MeasurementReminder>(`${BASE}/${id}/satisfy`),
|
||||
onSuccess: invalidate,
|
||||
onSuccess: answered,
|
||||
});
|
||||
|
||||
// v1.37.20 (#223) — honest skip: the interval restarts from the skip
|
||||
@@ -161,7 +180,7 @@ export function useMeasurementReminderMutations() {
|
||||
apiPost<{ skipped: boolean; reminder: MeasurementReminder }>(
|
||||
`${BASE}/${id}/skip`,
|
||||
),
|
||||
onSuccess: invalidate,
|
||||
onSuccess: ({ reminder }) => answered(reminder),
|
||||
});
|
||||
|
||||
// v1.37.20 (#223) — snooze to a calendar day; the server resolves it to
|
||||
@@ -172,7 +191,7 @@ export function useMeasurementReminderMutations() {
|
||||
apiPost<{ reminder: MeasurementReminder }>(`${BASE}/${id}/snooze`, {
|
||||
until,
|
||||
}),
|
||||
onSuccess: invalidate,
|
||||
onSuccess: ({ reminder }) => answered(reminder),
|
||||
});
|
||||
|
||||
return { create, update, remove, satisfy, skip, snooze };
|
||||
|
||||
@@ -77,12 +77,12 @@ describe("listTelegramDoseMedications", () => {
|
||||
|
||||
describe("findTelegramDoseMedication", () => {
|
||||
it("resolves a current course", async () => {
|
||||
db.findFirst.mockResolvedValue(CHRONIC);
|
||||
db.findMany.mockResolvedValue([CHRONIC]);
|
||||
const found = await findTelegramDoseMedication("user-1", null, {
|
||||
id: "chronic",
|
||||
});
|
||||
expect(found?.id).toBe("chronic");
|
||||
expect(db.findFirst).toHaveBeenCalledWith(
|
||||
expect(db.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
id: "chronic",
|
||||
@@ -95,7 +95,7 @@ describe("findTelegramDoseMedication", () => {
|
||||
});
|
||||
|
||||
it("refuses an ended course", async () => {
|
||||
db.findFirst.mockResolvedValue(ENDED_NOW);
|
||||
db.findMany.mockResolvedValue([ENDED_NOW]);
|
||||
expect(
|
||||
await findTelegramDoseMedication("user-1", "Europe/Berlin", {
|
||||
id: "ended-now",
|
||||
@@ -103,11 +103,31 @@ describe("findTelegramDoseMedication", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("finds the current course beside an ended one of the same name", async () => {
|
||||
db.findMany.mockResolvedValue([ENDED, CHRONIC]);
|
||||
const found = await findTelegramDoseMedication("user-1", "Europe/Berlin", {
|
||||
name: { equals: "ramipril", mode: "insensitive" },
|
||||
});
|
||||
expect(found?.id).toBe("chronic");
|
||||
});
|
||||
|
||||
it("judges the course at the dose's own slot", async () => {
|
||||
// The last evening dose, confirmed after midnight once the course ended.
|
||||
db.findMany.mockResolvedValue([ENDED_NOW]);
|
||||
const found = await findTelegramDoseMedication(
|
||||
"user-1",
|
||||
"Europe/Berlin",
|
||||
{ id: "ended-now" },
|
||||
new Date(Date.now() - 3 * 60 * 60 * 1000),
|
||||
);
|
||||
expect(found?.id).toBe("ended-now");
|
||||
});
|
||||
|
||||
it("refuses everything with the Medications module off", async () => {
|
||||
db.moduleOn.mockResolvedValue(false);
|
||||
expect(
|
||||
await findTelegramDoseMedication("user-1", null, { id: "chronic" }),
|
||||
).toBeNull();
|
||||
expect(db.findFirst).not.toHaveBeenCalled();
|
||||
expect(db.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ vi.mock("@/lib/crypto", () => ({
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { chainWithReasoningEffort } from "../provider-chain";
|
||||
import type { ProviderWorkAuthority } from "@/lib/sharing/provider-work-authority";
|
||||
|
||||
import {
|
||||
@@ -238,16 +239,36 @@ describe("probeProviderChain", () => {
|
||||
]);
|
||||
expect(presence.managedBy).toBe("user");
|
||||
|
||||
// Once the person saved a chain without it, the chain is their word.
|
||||
// A saved chain without it says nothing about the gateway, and neither
|
||||
// does the switched-off entry the gateway form wrote for its reasoning
|
||||
// effort.
|
||||
for (const aiProviderChain of [
|
||||
[{ providerType: "local", enabled: true, priority: 1 }],
|
||||
chainWithReasoningEffort(null, "openai-compatible", "low"),
|
||||
]) {
|
||||
load(row({ ...gateway, aiProviderChain }));
|
||||
expect(
|
||||
(await probeProviderChain("u1", OWNER)).entries.map(
|
||||
(e) => e.providerType,
|
||||
),
|
||||
).toEqual(["openai-compatible"]);
|
||||
}
|
||||
|
||||
// Switched off in the chain, the gateway stays off.
|
||||
load(
|
||||
row({
|
||||
...gateway,
|
||||
aiProviderChain: [
|
||||
{ providerType: "local", enabled: true, priority: 1 },
|
||||
{ providerType: "openai-compatible", enabled: false, priority: 2 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect((await probeProviderChain("u1", OWNER)).entries).toEqual([]);
|
||||
expect(await resolveProviderAvailability("u1")).toEqual({
|
||||
aiAvailable: false,
|
||||
managedBy: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("carries the person's in-browser OCR opt-in", async () => {
|
||||
|
||||
@@ -352,6 +352,21 @@ describe("resolveProviderForTest — empty override resolves via the chain", ()
|
||||
expect(provider.type).not.toBe("none");
|
||||
expect(provider).toBeInstanceOf(OpenAIClient);
|
||||
});
|
||||
|
||||
it("answers no provider, which the route names, when the chain resolves empty", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
aiProvider: null,
|
||||
aiModel: null,
|
||||
aiBaseUrl: null,
|
||||
aiAnthropicKeyEncrypted: null,
|
||||
aiLocalKeyEncrypted: null,
|
||||
aiOpenaiKeyEncrypted: null,
|
||||
aiProviderChain: [{ providerType: "openai", priority: 1, enabled: true }],
|
||||
} as unknown as Awaited<ReturnType<typeof prisma.user.findUnique>>);
|
||||
|
||||
const provider = await resolveProviderForTest("user-123", {});
|
||||
expect(provider.type).toBe("none");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveProviderAvailability", () => {
|
||||
|
||||
@@ -17,6 +17,10 @@ vi.mock("@/lib/ai/codex-oauth", () => ({
|
||||
}));
|
||||
|
||||
import { resolveProviderChain } from "../provider";
|
||||
import {
|
||||
chainWithReasoningEffort,
|
||||
PROVIDER_CHAIN_DEFAULT,
|
||||
} from "../provider-chain";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decryptCodexCreds } from "@/lib/ai/codex-oauth";
|
||||
|
||||
@@ -207,41 +211,67 @@ describe("resolveProviderChain", () => {
|
||||
|
||||
expect(await resolveProviderChain("user-1")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("serves the picked gateway, under its own name, on a chain never saved", async () => {
|
||||
const gatewayRow = {
|
||||
aiProvider: "OPENAI_COMPATIBLE",
|
||||
aiModel: null,
|
||||
aiBaseUrl: null,
|
||||
aiAnthropicKeyEncrypted: null,
|
||||
aiLocalKeyEncrypted: null,
|
||||
aiOpenaiKeyEncrypted: null,
|
||||
aiCompatBaseUrl: "https://gateway.example/v1",
|
||||
aiCompatKeyEncrypted: null,
|
||||
aiCompatModel: "llama-3",
|
||||
role: "USER",
|
||||
};
|
||||
userRows(
|
||||
{ ...gatewayRow, aiProviderChain: null },
|
||||
{
|
||||
codexAccessTokenEncrypted: null,
|
||||
codexRefreshTokenEncrypted: null,
|
||||
codexTokenExpiresAt: null,
|
||||
codexConnectionStatus: "disconnected",
|
||||
},
|
||||
describe("resolveProviderChain and the picked gateway", () => {
|
||||
const gatewayRow = {
|
||||
aiProvider: "OPENAI_COMPATIBLE",
|
||||
aiModel: null,
|
||||
aiBaseUrl: null,
|
||||
aiAnthropicKeyEncrypted: null,
|
||||
aiLocalKeyEncrypted: null,
|
||||
aiOpenaiKeyEncrypted: null,
|
||||
aiCompatBaseUrl: "https://gateway.example/v1",
|
||||
aiCompatKeyEncrypted: null,
|
||||
aiCompatModel: "llama-3",
|
||||
role: "USER",
|
||||
};
|
||||
const DEFAULT_SAVED = PROVIDER_CHAIN_DEFAULT.map((entry) => ({ ...entry }));
|
||||
|
||||
/** Serve `chain` as the record's saved chain; Codex is not connected. */
|
||||
async function resolveWith(chain: unknown) {
|
||||
const read = async (args: { select?: Record<string, unknown> }) =>
|
||||
args.select?.codexAccessTokenEncrypted
|
||||
? {
|
||||
codexAccessTokenEncrypted: null,
|
||||
codexRefreshTokenEncrypted: null,
|
||||
codexTokenExpiresAt: null,
|
||||
codexConnectionStatus: "disconnected",
|
||||
}
|
||||
: { ...gatewayRow, aiProviderChain: chain };
|
||||
vi.mocked(prisma.user.findUnique).mockImplementation(
|
||||
read as unknown as typeof prisma.user.findUnique,
|
||||
);
|
||||
operator({
|
||||
adminAiKeyEncrypted: null,
|
||||
});
|
||||
operator({ adminAiKeyEncrypted: null });
|
||||
return (await resolveProviderChain("user-1")).map((c) => c.providerType);
|
||||
}
|
||||
|
||||
const chain = await resolveProviderChain("user-1");
|
||||
expect(chain.map((c) => c.providerType)).toEqual(["openai-compatible"]);
|
||||
it.each([
|
||||
["never saved", null],
|
||||
["saved with the default content", DEFAULT_SAVED],
|
||||
[
|
||||
"reset to the default and saved",
|
||||
JSON.parse(JSON.stringify(DEFAULT_SAVED)),
|
||||
],
|
||||
["reordered without the gateway", [...DEFAULT_SAVED].reverse()],
|
||||
[
|
||||
"holding a reasoning effort the gateway form wrote switched off",
|
||||
chainWithReasoningEffort(null, "openai-compatible", "low"),
|
||||
],
|
||||
[
|
||||
"holding a reasoning effort the gateway form writes now",
|
||||
chainWithReasoningEffort(null, "openai-compatible", "low", true),
|
||||
],
|
||||
])("serves the gateway on a chain %s", async (_case, chain) => {
|
||||
expect(await resolveWith(chain)).toEqual(["openai-compatible"]);
|
||||
});
|
||||
|
||||
// A saved chain without the gateway is the person's word.
|
||||
userRows({
|
||||
...gatewayRow,
|
||||
aiProviderChain: [{ providerType: "local", priority: 1, enabled: true }],
|
||||
});
|
||||
expect(await resolveProviderChain("user-1")).toEqual([]);
|
||||
it("serves nothing once the person switched the gateway off in the chain", async () => {
|
||||
expect(
|
||||
await resolveWith([
|
||||
...DEFAULT_SAVED,
|
||||
{ providerType: "openai-compatible", priority: 6, enabled: false },
|
||||
]),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -336,3 +336,17 @@ describe("getCoachSystemPrompt — native locales name the reader's unit", () =>
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("the plan review rule", () => {
|
||||
// A reviewed plan has no progress line, so the rule must not leave the
|
||||
// model a result to make up.
|
||||
it.each([
|
||||
["en", "Without one you have no result: ask how it went"],
|
||||
["de", "Ohne Fortschrittszeile hast du kein Ergebnis"],
|
||||
] as const)(
|
||||
"names no result without a progress line (%s)",
|
||||
(locale, rule) => {
|
||||
expect(getCoachSystemPrompt(locale)).toContain(rule);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1419,8 +1419,8 @@ function buildCoachV122Addendum(locale: Locale): string {
|
||||
|
||||
parts.push(
|
||||
isDe
|
||||
? `EXPERIMENT-RÜCKBLICK — sprichst du darüber, wie ein vereinbarter Plan gelaufen ist, lies es aus seiner Fortschrittszeile in den eigenen Zahlen des Nutzers zurück, rein assoziativ ("sieht beibehaltenswert aus" / "hängt zusammen mit", NIEMALS "bewiesen" / "hat funktioniert" / "verursacht"). Ein Null-Ergebnis berichte ehrlich ("keine messbare Veränderung — deine Entscheidung"). Hat sich ein Vitalwert während des Experiments VERSCHLECHTERT, bejuble das nicht — sprich es klar an und verweise an die Ärztin/den Arzt bzw. den Akut-Pfad. Schlage NUR verhaltensbezogene Experimente vor (Schlafzeit, Schritte, Bildschirmzeit, Essenszeit); nie ein Medikamenten-/Dosis-/Präparate-Experiment — lehne das ab und verweise an die verschreibende Person.`
|
||||
: `EXPERIMENT REVIEW — when you speak to how an agreed plan went, read it back from its progress line in the user's own numbers, association-only ("looks worth keeping" / "associated with", NEVER "proven" / "it worked" / "caused"). Report a null result honestly ("no measurable change — your call"). If a vital WORSENED during the experiment, do NOT cheerlead it — raise it plainly and route to the doctor / the acute-redflag path. Only ever propose BEHAVIORAL experiments (sleep timing, steps, screen time, meal timing); never a medication / dose / supplement trial — refuse those and defer to the prescriber.`,
|
||||
? `EXPERIMENT-RÜCKBLICK — sprichst du darüber, wie ein vereinbarter Plan gelaufen ist, lies es NUR aus einer Fortschrittszeile zurück, die der Snapshot für diesen Plan trägt, in den eigenen Zahlen des Nutzers. Ohne Fortschrittszeile hast du kein Ergebnis: frag, wie es gelaufen ist, statt eines zu nennen. Bleib rein assoziativ ("sieht beibehaltenswert aus" / "hängt zusammen mit", NIEMALS "bewiesen" / "hat funktioniert" / "verursacht"). Ein Null-Ergebnis berichte ehrlich ("keine messbare Veränderung — deine Entscheidung"). Hat sich ein Vitalwert während des Experiments VERSCHLECHTERT, bejuble das nicht — sprich es klar an und verweise an die Ärztin/den Arzt bzw. den Akut-Pfad. Schlage NUR verhaltensbezogene Experimente vor (Schlafzeit, Schritte, Bildschirmzeit, Essenszeit); nie ein Medikamenten-/Dosis-/Präparate-Experiment — lehne das ab und verweise an die verschreibende Person.`
|
||||
: `EXPERIMENT REVIEW — when you speak to how an agreed plan went, read it back ONLY from a progress line the snapshot carries for that plan, in the user's own numbers. Without one you have no result: ask how it went instead of naming one. Stay association-only ("looks worth keeping" / "associated with", NEVER "proven" / "it worked" / "caused"). Report a null result honestly ("no measurable change — your call"). If a vital WORSENED during the experiment, do NOT cheerlead it — raise it plainly and route to the doctor / the acute-redflag path. Only ever propose BEHAVIORAL experiments (sleep timing, steps, screen time, meal timing); never a medication / dose / supplement trial — refuse those and defer to the prescriber.`,
|
||||
);
|
||||
|
||||
return parts.join("\n\n");
|
||||
|
||||
@@ -18,8 +18,8 @@ import { reasoningTitleOf } from "./reasoning/support";
|
||||
* never hit the network.
|
||||
*
|
||||
* The mock is exported from `src/lib/ai/` so any test can import it,
|
||||
* but it is NOT registered in `resolveProvider()` — the production
|
||||
* path must never reach it.
|
||||
* but no provider resolver returns it: the production path must never
|
||||
* reach it.
|
||||
*/
|
||||
export interface MockAIProviderOptions {
|
||||
/** Provider tag returned in `CompletionResult.providerType`. Defaults to "local". */
|
||||
|
||||
@@ -181,27 +181,30 @@ export function serializeProviderChain(
|
||||
* reasoning effort from that provider's own settings form.
|
||||
*
|
||||
* The setting lives on the chain entry, so a chain that was never customised
|
||||
* is materialised from the default first. A provider that is not in the chain
|
||||
* at all gets a DISABLED entry to hold the value: the setting must reach the
|
||||
* provider when it is resolved as the selected one, and a disabled entry is
|
||||
* inert everywhere else (every reader filters on `enabled`). Clearing on an
|
||||
* absent entry writes nothing new.
|
||||
* is materialised from the default first. With `enable`, which the gateway
|
||||
* form passes because saving it picks the gateway, the entry is switched on,
|
||||
* a new one included. Otherwise a provider that is not in the chain gets a
|
||||
* DISABLED entry to hold the value, inert everywhere but where the provider
|
||||
* serves as the picked one (`servesPickedGateway`). Clearing on an absent
|
||||
* entry writes nothing new.
|
||||
*/
|
||||
export function chainWithReasoningEffort(
|
||||
rawChain: unknown,
|
||||
providerType: ReasoningProviderType,
|
||||
effort: ReasoningEffort | null,
|
||||
enable = false,
|
||||
): ProviderChainEntry[] {
|
||||
const chain = parseProviderChain(rawChain).map((e) => ({ ...e }));
|
||||
const entry = chain.find((e) => e.providerType === providerType);
|
||||
if (entry) {
|
||||
if (effort) entry.reasoningEffort = effort;
|
||||
else delete entry.reasoningEffort;
|
||||
if (enable) entry.enabled = true;
|
||||
} else if (effort) {
|
||||
chain.push({
|
||||
providerType,
|
||||
priority: Math.max(0, ...chain.map((e) => e.priority)) + 1,
|
||||
enabled: false,
|
||||
enabled: enable,
|
||||
reasoningEffort: effort,
|
||||
});
|
||||
}
|
||||
|
||||
+19
-19
@@ -25,7 +25,6 @@ import { isPublicUrl } from "@/lib/validations/notifications";
|
||||
import { isLocalAiHostAllowed } from "./local-host-allowlist";
|
||||
import {
|
||||
parseProviderChain,
|
||||
PROVIDER_CHAIN_DEFAULT,
|
||||
PROVIDER_CHAIN_TYPES,
|
||||
type ProviderChainType,
|
||||
} from "./provider-chain";
|
||||
@@ -712,7 +711,7 @@ async function resolveAdminCodexProvider(): Promise<AIProvider | null> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the AI provider for a given user.
|
||||
* Test-only, ignoring the chain's switches: use `resolveProviderChain`.
|
||||
*
|
||||
* Priority:
|
||||
* 1. User selected ANTHROPIC / LOCAL with valid creds → that provider.
|
||||
@@ -812,7 +811,7 @@ async function readBoundSettings(userId: string): Promise<{
|
||||
* usable credential (e.g. chain says `anthropic` but
|
||||
* `aiAnthropicKeyEncrypted` is null).
|
||||
* 3. Append the operator's central Codex behind the person's opt-in.
|
||||
* 4. Still empty: the picked gateway on a chain never saved
|
||||
* 4. Still empty: the picked gateway, unless the chain switched it off
|
||||
* (`servesPickedGateway`), else nothing. Every caller treats an
|
||||
* empty result as "no provider"; none falls back on its own.
|
||||
*/
|
||||
@@ -841,7 +840,7 @@ export async function resolveProviderChain(
|
||||
aiResponseTimeoutSeconds: true,
|
||||
},
|
||||
});
|
||||
// See `resolveProvider`: every instance carries the record's settings.
|
||||
// Every instance carries the record's timeout and reasoning effort.
|
||||
const bind = (provider: AIProvider): AIProvider =>
|
||||
bindReasoningEffort(
|
||||
bindResponseTimeout(provider, userRow?.aiResponseTimeoutSeconds),
|
||||
@@ -964,24 +963,25 @@ function providerOrigin(providerType: string): ProviderManagedBy {
|
||||
}
|
||||
|
||||
/**
|
||||
* What a chain that resolves empty falls back to. A saved chain is the
|
||||
* person's own word: an entry they switched off or removed stays out, so an
|
||||
* empty saved chain means no provider. A chain they never saved is the
|
||||
* default one (`parseProviderChain` hands back `PROVIDER_CHAIN_DEFAULT`
|
||||
* itself), which holds every provider a person can pick except the
|
||||
* OpenAI-compatible gateway; so the gateway they picked under Settings, AI
|
||||
* serves when nothing in the default chain does. Resolution and the presence
|
||||
* probe both read this, so they cannot disagree about it.
|
||||
* What a chain that resolves empty falls back to: the OpenAI-compatible
|
||||
* gateway picked under Settings, AI, unless the chain switched it off. The
|
||||
* default chain has no entry for the gateway, so a chain never saved, reset
|
||||
* to the default, or reordered without it says nothing about the gateway.
|
||||
* Nor does a disabled gateway entry that carries a reasoning effort: that is
|
||||
* the holder the gateway form wrote for the setting
|
||||
* (`chainWithReasoningEffort`), not a switch somebody turned off; the chain
|
||||
* editor drops the effort of a gateway it switches off. Resolution and the
|
||||
* presence probe both read this, so they cannot disagree about it.
|
||||
*/
|
||||
function servesPickedGateway(row: {
|
||||
aiProvider: string | null;
|
||||
aiProviderChain: unknown;
|
||||
}): boolean {
|
||||
return (
|
||||
parseProviderChain(row.aiProviderChain ?? null) ===
|
||||
PROVIDER_CHAIN_DEFAULT &&
|
||||
row.aiProvider?.toUpperCase() === "OPENAI_COMPATIBLE"
|
||||
if (row.aiProvider?.toUpperCase() !== "OPENAI_COMPATIBLE") return false;
|
||||
const gateway = parseProviderChain(row.aiProviderChain ?? null).find(
|
||||
(entry) => entry.providerType === "openai-compatible",
|
||||
);
|
||||
return !gateway || gateway.enabled || gateway.reasoningEffort !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1355,14 +1355,14 @@ async function resolveProviderForTestUnbound(
|
||||
|
||||
// Empty selection ("test my saved config" — the ai-section calls
|
||||
// /api/ai/test with an empty body): resolve via the SAME path generation
|
||||
// uses — the first entry of `resolveProviderChain`. The legacy codex→admin
|
||||
// uses, the first entry of `resolveProviderChain`. The legacy codex→admin
|
||||
// fallback used to probe a different provider than generation ran
|
||||
// (observed: test→admin-key while generation→codex), so a green test gave
|
||||
// no signal about whether overnight generation would work.
|
||||
// Nothing resolving is the route's `ai.provider.none`, not a refused entry.
|
||||
if (!provider) {
|
||||
const chain = await resolveProviderChain(userId);
|
||||
if (chain.length > 0) return chain[0].instance;
|
||||
throw new AITestConfigError(422, "No AI provider configured");
|
||||
return chain.length > 0 ? chain[0].instance : new NoProvider();
|
||||
}
|
||||
|
||||
switch (provider) {
|
||||
|
||||
@@ -66,8 +66,8 @@ function openAIFamilySupportsVision(model: string): boolean {
|
||||
|
||||
/**
|
||||
* Logical provider tags the OCR path reasons about. Mirrors the
|
||||
* `ProviderChainType` vocabulary plus the legacy `admin-key` single-provider
|
||||
* tag `resolveProvider`'s admin fallback returns.
|
||||
* `ProviderChainType` vocabulary plus the legacy `admin-key` tag the
|
||||
* operator-key client carries.
|
||||
*/
|
||||
export type VisionProviderType =
|
||||
| "anthropic"
|
||||
|
||||
@@ -126,6 +126,21 @@ describe("localizedApiError", () => {
|
||||
["a rate limit", 429, "http.rate_limited", "too many requests"],
|
||||
["a missing right", 403, "http.forbidden", "permission"],
|
||||
["a rejected entry", 422, "http.invalid", "Check them"],
|
||||
["a mistyped proof", 401, "auth.reproof.failed", "did not verify"],
|
||||
[
|
||||
"a password where a second factor is needed",
|
||||
422,
|
||||
"auth.reproof.too_weak",
|
||||
"second factor",
|
||||
],
|
||||
[
|
||||
"an authenticator already set up",
|
||||
409,
|
||||
"auth.mfa.already_active",
|
||||
"Disable it first",
|
||||
],
|
||||
["nothing to test with", 422, "ai.provider.none", "No AI provider"],
|
||||
["a spent AI budget", 429, "ai.budget.exceeded", "budget is used up"],
|
||||
])("names %s instead of the save fallback", (_, status, code, text) => {
|
||||
const error = new ApiError("raw English server prose", status, {
|
||||
errorCode: code,
|
||||
@@ -157,6 +172,10 @@ describe("localizedApiError", () => {
|
||||
"http.rate_limited",
|
||||
"http.forbidden",
|
||||
"http.invalid",
|
||||
"auth.reproof.too_weak",
|
||||
"auth.mfa.already_active",
|
||||
"ai.provider.none",
|
||||
"ai.budget.exceeded",
|
||||
]) {
|
||||
const sentence = localT(`apiErrors.${code}`);
|
||||
expect(sentence).not.toBe(`apiErrors.${code}`);
|
||||
|
||||
@@ -63,6 +63,53 @@ describe("useIdempotentPost", () => {
|
||||
expect(keyOf(1)).not.toBe(keyOf(0));
|
||||
});
|
||||
|
||||
it("takes a new key once the server refused the entry", async () => {
|
||||
// The refusal is stored under the key, so the corrected retry under it
|
||||
// ran unprotected and an unchanged one replayed the refusal.
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({ data: null, error: "Name taken", meta: {} }),
|
||||
{ status: 409, headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce(stored());
|
||||
const post = openForm();
|
||||
|
||||
await expect(
|
||||
post("/api/custom-metrics", { name: "Steps" }),
|
||||
).rejects.toThrow();
|
||||
await post("/api/custom-metrics", { name: "Steps" });
|
||||
|
||||
expect(keyOf(1)).not.toBe(keyOf(0));
|
||||
});
|
||||
|
||||
it("keeps the key while the first request is still running", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: null,
|
||||
error: "A request with this Idempotency-Key is already in progress",
|
||||
}),
|
||||
{
|
||||
status: 409,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Idempotent-Replay": "false",
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce(stored());
|
||||
const post = openForm();
|
||||
|
||||
await expect(post("/api/mood-entries", { mood: "GOOD" })).rejects.toThrow();
|
||||
await post("/api/mood-entries", { mood: "GOOD" });
|
||||
|
||||
expect(keyOf(1)).toBe(keyOf(0));
|
||||
});
|
||||
|
||||
it("keeps a header the caller set", async () => {
|
||||
fetchMock.mockResolvedValueOnce(stored());
|
||||
const post = openForm();
|
||||
|
||||
@@ -79,6 +79,12 @@ export class ApiError extends Error {
|
||||
* so a form can mark the one the server refused. Empty when it named none.
|
||||
*/
|
||||
readonly issuePaths: readonly string[];
|
||||
/**
|
||||
* The idempotency layer's "a request under this key is still running" (a
|
||||
* 409 with `X-Idempotent-Replay: false`): nothing was decided, and a retry
|
||||
* under the same key replays the first request's answer.
|
||||
*/
|
||||
readonly inFlight: boolean;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
@@ -86,12 +92,14 @@ export class ApiError extends Error {
|
||||
meta?: Record<string, unknown>,
|
||||
requestId?: string | null,
|
||||
issuePaths: readonly string[] = [],
|
||||
inFlight = false,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
this.meta = meta;
|
||||
this.issuePaths = issuePaths;
|
||||
this.inFlight = inFlight;
|
||||
const fromMeta =
|
||||
typeof meta?.requestId === "string" && meta.requestId.length > 0
|
||||
? meta.requestId
|
||||
@@ -133,6 +141,7 @@ export async function throwApiError(res: Response): Promise<never> {
|
||||
meta,
|
||||
res.headers.get("x-request-id"),
|
||||
issuePaths,
|
||||
res.status === 409 && res.headers.get("x-idempotent-replay") === "false",
|
||||
);
|
||||
// A server-side failure is worth an id in the session's support info,
|
||||
// whether or not the call site ends up showing it.
|
||||
|
||||
@@ -2,19 +2,23 @@
|
||||
|
||||
import { useRef } from "react";
|
||||
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
import { ApiError, apiPost } from "@/lib/api/api-fetch";
|
||||
import { randomId } from "@/lib/random-id";
|
||||
|
||||
/**
|
||||
* `apiPost` for a create form, under one `Idempotency-Key` until a save lands.
|
||||
* `apiPost` for a create form, under one `Idempotency-Key` until an answer
|
||||
* arrives.
|
||||
*
|
||||
* The transport mints a fresh key per call, which covers a replayed request
|
||||
* but not a second tap on Save: when the first answer never arrived (the 15 s
|
||||
* timeout on a weak signal) the entry is already stored, and the retry is a
|
||||
* new call with a new key, so the server stored it twice. Every submit
|
||||
* through this function sends the same key until one succeeds, so the retry
|
||||
* replays the first answer instead; a retry with a changed body is a
|
||||
* different request and runs as one. The next entry gets a new key.
|
||||
* through this function sends the same key until the server answers, so the
|
||||
* retry replays the first answer instead. An answer settles the key, a
|
||||
* refusal included: the server keeps a refusal under its key for a day, so a
|
||||
* corrected retry under the old key was never protected and an unchanged one
|
||||
* replayed a refusal that no longer held. Only the answer that the first
|
||||
* request is still running keeps it. The next entry gets a new key.
|
||||
*/
|
||||
export function useIdempotentPost() {
|
||||
const key = useRef<string | null>(null);
|
||||
@@ -26,8 +30,13 @@ export function useIdempotentPost() {
|
||||
key.current ??= randomId();
|
||||
const headers = new Headers(init?.headers);
|
||||
headers.set("Idempotency-Key", key.current);
|
||||
const result = await apiPost<T>(path, body, { ...init, headers });
|
||||
key.current = null;
|
||||
return result;
|
||||
try {
|
||||
const result = await apiPost<T>(path, body, { ...init, headers });
|
||||
key.current = null;
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && !error.inFlight) key.current = null;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
* account only possession of a factor counts now; the account without one is
|
||||
* unchanged.
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
@@ -247,6 +248,20 @@ describe("POST /api/auth/passkey/register-options", () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(refundRateLimit).toHaveBeenCalledWith("auth:step-up:user-1");
|
||||
});
|
||||
|
||||
it("names a mistyped password as a failed proof, not an ended session", async () => {
|
||||
vi.mocked(requireCookieAuth).mockResolvedValue({
|
||||
user: stored(PLAIN),
|
||||
session: { id: "sess-1" },
|
||||
} as unknown as Awaited<ReturnType<typeof requireCookieAuth>>);
|
||||
vi.mocked(verifyPassword).mockResolvedValue(false);
|
||||
const res = await PASSKEY_REGISTER_OPTIONS(
|
||||
passkeyReq({ method: "password", password: "typo" }) as NextRequest,
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect((await res.json()).meta.errorCode).toBe("auth.reproof.failed");
|
||||
expect(createRegistrationOptions).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("changing the email address", () => {
|
||||
|
||||
@@ -71,6 +71,55 @@ describe("applyProfileUpdate locale", () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe("applyProfileUpdate date of birth", () => {
|
||||
const writtenDateOfBirth = () =>
|
||||
(
|
||||
vi.mocked(prisma.user.update).mock.calls[0]![0]!.data as {
|
||||
dateOfBirth?: Date | null;
|
||||
}
|
||||
).dateOfBirth;
|
||||
|
||||
it("stores a typed date as that calendar date", async () => {
|
||||
await applyProfileUpdate(USER_ID, { dateOfBirth: "2012-05-14" });
|
||||
expect(writtenDateOfBirth()?.toISOString()).toBe(
|
||||
"2012-05-14T12:00:00.000Z",
|
||||
);
|
||||
});
|
||||
|
||||
it("stores an instant from the iOS app as its day in the zone the request sets", async () => {
|
||||
// 14 May picked at 20:00 in Honolulu (UTC-10).
|
||||
await applyProfileUpdate(USER_ID, {
|
||||
dateOfBirth: "2012-05-15T06:00:00Z",
|
||||
timezone: "Pacific/Honolulu",
|
||||
});
|
||||
expect(writtenDateOfBirth()?.toISOString()).toBe(
|
||||
"2012-05-14T12:00:00.000Z",
|
||||
);
|
||||
});
|
||||
|
||||
it("reads an instant in the stored zone when the request sets none", async () => {
|
||||
// 14 May picked at 09:30 in Nuku'alofa (UTC+13). A user id of its own:
|
||||
// the zone resolver caches per account.
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
timezone: "Pacific/Tongatapu",
|
||||
} as unknown as Awaited<ReturnType<typeof prisma.user.findUnique>>);
|
||||
await applyProfileUpdate("user-tonga", {
|
||||
dateOfBirth: "2012-05-13T20:30:00Z",
|
||||
});
|
||||
expect(writtenDateOfBirth()?.toISOString()).toBe(
|
||||
"2012-05-14T12:00:00.000Z",
|
||||
);
|
||||
});
|
||||
|
||||
it("clears on null and leaves the column alone for a string that names no date", async () => {
|
||||
await applyProfileUpdate(USER_ID, { dateOfBirth: null });
|
||||
expect(writtenDateOfBirth()).toBeNull();
|
||||
vi.mocked(prisma.user.update).mockClear();
|
||||
await applyProfileUpdate(USER_ID, { dateOfBirth: "2012-02-30" });
|
||||
expect(writtenDateOfBirth()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyProfileUpdate timezone validation", () => {
|
||||
it("accepts a valid IANA zone", async () => {
|
||||
const result = await applyProfileUpdate(USER_ID, {
|
||||
|
||||
@@ -7,7 +7,9 @@ import { optionalDisplayNameSchema } from "@/lib/validations/display-name";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { profileSchema } from "@/lib/validations/auth";
|
||||
import { dateOfBirthForStorage } from "@/lib/tz/date-only";
|
||||
import { isValidTimezone } from "@/lib/tz/format";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import {
|
||||
invalidateUserMedications,
|
||||
@@ -187,6 +189,21 @@ export interface ApplyProfileError {
|
||||
issues?: SanitisedZodIssue[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The date of birth to store: the owner's calendar day (the iOS app sends an
|
||||
* instant, read in the zone this request sets or the stored one), null to
|
||||
* clear, and undefined, which leaves the column alone, for a string that
|
||||
* names no date.
|
||||
*/
|
||||
async function storedDateOfBirth(
|
||||
userId: string,
|
||||
data: Partial<ExtendedProfileInput>,
|
||||
): Promise<Date | null | undefined> {
|
||||
if (!data.dateOfBirth) return null;
|
||||
const timezone = data.timezone ?? (await resolveUserTimezone(userId));
|
||||
return dateOfBirthForStorage(data.dateOfBirth, timezone) ?? undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and persist profile updates for `userId`. Returns either an
|
||||
* `ApplyProfileResult` on success or `ApplyProfileError` with a status
|
||||
@@ -434,7 +451,7 @@ export async function applyProfileUpdate(
|
||||
if (data.email !== undefined) updates.email = normalizedEmail;
|
||||
if (data.heightCm !== undefined) updates.heightCm = data.heightCm ?? null;
|
||||
if (data.dateOfBirth !== undefined) {
|
||||
updates.dateOfBirth = data.dateOfBirth ? new Date(data.dateOfBirth) : null;
|
||||
updates.dateOfBirth = await storedDateOfBirth(userId, data);
|
||||
}
|
||||
if (data.gender !== undefined) updates.gender = data.gender;
|
||||
if (data.displayName !== undefined) {
|
||||
|
||||
@@ -86,6 +86,7 @@ export interface DoctorReportData {
|
||||
* login, never a managed record's generated handle. Null prints no line.
|
||||
*/
|
||||
name: string | null;
|
||||
/** The calendar date, `YYYY-MM-DD` (`dateOfBirthKey`). */
|
||||
dateOfBirth: string | null;
|
||||
gender: string | null;
|
||||
heightCm: number | null;
|
||||
|
||||
@@ -500,6 +500,35 @@ describe("the name the report prints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("the date of birth the report prints", () => {
|
||||
const identity = selectionFromLeaves(["PATIENT_IDENTITY"]);
|
||||
const born = async (dateOfBirth: Date, timezone: string) => {
|
||||
rows.userUnique = [
|
||||
{ ...(rows.userUnique[0] as object), dateOfBirth, timezone },
|
||||
];
|
||||
const payload = await collectDoctorReportData("u1", RANGE, identity, {
|
||||
moduleMap: moduleMap(),
|
||||
});
|
||||
return payload.patient.dateOfBirth;
|
||||
};
|
||||
|
||||
it("prints a date stored at UTC midnight as that date in every zone", async () => {
|
||||
const stored = new Date("1960-05-14T00:00:00.000Z");
|
||||
expect(await born(stored, "Pacific/Honolulu")).toBe("1960-05-14");
|
||||
expect(await born(stored, "Pacific/Tongatapu")).toBe("1960-05-14");
|
||||
});
|
||||
|
||||
it("prints an instant from the iOS app as its date in the owner's zone", async () => {
|
||||
// 14 May entered at 00:30 in Berlin, and at 20:00 in Honolulu.
|
||||
expect(
|
||||
await born(new Date("1960-05-13T23:30:00.000Z"), "Europe/Berlin"),
|
||||
).toBe("1960-05-14");
|
||||
expect(
|
||||
await born(new Date("1960-05-15T06:00:00.000Z"), "Pacific/Honolulu"),
|
||||
).toBe("1960-05-14");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fail-closed exclusion", () => {
|
||||
it("excludes a measurement type the catalogue does not know", () => {
|
||||
const gate = resolveReportGate(
|
||||
|
||||
@@ -83,7 +83,7 @@ import {
|
||||
loadSurgicalHistory,
|
||||
loadLabResults,
|
||||
} from "./clinical-records";
|
||||
import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
|
||||
import { dateOfBirthKey, dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
|
||||
import { PATIENT_NAME_SELECT, patientName } from "@/lib/account-name";
|
||||
import { readingsMean } from "@/lib/measurements/day-mean";
|
||||
import { loadScreenerUnvalidatedTranslations } from "./screener-translations";
|
||||
@@ -723,7 +723,7 @@ export async function collectDoctorReportData(
|
||||
name: identityOn && userProfile ? patientName(userProfile) : null,
|
||||
dateOfBirth:
|
||||
identityOn && userProfile?.dateOfBirth
|
||||
? userProfile.dateOfBirth.toISOString()
|
||||
? dateOfBirthKey(userProfile.dateOfBirth, reportTz)
|
||||
: null,
|
||||
gender: identityOn ? (userProfile?.gender ?? null) : null,
|
||||
heightCm: identityOn ? (userProfile?.heightCm ?? null) : null,
|
||||
|
||||
@@ -346,7 +346,7 @@ describe("generateBloodPressureStatusForUser — no rate without an expectation"
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateBloodPressureStatusForUser — the person's own target", () => {
|
||||
describe("generateBloodPressureStatusForUser and the person's own target", () => {
|
||||
const readings = [1, 2, 3].flatMap((daysAgo) => {
|
||||
const measuredAt = new Date(Date.now() - daysAgo * dayMs);
|
||||
return [
|
||||
|
||||
@@ -422,7 +422,7 @@ describe("extractFeatures — weight target direction (#1006)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractFeatures — blood pressure against the person's own target", () => {
|
||||
describe("extractFeatures: blood pressure against the person's own target", () => {
|
||||
// 135/85 on three days: above the age default's ceiling, inside a target
|
||||
// the person's doctor agreed (up to 140/90).
|
||||
const bpRows = [1, 2, 3].flatMap((daysAgo) => {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { waitForMedicationIntakeImport } from "../intake-import-poll";
|
||||
import {
|
||||
MedicationIntakeImportTimeout,
|
||||
waitForMedicationIntakeImport,
|
||||
} from "../intake-import-poll";
|
||||
|
||||
describe("waitForMedicationIntakeImport", () => {
|
||||
it("polls queued and running jobs until the durable result is done", async () => {
|
||||
@@ -53,4 +56,19 @@ describe("waitForMedicationIntakeImport", () => {
|
||||
expect(readStatus).toHaveBeenCalledOnce();
|
||||
expect(wait).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("tells a watch that gave up from a job that failed", async () => {
|
||||
// The job may still write, so the card must not say nothing was written.
|
||||
const readStatus = vi.fn().mockResolvedValue({
|
||||
status: "running",
|
||||
result: null,
|
||||
});
|
||||
await expect(
|
||||
waitForMedicationIntakeImport("/api/import/job-1/status", {
|
||||
readStatus,
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
maxAttempts: 2,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(MedicationIntakeImportTimeout);
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,14 @@ export class MedicationIntakeImportError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** The job did not settle while the client watched; it may still write. */
|
||||
export class MedicationIntakeImportTimeout extends MedicationIntakeImportError {
|
||||
constructor() {
|
||||
super("Medication intake import timed out");
|
||||
this.name = "MedicationIntakeImportTimeout";
|
||||
}
|
||||
}
|
||||
|
||||
const waitForNextPoll = () =>
|
||||
new Promise<void>((resolve) => setTimeout(resolve, 1_000));
|
||||
|
||||
@@ -52,5 +60,5 @@ export async function waitForMedicationIntakeImport(
|
||||
if (attempt + 1 < maxAttempts) await wait();
|
||||
}
|
||||
|
||||
throw new MedicationIntakeImportError("Medication intake import timed out");
|
||||
throw new MedicationIntakeImportTimeout();
|
||||
}
|
||||
@@ -86,6 +86,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
"auth.account.deletion_pending",
|
||||
"auth.admin.required",
|
||||
"auth.already_authenticated",
|
||||
"auth.mfa.already_active",
|
||||
"auth.mfa.code_invalid",
|
||||
"auth.missing",
|
||||
"auth.passkey.last_method",
|
||||
@@ -140,6 +141,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
"coach-prefs.body.invalid_shape",
|
||||
],
|
||||
consent: ["consent.ai.confirmRequired", "consent.ai.required"],
|
||||
customMetric: ["customMetric.name_taken"],
|
||||
cycle: [
|
||||
"cycle.bulk.invalid",
|
||||
"cycle.bulk.too_large",
|
||||
|
||||
@@ -565,7 +565,7 @@ export const insightsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Insights"],
|
||||
summary: "Per-metric target tiles + consistency strips",
|
||||
description:
|
||||
"The whole targets page in one read: a tile per metric with its band, its current and 30-day-average values, and the seven-day consistency strip behind it, plus the page summary, the diastolic companion figures and the profile facts every band was resolved from. Pure compute over the record's own data — no provider anywhere on the path. Stale-while-revalidate: past the fresh window the prior body serves immediately while one background rebuild warms the cell, and a write hard-evicts the bucket so the account's own action lands on the next read. Delegable at MANAGE level over the whole record: the walk spans vitals, sleep, medications, mood and glucose, so a section-scoped grant is refused rather than served a filtered page. Not module-gated as a whole; a tile whose data does not exist is simply absent. Cookie or Bearer auth.",
|
||||
"The whole targets page in one read: a tile per metric with its band, its current and 30-day-average values, and the seven-day consistency strip behind it, plus the page summary, the diastolic companion figures and the profile facts every band was resolved from. Pure compute over the record's own data — no provider anywhere on the path. Stale-while-revalidate: past the fresh window the prior body serves immediately while one background rebuild warms the cell, and a write hard-evicts the bucket so the account's own action lands on the next read. Delegable at READ level over the whole record, like the dashboard snapshot: the walk spans vitals, sleep, medications, mood and glucose, so a section-scoped grant is refused rather than served a filtered page. Not module-gated as a whole; a tile whose data does not exist is simply absent. Cookie or Bearer auth.",
|
||||
responses: {
|
||||
...recordRefusal(),
|
||||
"200": {
|
||||
|
||||
@@ -76,7 +76,7 @@ const notificationPreferencesResponse = z
|
||||
eventTypes: z
|
||||
.array(z.string())
|
||||
.describe(
|
||||
"The event categories this account is shown a switch for, in declaration order — the list a client renders rows for. A category nothing sends is not listed, and `preferences` carries no stored row for it.",
|
||||
"The event categories this account is shown a switch for, in declaration order: the list a client renders rows for. A category nothing sends is not listed, and `preferences` carries no stored row for it.",
|
||||
),
|
||||
eventDefaults: z
|
||||
.record(eventTypeEnum, z.boolean())
|
||||
|
||||
@@ -274,3 +274,50 @@ describe("buildVitalTargets — user weight target", () => {
|
||||
expect(weight?.source).toBe("Custom");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildVitalTargets — a child's record", () => {
|
||||
// 118 cm and 21 kg: a BMI of about 15, normal for a six-year-old and red
|
||||
// under the adult cut-offs.
|
||||
const child = {
|
||||
recentMeasurements: [],
|
||||
restingPulseProxy: [],
|
||||
latestByType: { WEIGHT: 21 },
|
||||
average30ByType: { WEIGHT: 21 },
|
||||
heightCm: 118,
|
||||
age: 6,
|
||||
gender: null,
|
||||
timezone: TZ,
|
||||
now: NOW,
|
||||
weightTargetOverride: null,
|
||||
adultBands: false,
|
||||
};
|
||||
const card = (
|
||||
targets: ReturnType<typeof buildVitalTargets>["targets"],
|
||||
type: string,
|
||||
) => targets.find((t) => t.type === type);
|
||||
|
||||
it("keeps the values and names no adult band or category", () => {
|
||||
const { targets } = buildVitalTargets(child);
|
||||
expect(card(targets, "WEIGHT")).toMatchObject({
|
||||
current: 21,
|
||||
range: null,
|
||||
classification: null,
|
||||
});
|
||||
expect(card(targets, "BMI")).toMatchObject({
|
||||
current: 15.1,
|
||||
range: null,
|
||||
classification: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the record's own weight target", () => {
|
||||
const { targets } = buildVitalTargets({
|
||||
...child,
|
||||
weightTargetOverride: { min: 19, max: 24 },
|
||||
});
|
||||
expect(card(targets, "WEIGHT")).toMatchObject({
|
||||
range: { min: 19, max: 24 },
|
||||
source: "Custom",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
COURSES_COMPLIANCE_SELECT,
|
||||
SCHEDULE_COMPLIANCE_SELECT,
|
||||
} from "@/lib/analytics/compliance";
|
||||
import { ageInYears } from "@/lib/tz/date-only";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
|
||||
import pLimit from "p-limit";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
@@ -56,19 +57,6 @@ import {
|
||||
import type { User } from "@/generated/prisma/client";
|
||||
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
|
||||
|
||||
function getAge(dateOfBirth: Date): number {
|
||||
const today = new Date();
|
||||
let age = today.getFullYear() - dateOfBirth.getFullYear();
|
||||
const monthDiff = today.getMonth() - dateOfBirth.getMonth();
|
||||
if (
|
||||
monthDiff < 0 ||
|
||||
(monthDiff === 0 && today.getDate() < dateOfBirth.getDate())
|
||||
) {
|
||||
age--;
|
||||
}
|
||||
return age;
|
||||
}
|
||||
|
||||
/**
|
||||
* The authenticated-user shape the builder reads directly. The profile
|
||||
* fields (height / dob / gender / glucose unit / thresholds) are re-read
|
||||
@@ -128,6 +116,7 @@ export async function buildTargetsResponse(user: AuthedUser) {
|
||||
select: {
|
||||
heightCm: true,
|
||||
dateOfBirth: true,
|
||||
managedProfileAt: true,
|
||||
gender: true,
|
||||
glucoseUnit: true,
|
||||
hasDiabetes: true,
|
||||
@@ -256,7 +245,7 @@ export async function buildTargetsResponse(user: AuthedUser) {
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const age = dbUser?.dateOfBirth ? getAge(new Date(dbUser.dateOfBirth)) : null;
|
||||
const age = ageInYears(dbUser?.dateOfBirth ?? null, timezone, now);
|
||||
const gender = toProfileSex(dbUser?.gender);
|
||||
const heightCm = dbUser?.heightCm ?? null;
|
||||
const latestByType: TargetValueByType = {};
|
||||
@@ -286,6 +275,7 @@ export async function buildTargetsResponse(user: AuthedUser) {
|
||||
average30ByType,
|
||||
heightCm,
|
||||
age,
|
||||
adultBands: age != null ? age >= 18 : dbUser?.managedProfileAt == null,
|
||||
gender,
|
||||
timezone,
|
||||
now,
|
||||
|
||||
@@ -74,6 +74,13 @@ interface VitalTargetsInput {
|
||||
* names a category, not a goal. Absent or `null` keeps the age band.
|
||||
*/
|
||||
bpTargetOverride?: BloodPressureTargetRange | null;
|
||||
/**
|
||||
* Whether the adult WHO weight and BMI cut-offs apply. No band here knows a
|
||||
* child's age yet, so for a record under 18, or a managed one with no date
|
||||
* of birth, the weight card keeps only the record's own target, the BMI
|
||||
* card names no band, and neither names a category. Absent means they do.
|
||||
*/
|
||||
adultBands?: boolean;
|
||||
}
|
||||
|
||||
export interface VitalTargetsResult {
|
||||
@@ -170,6 +177,7 @@ export function buildVitalTargets({
|
||||
now,
|
||||
weightTargetOverride,
|
||||
bpTargetOverride = null,
|
||||
adultBands = true,
|
||||
}: VitalTargetsInput): VitalTargetsResult {
|
||||
const targets: TargetItem[] = [];
|
||||
const consistencyClock = { timezone, now };
|
||||
@@ -181,11 +189,12 @@ export function buildVitalTargets({
|
||||
// v1.34 — a user-set target outranks the WHO BMI band. The BMI
|
||||
// classification below stays WHO-derived either way: it names which BMI
|
||||
// category a reading falls in, which is a reference fact and not a goal.
|
||||
const whoHeightCm = adultBands ? heightCm : null;
|
||||
const weightRange =
|
||||
weightTargetOverride ?? (heightCm ? getWeightRange(heightCm) : null);
|
||||
weightTargetOverride ?? (whoHeightCm ? getWeightRange(whoHeightCm) : null);
|
||||
let weightClassification: TargetItem["classification"] = null;
|
||||
if (latestByType.WEIGHT != null && heightCm) {
|
||||
const heightM = heightCm / 100;
|
||||
if (latestByType.WEIGHT != null && whoHeightCm) {
|
||||
const heightM = whoHeightCm / 100;
|
||||
const classification = classifyBMI(
|
||||
latestByType.WEIGHT / (heightM * heightM),
|
||||
);
|
||||
@@ -389,14 +398,14 @@ export function buildVitalTargets({
|
||||
? Math.round((averageWeight / heightSquared) * 10) / 10
|
||||
: null;
|
||||
let bmiClassification: TargetItem["classification"] = null;
|
||||
if (currentBmi != null) {
|
||||
if (currentBmi != null && adultBands) {
|
||||
const classification = classifyBMI(currentBmi);
|
||||
bmiClassification = {
|
||||
category: classification.category,
|
||||
color: classification.color,
|
||||
};
|
||||
}
|
||||
const bmiRange = { min: 18.5, max: 24.9 };
|
||||
const bmiRange = adultBands ? { min: 18.5, max: 24.9 } : null;
|
||||
targets.push({
|
||||
type: "BMI",
|
||||
label: "BMI",
|
||||
|
||||
@@ -40,10 +40,10 @@ function courseEnded(
|
||||
endedAt: Date | null;
|
||||
},
|
||||
timeZone: string | null,
|
||||
at: Date = new Date(),
|
||||
): boolean {
|
||||
return (
|
||||
courseStatusAt(medication, new Date(), timeZone || DEFAULT_TIMEZONE) ===
|
||||
"ENDED"
|
||||
courseStatusAt(medication, at, timeZone || DEFAULT_TIMEZONE) === "ENDED"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -61,16 +61,24 @@ export async function listTelegramDoseMedications(
|
||||
return medications.filter((m) => !courseEnded(m, timeZone));
|
||||
}
|
||||
|
||||
/** The one medication `where` names, or null when the bot may not act on it. */
|
||||
/**
|
||||
* The medication `where` names, or null when the bot may not act on it. A
|
||||
* name can match an ended course beside the current one, so the course is
|
||||
* judged on every match. `at` is the moment the dose is for: a reminder's
|
||||
* button books its own slot, so the last evening dose of a course can still
|
||||
* be confirmed after midnight.
|
||||
*/
|
||||
export async function findTelegramDoseMedication(
|
||||
userId: string,
|
||||
timeZone: string | null,
|
||||
where: Prisma.MedicationWhereInput,
|
||||
at: Date = new Date(),
|
||||
) {
|
||||
if (!(await isModuleEnabled(userId, "medications"))) return null;
|
||||
const medication = await prisma.medication.findFirst({
|
||||
const medications = await prisma.medication.findMany({
|
||||
where: { ...where, userId, active: true, ...TRACKED_INTAKE_WHERE },
|
||||
select: DOSE_MEDICATION_SELECT,
|
||||
orderBy: { id: "asc" },
|
||||
});
|
||||
return medication && !courseEnded(medication, timeZone) ? medication : null;
|
||||
return medications.find((m) => !courseEnded(m, timeZone, at)) ?? null;
|
||||
}
|
||||
@@ -266,9 +266,24 @@ async function markMedicationTaken(
|
||||
reminderRef?: { chatId: string; messageId: number },
|
||||
): Promise<{ ok: boolean; message: string; medicationName?: string }> {
|
||||
const { t } = getServerTranslator(locale);
|
||||
const medication = await findTelegramDoseMedication(userId, userTz, {
|
||||
id: medicationId,
|
||||
});
|
||||
const tz = userTz || DEFAULT_TIMEZONE;
|
||||
// The reminder's own slot, when the message names one: the course is
|
||||
// judged on that dose, not on the moment of the tap.
|
||||
const reminderSlot = reminderRef
|
||||
? await resolveReminderSlot({
|
||||
userId,
|
||||
medicationId,
|
||||
userTz: tz,
|
||||
chatId: reminderRef.chatId,
|
||||
messageId: reminderRef.messageId,
|
||||
})
|
||||
: null;
|
||||
const medication = await findTelegramDoseMedication(
|
||||
userId,
|
||||
userTz,
|
||||
{ id: medicationId },
|
||||
reminderSlot ?? undefined,
|
||||
);
|
||||
if (!medication) {
|
||||
return { ok: false, message: t("telegram.errorMedicationInactive") };
|
||||
}
|
||||
@@ -281,7 +296,6 @@ async function markMedicationTaken(
|
||||
let scheduledFor: Date | null = null;
|
||||
if (!existing) {
|
||||
const takenAt = new Date();
|
||||
const tz = userTz || DEFAULT_TIMEZONE;
|
||||
|
||||
// v1.16.9 — converge onto the slot's canonical row instead of bare-
|
||||
// creating a second row anchored at `now`. The old shape left the
|
||||
@@ -289,15 +303,7 @@ async function markMedicationTaken(
|
||||
// and compliance punished a dose the user explicitly confirmed.
|
||||
// Prefer the reminder's own slot (the message names the dose it fired
|
||||
// for); fall back to band attribution on the tap instant.
|
||||
let canonicalSlot: Date | null = reminderRef
|
||||
? await resolveReminderSlot({
|
||||
userId,
|
||||
medicationId: medication.id,
|
||||
userTz: tz,
|
||||
chatId: reminderRef.chatId,
|
||||
messageId: reminderRef.messageId,
|
||||
})
|
||||
: null;
|
||||
let canonicalSlot: Date | null = reminderSlot;
|
||||
if (canonicalSlot === null) {
|
||||
const attribution = await resolveSlotForWriteByBand({
|
||||
userId,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ageInYears,
|
||||
dateOfBirthForStorage,
|
||||
dateOfBirthKey,
|
||||
isNoonUtcAnchor,
|
||||
dateOnlyAtNoonUtc,
|
||||
dateOnlyKey,
|
||||
@@ -62,6 +65,86 @@ describe("isNoonUtcAnchor", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("date of birth", () => {
|
||||
// UTC-10 and UTC+13 the year round.
|
||||
const WEST = "Pacific/Honolulu";
|
||||
const EAST = "Pacific/Tongatapu";
|
||||
|
||||
it("reads a date-only anchor by its date in every zone", () => {
|
||||
for (const zone of [WEST, EAST, "Europe/Berlin"]) {
|
||||
expect(dateOfBirthKey(new Date("2012-05-14T00:00:00.000Z"), zone)).toBe(
|
||||
"2012-05-14",
|
||||
);
|
||||
expect(dateOfBirthKey(new Date("2012-05-14T12:00:00.000Z"), zone)).toBe(
|
||||
"2012-05-14",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("reads an instant from the iOS app as its date in the owner's zone", () => {
|
||||
// 14 May picked at 20:00 in Honolulu, and at 09:30 in Nuku'alofa.
|
||||
expect(dateOfBirthKey(new Date("2012-05-15T06:00:00.000Z"), WEST)).toBe(
|
||||
"2012-05-14",
|
||||
);
|
||||
expect(dateOfBirthKey(new Date("2012-05-13T20:30:00.000Z"), EAST)).toBe(
|
||||
"2012-05-14",
|
||||
);
|
||||
});
|
||||
|
||||
it("stores a typed date and an iOS instant as the same calendar date", () => {
|
||||
const noon = "2012-05-14T12:00:00.000Z";
|
||||
expect(dateOfBirthForStorage("2012-05-14", WEST)?.toISOString()).toBe(noon);
|
||||
expect(
|
||||
dateOfBirthForStorage("2012-05-15T06:00:00.000Z", WEST)?.toISOString(),
|
||||
).toBe(noon);
|
||||
expect(
|
||||
dateOfBirthForStorage("2012-05-13T20:30:00+00:00", EAST)?.toISOString(),
|
||||
).toBe(noon);
|
||||
});
|
||||
|
||||
it("keeps a stored value that comes back unchanged", () => {
|
||||
for (const echoed of [
|
||||
"2012-05-14T00:00:00.000Z",
|
||||
"2012-05-14T12:00:00.000Z",
|
||||
]) {
|
||||
expect(dateOfBirthForStorage(echoed, WEST)?.toISOString()).toBe(
|
||||
"2012-05-14T12:00:00.000Z",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("stores nothing for a string that names no date", () => {
|
||||
expect(dateOfBirthForStorage("2012-02-30", WEST)).toBeNull();
|
||||
expect(dateOfBirthForStorage("not a date", WEST)).toBeNull();
|
||||
});
|
||||
|
||||
it("counts the age from the birthday on the owner's calendar", () => {
|
||||
const iosEntry = new Date("2012-05-13T20:30:00.000Z"); // 14 May in EAST
|
||||
expect(
|
||||
ageInYears(iosEntry, EAST, new Date("2026-05-13T10:59:00.000Z")),
|
||||
).toBe(13);
|
||||
// 00:00 on 14 May in Nuku'alofa.
|
||||
expect(
|
||||
ageInYears(iosEntry, EAST, new Date("2026-05-13T11:00:00.000Z")),
|
||||
).toBe(14);
|
||||
expect(
|
||||
ageInYears(
|
||||
new Date("2012-05-14T00:00:00.000Z"),
|
||||
WEST,
|
||||
new Date("2026-05-14T09:59:00.000Z"),
|
||||
),
|
||||
).toBe(13);
|
||||
// 00:00 on 14 May in Honolulu.
|
||||
expect(
|
||||
ageInYears(
|
||||
new Date("2012-05-14T00:00:00.000Z"),
|
||||
WEST,
|
||||
new Date("2026-05-14T10:00:00.000Z"),
|
||||
),
|
||||
).toBe(14);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dateOnlyWindow", () => {
|
||||
it("spans UTC midnight to the next UTC midnight, both days included", () => {
|
||||
expect(dateOnlyWindow("2026-11-11")).toEqual({
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
* migration.
|
||||
*/
|
||||
|
||||
import { userDayKey } from "./format";
|
||||
|
||||
const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/u;
|
||||
|
||||
/** True for a strict `YYYY-MM-DD` string. */
|
||||
@@ -85,6 +87,58 @@ export function dateOnlyKey(value: Date): string {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* The calendar date a stored date of birth names, in its owner's `timezone`.
|
||||
*
|
||||
* The web and the managed profiles store the date as a date-only anchor (UTC
|
||||
* midnight, or noon), read by its date. The iOS app sent the instant its date
|
||||
* picker returned, which names the day it falls on in the owner's zone: a
|
||||
* Berlin entry of 14 May made at half past midnight is 23:30 UTC on 13 May,
|
||||
* and read by its UTC date it printed the day before.
|
||||
*/
|
||||
export function dateOfBirthKey(value: Date, timezone: string): string {
|
||||
const anchored =
|
||||
value.getUTCHours() % 12 === 0 &&
|
||||
value.getUTCMinutes() === 0 &&
|
||||
value.getUTCSeconds() === 0 &&
|
||||
value.getUTCMilliseconds() === 0;
|
||||
return anchored ? dateOnlyKey(value) : userDayKey(value, timezone);
|
||||
}
|
||||
|
||||
/**
|
||||
* A date of birth as a client sends it (`YYYY-MM-DD`, or an instant from the
|
||||
* iOS app) as it is stored: its calendar date, read the way
|
||||
* {@link dateOfBirthKey} reads a stored value, at noon UTC. Noon keeps the
|
||||
* date wherever a client reads the stored instant back in its own zone from
|
||||
* UTC-11 to UTC+11, where UTC midnight is the evening before everywhere
|
||||
* west of UTC. Null when the string names no date.
|
||||
*/
|
||||
export function dateOfBirthForStorage(
|
||||
raw: string,
|
||||
timezone: string,
|
||||
): Date | null {
|
||||
if (isDateOnlyKey(raw)) {
|
||||
return isCalendarDateKey(raw) ? dateOnlyAtNoonUtc(raw) : null;
|
||||
}
|
||||
const instant = new Date(raw);
|
||||
if (Number.isNaN(instant.getTime())) return null;
|
||||
return dateOnlyAtNoonUtc(dateOfBirthKey(instant, timezone));
|
||||
}
|
||||
|
||||
/** Whole years of age on `now` in the owner's `timezone`; null when unknown. */
|
||||
export function ageInYears(
|
||||
dateOfBirth: Date | null,
|
||||
timezone: string,
|
||||
now: Date = new Date(),
|
||||
): number | null {
|
||||
if (!dateOfBirth) return null;
|
||||
const [by, bm, bd] = dateOfBirthKey(dateOfBirth, timezone)
|
||||
.split("-")
|
||||
.map(Number);
|
||||
const [ty, tm, td] = userDayKey(now, timezone).split("-").map(Number);
|
||||
return ty - by - (tm < bm || (tm === bm && td < bd) ? 1 : 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calendar date `key` as UTC midnight. A LABEL, never the instant the day
|
||||
* began for anyone: it is how a `@db.Date` column holds a date (Prisma reads
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Logging the next dose re-anchors a booster reminder on the dose date. The
|
||||
* dose date is a calendar date stored at UTC midnight, which is the evening
|
||||
* before west of UTC, so the next booster came due a day early there.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { satisfyBoostersForDose } from "@/lib/vaccinations/satisfy-matching";
|
||||
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
|
||||
import { startOfLocalDayKey } from "@/lib/tz/local-day";
|
||||
|
||||
const TZ = "America/New_York";
|
||||
|
||||
function fakeTx() {
|
||||
const updates: Array<Record<string, unknown>> = [];
|
||||
const reminder = {
|
||||
id: "reminder-1",
|
||||
userId: "u1",
|
||||
vaccinationAntigen: "tetanus",
|
||||
intervalDays: 3650,
|
||||
rrule: null,
|
||||
anchorDate: new Date("2016-03-10T05:00:00.000Z"),
|
||||
notifyHour: 9,
|
||||
nextDueAt: new Date("2026-03-07T14:00:00.000Z"),
|
||||
lastSatisfiedAt: null,
|
||||
lastSkippedAt: null,
|
||||
createdAt: new Date("2016-03-10T05:00:00.000Z"),
|
||||
endsOn: null,
|
||||
};
|
||||
const tx = {
|
||||
measurementReminder: {
|
||||
findMany: vi.fn(async () => [reminder]),
|
||||
updateMany: vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
|
||||
updates.push(data);
|
||||
return { count: 1 };
|
||||
}),
|
||||
},
|
||||
measurementReminderEvent: { create: vi.fn(async () => ({})) },
|
||||
};
|
||||
return { tx: tx as unknown as Prisma.TransactionClient, updates };
|
||||
}
|
||||
|
||||
describe("satisfyBoostersForDose", () => {
|
||||
it("counts the next interval from the dose's own date west of UTC", async () => {
|
||||
const { tx, updates } = fakeTx();
|
||||
await satisfyBoostersForDose(
|
||||
tx,
|
||||
"u1",
|
||||
{ antigenSlug: "tetanus" },
|
||||
new Date("2026-03-10T00:00:00.000Z"),
|
||||
TZ,
|
||||
);
|
||||
// Ten years of days after 10 March, on the person's calendar.
|
||||
expect(userDayKey(updates[0].nextDueAt as Date, TZ)).toBe(
|
||||
shiftDateKey("2026-03-10", 3650),
|
||||
);
|
||||
expect(updates[0].lastSatisfiedAt).toEqual(
|
||||
startOfLocalDayKey("2026-03-10", TZ),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -52,6 +52,8 @@ import {
|
||||
satisfyReminder,
|
||||
type SatisfiableReminder,
|
||||
} from "@/lib/measurement-reminders/satisfy";
|
||||
import { dateOnlyKey } from "@/lib/tz/date-only";
|
||||
import { startOfLocalDayKey } from "@/lib/tz/local-day";
|
||||
import {
|
||||
componentsForDose,
|
||||
loadCustomVaccineLookup,
|
||||
@@ -136,6 +138,10 @@ export async function satisfyBoostersForDose(
|
||||
return byAntigen !== 0 ? byAntigen : a.id.localeCompare(b.id);
|
||||
});
|
||||
|
||||
// The dose date is a calendar date at UTC midnight, the evening before west
|
||||
// of UTC; the reminder engine reads an instant in the person's zone, so it
|
||||
// gets the start of that day there, as the booster mint does.
|
||||
const satisfiedAt = startOfLocalDayKey(dateOnlyKey(occurredAt), timezone);
|
||||
const satisfiedReminderIds: string[] = [];
|
||||
for (const reminder of ordered) {
|
||||
const row: SatisfiableReminder = {
|
||||
@@ -160,7 +166,7 @@ export async function satisfyBoostersForDose(
|
||||
tx as unknown as PrismaClient,
|
||||
row,
|
||||
timezone,
|
||||
occurredAt,
|
||||
satisfiedAt,
|
||||
"vaccination",
|
||||
);
|
||||
if (result.satisfied) satisfiedReminderIds.push(reminder.id);
|
||||
|
||||
Reference in new issue
Block a user