diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index a0f523a40..808c289bc 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1274,7 +1274,9 @@ paths: - in: query name: _offset schema: + description: At most 100000; a larger offset is a 400 OperationOutcome. type: number + description: At most 100000; a larger offset is a 400 OperationOutcome. responses: "200": description: searchset Bundle (application/fhir+json). @@ -1310,7 +1312,9 @@ paths: - in: query name: _offset schema: + description: At most 100000; a larger offset is a 400 OperationOutcome. type: number + description: At most 100000; a larger offset is a 400 OperationOutcome. - in: query name: date schema: @@ -1345,7 +1349,9 @@ paths: - in: query name: _offset schema: + description: At most 100000; a larger offset is a 400 OperationOutcome. type: number + description: At most 100000; a larger offset is a 400 OperationOutcome. responses: "200": description: searchset Bundle (application/fhir+json). @@ -1372,7 +1378,9 @@ paths: - in: query name: _offset schema: + description: At most 100000; a larger offset is a 400 OperationOutcome. type: number + description: At most 100000; a larger offset is a 400 OperationOutcome. responses: "200": description: searchset Bundle (application/fhir+json). @@ -1400,7 +1408,9 @@ paths: - in: query name: _offset schema: + description: At most 100000; a larger offset is a 400 OperationOutcome. type: number + description: At most 100000; a larger offset is a 400 OperationOutcome. responses: "200": description: searchset Bundle (application/fhir+json). @@ -1595,7 +1605,9 @@ paths: - in: query name: _offset schema: + description: At most 100000; a larger offset is a 400 OperationOutcome. type: number + description: At most 100000; a larger offset is a 400 OperationOutcome. responses: "200": description: searchset Bundle (application/fhir+json). @@ -15308,17 +15320,21 @@ paths: reason `duplicate` and not written. Without it a row is deduplicated on `(type, measuredAt)` and a repeat is `skipped` / `duplicate`. Writes commit in chunks rather than as one transaction; a failure part-way leaves earlier chunks written, and re-uploading the same file completes the rest. `?dryRun=1` validates + previews - without writing. Shares the 5/hour `import:` rate bucket." + without writing. Shares the 5/hour `import:` rate bucket. An unknown query parameter is refused with 422, so a + misspelt `dryRun` never writes." parameters: - - name: dryRun - in: query - required: false + - in: query + name: dryRun schema: + description: "`1` validates every row and reports what would happen, writing nothing." + default: "0" type: string enum: + - "0" - "1" - "true" - description: When `1` / `true`, parse + validate + return the per-row envelope without writing. + - "false" + description: "`1` validates every row and reports what would happen, writing nothing." requestBody: required: true content: @@ -15357,11 +15373,12 @@ paths: refused). Re-uploading the same file writes nothing: every row is reported as a duplicate. `dryRun=1` checks the whole file without writing and returns the first rows as they would be stored; it spends a separate bucket of 30 checks an hour, the import itself the shared 5-an-hour `import:` bucket. Owner only; refused with - `module.disabled` when the module is switched off. Each row's name resolves to the account's marker the way a - scanned report's rows do (name, alias, catalog name); a new analyte mints one marker from its first row. A - numeric result needs a unit; one in another unit is converted when the conversion is known and keeps the printed - value beside it, otherwise the row is refused with `unit_mismatch`. Any other text in the value column is a - qualitative result. A date without a time is stored as that calendar date. At most 20 000 data rows." + `module.disabled` when the module is switched off. An unknown query parameter is refused with 422, so a misspelt + `dryRun` never writes. Each row's name resolves to the account's marker the way a scanned report's rows do + (name, alias, catalog name); a new analyte mints one marker from its first row. A numeric result needs a unit; + one in another unit is converted when the conversion is known and keeps the printed value beside it, otherwise + the row is refused with `unit_mismatch`. Any other text in the value column is a qualitative result. A date + without a time is stored as that calendar date. At most 20 000 data rows." parameters: - in: query name: dryRun @@ -15562,11 +15579,12 @@ paths: refused). Re-uploading the same file writes nothing: every row is reported as a duplicate. `dryRun=1` checks the whole file without writing and returns the first rows as they would be stored; it spends a separate bucket of 30 checks an hour, the import itself the shared 5-an-hour `import:` bucket. Owner only; refused with - `module.disabled` when the module is switched off. Maps name, dose (as written), daily intake times, an - as-needed mark and a start and end date; every other rhythm is set up afterwards in the app. A row needs times - unless it is marked as needed. Each row is checked against the create route's own schema; a dated window opens a - course. Reminders stay off unless `reminders=1`. A medication the record holds under the same name and dose is a - duplicate. At most 500 data rows." + `module.disabled` when the module is switched off. An unknown query parameter is refused with 422, so a misspelt + `dryRun` never writes. Maps name, dose (as written), daily intake times, an as-needed mark and a start and end + date; every other rhythm is set up afterwards in the app. A row needs times unless it is marked as needed. Each + row is checked against the create route's own schema; a dated window opens a course. Reminders stay off unless + `reminders=1`. A medication the record holds under the same name and dose is a duplicate. At most 500 data + rows." parameters: - in: query name: dryRun @@ -49454,7 +49472,8 @@ components: const: ok asOf: type: string - description: The last scored UTC day the figures describe. + description: "The last UTC day with a stored impulse, at most yesterday: a nightly run that has not happened yet never + reads as a rest day." ctl: type: number description: "Fitness: 42-day exponentially weighted TRIMP." @@ -49472,6 +49491,15 @@ components: type: integer minimum: -9007199254740991 maximum: 9007199254740991 + recentRatedDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Days of the trailing `recentDays` (42, or fewer when covered less) with a stored impulse. + recentDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 warmingUp: type: boolean description: "True below 42 covered days: both averages start at zero." @@ -49488,11 +49516,16 @@ components: type: number tsb: type: number + rated: + type: boolean + description: "False for a day without a stored impulse (never scored): the averages held through it rather than counting + it as rest." required: - day - ctl - atl - tsb + - rated additionalProperties: false description: The trailing 90 days, oldest first; `tsb` is form going into the day. required: @@ -49503,6 +49536,8 @@ components: - tsb - daysCovered - trainingDays + - recentRatedDays + - recentDays - warmingUp - series additionalProperties: false @@ -49530,9 +49565,9 @@ components: - daysCovered - trainingDays additionalProperties: false - description: "v1.44 — the fitness-fatigue model over the stored daily heart-rate training impulse (Banister TRIMP; a day - without a stored impulse counts as rest). Needs 28 days and three training days. TRIMP units: comparable with - the person's own past only." + description: "v1.44 — the fitness-fatigue model over the stored daily heart-rate training impulse (Banister TRIMP; a + scored day without training is 0, a day without a stored impulse is not rated and the averages hold through it). + Needs 28 days and three training days. TRIMP units: comparable with the person's own past only." GetWorkoutResponse: type: object properties: @@ -50159,6 +50194,7 @@ components: enum: - none - unrated + - in_progress - low - below - usual @@ -50166,12 +50202,14 @@ components: - high description: "Where the day sits in the person's own spread over the 84 days before the period: `low` below their 10th percentile, `below` below the 25th, `above` above the 75th, `high` above the 90th, `usual` - between. `none` = no value that day; `unrated` = fewer than 14 days of history to compare with." + between. `none` = no value that day; `unrated` = fewer than 14 days of history to compare with; + `in_progress` = today, which has not ended and is never rated." activeMinutes: type: string enum: - none - unrated + - in_progress - low - below - usual @@ -50179,12 +50217,14 @@ components: - high description: "Where the day sits in the person's own spread over the 84 days before the period: `low` below their 10th percentile, `below` below the 25th, `above` above the 75th, `high` above the 90th, `usual` - between. `none` = no value that day; `unrated` = fewer than 14 days of history to compare with." + between. `none` = no value that day; `unrated` = fewer than 14 days of history to compare with; + `in_progress` = today, which has not ended and is never rated." load: type: string enum: - none - unrated + - in_progress - low - below - usual @@ -50192,7 +50232,8 @@ components: - high description: "Where the day sits in the person's own spread over the 84 days before the period: `low` below their 10th percentile, `below` below the 25th, `above` above the 75th, `high` above the 90th, `usual` - between. `none` = no value that day; `unrated` = fewer than 14 days of history to compare with." + between. `none` = no value that day; `unrated` = fewer than 14 days of history to compare with; + `in_progress` = today, which has not ended and is never rated." required: - steps - activeMinutes @@ -50412,6 +50453,17 @@ components: type: integer minimum: -9007199254740991 maximum: 9007199254740991 + completedDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Days of the period that have ended (today excluded). + completedValue: + description: "`value` over the completed days only. This, not `value`, is what `usualToDate` is compared with, so a + running period is never judged on today's partial day." + type: + - number + - "null" usual: description: "The person's usual full period: the median of the twelve weeks (or, per day, of the six months) before it. Null with fewer than three earlier periods of data." @@ -50419,7 +50471,8 @@ components: - number - "null" usualToDate: - description: The usual figure for the days elapsed so far; equals `usual` once the period is over. + description: The usual figure for the completed days (today excluded); equals `usual` once the period is over, null + before the period's first day has ended. type: - number - "null" @@ -50430,6 +50483,8 @@ components: required: - value - daysWithData + - completedDays + - completedValue - usual - usualToDate - referencePeriods @@ -54861,12 +54916,18 @@ components: type: integer minimum: -9007199254740991 maximum: 9007199254740991 + description: "Newey–West lag applied: the automatic rule, at least 7 (a week of daily carry-over)." + autocorrelationCaveat: + type: boolean + description: "True when the lag-1 residual autocorrelation exceeds 0.5: the carry-over is strong enough that the + intervals may still be too narrow, and the read-out says so." required: - n - df - rSquared - residualAutocorrelation - hacLag + - autocorrelationCaveat additionalProperties: false - type: "null" confounders: @@ -74758,7 +74819,11 @@ components: description: The line's value on `asOf`. asOf: type: string - description: YYYY-MM-DD the line was evaluated on. + description: "YYYY-MM-DD the line was evaluated on: today, or `lastAt` when `projection` is `stale`." + horizonYears: + type: number + description: "How far ahead a bound may be met and still count as `exits`: three years, or the readings' span when + shorter." projection: type: string enum: @@ -74768,6 +74833,7 @@ components: - already_outside - no_clear_trend - no_band + - stale exitDate: description: "Only for `exits`: the day the straight line meets `exitBound`. A projection, never a forecast." type: @@ -74791,6 +74857,7 @@ components: - pValue - fittedNow - asOf + - horizonYears - projection - exitDate - exitBound @@ -74822,8 +74889,9 @@ components: description: "v1.44 — the least-squares line through every numeric reading of the marker (all stored in the marker's unit). Needs at least three readings spanning at least 365 days, else `insufficient`. `projection` places the line against the marker's band: `exits` (with `exitDate`) only when the slope's 95 % interval excludes zero, the - line is inside the band today and reaches a bound within three years. Server-computed; clients render it as - given." + line is inside the band today and reaches a bound within `horizonYears` (three years, or the readings' span when + shorter). `stale` when the newest reading is more than 365 days old: the line is then reported as of `lastAt`, + with no projection. Server-computed; clients render it as given." UpdateBiomarkerResponse: type: object properties: diff --git a/e2e/activity-calendar.spec.ts b/e2e/activity-calendar.spec.ts index 3cfae6517..ef6b547ce 100644 --- a/e2e/activity-calendar.spec.ts +++ b/e2e/activity-calendar.spec.ts @@ -32,7 +32,9 @@ test.describe("activity calendar", () => { await page.locator('[data-slot="activity-calendar-previous"]').click(); await expect(label).not.toHaveText(before ?? ""); - await card.getByRole("radio", { name: "Week" }).click(); + await expect(card).toHaveAttribute("data-period", "month"); + await page.locator('[data-slot="activity-calendar-period-week"]').click(); + await expect(card).toHaveAttribute("data-period", "week"); if ((await days.count()) > 0) await expect(days).toHaveCount(7); // Nothing in the card pushes the page sideways on a phone. @@ -47,22 +49,20 @@ test.describe("activity calendar", () => { test("saves a goal and shows its progress", async ({ page }) => { await page.goto("/insights/workouts"); await page.locator('[data-slot="activity-calendar-set-goals"]').click(); - await page.getByLabel("Steps per month").fill("150000"); + const field = page.locator( + '[data-slot="activity-goals-form"] [data-goal-key="stepsPerMonth"]', + ); + const goal = page.locator( + '[data-slot="activity-calendar-goal"][data-goal-key="stepsPerMonth"]', + ); + await field.fill("150000"); await page.locator('[data-slot="activity-goals-save"]').click(); - await expect( - page - .locator('[data-slot="activity-calendar-goals"]') - .getByText("Steps per month"), - ).toBeVisible(); + await expect(goal).toBeVisible(); // Clear it again so the account is left as it was found. await page.locator('[data-slot="activity-calendar-set-goals"]').click(); - await page.getByLabel("Steps per month").fill(""); + await field.fill(""); await page.locator('[data-slot="activity-goals-save"]').click(); - await expect( - page - .locator('[data-slot="activity-calendar-goals"]') - .getByText("Steps per month"), - ).toHaveCount(0); + await expect(goal).toHaveCount(0); }); }); diff --git a/e2e/settings-admin-one-piece.spec.ts b/e2e/settings-admin-one-piece.spec.ts index 3f10f6540..2b8e70726 100644 --- a/e2e/settings-admin-one-piece.spec.ts +++ b/e2e/settings-admin-one-piece.spec.ts @@ -58,7 +58,6 @@ const SETTINGS_ROUTES = [ "/settings/api", "/settings/mcp", "/settings/advanced", - "/settings/preview", "/settings/about", ] as const; diff --git a/messages/de.json b/messages/de.json index 255887f9c..d78afea07 100644 --- a/messages/de.json +++ b/messages/de.json @@ -431,7 +431,10 @@ "notTracked": "Einnahme nicht erfasst, daher keine Tage wegen ausgelassener Dosen entfernt", "endDoseChange": "Zeitraum endet am {date}, vor der ersten Dosisänderung", "days": "{before} Tage vor, {after} Tage nach dem Start, {excluded} Tage ausgelassen", - "method": "Segmentierte Regression über die Tageswerte von 90 Tagen vor bis höchstens 180 Tagen nach dem Start, Intervalle nach Newey-West. Tage nach dem Start, an denen in den 14 Tagen davor weniger als 80 % der Dosen eingenommen wurden, sind ausgelassen. Beschreibt einen zeitlichen Zusammenhang, keine nachgewiesene Wirkung des Medikaments." + "method": "Segmentierte Regression über die Tageswerte von 90 Tagen vor bis höchstens 180 Tagen nach dem Start, Intervalle nach Newey-West. Tage nach dem Start, an denen in den 14 Tagen davor weniger als 80 % der Dosen eingenommen wurden, sind ausgelassen. Beschreibt einen zeitlichen Zusammenhang, keine nachgewiesene Wirkung des Medikaments.", + "autocorrelation": "starke Abhängigkeit vom Vortag, die Bereiche können daher noch zu eng sein", + "window": "ausgewertet {from} bis {to}, das kann über den Berichtszeitraum hinausreichen", + "windowLine": "Ausgewertet {from} bis {to}. Dieser Zeitraum kann über den Berichtszeitraum hinausreichen." } }, "errorBoundary": { @@ -2412,7 +2415,8 @@ "needStart": "Lege ein Startdatum fest, um die Veränderung zum Start zu sehen.", "needBefore": "Für die Veränderung zum Start braucht es Werte an mindestens {days} Tagen, verteilt über {span} Tage vor dem Start.", "needAfter": "Für die Veränderung zum Start braucht es Werte an mindestens {days} Tagen, verteilt über {span} Tage seit dem Start und an Tagen mit eingenommenen Dosen.", - "fitFailed": "Diese Werte ließen sich nicht auswerten, deshalb wird keine Veränderung zum Start gezeigt." + "fitFailed": "Diese Werte ließen sich nicht auswerten, deshalb wird keine Veränderung zum Start gezeigt.", + "autocorrelation": "Die Tageswerte hängen stark vom Vortag ab, deshalb können selbst diese Bereiche zu eng sein." } }, "import": { @@ -3898,7 +3902,8 @@ "fatigue": "Ermüdung", "form": "Form", "explainer": "Fitness ist dein Trainingsimpuls aus der Herzfrequenz, gemittelt über etwa sechs Wochen, Ermüdung über etwa eine Woche, und Form ist der Unterschied. Die Zahlen lassen sich mit deinen eigenen früheren vergleichen, nicht mit anderen Menschen oder Apps.", - "warmingUp": "Auf Basis von bisher {days} Tagen: Bis sechs Wochen abgedeckt sind, fällt die Fitness niedriger aus, als sie ist." + "warmingUp": "Auf Basis von bisher {days} Tagen: Bis sechs Wochen abgedeckt sind, fällt die Fitness niedriger aus, als sie ist.", + "coverage": "{rated} der letzten {days} Tage haben Herzfrequenzdaten. Tage ohne sie bleiben außen vor, statt als Ruhetag zu zählen." } }, "bloodGlucose": { @@ -6136,10 +6141,6 @@ "access": { "title": "Geteilter Zugriff", "subtitle": "Wer deine Gesundheitsakte öffnen kann und wessen Akte du öffnen kannst." - }, - "preview": { - "title": "Vorschau-Funktionen", - "subtitle": "Probier aus, was noch nicht fertig ist. Alles hier bleibt aus, bis du es einschaltest." } }, "profile": "Profil", @@ -7623,17 +7624,6 @@ "passkeyRemovedToast": "Passkey entfernt.", "integrationDisconnected": "{provider} getrennt.", "integrationDisconnectFailed": "{provider} konnte nicht getrennt werden.", - "preview": { - "topics": { - "title": "Themen (Beta)", - "description": "Fügt einen eigenen Eintrag Themen hinzu, sobald die Übersichtsseiten über den einzelnen Insights-Seiten fertig sind.", - "detail": "Deine Insights-Seiten, die Navigation und alle Links bleiben genau wie sie sind. Die Themenseiten kommen mit einem späteren Update; dieser Schalter legt fest, ob du sie siehst.", - "savedOn": "Themen-Vorschau eingeschaltet.", - "savedOff": "Themen-Vorschau ausgeschaltet." - }, - "loadError": "Deine Vorschau-Einstellungen konnten nicht geladen werden.", - "feedbackLink": "Feedback in den GitHub-Diskussionen geben" - }, "timezoneSaved": "Zeitzone gespeichert.", "moduleState": { "on": "An", @@ -10207,10 +10197,11 @@ "high": "Liefe diese Gerade so weiter, erreichte sie um den {date} das obere Ende deines Referenzbereichs. Das ist eine Fortschreibung, keine Vorhersage.", "low": "Liefe diese Gerade so weiter, erreichte sie um den {date} das untere Ende deines Referenzbereichs. Das ist eine Fortschreibung, keine Vorhersage." }, - "beyond_horizon": "In diesem Tempo bleibt die Gerade noch mindestens drei Jahre in deinem Referenzbereich.", + "beyond_horizon": "In diesem Tempo bleibt die Gerade länger als {years} Jahre in deinem Referenzbereich, so weit reichen deine Werte zurück.", "no_exit": "Die Gerade läuft von den Grenzen deines Referenzbereichs weg.", "already_outside": "Die Gerade liegt bereits außerhalb deines Referenzbereichs.", - "no_clear_trend": "Über diese Werte ist die Veränderung nicht klar von keiner Veränderung zu unterscheiden, deshalb wird nichts fortgeschrieben." + "no_clear_trend": "Über diese Werte ist die Veränderung nicht klar von keiner Veränderung zu unterscheiden, deshalb wird nichts fortgeschrieben.", + "stale": "Der neueste Wert ist vom {date}, also älter als ein Jahr. Die Gerade gilt für diesen Stand, ohne Projektion." }, "interval": "95-%-Intervall der jährlichen Veränderung: {lo} bis {hi} {unit}." } @@ -12423,7 +12414,7 @@ "leafSurgicalHistory": "Operationen und Eingriffe", "leafImmunizations": "Impfungen", "leafEmergency": "Notfalldaten (Blutgruppe, Kontakte, Verfügungen)", - "leafMedicationEffect": "Veränderung rund um den Start jedes Medikaments", + "leafMedicationEffect": "Veränderung rund um den Start jedes Medikaments (braucht die Medikamentenliste)", "leafWorkouts": "Trainings (Übersicht je Sportart)", "leafHrvSdnn": "HRV (SDNN)" }, @@ -13639,7 +13630,8 @@ "below": "Unter deinem Üblichen", "usual": "Etwa wie üblich", "above": "Über deinem Üblichen", - "high": "Weit über deinem Üblichen" + "high": "Weit über deinem Üblichen", + "in_progress": "Heute, noch nicht vorbei" }, "legend": "Verglichen mit deinen 12 Wochen davor:", "noBaseline": "Dein üblicher Bereich erscheint nach 14 Tagen mit Daten.", @@ -13649,7 +13641,9 @@ "workouts": "{value} Trainings", "load": "Durchschnittliche Trainingslast {value}", "usual": "Bis hierher üblich bei dir: {value}", - "noUsual": "Noch zu wenige frühere Wochen oder Monate zum Vergleichen." + "noUsual": "Noch zu wenige frühere Wochen oder Monate zum Vergleichen.", + "usualThroughYesterday": "Bis gestern {value}, bis dahin üblich bei dir {usual}", + "firstDay": "Der Vergleich mit deinem Üblichen kommt, sobald der erste Tag vorbei ist." }, "sportCount": "{sport}: {count}", "goal": { diff --git a/messages/en.json b/messages/en.json index d5caa0a3c..10a8e34e6 100644 --- a/messages/en.json +++ b/messages/en.json @@ -431,7 +431,10 @@ "notTracked": "intake not tracked, so no days left out for missed doses", "endDoseChange": "window ends {date}, before the first dose change", "days": "{before} days before, {after} days after the start, {excluded} days left out", - "method": "Segmented regression on daily values from 90 days before to at most 180 days after the start, with Newey-West intervals. Days after the start on which fewer than 80 % of the doses of the preceding 14 days were taken are left out. Describes an association over time, not a proven effect of the medication." + "method": "Segmented regression on daily values from 90 days before to at most 180 days after the start, with Newey-West intervals. Days after the start on which fewer than 80 % of the doses of the preceding 14 days were taken are left out. Describes an association over time, not a proven effect of the medication.", + "autocorrelation": "strong day-to-day carry-over, so the ranges may still be too narrow", + "window": "analysed {from} to {to}, which can reach beyond the report period", + "windowLine": "Analysed {from} to {to}. This window can reach beyond the report period." } }, "errorBoundary": { @@ -2412,7 +2415,8 @@ "needStart": "Set a start date to see the change at the start.", "needBefore": "The change at the start needs readings on at least {days} days spread over {span} days before the start.", "needAfter": "The change at the start needs readings on at least {days} days spread over {span} days since the start, on days the doses were taken.", - "fitFailed": "These readings could not be fitted, so no change at the start is shown." + "fitFailed": "These readings could not be fitted, so no change at the start is shown.", + "autocorrelation": "The daily values carry over strongly from one day to the next, so even these ranges may be too narrow." } }, "import": { @@ -3898,7 +3902,8 @@ "fatigue": "Fatigue", "form": "Form", "explainer": "Fitness is your heart-rate training impulse averaged over about six weeks, fatigue over about one week, and form is the difference. The numbers compare with your own past, not with other people or other apps.", - "warmingUp": "Based on {days} days so far: until six weeks are covered, fitness reads lower than it is." + "warmingUp": "Based on {days} days so far: until six weeks are covered, fitness reads lower than it is.", + "coverage": "{rated} of the last {days} days have heart-rate data. Days without it are left out rather than counted as rest." } }, "bloodGlucose": { @@ -6136,10 +6141,6 @@ "access": { "title": "Shared access", "subtitle": "Who can open your health record, and whose you can open." - }, - "preview": { - "title": "Preview features", - "subtitle": "Try what is not finished yet. Everything here stays off until you switch it on." } }, "profile": "Profile", @@ -7623,17 +7624,6 @@ "passkeyRemovedToast": "Passkey removed.", "integrationDisconnected": "{provider} disconnected.", "integrationDisconnectFailed": "Could not disconnect {provider}.", - "preview": { - "topics": { - "title": "Topics (beta)", - "description": "Will add a separate Topics entry with overview pages above the single-metric Insights pages once they are ready.", - "detail": "Your Insights pages, navigation and links stay exactly as they are. The topic pages arrive in a later update; this switch decides whether you see them.", - "savedOn": "Topics preview switched on.", - "savedOff": "Topics preview switched off." - }, - "loadError": "Could not load your preview settings.", - "feedbackLink": "Share feedback in the GitHub discussions" - }, "timezoneSaved": "Timezone saved.", "moduleState": { "on": "On", @@ -10207,10 +10197,11 @@ "high": "If this straight line continued, it would reach the upper end of your reference range around {date}. That is a projection, not a forecast.", "low": "If this straight line continued, it would reach the lower end of your reference range around {date}. That is a projection, not a forecast." }, - "beyond_horizon": "At this pace the line stays inside your reference range for at least three more years.", + "beyond_horizon": "At this pace the line stays inside your reference range for more than {years} years, the span your readings cover.", "no_exit": "The line heads away from the edges of your reference range.", "already_outside": "The line already sits outside your reference range.", - "no_clear_trend": "Across these readings the change is not clearly different from no change, so no projection is drawn." + "no_clear_trend": "Across these readings the change is not clearly different from no change, so no projection is drawn.", + "stale": "The newest reading is from {date}, more than a year ago. The line is shown as of then, without a projection." }, "interval": "95 % interval of the yearly change: {lo} to {hi} {unit}." } @@ -12423,7 +12414,7 @@ "leafSurgicalHistory": "Surgical history", "leafImmunizations": "Immunizations", "leafEmergency": "Emergency information (blood type, contacts, directives)", - "leafMedicationEffect": "Change around each medication start", + "leafMedicationEffect": "Change around each medication start (needs the medication list)", "leafWorkouts": "Workouts (summary per sport)", "leafHrvSdnn": "HRV (SDNN)" }, @@ -13639,7 +13630,8 @@ "below": "Below your usual", "usual": "About your usual", "above": "Above your usual", - "high": "Far above your usual" + "high": "Far above your usual", + "in_progress": "Today, still in progress" }, "legend": "Compared with your 12 weeks before:", "noBaseline": "Your usual range shows after 14 days with data.", @@ -13649,7 +13641,9 @@ "workouts": "{value} workouts", "load": "Average training load {value}", "usual": "Your usual by this point: {value}", - "noUsual": "Not enough earlier weeks or months to compare with yet." + "noUsual": "Not enough earlier weeks or months to compare with yet.", + "usualThroughYesterday": "Through yesterday {value}, your usual by then {usual}", + "firstDay": "Compared with your usual once the first day is over." }, "sportCount": "{sport}: {count}", "goal": { diff --git a/messages/es.json b/messages/es.json index 067c6cc1d..e331d5c2a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -431,7 +431,10 @@ "notTracked": "toma no registrada, así que no se omitió ningún día por dosis olvidadas", "endDoseChange": "el periodo termina el {date}, antes del primer cambio de dosis", "days": "{before} días antes, {after} días después del inicio, {excluded} días omitidos", - "method": "Regresión segmentada sobre los valores diarios desde 90 días antes hasta como máximo 180 días después del inicio, con intervalos de Newey-West. Se omiten los días posteriores al inicio en los que se tomó menos del 80 % de las dosis de los 14 días anteriores. Describe una asociación en el tiempo, no un efecto demostrado del medicamento." + "method": "Regresión segmentada sobre los valores diarios desde 90 días antes hasta como máximo 180 días después del inicio, con intervalos de Newey-West. Se omiten los días posteriores al inicio en los que se tomó menos del 80 % de las dosis de los 14 días anteriores. Describe una asociación en el tiempo, no un efecto demostrado del medicamento.", + "autocorrelation": "fuerte dependencia del día anterior, así que los rangos pueden seguir siendo demasiado estrechos", + "window": "analizado del {from} al {to}, puede ir más allá del periodo del informe", + "windowLine": "Analizado del {from} al {to}. Este intervalo puede ir más allá del periodo del informe." } }, "errorBoundary": { @@ -2412,7 +2415,8 @@ "needStart": "Indica una fecha de inicio para ver el cambio al inicio.", "needBefore": "El cambio al inicio necesita valores de al menos {days} días repartidos en {span} días antes del inicio.", "needAfter": "El cambio al inicio necesita valores de al menos {days} días repartidos en {span} días desde el inicio, en días con las dosis tomadas.", - "fitFailed": "No se pudieron ajustar estos valores, así que no se muestra el cambio al inicio." + "fitFailed": "No se pudieron ajustar estos valores, así que no se muestra el cambio al inicio.", + "autocorrelation": "Los valores diarios dependen mucho del día anterior, así que incluso estos rangos pueden ser demasiado estrechos." } }, "import": { @@ -3898,7 +3902,8 @@ "fatigue": "Fatiga", "form": "Estado", "explainer": "La forma de base es tu impulso de entrenamiento por frecuencia cardiaca promediado en unas seis semanas, la fatiga en una semana, y el estado es la diferencia. Las cifras se comparan con tu propio pasado, no con otras personas ni otras apps.", - "warmingUp": "Con {days} días hasta ahora: hasta cubrir seis semanas, la forma de base aparece más baja de lo que es." + "warmingUp": "Con {days} días hasta ahora: hasta cubrir seis semanas, la forma de base aparece más baja de lo que es.", + "coverage": "{rated} de los últimos {days} días tienen datos de frecuencia cardiaca. Los días sin ellos se omiten en lugar de contar como descanso." } }, "bloodGlucose": { @@ -6136,10 +6141,6 @@ "access": { "title": "Acceso compartido", "subtitle": "Quién puede abrir tu historial de salud y cuál puedes abrir tú." - }, - "preview": { - "title": "Funciones en vista previa", - "subtitle": "Prueba lo que aún no está terminado. Todo aquí sigue desactivado hasta que lo actives." } }, "profile": "Perfil", @@ -7623,17 +7624,6 @@ "passkeyRemovedToast": "Passkey eliminada.", "integrationDisconnected": "{provider} desconectado.", "integrationDisconnectFailed": "No se pudo desconectar {provider}.", - "preview": { - "topics": { - "title": "Temas (beta)", - "description": "Añadirá una entrada aparte, Temas, con páginas de resumen por encima de las páginas de cada métrica cuando estén listas.", - "detail": "Tus páginas de análisis, la navegación y los enlaces se quedan exactamente como están. Las páginas de temas llegan en una actualización posterior; este interruptor decide si las ves.", - "savedOn": "Vista previa de Temas activada.", - "savedOff": "Vista previa de Temas desactivada." - }, - "loadError": "No se pudieron cargar tus ajustes de vista previa.", - "feedbackLink": "Comparte tu opinión en las discusiones de GitHub" - }, "timezoneSaved": "Zona horaria guardada.", "moduleState": { "on": "Activado", @@ -10207,10 +10197,11 @@ "high": "Si esta línea recta continuara, llegaría al límite superior de tu rango de referencia hacia el {date}. Es una proyección, no un pronóstico.", "low": "Si esta línea recta continuara, llegaría al límite inferior de tu rango de referencia hacia el {date}. Es una proyección, no un pronóstico." }, - "beyond_horizon": "A este ritmo, la línea se mantiene dentro de tu rango de referencia al menos tres años más.", + "beyond_horizon": "A este ritmo, la línea se mantiene dentro de tu rango de referencia más de {years} años, el periodo que cubren tus valores.", "no_exit": "La línea se aleja de los límites de tu rango de referencia.", "already_outside": "La línea ya está fuera de tu rango de referencia.", - "no_clear_trend": "En estos valores, el cambio no se distingue con claridad de la ausencia de cambio, así que no se proyecta nada." + "no_clear_trend": "En estos valores, el cambio no se distingue con claridad de la ausencia de cambio, así que no se proyecta nada.", + "stale": "El valor más reciente es del {date}, hace más de un año. La línea se muestra a esa fecha, sin proyección." }, "interval": "Intervalo del 95 % del cambio anual: de {lo} a {hi} {unit}." } @@ -12423,7 +12414,7 @@ "leafSurgicalHistory": "Antecedentes quirúrgicos", "leafImmunizations": "Vacunas", "leafEmergency": "Información de emergencia (grupo sanguíneo, contactos, voluntades)", - "leafMedicationEffect": "Cambio en torno al inicio de cada medicamento", + "leafMedicationEffect": "Cambio en torno al inicio de cada medicamento (requiere la lista de medicamentos)", "leafWorkouts": "Entrenamientos (resumen por deporte)", "leafHrvSdnn": "VFC (SDNN)" }, @@ -13639,7 +13630,8 @@ "below": "Por debajo de lo habitual", "usual": "Como de costumbre", "above": "Por encima de lo habitual", - "high": "Muy por encima de lo habitual" + "high": "Muy por encima de lo habitual", + "in_progress": "Hoy, aún en curso" }, "legend": "Comparado con tus 12 semanas anteriores:", "noBaseline": "Tu rango habitual aparece tras 14 días con datos.", @@ -13649,7 +13641,9 @@ "workouts": "{value} entrenamientos", "load": "Carga de entrenamiento media {value}", "usual": "Lo habitual hasta este punto: {value}", - "noUsual": "Aún no hay suficientes semanas o meses anteriores para comparar." + "noUsual": "Aún no hay suficientes semanas o meses anteriores para comparar.", + "usualThroughYesterday": "Hasta ayer {value}, lo habitual para ti hasta entonces {usual}", + "firstDay": "Se compara con lo habitual cuando termine el primer día." }, "sportCount": "{sport}: {count}", "goal": { diff --git a/messages/fr.json b/messages/fr.json index 72bbb6ea3..e4ca44d21 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -431,7 +431,10 @@ "notTracked": "prises non suivies, aucun jour écarté pour des doses manquées", "endDoseChange": "la période s'arrête le {date}, avant le premier changement de dose", "days": "{before} jours avant, {after} jours après le démarrage, {excluded} jours écartés", - "method": "Régression segmentée sur les valeurs quotidiennes, de 90 jours avant à 180 jours au plus après le démarrage, intervalles de Newey-West. Les jours après le démarrage où moins de 80 % des doses des 14 jours précédents ont été prises sont écartés. Décrit une association dans le temps, pas un effet démontré du médicament." + "method": "Régression segmentée sur les valeurs quotidiennes, de 90 jours avant à 180 jours au plus après le démarrage, intervalles de Newey-West. Les jours après le démarrage où moins de 80 % des doses des 14 jours précédents ont été prises sont écartés. Décrit une association dans le temps, pas un effet démontré du médicament.", + "autocorrelation": "forte dépendance à la veille, les plages peuvent donc rester trop étroites", + "window": "analysé du {from} au {to}, ce qui peut dépasser la période du rapport", + "windowLine": "Analysé du {from} au {to}. Cette fenêtre peut dépasser la période du rapport." } }, "errorBoundary": { @@ -2412,7 +2415,8 @@ "needStart": "Indique une date de début pour voir le changement au démarrage.", "needBefore": "Le changement au démarrage demande des valeurs sur au moins {days} jours répartis sur {span} jours avant le démarrage.", "needAfter": "Le changement au démarrage demande des valeurs sur au moins {days} jours répartis sur {span} jours depuis le démarrage, les jours où les doses ont été prises.", - "fitFailed": "Ces valeurs n'ont pas pu être ajustées, aucun changement au démarrage n'est donc affiché." + "fitFailed": "Ces valeurs n'ont pas pu être ajustées, aucun changement au démarrage n'est donc affiché.", + "autocorrelation": "Les valeurs quotidiennes dépendent fortement de la veille, donc même ces plages peuvent être trop étroites." } }, "import": { @@ -3898,7 +3902,8 @@ "fatigue": "Fatigue", "form": "Fraîcheur", "explainer": "La condition est ton impulsion d'entraînement par fréquence cardiaque moyennée sur environ six semaines, la fatigue sur environ une semaine, et la fraîcheur est la différence. Les chiffres se comparent à ton propre passé, pas à d'autres personnes ni à d'autres apps.", - "warmingUp": "Sur {days} jours pour l'instant : tant que six semaines ne sont pas couvertes, la condition paraît plus basse qu'elle ne l'est." + "warmingUp": "Sur {days} jours pour l'instant : tant que six semaines ne sont pas couvertes, la condition paraît plus basse qu'elle ne l'est.", + "coverage": "{rated} des {days} derniers jours ont des données de fréquence cardiaque. Les jours sans elles sont écartés au lieu de compter comme repos." } }, "bloodGlucose": { @@ -6136,10 +6141,6 @@ "access": { "title": "Accès partagé", "subtitle": "Qui peut ouvrir votre dossier de santé, et lequel vous pouvez ouvrir." - }, - "preview": { - "title": "Fonctions en aperçu", - "subtitle": "Essayez ce qui n’est pas encore terminé. Tout reste désactivé ici tant que vous ne l’activez pas." } }, "profile": "Profil", @@ -7623,17 +7624,6 @@ "passkeyRemovedToast": "Passkey supprimée.", "integrationDisconnected": "{provider} déconnecté.", "integrationDisconnectFailed": "Impossible de déconnecter {provider}.", - "preview": { - "topics": { - "title": "Thèmes (bêta)", - "description": "Ajoutera une entrée Thèmes distincte, avec des pages de synthèse au-dessus des pages par mesure, dès qu'elles seront prêtes.", - "detail": "Vos pages d’analyse, la navigation et les liens restent exactement tels quels. Les pages de thèmes arrivent dans une mise à jour ultérieure ; cet interrupteur décide si vous les voyez.", - "savedOn": "Aperçu des thèmes activé.", - "savedOff": "Aperçu des thèmes désactivé." - }, - "loadError": "Impossible de charger vos réglages d’aperçu.", - "feedbackLink": "Donner votre avis dans les discussions GitHub" - }, "timezoneSaved": "Fuseau horaire enregistré.", "moduleState": { "on": "Activé", @@ -10207,10 +10197,11 @@ "high": "Si cette droite se prolongeait, elle atteindrait la limite haute de ta plage de référence vers le {date}. C'est une projection, pas une prévision.", "low": "Si cette droite se prolongeait, elle atteindrait la limite basse de ta plage de référence vers le {date}. C'est une projection, pas une prévision." }, - "beyond_horizon": "À ce rythme, la droite reste dans ta plage de référence pendant au moins trois ans encore.", + "beyond_horizon": "À ce rythme, la droite reste dans ta plage de référence plus de {years} ans, la durée couverte par tes valeurs.", "no_exit": "La droite s'éloigne des limites de ta plage de référence.", "already_outside": "La droite est déjà hors de ta plage de référence.", - "no_clear_trend": "Sur ces valeurs, la variation ne se distingue pas clairement d'une absence de variation, donc aucune projection n'est tracée." + "no_clear_trend": "Sur ces valeurs, la variation ne se distingue pas clairement d'une absence de variation, donc aucune projection n'est tracée.", + "stale": "La valeur la plus récente date du {date}, il y a plus d’un an. La droite est montrée à cette date, sans projection." }, "interval": "Intervalle à 95 % de la variation annuelle : de {lo} à {hi} {unit}." } @@ -12423,7 +12414,7 @@ "leafSurgicalHistory": "Antécédents chirurgicaux", "leafImmunizations": "Vaccinations", "leafEmergency": "Informations d'urgence (groupe sanguin, contacts, directives)", - "leafMedicationEffect": "Changement autour du démarrage de chaque médicament", + "leafMedicationEffect": "Changement autour du démarrage de chaque médicament (nécessite la liste des médicaments)", "leafWorkouts": "Entraînements (résumé par sport)", "leafHrvSdnn": "VFC (SDNN)" }, @@ -13639,7 +13630,8 @@ "below": "En dessous de ton habitude", "usual": "Comme d'habitude", "above": "Au-dessus de ton habitude", - "high": "Bien au-dessus de ton habitude" + "high": "Bien au-dessus de ton habitude", + "in_progress": "Aujourd’hui, en cours" }, "legend": "Comparé à tes 12 semaines précédentes :", "noBaseline": "Ta plage habituelle apparaît après 14 jours avec des données.", @@ -13649,7 +13641,9 @@ "workouts": "{value} entraînements", "load": "Charge d'entraînement moyenne {value}", "usual": "Ton habitude à ce stade : {value}", - "noUsual": "Pas encore assez de semaines ou de mois précédents pour comparer." + "noUsual": "Pas encore assez de semaines ou de mois précédents pour comparer.", + "usualThroughYesterday": "Jusqu’à hier {value}, ton habitude à ce stade {usual}", + "firstDay": "Comparé à ton habitude dès que le premier jour est terminé." }, "sportCount": "{sport} : {count}", "goal": { diff --git a/messages/it.json b/messages/it.json index f18c92e05..6af2d8b19 100644 --- a/messages/it.json +++ b/messages/it.json @@ -431,7 +431,10 @@ "notTracked": "assunzione non registrata, quindi nessun giorno escluso per dosi saltate", "endDoseChange": "il periodo termina il {date}, prima della prima variazione di dose", "days": "{before} giorni prima, {after} giorni dopo l'inizio, {excluded} giorni esclusi", - "method": "Regressione segmentata sui valori giornalieri da 90 giorni prima a non oltre 180 giorni dopo l'inizio, con intervalli di Newey-West. Sono esclusi i giorni dopo l'inizio in cui è stato assunto meno dell'80 % delle dosi dei 14 giorni precedenti. Descrive un'associazione nel tempo, non un effetto dimostrato del farmaco." + "method": "Regressione segmentata sui valori giornalieri da 90 giorni prima a non oltre 180 giorni dopo l'inizio, con intervalli di Newey-West. Sono esclusi i giorni dopo l'inizio in cui è stato assunto meno dell'80 % delle dosi dei 14 giorni precedenti. Descrive un'associazione nel tempo, non un effetto dimostrato del farmaco.", + "autocorrelation": "forte dipendenza dal giorno precedente, quindi gli intervalli possono restare troppo stretti", + "window": "analizzato dal {from} al {to}, può andare oltre il periodo del report", + "windowLine": "Analizzato dal {from} al {to}. Questo intervallo può andare oltre il periodo del report." } }, "errorBoundary": { @@ -2412,7 +2415,8 @@ "needStart": "Imposta una data di inizio per vedere la variazione all'inizio.", "needBefore": "La variazione all'inizio richiede valori in almeno {days} giorni distribuiti su {span} giorni prima dell'inizio.", "needAfter": "La variazione all'inizio richiede valori in almeno {days} giorni distribuiti su {span} giorni dall'inizio, nei giorni con le dosi assunte.", - "fitFailed": "Non è stato possibile elaborare questi valori, quindi non viene mostrata alcuna variazione all'inizio." + "fitFailed": "Non è stato possibile elaborare questi valori, quindi non viene mostrata alcuna variazione all'inizio.", + "autocorrelation": "I valori giornalieri dipendono molto dal giorno precedente, quindi anche questi intervalli possono essere troppo stretti." } }, "import": { @@ -3898,7 +3902,8 @@ "fatigue": "Affaticamento", "form": "Freschezza", "explainer": "La forma di base è il tuo impulso di allenamento dalla frequenza cardiaca mediato su circa sei settimane, l'affaticamento su circa una settimana, e la freschezza è la differenza. I numeri si confrontano con il tuo passato, non con altre persone o altre app.", - "warmingUp": "Su {days} giorni finora: finché non sono coperte sei settimane, la forma di base risulta più bassa di quanto sia." + "warmingUp": "Su {days} giorni finora: finché non sono coperte sei settimane, la forma di base risulta più bassa di quanto sia.", + "coverage": "{rated} degli ultimi {days} giorni hanno dati di frequenza cardiaca. I giorni senza vengono esclusi invece di contare come riposo." } }, "bloodGlucose": { @@ -6136,10 +6141,6 @@ "access": { "title": "Accesso condiviso", "subtitle": "Chi può aprire la tua cartella sanitaria e quale puoi aprire tu." - }, - "preview": { - "title": "Funzioni in anteprima", - "subtitle": "Prova ciò che non è ancora finito. Qui tutto resta spento finché non lo attivi." } }, "profile": "Profilo", @@ -7623,17 +7624,6 @@ "passkeyRemovedToast": "Passkey rimossa.", "integrationDisconnected": "{provider} disconnesso.", "integrationDisconnectFailed": "Impossibile disconnettere {provider}.", - "preview": { - "topics": { - "title": "Temi (beta)", - "description": "Aggiungerà una voce Temi separata, con pagine di panoramica sopra le pagine delle singole metriche, appena saranno pronte.", - "detail": "Le pagine delle analisi, la navigazione e i link restano esattamente come sono. Le pagine dei temi arrivano con un aggiornamento successivo; questo interruttore decide se le vedi.", - "savedOn": "Anteprima dei temi attivata.", - "savedOff": "Anteprima dei temi disattivata." - }, - "loadError": "Impossibile caricare le impostazioni di anteprima.", - "feedbackLink": "Lascia un parere nelle discussioni su GitHub" - }, "timezoneSaved": "Fuso orario salvato.", "moduleState": { "on": "Attivo", @@ -10207,10 +10197,11 @@ "high": "Se questa retta proseguisse, raggiungerebbe il limite superiore del tuo intervallo di riferimento verso il {date}. È una proiezione, non una previsione.", "low": "Se questa retta proseguisse, raggiungerebbe il limite inferiore del tuo intervallo di riferimento verso il {date}. È una proiezione, non una previsione." }, - "beyond_horizon": "A questo ritmo la retta resta nel tuo intervallo di riferimento per almeno altri tre anni.", + "beyond_horizon": "A questo ritmo la retta resta nel tuo intervallo di riferimento per più di {years} anni, il periodo coperto dai tuoi valori.", "no_exit": "La retta si allontana dai limiti del tuo intervallo di riferimento.", "already_outside": "La retta è già fuori dal tuo intervallo di riferimento.", - "no_clear_trend": "Su questi valori la variazione non si distingue chiaramente dall'assenza di variazione, quindi non si proietta nulla." + "no_clear_trend": "Su questi valori la variazione non si distingue chiaramente dall'assenza di variazione, quindi non si proietta nulla.", + "stale": "Il valore più recente è del {date}, più di un anno fa. La retta è mostrata a quella data, senza proiezione." }, "interval": "Intervallo al 95 % della variazione annua: da {lo} a {hi} {unit}." } @@ -12423,7 +12414,7 @@ "leafSurgicalHistory": "Anamnesi chirurgica", "leafImmunizations": "Vaccinazioni", "leafEmergency": "Informazioni di emergenza (gruppo sanguigno, contatti, disposizioni)", - "leafMedicationEffect": "Variazione intorno all'inizio di ogni farmaco", + "leafMedicationEffect": "Variazione intorno all'inizio di ogni farmaco (richiede l'elenco dei farmaci)", "leafWorkouts": "Allenamenti (riepilogo per sport)", "leafHrvSdnn": "HRV (SDNN)" }, @@ -13639,7 +13630,8 @@ "below": "Sotto il tuo solito", "usual": "Come al solito", "above": "Sopra il tuo solito", - "high": "Molto sopra il tuo solito" + "high": "Molto sopra il tuo solito", + "in_progress": "Oggi, ancora in corso" }, "legend": "Rispetto alle tue 12 settimane precedenti:", "noBaseline": "Il tuo intervallo abituale compare dopo 14 giorni con dati.", @@ -13649,7 +13641,9 @@ "workouts": "{value} allenamenti", "load": "Carico di allenamento medio {value}", "usual": "Il tuo solito fino a questo punto: {value}", - "noUsual": "Non ci sono ancora abbastanza settimane o mesi precedenti per un confronto." + "noUsual": "Non ci sono ancora abbastanza settimane o mesi precedenti per un confronto.", + "usualThroughYesterday": "Fino a ieri {value}, il tuo solito fino a quel punto {usual}", + "firstDay": "Il confronto con il tuo solito arriva quando il primo giorno è finito." }, "sportCount": "{sport}: {count}", "goal": { diff --git a/messages/ko.json b/messages/ko.json index afddfe2e2..665971476 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -431,7 +431,10 @@ "notTracked": "복용 미기록으로 놓친 용량에 따른 제외 없음", "endDoseChange": "기간은 첫 용량 변경 전인 {date}에 끝남", "days": "시작 전 {before}일, 시작 후 {after}일, 제외 {excluded}일", - "method": "시작 90일 전부터 최대 180일 후까지의 일별 값에 대한 분절 회귀, Newey-West 구간. 직전 14일 복용률이 80% 미만인 시작 후 날짜는 제외했습니다. 시간에 따른 연관성을 보여 줄 뿐, 약의 효과를 입증하지 않습니다." + "method": "시작 90일 전부터 최대 180일 후까지의 일별 값에 대한 분절 회귀, Newey-West 구간. 직전 14일 복용률이 80% 미만인 시작 후 날짜는 제외했습니다. 시간에 따른 연관성을 보여 줄 뿐, 약의 효과를 입증하지 않습니다.", + "autocorrelation": "전날 값에 크게 좌우되므로 범위가 여전히 너무 좁을 수 있음", + "window": "{from}부터 {to}까지 분석, 보고 기간을 넘어설 수 있음", + "windowLine": "{from}부터 {to}까지 분석했습니다. 이 기간은 보고 기간을 넘어설 수 있습니다." } }, "errorBoundary": { @@ -2412,7 +2415,8 @@ "needStart": "시작 시점의 변화를 보려면 시작일을 설정하세요.", "needBefore": "시작 시점의 변화를 보려면 시작 전 {span}일에 걸쳐 최소 {days}일의 측정값이 필요합니다.", "needAfter": "시작 시점의 변화를 보려면 시작 후 {span}일에 걸쳐, 용량을 복용한 날 기준으로 최소 {days}일의 측정값이 필요합니다.", - "fitFailed": "이 측정값으로는 계산할 수 없어 시작 시점의 변화를 표시하지 않습니다." + "fitFailed": "이 측정값으로는 계산할 수 없어 시작 시점의 변화를 표시하지 않습니다.", + "autocorrelation": "하루 값이 전날 값에 크게 좌우되므로 이 범위도 너무 좁을 수 있습니다." } }, "import": { @@ -3898,7 +3902,8 @@ "fatigue": "피로", "form": "컨디션", "explainer": "체력은 심박 기반 훈련 자극을 약 6주 동안, 피로는 약 1주 동안 평균한 값이고, 컨디션은 그 차이입니다. 이 숫자는 다른 사람이나 다른 앱이 아니라 내 과거와 비교하는 값입니다.", - "warmingUp": "지금까지 {days}일 기준: 6주가 채워질 때까지는 체력이 실제보다 낮게 나옵니다." + "warmingUp": "지금까지 {days}일 기준: 6주가 채워질 때까지는 체력이 실제보다 낮게 나옵니다.", + "coverage": "최근 {days}일 중 {rated}일에 심박수 데이터가 있습니다. 데이터가 없는 날은 휴식일로 세지 않고 제외합니다." } }, "bloodGlucose": { @@ -6136,10 +6141,6 @@ "access": { "title": "공유 접근", "subtitle": "내 건강 기록을 누가 열 수 있고, 내가 누구의 기록을 열 수 있는지예요." - }, - "preview": { - "title": "미리 보기 기능", - "subtitle": "아직 완성되지 않은 기능을 써 보세요. 직접 켜기 전까지는 모두 꺼져 있습니다." } }, "profile": "프로필", @@ -7623,17 +7624,6 @@ "passkeyRemovedToast": "패스키를 삭제했어요.", "integrationDisconnected": "{provider} 연결을 해제했어요.", "integrationDisconnectFailed": "{provider} 연결을 해제하지 못했어요.", - "preview": { - "topics": { - "title": "주제 (베타)", - "description": "준비되면 개별 지표 페이지 위에 개요 페이지가 있는 별도의 주제 항목을 추가해요.", - "detail": "인사이트 페이지, 메뉴, 링크는 지금 그대로 유지됩니다. 주제 페이지는 이후 업데이트에서 제공되며, 이 스위치로 표시 여부를 정합니다.", - "savedOn": "주제 미리 보기를 켰습니다.", - "savedOff": "주제 미리 보기를 껐습니다." - }, - "loadError": "미리 보기 설정을 불러오지 못했습니다.", - "feedbackLink": "GitHub 토론에서 의견 남기기" - }, "timezoneSaved": "시간대를 저장했습니다.", "moduleState": { "on": "켜짐", @@ -10207,10 +10197,11 @@ "high": "이 직선이 그대로 이어진다면 {date}쯤 참고 범위의 상한에 닿습니다. 이는 연장선일 뿐 예측이 아닙니다.", "low": "이 직선이 그대로 이어진다면 {date}쯤 참고 범위의 하한에 닿습니다. 이는 연장선일 뿐 예측이 아닙니다." }, - "beyond_horizon": "이 속도라면 적어도 3년 동안은 참고 범위 안에 머뭅니다.", + "beyond_horizon": "이 속도라면 {years}년 넘게 참고 범위 안에 머뭅니다. 측정값이 다루는 기간입니다.", "no_exit": "직선이 참고 범위의 경계에서 멀어지고 있습니다.", "already_outside": "직선이 이미 참고 범위 밖에 있습니다.", - "no_clear_trend": "이 값들에서는 변화가 '변화 없음'과 뚜렷이 구별되지 않아 연장선을 그리지 않습니다." + "no_clear_trend": "이 값들에서는 변화가 '변화 없음'과 뚜렷이 구별되지 않아 연장선을 그리지 않습니다.", + "stale": "가장 최근 값은 {date}로 1년이 넘었습니다. 직선은 그 시점 기준으로 표시하며 예측은 하지 않습니다." }, "interval": "연간 변화의 95% 구간: {lo}~{hi} {unit}." } @@ -12423,7 +12414,7 @@ "leafSurgicalHistory": "수술 이력", "leafImmunizations": "예방접종", "leafEmergency": "응급 정보 (혈액형, 연락처, 사전지시)", - "leafMedicationEffect": "각 약 시작 전후의 변화", + "leafMedicationEffect": "각 약 시작 전후의 변화 (약 목록 필요)", "leafWorkouts": "운동(종목별 요약)", "leafHrvSdnn": "심박변이도 (SDNN)" }, @@ -13639,7 +13630,8 @@ "below": "평소보다 적음", "usual": "평소와 비슷함", "above": "평소보다 많음", - "high": "평소보다 훨씬 많음" + "high": "평소보다 훨씬 많음", + "in_progress": "오늘, 아직 진행 중" }, "legend": "이전 12주와 비교:", "noBaseline": "데이터가 있는 날이 14일이 되면 평소 범위가 표시됩니다.", @@ -13649,7 +13641,9 @@ "workouts": "운동 {value}회", "load": "평균 운동 부하 {value}", "usual": "이 시점까지의 평소 값: {value}", - "noUsual": "비교할 이전 주나 달이 아직 충분하지 않습니다." + "noUsual": "비교할 이전 주나 달이 아직 충분하지 않습니다.", + "usualThroughYesterday": "어제까지 {value}, 그때까지의 평소 {usual}", + "firstDay": "첫날이 끝나면 평소와 비교합니다." }, "sportCount": "{sport}: {count}", "goal": { diff --git a/messages/nl.json b/messages/nl.json index 80005fc1f..ab1552351 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -431,7 +431,10 @@ "notTracked": "inname niet bijgehouden, dus geen dagen weggelaten om gemiste doses", "endDoseChange": "periode eindigt op {date}, vóór de eerste dosiswijziging", "days": "{before} dagen voor, {after} dagen na de start, {excluded} dagen weggelaten", - "method": "Gesegmenteerde regressie op de dagwaarden van 90 dagen vóór tot hoogstens 180 dagen na de start, met Newey-West-intervallen. Dagen na de start waarop minder dan 80% van de doses van de 14 dagen ervoor is ingenomen, zijn weggelaten. Beschrijft een samenhang in de tijd, geen bewezen effect van het medicijn." + "method": "Gesegmenteerde regressie op de dagwaarden van 90 dagen vóór tot hoogstens 180 dagen na de start, met Newey-West-intervallen. Dagen na de start waarop minder dan 80% van de doses van de 14 dagen ervoor is ingenomen, zijn weggelaten. Beschrijft een samenhang in de tijd, geen bewezen effect van het medicijn.", + "autocorrelation": "sterke afhankelijkheid van de vorige dag, dus de bereiken kunnen nog te smal zijn", + "window": "geanalyseerd van {from} tot {to}, dit kan buiten de rapportperiode vallen", + "windowLine": "Geanalyseerd van {from} tot {to}. Deze periode kan buiten de rapportperiode vallen." } }, "errorBoundary": { @@ -2412,7 +2415,8 @@ "needStart": "Stel een startdatum in om de verandering bij de start te zien.", "needBefore": "Voor de verandering bij de start zijn waarden op minstens {days} dagen nodig, verspreid over {span} dagen vóór de start.", "needAfter": "Voor de verandering bij de start zijn waarden op minstens {days} dagen nodig, verspreid over {span} dagen sinds de start en op dagen waarop de doses zijn ingenomen.", - "fitFailed": "Deze waarden konden niet worden verwerkt, dus er wordt geen verandering bij de start getoond." + "fitFailed": "Deze waarden konden niet worden verwerkt, dus er wordt geen verandering bij de start getoond.", + "autocorrelation": "De dagwaarden hangen sterk af van de vorige dag, dus zelfs deze bereiken kunnen te smal zijn." } }, "import": { @@ -3898,7 +3902,8 @@ "fatigue": "Vermoeidheid", "form": "Vorm", "explainer": "Fitheid is je trainingsprikkel uit de hartslag, gemiddeld over ongeveer zes weken, vermoeidheid over ongeveer een week, en vorm is het verschil. De getallen zijn te vergelijken met je eigen verleden, niet met andere mensen of andere apps.", - "warmingUp": "Op basis van {days} dagen tot nu toe: tot zes weken gedekt zijn, valt de fitheid lager uit dan ze is." + "warmingUp": "Op basis van {days} dagen tot nu toe: tot zes weken gedekt zijn, valt de fitheid lager uit dan ze is.", + "coverage": "{rated} van de laatste {days} dagen hebben hartslaggegevens. Dagen zonder worden weggelaten in plaats van als rustdag te tellen." } }, "bloodGlucose": { @@ -6136,10 +6141,6 @@ "access": { "title": "Gedeelde toegang", "subtitle": "Wie je gezondheidsdossier kan openen, en wiens dossier jij kunt openen." - }, - "preview": { - "title": "Previewfuncties", - "subtitle": "Probeer wat nog niet af is. Alles hier blijft uit tot je het aanzet." } }, "profile": "Profiel", @@ -7623,17 +7624,6 @@ "passkeyRemovedToast": "Passkey verwijderd.", "integrationDisconnected": "{provider} ontkoppeld.", "integrationDisconnectFailed": "{provider} kon niet worden ontkoppeld.", - "preview": { - "topics": { - "title": "Onderwerpen (bèta)", - "description": "Voegt een apart item Onderwerpen toe, met overzichtspagina’s boven de pagina’s per meting, zodra die klaar zijn.", - "detail": "Je inzichtpagina’s, navigatie en links blijven precies zoals ze zijn. De onderwerppagina’s komen met een latere update; deze schakelaar bepaalt of je ze ziet.", - "savedOn": "Preview van onderwerpen ingeschakeld.", - "savedOff": "Preview van onderwerpen uitgeschakeld." - }, - "loadError": "Je preview-instellingen konden niet worden geladen.", - "feedbackLink": "Geef feedback in de GitHub-discussies" - }, "timezoneSaved": "Tijdzone opgeslagen.", "moduleState": { "on": "Aan", @@ -10207,10 +10197,11 @@ "high": "Als deze rechte lijn zo doorliep, zou ze rond {date} de bovengrens van je referentiebereik bereiken. Dat is een doortrekking, geen voorspelling.", "low": "Als deze rechte lijn zo doorliep, zou ze rond {date} de ondergrens van je referentiebereik bereiken. Dat is een doortrekking, geen voorspelling." }, - "beyond_horizon": "In dit tempo blijft de lijn nog minstens drie jaar binnen je referentiebereik.", + "beyond_horizon": "In dit tempo blijft de lijn langer dan {years} jaar binnen je referentiebereik, de periode die je waarden beslaan.", "no_exit": "De lijn loopt weg van de grenzen van je referentiebereik.", "already_outside": "De lijn ligt al buiten je referentiebereik.", - "no_clear_trend": "Over deze waarden is de verandering niet duidelijk te onderscheiden van geen verandering, dus er wordt niets doorgetrokken." + "no_clear_trend": "Over deze waarden is de verandering niet duidelijk te onderscheiden van geen verandering, dus er wordt niets doorgetrokken.", + "stale": "De nieuwste waarde is van {date}, meer dan een jaar geleden. De lijn staat op die datum, zonder projectie." }, "interval": "95%-interval van de jaarlijkse verandering: {lo} tot {hi} {unit}." } @@ -12423,7 +12414,7 @@ "leafSurgicalHistory": "Operatiegeschiedenis", "leafImmunizations": "Vaccinaties", "leafEmergency": "Noodinformatie (bloedgroep, contactpersonen, wilsverklaringen)", - "leafMedicationEffect": "Verandering rond de start van elk medicijn", + "leafMedicationEffect": "Verandering rond de start van elk medicijn (vereist de medicatielijst)", "leafWorkouts": "Trainingen (overzicht per sport)", "leafHrvSdnn": "HRV (SDNN)" }, @@ -13639,7 +13630,8 @@ "below": "Onder je gewone niveau", "usual": "Ongeveer zoals gewoonlijk", "above": "Boven je gewone niveau", - "high": "Ver boven je gewone niveau" + "high": "Ver boven je gewone niveau", + "in_progress": "Vandaag, nog bezig" }, "legend": "Vergeleken met je 12 weken daarvoor:", "noBaseline": "Je gebruikelijke bereik verschijnt na 14 dagen met gegevens.", @@ -13649,7 +13641,9 @@ "workouts": "{value} trainingen", "load": "Gemiddelde trainingsbelasting {value}", "usual": "Gebruikelijk tot dit punt: {value}", - "noUsual": "Nog niet genoeg eerdere weken of maanden om mee te vergelijken." + "noUsual": "Nog niet genoeg eerdere weken of maanden om mee te vergelijken.", + "usualThroughYesterday": "Tot en met gisteren {value}, je gebruikelijke tot dan {usual}", + "firstDay": "Vergeleken met je gebruikelijke zodra de eerste dag voorbij is." }, "sportCount": "{sport}: {count}", "goal": { diff --git a/messages/pl.json b/messages/pl.json index ce08e9fbd..35fe5b2ca 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -431,7 +431,10 @@ "notTracked": "przyjmowanie nie jest rejestrowane, więc nie pominięto dni z powodu opuszczonych dawek", "endDoseChange": "okres kończy się {date}, przed pierwszą zmianą dawki", "days": "{before} dni przed, {after} dni po rozpoczęciu, pominięto {excluded} dni", - "method": "Regresja segmentowa na wartościach dziennych od 90 dni przed do najwyżej 180 dni po rozpoczęciu, przedziały Neweya-Westa. Pominięto dni po rozpoczęciu, w których w ciągu poprzednich 14 dni przyjęto mniej niż 80% dawek. Opisuje związek w czasie, a nie udowodnione działanie leku." + "method": "Regresja segmentowa na wartościach dziennych od 90 dni przed do najwyżej 180 dni po rozpoczęciu, przedziały Neweya-Westa. Pominięto dni po rozpoczęciu, w których w ciągu poprzednich 14 dni przyjęto mniej niż 80% dawek. Opisuje związek w czasie, a nie udowodnione działanie leku.", + "autocorrelation": "silna zależność od poprzedniego dnia, więc zakresy mogą nadal być zbyt wąskie", + "window": "analizowano od {from} do {to}, może to wykraczać poza okres raportu", + "windowLine": "Analizowano od {from} do {to}. Ten okres może wykraczać poza okres raportu." } }, "errorBoundary": { @@ -2412,7 +2415,8 @@ "needStart": "Ustaw datę rozpoczęcia, aby zobaczyć zmianę na początku.", "needBefore": "Do zmiany na początku potrzeba pomiarów z co najmniej {days} dni rozłożonych na {span} dni przed rozpoczęciem.", "needAfter": "Do zmiany na początku potrzeba pomiarów z co najmniej {days} dni rozłożonych na {span} dni od rozpoczęcia, w dni z przyjętymi dawkami.", - "fitFailed": "Nie udało się dopasować tych pomiarów, więc zmiana na początku nie jest pokazana." + "fitFailed": "Nie udało się dopasować tych pomiarów, więc zmiana na początku nie jest pokazana.", + "autocorrelation": "Wartości dzienne silnie zależą od poprzedniego dnia, więc nawet te zakresy mogą być zbyt wąskie." } }, "import": { @@ -3898,7 +3902,8 @@ "fatigue": "Zmęczenie", "form": "Forma", "explainer": "Wydolność to twój bodziec treningowy z tętna uśredniony z około sześciu tygodni, zmęczenie z około tygodnia, a forma to różnica. Liczby porównuje się z twoją własną przeszłością, nie z innymi osobami ani aplikacjami.", - "warmingUp": "Na podstawie dotychczasowych {days} dni: dopóki nie minie sześć tygodni, wydolność wychodzi niższa, niż jest." + "warmingUp": "Na podstawie dotychczasowych {days} dni: dopóki nie minie sześć tygodni, wydolność wychodzi niższa, niż jest.", + "coverage": "{rated} z ostatnich {days} dni ma dane tętna. Dni bez nich są pomijane, a nie liczone jako odpoczynek." } }, "bloodGlucose": { @@ -6136,10 +6141,6 @@ "access": { "title": "Udostępniony dostęp", "subtitle": "Kto może otworzyć twoją kartę zdrowia i czyją kartę możesz otworzyć ty." - }, - "preview": { - "title": "Funkcje w podglądzie", - "subtitle": "Wypróbuj to, co nie jest jeszcze gotowe. Wszystko tutaj pozostaje wyłączone, dopóki tego nie włączysz." } }, "profile": "Profil", @@ -7623,17 +7624,6 @@ "passkeyRemovedToast": "Usunięto passkey.", "integrationDisconnected": "Rozłączono {provider}.", "integrationDisconnectFailed": "Nie udało się rozłączyć {provider}.", - "preview": { - "topics": { - "title": "Tematy (beta)", - "description": "Doda osobną pozycję Tematy ze stronami przeglądowymi nad stronami poszczególnych pomiarów, gdy będą gotowe.", - "detail": "Strony analiz, nawigacja i linki pozostają dokładnie takie, jakie są. Strony tematów pojawią się w późniejszej aktualizacji; ten przełącznik decyduje, czy je zobaczysz.", - "savedOn": "Podgląd tematów włączony.", - "savedOff": "Podgląd tematów wyłączony." - }, - "loadError": "Nie udało się wczytać ustawień podglądu.", - "feedbackLink": "Podziel się opinią w dyskusjach na GitHubie" - }, "timezoneSaved": "Strefa czasowa zapisana.", "moduleState": { "on": "Włączone", @@ -10207,10 +10197,11 @@ "high": "Gdyby ta prosta biegła dalej, osiągnęłaby górną granicę twojego zakresu referencyjnego około {date}. To ekstrapolacja, nie prognoza.", "low": "Gdyby ta prosta biegła dalej, osiągnęłaby dolną granicę twojego zakresu referencyjnego około {date}. To ekstrapolacja, nie prognoza." }, - "beyond_horizon": "W tym tempie prosta pozostaje w twoim zakresie referencyjnym jeszcze co najmniej trzy lata.", + "beyond_horizon": "W tym tempie prosta pozostaje w twoim zakresie referencyjnym dłużej niż {years} lat, czyli okres objęty twoimi wynikami.", "no_exit": "Prosta oddala się od granic twojego zakresu referencyjnego.", "already_outside": "Prosta jest już poza twoim zakresem referencyjnym.", - "no_clear_trend": "Na podstawie tych wyników zmiana nie różni się wyraźnie od braku zmiany, więc nie jest wyznaczana ekstrapolacja." + "no_clear_trend": "Na podstawie tych wyników zmiana nie różni się wyraźnie od braku zmiany, więc nie jest wyznaczana ekstrapolacja.", + "stale": "Najnowszy wynik pochodzi z {date}, sprzed ponad roku. Prosta jest pokazana na ten dzień, bez projekcji." }, "interval": "95% przedział rocznej zmiany: od {lo} do {hi} {unit}." } @@ -12423,7 +12414,7 @@ "leafSurgicalHistory": "Historia operacji", "leafImmunizations": "Szczepienia", "leafEmergency": "Informacje na wypadek nagły (grupa krwi, kontakty, oświadczenia)", - "leafMedicationEffect": "Zmiana wokół rozpoczęcia każdego leku", + "leafMedicationEffect": "Zmiana wokół rozpoczęcia każdego leku (wymaga listy leków)", "leafWorkouts": "Treningi (podsumowanie według dyscypliny)", "leafHrvSdnn": "HRV (SDNN)" }, @@ -13639,7 +13630,8 @@ "below": "Poniżej twojej normy", "usual": "Mniej więcej jak zwykle", "above": "Powyżej twojej normy", - "high": "Znacznie powyżej twojej normy" + "high": "Znacznie powyżej twojej normy", + "in_progress": "Dziś, jeszcze trwa" }, "legend": "W porównaniu z twoimi 12 wcześniejszymi tygodniami:", "noBaseline": "Twój zwykły zakres pojawi się po 14 dniach z danymi.", @@ -13649,7 +13641,9 @@ "workouts": "Treningi: {value}", "load": "Średnie obciążenie treningowe {value}", "usual": "Twoja norma do tego momentu: {value}", - "noUsual": "Za mało wcześniejszych tygodni lub miesięcy do porównania." + "noUsual": "Za mało wcześniejszych tygodni lub miesięcy do porównania.", + "usualThroughYesterday": "Do wczoraj {value}, twoja zwykła wartość do tego dnia {usual}", + "firstDay": "Porównanie ze zwykłą wartością pojawi się po zakończeniu pierwszego dnia." }, "sportCount": "{sport}: {count}", "goal": { diff --git a/prisma/migrations/0470_user_activity_goals/migration.sql b/prisma/migrations/0470_user_activity_goals/migration.sql index 49d25a9d1..a988680c1 100644 --- a/prisma/migrations/0470_user_activity_goals/migration.sql +++ b/prisma/migrations/0470_user_activity_goals/migration.sql @@ -6,4 +6,4 @@ -- -- Reversibility: dropping the column removes the goals; nothing else reads it. -ALTER TABLE "users" ADD COLUMN "activity_goals_json" JSONB; +ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "activity_goals_json" JSONB; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index cf97a4241..860778e7a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -388,9 +388,9 @@ model User { // Shape: { "version": 1, "labs": { "view": "list", "order": [""], "sortDir": "manual" }, ... } moduleListLayoutJson Json? @map("module_list_layout_json") /// Per-user opt-in to preview features, one boolean per flag, all off by - /// default (`src/lib/settings/preview-features.ts`). Read and written by - /// Settings > System through `/api/settings/preview-features`. - /// @pending-consumer(topics preview) the `topics` flag is stored and shown, but no surface reads it yet; the topic overview pages will. + /// default (`src/lib/settings/preview-features.ts`). Read and written + /// through `/api/settings/preview-features` and carried by the backup. + /// @pending-consumer(topics preview) the `topics` flag is stored, but no surface reads it and the Settings > System switch is unlisted until the topic overview pages ship. previewFeaturesJson Json? @map("preview_features_json") /// Optional activity goals the person set for themselves: workouts, active /// minutes and steps per week, steps per month. Null or an absent key = no diff --git a/src/__tests__/settings-copy-tense.test.ts b/src/__tests__/settings-copy-tense.test.ts index 31d44ea71..f6298b988 100644 --- a/src/__tests__/settings-copy-tense.test.ts +++ b/src/__tests__/settings-copy-tense.test.ts @@ -1,11 +1,10 @@ /** * Copy that promises something not yet shipped says so. * - * The Topics preview switch exists before its pages do, so its description is - * in the future tense in every locale's source (English) and in German; and - * the workout-route switch is off for new accounts, which is what its detail + * The workout-route switch is off for new accounts, which is what its detail * says rather than "off by default": an account that already held tracks at - * the upgrade started with it on (migration 0434). + * the upgrade started with it on (migration 0434). (The Topics preview switch + * this file also held is unlisted until its pages ship, and its copy with it.) */ import { describe, expect, it } from "vitest"; @@ -13,11 +12,6 @@ import en from "../../messages/en.json"; import de from "../../messages/de.json"; describe("settings copy tense", () => { - it("describes the Topics preview as something it will add", () => { - expect(en.settings.preview.topics.description).toMatch(/^Will add /); - expect(de.settings.preview.topics.description).toMatch(/sobald/); - }); - it("says workout routes are off for new accounts", () => { expect(en.settings.privacy.workoutRoutes.detail).toMatch( /^Off for new accounts\./, diff --git a/src/__tests__/settings-legacy-routes.test.ts b/src/__tests__/settings-legacy-routes.test.ts index db699d3bc..c04cbe5ad 100644 --- a/src/__tests__/settings-legacy-routes.test.ts +++ b/src/__tests__/settings-legacy-routes.test.ts @@ -146,10 +146,7 @@ describe("historical Settings addresses", () => { // one silently. Module pages are new in v1.44 and are allowed here. const listed = new Set(HISTORICAL_SETTINGS_PATHS); const unlisted = routable.filter( - (path) => - !listed.has(path) && - !path.startsWith("/settings/modules/") && - path !== "/settings/preview", + (path) => !listed.has(path) && !path.startsWith("/settings/modules/"), ); expect(unlisted).toEqual([]); }); diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index 33b0c3305..fe252a19d 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -2191,8 +2191,11 @@ const ACTOR_ROUTES: Record = { * v1.44 -- the module list layout and the preview-feature switch are the * person's own presentation and join the actor list. 268 -> 270. * - * v1.44 -- the activity calendar read, the activity goals and the source - * provenance reads join the list. 270 -> 273. + * v1.44 -- three readable routes join the record list: the activity calendar + * read and the two source provenance reads (one day, and two sources side by + * side). The activity goals route does not: setting or changing goals stays + * owner-only, and a delegate sees the goals only as the calendar reports them. + * 270 -> 273. */ const FROZEN_ENTRY_COUNT = 273; diff --git a/src/app/api/biomarkers/[id]/route.ts b/src/app/api/biomarkers/[id]/route.ts index e47454e51..17f4b07ca 100644 --- a/src/app/api/biomarkers/[id]/route.ts +++ b/src/app/api/biomarkers/[id]/route.ts @@ -74,7 +74,9 @@ export const GET = apiHandler( deletedAt: null, value: { not: null }, }, - orderBy: { takenAt: "asc" }, + // Newest first, so a capped read keeps the recent end of the series + // (the staleness check reads the newest reading); the fit sorts. + orderBy: { takenAt: "desc" }, take: LAB_TREND_READ_CAP, select: { takenAt: true, value: true }, }), diff --git a/src/app/api/fhir/Coverage/route.ts b/src/app/api/fhir/Coverage/route.ts index ce45cbc0f..de38c6414 100644 --- a/src/app/api/fhir/Coverage/route.ts +++ b/src/app/api/fhir/Coverage/route.ts @@ -43,7 +43,11 @@ export const GET = apiHandler(async (request: NextRequest) => { if (!rl.allowed) { return operationOutcome(429, "throttled", "Rate limit exceeded"); } - const { count, offset } = parsePaging(request.nextUrl.searchParams); + const paging = parsePaging(request.nextUrl.searchParams); + if ("error" in paging) { + return operationOutcome(400, "invalid", paging.error); + } + const { count, offset } = paging; const context = await loadFhirContext(user.id); const all = [coverageResource(context.data, context.identity)].filter( diff --git a/src/app/api/fhir/MedicationAdministration/route.ts b/src/app/api/fhir/MedicationAdministration/route.ts index 361d1eed6..639472eba 100644 --- a/src/app/api/fhir/MedicationAdministration/route.ts +++ b/src/app/api/fhir/MedicationAdministration/route.ts @@ -41,7 +41,11 @@ export const GET = apiHandler(async (request: NextRequest) => { if (!rl.allowed) { return operationOutcome(429, "throttled", "Rate limit exceeded"); } - const { count, offset } = parsePaging(request.nextUrl.searchParams); + const paging = parsePaging(request.nextUrl.searchParams); + if ("error" in paging) { + return operationOutcome(400, "invalid", paging.error); + } + const { count, offset } = paging; const context = await loadFhirContext(user.id); const all = medicationAdministrationsFromReportData(context.data, { diff --git a/src/app/api/fhir/MedicationStatement/route.ts b/src/app/api/fhir/MedicationStatement/route.ts index bd205854c..848f6562d 100644 --- a/src/app/api/fhir/MedicationStatement/route.ts +++ b/src/app/api/fhir/MedicationStatement/route.ts @@ -41,7 +41,11 @@ export const GET = apiHandler(async (request: NextRequest) => { if (!rl.allowed) { return operationOutcome(429, "throttled", "Rate limit exceeded"); } - const { count, offset } = parsePaging(request.nextUrl.searchParams); + const paging = parsePaging(request.nextUrl.searchParams); + if ("error" in paging) { + return operationOutcome(400, "invalid", paging.error); + } + const { count, offset } = paging; const context = await loadFhirContext(user.id); const all = medicationStatementsFromReportData(context.data, { diff --git a/src/app/api/fhir/Observation/route.ts b/src/app/api/fhir/Observation/route.ts index 1988f78c4..8267b28d3 100644 --- a/src/app/api/fhir/Observation/route.ts +++ b/src/app/api/fhir/Observation/route.ts @@ -57,7 +57,11 @@ export const GET = apiHandler(async (request: NextRequest) => { return operationOutcome(429, "throttled", "Rate limit exceeded"); } const params = request.nextUrl.searchParams; - const { count, offset } = parsePaging(params); + const paging = parsePaging(params); + if ("error" in paging) { + return operationOutcome(400, "invalid", paging.error); + } + const { count, offset } = paging; const codeValues = params.getAll("code"); const range = parseDateParams(params.getAll("date")); if (range && "error" in range) { diff --git a/src/app/api/fhir/Patient/$everything/route.ts b/src/app/api/fhir/Patient/$everything/route.ts index 68faf6304..bebc9d12c 100644 --- a/src/app/api/fhir/Patient/$everything/route.ts +++ b/src/app/api/fhir/Patient/$everything/route.ts @@ -69,7 +69,11 @@ export const GET = apiHandler(async (request: NextRequest) => { return operationOutcome(429, "throttled", "Rate limit exceeded"); } - const { count, offset } = parsePaging(request.nextUrl.searchParams); + const paging = parsePaging(request.nextUrl.searchParams); + if ("error" in paging) { + return operationOutcome(400, "invalid", paging.error); + } + const { count, offset } = paging; const { data, identity, germanAtc, records } = await loadFhirContext(user.id); const document = buildFhirDocumentBundle( diff --git a/src/app/api/fhir/Patient/route.ts b/src/app/api/fhir/Patient/route.ts index dcf03b802..27a8b90f0 100644 --- a/src/app/api/fhir/Patient/route.ts +++ b/src/app/api/fhir/Patient/route.ts @@ -43,7 +43,11 @@ export const GET = apiHandler(async (request: NextRequest) => { if (!rl.allowed) { return operationOutcome(429, "throttled", "Rate limit exceeded"); } - const { count, offset } = parsePaging(request.nextUrl.searchParams); + const paging = parsePaging(request.nextUrl.searchParams); + if ("error" in paging) { + return operationOutcome(400, "invalid", paging.error); + } + const { count, offset } = paging; const context = await loadFhirContext(user.id); const all = [patientResource(context.data, context.identity)]; diff --git a/src/app/api/fhir/__tests__/route.test.ts b/src/app/api/fhir/__tests__/route.test.ts index af4bdfe81..eaf538a41 100644 --- a/src/app/api/fhir/__tests__/route.test.ts +++ b/src/app/api/fhir/__tests__/route.test.ts @@ -147,6 +147,23 @@ describe("GET /api/fhir/Observation — searchset", () => { expect(bundle.entry[0].resource.id).toBe("obs-3"); }); + it("refuses an offset past the ceiling with a 400 OperationOutcome", async () => { + vi.mocked(observationsFromReportData).mockReturnValue([obs(1)] as never); + for (const offset of ["100001", "1e20", "Infinity"]) { + const res = await observationGet(req(`?_offset=${offset}`)); + expect(res.status).toBe(400); + const outcome = (await res.json()) as { + resourceType: string; + issue: Array<{ code: string }>; + }; + expect(outcome.resourceType).toBe("OperationOutcome"); + expect(outcome.issue[0].code).toBe("invalid"); + } + // The ceiling itself still pages (an empty page past the end). + const atCeiling = await observationGet(req("?_offset=100000")); + expect(atCeiling.status).toBe(200); + }); + it("enforces the fhir:read scope (Bearer narrow token without it 403s)", async () => { // No session → Bearer path. requireAuth(FHIR_READ_SCOPE) must reject a // token lacking the scope; here no auth at all yields 401, proving the diff --git a/src/app/api/import/__tests__/record-csv-routes.test.ts b/src/app/api/import/__tests__/record-csv-routes.test.ts index 2c3ec0294..0c1ae9b70 100644 --- a/src/app/api/import/__tests__/record-csv-routes.test.ts +++ b/src/app/api/import/__tests__/record-csv-routes.test.ts @@ -100,6 +100,16 @@ describe("record CSV import routes", () => { expect(mocks.importLabsCsv).not.toHaveBeenCalled(); }); + it("refuses an unknown parameter, so a misspelt dry run never writes", async () => { + for (const typo of ["dryrun=1", "dry_run=1", "dryRun=1&preview=1"]) { + expect((await post(labsPost, `${LABS}&${typo}`)).status).toBe(422); + expect((await post(medsPost, `${MEDS}&${typo}`)).status).toBe(422); + } + expect(mocks.importLabsCsv).not.toHaveBeenCalled(); + expect(mocks.importMedicationsCsv).not.toHaveBeenCalled(); + expect(mocks.checkRateLimit).not.toHaveBeenCalled(); + }); + it("spends the preview bucket on a dry run and the import bucket otherwise", async () => { await post(labsPost, `${LABS}&dryRun=1`); expect(mocks.checkRateLimit).toHaveBeenLastCalledWith( diff --git a/src/app/api/import/csv/__tests__/route.test.ts b/src/app/api/import/csv/__tests__/route.test.ts index a6d8dcaa9..8ce841f24 100644 --- a/src/app/api/import/csv/__tests__/route.test.ts +++ b/src/app/api/import/csv/__tests__/route.test.ts @@ -164,6 +164,23 @@ const mMeasurement = () => updateMany: ReturnType; }; +describe("POST /api/import/csv — strict query", () => { + it("refuses a misspelt dry-run flag instead of importing", async () => { + for (const query of ["dryrun=1", "dry_run=1", "dryRun=yes"]) { + const res = await POST( + new NextRequest(`http://localhost/api/import/csv?${query}`, { + method: "POST", + body: [HEADER, "WEIGHT,80,kg,2026-05-01T08:00:00Z,,,"].join("\n"), + headers: { "content-type": "text/csv" }, + }), + ); + expect(res.status).toBe(422); + } + expect(mMeasurement().createManyAndReturn).not.toHaveBeenCalled(); + expect(checkRateLimit).not.toHaveBeenCalled(); + }); +}); + describe("POST /api/import/csv — fatal header error", () => { it("returns 422 when a required column is missing", async () => { const res = await POST(csvRequest("type,value,unit\nWEIGHT,80,kg")); diff --git a/src/app/api/import/csv/route.ts b/src/app/api/import/csv/route.ts index d39ccc3e4..97d1e4239 100644 --- a/src/app/api/import/csv/route.ts +++ b/src/app/api/import/csv/route.ts @@ -7,7 +7,9 @@ import { apiError, getClientIp, readBodyText, + returnAllZodIssues, } from "@/lib/api-response"; +import { csvImportQuerySchema } from "@/lib/validations/record-import"; import { encryptNote, readNote } from "@/lib/crypto/note-cipher"; import { checkRateLimit } from "@/lib/rate-limit"; import { NextRequest } from "next/server"; @@ -109,9 +111,13 @@ export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); annotate({ action: { name: "import.csv.upload" } }); - const dryRun = - new URL(request.url).searchParams.get("dryRun") === "1" || - new URL(request.url).searchParams.get("dryRun") === "true"; + // Strict: an unknown parameter is refused, so a misspelt `dryrun=1` can + // never fall through to a real import. + const queryParse = csvImportQuerySchema.safeParse( + Object.fromEntries(new URL(request.url).searchParams), + ); + if (!queryParse.success) return returnAllZodIssues(queryParse.error, 422); + const dryRun = queryParse.data.dryRun; // Same blast-radius reasoning as the JSON import — share the bucket so a // CSV upload counts against the same 5/hour quota. diff --git a/src/app/api/workouts/__tests__/canonical-dedup.test.ts b/src/app/api/workouts/__tests__/canonical-dedup.test.ts index 82906eb8e..dd05951ff 100644 --- a/src/app/api/workouts/__tests__/canonical-dedup.test.ts +++ b/src/app/api/workouts/__tests__/canonical-dedup.test.ts @@ -461,6 +461,8 @@ describe("GET /api/workouts — training load (v1.44)", () => { asOf: yesterday, daysCovered: 50, trainingDays: 50, + recentRatedDays: 42, + recentDays: 42, warmingUp: false, }); const later = await (await GET(makeRequest("?offset=100"))).json(); diff --git a/src/components/activity/__tests__/activity-calendar-card.test.tsx b/src/components/activity/__tests__/activity-calendar-card.test.tsx index a005c664f..f56ff213b 100644 --- a/src/components/activity/__tests__/activity-calendar-card.test.tsx +++ b/src/components/activity/__tests__/activity-calendar-card.test.tsx @@ -58,6 +58,16 @@ function calendar() { load: null, }); } + // This morning: a few hundred steps so far. + input.push({ + date: today, + steps: 400, + exerciseMinutes: null, + workoutMinutes: 0, + workoutCount: 0, + workoutsBySport: {}, + load: null, + }); input.push({ date: "2026-10-05", steps: 20000, @@ -113,14 +123,30 @@ describe("ActivityCalendarCard", () => { state.data = calendar(); const html = render(); expect(html).toContain('data-metric="steps"'); - expect(html).toContain("Your usual by this point:"); + // A running month is compared through yesterday, not on today. + expect(html).toContain("Through yesterday"); + expect(html).toContain("your usual by then"); expect(html).toContain("Running: 1"); }); + it("marks today as in progress rather than rating it", () => { + state.data = calendar(); + const html = render(); + expect(html).toMatch(/data-date="2026-10-21" data-level="in_progress"/); + expect(html).toContain("Today, still in progress"); + }); + it("shows goal progress and a week tick column", () => { state.data = calendar(); const html = render(); expect(html).toContain("Steps per month"); + // Stable hooks for the end-to-end spec, independent of the wording. + expect(html).toContain( + 'data-slot="activity-calendar-goal" data-goal-key="stepsPerMonth"', + ); + expect(html).toMatch( + /data-slot="activity-calendar"[^>]*data-period="month"/, + ); expect(html).toContain( 'data-slot="activity-calendar-week-goal" data-met="true"', ); diff --git a/src/components/activity/activity-calendar-card.tsx b/src/components/activity/activity-calendar-card.tsx index 7e9eef083..a07960a0b 100644 --- a/src/components/activity/activity-calendar-card.tsx +++ b/src/components/activity/activity-calendar-card.tsx @@ -41,6 +41,7 @@ type DayLevel = CalendarDay["level"]["steps"]; const LEVEL_BAR: Record = { none: "bg-transparent", unrated: "bg-muted-foreground/60", + in_progress: "bg-muted-foreground/30", low: "bg-chart-2/20", below: "bg-chart-2/40", usual: "bg-chart-2/60", @@ -172,6 +173,7 @@ export function ActivityCalendarCard() { @@ -412,10 +414,13 @@ export function ActivityCalendarCard() { .filter((m) => data.available[m]) .map((m) => { const s = data.summary[m]; + // Compared over completed days only: today has not ended. const delta = - s.value !== null && s.usualToDate - ? (s.value - s.usualToDate) / s.usualToDate + s.completedValue !== null && s.usualToDate + ? (s.completedValue - s.usualToDate) / s.usualToDate : null; + const running = + data.today >= data.start && data.today <= data.end; return (
  • - {s.usualToDate === null - ? t("activityCalendar.summary.noUsual") - : t("activityCalendar.summary.usual", { - value: formatMetric(m, s.usualToDate), - })} + {s.usual !== null && s.completedDays === 0 + ? t("activityCalendar.summary.firstDay") + : s.usualToDate === null + ? t("activityCalendar.summary.noUsual") + : running + ? t( + "activityCalendar.summary.usualThroughYesterday", + { + value: + s.completedValue === null + ? "–" + : formatMetric(m, s.completedValue), + usual: formatMetric(m, s.usualToDate), + }, + ) + : t("activityCalendar.summary.usual", { + value: formatMetric(m, s.usualToDate), + })}

    {delta !== null ? ( @@ -466,7 +484,12 @@ export function ActivityCalendarCard() {
    {data.goalProgress.map((g) => ( -
    +
    {t(`activityCalendar.goal.${key}`)} ", () => { expect(html).not.toContain("Current smoker"); }); + it("shows the medication effect only beside the medication list", () => { + const report = makeReport({ + medicationEffects: [ + { + medicationName: "Named effect drug", + startsOn: "2026-01-05", + targets: [ + { + type: "BLOOD_PRESSURE_SYS", + effect: { + present: false, + reason: "insufficient_after", + level: 0.95, + window: null, + days: { + before: 40, + after: 3, + excludedLowAdherence: 0, + excludedNotTaking: 0, + }, + adherence: { + status: "tracked", + floorPct: 80, + lookbackDays: 14, + }, + levelChange: null, + slopeChangePerWeek: null, + preSlopePerWeek: null, + fit: null, + confounders: [], + }, + }, + ], + }, + ], + }); + const leaves = ["MEDICATION_EFFECT", "BLOOD_PRESSURE_SYS"] as const; + const est = { + estimate: -5, + ciLow: -7, + ciHigh: -3, + standardError: 1, + includesZero: false, + }; + const fitted = makeReport({ + medicationEffects: [ + { + ...report.medicationEffects![0], + targets: [ + { + type: "BLOOD_PRESSURE_SYS", + effect: { + ...report.medicationEffects![0].targets[0].effect, + present: true, + reason: undefined, + levelChange: est, + slopeChangePerWeek: est, + preSlopePerWeek: est, + window: { + from: "2025-10-07", + to: "2026-01-20", + endReason: "today", + }, + }, + }, + ], + }, + ], + }); + expect( + render(fitted, { + selection: selectionFromLeaves([...leaves, "MEDICATION_LIST"]), + }), + ).toContain("This window can reach beyond the report period."); + expect( + render(report, { + selection: selectionFromLeaves([...leaves, "MEDICATION_LIST"]), + }), + ).toContain("Named effect drug"); + expect( + render(report, { selection: selectionFromLeaves([...leaves]) }), + ).not.toContain("Named effect drug"); + }); + it("names both windows when a lab's report and the saved band disagree", () => { // Two windows that disagree about the same number, and a card showing one // of them is a partial answer. The PDF settles this with a footnote under diff --git a/src/components/clinician/activity-effect-sections.tsx b/src/components/clinician/activity-effect-sections.tsx index 3f62c9878..d6a2629f1 100644 --- a/src/components/clinician/activity-effect-sections.tsx +++ b/src/components/clinician/activity-effect-sections.tsx @@ -113,7 +113,11 @@ export function MedicationEffectSection({ /** A stated calendar day (YYYY-MM-DD) in the owner's date order. */ fmtDay: (day: string) => string; }) { - const effects = report.medicationEffects ?? []; + // The block names medications: without the list leaf it shows nothing, + // whatever the payload carries. + const effects = scope.admits("MEDICATION_LIST") + ? (report.medicationEffects ?? []) + : []; const glucoseUnit = resolveGlucoseUnit(report.glucoseUnit ?? null); return ( + {e.window ? ( +

    + {t("doctorReport.medicationEffect.windowLine", { + from: fmtDay(e.window.from), + to: fmtDay(e.window.to), + })} +

    + ) : null}

    {t("doctorReport.medicationEffect.days", { before: e.days.before, @@ -167,6 +179,11 @@ export function MedicationEffectSection({ e.days.excludedLowAdherence + e.days.excludedNotTaking, })}

    + {e.fit?.autocorrelationCaveat ? ( +

    + {t("medications.efficacy.segmented.autocorrelation")} +

    + ) : null} {e.confounders.map((c) => (

    ", () => { ).toBe(""); expect(render(undefined)).toBe(""); }); + + it("words a stale trend as of its newest reading, and a span-bound horizon", () => { + const stale = render({ + ...OK, + asOf: "2026-01-10", + projection: "stale", + exitDate: null, + exitBound: null, + }); + expect(stale).toContain('data-projection="stale"'); + expect(stale).toContain("more than a year ago"); + expect(stale).not.toContain("forecast"); + const beyond = render({ + ...OK, + horizonYears: 1.5, + projection: "beyond_horizon", + exitDate: null, + exitBound: null, + }); + expect(beyond).toContain("for more than 1.5 years"); + }); }); diff --git a/src/components/labs/lab-trend-card.tsx b/src/components/labs/lab-trend-card.tsx index 94b81f164..975518443 100644 --- a/src/components/labs/lab-trend-card.tsx +++ b/src/components/labs/lab-trend-card.tsx @@ -48,7 +48,15 @@ export function LabTrendCard({ }) : trend.projection === "no_band" ? null - : t(`labs.trend.projection.${trend.projection}`); + : trend.projection === "stale" + ? t("labs.trend.projection.stale", { + date: fmt.dateShortSmartCalendar(trend.lastAt), + }) + : trend.projection === "beyond_horizon" + ? t("labs.trend.projection.beyond_horizon", { + years: fmt.number(trend.horizonYears, 1), + }) + : t(`labs.trend.projection.${trend.projection}`); return ( diff --git a/src/components/labs/types.ts b/src/components/labs/types.ts index f4c976a74..783c3d3c1 100644 --- a/src/components/labs/types.ts +++ b/src/components/labs/types.ts @@ -125,13 +125,15 @@ export type BiomarkerTrendDto = pValue: number; fittedNow: number; asOf: string; + horizonYears: number; projection: | "exits" | "beyond_horizon" | "no_exit" | "already_outside" | "no_clear_trend" - | "no_band"; + | "no_band" + | "stale"; exitDate: string | null; exitBound: "low" | "high" | null; } diff --git a/src/components/medications/detail/efficacy/__tests__/change-at-start-summary.test.tsx b/src/components/medications/detail/efficacy/__tests__/change-at-start-summary.test.tsx index b9fa7eb63..6f40afc8a 100644 --- a/src/components/medications/detail/efficacy/__tests__/change-at-start-summary.test.tsx +++ b/src/components/medications/detail/efficacy/__tests__/change-at-start-summary.test.tsx @@ -37,7 +37,8 @@ const present: EfficacySegmented = { df: 146, rSquared: 0.6, residualAutocorrelation: 0.3, - hacLag: 4, + hacLag: 7, + autocorrelationCaveat: false, }, confounders: [ { medicationName: "Example B", event: "started", date: "2026-02-10" }, @@ -70,6 +71,20 @@ describe("ChangeAtStartSummary", () => { expect(html).toContain("describes the starting dose"); expect(html).toContain("Example B started on"); expect(html).not.toMatch(/work(s|ing)|effective|successful/i); + expect(html).not.toContain("medication-wirkung-autocorrelation"); + }); + + it("warns when the daily values carry over strongly", () => { + const html = render({ + ...present, + fit: { + ...present.fit!, + residualAutocorrelation: 0.7, + autocorrelationCaveat: true, + }, + }); + expect(html).toContain("medication-wirkung-autocorrelation"); + expect(html).toContain("even these ranges may be too narrow"); }); it("names the missing floor instead of a number", () => { diff --git a/src/components/medications/detail/efficacy/change-at-start-summary.tsx b/src/components/medications/detail/efficacy/change-at-start-summary.tsx index 8f05249c0..af2c132fb 100644 --- a/src/components/medications/detail/efficacy/change-at-start-summary.tsx +++ b/src/components/medications/detail/efficacy/change-at-start-summary.tsx @@ -125,6 +125,14 @@ export function ChangeAtStartSummary({ {t("medications.efficacy.segmented.notTracked")}

    ) : null} + {segmented.fit?.autocorrelationCaveat ? ( +

    + {t("medications.efficacy.segmented.autocorrelation")} +

    + ) : null} {segmented.window.endReason === "dose_change" ? (

    {t("medications.efficacy.segmented.endDoseChange")} diff --git a/src/components/settings/__tests__/preview-section.test.tsx b/src/components/settings/__tests__/preview-section.test.tsx deleted file mode 100644 index 7dc3e11ba..000000000 --- a/src/components/settings/__tests__/preview-section.test.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; - -import { I18nProvider } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; -import { PreviewSection } from "../preview-section"; -import { primeLocales } from "@/__tests__/helpers/prime-locales"; - -primeLocales("de"); - -function render(topics: boolean | undefined, locale: "en" | "de" = "en") { - const client = new QueryClient({ - defaultOptions: { queries: { retry: false, refetchOnMount: false } }, - }); - if (topics !== undefined) { - client.setQueryData(queryKeys.previewFeatures(), { topics }); - } - return renderToStaticMarkup( - - - - - , - ); -} - -describe("", () => { - it("shows the Topics switch off by default, saved on change", () => { - const html = render(false); - expect(html).toContain('data-slot="settings-preview-topics-switch"'); - expect(html).toMatch( - /data-slot="settings-preview-topics-switch"[^>]*aria-checked="false"|aria-checked="false"[^>]*data-slot="settings-preview-topics-switch"/, - ); - expect(html).toContain("Topics (beta)"); - // A switch, no Save: one save model per card. - expect(html).not.toContain('type="submit"'); - }); - - it("reads on once the person switched it on", () => { - expect(render(true)).toMatch( - /data-slot="settings-preview-topics-switch"[^>]*aria-checked="true"|aria-checked="true"[^>]*data-slot="settings-preview-topics-switch"/, - ); - }); - - it("says the existing pages stay as they are and nothing raw leaks", () => { - const html = render(false, "de"); - expect(html).toContain("Themen (Beta)"); - expect(html).not.toContain("settings.preview."); - }); -}); diff --git a/src/components/settings/__tests__/settings-shell.test.tsx b/src/components/settings/__tests__/settings-shell.test.tsx index dfeed9e79..117aa294f 100644 --- a/src/components/settings/__tests__/settings-shell.test.tsx +++ b/src/components/settings/__tests__/settings-shell.test.tsx @@ -170,12 +170,19 @@ describe("SETTINGS_SECTION_SLUGS", () => { // v1.23 — "Data & Privacy" assembles the export / deletion / retention / // encryption / session / activity pieces into one pane, after `advanced`. "privacy", - // v1.44 — per-user preview opt-ins, in the System group before About. - "preview", "about", ]); }); + it("lists no Preview features page until the topic pages ship", () => { + // The stored flag and its API stay; the page and its nav entry return + // with the first preview the switch actually turns on. + expect(isSettingsSectionSlug("preview")).toBe(false); + expect(SETTINGS_SECTIONS.some((s) => s.slug === ("preview" as never))).toBe( + false, + ); + }); + it("`isSettingsSectionSlug` accepts known slugs and rejects others", () => { for (const slug of SETTINGS_SECTION_SLUGS) { expect(isSettingsSectionSlug(slug)).toBe(true); @@ -242,7 +249,6 @@ describe("", () => { gesundheitsakte: "data", privacy: "data", advanced: "data", - preview: "system", about: "system", }); // Every routable section has a nav entry, and nothing else does. diff --git a/src/components/settings/preview-section.tsx b/src/components/settings/preview-section.tsx deleted file mode 100644 index 9df4789e5..000000000 --- a/src/components/settings/preview-section.tsx +++ /dev/null @@ -1,116 +0,0 @@ -"use client"; - -/** - * Settings > System > Preview features. - * - * One switch per preview feature, each off until the person turns it on, each - * saved the moment it changes (`PATCH /api/settings/preview-features`). A - * preview adds something beside what is there and never replaces a page. - * - * The `topics` flag is stored and shown here; the topic overview pages that - * read it arrive in a later release, and the card says so rather than - * promising a change the switch does not make yet. - */ - -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import Link from "next/link"; -import { LayoutDashboard } from "lucide-react"; - -import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; -import { SettingsCard } from "@/components/settings/settings-card"; -import { SettingsCardHeader } from "@/components/settings/_card-header"; -import { InlineAction } from "@/components/ui/inline-action"; -import { QueryErrorRow } from "@/components/ui/query-error-row"; -import { Switch } from "@/components/ui/switch"; -import { apiGet, apiPatch } from "@/lib/api/api-fetch"; -import { showErrorToast } from "@/lib/errors/error-toast"; -import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; -import { - DEFAULT_PREVIEW_FEATURES, - type PreviewFeatures, -} from "@/lib/settings/preview-features"; - -const DISCUSSIONS_URL = "https://github.com/MBombeck/HealthLog/discussions"; - -export function PreviewSection() { - const { t } = useTranslations(); - const queryClient = useQueryClient(); - const key = queryKeys.previewFeatures(); - - const query = useQuery({ - queryKey: key, - queryFn: () => apiGet("/api/settings/preview-features"), - }); - const features = query.data ?? DEFAULT_PREVIEW_FEATURES; - - const toggle = useMutation({ - mutationKey: key, - mutationFn: (topics: boolean) => - apiPatch("/api/settings/preview-features", { topics }), - onMutate: async (topics) => { - await queryClient.cancelQueries({ queryKey: key }); - const previous = queryClient.getQueryData(key); - queryClient.setQueryData(key, { - ...(previous ?? DEFAULT_PREVIEW_FEATURES), - topics, - }); - return { previous }; - }, - onSuccess: (saved, topics) => { - if (saved) queryClient.setQueryData(key, saved); - toastWrittenOutcome( - "success", - topics - ? t("settings.preview.topics.savedOn") - : t("settings.preview.topics.savedOff"), - ); - }, - onError: (failure, _topics, context) => { - queryClient.setQueryData(key, context?.previous); - showErrorToast(t("settings.savingError"), { cause: failure }); - }, - }); - - return ( - - toggle.mutate(next)} - aria-labelledby="settings-preview-topics-title" - /> - } - /> - {query.isError ? ( - void query.refetch()} - /> - ) : null} -

    {t("settings.preview.topics.detail")}

    -

    - - - {t("settings.preview.feedbackLink")} - - -

    - - ); -} diff --git a/src/components/settings/section-slugs.ts b/src/components/settings/section-slugs.ts index 89d05e243..b6f69ed40 100644 --- a/src/components/settings/section-slugs.ts +++ b/src/components/settings/section-slugs.ts @@ -114,9 +114,10 @@ export const SETTINGS_SECTION_SLUGS = [ // pieces into one coherent privacy pane. Sits next to `advanced` (the // destructive-action home) and before `about`. "privacy", - // v1.44 — "Preview features": per-user opt-in to things finished enough to - // try and not yet the default. Sits in the System group before About. - "preview", + // v1.44 — "Preview features" (per-user opt-ins, Settings > System) is not + // listed until the first preview it switches, the topic pages, ships: a + // switch that changes nothing is a promise the page cannot keep. The stored + // flag, `/api/settings/preview-features` and its backup round-trip stay. "about", ] as const; diff --git a/src/components/settings/settings-section-loader.tsx b/src/components/settings/settings-section-loader.tsx index 252c75905..41aecb597 100644 --- a/src/components/settings/settings-section-loader.tsx +++ b/src/components/settings/settings-section-loader.tsx @@ -124,10 +124,6 @@ const LOADERS: Record< import("@/components/settings/privacy-section").then( (m) => m.PrivacySection, ), - preview: () => - import("@/components/settings/preview-section").then( - (m) => m.PreviewSection, - ), }; const SECTIONS: Record> = { @@ -162,7 +158,6 @@ const SECTIONS: Record> = { export: dynamic(LOADERS.export, { loading: () => }), advanced: dynamic(LOADERS.advanced, { loading: () => }), privacy: dynamic(LOADERS.privacy, { loading: () => }), - preview: dynamic(LOADERS.preview, { loading: () => }), }; /** diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index b260fa6d6..29c8b1af2 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -26,7 +26,6 @@ import { CloudSun, Download, FileHeart, - FlaskConical, Gauge, Info, KeyRound, @@ -275,12 +274,6 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [ group: "data", }, // ── System ── - { - slug: "preview", - titleKey: "settings.sections.preview.title", - icon: FlaskConical, - group: "system", - }, { slug: "about", titleKey: "settings.sections.about.title", diff --git a/src/components/workouts/__tests__/training-load-card.test.tsx b/src/components/workouts/__tests__/training-load-card.test.tsx index 27d9dcd62..32f734eb3 100644 --- a/src/components/workouts/__tests__/training-load-card.test.tsx +++ b/src/components/workouts/__tests__/training-load-card.test.tsx @@ -17,6 +17,8 @@ const OK: TrainingLoad = { tsb: -7.6, daysCovered: 30, trainingDays: 18, + recentRatedDays: 30, + recentDays: 30, warmingUp: true, series: [], }; @@ -36,6 +38,13 @@ describe("", () => { expect(html).toMatch(/training-load-fatigue[^]*?>62 { + const html = render({ ...OK, recentRatedDays: 25, recentDays: 42 }); + expect(html).toContain('data-slot="training-load-coverage"'); + expect(html).toContain("25 of the last 42 days have heart-rate data"); }); it("signs a positive form and drops the warm-up note once covered", () => { diff --git a/src/components/workouts/training-load-card.tsx b/src/components/workouts/training-load-card.tsx index 530fec929..08b76f91a 100644 --- a/src/components/workouts/training-load-card.tsx +++ b/src/components/workouts/training-load-card.tsx @@ -47,6 +47,17 @@ export function TrainingLoadCard({ ))}

    {t("insights.workouts.load.explainer")}

    + {trainingLoad.recentRatedDays < trainingLoad.recentDays ? ( +

    + {t("insights.workouts.load.coverage", { + rated: trainingLoad.recentRatedDays, + days: trainingLoad.recentDays, + })} +

    + ) : null} {trainingLoad.warmingUp ? (

    {t("insights.workouts.load.warmingUp", { diff --git a/src/lib/activity/__tests__/calendar-math.test.ts b/src/lib/activity/__tests__/calendar-math.test.ts index 816cec6ab..1967237af 100644 --- a/src/lib/activity/__tests__/calendar-math.test.ts +++ b/src/lib/activity/__tests__/calendar-math.test.ts @@ -143,7 +143,7 @@ describe("period against the usual period", () => { expect(result.referencePeriods).toBe(12); }); - it("pro-rates the usual for a period still running", () => { + it("compares a running period over its completed days, never today's partial day", () => { const bounds = periodBounds("week", "2026-10-21"); const values = new Map(); for (let i = 1; i <= 84; i++) @@ -155,9 +155,27 @@ describe("period against the usual period", () => { values.get(d) ?? (d <= "2026-10-21" && d >= bounds.start ? 2000 : null), mode: "sum", }); + // The total shown counts today; the comparison stops at yesterday. expect(result.value).toBe(6000); expect(result.usual).toBe(7000); - expect(result.usualToDate).toBe(3000); + expect(result.completedDays).toBe(2); + expect(result.completedValue).toBe(4000); + expect(result.usualToDate).toBe(2000); + }); + + it("has nothing to compare on the first day of a period", () => { + const bounds = periodBounds("week", "2026-10-19"); + const result = comparePeriod({ + bounds, + today: "2026-10-19", // Monday + valueOf: (d) => (d <= "2026-10-19" ? 1000 : null), + mode: "sum", + }); + expect(result.value).toBe(1000); + expect(result.usual).toBe(7000); + expect(result.completedDays).toBe(0); + expect(result.completedValue).toBeNull(); + expect(result.usualToDate).toBeNull(); }); it("compares months per day, so February meets March fairly", () => { @@ -258,6 +276,28 @@ describe("the assembled calendar", () => { ).toBe("none"); }); + it("shows today as in progress, never rated against whole days", () => { + const morning = buildActivityCalendar({ + bounds, + today, + input: [...input, day(today, { steps: 800 })], + goals: NO_ACTIVITY_GOALS, + }); + const todayCell = morning.days.find((d) => d.date === today); + expect(todayCell?.steps).toBe(800); + expect(todayCell?.level.steps).toBe("in_progress"); + expect(todayCell?.level.activeMinutes).toBe("in_progress"); + expect(todayCell?.level.load).toBe("none"); + // Yesterday, with the same value, is rated. + const yesterday = buildActivityCalendar({ + bounds, + today, + input: [...input, day("2026-10-20", { steps: 800 })], + goals: NO_ACTIVITY_GOALS, + }).days.find((d) => d.date === "2026-10-20"); + expect(yesterday?.level.steps).toBe("low"); + }); + it("counts workouts by sport in the period", () => { expect(calendar.sports).toEqual([ { sportType: "cycling", count: 1 }, diff --git a/src/lib/activity/calendar-math.ts b/src/lib/activity/calendar-math.ts index 6f4a5e225..6a6f821ab 100644 --- a/src/lib/activity/calendar-math.ts +++ b/src/lib/activity/calendar-math.ts @@ -11,14 +11,17 @@ * above the 90th `high`, everything between `usual`. With fewer than * `MIN_BASELINE_DAYS` days that carry a value the day is `unrated`: * there is not enough history to say what usual is, and the calendar says - * so instead of guessing. + * so instead of guessing. TODAY is `in_progress`, never rated: a day + * that has not ended would read as a low day every morning. * - A PERIOD total is placed against the median total of the same kind of * period before it: the twelve weeks before a week, the six months before * a month. A month is compared per day (months differ in length), so the * usual month is the median daily rate of the earlier months times this - * month's length. A period still running is compared with the usual rate - * times the days elapsed so far, not with a full period it cannot have - * reached yet. At least `MIN_REFERENCE_PERIODS` earlier periods with data + * month's length. A period still running is compared over its COMPLETED + * days only: the total through yesterday against the usual rate times the + * days completed, never a full period it cannot have reached yet, and + * never today's partial day (a morning would always read as a shortfall). + * The period total shown and the goals still count today. At least `MIN_REFERENCE_PERIODS` earlier periods with data * are needed; fewer gives no usual. * * What a day's value is: @@ -62,7 +65,14 @@ export const MIN_REFERENCE_PERIODS = 3; export type ActivityPeriod = "week" | "month"; export type RatedMetric = "steps" | "activeMinutes" | "load"; export type DayLevel = - "none" | "unrated" | "low" | "below" | "usual" | "above" | "high"; + | "none" + | "unrated" + | "in_progress" + | "low" + | "below" + | "usual" + | "above" + | "high"; /** One local day of raw signals, as the reader assembles it. */ export interface ActivityDayInput { @@ -237,13 +247,17 @@ export function dayRange(from: string, to: string): string[] { } export interface PeriodComparison { - /** The period's total (steps, active minutes, workouts) or mean (load), over the days so far. */ + /** The period's total (steps, active minutes, workouts) or mean (load), over the days so far, today included. */ value: number | null; /** Days of the period that carry a value. */ daysWithData: number; + /** Days of the period that have ended (today excluded). */ + completedDays: number; + /** `value` over the completed days only: what `usualToDate` is compared with. */ + completedValue: number | null; /** The usual full period, or null when there is not enough history. */ usual: number | null; - /** The usual figure for the days elapsed so far (equals `usual` for a finished period). */ + /** The usual figure for the completed days (equals `usual` for a finished period); null before the first day has ended. */ usualToDate: number | null; /** Earlier periods the usual was computed from. */ referencePeriods: number; @@ -293,15 +307,21 @@ export function comparePeriod(input: { }): PeriodComparison { const { bounds, today, valueOf, mode } = input; const lastCounted = bounds.end < today ? bounds.end : today; + const yesterday = shiftDateKey(today, -1); + const lastCompleted = bounds.end < yesterday ? bounds.end : yesterday; const length = daysBetweenDateKeys(bounds.start, bounds.end) + 1; - const elapsed = - lastCounted < bounds.start - ? 0 - : daysBetweenDateKeys(bounds.start, lastCounted) + 1; + const daysUpTo = (last: string) => + last < bounds.start ? 0 : daysBetweenDateKeys(bounds.start, last) + 1; + const elapsed = daysUpTo(lastCounted); + const completed = daysUpTo(lastCompleted); const current = elapsed === 0 ? { value: null, n: 0 } : aggregate(dayRange(bounds.start, lastCounted).map(valueOf), mode); + const done = + completed === 0 + ? { value: null, n: 0 } + : aggregate(dayRange(bounds.start, lastCompleted).map(valueOf), mode); // Each earlier period becomes a per-day rate (sum) or a mean (mean), so a // 28-day February and a 31-day March compare on the same footing. @@ -319,13 +339,15 @@ export function comparePeriod(input: { usualRate === null ? null : mode === "sum" - ? usualRate * elapsed + ? usualRate * completed : usualRate; return { value: current.value, daysWithData: current.n, + completedDays: completed, + completedValue: done.value, usual, - usualToDate: elapsed === 0 ? null : usualToDate, + usualToDate: completed === 0 ? null : usualToDate, referencePeriods: rates.length, }; } @@ -477,6 +499,14 @@ export function buildActivityCalendar(args: { ? null : activeMinutesOf(day, hasActivityOn(date)); const load = future ? null : day.load; + // Today has not ended: shown, never rated against whole days. + const levelOf = ( + value: number | null, + stats: BaselineStats | null, + ): DayLevel => + date === today && value !== null + ? "in_progress" + : dayLevel(value, stats); return { date, inPeriod: date >= bounds.start && date <= bounds.end, @@ -487,9 +517,9 @@ export function buildActivityCalendar(args: { workoutsBySport: future ? {} : day.workoutsBySport, load, level: { - steps: dayLevel(steps, baseline.steps), - activeMinutes: dayLevel(activeMinutes, baseline.activeMinutes), - load: dayLevel(load, baseline.load), + steps: levelOf(steps, baseline.steps), + activeMinutes: levelOf(activeMinutes, baseline.activeMinutes), + load: levelOf(load, baseline.load), }, }; }, diff --git a/src/lib/doctor-report-pdf/__tests__/activity-effect-sections.test.ts b/src/lib/doctor-report-pdf/__tests__/activity-effect-sections.test.ts index a2c0ac544..46aa08183 100644 --- a/src/lib/doctor-report-pdf/__tests__/activity-effect-sections.test.ts +++ b/src/lib/doctor-report-pdf/__tests__/activity-effect-sections.test.ts @@ -81,7 +81,8 @@ const EFFECT: EfficacySegmented = { df: 141, rSquared: 0.55, residualAutocorrelation: 0.31, - hacLag: 4, + hacLag: 7, + autocorrelationCaveat: false, }, confounders: [ { medicationName: "Other drug", event: "started", date: "2026-02-14" }, @@ -216,8 +217,41 @@ describe("doctor report v1.44 sections", () => { expect(text).toContain("-0.3 mmHg/week"); expect(text).toContain("84 / 61"); expect(text).toContain("5 days after the start left out"); + // The fitted window (Dec to Apr) reaches outside the Feb to May report + // period; the report says which days the figures rest on. + expect(text).toMatch( + /analysed \S+ to \S+, which can reach beyond the report period/, + ); expect(text).toContain("Other drug started on"); expect(text).toContain("not a proven effect"); + expect(text).not.toContain("carry-over"); + }); + + it("notes strong day-to-day carry-over in the effect notes", async () => { + const text = ( + await textOf( + render( + data({ + medicationEffects: [ + { + medicationName: "Example ACE inhibitor", + startsOn: "2026-03-02", + targets: [ + { + type: "BLOOD_PRESSURE_SYS", + effect: { + ...EFFECT, + fit: { ...EFFECT.fit!, autocorrelationCaveat: true }, + }, + }, + ], + }, + ], + }), + ), + ) + ).replace(/\s+/g, " "); + expect(text).toContain("strong day-to-day carry-over"); }); it("names the missing floor instead of printing numbers", async () => { diff --git a/src/lib/doctor-report-pdf/activity-effect-sections.ts b/src/lib/doctor-report-pdf/activity-effect-sections.ts index cbf7c0d36..e122c7e8f 100644 --- a/src/lib/doctor-report-pdf/activity-effect-sections.ts +++ b/src/lib/doctor-report-pdf/activity-effect-sections.ts @@ -213,7 +213,15 @@ export function buildMedicationEffectSection( `${slope.value} ${unit}/${t("doctorReport.medicationEffect.week")}\n(${slope.low} ${t("doctorReport.medicationEffect.to")} ${slope.high})`, `${e.days.before} / ${e.days.after}`, ]); - const extras: string[] = []; + // The fitted window reaches up to 90 days before the start and 180 + // after it, so it can lie partly outside the report period: always + // say which days the figures rest on. + const extras: string[] = [ + t("doctorReport.medicationEffect.window", { + from: dayLabel(fmtDate, e.window.from), + to: dayLabel(fmtDate, e.window.to), + }), + ]; if (e.days.excludedLowAdherence > 0) { extras.push( t("doctorReport.medicationEffect.excludedAdherence", { @@ -232,6 +240,9 @@ export function buildMedicationEffectSection( if (e.adherence.status === "not_tracked") { extras.push(t("doctorReport.medicationEffect.notTracked")); } + if (e.fit?.autocorrelationCaveat) { + extras.push(t("doctorReport.medicationEffect.autocorrelation")); + } if (e.window.endReason === "dose_change") { extras.push( t("doctorReport.medicationEffect.endDoseChange", { @@ -247,9 +258,7 @@ export function buildMedicationEffectSection( }), ); } - if (extras.length > 0) { - notes.push(`${med.medicationName}, ${label}: ${extras.join("; ")}.`); - } + notes.push(`${med.medicationName}, ${label}: ${extras.join("; ")}.`); } } if (rows.length === 0) return state; diff --git a/src/lib/doctor-report-types.ts b/src/lib/doctor-report-types.ts index 3a18f18fb..24430b282 100644 --- a/src/lib/doctor-report-types.ts +++ b/src/lib/doctor-report-types.ts @@ -509,8 +509,9 @@ export interface DoctorReportData { * metric(s), around the medication's start (v1.44). Computed without AI from * the same day values and dose ledger the efficacy tab reads. A target rides * only when its own measurement leaf is admitted too, so the block never - * carries a figure derived from a series the owner withheld. Null when the - * MEDICATION_EFFECT leaf was not selected or nothing qualified. + * carries a figure derived from a series the owner withheld. It names the + * medications, so it rides only when MEDICATION_LIST is admitted as well. + * Null when either leaf was not selected or nothing qualified. */ medicationEffects?: DoctorReportMedicationEffect[] | null; } diff --git a/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts b/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts index fce815725..da880ef5d 100644 --- a/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts +++ b/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts @@ -409,6 +409,33 @@ describe("the derived figures ride their own leaf", () => { expect(off.medications.length).toBeGreaterThan(0); }); + it("withholds the medication effect without the medication list", async () => { + // The effect block names every medication it describes, so withholding + // the list must withhold the names here too. + const leaves: ReportLeafId[] = [ + "MEDICATION_EFFECT", + ...MEASUREMENT_LEAF_IDS, + ]; + const withList = await collectDoctorReportData( + "u1", + RANGE, + selectionFromLeaves([...leaves, "MEDICATION_LIST"]), + { moduleMap: moduleMap() }, + ); + expect(withList.medicationEffects?.length ?? 0).toBeGreaterThan(0); + + const withoutList = await collectDoctorReportData( + "u1", + RANGE, + selectionFromLeaves(leaves), + { moduleMap: moduleMap() }, + ); + expect(withoutList.medicationEffects ?? null).toBeNull(); + expect(JSON.stringify(withoutList)).not.toContain( + withList.medicationEffects![0].medicationName, + ); + }); + it("withholds the glucose panel while the raw series stays", async () => { const withPanel = await collectDoctorReportData( "u1", diff --git a/src/lib/doctor-report/collect.ts b/src/lib/doctor-report/collect.ts index a38c3313b..371d49918 100644 --- a/src/lib/doctor-report/collect.ts +++ b/src/lib/doctor-report/collect.ts @@ -674,7 +674,11 @@ export async function collectDoctorReportData( : Promise.resolve(null), // The effect around each listed medication's start. Its own leaf AND the // target's measurement leaf must both admit it; the loader asks the gate. - gate.admits("MEDICATION_EFFECT") + // The block names every medication it describes (and the confounders), + // so it also needs the medication list: an owner who withheld the list + // never releases the names through the effect block. Same rule as the + // clinician view's compliance rows, which are named from the list. + gate.admits("MEDICATION_EFFECT") && gate.admits("MEDICATION_LIST") ? loadReportMedicationEffects({ userId, medicationIds: medications.map((m) => m.id), diff --git a/src/lib/fhir/rest.ts b/src/lib/fhir/rest.ts index 75e87ecf2..ebd1c54e3 100644 --- a/src/lib/fhir/rest.ts +++ b/src/lib/fhir/rest.ts @@ -91,21 +91,33 @@ export const DEFAULT_COUNT = 50; /** Hard ceiling on `_count` so a single search can never page the whole store. */ export const MAX_COUNT = 200; +/** + * Ceiling on `_offset`. No record pages that far (the largest search, the + * Observation time series, is bounded by the readings in a date range), and + * an unbounded offset reached the database as a skip it cannot represent and + * answered 500. + */ +export const MAX_OFFSET = 100_000; + /** * Parsed + clamped paging parameters. `count` is bounded to `[1, MAX_COUNT]`; * `offset` floors at 0. A non-numeric / negative input collapses to the * default rather than erroring — FHIR search is forgiving on paging params. + * An offset above {@link MAX_OFFSET} is the one refusal: the caller answers + * it with a 400 `invalid` OperationOutcome. */ -export function parsePaging(searchParams: URLSearchParams): { - count: number; - offset: number; -} { +export function parsePaging( + searchParams: URLSearchParams, +): { count: number; offset: number } | { error: string } { const rawCount = Number(searchParams.get("_count")); const count = Number.isFinite(rawCount) && rawCount > 0 ? Math.min(Math.floor(rawCount), MAX_COUNT) : DEFAULT_COUNT; const rawOffset = Number(searchParams.get("_offset")); + if (rawOffset > MAX_OFFSET) { + return { error: `_offset must be at most ${MAX_OFFSET}` }; + } const offset = Number.isFinite(rawOffset) && rawOffset > 0 ? Math.floor(rawOffset) : 0; return { count, offset }; diff --git a/src/lib/import/__tests__/record-import-route.test.ts b/src/lib/import/__tests__/record-import-route.test.ts new file mode 100644 index 000000000..33b74f93b --- /dev/null +++ b/src/lib/import/__tests__/record-import-route.test.ts @@ -0,0 +1,110 @@ +/** + * The shared CSV record-import request side: the audit row is written for a + * finished import and for one cut off part way, with how many rows landed. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { z } from "zod/v4"; + +const { auditLog } = vi.hoisted(() => ({ auditLog: vi.fn() })); + +vi.mock("@/lib/api-handler", () => ({ + requireAuth: vi.fn(async () => ({ user: { id: "u1" } })), +})); +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: vi.fn(async () => ({ enabled: true })), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(async () => ({ allowed: true })), +})); +vi.mock("@/lib/tz/resolver", () => ({ + resolveUserTimezone: vi.fn(async () => "UTC"), +})); +vi.mock("@/lib/auth/audit", () => ({ auditLog })); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); + +import { handleRecordImport } from "../record-import-route"; +import type { RecordImportResult } from "@/lib/validations/record-import"; + +const schema = z + .object({ dryRun: z.enum(["0", "1"]).transform((v) => v === "1") }) + .strict(); + +function request(dryRun = "0") { + return new Request(`http://localhost/api/import/labs-csv?dryRun=${dryRun}`, { + method: "POST", + headers: { "content-type": "text/csv" }, + body: "a,b\n1,2\n", + }) as never; +} + +const DONE = { + kind: "labs", + dryRun: false, + rows: 400, + valid: 400, + inserted: 400, + duplicates: 0, + failed: 0, +} as unknown as RecordImportResult; + +beforeEach(() => auditLog.mockReset()); + +describe("handleRecordImport audit", () => { + it("audits a finished import as complete", async () => { + await handleRecordImport(request(), { + kind: "labs", + module: "labs", + schema, + run: async ({ onWritten }) => { + onWritten(200); + onWritten(200); + return DONE; + }, + }); + expect(auditLog).toHaveBeenCalledTimes(1); + expect(auditLog.mock.calls[0][1].details).toMatchObject({ + inserted: 400, + partial: false, + written: 400, + }); + }); + + it("audits an import cut off part way with what it wrote", async () => { + await expect( + handleRecordImport(request(), { + kind: "labs", + module: "labs", + schema, + run: async ({ onWritten }) => { + onWritten(200); + throw new Error("client went away"); + }, + }), + ).rejects.toThrow("client went away"); + expect(auditLog).toHaveBeenCalledTimes(1); + expect(auditLog.mock.calls[0][1].details).toEqual({ + partial: true, + written: 200, + }); + }); + + it("writes no audit row for a dry run or an abort that wrote nothing", async () => { + await handleRecordImport(request("1"), { + kind: "labs", + module: "labs", + schema, + run: async () => ({ ...DONE, dryRun: true }), + }); + await expect( + handleRecordImport(request(), { + kind: "labs", + module: "labs", + schema, + run: async () => { + throw new Error("boom"); + }, + }), + ).rejects.toThrow("boom"); + expect(auditLog).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/import/labs-csv.ts b/src/lib/import/labs-csv.ts index 86e4b202c..e3308503a 100644 --- a/src/lib/import/labs-csv.ts +++ b/src/lib/import/labs-csv.ts @@ -13,7 +13,14 @@ * Memory: the file is read record by record (`readCsvRecords`) and written in * chunks of `CHUNK_ROWS`; what is held across chunks is the marker index, the * duplicate keys of rows already accepted and the bounded report. + * + * Writes: one `createMany` per chunk, so a chunk lands whole or not at all. + * An import cut off part way (the client went away, a proxy timed out) leaves + * whole chunks behind, and uploading the same file again skips them as + * duplicates and writes the rest: a retry is idempotent. `onWritten` reports + * each committed chunk so the caller can audit a partial import. */ +import type { Prisma } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { readCsvRecords, @@ -272,9 +279,15 @@ export async function importLabsCsv(args: { maxBytes: number; query: LabsCsvQuery; now?: Date; + /** Called after each committed chunk with the rows it wrote. */ + onWritten?: (rows: number) => void; }): Promise { const { userId, tz, query } = args; const dryRun = query.dryRun; + // One zone for everything: the file's times are read in it, and the + // calendar day a reading is a duplicate on is cut in it too. Cutting the + // days in the account's zone while reading the file in another split one + // draw day in two, or folded two into one. const timezone = query.timezone ?? tz; const report = new RecordImportReport("labs", dryRun); const index: MarkerIndex = await loadMarkerIndex(prisma, userId); @@ -365,7 +378,9 @@ export async function importLabsCsv(args: { } // 2. Duplicates against the record, one query for the chunk. - const days = planned.map((p) => labReadingDay(p.row.takenAt, tz)).sort(); + const days = planned + .map((p) => labReadingDay(p.row.takenAt, timezone)) + .sort(); const existingKeys = new Set(); if (planned.length > 0) { const from = labReadingDaySearchRange(days[0]).gte; @@ -396,16 +411,18 @@ export async function importLabsCsv(args: { }, }); for (const e of existing) { - for (const k of readingKeys(e, tz)) existingKeys.add(k); + for (const k of readingKeys(e, timezone)) existingKeys.add(k); // A reading filed under a marker also answers to its printed name. if (e.biomarkerId) { - for (const k of readingKeys({ ...e, biomarkerId: null }, tz)) + for (const k of readingKeys({ ...e, biomarkerId: null }, timezone)) existingKeys.add(k); } } } - // 3. Write (or count) what is new. + // 3. Write (or count) what is new: rows are built here and written in + // one statement for the chunk. + const toWrite: Prisma.LabResultCreateManyInput[] = []; for (const { row, marker } of planned) { const probe = { biomarkerId: @@ -418,10 +435,10 @@ export async function importLabsCsv(args: { sourceUnit: row.unit, }; const keys = [ - ...readingKeys(probe, tz), - ...readingKeys({ ...probe, biomarkerId: null }, tz), + ...readingKeys(probe, timezone), + ...readingKeys({ ...probe, biomarkerId: null }, timezone), ...(marker - ? readingKeys({ ...probe, biomarkerId: marker.id }, tz) + ? readingKeys({ ...probe, biomarkerId: marker.id }, timezone) : []), ]; if (keys.some((k) => existingKeys.has(k) || seen.has(k))) { @@ -483,34 +500,36 @@ export async function importLabsCsv(args: { } // Field by field, never a spread of the parsed row. - await prisma.labResult.create({ - data: { - userId, - biomarkerId: biomarker.id, - panel: row.panel ?? biomarker.panel, - analyte: biomarker.name, - value: storedValue, - valueText: row.valueText, - unit: biomarker.unit, - sourceValue: convertedFrom?.sourceValue ?? null, - sourceUnit: convertedFrom?.sourceUnit ?? null, - referenceLow: biomarker.lowerBound, - referenceHigh: biomarker.upperBound, - sourceReferenceLow: storedLow, - sourceReferenceHigh: storedHigh, - sourceReferenceText: row.sourceReferenceText, - takenAt: row.takenAt, - source: "IMPORT", - noteEncrypted: row.note ? encryptNoteToBytes(row.note) : null, - }, + toWrite.push({ + userId, + biomarkerId: biomarker.id, + panel: row.panel ?? biomarker.panel, + analyte: biomarker.name, + value: storedValue, + valueText: row.valueText, + unit: biomarker.unit, + sourceValue: convertedFrom?.sourceValue ?? null, + sourceUnit: convertedFrom?.sourceUnit ?? null, + referenceLow: biomarker.lowerBound, + referenceHigh: biomarker.upperBound, + sourceReferenceLow: storedLow, + sourceReferenceHigh: storedHigh, + sourceReferenceText: row.sourceReferenceText, + takenAt: row.takenAt, + source: "IMPORT", + noteEncrypted: row.note ? encryptNoteToBytes(row.note) : null, }); for (const k of [ ...keys, - ...readingKeys({ ...probe, biomarkerId: biomarker.id }, tz), + ...readingKeys({ ...probe, biomarkerId: biomarker.id }, timezone), ]) seen.add(k); report.valid += 1; - report.inserted += 1; + } + if (toWrite.length > 0) { + const { count } = await prisma.labResult.createMany({ data: toWrite }); + report.inserted += count; + args.onWritten?.(count); } }; diff --git a/src/lib/import/medications-csv.ts b/src/lib/import/medications-csv.ts index d616ce675..354bfab13 100644 --- a/src/lib/import/medications-csv.ts +++ b/src/lib/import/medications-csv.ts @@ -185,6 +185,8 @@ export async function importMedicationsCsv(args: { maxBytes: number; query: MedicationsCsvQuery; now?: Date; + /** Called after each medication written. */ + onWritten?: (rows: number) => void; }): Promise { const { userId, tz, query } = args; const dryRun = query.dryRun; @@ -207,125 +209,131 @@ export async function importMedicationsCsv(args: { ? "\t" : "auto"; - let first = true; - for await (const record of readCsvRecords(args.stream, { - maxBytes: args.maxBytes, - delimiter, - })) { - if (first && query.header) { + let written = 0; + try { + let first = true; + for await (const record of readCsvRecords(args.stream, { + maxBytes: args.maxBytes, + delimiter, + })) { + if (first && query.header) { + first = false; + continue; + } first = false; - continue; - } - first = false; - report.rows += 1; - if (report.rows > MEDICATIONS_CSV_MAX_ROWS) { - report.fail(record.line, "row_limit"); - continue; - } - if (record.oversized) { - report.fail(record.line, "oversized_row"); - continue; - } - const result = normaliseMedicationRow(record, query, timezone); - if (!result.ok) { - report.fail(record.line, result.reason, result.field); - continue; - } - const row = result.row; - const parsed = createMedicationSchema.safeParse( - medicationCreateBody(row, query.reminders), - ); - if (!parsed.success) { - report.fail(row.line, "invalid_medication"); - continue; - } - // The course the create route would open for this window. - let course: { startsOn: Date; endsOn: Date | null } | null = null; - if (parsed.data.startsOn || parsed.data.endsOn) { - const endKey = parsed.data.endsOn - ? dayKeyOfDate(parsed.data.endsOn) - : null; - course = { - startsOn: - parsed.data.startsOn ?? - dateOfDayKey(endKey && endKey < todayKey ? endKey : todayKey), - endsOn: parsed.data.endsOn ?? null, - }; - if (validateCourses([course], todayKey, false)) { - report.fail(row.line, "end_before_start", "endsOn"); + report.rows += 1; + if (report.rows > MEDICATIONS_CSV_MAX_ROWS) { + report.fail(record.line, "row_limit"); + continue; + } + if (record.oversized) { + report.fail(record.line, "oversized_row"); + continue; + } + const result = normaliseMedicationRow(record, query, timezone); + if (!result.ok) { + report.fail(record.line, result.reason, result.field); + continue; + } + const row = result.row; + const parsed = createMedicationSchema.safeParse( + medicationCreateBody(row, query.reminders), + ); + if (!parsed.success) { + report.fail(row.line, "invalid_medication"); + continue; + } + // The course the create route would open for this window. + let course: { startsOn: Date; endsOn: Date | null } | null = null; + if (parsed.data.startsOn || parsed.data.endsOn) { + const endKey = parsed.data.endsOn + ? dayKeyOfDate(parsed.data.endsOn) + : null; + course = { + startsOn: + parsed.data.startsOn ?? + dateOfDayKey(endKey && endKey < todayKey ? endKey : todayKey), + endsOn: parsed.data.endsOn ?? null, + }; + if (validateCourses([course], todayKey, false)) { + report.fail(row.line, "end_before_start", "endsOn"); + continue; + } + } + const key = identity(row.name, row.dose); + if (seen.has(key)) { + report.duplicate(row.line); + continue; + } + seen.add(key); + report.valid += 1; + report.inserted += 1; + if (dryRun) { + report.addPreview({ + line: row.line, + name: row.name, + dose: row.dose, + times: row.times.join(" "), + asNeeded: row.asNeeded, + startsOn: row.startsOn, + endsOn: row.endsOn, + }); continue; } - } - const key = identity(row.name, row.dose); - if (seen.has(key)) { - report.duplicate(row.line); - continue; - } - seen.add(key); - report.valid += 1; - report.inserted += 1; - if (dryRun) { - report.addPreview({ - line: row.line, - name: row.name, - dose: row.dose, - times: row.times.join(" "), - asNeeded: row.asNeeded, - startsOn: row.startsOn, - endsOn: row.endsOn, - }); - continue; - } - const data = parsed.data; - // Field by field, with the create route's invariants for the shapes an - // import produces: a daily schedule (FREQ=DAILY, times dual-written), an - // opening course for a dated window, no schedule for as-needed. - const medication = await prisma.medication.create({ - data: { - userId, - name: data.name, - dose: data.dose, - asNeeded: data.asNeeded ?? false, - notificationsEnabled: data.notificationsEnabled ?? false, - ...(course - ? { - startsOn: course.startsOn, - endsOn: course.endsOn, - courses: { - create: { - userId, - startsOn: course.startsOn, - endsOn: course.endsOn, + const data = parsed.data; + // Field by field, with the create route's invariants for the shapes an + // import produces: a daily schedule (FREQ=DAILY, times dual-written), an + // opening course for a dated window, no schedule for as-needed. + const medication = await prisma.medication.create({ + data: { + userId, + name: data.name, + dose: data.dose, + asNeeded: data.asNeeded ?? false, + notificationsEnabled: data.notificationsEnabled ?? false, + ...(course + ? { + startsOn: course.startsOn, + endsOn: course.endsOn, + courses: { + create: { + userId, + startsOn: course.startsOn, + endsOn: course.endsOn, + }, }, - }, - } - : {}), - schedules: { - create: (data.schedules ?? []).map((s) => ({ - windowStart: s.windowStart, - windowEnd: s.windowEnd, - label: null, - dose: null, - daysOfWeek: serializeScheduleRecurrence({ - daysOfWeek: [], - intervalWeeks: 1, - }), - timesOfDay: - s.timesOfDay && s.timesOfDay.length > 0 - ? s.timesOfDay - : [s.windowStart], - rrule: "FREQ=DAILY", - })), + } + : {}), + schedules: { + create: (data.schedules ?? []).map((s) => ({ + windowStart: s.windowStart, + windowEnd: s.windowEnd, + label: null, + dose: null, + daysOfWeek: serializeScheduleRecurrence({ + daysOfWeek: [], + intervalWeeks: 1, + }), + timesOfDay: + s.timesOfDay && s.timesOfDay.length > 0 + ? s.timesOfDay + : [s.windowStart], + rrule: "FREQ=DAILY", + })), + }, }, - }, - select: { id: true }, - }); - await setMedicationCategory(medication.id, undefined); - } - - if (!dryRun && report.inserted > 0) { - invalidateUserMedications(userId, { evict: true }); + select: { id: true }, + }); + await setMedicationCategory(medication.id, undefined); + written += 1; + args.onWritten?.(1); + } + } finally { + // Also after an import cut off part way: what was written is visible. + if (!dryRun && written > 0) { + invalidateUserMedications(userId, { evict: true }); + } } return report.toResult(); } diff --git a/src/lib/import/record-import-route.ts b/src/lib/import/record-import-route.ts index ded29bc6e..8035becd4 100644 --- a/src/lib/import/record-import-route.ts +++ b/src/lib/import/record-import-route.ts @@ -47,6 +47,7 @@ export async function handleRecordImport< stream: ReadableStream; maxBytes: number; query: z.infer; + onWritten: (rows: number) => void; }) => Promise; }, ): Promise { @@ -102,7 +103,8 @@ export async function handleRecordImport< } const tz = await resolveUserTimezone(user.id); - let result: RecordImportResult; + let result: RecordImportResult | null = null; + let written = 0; try { result = await opts.run({ userId: user.id, @@ -110,6 +112,9 @@ export async function handleRecordImport< stream: request.body, maxBytes: RECORD_IMPORT_MAX_BYTES, query, + onWritten: (rows) => { + written += rows; + }, }); } catch (err) { if (err instanceof CsvTooLargeError) { @@ -118,25 +123,36 @@ export async function handleRecordImport< }); } throw err; + } finally { + // The audit row is written whatever happened: an import cut off part way + // (the client went away, a proxy timed out, the cap was hit after some + // chunks) has already written rows, and the trail says how many. + if ( + !query.dryRun && + ((result !== null && result.rows > 0) || written > 0) + ) { + await auditLog(action, { + userId: user.id, + ipAddress: getClientIp(request), + details: + result !== null + ? { + rows: result.rows, + inserted: result.inserted, + duplicates: result.duplicates, + failed: result.failed, + partial: false, + written, + } + : { partial: true, written }, + }); + } } if (result.rows === 0) { return apiError("The CSV file has no data rows", 422, { errorCode: "import.csv.empty", }); } - - if (!query.dryRun) { - await auditLog(action, { - userId: user.id, - ipAddress: getClientIp(request), - details: { - rows: result.rows, - inserted: result.inserted, - duplicates: result.duplicates, - failed: result.failed, - }, - }); - } annotate({ meta: { import_dry_run: query.dryRun, diff --git a/src/lib/insights/__tests__/strain-score.test.ts b/src/lib/insights/__tests__/strain-score.test.ts index fcf7173fb..03ea6f010 100644 --- a/src/lib/insights/__tests__/strain-score.test.ts +++ b/src/lib/insights/__tests__/strain-score.test.ts @@ -33,6 +33,8 @@ import { resolvePersonalReference, persistStrainScore, canonicalWorkoutTrimps, + canonicalDayActiveEnergy, + STRAIN_ACTIVE_ENERGY_REFERENCE, STRAIN_SCORE_EXTERNAL_ID_PREFIX, STRAIN_TRIMP_REFERENCE, STRAIN_MIN_TRAINING_DAYS, @@ -152,7 +154,7 @@ describe("saturateToScore", () => { }); function makePrisma(opts: { - energy?: number[]; + energy?: Array; workouts?: Array<{ id: string; samples: { samples: unknown } | null; @@ -172,8 +174,9 @@ function makePrisma(opts: { trainingDays?: number; }>; }) { - const energyRows = (opts.energy ?? []).map((value, i) => ({ - value, + const energyRows = (opts.energy ?? []).map((entry, i) => ({ + ...(typeof entry === "number" ? { value: entry } : entry), + measuredAt: new Date(Date.UTC(2026, 5, 1, 6 + i)), id: `e${i}`, })); const measurementFindMany = vi.fn().mockResolvedValue(energyRows); @@ -577,6 +580,21 @@ describe("canonicalWorkoutTrimps — one impulse per session", () => { ).toEqual([15, 20, 40]); }); + it("never adds a source off the ladder to another source's session", () => { + // WHOOP and IMPORT are both off the workout ladder, so the picker keeps + // both rows of the slot: one session, the larger impulse. + const rows = [row("w", "WHOOP", 50), row("i", "IMPORT", 45, 1)]; + expect(canonicalWorkoutTrimps(rows, null, (r) => r.trimp)).toEqual([50]); + }); + + it("still adds two sessions of one source in the same slot", () => { + const rows = [ + { ...row("w1", "WHOOP", 30), durationSec: 600 }, + { ...row("w2", "WHOOP", 20, 2), durationSec: 900 }, + ]; + expect(canonicalWorkoutTrimps(rows, null, (r) => r.trimp)).toEqual([50]); + }); + it("is empty when nothing carries an impulse", () => { expect( canonicalWorkoutTrimps([row("a", "APPLE_HEALTH", 0)], null, () => 0), @@ -584,6 +602,46 @@ describe("canonicalWorkoutTrimps — one impulse per session", () => { }); }); +describe("canonicalDayActiveEnergy — one source per day", () => { + const at = new Date("2026-06-01T09:00:00.000Z"); + const e = (source: string, value: number) => ({ + source: source as "APPLE_HEALTH", + value, + measuredAt: at, + }); + + it("keeps the ladder's first source, not the sum of two", () => { + expect( + canonicalDayActiveEnergy( + [e("APPLE_HEALTH", 200), e("OURA", 280), e("APPLE_HEALTH", 100)], + null, + ), + ).toBe(300); + }); + + it("keeps the larger total when no source is on the ladder", () => { + expect( + canonicalDayActiveEnergy([e("WHOOP", 200), e("IMPORT", 150)], null), + ).toBe(200); + }); + + it("drives the active-energy fallback score", async () => { + const { prisma } = makePrisma({ + rhr: 50, + energy: [ + { value: 300, source: "APPLE_HEALTH" }, + { value: 290, source: "OURA" }, + ], + workouts: [], + }); + const result = await persistStrainScore(prisma, "user-1", NOW); + expect(result.reason).toBe("active_energy_fallback"); + expect(result.score).toBe( + saturateToScore(300, STRAIN_ACTIVE_ENERGY_REFERENCE), + ); + }); +}); + describe("persistStrainScore — duplicate sessions", () => { it("scores a session stored twice the same as the session stored once", async () => { const series = { diff --git a/src/lib/insights/__tests__/training-load.test.ts b/src/lib/insights/__tests__/training-load.test.ts index 2e1e33ae2..bd5994013 100644 --- a/src/lib/insights/__tests__/training-load.test.ts +++ b/src/lib/insights/__tests__/training-load.test.ts @@ -31,23 +31,29 @@ describe("computeTrainingLoad", () => { ctl: 2.4, atl: 14.3, tsb: 0, + rated: true, }); + expect(load.recentRatedDays).toBe(42); + expect(load.recentDays).toBe(42); }); - it("counts a day without a stored row as a rest day", () => { - // Train on day 0 only, then 29 empty days up to day 29. - const load = computeTrainingLoad( - [ - { day: day(0), dayTrimp: 84 }, - { day: day(1), dayTrimp: 42 }, - { day: day(2), dayTrimp: 42 }, - ], - day(29), - ); + it("counts a scored day of 0 as rest, and holds through a day never scored", () => { + // Three training days, then scored rest days 3..29 except a gap of + // unscored days 10..19 (no heart-rate data reached the server). + const rows = [ + { day: day(0), dayTrimp: 84 }, + { day: day(1), dayTrimp: 42 }, + { day: day(2), dayTrimp: 42 }, + ]; + for (let i = 3; i < 30; i += 1) { + if (i < 10 || i > 19) rows.push({ day: day(i), dayTrimp: 0 }); + } + const load = computeTrainingLoad(rows, day(29)); if (load.status !== "ok") throw new Error("expected ok"); let ctl = 0; let atl = 0; for (let i = 0; i < 30; i += 1) { + if (i >= 10 && i <= 19) continue; // held const l = i === 0 ? 84 : i < 3 ? 42 : 0; ctl += (l - ctl) / 42; atl += (l - atl) / 7; @@ -55,9 +61,26 @@ describe("computeTrainingLoad", () => { expect(load.ctl).toBe(Math.round(ctl * 10) / 10); expect(load.atl).toBe(Math.round(atl * 10) / 10); expect(load.daysCovered).toBe(30); + expect(load.recentRatedDays).toBe(20); + expect(load.recentDays).toBe(30); + expect(load.series.find((p) => p.day === day(12))?.rated).toBe(false); + expect(load.series.find((p) => p.day === day(25))?.rated).toBe(true); expect(load.warmingUp).toBe(true); }); + it("describes the last stored day, so a run not yet made never zeroes it", () => { + // Rows through day 39; asked for day 41 (the nightly run for 40 and 41 + // has not happened). The figures are those of day 39. + const rows = Array.from({ length: 40 }, (_, i) => ({ + day: day(i), + dayTrimp: 60, + })); + const late = computeTrainingLoad(rows, day(41)); + const exact = computeTrainingLoad(rows, day(39)); + expect(late).toEqual(exact); + expect(late).toMatchObject({ status: "ok", asOf: day(39) }); + }); + it("answers insufficient without impulse or with too little history", () => { expect(computeTrainingLoad([], day(10))).toMatchObject({ status: "insufficient", diff --git a/src/lib/insights/derived/__tests__/changepoint-extended.test.ts b/src/lib/insights/derived/__tests__/changepoint-extended.test.ts index edfcfe85b..f5314f533 100644 --- a/src/lib/insights/derived/__tests__/changepoint-extended.test.ts +++ b/src/lib/insights/derived/__tests__/changepoint-extended.test.ts @@ -11,7 +11,10 @@ vi.mock("@/lib/rollups/measurement-coverage", () => ({ import { buildChangepointSignals, buildExtendedChangepointSignals, + CHANGEPOINT_FLOORS, CHANGEPOINT_WINDOW_DAYS, + detectLevelShift, + MAX_MAGNITUDE, EXTENDED_CHANGEPOINT_METRICS, EXTENDED_CHANGEPOINT_WINDOW_DAYS, } from "../changepoint"; @@ -103,3 +106,92 @@ describe("buildChangepointSignals (the Coach's scan)", () => { ]); }); }); + +/** One metric's series, every other metric empty. */ +function only(metric: string, means: number[]) { + readDayMeanSeries.mockImplementation(async (_u: string, m: string) => + m === metric + ? { + points: means.map((mean, i) => ({ + day: shiftDateKey("2026-04-01", i), + mean, + })), + source: "DAY", + } + : { points: [], source: "DAY" }, + ); +} + +describe("false-positive floors", () => { + it("gives every extended metric a floor", () => { + for (const metric of EXTENDED_CHANGEPOINT_METRICS) { + expect(CHANGEPOINT_FLOORS[metric], metric).toBeDefined(); + } + }); + + it("does not call a VO2 max estimate stepping by 0.3 a shift", async () => { + // 60 days at 40.0, 60 at 40.3: each plateau flat, so the raw detector + // sees a near-infinite magnitude. + const plateau = [ + ...Array(60).fill(40), + ...Array(60).fill(40.3), + ]; + expect(detectLevelShift(plateau)).not.toBeNull(); + only("VO2_MAX", plateau); + expect(await buildExtendedChangepointSignals("u1", NOW, {})).toEqual([]); + // A real change in the estimate still fires. + only("VO2_MAX", [ + ...Array(60).fill(40), + ...Array(60).fill(43), + ]); + // Measured against the 0.5 resolution floor, not a spread of zero: + // 3 / 0.5 = 6 σ, not the cap. + expect(await buildExtendedChangepointSignals("u1", NOW, {})).toEqual([ + expect.objectContaining({ + metric: "VO2_MAX", + direction: "up", + magnitude: 6, + }), + ]); + }); + + it("needs a change worth noting, not just a clean one", async () => { + // Resting heart rate 60 → 62: clean, but under the 3 bpm floor. + only("RESTING_HEART_RATE", [ + ...Array(60).fill(60), + ...Array(60).fill(62), + ]); + expect(await buildExtendedChangepointSignals("u1", NOW, {})).toEqual([]); + only("RESTING_HEART_RATE", [ + ...Array(60).fill(60), + ...Array(60).fill(64), + ]); + expect(await buildExtendedChangepointSignals("u1", NOW, {})).toHaveLength( + 1, + ); + }); + + it("reads a slow drift as a drift, not a dated shift", () => { + // Resting heart rate creeping from 55 to 65 over 120 days, ±0.6 noise. + const drift = Array.from({ length: 120 }, (_, i) => { + const jitter = (((i * 7919) % 100) / 100 - 0.5) * 1.2; + return 55 + (10 * i) / 119 + jitter; + }); + expect( + detectLevelShift(drift, { minAbsDelta: 3, spreadFloor: 1 }), + ).toBeNull(); + // The same rise as a step does fire. + const stepped = drift.map((_, i) => (i < 60 ? 55 : 65) + (i % 3) * 0.3); + expect( + detectLevelShift(stepped, { minAbsDelta: 3, spreadFloor: 1 }), + ).not.toBeNull(); + }); + + it("bounds the magnitude, so the flattest series cannot win the ranking", () => { + const shift = detectLevelShift([ + ...Array(30).fill(14), + ...Array(30).fill(17), + ]); + expect(shift?.magnitude).toBe(MAX_MAGNITUDE); + }); +}); diff --git a/src/lib/insights/derived/changepoint.ts b/src/lib/insights/derived/changepoint.ts index d7505c8c0..abbbdb39c 100644 --- a/src/lib/insights/derived/changepoint.ts +++ b/src/lib/insights/derived/changepoint.ts @@ -16,7 +16,18 @@ * - the post-break level must PERSIST (≥ `minPersistDays` after the break), * - the level change must exceed the metric's own robust spread * (|Δmean| ≥ `magnitudeK` · MAD · 1.4826), the personal yardstick, and - * - the series must be long enough overall. + * - the series must be long enough overall, + * - the step must explain the series better than a straight line does + * (a slow drift splits into two halves with different means, but it is a + * drift, not a shift), and + * - with the per-metric floors the scan passes ({@link CHANGEPOINT_FLOORS}): + * the step must reach a minimum absolute size, and the spread it is + * measured against never drops below the device / clinical resolution, + * so a device that reports in 0.3 steps cannot make a 0.3 plateau change + * look like a many-sigma event. + * + * `magnitude` is capped at {@link MAX_MAGNITUDE} so the cross-metric ranking + * is not won by whichever series happens to be the flattest. * * Below the bar the detector emits `null` and the Coach keeps saying "lately". * One changepoint per metric per window — no multi-break hunting in v1.22. @@ -30,8 +41,11 @@ import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage"; import { readDayMeanSeries } from "@/lib/insights/derived/baseline"; import { moduleForMeasurementType } from "@/lib/modules/measurement-scope"; import type { SurfaceModuleMap } from "@/lib/modules/surface"; +import { linearFit } from "@/lib/stats/linear-fit"; const MAD_TO_SIGMA = 1.4826; +/** Upper bound of the reported (and ranked) magnitude, in robust-σ units. */ +export const MAX_MAGNITUDE = 10; /** A validated level shift in a numeric series. */ export interface LevelShift { @@ -55,6 +69,10 @@ export interface DetectLevelShiftOptions { minPersistDays?: number; /** |Δmean| floor in robust-σ units. Default 1.4 (high firing bar). */ magnitudeK?: number; + /** |Δmean| floor in the metric's own unit. Default 0 (none). */ + minAbsDelta?: number; + /** Lower bound of the pooled spread, in the metric's unit. Default 0. */ + spreadFloor?: number; } function mean(xs: readonly number[]): number { @@ -94,6 +112,8 @@ export function detectLevelShift( const minSegmentLength = opts.minSegmentLength ?? 5; const minPersistDays = opts.minPersistDays ?? 10; const magnitudeK = opts.magnitudeK ?? 1.4; + const minAbsDelta = opts.minAbsDelta ?? 0; + const spreadFloor = opts.spreadFloor ?? 0; const n = series.length; if (n < minSeriesLength) return null; @@ -128,6 +148,7 @@ export function detectLevelShift( const beforeMean = mean(before); const afterMean = mean(after); const delta = afterMean - beforeMean; + if (Math.abs(delta) < minAbsDelta) return null; // Magnitude bar: the step must exceed the WITHIN-segment robust spread (the // metric's day-to-day noise), NOT the whole-series spread — a clean step @@ -138,21 +159,49 @@ export function detectLevelShift( const pooledSpread = Math.max( robustSpread(before), robustSpread(after), + spreadFloor, 1e-9, ); const magnitude = Math.abs(delta) / pooledSpread; if (magnitude < magnitudeK) return null; + if (isDrift(series, before, after, beforeMean, afterMean)) return null; return { breakIndex, beforeMean, afterMean, delta, - magnitude, + magnitude: Math.min(magnitude, MAX_MAGNITUDE), direction: delta >= 0 ? "up" : "down", }; } +/** + * True when a straight line through the whole series fits at least as well as + * the two-level step (same parameter count, so the residual sums compare + * directly). A drift is then the honest reading, not a dated shift. + */ +function isDrift( + series: readonly number[], + before: readonly number[], + after: readonly number[], + beforeMean: number, + afterMean: number, +): boolean { + let sseStep = 0; + for (const x of before) sseStep += (x - beforeMean) ** 2; + for (const x of after) sseStep += (x - afterMean) ** 2; + + const fit = linearFit( + series.map((_, i) => i), + series, + ); + if (!fit) return false; + // Residual sum of the line from the centred sums: SSR = Syy − Sxy² / Sxx. + const sseLine = Math.max(0, fit.syy - (fit.sxy * fit.sxy) / fit.sxx); + return sseLine <= sseStep; +} + /** One detected changepoint, ready for the snapshot. */ export interface ChangepointSignal { metric: MeasurementType; @@ -178,6 +227,42 @@ const CHANGEPOINT_METRICS: readonly MeasurementType[] = [ "BLOOD_GLUCOSE", ]; +/** + * Per-metric floors, in the metric's stored unit: `minDelta` is the smallest + * level change worth calling a shift (about the smallest change a clinician + * would note for that vital), `spreadFloor` the smallest day-to-day spread the + * step is measured against (about the measurement resolution / repeat error of + * a consumer device). A metric without an entry has neither floor. + * + * - heart rates (bpm): a 3 bpm change, 1 bpm resolution. + * - HRV (ms): 5 ms, 2 ms (beat detection jitter). + * - weight (kg): 1 kg, 0.2 kg (scale repeat error). + * - blood pressure (mmHg): 5 / 3 mmHg, 2 mmHg (cuff repeat error). + * - glucose (mg/dL): 10 mg/dL, 3 mg/dL (meter accuracy). + * - body fat (%): 1 point, 0.3 point (impedance repeat error). + * - respiratory rate (breaths/min): 1, 0.3. + * - SpO₂ (%): 1 point, 0.5 point (reported in whole percent). + * - VO₂ max (ml/kg/min): 1.5, 0.5 (a modelled estimate that moves in 0.1–0.3 + * steps; a plateau stepping by 0.3 is the estimator, not the person). + */ +export const CHANGEPOINT_FLOORS: Partial< + Record +> = { + RESTING_HEART_RATE: { minDelta: 3, spreadFloor: 1 }, + PULSE: { minDelta: 3, spreadFloor: 1 }, + WALKING_HEART_RATE_AVERAGE: { minDelta: 3, spreadFloor: 1 }, + HEART_RATE_VARIABILITY: { minDelta: 5, spreadFloor: 2 }, + HRV_RMSSD: { minDelta: 5, spreadFloor: 2 }, + WEIGHT: { minDelta: 1, spreadFloor: 0.2 }, + BLOOD_PRESSURE_SYS: { minDelta: 5, spreadFloor: 2 }, + BLOOD_PRESSURE_DIA: { minDelta: 3, spreadFloor: 2 }, + BLOOD_GLUCOSE: { minDelta: 10, spreadFloor: 3 }, + BODY_FAT: { minDelta: 1, spreadFloor: 0.3 }, + RESPIRATORY_RATE: { minDelta: 1, spreadFloor: 0.3 }, + OXYGEN_SATURATION: { minDelta: 1, spreadFloor: 0.5 }, + VO2_MAX: { minDelta: 1.5, spreadFloor: 0.5 }, +}; + /** Window the reader looks back over (days). */ export const CHANGEPOINT_WINDOW_DAYS = 90; /** At most this many changepoints in one snapshot (strongest first). */ @@ -244,7 +329,14 @@ async function scanChangepoints( coverage, ); if (points.length === 0) continue; - const shift = detectLevelShift(points.map((p) => p.mean)); + const floors = CHANGEPOINT_FLOORS[metric]; + const shift = detectLevelShift( + points.map((p) => p.mean), + { + minAbsDelta: floors?.minDelta, + spreadFloor: floors?.spreadFloor, + }, + ); if (!shift) continue; const breakDate = points[shift.breakIndex + 1]?.day ?? points[shift.breakIndex].day; diff --git a/src/lib/insights/strain-score.ts b/src/lib/insights/strain-score.ts index 52e02f51a..ee87a461b 100644 --- a/src/lib/insights/strain-score.ts +++ b/src/lib/insights/strain-score.ts @@ -75,8 +75,13 @@ import { shiftDateKey } from "@/lib/tz/format"; import { pickCanonicalWorkoutRows, workoutDuplicateSlotKey, + workoutSourceLadder, type WorkoutPickerRow, } from "@/lib/measurements/pick-canonical-workout-rows"; +import { + pickCanonicalSourceRows, + type SourcePickerRow, +} from "@/lib/analytics/source-priority"; /** The per-day idempotency-key prefix for a stored Strain score row. */ export const STRAIN_SCORE_EXTERNAL_ID_PREFIX = "strain:"; @@ -413,10 +418,16 @@ export async function computeStrainScore( deletedAt: null, measuredAt: { gte: dayStart, lt: dayEnd }, }, - select: { value: true }, + orderBy: [{ measuredAt: "asc" }, { id: "asc" }], + select: { + value: true, + source: true, + deviceType: true, + measuredAt: true, + type: true, + }, take: 5000, }); - const dayActiveEnergy = energyRows.reduce((s, r) => s + r.value, 0); // The day's workouts + their HR series, with the session identity the // cross-source picker needs: one run recorded by the watch and synced again @@ -441,6 +452,13 @@ export async function computeStrainScore( }), ]); + // The watch and the phone relay (or a ring) both write the day's active + // energy: one source per day, the same pick the energy chart applies. + const dayActiveEnergy = canonicalDayActiveEnergy( + energyRows, + account?.sourcePriorityJson ?? null, + ); + const hrMax = profile.ageYears != null ? tanakaHrMax(profile.ageYears) : null; const canTrimp = hrMax != null && hrRest != null && hrMax > hrRest; @@ -529,6 +547,31 @@ export async function computeStrainScore( }; } +/** + * The day's active energy from ONE source. The rows of a single scored day go + * through the cumulative-metric picker on the `activeEnergy` ladder (source, + * then device type); when no source on the ladder is present the picker keeps + * every row, so the sources left are then compared by their day total and the + * largest kept: never two sources' totals added. Pure; exported for tests. + */ +export function canonicalDayActiveEnergy< + T extends SourcePickerRow & { value: number }, +>(rows: readonly T[], priorityJson: unknown): number { + const { canonicalRows } = pickCanonicalSourceRows( + rows, + "activeEnergy", + priorityJson, + () => "day", + ); + const bySource = new Map(); + for (const row of canonicalRows) { + bySource.set(row.source, (bySource.get(row.source) ?? 0) + row.value); + } + let best = 0; + for (const total of bySource.values()) best = Math.max(best, total); + return best; +} + /** * One training impulse per workout SESSION, not per stored row. * @@ -542,6 +585,13 @@ export async function computeStrainScore( * rate: the winning row can be one without a series while its dropped twin * carries one. So the impulse of a slot is the sum over its picked rows, and * only when that is zero the largest impulse among the slot's dropped rows. + * + * The picker keeps every row of a slot none of whose sources is on the + * ladder, so the picked rows of one slot can come from several sources. Only + * one source's rows count: the best-ranked on the ladder, a source off the + * ladder ranking below every listed one, and among sources off the ladder + * the one with the largest impulse. Rows of that one source still add up + * (two distinct sessions of the same sport in one five-minute slot). * Returns the positive impulses, one per session. Pure; exported for tests. */ export function canonicalWorkoutTrimps< @@ -556,20 +606,42 @@ export function canonicalWorkoutTrimps< const picked = new Set( pickCanonicalWorkoutRows(rows, priorityJson).map((row) => row.id), ); - const slots = new Map(); + const ladder = workoutSourceLadder(priorityJson); + const rankOf = (source: string) => { + const index = ladder.indexOf(source as (typeof ladder)[number]); + return index === -1 ? ladder.length : index; + }; + const slots = new Map< + string, + { picked: Map; dropped: number } + >(); for (const row of rows) { const key = workoutDuplicateSlotKey(row); - const slot = slots.get(key) ?? { picked: 0, dropped: 0 }; + const slot = slots.get(key) ?? { picked: new Map(), dropped: 0 }; const trimp = trimpOf(row); if (trimp > 0) { - if (picked.has(row.id)) slot.picked += trimp; - else slot.dropped = Math.max(slot.dropped, trimp); + if (picked.has(row.id)) { + slot.picked.set(row.source, (slot.picked.get(row.source) ?? 0) + trimp); + } else { + slot.dropped = Math.max(slot.dropped, trimp); + } } slots.set(key, slot); } const out: number[] = []; for (const slot of slots.values()) { - const trimp = slot.picked > 0 ? slot.picked : slot.dropped; + let best: { rank: number; trimp: number } | null = null; + for (const [source, trimp] of slot.picked) { + const rank = rankOf(source); + if ( + best === null || + rank < best.rank || + (rank === best.rank && trimp > best.trimp) + ) { + best = { rank, trimp }; + } + } + const trimp = best !== null ? best.trimp : slot.dropped; if (trimp > 0) out.push(trimp); } return out; diff --git a/src/lib/insights/training-load.ts b/src/lib/insights/training-load.ts index b96eaa865..7c56444fb 100644 --- a/src/lib/insights/training-load.ts +++ b/src/lib/insights/training-load.ts @@ -3,8 +3,13 @@ * training impulse. * * The impulse is the Banister TRIMP the nightly strain run already writes to - * `strain_trimp_cache` (one row per scored UTC day; a day without a row - * carried no impulse). Over it run the two exponentially weighted averages + * `strain_trimp_cache`: one row per scored UTC day, 0 on a scored day without + * training. A day WITHOUT a row was never scored (no heart-rate data reached + * the server, or the run has not happened yet), so it is not rated rather + * than read as rest: the averages hold through it instead of decaying toward + * zero, and the figures report how many recent days were rated. The figures + * describe the last stored day, so a nightly run that has not happened yet + * never turns yesterday into a rest day. Over it run the two exponentially weighted averages * of the fitness-fatigue model (Banister, Calvert, Savage & Bach 1975; in the * time-constant form popularised as chronic and acute training load): * @@ -37,6 +42,8 @@ export interface TrainingLoadPoint { ctl: number; atl: number; tsb: number; + /** False for a day without a stored impulse: the averages held through it. */ + rated: boolean; } export type TrainingLoad = @@ -50,6 +57,10 @@ export type TrainingLoad = tsb: number; daysCovered: number; trainingDays: number; + /** Days of the trailing {@link CTL_DAYS} (or fewer, when covered less) with a stored impulse. */ + recentRatedDays: number; + /** The trailing window `recentRatedDays` counts over. */ + recentDays: number; warmingUp: boolean; series: TrainingLoadPoint[]; } @@ -63,16 +74,17 @@ export type TrainingLoad = const round1 = (x: number) => Math.round(x * 10) / 10; /** - * Run the model from the first stored day through `asOf` (inclusive), a - * missing day counting as zero impulse. Rows after `asOf` are ignored. + * Run the model from the first stored day through the last stored day on or + * before `until`. A day without a row is not rated: the averages hold through + * it. Rows after `until` are ignored. */ export function computeTrainingLoad( rows: ReadonlyArray<{ day: string; dayTrimp: number }>, - asOf: string, + until: string, ): TrainingLoad { const byDay = new Map(); for (const row of rows) { - if (row.day > asOf || !Number.isFinite(row.dayTrimp)) continue; + if (row.day > until || !Number.isFinite(row.dayTrimp)) continue; byDay.set(row.day, Math.max(0, row.dayTrimp)); } const days = [...byDay.keys()].sort(); @@ -86,18 +98,23 @@ export function computeTrainingLoad( }; } + const asOf = days[days.length - 1]; const series: TrainingLoadPoint[] = []; let ctl = 0; let atl = 0; let daysCovered = 0; for (let day = days[0]; day <= asOf; day = shiftDateKey(day, 1)) { const tsb = ctl - atl; - const load = byDay.get(day) ?? 0; - ctl += (load - ctl) / CTL_DAYS; - atl += (load - atl) / ATL_DAYS; + const load = byDay.get(day); + if (load !== undefined) { + ctl += (load - ctl) / CTL_DAYS; + atl += (load - atl) / ATL_DAYS; + } daysCovered += 1; - series.push({ day, ctl, atl, tsb }); + series.push({ day, ctl, atl, tsb, rated: load !== undefined }); } + const recent = series.slice(-CTL_DAYS); + const recentRatedDays = recent.filter((p) => p.rated).length; if ( daysCovered < TRAINING_LOAD_MIN_DAYS || @@ -119,17 +136,23 @@ export function computeTrainingLoad( tsb: round1(ctl - atl), daysCovered, trainingDays, + recentRatedDays, + recentDays: recent.length, warmingUp: daysCovered < CTL_DAYS, series: series.slice(-TRAINING_LOAD_SERIES_DAYS).map((p) => ({ day: p.day, ctl: round1(p.ctl), atl: round1(p.atl), tsb: round1(p.tsb), + rated: p.rated, })), }; } -/** Read the stored impulse and run the model up to the last scored day. */ +/** + * Read the stored impulse and run the model up to the last stored day, at + * most the day the strain run scores (yesterday in UTC). + */ export async function readTrainingLoad( userId: string, now: Date = new Date(), @@ -138,15 +161,15 @@ export async function readTrainingLoad( // here rather than imported: `score-row` sits on the score writers' import // graph, and this read stays off it. // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the strain cache is keyed on the UTC score day - const asOf = new Date(now.getTime() - 86_400_000).toISOString().slice(0, 10); + const until = new Date(now.getTime() - 86_400_000).toISOString().slice(0, 10); const rows = await prisma.strainTrimpCache.findMany({ where: { userId, - day: { gte: shiftDateKey(asOf, -TRAINING_LOAD_READ_DAYS), lte: asOf }, + day: { gte: shiftDateKey(until, -TRAINING_LOAD_READ_DAYS), lte: until }, }, orderBy: { day: "asc" }, take: TRAINING_LOAD_READ_DAYS + 1, select: { day: true, dayTrimp: true }, }); - return computeTrainingLoad(rows, asOf); + return computeTrainingLoad(rows, until); } diff --git a/src/lib/labs/__tests__/lab-trend.test.ts b/src/lib/labs/__tests__/lab-trend.test.ts index b4aeb3dfa..9a7591b63 100644 --- a/src/lib/labs/__tests__/lab-trend.test.ts +++ b/src/lib/labs/__tests__/lab-trend.test.ts @@ -128,4 +128,50 @@ describe("computeLabTrend", () => { computeLabTrend(falling, { low: 90, high: 140 }, atYears(2)), ).toMatchObject({ projection: "exits", exitBound: "low" }); }); + + it("never carries the line further ahead than the readings reach back", () => { + // 10 a year over one year (span 1.0); band top 125 sits 1.5 years past + // the last reading. Inside the three-year cap, outside the one-year span. + const short = readings([ + [0, 100], + [0.5, 105], + [1, 110], + ]); + expect( + computeLabTrend(short, { low: 70, high: 125 }, atYears(1)), + ).toMatchObject({ projection: "beyond_horizon", horizonYears: 1 }); + // The same line with a top of 118 meets it 0.8 years out: projected. + expect( + computeLabTrend(short, { low: 70, high: 118 }, atYears(1)), + ).toMatchObject({ projection: "exits", exitBound: "high" }); + }); + + it("reports a stale series as of its last reading, without a projection", () => { + const line = readings([ + [0, 100], + [1, 110], + [2, 120], + ]); + // Last reading two years before "now": evaluated on that day. + const stale = computeLabTrend(line, { low: 70, high: 125 }, atYears(4)); + expect(stale).toMatchObject({ + status: "ok", + projection: "stale", + exitDate: null, + exitBound: null, + fittedNow: 120, + slopePerYear: 10, + }); + if (stale.status !== "ok") throw new Error("expected ok"); + expect(stale.asOf).toBe(stale.lastAt); + // Carried to today the line would read 140, above the band: a stale + // series never says "already outside". + expect( + computeLabTrend(line, { low: 70, high: 115 }, atYears(4)), + ).toMatchObject({ projection: "stale" }); + // Eleven months after the last reading it is still current. + expect( + computeLabTrend(line, { low: 70, high: 200 }, atYears(2.9)), + ).toMatchObject({ projection: "beyond_horizon" }); + }); }); diff --git a/src/lib/labs/lab-trend.ts b/src/lib/labs/lab-trend.ts index f577c1f3d..c5781b80e 100644 --- a/src/lib/labs/lab-trend.ts +++ b/src/lib/labs/lab-trend.ts @@ -13,7 +13,14 @@ * The slope carries its 95 % confidence interval (Student t at n − 2 degrees * of freedom). A projection is offered only when that interval excludes zero, * the line is inside the band today and heads toward a bound it reaches - * within {@link LAB_PROJECTION_HORIZON_YEARS} years. It is a straight-line + * within the horizon: {@link LAB_PROJECTION_HORIZON_YEARS} years, or the span + * the readings cover when that is shorter, so the line is never carried + * further ahead than the data reaches back. + * + * A marker whose newest reading is older than {@link LAB_TREND_STALE_DAYS} + * days gets no projection at all (`stale`): the line is evaluated on the day + * of that reading and reported as of then, with no exit date and no + * "already outside" verdict about a today the readings say nothing about. It is a straight-line * extrapolation, labelled as one wherever it is shown, never a forecast of * the body and never a risk statement: lab values plateau, respond to * treatment and carry assay noise a line does not know about. With three @@ -29,6 +36,8 @@ import { dateOnlyKey } from "@/lib/tz/date-only"; export const LAB_TREND_MIN_VALUES = 3; export const LAB_TREND_MIN_SPAN_DAYS = 365; export const LAB_PROJECTION_HORIZON_YEARS = 3; +/** Newest reading older than this: the trend is reported as of that reading. */ +export const LAB_TREND_STALE_DAYS = 365; const DAY_MS = 86_400_000; const YEAR_DAYS = 365.25; @@ -45,7 +54,12 @@ export type LabTrendProjection = /** The slope's interval includes zero: no projection is drawn. */ | "no_clear_trend" /** The marker has no reference band to leave. */ - | "no_band"; + | "no_band" + /** + * The newest reading is older than {@link LAB_TREND_STALE_DAYS} days: the + * line is reported as of that reading (`asOf` = `lastAt`), not projected. + */ + | "stale"; export interface LabTrendOk { status: "ok"; @@ -62,7 +76,13 @@ export interface LabTrendOk { pValue: number; /** The line's value on `asOf`. */ fittedNow: number; + /** Today, or the newest reading's day when the trend is `stale`. */ asOf: string; + /** + * How far ahead a bound may be met and still count as `exits`: three years, + * or the readings' span when shorter. + */ + horizonYears: number; projection: LabTrendProjection; /** Set only for `exits`: the day the line meets `exitBound`. */ exitDate: string | null; @@ -136,14 +156,23 @@ export function computeLabTrend( : 0 : twoSidedPFromT(Math.abs(fit.slope / se), df); - const xNow = (now.getTime() - t0) / DAY_MS / YEAR_DAYS; + const tLast = points[n - 1].t; + const stale = now.getTime() - tLast > LAB_TREND_STALE_DAYS * DAY_MS; + // A stale series is read on its own last day, never carried to today. + const tAsOf = stale ? tLast : now.getTime(); + const xNow = (tAsOf - t0) / DAY_MS / YEAR_DAYS; const fittedNow = fit.intercept + fit.slope * xNow; + const horizonYears = Math.min( + LAB_PROJECTION_HORIZON_YEARS, + (tLast - t0) / DAY_MS / YEAR_DAYS, + ); let projection: LabTrendProjection; let exitDate: string | null = null; let exitBound: "low" | "high" | null = null; const hasBand = band.low != null || band.high != null; - if (!hasBand) projection = "no_band"; + if (stale) projection = "stale"; + else if (!hasBand) projection = "no_band"; else if (!(lo > 0 || hi < 0)) projection = "no_clear_trend"; else if ( (band.low != null && fittedNow < band.low) || @@ -156,7 +185,7 @@ export function computeLabTrend( else { const xCross = (bound - fit.intercept) / fit.slope; const yearsAhead = xCross - xNow; - if (yearsAhead > LAB_PROJECTION_HORIZON_YEARS) { + if (yearsAhead > horizonYears) { projection = "beyond_horizon"; } else { projection = "exits"; @@ -176,7 +205,8 @@ export function computeLabTrend( slopeCi95: [round(lo, 4), round(hi, 4)], pValue: round(pValue, 6), fittedNow: round(fittedNow, 4), - asOf: dayOf(now.getTime()), + asOf: dayOf(tAsOf), + horizonYears: round(horizonYears, 2), projection, exitDate, exitBound, diff --git a/src/lib/medications/efficacy/__tests__/segmented-effect.test.ts b/src/lib/medications/efficacy/__tests__/segmented-effect.test.ts index 6f099d6bc..ea4191657 100644 --- a/src/lib/medications/efficacy/__tests__/segmented-effect.test.ts +++ b/src/lib/medications/efficacy/__tests__/segmented-effect.test.ts @@ -173,6 +173,25 @@ describe("buildSegmentedEffect", () => { expect(r.present).toBe(true); expect(r.levelChange!.includesZero).toBe(true); }); + + it("flags strong residual autocorrelation for a caveat, not a clean fit", () => { + // A slow wave around the line: neighbouring residuals share a sign. + const wavy = series(-60, 59).map((p, i) => ({ + day: p.day, + value: p.value + 4 * Math.sin(i / 6), + })); + const r = buildSegmentedEffect(input({ dayValues: wavy })); + expect(r.fit!.residualAutocorrelation).toBeGreaterThan(0.5); + expect(r.fit!.autocorrelationCaveat).toBe(true); + expect(r.fit!.hacLag).toBe(7); + // Alternating noise: negative carry-over, no caveat. + const alternating = series(-60, 59).map((p, i) => ({ + day: p.day, + value: p.value + (i % 2 === 0 ? 2 : -2), + })); + const clean = buildSegmentedEffect(input({ dayValues: alternating })); + expect(clean.fit!.autocorrelationCaveat).toBe(false); + }); }); describe("notTakingPredicate", () => { diff --git a/src/lib/medications/efficacy/segmented-effect.ts b/src/lib/medications/efficacy/segmented-effect.ts index 028f4a55b..f102a8217 100644 --- a/src/lib/medications/efficacy/segmented-effect.ts +++ b/src/lib/medications/efficacy/segmented-effect.ts @@ -48,6 +48,12 @@ export const ADHERENCE_LOOKBACK_DAYS = 14; export const ADHERENCE_FLOOR = 0.8; /** Confidence level of every interval. */ export const SEGMENTED_LEVEL = 0.95; +/** + * Lag-1 residual autocorrelation above which the read-out carries a caveat: + * the carry-over is then strong enough that even the widened intervals may be + * too narrow. + */ +export const AUTOCORRELATION_CAVEAT = 0.5; /** An estimate in display terms: the unit per day for a level, per week for a slope. */ export interface EfficacyEstimate { @@ -106,6 +112,8 @@ export interface EfficacySegmented { rSquared: number; residualAutocorrelation: number; hacLag: number; + /** True above {@link AUTOCORRELATION_CAVEAT}: renderers add the caveat line. */ + autocorrelationCaveat: boolean; } | null; /** Other medication changes inside the window, oldest first. */ confounders: EfficacyConfounder[]; @@ -311,6 +319,8 @@ export function buildSegmentedEffect( rSquared: fit.rSquared, residualAutocorrelation: fit.residualAutocorrelation, hacLag: fit.hacLag, + autocorrelationCaveat: + fit.residualAutocorrelation > AUTOCORRELATION_CAVEAT, }, confounders, }; diff --git a/src/lib/openapi/routes/biomarkers.ts b/src/lib/openapi/routes/biomarkers.ts index 8de2be86d..b4568f1fe 100644 --- a/src/lib/openapi/routes/biomarkers.ts +++ b/src/lib/openapi/routes/biomarkers.ts @@ -93,7 +93,16 @@ const labTrend = z .describe("95 % interval of the slope (Student t at n − 2 df)."), pValue: z.number(), fittedNow: z.number().describe("The line's value on `asOf`."), - asOf: z.string().describe("YYYY-MM-DD the line was evaluated on."), + asOf: z + .string() + .describe( + "YYYY-MM-DD the line was evaluated on: today, or `lastAt` when `projection` is `stale`.", + ), + horizonYears: z + .number() + .describe( + "How far ahead a bound may be met and still count as `exits`: three years, or the readings' span when shorter.", + ), projection: z.enum([ "exits", "beyond_horizon", @@ -101,6 +110,7 @@ const labTrend = z "already_outside", "no_clear_trend", "no_band", + "stale", ]), exitDate: z .string() @@ -120,7 +130,7 @@ const labTrend = z .meta({ id: "BiomarkerTrend", description: - "v1.44 — the least-squares line through every numeric reading of the marker (all stored in the marker's unit). Needs at least three readings spanning at least 365 days, else `insufficient`. `projection` places the line against the marker's band: `exits` (with `exitDate`) only when the slope's 95 % interval excludes zero, the line is inside the band today and reaches a bound within three years. Server-computed; clients render it as given.", + "v1.44 — the least-squares line through every numeric reading of the marker (all stored in the marker's unit). Needs at least three readings spanning at least 365 days, else `insufficient`. `projection` places the line against the marker's band: `exits` (with `exitDate`) only when the slope's 95 % interval excludes zero, the line is inside the band today and reaches a bound within `horizonYears` (three years, or the readings' span when shorter). `stale` when the newest reading is more than 365 days old: the line is then reported as of `lastAt`, with no projection. Server-computed; clients render it as given.", }); const biomarkerDetail = biomarkerRow diff --git a/src/lib/openapi/routes/health-record.ts b/src/lib/openapi/routes/health-record.ts index 9673e3aa3..20e0e4042 100644 --- a/src/lib/openapi/routes/health-record.ts +++ b/src/lib/openapi/routes/health-record.ts @@ -376,7 +376,12 @@ export const healthRecordPaths: NonNullable = { requestParams: { query: z.object({ _count: z.coerce.number().optional(), - _offset: z.coerce.number().optional(), + _offset: z.coerce + .number() + .optional() + .describe( + "At most 100000; a larger offset is a 400 OperationOutcome.", + ), }), }, responses: { @@ -401,7 +406,12 @@ export const healthRecordPaths: NonNullable = { requestParams: { query: z.object({ _count: z.coerce.number().optional(), - _offset: z.coerce.number().optional(), + _offset: z.coerce + .number() + .optional() + .describe( + "At most 100000; a larger offset is a 400 OperationOutcome.", + ), date: z.string().optional(), code: z.string().optional(), }), @@ -428,7 +438,12 @@ export const healthRecordPaths: NonNullable = { requestParams: { query: z.object({ _count: z.coerce.number().optional(), - _offset: z.coerce.number().optional(), + _offset: z.coerce + .number() + .optional() + .describe( + "At most 100000; a larger offset is a 400 OperationOutcome.", + ), }), }, responses: { @@ -453,7 +468,12 @@ export const healthRecordPaths: NonNullable = { requestParams: { query: z.object({ _count: z.coerce.number().optional(), - _offset: z.coerce.number().optional(), + _offset: z.coerce + .number() + .optional() + .describe( + "At most 100000; a larger offset is a 400 OperationOutcome.", + ), }), }, responses: { @@ -478,7 +498,12 @@ export const healthRecordPaths: NonNullable = { requestParams: { query: z.object({ _count: z.coerce.number().optional(), - _offset: z.coerce.number().optional(), + _offset: z.coerce + .number() + .optional() + .describe( + "At most 100000; a larger offset is a 400 OperationOutcome.", + ), }), }, responses: { @@ -653,7 +678,12 @@ export const healthRecordPaths: NonNullable = { requestParams: { query: z.object({ _count: z.coerce.number().optional(), - _offset: z.coerce.number().optional(), + _offset: z.coerce + .number() + .optional() + .describe( + "At most 100000; a larger offset is a 400 OperationOutcome.", + ), }), }, responses: { diff --git a/src/lib/openapi/routes/import.ts b/src/lib/openapi/routes/import.ts index 6fee3fa7f..a9fc17f68 100644 --- a/src/lib/openapi/routes/import.ts +++ b/src/lib/openapi/routes/import.ts @@ -24,13 +24,14 @@ import { } from "./shared"; import { isoDateTime } from "@/lib/validations/datetime"; import { + csvImportQuerySchema, labsCsvQuerySchema, medicationsCsvQuerySchema, recordImportResultSchema, } from "@/lib/validations/record-import"; const RECORD_IMPORT_COMMON = - "Body is the raw CSV file (`text/csv`, ≤ 16 MB), read as a stream: a UTF-8 byte-order mark is dropped, the separator is `,`, `;` or a tab, quoted fields may hold separators and line breaks. Every row is checked on its own and a refused row is reported by line with a reason, never guessed (a number or date that reads two ways is refused). Re-uploading the same file writes nothing: every row is reported as a duplicate. `dryRun=1` checks the whole file without writing and returns the first rows as they would be stored; it spends a separate bucket of 30 checks an hour, the import itself the shared 5-an-hour `import:` bucket. Owner only; refused with `module.disabled` when the module is switched off."; + "Body is the raw CSV file (`text/csv`, ≤ 16 MB), read as a stream: a UTF-8 byte-order mark is dropped, the separator is `,`, `;` or a tab, quoted fields may hold separators and line breaks. Every row is checked on its own and a refused row is reported by line with a reason, never guessed (a number or date that reads two ways is refused). Re-uploading the same file writes nothing: every row is reported as a duplicate. `dryRun=1` checks the whole file without writing and returns the first rows as they would be stored; it spends a separate bucket of 30 checks an hour, the import itself the shared 5-an-hour `import:` bucket. Owner only; refused with `module.disabled` when the module is switched off. An unknown query parameter is refused with 422, so a misspelt `dryRun` never writes."; function recordImportResponses(id: string) { return { @@ -339,17 +340,8 @@ export const importPaths: NonNullable = { tags: ["Import"], summary: "Import measurements from CSV", description: - "Bulk-import measurements from a CSV file (web cold-start escape hatch). Body is raw `text/csv` (≤ 16 MB, ≤ 10 000 valid rows). Header (order-independent): `type,value,unit,measuredAt[,glucoseContext,notes,externalId]`. `measuredAt` must carry an explicit ISO-8601 offset and is bounded (no future beyond a 5-min skew, no instant before 1900). Glucose accepts `mmol/L` (converted to canonical `mg/dL`); weight accepts `lb` (converted to `kg`). `glucoseContext` is optional on a `BLOOD_GLUCOSE` row — a blank cell stores no context, which is the normal shape of a continuous-sensor export; a non-empty value is still checked against the enum, and the column is refused on any other type. Re-uploading a file is idempotent. With an `externalId` column a row upserts on `(userId, type, source=IMPORT, externalId)`: a changed row is `updated`, an unchanged one is `skipped` with reason `duplicate` and not written. Without it a row is deduplicated on `(type, measuredAt)` and a repeat is `skipped` / `duplicate`. Writes commit in chunks rather than as one transaction; a failure part-way leaves earlier chunks written, and re-uploading the same file completes the rest. `?dryRun=1` validates + previews without writing. Shares the 5/hour `import:` rate bucket.", - parameters: [ - { - name: "dryRun", - in: "query", - required: false, - schema: { type: "string", enum: ["1", "true"] }, - description: - "When `1` / `true`, parse + validate + return the per-row envelope without writing.", - }, - ], + "Bulk-import measurements from a CSV file (web cold-start escape hatch). Body is raw `text/csv` (≤ 16 MB, ≤ 10 000 valid rows). Header (order-independent): `type,value,unit,measuredAt[,glucoseContext,notes,externalId]`. `measuredAt` must carry an explicit ISO-8601 offset and is bounded (no future beyond a 5-min skew, no instant before 1900). Glucose accepts `mmol/L` (converted to canonical `mg/dL`); weight accepts `lb` (converted to `kg`). `glucoseContext` is optional on a `BLOOD_GLUCOSE` row — a blank cell stores no context, which is the normal shape of a continuous-sensor export; a non-empty value is still checked against the enum, and the column is refused on any other type. Re-uploading a file is idempotent. With an `externalId` column a row upserts on `(userId, type, source=IMPORT, externalId)`: a changed row is `updated`, an unchanged one is `skipped` with reason `duplicate` and not written. Without it a row is deduplicated on `(type, measuredAt)` and a repeat is `skipped` / `duplicate`. Writes commit in chunks rather than as one transaction; a failure part-way leaves earlier chunks written, and re-uploading the same file completes the rest. `?dryRun=1` validates + previews without writing. Shares the 5/hour `import:` rate bucket. An unknown query parameter is refused with 422, so a misspelt `dryRun` never writes.", + requestParams: { query: csvImportQuerySchema }, requestBody: { required: true, content: { diff --git a/src/lib/openapi/routes/workouts.ts b/src/lib/openapi/routes/workouts.ts index 217766ab0..a64febd99 100644 --- a/src/lib/openapi/routes/workouts.ts +++ b/src/lib/openapi/routes/workouts.ts @@ -115,6 +115,11 @@ const trainingLoadPoint = z.object({ ctl: z.number(), atl: z.number(), tsb: z.number(), + rated: z + .boolean() + .describe( + "False for a day without a stored impulse (never scored): the averages held through it rather than counting it as rest.", + ), }); const trainingLoad = z @@ -123,12 +128,21 @@ const trainingLoad = z status: z.literal("ok"), asOf: z .string() - .describe("The last scored UTC day the figures describe."), + .describe( + "The last UTC day with a stored impulse, at most yesterday: a nightly run that has not happened yet never reads as a rest day.", + ), ctl: z.number().describe("Fitness: 42-day exponentially weighted TRIMP."), atl: z.number().describe("Fatigue: 7-day exponentially weighted TRIMP."), tsb: z.number().describe("Form: fitness minus fatigue at `asOf`."), daysCovered: z.number().int(), trainingDays: z.number().int(), + recentRatedDays: z + .number() + .int() + .describe( + "Days of the trailing `recentDays` (42, or fewer when covered less) with a stored impulse.", + ), + recentDays: z.number().int(), warmingUp: z .boolean() .describe("True below 42 covered days: both averages start at zero."), @@ -148,7 +162,7 @@ const trainingLoad = z .meta({ id: "WorkoutTrainingLoad", description: - "v1.44 — the fitness-fatigue model over the stored daily heart-rate training impulse (Banister TRIMP; a day without a stored impulse counts as rest). Needs 28 days and three training days. TRIMP units: comparable with the person's own past only.", + "v1.44 — the fitness-fatigue model over the stored daily heart-rate training impulse (Banister TRIMP; a scored day without training is 0, a day without a stored impulse is not rated and the averages hold through it). Needs 28 days and three training days. TRIMP units: comparable with the person's own past only.", }); const workoutListResponse = z diff --git a/src/lib/query-keys/settings.ts b/src/lib/query-keys/settings.ts index 95c1a4f13..03673daa1 100644 --- a/src/lib/query-keys/settings.ts +++ b/src/lib/query-keys/settings.ts @@ -44,9 +44,6 @@ export const settingsKeys = { */ moduleListLayout: () => ["settings", "module-list-layout"] as const, - /** The per-user preview-feature opt-ins behind `/api/settings/preview-features`. */ - previewFeatures: () => ["settings", "preview-features"] as const, - /** * v1.35.0 — which pillars count toward the Health Score, behind * `GET/PATCH /api/auth/me/health-score-config`. Its own cell rather than diff --git a/src/lib/record-settings/classification.ts b/src/lib/record-settings/classification.ts index 8d54e3eb1..fbeda64b2 100644 --- a/src/lib/record-settings/classification.ts +++ b/src/lib/record-settings/classification.ts @@ -98,7 +98,6 @@ export const SETTINGS_DESTINATION_INVENTORY = { export: PERSONAL, advanced: PERSONAL, privacy: PERSONAL, - preview: PERSONAL, about: PERSONAL, } as const satisfies Record< SettingsSectionSlug, diff --git a/src/lib/settings/preview-features.ts b/src/lib/settings/preview-features.ts index cd184520d..514b55fd1 100644 --- a/src/lib/settings/preview-features.ts +++ b/src/lib/settings/preview-features.ts @@ -7,7 +7,9 @@ * * Flags: * - `topics`: topic overview pages above the single-metric Insights pages. - * Stored and shown in Settings > System; no surface reads it yet. + * Stored, served by `/api/settings/preview-features` and carried by the + * backup. The Settings > System switch is not listed until the topic pages + * ship, because nothing reads the flag yet. * * Unknown keys in a stored blob are dropped on read, so a flag retired later * disappears cleanly and a malformed blob reads as everything off. diff --git a/src/lib/stats/__tests__/segmented-regression.test.ts b/src/lib/stats/__tests__/segmented-regression.test.ts index 57001444b..2c057825b 100644 --- a/src/lib/stats/__tests__/segmented-regression.test.ts +++ b/src/lib/stats/__tests__/segmented-regression.test.ts @@ -2,16 +2,22 @@ * Exact statistics for the segmented (interrupted time series) regression. * * The reference values for the noisy fixtures were computed independently - * with statsmodels 0.14 (`OLS(...).fit(cov_type="HAC", cov_kwds={"maxlags": - * L, "use_correction": True}, use_t=True)`, L = floor(4·(n/100)^(2/9))) and - * scipy's `stats.t`. The fixture generator below is the same deterministic + * with statsmodels 0.14.4 (`OLS(...).fit(cov_type="HAC", cov_kwds={"maxlags": + * L, "use_correction": True}, use_t=True)`) and scipy's `stats.t`, at the + * default lag L = max(7, floor(4·(n/100)^(2/9))) = 7 and, to pin the HAC meat + * at a second lag, at the automatic L = 3 passed explicitly. To recompute: + * build the fixture below in Python (Python's floored `%`), fit the design + * [1, t, D, t·D] with the call above at each L, and read `params`, `bse` and + * `conf_int(0.05)`; the coefficients and R² do not depend on L. The fixture generator below is the same deterministic * formula the reference script used, so a drift in the estimator, the HAC * meat, the small-sample factor or the t quantile fails here. */ import { describe, expect, it } from "vitest"; import { + defaultHacLag, fitSegmentedRegression, + MIN_HAC_LAG, neweyWestLag, studentTCdf, studentTQuantile, @@ -62,10 +68,27 @@ describe("fitSegmentedRegression", () => { expect(fit!.rSquared).toBeCloseTo(1, 12); }); - it("matches statsmodels HAC on a contiguous 90-day series", () => { + it("matches statsmodels HAC on a contiguous 90-day series at the default lag", () => { const fit = fitSegmentedRegression(fixture(40, 50))!; expect(fit.n).toBe(90); expect(fit.df).toBe(86); + expect(fit.hacLag).toBe(7); + expect(fit.levelChange.estimate).toBeCloseTo(-7.336815685253282, 9); + expect(fit.intercept.standardError).toBeCloseTo(0.3926378134611552, 9); + expect(fit.preSlope.standardError).toBeCloseTo(0.015434960504949637, 10); + expect(fit.levelChange.standardError).toBeCloseTo(0.5544100003043471, 9); + expect(fit.slopeChange.standardError).toBeCloseTo(0.02119259135971675, 10); + expect(fit.levelChange.ciLow).toBeCloseTo(-8.43894628913928, 8); + expect(fit.levelChange.ciHigh).toBeCloseTo(-6.234685081367285, 8); + expect(fit.slopeChange.ciLow).toBeCloseTo(-0.07897452738576954, 9); + expect(fit.slopeChange.ciHigh).toBeCloseTo(0.005284427179883318, 9); + expect(fit.rSquared).toBeCloseTo(0.8683372493940129, 10); + }); + + it("matches statsmodels HAC on a contiguous 90-day series at lag 3", () => { + const fit = fitSegmentedRegression(fixture(40, 50), { hacLag: 3 })!; + expect(fit.n).toBe(90); + expect(fit.df).toBe(86); expect(fit.hacLag).toBe(3); expect(fit.intercept.estimate).toBeCloseTo(139.46950523140714, 9); expect(fit.preSlope.estimate).toBeCloseTo(-0.09725785860840647, 10); @@ -82,9 +105,24 @@ describe("fitSegmentedRegression", () => { expect(fit.rSquared).toBeCloseTo(0.8683372493940129, 10); }); - it("matches statsmodels HAC on a series with gaps", () => { + it("matches statsmodels HAC on a series with gaps at the default lag", () => { const fit = fitSegmentedRegression(fixture(30, 30, { gaps: true }))!; expect(fit.n).toBe(48); + expect(fit.hacLag).toBe(7); + expect(fit.levelChange.estimate).toBeCloseTo(-6.123258149435133, 9); + expect(fit.levelChange.standardError).toBeCloseTo(0.7919225818543464, 9); + expect(fit.levelChange.ciLow).toBeCloseTo(-7.71927324237417, 8); + expect(fit.levelChange.ciHigh).toBeCloseTo(-4.527243056496095, 8); + expect(fit.slopeChange.estimate).toBeCloseTo(-0.11857109294626766, 10); + expect(fit.slopeChange.ciLow).toBeCloseTo(-0.22143394263874167, 9); + expect(fit.slopeChange.ciHigh).toBeCloseTo(-0.01570824325379365, 9); + }); + + it("matches statsmodels HAC on a series with gaps at lag 3", () => { + const fit = fitSegmentedRegression(fixture(30, 30, { gaps: true }), { + hacLag: 3, + })!; + expect(fit.n).toBe(48); expect(fit.hacLag).toBe(3); expect(fit.levelChange.estimate).toBeCloseTo(-6.123258149435133, 9); expect(fit.levelChange.standardError).toBeCloseTo(0.9608527406825199, 9); @@ -106,9 +144,16 @@ describe("fitSegmentedRegression", () => { expect(fitSegmentedRegression(fixture(2, 2, { noise: false }))).toBeNull(); }); - it("uses the automatic Newey–West lag", () => { + it("uses the automatic Newey–West lag, never below a week", () => { expect(neweyWestLag(90)).toBe(3); expect(neweyWestLag(30)).toBe(3); expect(neweyWestLag(10)).toBe(2); + expect(MIN_HAC_LAG).toBe(7); + expect(defaultHacLag(90)).toBe(7); + expect(defaultHacLag(270)).toBe(7); + expect(defaultHacLag(100_000)).toBe(neweyWestLag(100_000)); + // A short series clamps to n − 1. + expect(fitSegmentedRegression(fixture(4, 4))!.hacLag).toBe(7); + expect(fitSegmentedRegression(fixture(3, 3))!.hacLag).toBe(5); }); }); diff --git a/src/lib/stats/segmented-regression.ts b/src/lib/stats/segmented-regression.ts index b0451c5cd..e5e81eb23 100644 --- a/src/lib/stats/segmented-regression.ts +++ b/src/lib/stats/segmented-regression.ts @@ -13,11 +13,14 @@ * Daily health series are autocorrelated: today's blood pressure says a lot * about tomorrow's. Plain OLS standard errors ignore that and come out too * narrow, so the covariance here is the Newey–West estimator (Bartlett - * kernel) with the usual automatic lag `floor(4·(n/100)^(2/9))` and the - * small-sample factor n/(n−k). Intervals use Student's t with n−k degrees of - * freedom. This matches `statsmodels` OLS `cov_type="HAC"` with - * `use_correction=True` and `use_t=True`; the unit tests pin it to values - * computed there. + * kernel) and the small-sample factor n/(n−k). The lag is the usual automatic + * `floor(4·(n/100)^(2/9))`, but never below {@link MIN_HAC_LAG}: on 30 to 270 + * daily values the automatic rule gives 3, while a physiological series + * carries over for about a week (weight, resting heart rate, blood pressure + * under a new medication), and three lags leave most of that out. Intervals + * use Student's t with n−k degrees of freedom. This matches `statsmodels` OLS + * `cov_type="HAC"` with `use_correction=True` and `use_t=True` at the same + * `maxlags`; the unit tests pin it to values computed there. * * Observations are taken in the order given, which for a daily series with * gaps treats a gap as adjacent days. That is a known approximation of the @@ -61,11 +64,19 @@ export interface SegmentedFit { const K = 4; +/** Smallest Newey–West lag applied: a week of daily carry-over. */ +export const MIN_HAC_LAG = 7; + /** Automatic Newey–West lag: floor(4·(n/100)^(2/9)). */ export function neweyWestLag(n: number): number { return Math.floor(4 * Math.pow(n / 100, 2 / 9)); } +/** The lag applied by default: the automatic rule, at least {@link MIN_HAC_LAG}. */ +export function defaultHacLag(n: number): number { + return Math.max(MIN_HAC_LAG, neweyWestLag(n)); +} + /** Invert a small symmetric positive-definite matrix (Gauss–Jordan). */ function invert(matrix: number[][]): number[][] | null { const size = matrix.length; @@ -131,7 +142,7 @@ export function fitSegmentedRegression( ); // Meat: Σ e_t² x_t x_tᵀ + Σ_l w_l Σ_t e_t e_{t−l} (x_t x_{t−l}ᵀ + x_{t−l} x_tᵀ) - const lag = Math.max(0, Math.min(options.hacLag ?? neweyWestLag(n), n - 1)); + const lag = Math.max(0, Math.min(options.hacLag ?? defaultHacLag(n), n - 1)); const meat = Array.from({ length: K }, () => new Array(K).fill(0)); for (let l = 0; l <= lag; l++) { const w = l === 0 ? 1 : 1 - l / (lag + 1); diff --git a/src/lib/validations/activity-calendar.ts b/src/lib/validations/activity-calendar.ts index 75ba90c65..0aa7d8155 100644 --- a/src/lib/validations/activity-calendar.ts +++ b/src/lib/validations/activity-calendar.ts @@ -21,9 +21,18 @@ export type ActivityCalendarQuery = z.infer; const dateKey = z.iso.date(); const dayLevelEnum = z - .enum(["none", "unrated", "low", "below", "usual", "above", "high"]) + .enum([ + "none", + "unrated", + "in_progress", + "low", + "below", + "usual", + "above", + "high", + ]) .describe( - "Where the day sits in the person's own spread over the 84 days before the period: `low` below their 10th percentile, `below` below the 25th, `above` above the 75th, `high` above the 90th, `usual` between. `none` = no value that day; `unrated` = fewer than 14 days of history to compare with.", + "Where the day sits in the person's own spread over the 84 days before the period: `low` below their 10th percentile, `below` below the 25th, `above` above the 75th, `high` above the 90th, `usual` between. `none` = no value that day; `unrated` = fewer than 14 days of history to compare with; `in_progress` = today, which has not ended and is never rated.", ); const baselineStatsSchema = z @@ -50,6 +59,16 @@ const periodComparisonSchema = z "The period so far: a total (steps, active minutes, workouts) or a mean (load).", ), daysWithData: z.number().int(), + completedDays: z + .number() + .int() + .describe("Days of the period that have ended (today excluded)."), + completedValue: z + .number() + .nullable() + .describe( + "`value` over the completed days only. This, not `value`, is what `usualToDate` is compared with, so a running period is never judged on today's partial day.", + ), usual: z .number() .nullable() @@ -60,7 +79,7 @@ const periodComparisonSchema = z .number() .nullable() .describe( - "The usual figure for the days elapsed so far; equals `usual` once the period is over.", + "The usual figure for the completed days (today excluded); equals `usual` once the period is over, null before the period's first day has ended.", ), referencePeriods: z.number().int(), }) diff --git a/src/lib/validations/medication-efficacy.ts b/src/lib/validations/medication-efficacy.ts index 743e3b42a..ba6b075a1 100644 --- a/src/lib/validations/medication-efficacy.ts +++ b/src/lib/validations/medication-efficacy.ts @@ -137,7 +137,17 @@ const segmentedSchema = z df: z.number().int(), rSquared: z.number(), residualAutocorrelation: z.number(), - hacLag: z.number().int(), + hacLag: z + .number() + .int() + .describe( + "Newey–West lag applied: the automatic rule, at least 7 (a week of daily carry-over).", + ), + autocorrelationCaveat: z + .boolean() + .describe( + "True when the lag-1 residual autocorrelation exceeds 0.5: the carry-over is strong enough that the intervals may still be too narrow, and the read-out says so.", + ), }) .nullable(), confounders: z.array( diff --git a/src/lib/validations/record-import.ts b/src/lib/validations/record-import.ts index c6c572d1f..b65a5e2fd 100644 --- a/src/lib/validations/record-import.ts +++ b/src/lib/validations/record-import.ts @@ -8,6 +8,10 @@ * order and the zone a time without an offset is read in. The client works * these out with the person on the mapping screen; the server never guesses * a column. + * + * Both query schemas are strict: a parameter they do not know is a 422, not + * ignored. A misspelt `dryrun=1` or `dry_run=1` would otherwise fall back to + * the default `dryRun=0` and write the file the caller only meant to check. */ import { z } from "zod/v4"; @@ -60,6 +64,14 @@ const formatFields = { ), }; +/** + * Query of `POST /api/import/csv` (the measurement CSV). Strict for the same + * reason as the two record imports: a misspelt flag must never write. + */ +export const csvImportQuerySchema = z + .object({ dryRun: formatFields.dryRun }) + .strict(); + function distinctColumns( value: Record, keys: readonly string[], @@ -103,6 +115,7 @@ export const labsCsvQuerySchema = z panel: column.optional(), note: column.optional().describe("Column of a note; stored encrypted."), }) + .strict() .refine((v) => distinctColumns(v, LAB_COLUMN_KEYS), { message: "Each column can feed one field", path: ["analyte"], @@ -144,6 +157,7 @@ export const medicationsCsvQuerySchema = z "`1` turns reminders on for imported medications that have times. Off by default, so an import never starts reminding on its own.", ), }) + .strict() .refine((v) => distinctColumns(v, MEDICATION_COLUMN_KEYS), { message: "Each column can feed one field", path: ["name"], diff --git a/tests/integration/record-csv-import.test.ts b/tests/integration/record-csv-import.test.ts index 665fc6da6..7e28273ab 100644 --- a/tests/integration/record-csv-import.test.ts +++ b/tests/integration/record-csv-import.test.ts @@ -176,6 +176,124 @@ describe("lab CSV import", () => { }); }); +describe("lab CSV import in a zone of the file's own", () => { + it("cuts the duplicate day in the zone the file is read in", async () => { + // Read in New York, these are draws on two days (1 and 2 March). In the + // account's Berlin they both fall on 2 March, which used to make the + // second a duplicate of the first. + const csv = [ + "Analyt;Wert;Einheit;Datum", + "Zone marker;7;mg/dL;2025-03-01T22:30", + "Zone marker;7;mg/dL;2025-03-02T02:00", + ].join("\n"); + const result = await importLabsCsv({ + userId: USER, + tz: TZ, + stream: textToStream(csv), + maxBytes: 1_000_000, + query: labsCsvQuerySchema.parse({ + analyte: "0", + value: "1", + unit: "2", + date: "3", + timezone: "America/New_York", + }), + }); + expect(result).toMatchObject({ inserted: 2, duplicates: 0 }); + }); +}); + +describe("lab CSV import at the row cap", () => { + const BIG_USER = "csv-import-big-user"; + // 20 000 rows: 40 markers, 500 draw days each, one reading per day. + const lines = ["Analyt;Wert;Einheit;Datum"]; + for (let i = 0; i < 20_000; i++) { + const marker = `Marker ${i % 40}`; + const day = new Date(Date.UTC(2020, 0, 1) + Math.floor(i / 40) * 86_400_000) + .toISOString() + .slice(0, 10); + lines.push(`${marker};${(i % 97) + 1};U/l;${day}`); + } + const BIG = lines.join("\n"); + const query = () => + labsCsvQuerySchema.parse({ + analyte: "0", + value: "1", + unit: "2", + date: "3", + }); + + /** The file, failing like a dropped connection after `bytes`. */ + function cutAt(bytes: number): ReadableStream { + const all = new TextEncoder().encode(BIG); + let offset = 0; + return new ReadableStream({ + pull(controller) { + if (offset >= bytes) { + controller.error(new Error("connection reset")); + return; + } + controller.enqueue(all.slice(offset, Math.min(offset + 64_000, bytes))); + offset += 64_000; + }, + }); + } + + beforeAll(async () => { + await prisma.user.create({ + data: { id: BIG_USER, username: BIG_USER, timezone: TZ }, + }); + }); + + it("an import cut off part way leaves whole chunks, and a retry writes the rest once", async () => { + let written = 0; + await expect( + importLabsCsv({ + userId: BIG_USER, + tz: TZ, + stream: cutAt(Math.floor(BIG.length / 3)), + maxBytes: 16 * 1024 * 1024, + query: query(), + onWritten: (n) => { + written += n; + }, + }), + ).rejects.toThrow("connection reset"); + const afterCut = await prisma.labResult.count({ + where: { userId: BIG_USER }, + }); + expect(afterCut).toBe(written); + expect(afterCut).toBeGreaterThan(0); + // Whole chunks only: nothing half-written. + expect(afterCut % 200).toBe(0); + + const started = Date.now(); + const result = await importLabsCsv({ + userId: BIG_USER, + tz: TZ, + stream: textToStream(BIG), + maxBytes: 16 * 1024 * 1024, + query: query(), + }); + const elapsed = Date.now() - started; + expect(result).toMatchObject({ + rows: 20_000, + inserted: 20_000 - afterCut, + duplicates: afterCut, + failed: 0, + }); + expect(await prisma.labResult.count({ where: { userId: BIG_USER } })).toBe( + 20_000, + ); + expect(await prisma.biomarker.count({ where: { userId: BIG_USER } })).toBe( + 40, + ); + // Generous: the chunked write takes a few seconds here; the bound only + // catches a return to one statement per row. + expect(elapsed).toBeLessThan(120_000); + }, 300_000); +}); + describe("medication CSV import", () => { const MEDS = [ "name,dose,times,as needed,start,end",