mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
Compare commits
25
Commits
be7a65bdb9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0eadcdc326 | ||
|
|
5e1c39438f | ||
|
|
ce32b0ef89 | ||
|
|
73b9544f83 | ||
|
|
5793db9f2a | ||
|
|
cef48d20f0 | ||
|
|
a91689a0be | ||
|
|
bee4ac4f73 | ||
|
|
8eb2fb773f | ||
|
|
84dace4f89 | ||
|
|
879c92f6ee | ||
|
|
4921188f22 | ||
|
|
2adea7052b | ||
|
|
31997e3232 | ||
|
|
aea9796170 | ||
|
|
78611277fe | ||
|
|
46a1a5d320 | ||
|
|
01884e91fa | ||
|
|
126bab8c00 | ||
|
|
c7b3f6e4d0 | ||
|
|
24dd8a28e3 | ||
|
|
6c5146942b | ||
|
|
2aa98bb6c2 | ||
|
|
a605a7223f | ||
|
|
74e691cecd |
No files matched your search
@@ -60,7 +60,7 @@ jobs:
|
||||
# minutes and can produce nothing actionable, because nobody edits
|
||||
# it and the fix for anything found there is `pnpm prisma generate`.
|
||||
- name: Initialise CodeQL
|
||||
uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
uses: github/codeql-action/init@33d7b85da1a04171e211befe2c739303b9a4c066 # v4.39.3
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
build-mode: none
|
||||
@@ -75,6 +75,6 @@ jobs:
|
||||
- public
|
||||
|
||||
- name: Analyse
|
||||
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
uses: github/codeql-action/analyze@33d7b85da1a04171e211befe2c739303b9a4c066 # v4.39.3
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
publish_results: true
|
||||
|
||||
- name: Upload results
|
||||
uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
uses: github/codeql-action/upload-sarif@33d7b85da1a04171e211befe2c739303b9a4c066 # v4.39.3
|
||||
with:
|
||||
sarif_file: scorecard-results.sarif
|
||||
category: scorecard
|
||||
@@ -88,7 +88,7 @@ jobs:
|
||||
skip-dirs: src/generated
|
||||
|
||||
- name: Upload results
|
||||
uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
uses: github/codeql-action/upload-sarif@33d7b85da1a04171e211befe2c739303b9a4c066 # v4.39.3
|
||||
with:
|
||||
sarif_file: trivy-filesystem.sarif
|
||||
category: trivy-filesystem
|
||||
@@ -152,7 +152,7 @@ jobs:
|
||||
exit-code: "0"
|
||||
|
||||
- name: Upload results
|
||||
uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
uses: github/codeql-action/upload-sarif@33d7b85da1a04171e211befe2c739303b9a4c066 # v4.39.3
|
||||
with:
|
||||
sarif_file: trivy-published-image.sarif
|
||||
category: trivy-published-image
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.47.1] — 2026-11-07
|
||||
|
||||
### Added
|
||||
|
||||
- `GET /api/integrations/healthkit` lists the HealthKit sample types the
|
||||
@@ -65,6 +67,17 @@
|
||||
`0494_cycle_calendar_feed`. Self-hosters: the feed link sits in the URL
|
||||
path, so keep `/api/cycle/feed/` out of your proxy's access log;
|
||||
`docs/self-hosting/reverse-proxy.md` shows how. (#1350)
|
||||
- **Your own symptoms as lines on the timeline.** The timeline's values
|
||||
menu has a Symptoms group with every symptom you have logged at least
|
||||
once, hidden ones included. A line sits on a fixed 0 to 10 scale and
|
||||
follows the zoom: each point is the mean of the bucket's days, where a
|
||||
day counts with the highest intensity logged on it, and it says how many
|
||||
days that was. A day without an entry is left out rather than counted as
|
||||
zero. Symptoms follow the illness module and its sharing domain, so the
|
||||
lines are only offered where the symptom log itself is.
|
||||
`GET /api/timeline` takes `symptom:<id>` in `values` (up to 24) and answers
|
||||
with `symptomSeries` and `availableSymptoms`; with `zoom=all` the window
|
||||
reaches back to the first entry of a chosen symptom. No migration. (#981)
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -133,6 +146,11 @@
|
||||
to half a minute before it reports anything. Attaching a new file in the
|
||||
Coach does the same. Reaching the hourly upload limit now says that
|
||||
instead of "a few minutes".
|
||||
- **Counts in Russian, French and Portuguese.** Russian uses its singular
|
||||
form for 21, 31 and so on, and French and Brazilian Portuguese use it for
|
||||
zero, but those strings had the digit 1 written in, so "21 days" read as
|
||||
"1 day". They now carry the actual count, and a guard keeps new ones from
|
||||
writing the digit.
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -141,6 +159,10 @@
|
||||
|
||||
### Security
|
||||
|
||||
- Next.js moves to 16.4.5, which fixes seven moderate advisories. Most
|
||||
concern `use cache` and Cache Components, which HealthLog does not use;
|
||||
one concerns self-hosted apps that pass a fetched response on to the
|
||||
client, and one the Edge runtime's internal headers.
|
||||
- `@hono/node-server` moves to the 2.x line, which fixes a second decode of
|
||||
the request path in `serveStatic`. It arrives under the MCP SDK; the app
|
||||
uses the SDK's web-standard transport and never loads it, but the copy
|
||||
|
||||
+155
-25
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.47.0
|
||||
version: 1.47.1
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
@@ -958,7 +958,8 @@ paths:
|
||||
- Cycle
|
||||
summary: Replace the calendar subscription's link (#1350)
|
||||
description: "One transaction: the live link is revoked and a new one minted with the same options. Returns the new URL
|
||||
EXACTLY ONCE. Calendars using the old link stop updating."
|
||||
EXACTLY ONCE. Calendars using the old link stop updating. Of two rotations racing, the one that loses answers
|
||||
409 `cycle.feed.exists` and the winner's link stands."
|
||||
responses:
|
||||
"201": *a13
|
||||
"401": *a14
|
||||
@@ -969,6 +970,12 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"409":
|
||||
description: Another rotation replaced the link at the same moment (`cycle.feed.exists`).
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a4
|
||||
"429": *a5
|
||||
/api/measurement-categories:
|
||||
@@ -4515,16 +4522,20 @@ paths:
|
||||
- in: query
|
||||
name: start
|
||||
schema:
|
||||
description: "`now` answers an empty page (every domain empty, `hasMore: false`) whose `cursor` sits at `serverNow` in
|
||||
every domain, in the same opaque format as any other page. Paging forward from it yields only what changes
|
||||
from then on, including `measurementMirror`, without walking the history first. Mutually exclusive with
|
||||
`cursor` (422). A server without it ignores the parameter and starts from the oldest row."
|
||||
description: "`now` answers an empty page (every domain empty, `hasMore: false`) whose `cursor` sits one minute before
|
||||
`serverNow` in every domain, in the same opaque format as any other page. Paging forward from it yields
|
||||
what changes from then on, plus anything written in that last minute (a write stamped before `serverNow`
|
||||
can commit after it, and is not skipped; upserts are idempotent, so a row seen twice is harmless),
|
||||
including `measurementMirror`, without walking the history first. Mutually exclusive with `cursor` (422).
|
||||
A server without it ignores the parameter and starts from the oldest row."
|
||||
type: string
|
||||
const: now
|
||||
description: "`now` answers an empty page (every domain empty, `hasMore: false`) whose `cursor` sits at `serverNow` in
|
||||
every domain, in the same opaque format as any other page. Paging forward from it yields only what changes
|
||||
from then on, including `measurementMirror`, without walking the history first. Mutually exclusive with
|
||||
`cursor` (422). A server without it ignores the parameter and starts from the oldest row."
|
||||
description: "`now` answers an empty page (every domain empty, `hasMore: false`) whose `cursor` sits one minute before
|
||||
`serverNow` in every domain, in the same opaque format as any other page. Paging forward from it yields what
|
||||
changes from then on, plus anything written in that last minute (a write stamped before `serverNow` can
|
||||
commit after it, and is not skipped; upserts are idempotent, so a row seen twice is harmless), including
|
||||
`measurementMirror`, without walking the history first. Mutually exclusive with `cursor` (422). A server
|
||||
without it ignores the parameter and starts from the oldest row."
|
||||
- in: query
|
||||
name: limit
|
||||
schema:
|
||||
@@ -26948,9 +26959,12 @@ paths:
|
||||
- Records
|
||||
summary: The record over the years
|
||||
description: Lanes of conditions, allergies, medications, vaccinations, visits, lab days, documents and life events, the
|
||||
standing items without a start, and monthly (or finer, by zoom) value series. Empty lanes are not sent; lanes of
|
||||
a switched-off module never are. The `life` lane (life events and travel periods) is the owner's only and is
|
||||
never sent to a delegate. Requires the opt-in `timeline` module.
|
||||
standing items without a start, and monthly (or finer, by zoom) value series, among them the lab markers
|
||||
(`labSeries`, single readings) and the person-defined symptoms (`symptomSeries`, the day's highest intensity
|
||||
averaged over logged days, on a fixed 0 to 10 scale; read only with the illness module on and the illness
|
||||
section visible) named in `values`. Empty lanes are not sent; lanes of a switched-off module never are. The
|
||||
`life` lane (life events and travel periods) is the owner's only and is never sent to a delegate. Requires the
|
||||
opt-in `timeline` module.
|
||||
parameters:
|
||||
- in: query
|
||||
name: zoom
|
||||
@@ -26979,19 +26993,22 @@ paths:
|
||||
- in: query
|
||||
name: values
|
||||
schema:
|
||||
description: "Comma-separated value series to include, as `MeasurementType` names, `MOOD`, or a lab marker as
|
||||
`lab:<biomarkerId>` (see `availableLabs`). A name given twice counts once, so the list is bounded by the
|
||||
names that exist; an unknown measurement name or a malformed marker key answers 422. At most 24 lab
|
||||
markers are drawn (ids, unlike names, are not a closed set): the first 24 are kept and any further ones
|
||||
left out without an error. A lab marker that is not in the record (deleted, or another record's) is
|
||||
likewise left out of `labSeries`, so a stored choice never breaks the page."
|
||||
description: "Comma-separated value series to include, as `MeasurementType` names, `MOOD`, a lab marker as
|
||||
`lab:<biomarkerId>` (see `availableLabs`), or a person-defined symptom as `symptom:<definitionId>` (see
|
||||
`availableSymptoms`). A name given twice counts once, so the list is bounded by the names that exist; an
|
||||
unknown measurement name or a malformed marker or symptom key answers 422. At most 24 lab markers and 24
|
||||
symptoms are drawn (ids, unlike names, are not a closed set): the first 24 of each are kept and any
|
||||
further ones left out without an error. A lab marker or symptom that is not in the record (deleted, or
|
||||
another record's) is likewise left out of `labSeries` / `symptomSeries`, so a stored choice never breaks
|
||||
the page."
|
||||
type: string
|
||||
description: "Comma-separated value series to include, as `MeasurementType` names, `MOOD`, or a lab marker as
|
||||
`lab:<biomarkerId>` (see `availableLabs`). A name given twice counts once, so the list is bounded by the
|
||||
names that exist; an unknown measurement name or a malformed marker key answers 422. At most 24 lab markers
|
||||
are drawn (ids, unlike names, are not a closed set): the first 24 are kept and any further ones left out
|
||||
without an error. A lab marker that is not in the record (deleted, or another record's) is likewise left out
|
||||
of `labSeries`, so a stored choice never breaks the page."
|
||||
description: "Comma-separated value series to include, as `MeasurementType` names, `MOOD`, a lab marker as
|
||||
`lab:<biomarkerId>` (see `availableLabs`), or a person-defined symptom as `symptom:<definitionId>` (see
|
||||
`availableSymptoms`). A name given twice counts once, so the list is bounded by the names that exist; an
|
||||
unknown measurement name or a malformed marker or symptom key answers 422. At most 24 lab markers and 24
|
||||
symptoms are drawn (ids, unlike names, are not a closed set): the first 24 of each are kept and any further
|
||||
ones left out without an error. A lab marker or symptom that is not in the record (deleted, or another
|
||||
record's) is likewise left out of `labSeries` / `symptomSeries`, so a stored choice never breaks the page."
|
||||
responses:
|
||||
"200":
|
||||
description: The timeline for the requested zoom and window.
|
||||
@@ -94385,6 +94402,19 @@ components:
|
||||
$ref: "#/components/schemas/TimelineLabMarker"
|
||||
description: Every lab marker this record has a numeric reading for, whatever the window, when the caller may see labs.
|
||||
Each may be named in `values` by its `key`.
|
||||
symptomSeries:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/TimelineSymptomSeries"
|
||||
description: The person-defined symptoms named in `values`, in the order named, each averaged per `bucket` over the days
|
||||
it was logged. A symptom named but not found in the record is left out.
|
||||
availableSymptoms:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/TimelineSymptomOption"
|
||||
description: Every person-defined symptom this record has logged at least once, hidden ones included (their history is
|
||||
kept), in the record's own order, when the caller may see illness data. Each may be named in `values` by its
|
||||
`key`.
|
||||
notable:
|
||||
type: array
|
||||
items:
|
||||
@@ -94414,6 +94444,8 @@ components:
|
||||
- availableSeries
|
||||
- labSeries
|
||||
- availableLabs
|
||||
- symptomSeries
|
||||
- availableSymptoms
|
||||
- notable
|
||||
additionalProperties: false
|
||||
TimelineItem:
|
||||
@@ -94590,6 +94622,104 @@ components:
|
||||
- panel
|
||||
- unit
|
||||
additionalProperties: false
|
||||
TimelineSymptomSeries:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
description: "`symptom:<definitionId>`, as named in `values`."
|
||||
definitionId:
|
||||
type: string
|
||||
label:
|
||||
description: The person's own name for the symptom. Null when it cannot be read; the client words a stand-in.
|
||||
type:
|
||||
- string
|
||||
- "null"
|
||||
icon:
|
||||
description: The symptom's icon name; null for the default glyph.
|
||||
type:
|
||||
- string
|
||||
- "null"
|
||||
isActive:
|
||||
type: boolean
|
||||
description: "False for a hidden symptom: no longer offered for entry, its history kept and drawn."
|
||||
scale:
|
||||
type: object
|
||||
properties:
|
||||
min:
|
||||
type: number
|
||||
const: 0
|
||||
max:
|
||||
type: number
|
||||
const: 10
|
||||
required:
|
||||
- min
|
||||
- max
|
||||
additionalProperties: false
|
||||
description: The fixed scale the line is drawn on, the intensity scale, whatever the window holds.
|
||||
points:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/TimelineSymptomPoint"
|
||||
description: Buckets of the response's `bucket` with at least one logged day, oldest first. A bucket without one is
|
||||
absent, never zero and never interpolated.
|
||||
required:
|
||||
- key
|
||||
- definitionId
|
||||
- label
|
||||
- icon
|
||||
- isActive
|
||||
- scale
|
||||
- points
|
||||
additionalProperties: false
|
||||
TimelineSymptomPoint:
|
||||
type: object
|
||||
properties:
|
||||
t:
|
||||
type: string
|
||||
description: The bucket's first day.
|
||||
mean:
|
||||
type: number
|
||||
minimum: 0
|
||||
maximum: 10
|
||||
description: The mean of the bucket's day values over the days the symptom was logged. A day's value is the highest
|
||||
intensity (0 to 10) logged on it; a day without an entry is not a zero and does not count.
|
||||
count:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 9007199254740991
|
||||
description: Days the symptom was logged in the bucket, the days behind the mean. Clients draw a point from fewer than
|
||||
three as thin.
|
||||
required:
|
||||
- t
|
||||
- mean
|
||||
- count
|
||||
additionalProperties: false
|
||||
TimelineSymptomOption:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
description: "`symptom:<definitionId>`, to name in `values`."
|
||||
definitionId:
|
||||
type: string
|
||||
label:
|
||||
type:
|
||||
- string
|
||||
- "null"
|
||||
icon:
|
||||
type:
|
||||
- string
|
||||
- "null"
|
||||
isActive:
|
||||
type: boolean
|
||||
required:
|
||||
- key
|
||||
- definitionId
|
||||
- label
|
||||
- icon
|
||||
- isActive
|
||||
additionalProperties: false
|
||||
TimelineReadinessEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -287,7 +287,6 @@ async function chooseStandardPdfScope(page: Page): Promise<void> {
|
||||
interface GeneratedReport {
|
||||
status: number;
|
||||
contentType: string;
|
||||
contentLength: string | undefined;
|
||||
bytes: Buffer;
|
||||
text: string;
|
||||
filename: string;
|
||||
@@ -348,7 +347,6 @@ async function generateReport(page: Page): Promise<GeneratedReport> {
|
||||
return {
|
||||
status: response.status(),
|
||||
contentType: response.headers()["content-type"] ?? "",
|
||||
contentLength: response.headers()["content-length"],
|
||||
bytes,
|
||||
text,
|
||||
filename: download.suggestedFilename(),
|
||||
@@ -370,9 +368,13 @@ function expectRealPdf(report: GeneratedReport): void {
|
||||
expect(report.contentType).toContain("application/pdf");
|
||||
expect(report.bytes.subarray(0, 5).toString("latin1")).toBe("%PDF-");
|
||||
expect(report.bytes.byteLength).toBeGreaterThan(MIN_PDF_BYTES);
|
||||
// The route declares the length it sent; a disagreement means the body was
|
||||
// truncated on the way out, which a magic-number check alone would miss.
|
||||
expect(report.contentLength).toBe(String(report.bytes.byteLength));
|
||||
// A body truncated on the way out keeps its header but loses the trailer,
|
||||
// which a magic-number check alone would miss. The route's Content-Length
|
||||
// cannot say this: Next drops that header from every route response since
|
||||
// 16.4.5, so the bytes have to.
|
||||
expect(report.bytes.subarray(-1024).toString("latin1").trimEnd()).toMatch(
|
||||
/%%EOF$/,
|
||||
);
|
||||
// The name the CLIENT anchor invents, not the route's `Content-Disposition`
|
||||
// — the two differ by a word today. This says the panel saved a PDF, and it
|
||||
// is not contract coverage of the header.
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
* 7. A lab marker (#1299) is picked from the values menu, drawn as its
|
||||
* single readings with the range behind them, read on its own, kept
|
||||
* across a reload, and listed on its lab day on a phone.
|
||||
* 8. A symptom (#981) is picked from the values menu, drawn as a value
|
||||
* line on the fixed 0 to 10 scale, read with the days behind a point,
|
||||
* kept across a reload, and named in the phone chronicle.
|
||||
*
|
||||
* The timeline's server routes are mocked with the contract's shapes
|
||||
* (`utils/mock-timeline.ts`); the module switch is the real one. Every
|
||||
@@ -919,6 +922,86 @@ test("a lab marker is picked, drawn as its readings, read on its own and kept",
|
||||
await expect(labs).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("a symptom is picked, drawn as a 0 to 10 line, read with its days and kept", async ({
|
||||
page,
|
||||
}) => {
|
||||
await setTimelineModuleOn(page);
|
||||
const log = await mockTimeline(page, "full", TODAY, { symptoms: true });
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.goto("/timeline?zoom=all");
|
||||
await expect(page.locator('[data-slot="timeline-chart"]')).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
await page
|
||||
.locator(
|
||||
'[data-slot="timeline-desktop"] [data-slot="timeline-values-trigger"]',
|
||||
)
|
||||
.click();
|
||||
const menu = page.locator('[data-slot="timeline-values-menu"]');
|
||||
await expect(
|
||||
menu.locator('[data-slot="timeline-values-symptoms"]'),
|
||||
).toBeVisible();
|
||||
// A symptom hidden from entry is still offered, and says so.
|
||||
await expect(
|
||||
menu.locator(
|
||||
'[data-slot="timeline-values-option"][data-key="symptom:sd-nausea"]',
|
||||
),
|
||||
).toBeVisible();
|
||||
await menu
|
||||
.locator(
|
||||
'[data-slot="timeline-values-option"][data-key="symptom:sd-headache"]',
|
||||
)
|
||||
.click();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(menu).toBeHidden();
|
||||
|
||||
const row = page.locator('g[data-series="symptom:sd-headache"]');
|
||||
const points = row.locator('[data-slot="timeline-series-point"]');
|
||||
await expect(points).toHaveCount(3);
|
||||
// The quarter resting on two days is hollow.
|
||||
await expect(points.nth(1)).toHaveAttribute("data-thin", "true");
|
||||
await expect(
|
||||
page.locator('[data-slot="timeline-legend-symptom"]'),
|
||||
).toBeVisible();
|
||||
expect(log.timelineQueries.at(-1)?.get("values")?.split(",")).toContain(
|
||||
"symptom:sd-headache",
|
||||
);
|
||||
|
||||
const target = page.locator(
|
||||
'[data-slot="timeline-point-target"][data-series="symptom:sd-headache"][data-t="2024-01-01"]',
|
||||
);
|
||||
const tip = page.locator('[data-slot="timeline-point-tip"]');
|
||||
await target.focus();
|
||||
await expect(tip).toHaveAttribute("data-t", "2024-01-01");
|
||||
// Out of 10 in the account's own decimal mark, with the days behind it.
|
||||
await expect(tip).toContainText(/6[.,]5\/10/);
|
||||
await expect(tip).toContainText(/\b9\b/);
|
||||
|
||||
const axe = await new AxeBuilder({ page })
|
||||
.include('[data-slot="timeline-page"]')
|
||||
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
|
||||
.analyze();
|
||||
expect(axe.violations.map((v) => `${v.id}: ${v.help}`)).toEqual([]);
|
||||
|
||||
// The choice is kept: a reload asks for the symptom again.
|
||||
await page.reload();
|
||||
await expect(points).toHaveCount(3, { timeout: 20_000 });
|
||||
|
||||
// On a phone: its means in the chronicle, no sideways scroll.
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const means = page.locator(
|
||||
'[data-slot="timeline-mobile"] [data-slot="timeline-mean-part"][data-series="symptom:sd-headache"]',
|
||||
);
|
||||
await expect(means.first()).toBeVisible();
|
||||
const overflow = await page.evaluate(
|
||||
() =>
|
||||
document.documentElement.scrollWidth -
|
||||
document.documentElement.clientWidth,
|
||||
);
|
||||
expect(overflow).toBeLessThanOrEqual(0);
|
||||
});
|
||||
|
||||
test("the timeline page ends where its content does, at every desktop width", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -514,6 +514,8 @@ export function fullTimeline(today: string) {
|
||||
availableSeries: FULL_AVAILABLE,
|
||||
labSeries: [],
|
||||
availableLabs: [],
|
||||
symptomSeries: [],
|
||||
availableSymptoms: [],
|
||||
notable: [{ date: "2026-01-04", kind: "extremeHigh" }],
|
||||
};
|
||||
}
|
||||
@@ -610,6 +612,50 @@ function labsLane(keys: readonly string[]) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Two of the person's own symptoms (#981): a headache logged over several
|
||||
* quarters, one quarter resting on two days, and a nausea hidden from entry
|
||||
* since, logged once. Their points are bucket means of day peaks on the
|
||||
* fixed 0 to 10 scale; only quarters are drawn here, the bucket an `all`
|
||||
* view of the full record uses.
|
||||
*/
|
||||
export const SYMPTOM_OPTIONS = [
|
||||
{
|
||||
key: "symptom:sd-headache",
|
||||
definitionId: "sd-headache",
|
||||
label: "Kopfschmerz",
|
||||
icon: "brain",
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
key: "symptom:sd-nausea",
|
||||
definitionId: "sd-nausea",
|
||||
label: "Übelkeit",
|
||||
icon: null,
|
||||
isActive: false,
|
||||
},
|
||||
];
|
||||
|
||||
const SYMPTOM_QUARTERS: Record<
|
||||
string,
|
||||
Array<{ t: string; mean: number; count: number }>
|
||||
> = {
|
||||
"symptom:sd-headache": [
|
||||
{ t: "2024-01-01", mean: 6.5, count: 9 },
|
||||
{ t: "2024-04-01", mean: 4, count: 2 },
|
||||
{ t: "2025-01-01", mean: 3.25, count: 5 },
|
||||
],
|
||||
"symptom:sd-nausea": [{ t: "2023-07-01", mean: 2, count: 1 }],
|
||||
};
|
||||
|
||||
function symptomLines(keys: readonly string[], bucket: Bucket) {
|
||||
return SYMPTOM_OPTIONS.filter((o) => keys.includes(o.key)).map((o) => ({
|
||||
...o,
|
||||
scale: { min: 0, max: 10 },
|
||||
points: bucket === "quarter" ? SYMPTOM_QUARTERS[o.key] : [],
|
||||
}));
|
||||
}
|
||||
|
||||
export function readyTimeline(today: string) {
|
||||
return {
|
||||
zoom: "all",
|
||||
@@ -733,6 +779,8 @@ export function readyTimeline(today: string) {
|
||||
availableSeries: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "WEIGHT"],
|
||||
labSeries: [],
|
||||
availableLabs: [],
|
||||
symptomSeries: [],
|
||||
availableSymptoms: [],
|
||||
notable: [],
|
||||
};
|
||||
}
|
||||
@@ -935,6 +983,8 @@ export async function mockTimeline(
|
||||
dataFrom?: string;
|
||||
/** The record has lab markers to offer and draw (#1299). */
|
||||
labs?: boolean;
|
||||
/** The record has logged symptoms to offer and draw (#981). */
|
||||
symptoms?: boolean;
|
||||
} = {},
|
||||
): Promise<TimelineMockLog> {
|
||||
const log: TimelineMockLog = { timelineQueries: [], lifeEventPosts: [] };
|
||||
@@ -950,7 +1000,11 @@ export async function mockTimeline(
|
||||
.filter(Boolean);
|
||||
// A lab marker is a line of its own (`labSeries`), never a mean.
|
||||
const labKeys = named.filter((key) => key.startsWith("lab:"));
|
||||
const values = named.filter((key) => !key.startsWith("lab:"));
|
||||
// A symptom is a line of its own too (`symptomSeries`), on 0 to 10.
|
||||
const symptomKeys = named.filter((key) => key.startsWith("symptom:"));
|
||||
const values = named.filter(
|
||||
(key) => !key.startsWith("lab:") && !key.startsWith("symptom:"),
|
||||
);
|
||||
const labs =
|
||||
state === "full" && options.labs
|
||||
? {
|
||||
@@ -966,6 +1020,13 @@ export async function mockTimeline(
|
||||
const from = url.searchParams.get("from");
|
||||
const to = url.searchParams.get("to");
|
||||
const bucket = bucketFor(zoom, from, to);
|
||||
const symptoms =
|
||||
state === "full" && options.symptoms
|
||||
? {
|
||||
symptomSeries: symptomLines(symptomKeys, bucket),
|
||||
availableSymptoms: SYMPTOM_OPTIONS,
|
||||
}
|
||||
: {};
|
||||
const dataFrom =
|
||||
options.dataFrom ?? (state === "full" ? "2019-01-01" : "2024-05-01");
|
||||
if (options.onlyOld && (zoom === "quarter" || zoom === "year")) {
|
||||
@@ -988,6 +1049,7 @@ export async function mockTimeline(
|
||||
return json(route, 200, {
|
||||
...body,
|
||||
...labs,
|
||||
...symptoms,
|
||||
zoom,
|
||||
bucket,
|
||||
...(chosen
|
||||
|
||||
+11
-1
@@ -13944,7 +13944,17 @@
|
||||
},
|
||||
"legendLabValue": "Laborwert, wie gemessen",
|
||||
"legendLabOutside": "Außerhalb des Referenzbereichs",
|
||||
"legendLabRange": "Referenzbereich"
|
||||
"legendLabRange": "Referenzbereich",
|
||||
"legendSymptom": "Symptomlinie: höchste Stärke jedes erfassten Tages (0 bis 10), gemittelt über die erfassten Tage",
|
||||
"symptoms": {
|
||||
"menuTitle": "Symptome",
|
||||
"full": "Bis zu {max} Symptome gleichzeitig. Entferne eines, um ein anderes hinzuzufügen.",
|
||||
"hidden": "ausgeblendet",
|
||||
"unnamed": "Symptom",
|
||||
"daysOne": "an 1 Tag",
|
||||
"daysFew": "Schnitt aus {count} Tagen",
|
||||
"daysOther": "Schnitt aus {count} Tagen"
|
||||
}
|
||||
},
|
||||
"lifeEvents": {
|
||||
"title": "Lebensereignis",
|
||||
|
||||
+11
-1
@@ -13944,7 +13944,17 @@
|
||||
},
|
||||
"legendLabValue": "Lab value, as measured",
|
||||
"legendLabOutside": "Outside the reference range",
|
||||
"legendLabRange": "Reference range"
|
||||
"legendLabRange": "Reference range",
|
||||
"legendSymptom": "Symptom line: each logged day's highest intensity (0 to 10), averaged over the logged days",
|
||||
"symptoms": {
|
||||
"menuTitle": "Symptoms",
|
||||
"full": "Up to {max} symptoms at once. Remove one to add another.",
|
||||
"hidden": "hidden",
|
||||
"unnamed": "Symptom",
|
||||
"daysOne": "on 1 day",
|
||||
"daysFew": "mean of {count} days",
|
||||
"daysOther": "mean of {count} days"
|
||||
}
|
||||
},
|
||||
"lifeEvents": {
|
||||
"title": "Life event",
|
||||
|
||||
+11
-1
@@ -13944,7 +13944,17 @@
|
||||
},
|
||||
"legendLabValue": "Valor de laboratorio, tal como se midió",
|
||||
"legendLabOutside": "Fuera del intervalo de referencia",
|
||||
"legendLabRange": "Intervalo de referencia"
|
||||
"legendLabRange": "Intervalo de referencia",
|
||||
"legendSymptom": "Línea de síntoma: la intensidad más alta de cada día registrado (0 a 10), promediada sobre los días registrados",
|
||||
"symptoms": {
|
||||
"menuTitle": "Síntomas",
|
||||
"full": "Hasta {max} síntomas a la vez. Quita uno para añadir otro.",
|
||||
"hidden": "oculto",
|
||||
"unnamed": "Síntoma",
|
||||
"daysOne": "en 1 día",
|
||||
"daysFew": "media de {count} días",
|
||||
"daysOther": "media de {count} días"
|
||||
}
|
||||
},
|
||||
"lifeEvents": {
|
||||
"title": "Acontecimiento vital",
|
||||
|
||||
+40
-30
@@ -739,10 +739,10 @@
|
||||
"emptyDescription": "Saisissez votre première mesure ou humeur. Les tuiles et graphiques apparaissent automatiquement dès qu’il y a des données.",
|
||||
"emptyAddMeasurement": "Saisir la première mesure",
|
||||
"staleHint": "Dernière valeur il y a {count} j",
|
||||
"staleHintWeeksOne": "Dernière valeur il y a 1 semaine",
|
||||
"staleHintWeeksOne": "Dernière valeur il y a {count} semaine",
|
||||
"staleHintWeeksOther": "Dernière valeur il y a {count} semaines",
|
||||
"staleHintWeeksFew": "Dernière valeur il y a {count} semaines",
|
||||
"staleHintMonthsOne": "Dernière valeur il y a 1 mois",
|
||||
"staleHintMonthsOne": "Dernière valeur il y a {count} mois",
|
||||
"staleHintMonthsOther": "Dernière valeur il y a {count} mois",
|
||||
"staleHintMonthsFew": "Dernière valeur il y a {count} mois",
|
||||
"recentWorkouts": {
|
||||
@@ -2287,7 +2287,7 @@
|
||||
"currentSince": "Plan actuel depuis {date}",
|
||||
"noTimes": "Aucune heure fixe",
|
||||
"manualChip": "Ajoutée manuellement",
|
||||
"showErasOne": "Afficher 1 période antérieure",
|
||||
"showErasOne": "Afficher {count} période antérieure",
|
||||
"showErasOther": "Afficher {count} périodes antérieures",
|
||||
"hideEras": "Masquer les périodes antérieures",
|
||||
"addEra": "Ajouter une période antérieure",
|
||||
@@ -2359,7 +2359,7 @@
|
||||
"tableColActions": "Actions",
|
||||
"tableSortBy": "Trier par {column}",
|
||||
"tableComplianceDays": "{days} j",
|
||||
"tableStockDoseOne": "1 dose",
|
||||
"tableStockDoseOne": "{count} dose",
|
||||
"tableStockDoses": "{count} doses",
|
||||
"tableStockRunway": "≈ {days} jours restants",
|
||||
"cardLowStockRunway": "Réserve : ≈ {days} jours",
|
||||
@@ -3238,7 +3238,7 @@
|
||||
"settingsClustersLabel": "Données vues par le Coach",
|
||||
"settingsClustersHint": "Choisissez les groupes de données que le Coach peut lire. Plus de données, des réponses plus riches et un prompt plus grand.",
|
||||
"settingsClusterNoData": "Pas encore de données",
|
||||
"sourcesMemberCountOne": "1 mesure",
|
||||
"sourcesMemberCountOne": "{count} mesure",
|
||||
"sourcesMemberCountFew": "{count} mesures",
|
||||
"sourcesMemberCountOther": "{count} mesures",
|
||||
"lookback": {
|
||||
@@ -3338,7 +3338,7 @@
|
||||
"indexing": "Indexation…",
|
||||
"indexingHint": "Un document est encore en cours d'indexation ; vous pourrez envoyer une fois prêt.",
|
||||
"fencedHint": "Cette conversation s'exécute sans accès à vos données de santé : le coach lit uniquement les documents joints.",
|
||||
"documentsAttachedOne": "1 document joint",
|
||||
"documentsAttachedOne": "{count} document joint",
|
||||
"documentsAttachedOther": "{count} documents joints",
|
||||
"railBadge": "Chat de document cloisonné",
|
||||
"uploadError": "Échec du téléversement. Veuillez réessayer.",
|
||||
@@ -3942,9 +3942,9 @@
|
||||
"learningBody": "Pour l’instant, cela fait {count} sur {days}. Avec un peu plus d’historique, le tableau se précise : continuez à enregistrer ou laissez un appareil connecté compléter.",
|
||||
"learningBodyEmpty": "Dès que quelques mesures arrivent (saisies par vous ou synchronisées depuis un appareil), votre temps dans la cible, votre moyenne et votre variabilité prennent forme ici.",
|
||||
"readingsCount": "{count} mesures",
|
||||
"readingsCountOne": "1 mesure",
|
||||
"readingsCountOne": "{count} mesure",
|
||||
"daysCount": "{days} jours",
|
||||
"daysCountOne": "1 jour",
|
||||
"daysCountOne": "{count} jour",
|
||||
"tir": {
|
||||
"title": "Temps dans la cible",
|
||||
"caption": "Part des mesures dans chaque plage ({low}–{high} {unit} est la cible).",
|
||||
@@ -3996,7 +3996,7 @@
|
||||
"bedtime": "Au coucher",
|
||||
"unspecified": "Non précisée",
|
||||
"readingsShort": "{count} mesures",
|
||||
"readingsShortOne": "1 mesure"
|
||||
"readingsShortOne": "{count} mesure"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4935,7 +4935,7 @@
|
||||
},
|
||||
"crossLink": {
|
||||
"title": "Enregistrements ECG",
|
||||
"recordingsOne": "1 enregistrement disponible.",
|
||||
"recordingsOne": "{count} enregistrement disponible.",
|
||||
"recordingsMany": "{count} enregistrements disponibles.",
|
||||
"latestResult": "Dernier résultat de l’appareil : {result}.",
|
||||
"cta": "Voir les enregistrements"
|
||||
@@ -4949,7 +4949,7 @@
|
||||
"caption": "Moyenné par tranches de 10 minutes.",
|
||||
"hourlyNote": "Moyenne horaire affichée pour cette journée. Les jours récents affichent le détail complet par tranches de 10 minutes.",
|
||||
"empty": "Pas encore assez de mesures pour cette journée pour en afficher le rythme.",
|
||||
"coverageOne": "Basé sur 1 mesure sur {hours}h.",
|
||||
"coverageOne": "Basé sur {count} mesure sur {hours}h.",
|
||||
"coverageMany": "Basé sur {count} mesures sur {hours}h.",
|
||||
"baseline": "Repos",
|
||||
"today": "Aujourd'hui",
|
||||
@@ -5442,7 +5442,7 @@
|
||||
},
|
||||
"trendHints": {
|
||||
"title": "Première tendance après 5 mesures",
|
||||
"remainingOne": "Encore 1 mesure avant la tendance.",
|
||||
"remainingOne": "Encore {count} mesure avant la tendance.",
|
||||
"remainingMany": "Encore {count} mesures avant la tendance."
|
||||
},
|
||||
"settings": {
|
||||
@@ -8015,7 +8015,7 @@
|
||||
"colRedeemedBy": "Utilisée par",
|
||||
"colActions": "Actions",
|
||||
"byCreator": "par {by}",
|
||||
"expiresInDaysOne": "dans 1 jour",
|
||||
"expiresInDaysOne": "dans {count} jour",
|
||||
"expiresInDaysOther": "dans {count} jours",
|
||||
"statusActive": "Active",
|
||||
"statusExpired": "Expirée",
|
||||
@@ -11035,7 +11035,7 @@
|
||||
},
|
||||
"insights": {
|
||||
"title": "Votre historique de maladies",
|
||||
"countOne": "Vous avez enregistré 1 épisode sur cette période.",
|
||||
"countOne": "Vous avez enregistré {count} épisode sur cette période.",
|
||||
"countOther": "Vous avez enregistré {count} épisodes sur cette période.",
|
||||
"typicalGap": "Écart de récupération typique : {days} jours entre le mieux-être et la stabilisation de vos valeurs.",
|
||||
"typicalGapWithDriver": "Écart de récupération typique : {days} jours. Vous vous sentez généralement mieux avant que {vital} ne se stabilise.",
|
||||
@@ -11048,7 +11048,7 @@
|
||||
"resolved": "Résolus"
|
||||
},
|
||||
"flares": {
|
||||
"countOne": "1 poussée",
|
||||
"countOne": "{count} poussée",
|
||||
"countOther": "{count} poussées"
|
||||
},
|
||||
"timeline": {
|
||||
@@ -11180,13 +11180,13 @@
|
||||
"toggleShow": "Afficher ce que le Coach a consulté",
|
||||
"toggleHide": "Masquer ce que le Coach a consulté",
|
||||
"announceDone": "Terminé : {label}",
|
||||
"readingsOne": "1 mesure",
|
||||
"readingsOne": "{count} mesure",
|
||||
"readingsFew": "{count} mesures",
|
||||
"readingsOther": "{count} mesures",
|
||||
"rowsOne": "1 ligne",
|
||||
"rowsOne": "{count} ligne",
|
||||
"rowsFew": "{count} lignes",
|
||||
"rowsOther": "{count} lignes",
|
||||
"metricsOne": "1 indicateur",
|
||||
"metricsOne": "{count} indicateur",
|
||||
"metricsFew": "{count} indicateurs",
|
||||
"metricsOther": "{count} indicateurs",
|
||||
"reason": {
|
||||
@@ -11290,7 +11290,7 @@
|
||||
"entry": "{domain}, {window} : {detail}",
|
||||
"entryNoWindow": "{domain} : {detail}",
|
||||
"reused": "d'une réponse précédente",
|
||||
"readingsOne": "1 mesure",
|
||||
"readingsOne": "{count} mesure",
|
||||
"readingsFew": "{count} mesures",
|
||||
"readingsOther": "{count} mesures",
|
||||
"aggregation": {
|
||||
@@ -12248,7 +12248,7 @@
|
||||
},
|
||||
"ai": {
|
||||
"readDone": "L’IA a lu votre document.",
|
||||
"readStagedOne": "L’IA a lu votre document. 1 valeur de laboratoire attend votre vérification.",
|
||||
"readStagedOne": "L’IA a lu votre document. {count} valeur de laboratoire attend votre vérification.",
|
||||
"readStagedFew": "L’IA a lu votre document. {count} valeurs de laboratoire attendent votre vérification.",
|
||||
"readStagedOther": "L’IA a lu votre document. {count} valeurs de laboratoire attendent votre vérification.",
|
||||
"read": "Lire avec l’IA",
|
||||
@@ -12280,12 +12280,12 @@
|
||||
"fieldValueText": "Résultat (texte)",
|
||||
"fieldUnit": "Unité",
|
||||
"fieldDose": "Dose",
|
||||
"unverifiedNoteOne": "1 valeur reste à vérifier et demeure en attente.",
|
||||
"unverifiedNoteOne": "{count} valeur reste à vérifier et demeure en attente.",
|
||||
"unverifiedNoteFew": "{count} valeurs restent à vérifier et demeurent en attente.",
|
||||
"unverifiedNoteOther": "{count} valeurs restent à vérifier et demeurent en attente.",
|
||||
"finish": "Terminer la vérification",
|
||||
"confirmFailed": "La vérification n’a pas pu être enregistrée. Réessayez.",
|
||||
"savedToastOne": "1 valeur enregistrée",
|
||||
"savedToastOne": "{count} valeur enregistrée",
|
||||
"savedToastFew": "{count} valeurs enregistrées",
|
||||
"savedToastOther": "{count} valeurs enregistrées",
|
||||
"nothingSavedToast": "Vérification enregistrée. Aucune valeur n’a été ajoutée.",
|
||||
@@ -13605,7 +13605,7 @@
|
||||
"deleteBody": "« {symptom} » et toutes ses entrées seront supprimés. Pour garder l'historique, masquez-le plutôt.",
|
||||
"deleteEventTitle": "Supprimer l'entrée ?",
|
||||
"deleteEventBody": "Cette entrée sera supprimée.",
|
||||
"hiddenOne": "Masqués (1)",
|
||||
"hiddenOne": "Masqués ({count})",
|
||||
"hiddenFew": "Masqués ({count})",
|
||||
"hiddenOther": "Masqués ({count})",
|
||||
"recent": "30 derniers jours",
|
||||
@@ -13672,14 +13672,14 @@
|
||||
"valuesAllFew": "Les {count} valeurs",
|
||||
"valuesAllOther": "Les {count} valeurs",
|
||||
"valuesFewer": "Moins de valeurs",
|
||||
"countEntriesOne": "1 entrée",
|
||||
"countEntriesOne": "{count} entrée",
|
||||
"countEntriesFew": "{count} entrées",
|
||||
"countEntriesOther": "{count} entrées",
|
||||
"meanOfReadingsOne": "Moyenne de 1 mesure",
|
||||
"meanOfReadingsOne": "Moyenne de {count} mesure",
|
||||
"meanOfReadingsFew": "Moyenne de {count} mesures",
|
||||
"meanOfReadingsOther": "Moyenne de {count} mesures",
|
||||
"sinceLastVisit": {
|
||||
"countOne": "1 entrée",
|
||||
"countOne": "{count} entrée",
|
||||
"countFew": "{count} entrées",
|
||||
"countOther": "{count} entrées",
|
||||
"allOne": "Tout afficher",
|
||||
@@ -13868,7 +13868,7 @@
|
||||
},
|
||||
"noValue": "aucune valeur",
|
||||
"bloodPressure": "Tension artérielle",
|
||||
"readingsOne": "1 mesure",
|
||||
"readingsOne": "{count} mesure",
|
||||
"readingsFew": "moyenne de {count} mesures",
|
||||
"readingsOther": "moyenne de {count} mesures"
|
||||
},
|
||||
@@ -13901,7 +13901,7 @@
|
||||
"empty": "Aucune entrée de {from} à {to}",
|
||||
"emptySingle": "Aucune entrée en {period}",
|
||||
"ended": "Fin : {label}",
|
||||
"afterDaysOne": "après 1 jour",
|
||||
"afterDaysOne": "après {count} jour",
|
||||
"afterDaysFew": "après {count} jours",
|
||||
"afterDaysOther": "après {count} jours",
|
||||
"notable": {
|
||||
@@ -13925,7 +13925,7 @@
|
||||
"apply": "Appliquer"
|
||||
},
|
||||
"item": {
|
||||
"labValuesOne": "1 valeur de laboratoire",
|
||||
"labValuesOne": "{count} valeur de laboratoire",
|
||||
"labValuesFew": "{count} valeurs de laboratoire",
|
||||
"labValuesOther": "{count} valeurs de laboratoire"
|
||||
},
|
||||
@@ -13944,7 +13944,17 @@
|
||||
},
|
||||
"legendLabValue": "Valeur de laboratoire, telle que mesurée",
|
||||
"legendLabOutside": "Hors de l’intervalle de référence",
|
||||
"legendLabRange": "Intervalle de référence"
|
||||
"legendLabRange": "Intervalle de référence",
|
||||
"legendSymptom": "Ligne de symptôme : intensité la plus forte de chaque jour noté (0 à 10), en moyenne sur les jours notés",
|
||||
"symptoms": {
|
||||
"menuTitle": "Symptômes",
|
||||
"full": "Jusqu’à {max} symptômes à la fois. Retirez-en un pour en ajouter un autre.",
|
||||
"hidden": "masqué",
|
||||
"unnamed": "Symptôme",
|
||||
"daysOne": "sur {count} jour",
|
||||
"daysFew": "moyenne sur {count} jours",
|
||||
"daysOther": "moyenne sur {count} jours"
|
||||
}
|
||||
},
|
||||
"lifeEvents": {
|
||||
"title": "Événement de vie",
|
||||
|
||||
+11
-1
@@ -13944,7 +13944,17 @@
|
||||
},
|
||||
"legendLabValue": "Valore di laboratorio, così come misurato",
|
||||
"legendLabOutside": "Fuori dall’intervallo di riferimento",
|
||||
"legendLabRange": "Intervallo di riferimento"
|
||||
"legendLabRange": "Intervallo di riferimento",
|
||||
"legendSymptom": "Linea del sintomo: l’intensità più alta di ogni giorno registrato (da 0 a 10), in media sui giorni registrati",
|
||||
"symptoms": {
|
||||
"menuTitle": "Sintomi",
|
||||
"full": "Fino a {max} sintomi alla volta. Rimuovine uno per aggiungerne un altro.",
|
||||
"hidden": "nascosto",
|
||||
"unnamed": "Sintomo",
|
||||
"daysOne": "in 1 giorno",
|
||||
"daysFew": "media di {count} giorni",
|
||||
"daysOther": "media di {count} giorni"
|
||||
}
|
||||
},
|
||||
"lifeEvents": {
|
||||
"title": "Evento di vita",
|
||||
|
||||
+11
-1
@@ -13944,7 +13944,17 @@
|
||||
},
|
||||
"legendLabValue": "측정된 그대로의 검사 수치",
|
||||
"legendLabOutside": "참고 범위 밖",
|
||||
"legendLabRange": "참고 범위"
|
||||
"legendLabRange": "참고 범위",
|
||||
"legendSymptom": "증상 선: 기록한 날마다의 가장 높은 강도(0~10)를 기록한 날 기준으로 평균한 값",
|
||||
"symptoms": {
|
||||
"menuTitle": "증상",
|
||||
"full": "증상은 한 번에 최대 {max}개까지 표시할 수 있습니다. 다른 증상을 추가하려면 하나를 제거하세요.",
|
||||
"hidden": "숨김",
|
||||
"unnamed": "증상",
|
||||
"daysOne": "1일",
|
||||
"daysFew": "{count}일 평균",
|
||||
"daysOther": "{count}일 평균"
|
||||
}
|
||||
},
|
||||
"lifeEvents": {
|
||||
"title": "삶의 사건",
|
||||
|
||||
+11
-1
@@ -13944,7 +13944,17 @@
|
||||
},
|
||||
"legendLabValue": "Labwaarde, zoals gemeten",
|
||||
"legendLabOutside": "Buiten het referentiebereik",
|
||||
"legendLabRange": "Referentiebereik"
|
||||
"legendLabRange": "Referentiebereik",
|
||||
"legendSymptom": "Symptoomlijn: de hoogste intensiteit van elke vastgelegde dag (0 tot 10), gemiddeld over de vastgelegde dagen",
|
||||
"symptoms": {
|
||||
"menuTitle": "Symptomen",
|
||||
"full": "Maximaal {max} symptomen tegelijk. Verwijder er een om een ander toe te voegen.",
|
||||
"hidden": "verborgen",
|
||||
"unnamed": "Symptoom",
|
||||
"daysOne": "op 1 dag",
|
||||
"daysFew": "gemiddelde van {count} dagen",
|
||||
"daysOther": "gemiddelde van {count} dagen"
|
||||
}
|
||||
},
|
||||
"lifeEvents": {
|
||||
"title": "Levensgebeurtenis",
|
||||
|
||||
+11
-1
@@ -13944,7 +13944,17 @@
|
||||
},
|
||||
"legendLabValue": "Wynik badania, tak jak zmierzono",
|
||||
"legendLabOutside": "Poza zakresem referencyjnym",
|
||||
"legendLabRange": "Zakres referencyjny"
|
||||
"legendLabRange": "Zakres referencyjny",
|
||||
"legendSymptom": "Linia objawu: najwyższe natężenie każdego zapisanego dnia (od 0 do 10), uśrednione po zapisanych dniach",
|
||||
"symptoms": {
|
||||
"menuTitle": "Objawy",
|
||||
"full": "Naraz można pokazać do {max} objawów. Usuń jeden, aby dodać inny.",
|
||||
"hidden": "ukryty",
|
||||
"unnamed": "Objaw",
|
||||
"daysOne": "w 1 dniu",
|
||||
"daysFew": "średnia z {count} dni",
|
||||
"daysOther": "średnia z {count} dni"
|
||||
}
|
||||
},
|
||||
"lifeEvents": {
|
||||
"title": "Wydarzenie życiowe",
|
||||
|
||||
+40
-30
@@ -739,10 +739,10 @@
|
||||
"emptyDescription": "Registre sua primeira medição ou entrada de humor. Blocos e gráficos aparecem automaticamente conforme os dados chegam.",
|
||||
"emptyAddMeasurement": "Adicionar sua primeira medição",
|
||||
"staleHint": "Última leitura há {count} d",
|
||||
"staleHintWeeksOne": "Última leitura há 1 semana",
|
||||
"staleHintWeeksOne": "Última leitura há {count} semana",
|
||||
"staleHintWeeksOther": "Última leitura há {count} semanas",
|
||||
"staleHintWeeksFew": "Última leitura há {count} semanas",
|
||||
"staleHintMonthsOne": "Última leitura há 1 mês",
|
||||
"staleHintMonthsOne": "Última leitura há {count} mês",
|
||||
"staleHintMonthsOther": "Última leitura há {count} meses",
|
||||
"staleHintMonthsFew": "Última leitura há {count} meses",
|
||||
"recentWorkouts": {
|
||||
@@ -2287,7 +2287,7 @@
|
||||
"currentSince": "Plano atual desde {date}",
|
||||
"noTimes": "Sem horários fixos",
|
||||
"manualChip": "Adicionado manualmente",
|
||||
"showErasOne": "Mostrar 1 era anterior",
|
||||
"showErasOne": "Mostrar {count} era anterior",
|
||||
"showErasOther": "Mostrar {count} eras anteriores",
|
||||
"hideEras": "Ocultar eras anteriores",
|
||||
"addEra": "Adicionar era anterior",
|
||||
@@ -2359,7 +2359,7 @@
|
||||
"tableColActions": "Ações",
|
||||
"tableSortBy": "Ordenar por {column}",
|
||||
"tableComplianceDays": "{days} d",
|
||||
"tableStockDoseOne": "1 dose",
|
||||
"tableStockDoseOne": "{count} dose",
|
||||
"tableStockDoses": "{count} doses",
|
||||
"tableStockRunway": "≈ {days} dias restantes",
|
||||
"cardLowStockRunway": "Estoque: ≈ {days} dias",
|
||||
@@ -3238,7 +3238,7 @@
|
||||
"settingsClustersLabel": "Dados que o Coach vê",
|
||||
"settingsClustersHint": "Escolha quais grupos dos seus dados o Coach pode ler. Mais dados significam respostas mais ricas e um prompt maior.",
|
||||
"settingsClusterNoData": "Ainda sem dados",
|
||||
"sourcesMemberCountOne": "1 métrica",
|
||||
"sourcesMemberCountOne": "{count} métrica",
|
||||
"sourcesMemberCountFew": "{count} métricas",
|
||||
"sourcesMemberCountOther": "{count} métricas",
|
||||
"lookback": {
|
||||
@@ -3338,7 +3338,7 @@
|
||||
"indexing": "Indexando…",
|
||||
"indexingHint": "Um documento ainda está sendo indexado. Você pode enviar quando estiver pronto.",
|
||||
"fencedHint": "Esta conversa roda sem acesso aos seus dados de saúde. O coach lê apenas os documentos anexados.",
|
||||
"documentsAttachedOne": "1 documento anexado",
|
||||
"documentsAttachedOne": "{count} documento anexado",
|
||||
"documentsAttachedOther": "{count} documentos anexados",
|
||||
"railBadge": "Conversa de documento isolada",
|
||||
"uploadError": "Falha no envio. Tente novamente.",
|
||||
@@ -3942,9 +3942,9 @@
|
||||
"learningBody": "Até agora são {count} em {days}. Um pouco mais de histórico e um quadro mais completo entra em foco. Continue registrando, ou deixe um dispositivo conectado preencher.",
|
||||
"learningBodyEmpty": "Assim que algumas leituras chegarem (registradas por você ou sincronizadas de um dispositivo), seu tempo na faixa, média e variabilidade tomarão forma aqui.",
|
||||
"readingsCount": "{count} leituras",
|
||||
"readingsCountOne": "1 leitura",
|
||||
"readingsCountOne": "{count} leitura",
|
||||
"daysCount": "{days} dias",
|
||||
"daysCountOne": "1 dia",
|
||||
"daysCountOne": "{count} dia",
|
||||
"tir": {
|
||||
"title": "Tempo na faixa",
|
||||
"caption": "Parcela das leituras em cada faixa ({low}–{high} {unit} é a meta).",
|
||||
@@ -3996,7 +3996,7 @@
|
||||
"bedtime": "Hora de dormir",
|
||||
"unspecified": "Sem marcação",
|
||||
"readingsShort": "{count} leituras",
|
||||
"readingsShortOne": "1 leitura"
|
||||
"readingsShortOne": "{count} leitura"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4935,7 +4935,7 @@
|
||||
},
|
||||
"crossLink": {
|
||||
"title": "Registros de ECG",
|
||||
"recordingsOne": "1 registro em arquivo.",
|
||||
"recordingsOne": "{count} registro em arquivo.",
|
||||
"recordingsMany": "{count} registros em arquivo.",
|
||||
"latestResult": "Resultado mais recente do dispositivo: {result}.",
|
||||
"cta": "Ver registros"
|
||||
@@ -4949,7 +4949,7 @@
|
||||
"caption": "Média em intervalos de 10 minutos.",
|
||||
"hourlyNote": "Mostrando a média horária deste dia; dias recentes mostram o detalhe completo de 10 minutos.",
|
||||
"empty": "Não há leituras de frequência cardíaca suficientes para este dia mostrar sua forma ainda.",
|
||||
"coverageOne": "Com base em 1 leitura ao longo de {hours}h.",
|
||||
"coverageOne": "Com base em {count} leitura ao longo de {hours}h.",
|
||||
"coverageMany": "Com base em {count} leituras ao longo de {hours}h.",
|
||||
"baseline": "Em repouso",
|
||||
"today": "Hoje",
|
||||
@@ -5442,7 +5442,7 @@
|
||||
},
|
||||
"trendHints": {
|
||||
"title": "Primeira tendência após 5 leituras",
|
||||
"remainingOne": "Falta 1 leitura.",
|
||||
"remainingOne": "Falta {count} leitura.",
|
||||
"remainingMany": "Faltam {count} leituras."
|
||||
},
|
||||
"settings": {
|
||||
@@ -8015,7 +8015,7 @@
|
||||
"colRedeemedBy": "Resgatado por",
|
||||
"colActions": "Ações",
|
||||
"byCreator": "por {by}",
|
||||
"expiresInDaysOne": "em 1 dia",
|
||||
"expiresInDaysOne": "em {count} dia",
|
||||
"expiresInDaysOther": "em {count} dias",
|
||||
"statusActive": "Ativo",
|
||||
"statusExpired": "Expirado",
|
||||
@@ -11035,7 +11035,7 @@
|
||||
},
|
||||
"insights": {
|
||||
"title": "Seu histórico de doenças",
|
||||
"countOne": "Você registrou 1 episódio neste período.",
|
||||
"countOne": "Você registrou {count} episódio neste período.",
|
||||
"countOther": "Você registrou {count} episódios neste período.",
|
||||
"typicalGap": "Lacuna típica de recuperação: {days} dias entre se sentir melhor e seus números se estabilizarem.",
|
||||
"typicalGapWithDriver": "Lacuna típica de recuperação: {days} dias; você tende a se sentir melhor antes de seu {vital} se estabilizar.",
|
||||
@@ -11048,7 +11048,7 @@
|
||||
"resolved": "Resolvidos"
|
||||
},
|
||||
"flares": {
|
||||
"countOne": "1 surto",
|
||||
"countOne": "{count} surto",
|
||||
"countOther": "{count} surtos"
|
||||
},
|
||||
"timeline": {
|
||||
@@ -11180,13 +11180,13 @@
|
||||
"toggleShow": "Mostrar o que o Coach examinou",
|
||||
"toggleHide": "Ocultar o que o Coach examinou",
|
||||
"announceDone": "Concluído: {label}",
|
||||
"readingsOne": "1 leitura",
|
||||
"readingsOne": "{count} leitura",
|
||||
"readingsFew": "{count} leituras",
|
||||
"readingsOther": "{count} leituras",
|
||||
"rowsOne": "1 linha",
|
||||
"rowsOne": "{count} linha",
|
||||
"rowsFew": "{count} linhas",
|
||||
"rowsOther": "{count} linhas",
|
||||
"metricsOne": "1 métrica",
|
||||
"metricsOne": "{count} métrica",
|
||||
"metricsFew": "{count} métricas",
|
||||
"metricsOther": "{count} métricas",
|
||||
"reason": {
|
||||
@@ -11290,7 +11290,7 @@
|
||||
"entry": "{domain}, {window}: {detail}",
|
||||
"entryNoWindow": "{domain}: {detail}",
|
||||
"reused": "de uma resposta anterior",
|
||||
"readingsOne": "1 leitura",
|
||||
"readingsOne": "{count} leitura",
|
||||
"readingsFew": "{count} leituras",
|
||||
"readingsOther": "{count} leituras",
|
||||
"aggregation": {
|
||||
@@ -12248,7 +12248,7 @@
|
||||
},
|
||||
"ai": {
|
||||
"readDone": "A IA leu seu documento.",
|
||||
"readStagedOne": "A IA leu seu documento. 1 valor laboratorial está pronto para revisão.",
|
||||
"readStagedOne": "A IA leu seu documento. {count} valor laboratorial está pronto para revisão.",
|
||||
"readStagedFew": "A IA leu seu documento. {count} valores laboratoriais estão prontos para revisão.",
|
||||
"readStagedOther": "A IA leu seu documento. {count} valores laboratoriais estão prontos para revisão.",
|
||||
"read": "Ler com IA",
|
||||
@@ -12280,12 +12280,12 @@
|
||||
"fieldValueText": "Resultado (texto)",
|
||||
"fieldUnit": "Unidade",
|
||||
"fieldDose": "Dose",
|
||||
"unverifiedNoteOne": "1 valor ainda precisa de verificação e permanece pendente.",
|
||||
"unverifiedNoteOne": "{count} valor ainda precisa de verificação e permanece pendente.",
|
||||
"unverifiedNoteFew": "{count} valores ainda precisam de verificação e permanecem pendentes.",
|
||||
"unverifiedNoteOther": "{count} valores ainda precisam de verificação e permanecem pendentes.",
|
||||
"finish": "Concluir revisão",
|
||||
"confirmFailed": "A revisão não pôde ser salva. Tente novamente.",
|
||||
"savedToastOne": "1 valor salvo",
|
||||
"savedToastOne": "{count} valor salvo",
|
||||
"savedToastFew": "{count} valores salvos",
|
||||
"savedToastOther": "{count} valores salvos",
|
||||
"nothingSavedToast": "Revisão salva. Nenhum valor foi adicionado.",
|
||||
@@ -13605,7 +13605,7 @@
|
||||
"deleteBody": "“{symptom}” e todas as suas entradas serão excluídos. Para manter o histórico, oculte-o em vez disso.",
|
||||
"deleteEventTitle": "Excluir entrada?",
|
||||
"deleteEventBody": "Esta entrada será excluída.",
|
||||
"hiddenOne": "Ocultos (1)",
|
||||
"hiddenOne": "Ocultos ({count})",
|
||||
"hiddenFew": "Ocultos ({count})",
|
||||
"hiddenOther": "Ocultos ({count})",
|
||||
"recent": "Últimos 30 dias",
|
||||
@@ -13672,14 +13672,14 @@
|
||||
"valuesAllFew": "Todos os {count} valores",
|
||||
"valuesAllOther": "Todos os {count} valores",
|
||||
"valuesFewer": "Menos valores",
|
||||
"countEntriesOne": "1 entrada",
|
||||
"countEntriesOne": "{count} entrada",
|
||||
"countEntriesFew": "{count} entradas",
|
||||
"countEntriesOther": "{count} entradas",
|
||||
"meanOfReadingsOne": "Média de 1 leitura",
|
||||
"meanOfReadingsOne": "Média de {count} leitura",
|
||||
"meanOfReadingsFew": "Média de {count} leituras",
|
||||
"meanOfReadingsOther": "Média de {count} leituras",
|
||||
"sinceLastVisit": {
|
||||
"countOne": "1 entrada",
|
||||
"countOne": "{count} entrada",
|
||||
"countFew": "{count} entradas",
|
||||
"countOther": "{count} entradas",
|
||||
"allOne": "Mostrar tudo",
|
||||
@@ -13868,7 +13868,7 @@
|
||||
},
|
||||
"noValue": "sem valor",
|
||||
"bloodPressure": "Pressão arterial",
|
||||
"readingsOne": "1 leitura",
|
||||
"readingsOne": "{count} leitura",
|
||||
"readingsFew": "média de {count} leituras",
|
||||
"readingsOther": "média de {count} leituras"
|
||||
},
|
||||
@@ -13901,7 +13901,7 @@
|
||||
"empty": "Nenhuma entrada de {from} a {to}",
|
||||
"emptySingle": "Nenhuma entrada em {period}",
|
||||
"ended": "{label} terminou",
|
||||
"afterDaysOne": "após 1 dia",
|
||||
"afterDaysOne": "após {count} dia",
|
||||
"afterDaysFew": "após {count} dias",
|
||||
"afterDaysOther": "após {count} dias",
|
||||
"notable": {
|
||||
@@ -13925,7 +13925,7 @@
|
||||
"apply": "Aplicar"
|
||||
},
|
||||
"item": {
|
||||
"labValuesOne": "1 valor laboratorial",
|
||||
"labValuesOne": "{count} valor laboratorial",
|
||||
"labValuesFew": "{count} valores laboratoriais",
|
||||
"labValuesOther": "{count} valores laboratoriais"
|
||||
},
|
||||
@@ -13944,7 +13944,17 @@
|
||||
},
|
||||
"legendLabValue": "Valor de exame, como foi medido",
|
||||
"legendLabOutside": "Fora da faixa de referência",
|
||||
"legendLabRange": "Faixa de referência"
|
||||
"legendLabRange": "Faixa de referência",
|
||||
"legendSymptom": "Linha do sintoma: a maior intensidade de cada dia registrado (de 0 a 10), em média sobre os dias registrados",
|
||||
"symptoms": {
|
||||
"menuTitle": "Sintomas",
|
||||
"full": "Até {max} sintomas ao mesmo tempo. Remova um para adicionar outro.",
|
||||
"hidden": "oculto",
|
||||
"unnamed": "Sintoma",
|
||||
"daysOne": "em {count} dia",
|
||||
"daysFew": "média de {count} dias",
|
||||
"daysOther": "média de {count} dias"
|
||||
}
|
||||
},
|
||||
"lifeEvents": {
|
||||
"title": "Evento de vida",
|
||||
|
||||
+42
-32
@@ -739,10 +739,10 @@
|
||||
"emptyDescription": "Запишите первое измерение или настроение. Плитки и графики появятся автоматически по мере поступления данных.",
|
||||
"emptyAddMeasurement": "Добавить первое измерение",
|
||||
"staleHint": "Последнее измерение {count} дн. назад",
|
||||
"staleHintWeeksOne": "Последнее измерение 1 неделю назад",
|
||||
"staleHintWeeksOne": "Последнее измерение {count} неделю назад",
|
||||
"staleHintWeeksOther": "Последнее измерение {count} недель назад",
|
||||
"staleHintWeeksFew": "Последнее измерение {count} недели назад",
|
||||
"staleHintMonthsOne": "Последнее измерение 1 месяц назад",
|
||||
"staleHintMonthsOne": "Последнее измерение {count} месяц назад",
|
||||
"staleHintMonthsOther": "Последнее измерение {count} месяцев назад",
|
||||
"staleHintMonthsFew": "Последнее измерение {count} месяца назад",
|
||||
"recentWorkouts": {
|
||||
@@ -2287,7 +2287,7 @@
|
||||
"currentSince": "Текущий план с {date}",
|
||||
"noTimes": "Без фиксированного времени",
|
||||
"manualChip": "Добавлено вручную",
|
||||
"showErasOne": "Показать 1 прошлый период",
|
||||
"showErasOne": "Показать {count} прошлый период",
|
||||
"showErasOther": "Показать прошлые периоды ({count})",
|
||||
"hideEras": "Скрыть прошлые периоды",
|
||||
"addEra": "Добавить прошлый период",
|
||||
@@ -2359,7 +2359,7 @@
|
||||
"tableColActions": "Действия",
|
||||
"tableSortBy": "Сортировать по {column}",
|
||||
"tableComplianceDays": "{days} дн.",
|
||||
"tableStockDoseOne": "1 доза",
|
||||
"tableStockDoseOne": "{count} доза",
|
||||
"tableStockDoses": "{count} доз",
|
||||
"tableStockRunway": "≈ {days} дн. осталось",
|
||||
"cardLowStockRunway": "Запас: ≈ {days} дн.",
|
||||
@@ -3238,7 +3238,7 @@
|
||||
"settingsClustersLabel": "Данные, которые видит коуч",
|
||||
"settingsClustersHint": "Выберите, какие кластеры ваших данных может читать коуч. Больше данных — богаче ответы и больше запрос.",
|
||||
"settingsClusterNoData": "Данных пока нет",
|
||||
"sourcesMemberCountOne": "1 показатель",
|
||||
"sourcesMemberCountOne": "{count} показатель",
|
||||
"sourcesMemberCountFew": "{count} показателя",
|
||||
"sourcesMemberCountOther": "{count} показателей",
|
||||
"lookback": {
|
||||
@@ -3338,7 +3338,7 @@
|
||||
"indexing": "Индексация…",
|
||||
"indexingHint": "Документ ещё индексируется. Отправить можно, когда он будет готов.",
|
||||
"fencedHint": "Этот разговор идёт без доступа к вашим данным о здоровье. Коуч читает только прикреплённые документы.",
|
||||
"documentsAttachedOne": "Прикреплён 1 документ",
|
||||
"documentsAttachedOne": "Прикреплён {count} документ",
|
||||
"documentsAttachedOther": "Прикреплено документов: {count}",
|
||||
"railBadge": "Изолированный чат по документам",
|
||||
"uploadError": "Загрузка не удалась. Попробуйте ещё раз.",
|
||||
@@ -3942,9 +3942,9 @@
|
||||
"learningBody": "Пока это {count} за {days}. Ещё немного истории, и более полная картина прояснится. Продолжайте записывать или дайте подключённому устройству заполнить её.",
|
||||
"learningBodyEmpty": "Как только поступит несколько измерений (записанных вами или синхронизированных с устройства), здесь сформируются ваше время в диапазоне, среднее и вариабельность.",
|
||||
"readingsCount": "Измерений: {count}",
|
||||
"readingsCountOne": "1 измерение",
|
||||
"readingsCountOne": "{count} измерение",
|
||||
"daysCount": "Дней: {days}",
|
||||
"daysCountOne": "1 день",
|
||||
"daysCountOne": "{count} день",
|
||||
"tir": {
|
||||
"title": "Время в диапазоне",
|
||||
"caption": "Доля измерений в каждой полосе ({low}–{high} {unit} — цель).",
|
||||
@@ -3996,7 +3996,7 @@
|
||||
"bedtime": "Перед сном",
|
||||
"unspecified": "Без метки",
|
||||
"readingsShort": "Измерений: {count}",
|
||||
"readingsShortOne": "1 измерение"
|
||||
"readingsShortOne": "{count} измерение"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4935,7 +4935,7 @@
|
||||
},
|
||||
"crossLink": {
|
||||
"title": "Записи ЭКГ",
|
||||
"recordingsOne": "1 запись в архиве.",
|
||||
"recordingsOne": "{count} запись в архиве.",
|
||||
"recordingsMany": "Записей в архиве: {count}.",
|
||||
"latestResult": "Последний результат устройства: {result}.",
|
||||
"cta": "Посмотреть записи"
|
||||
@@ -4949,7 +4949,7 @@
|
||||
"caption": "Усреднено шагами по 10 минут.",
|
||||
"hourlyNote": "Показано среднее по часам за этот день; для недавних дней доступна полная детализация по 10 минут.",
|
||||
"empty": "Пока недостаточно измерений ЧСС за этот день, чтобы показать его форму.",
|
||||
"coverageOne": "На основе 1 измерения за {hours} ч.",
|
||||
"coverageOne": "На основе {count} измерения за {hours} ч.",
|
||||
"coverageMany": "На основе {count} измерений за {hours} ч.",
|
||||
"baseline": "В покое",
|
||||
"today": "Сегодня",
|
||||
@@ -5442,7 +5442,7 @@
|
||||
},
|
||||
"trendHints": {
|
||||
"title": "Первая динамика после 5 измерений",
|
||||
"remainingOne": "Осталось 1 измерение.",
|
||||
"remainingOne": "Осталось {count} измерение.",
|
||||
"remainingMany": "Осталось измерений: {count}."
|
||||
},
|
||||
"settings": {
|
||||
@@ -8015,7 +8015,7 @@
|
||||
"colRedeemedBy": "Активировано",
|
||||
"colActions": "Действия",
|
||||
"byCreator": "от {by}",
|
||||
"expiresInDaysOne": "через 1 день",
|
||||
"expiresInDaysOne": "через {count} день",
|
||||
"expiresInDaysOther": "через {count} дней",
|
||||
"statusActive": "Активно",
|
||||
"statusExpired": "Истекло",
|
||||
@@ -11035,7 +11035,7 @@
|
||||
},
|
||||
"insights": {
|
||||
"title": "Ваша история заболеваний",
|
||||
"countOne": "За этот период вы записали 1 эпизод.",
|
||||
"countOne": "За этот период вы записали {count} эпизод.",
|
||||
"countOther": "За этот период вы записали эпизодов: {count}.",
|
||||
"typicalGap": "Типичный разрыв восстановления: {days} дней между улучшением самочувствия и нормализацией показателей.",
|
||||
"typicalGapWithDriver": "Типичный разрыв восстановления: {days} дней; вы обычно чувствуете себя лучше раньше, чем нормализуется {vital}.",
|
||||
@@ -11048,7 +11048,7 @@
|
||||
"resolved": "Завершённые"
|
||||
},
|
||||
"flares": {
|
||||
"countOne": "1 обострение",
|
||||
"countOne": "{count} обострение",
|
||||
"countOther": "{count} обострений"
|
||||
},
|
||||
"timeline": {
|
||||
@@ -11180,13 +11180,13 @@
|
||||
"toggleShow": "Показать, что просмотрел Коуч",
|
||||
"toggleHide": "Скрыть, что просмотрел Коуч",
|
||||
"announceDone": "Готово: {label}",
|
||||
"readingsOne": "1 измерение",
|
||||
"readingsOne": "{count} измерение",
|
||||
"readingsFew": "Измерений: {count}",
|
||||
"readingsOther": "Измерений: {count}",
|
||||
"rowsOne": "1 строка",
|
||||
"rowsOne": "{count} строка",
|
||||
"rowsFew": "{count} строки",
|
||||
"rowsOther": "{count} строк",
|
||||
"metricsOne": "1 показатель",
|
||||
"metricsOne": "{count} показатель",
|
||||
"metricsFew": "{count} показателя",
|
||||
"metricsOther": "{count} показателей",
|
||||
"reason": {
|
||||
@@ -11290,7 +11290,7 @@
|
||||
"entry": "{domain}, {window}: {detail}",
|
||||
"entryNoWindow": "{domain}: {detail}",
|
||||
"reused": "из более раннего ответа",
|
||||
"readingsOne": "1 измерение",
|
||||
"readingsOne": "{count} измерение",
|
||||
"readingsFew": "Измерений: {count}",
|
||||
"readingsOther": "Измерений: {count}",
|
||||
"aggregation": {
|
||||
@@ -12224,7 +12224,7 @@
|
||||
"errorGeneric": "Не удалось прочитать документ. Попробуйте ещё раз.",
|
||||
"errorOcr": "Не удалось прочитать текст с этого изображения.",
|
||||
"errorRateLimited": "Достигнут часовой лимит чтения с ИИ. Попробуйте позже.",
|
||||
"errorRateLimitedWaitOne": "Достигнут часовой лимит чтения с ИИ. Попробуйте примерно через минуту.",
|
||||
"errorRateLimitedWaitOne": "Достигнут часовой лимит чтения с ИИ. Попробуйте примерно через {count} минуту.",
|
||||
"errorRateLimitedWaitFew": "Достигнут часовой лимит чтения с ИИ. Попробуйте примерно через {count} минуты.",
|
||||
"errorRateLimitedWaitOther": "Достигнут часовой лимит чтения с ИИ. Попробуйте примерно через {count} минут.",
|
||||
"errorBudget": "Ваш бюджет ИИ на сегодня исчерпан.",
|
||||
@@ -12248,7 +12248,7 @@
|
||||
},
|
||||
"ai": {
|
||||
"readDone": "ИИ прочитал ваш документ.",
|
||||
"readStagedOne": "ИИ прочитал ваш документ. 1 результат анализа готов к проверке.",
|
||||
"readStagedOne": "ИИ прочитал ваш документ. {count} результат анализа готов к проверке.",
|
||||
"readStagedFew": "ИИ прочитал ваш документ. Результатов анализов готово к проверке: {count}.",
|
||||
"readStagedOther": "ИИ прочитал ваш документ. Результатов анализов готово к проверке: {count}.",
|
||||
"read": "Прочитать с ИИ",
|
||||
@@ -12280,12 +12280,12 @@
|
||||
"fieldValueText": "Результат (текст)",
|
||||
"fieldUnit": "Единица",
|
||||
"fieldDose": "Доза",
|
||||
"unverifiedNoteOne": "1 значение ещё нужно проверить, оно остаётся в ожидании.",
|
||||
"unverifiedNoteOne": "{count} значение ещё нужно проверить, оно остаётся в ожидании.",
|
||||
"unverifiedNoteFew": "Значений, которые ещё нужно проверить и которые остаются в ожидании: {count}.",
|
||||
"unverifiedNoteOther": "Значений, которые ещё нужно проверить и которые остаются в ожидании: {count}.",
|
||||
"finish": "Завершить проверку",
|
||||
"confirmFailed": "Не удалось сохранить проверку. Попробуйте ещё раз.",
|
||||
"savedToastOne": "1 значение сохранено",
|
||||
"savedToastOne": "{count} значение сохранено",
|
||||
"savedToastFew": "Сохранено значений: {count}",
|
||||
"savedToastOther": "Сохранено значений: {count}",
|
||||
"nothingSavedToast": "Проверка сохранена. Значения не добавлены.",
|
||||
@@ -13592,7 +13592,7 @@
|
||||
"title": "Ваши симптомы",
|
||||
"log": "Записать",
|
||||
"loadError": "Ваши симптомы не удалось загрузить.",
|
||||
"summaryOne": "Один раз за 30 дней, {max}/10, последний {date}",
|
||||
"summaryOne": "{count} раз за 30 дней, {max}/10, последний {date}",
|
||||
"summaryFew": "{count} раза за 30 дней, до {max}/10, последний {date}",
|
||||
"summaryOther": "{count} раз за 30 дней, до {max}/10, последний {date}",
|
||||
"summaryNoneRecent": "Не за последние 30 дней, последний {date}",
|
||||
@@ -13605,7 +13605,7 @@
|
||||
"deleteBody": "«{symptom}» и каждая его запись будут удалены. Чтобы сохранить историю, скройте его вместо этого.",
|
||||
"deleteEventTitle": "Удалить запись?",
|
||||
"deleteEventBody": "Эта запись будет удалена.",
|
||||
"hiddenOne": "Скрыто (1)",
|
||||
"hiddenOne": "Скрыто ({count})",
|
||||
"hiddenFew": "Скрыто ({count})",
|
||||
"hiddenOther": "Скрыто ({count})",
|
||||
"recent": "Последние 30 дней",
|
||||
@@ -13672,14 +13672,14 @@
|
||||
"valuesAllFew": "Все {count} значения",
|
||||
"valuesAllOther": "Все {count} значений",
|
||||
"valuesFewer": "Меньше значений",
|
||||
"countEntriesOne": "1 запись",
|
||||
"countEntriesOne": "{count} запись",
|
||||
"countEntriesFew": "Записей: {count}",
|
||||
"countEntriesOther": "Записей: {count}",
|
||||
"meanOfReadingsOne": "Среднее по 1 измерению",
|
||||
"meanOfReadingsOne": "Среднее по {count} измерению",
|
||||
"meanOfReadingsFew": "Среднее по {count} измерениям",
|
||||
"meanOfReadingsOther": "Среднее по {count} измерениям",
|
||||
"sinceLastVisit": {
|
||||
"countOne": "1 запись",
|
||||
"countOne": "{count} запись",
|
||||
"countFew": "Записей: {count}",
|
||||
"countOther": "Записей: {count}",
|
||||
"allOne": "Показать все",
|
||||
@@ -13868,7 +13868,7 @@
|
||||
},
|
||||
"noValue": "нет значения",
|
||||
"bloodPressure": "АД",
|
||||
"readingsOne": "1 измерение",
|
||||
"readingsOne": "{count} измерение",
|
||||
"readingsFew": "среднее по {count} измерениям",
|
||||
"readingsOther": "среднее по {count} измерениям"
|
||||
},
|
||||
@@ -13901,7 +13901,7 @@
|
||||
"empty": "Нет записей с {from} по {to}",
|
||||
"emptySingle": "Нет записей в {period}",
|
||||
"ended": "{label} завершено",
|
||||
"afterDaysOne": "через 1 день",
|
||||
"afterDaysOne": "через {count} день",
|
||||
"afterDaysFew": "через {count} дня",
|
||||
"afterDaysOther": "через {count} дней",
|
||||
"notable": {
|
||||
@@ -13925,7 +13925,7 @@
|
||||
"apply": "Применить"
|
||||
},
|
||||
"item": {
|
||||
"labValuesOne": "1 результат анализа",
|
||||
"labValuesOne": "{count} результат анализа",
|
||||
"labValuesFew": "{count} результата анализов",
|
||||
"labValuesOther": "{count} результатов анализов"
|
||||
},
|
||||
@@ -13944,7 +13944,17 @@
|
||||
},
|
||||
"legendLabValue": "Результат анализа, как измерено",
|
||||
"legendLabOutside": "Вне референсного диапазона",
|
||||
"legendLabRange": "Референсный диапазон"
|
||||
"legendLabRange": "Референсный диапазон",
|
||||
"legendSymptom": "Линия симптома: наибольшая сила за каждый отмеченный день (от 0 до 10), усреднённая по отмеченным дням",
|
||||
"symptoms": {
|
||||
"menuTitle": "Симптомы",
|
||||
"full": "До {max} симптомов одновременно. Уберите один, чтобы добавить другой.",
|
||||
"hidden": "скрыт",
|
||||
"unnamed": "Симптом",
|
||||
"daysOne": "за {count} день",
|
||||
"daysFew": "среднее за {count} дня",
|
||||
"daysOther": "среднее за {count} дней"
|
||||
}
|
||||
},
|
||||
"lifeEvents": {
|
||||
"title": "Событие жизни",
|
||||
|
||||
+11
-11
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.47.0",
|
||||
"version": "1.47.1",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
@@ -62,27 +62,27 @@
|
||||
"@modelcontextprotocol/sdk": "^1.32.2",
|
||||
"@napi-rs/canvas": "1.1.0",
|
||||
"@node-rs/argon2": "^2.2.2",
|
||||
"@parse/node-apn": "^8.1.1",
|
||||
"@parse/node-apn": "^8.2.0",
|
||||
"@prisma/adapter-pg": "^7.10.1",
|
||||
"@prisma/client": "^7.10.1",
|
||||
"@simplewebauthn/browser": "^14.0.0",
|
||||
"@simplewebauthn/server": "^14.0.3",
|
||||
"@tanstack/react-query": "5.105.1",
|
||||
"@tanstack/react-virtual": "^3.14.25",
|
||||
"@tanstack/react-query": "5.105.3",
|
||||
"@tanstack/react-virtual": "^3.14.26",
|
||||
"@types/sax": "^1.2.7",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"dotenv": "^18.0.9",
|
||||
"fflate": "^0.8.3",
|
||||
"jose": "^6.2.14",
|
||||
"jose": "^6.2.15",
|
||||
"jspdf": "^4.2.1",
|
||||
"jspdf-autotable": "^5.0.9",
|
||||
"lucide-react": "^1.66.0",
|
||||
"mmdb-lib": "^3.0.3",
|
||||
"next": "16.4.3",
|
||||
"lucide-react": "^1.67.0",
|
||||
"mmdb-lib": "^3.0.4",
|
||||
"next": "16.4.5",
|
||||
"nodemailer": "10.3.1",
|
||||
"otpauth": "^9.5.2",
|
||||
"otpauth": "^9.6.0",
|
||||
"p-limit": "^7.3.3",
|
||||
"pg": "^8.23.1",
|
||||
"pg-boss": "^12.38.0",
|
||||
@@ -107,7 +107,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.14.0",
|
||||
"@next/bundle-analyzer": "16.4.3",
|
||||
"@next/bundle-analyzer": "16.4.5",
|
||||
"@playwright/test": "^1.64.1",
|
||||
"@stryker-mutator/core": "10.1.1",
|
||||
"@stryker-mutator/vitest-runner": "10.0.0",
|
||||
@@ -125,7 +125,7 @@
|
||||
"@vitest/coverage-v8": "^5.0.5",
|
||||
"ajv": "^8.20.0",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.4.3",
|
||||
"eslint-config-next": "16.4.5",
|
||||
"fast-check": "4.10.2",
|
||||
"knip": "^6.44.1",
|
||||
"lefthook": "^2.2.2",
|
||||
|
||||
Generated
+113
-111
@@ -71,8 +71,8 @@ importers:
|
||||
specifier: ^2.2.2
|
||||
version: 2.2.2
|
||||
'@parse/node-apn':
|
||||
specifier: ^8.1.1
|
||||
version: 8.1.1(supports-color@7.2.0)
|
||||
specifier: ^8.2.0
|
||||
version: 8.2.0(supports-color@7.2.0)
|
||||
'@prisma/adapter-pg':
|
||||
specifier: ^7.10.1
|
||||
version: 7.10.1
|
||||
@@ -86,11 +86,11 @@ importers:
|
||||
specifier: ^14.0.3
|
||||
version: 14.0.3
|
||||
'@tanstack/react-query':
|
||||
specifier: 5.105.1
|
||||
version: 5.105.1(@types/react@19.3.0)(react@19.3.1)
|
||||
specifier: 5.105.3
|
||||
version: 5.105.3(@types/react@19.3.0)(react@19.3.1)
|
||||
'@tanstack/react-virtual':
|
||||
specifier: ^3.14.25
|
||||
version: 3.14.25(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
|
||||
specifier: ^3.14.26
|
||||
version: 3.14.26(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
|
||||
'@types/sax':
|
||||
specifier: ^1.2.7
|
||||
version: 1.2.7
|
||||
@@ -110,8 +110,8 @@ importers:
|
||||
specifier: ^0.8.3
|
||||
version: 0.8.3
|
||||
jose:
|
||||
specifier: ^6.2.14
|
||||
version: 6.2.14
|
||||
specifier: ^6.2.15
|
||||
version: 6.2.15
|
||||
jspdf:
|
||||
specifier: ^4.2.1
|
||||
version: 4.2.1
|
||||
@@ -119,20 +119,20 @@ importers:
|
||||
specifier: ^5.0.9
|
||||
version: 5.0.9(jspdf@4.2.1)
|
||||
lucide-react:
|
||||
specifier: ^1.66.0
|
||||
version: 1.66.0(@types/react@19.3.0)(react@19.3.1)
|
||||
specifier: ^1.67.0
|
||||
version: 1.67.0(@types/react@19.3.0)(react@19.3.1)
|
||||
mmdb-lib:
|
||||
specifier: ^3.0.3
|
||||
version: 3.0.3
|
||||
specifier: ^3.0.4
|
||||
version: 3.0.4
|
||||
next:
|
||||
specifier: 16.4.3
|
||||
version: 16.4.3(@babel/core@8.0.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.64.1)(@types/node@26.6.6)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
|
||||
specifier: 16.4.5
|
||||
version: 16.4.5(@babel/core@7.29.7(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.64.1)(@types/node@26.6.6)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
|
||||
nodemailer:
|
||||
specifier: 10.3.1
|
||||
version: 10.3.1
|
||||
otpauth:
|
||||
specifier: ^9.5.2
|
||||
version: 9.5.2
|
||||
specifier: ^9.6.0
|
||||
version: 9.6.0
|
||||
p-limit:
|
||||
specifier: ^7.3.3
|
||||
version: 7.3.3
|
||||
@@ -201,8 +201,8 @@ importers:
|
||||
specifier: ^4.14.0
|
||||
version: 4.14.0(playwright-core@1.64.1)
|
||||
'@next/bundle-analyzer':
|
||||
specifier: 16.4.3
|
||||
version: 16.4.3
|
||||
specifier: 16.4.5
|
||||
version: 16.4.5
|
||||
'@playwright/test':
|
||||
specifier: ^1.64.1
|
||||
version: 1.64.1
|
||||
@@ -255,8 +255,8 @@ importers:
|
||||
specifier: ^9
|
||||
version: 9.39.3(jiti@2.7.0)(supports-color@7.2.0)
|
||||
eslint-config-next:
|
||||
specifier: 16.4.3
|
||||
version: 16.4.3(@typescript-eslint/parser@8.71.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
|
||||
specifier: 16.4.5
|
||||
version: 16.4.5(@typescript-eslint/parser@8.71.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
|
||||
fast-check:
|
||||
specifier: 4.10.2
|
||||
version: 4.10.2
|
||||
@@ -1329,63 +1329,63 @@ packages:
|
||||
'@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4
|
||||
'@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4
|
||||
|
||||
'@next/bundle-analyzer@16.4.3':
|
||||
resolution: {integrity: sha512-FGcLziT2rqvGZTxZUjnCzwjCANnigN9vrbCm+1hQtzXbbtc1bXBTRmkXohVqyB/ReF5bOjpM2bNJQhz0PfLk9Q==}
|
||||
'@next/bundle-analyzer@16.4.5':
|
||||
resolution: {integrity: sha512-kIlb+9t1i/Zg7yuRXN7I9nhEb92UGbnMfIzpZPps7YoCgmneCMpmN6zG6mduUluhI+y00MGLytkDsccVCujrGw==}
|
||||
|
||||
'@next/env@16.4.3':
|
||||
resolution: {integrity: sha512-a7tz1QCcWWkwC/rPJcPMJk/UNRu9CFhDd3OHYE5hddbJrs8BqRTalxI6GlKCWEGzRLHN+S/B7a4PAHCtm25aBQ==}
|
||||
'@next/env@16.4.5':
|
||||
resolution: {integrity: sha512-JwSPwpdkZL5VMl2jA/q+JI8MZdweAL3xmw5/tb9y/TLtmS0YXZnmYDzSihwk8xTj1OL+WM53gTqaj96yuLp6Xw==}
|
||||
|
||||
'@next/eslint-plugin-next@16.4.3':
|
||||
resolution: {integrity: sha512-oxuC06wt/71h6ACYCV9GbKOfnf5a7VlRe/p80YHTut15TNgDEmynDnaq8t/xhy5rlvk7dhItIPs6H3rvIM4kyw==}
|
||||
'@next/eslint-plugin-next@16.4.5':
|
||||
resolution: {integrity: sha512-dNmc4y4En/34tWxaBFF2tG/R9GeiRwVPf7v8Az/Pqk8FcT4Uv0ePmrZKvWBL2nFy1QprPgJW+6XjfyV6jbRV3Q==}
|
||||
|
||||
'@next/swc-darwin-arm64@16.4.3':
|
||||
resolution: {integrity: sha512-4yq5EXBt+VDtAZwTyfijaFp5D2C5oz3Ikw5a2kiX2K9nw72vrZX2q5UuAlkPtUMO1tQz6JTKpaKWjS7OOefTDQ==}
|
||||
'@next/swc-darwin-arm64@16.4.5':
|
||||
resolution: {integrity: sha512-W5TniBzWKASbNNwHZ/S/f1MxQUXU/LdjklVMWA0Ujbfl7SZnUzFls45hO0i/sF7Bx1jJBWp7WWmoQajXtX/O8A==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@next/swc-darwin-x64@16.4.3':
|
||||
resolution: {integrity: sha512-1Rvkn/LUhnEF8WxnRkOrqGhbwXui1bkWFxONKIbN+r7C+9KHDSZ+iUoj4CzymVMWUyZIyx9szcb9+kbKhD500A==}
|
||||
'@next/swc-darwin-x64@16.4.5':
|
||||
resolution: {integrity: sha512-uWq4qkHapBDS1oFltWS4YxTb9pLHAtLNcpbuK6SmRsebLYZqpBML/j/C0op+50JYGTpZgVL/DWNt/6oWlamrrw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@next/swc-linux-arm64-gnu@16.4.3':
|
||||
resolution: {integrity: sha512-dCKpXErGg3abUM6t5JTJV/ottVBTStJB5lRJTbgdUdQaQ+jNHiK56Hsc0YAEg/rinSh42hYG3HWiYmEMdilnxg==}
|
||||
'@next/swc-linux-arm64-gnu@16.4.5':
|
||||
resolution: {integrity: sha512-FdRcyOx5u5P51mlAqLB9EXdd8jZO5fsdHtk4BAQSkqOywCsbTNN/rmw+jO7pnmjNdAtu2AG46NzbCL2FPDrrgw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-arm64-musl@16.4.3':
|
||||
resolution: {integrity: sha512-WwYbhWkhYBvBtib1K4W0kL8MW2snBD2A5b3SN4vPpqalZbZs5+bQ1yzAdepVoOqrFfEW3dmREy4Zs1MrWcrFbw==}
|
||||
'@next/swc-linux-arm64-musl@16.4.5':
|
||||
resolution: {integrity: sha512-cyvmmgtRXR8aGVT86wi9Py53IlfyCx7P2fZxuAXAoTummDnhKtUyjrMeXfapqp4qgD+gT5RqyGD/VfA16jEK7Q==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-linux-x64-gnu@16.4.3':
|
||||
resolution: {integrity: sha512-QymWvfUwDEC5/lpJOsxX5VAbyoR2D13+2Jsjz4be7gT4LPqEgdpkeLIR/HFfCLgKzoMhpJbp6I/JnogyNZHcaA==}
|
||||
'@next/swc-linux-x64-gnu@16.4.5':
|
||||
resolution: {integrity: sha512-hPc+Fl+N22DAvqZHq559NmamXboOX5Lu8I5Wl0DezfrhTk1Zz21NQeYoZDmNI/NjTcrdsVxM72Fzn2lI+Q+vAw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-x64-musl@16.4.3':
|
||||
resolution: {integrity: sha512-Vmjd9D7DpA4Vv3cUlr82QWgJD/BQZmns801vtC5VWHg6jCIA4a5vMKpQnfrM2pyCrp2eV0ENdUg4PlN3bAc2Yw==}
|
||||
'@next/swc-linux-x64-musl@16.4.5':
|
||||
resolution: {integrity: sha512-gbACYE8IP/jlprNTpiWXHb8N9CofOzpnEwH2SrIz1dUbiJf1FbDssDupCAPeP1Taxe8mgG20sGZamJI7KFOdZw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-win32-arm64-msvc@16.4.3':
|
||||
resolution: {integrity: sha512-lG1pPdPLk7gTyGb00n5JIMLdBJgjb+UEhSdZvFbhUMg0DyC6z4B7RnpSKF9VCd4imkLObVbXOFA0kFCpw7yFow==}
|
||||
'@next/swc-win32-arm64-msvc@16.4.5':
|
||||
resolution: {integrity: sha512-+8gj++ZMo8H20Xh2ijjNqyFk5LPLw0AC4Fs4Tq/G94rjjQXhOjN4jgSWsWKPFsyY26hfIeemJIwKtMxOsp5GBA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@next/swc-win32-x64-msvc@16.4.3':
|
||||
resolution: {integrity: sha512-NmQC9RkJdnPXRaMxtkCzobB3teVjqF33oSOnwDfi+XYGWWYpt+xlGK3mtktSKLbdjwaWbJu2OkkMiRFGUUncKg==}
|
||||
'@next/swc-win32-x64-msvc@16.4.5':
|
||||
resolution: {integrity: sha512-qNZj66pYXnaWLiF9R9GUbfBdrCv7kC6TaZfIwB2KH5WPc/8pEM3nVOj8hpQhWJU8S94rlH5MPDcX15+RrgUhiw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
@@ -1743,9 +1743,9 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@parse/node-apn@8.1.1':
|
||||
resolution: {integrity: sha512-PXhWpSZiMSwxqJm9bR88bYcZGfe8JhauEEcUSbPlgwEEzGExK23Zxfj1a/9zy9/JWxP5VcULUVCvezJqV9mwUw==}
|
||||
engines: {node: 20 || 22 || 24}
|
||||
'@parse/node-apn@8.2.0':
|
||||
resolution: {integrity: sha512-GL997bYw9Q4P9RINbcSsn4N3NNjTSqS4kCxCPRrOTOJP6kj9fCvogu4E6y4RxQBkJADN4D23u3yavb99xDT/9g==}
|
||||
engines: {node: 20 || 22 || 24 || 26}
|
||||
|
||||
'@peculiar/asn1-android@2.9.4':
|
||||
resolution: {integrity: sha512-SYHm4SoWSI0nRCoos6jpGusIqhPH9bbGBqv7ohlZ+H6BunrDzzQPk2ePgDuEUzV82OdvbLgtW4twUDwhU9P3YQ==}
|
||||
@@ -2957,11 +2957,11 @@ packages:
|
||||
'@tailwindcss/postcss@4.3.3':
|
||||
resolution: {integrity: sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==}
|
||||
|
||||
'@tanstack/query-core@5.105.1':
|
||||
resolution: {integrity: sha512-B/rNlZoZ54w1dlddiUt9oEe1SLM+37c00SKLJEg9YH+7as3Y54P81o9Fh+2ujZ36tXPnQJ5oVe7AcyEWkyFhPg==}
|
||||
'@tanstack/query-core@5.105.3':
|
||||
resolution: {integrity: sha512-v1rbNy7MB/4Akth7CQH+CerV13K7Ok9hAusEsfz4sCjDdu3/kOSJPoWfoB+YjdEX1eBFBWKNAGE5nqZCgdlHDw==}
|
||||
|
||||
'@tanstack/react-query@5.105.1':
|
||||
resolution: {integrity: sha512-qcu+/+PtBOjW8qU4/0l47QVmNG7hPOWsCLzP0bTRhE1X7HYa757C4732wXTUH0QhCjTV1yedX8onsCiEwlNYrA==}
|
||||
'@tanstack/react-query@5.105.3':
|
||||
resolution: {integrity: sha512-dj5hGWZmwoc9NG4enoXuYbiYwb5FxgJYPVv06eUgicMTfEMTRXn08Xv9D/r1V8TfjAv+BpxdvZFzJ1h3hSWZcQ==}
|
||||
peerDependencies:
|
||||
'@types/react': ^18 || ^19
|
||||
react: ^18 || ^19
|
||||
@@ -2969,14 +2969,14 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@tanstack/react-virtual@3.14.25':
|
||||
resolution: {integrity: sha512-LYNLaogi8NZUqjPgJ0byKQrXprkDJsZLd94u0i1UpYtXz2fgvbP4KrHygM4MSgSAvEaqykQofYdJ1Vuf7XIz/Q==}
|
||||
'@tanstack/react-virtual@3.14.26':
|
||||
resolution: {integrity: sha512-HUDlfsrYH6ko5xH56L62Mcl/uzT+AGVNMHFuDNqDJMdZWc1AQa2oeVKNUvZ/RWBZQ4TYtyQerFrNvLnp39h65A==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@tanstack/virtual-core@3.19.10':
|
||||
resolution: {integrity: sha512-/ltpH0qUCs5XQYE7fIhgA+aqgC5NzSfLTDQkC+N1wOOdvIYx1FH/kz2LGfSeanNbekqy3WBu6mNIbqXhxE/pew==}
|
||||
'@tanstack/virtual-core@3.19.11':
|
||||
resolution: {integrity: sha512-/5iHb0RHBvj8Di59FDs4ESNm45jls33aZAi4zccVXZjc/6ZvwQP6nzEYeT6weXjTf9E6/vrp2JhYWRyO+ZP13A==}
|
||||
|
||||
'@tesseract.js-data/deu@1.0.0':
|
||||
resolution: {integrity: sha512-3ter1p2gFAiCd5CMC+PodZ9YY2UDomkKj+ytlKeeZqOUmhLx1SDnYPf5zog0g5MBbxYveW+8omI3PUflqHfZ4Q==}
|
||||
@@ -4272,8 +4272,8 @@ packages:
|
||||
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
eslint-config-next@16.4.3:
|
||||
resolution: {integrity: sha512-j9IqBma6abp800frdUURaizYMgXOK+jNGyq6CsYcM9AxpSJs5D/TgMNGeAmRUilZvA7g9V1Tr71gNg9/SVTuMQ==}
|
||||
eslint-config-next@16.4.5:
|
||||
resolution: {integrity: sha512-mvh39zYLoNjpT5tVmf8xZu7i5lEOb5jN5cW6qD9OpeCp4GEEOeYta0Z2XyKiD0pYdlvm5/Td6Bi890maPRlIjg==}
|
||||
peerDependencies:
|
||||
eslint: '>=9.0.0'
|
||||
typescript: '>=3.3.1'
|
||||
@@ -5057,11 +5057,8 @@ packages:
|
||||
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
||||
hasBin: true
|
||||
|
||||
jose@6.2.13:
|
||||
resolution: {integrity: sha512-7TIg10ZE1x82YediN+lcrPNvGMKge9yFHQD+Qho/b8y2adfugU3AsvvHfxV0fzyFllT6l9xCjf4RWY9g/Z9PJQ==}
|
||||
|
||||
jose@6.2.14:
|
||||
resolution: {integrity: sha512-3JasLuki1NRLQvY4wCoC37KZk4qxoBx2k2aFqLxT84IezqW0bkJKnNtxnZj/Fu1xsQivoc/lytxaRHJoGLjJ1A==}
|
||||
jose@6.2.15:
|
||||
resolution: {integrity: sha512-qdF6ewcZARtef63fJ9AKKZPgxw+t7USH6HLXYcD4CrdmSvDp79rpJSPFEKc90QBeGS63u1KQtN2MZQHC0yDIzA==}
|
||||
|
||||
js-md4@0.3.2:
|
||||
resolution: {integrity: sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==}
|
||||
@@ -5440,8 +5437,8 @@ packages:
|
||||
resolution: {integrity: sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==}
|
||||
engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'}
|
||||
|
||||
lucide-react@1.66.0:
|
||||
resolution: {integrity: sha512-8zjbi/Us+ETXqfBwmXF/FHrWGIqOdE6zT1yUdRJLQVoptomSkB0zdFWVIM667t9K8djWDaXdkBkqi2qVTuDNiw==}
|
||||
lucide-react@1.67.0:
|
||||
resolution: {integrity: sha512-z7kd21YNm/Gw3E7mTnq5dTuvV+yc+OEwA//oDENSG1SbZ/NVgKdjxALJFNdGoC0Ji7HaJR2Dha9ev7VUPxy9CQ==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
@@ -5538,8 +5535,8 @@ packages:
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
mmdb-lib@3.0.3:
|
||||
resolution: {integrity: sha512-xQPoBXcNjjHiOvOraFBKtA++uNWF6aCVHL9dRKFXEov8eI3QJwtgiw3qApsonFT5SpoqsEVISUTg3HIDs2DiXw==}
|
||||
mmdb-lib@3.0.4:
|
||||
resolution: {integrity: sha512-tWy4b2UQaJjRhW/VGpy7ypMMgS7JfPezMsmVW7/utlWm6vv1JgsxXkmjuv/SjN6tsG8YrKHfJ00zHhrOCQDhjg==}
|
||||
engines: {node: '>=10', npm: '>=6'}
|
||||
|
||||
mrmime@2.0.1:
|
||||
@@ -5611,8 +5608,8 @@ packages:
|
||||
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
next@16.4.3:
|
||||
resolution: {integrity: sha512-OqxKTGi9u80FydydsMNYNjnuje9Au+kvHlt2GZ//ftdpDHm1dxKqAaENoOOTX5wKzn+9vaKUNIfZeyZUuLhCnA==}
|
||||
next@16.4.5:
|
||||
resolution: {integrity: sha512-rmdfsWosjTUO+6R0S3KokoIk6Kj0sWDRTe86tZHdqaWH/oXq/udX3/4xb+YQi7aWbyEFoglDU54kaifLCdXbBw==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -5755,8 +5752,8 @@ packages:
|
||||
resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
otpauth@9.5.2:
|
||||
resolution: {integrity: sha512-GQ5emWR/x1tcExT62IBT0UfO95wZzJZyxYOJOGVeQF47SYEN9vmh0vISvDZaNMuFJRG+IaWCKtfm+t9Bfoal6w==}
|
||||
otpauth@9.6.0:
|
||||
resolution: {integrity: sha512-qQXHTxcXohDhQjF1ZEbUVEpTIssFL2rd+2kHfw/McIVCbTLD6qAsbTCQxpXRLyeyUw6Ipjpykw4dWZL/zfIVqA==}
|
||||
|
||||
outvariant@1.4.3:
|
||||
resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==}
|
||||
@@ -6407,6 +6404,11 @@ packages:
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
semver@7.8.6:
|
||||
resolution: {integrity: sha512-9lpSSU4Mly3mrtruwLs1eLK6+gzbzqeBFwRNjIjE+9LuDpWdYctrQlQ3ej1Ilgi7gknpsTRnPoLP8DpCHqHIJw==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
send@1.2.1:
|
||||
resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
|
||||
engines: {node: '>= 18'}
|
||||
@@ -8386,7 +8388,7 @@ snapshots:
|
||||
express: 5.2.1(supports-color@7.2.0)
|
||||
express-rate-limit: 8.6.2(express@5.2.1(supports-color@7.2.0))(supports-color@7.2.0)
|
||||
hono: 4.13.13
|
||||
jose: 6.2.13
|
||||
jose: 6.2.15
|
||||
json-schema-typed: 8.0.2
|
||||
pkce-challenge: 5.0.1
|
||||
raw-body: 3.0.2
|
||||
@@ -8480,44 +8482,44 @@ snapshots:
|
||||
'@tybys/wasm-util': 0.10.4
|
||||
optional: true
|
||||
|
||||
'@next/bundle-analyzer@16.4.3':
|
||||
'@next/bundle-analyzer@16.4.5':
|
||||
dependencies:
|
||||
webpack-bundle-analyzer: 4.10.1
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
'@next/env@16.4.3': {}
|
||||
'@next/env@16.4.5': {}
|
||||
|
||||
'@next/eslint-plugin-next@16.4.3(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))':
|
||||
'@next/eslint-plugin-next@16.4.5(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))':
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))
|
||||
fast-glob: 3.3.1
|
||||
transitivePeerDependencies:
|
||||
- eslint
|
||||
|
||||
'@next/swc-darwin-arm64@16.4.3':
|
||||
'@next/swc-darwin-arm64@16.4.5':
|
||||
optional: true
|
||||
|
||||
'@next/swc-darwin-x64@16.4.3':
|
||||
'@next/swc-darwin-x64@16.4.5':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-arm64-gnu@16.4.3':
|
||||
'@next/swc-linux-arm64-gnu@16.4.5':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-arm64-musl@16.4.3':
|
||||
'@next/swc-linux-arm64-musl@16.4.5':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-x64-gnu@16.4.3':
|
||||
'@next/swc-linux-x64-gnu@16.4.5':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-x64-musl@16.4.3':
|
||||
'@next/swc-linux-x64-musl@16.4.5':
|
||||
optional: true
|
||||
|
||||
'@next/swc-win32-arm64-msvc@16.4.3':
|
||||
'@next/swc-win32-arm64-msvc@16.4.5':
|
||||
optional: true
|
||||
|
||||
'@next/swc-win32-x64-msvc@16.4.3':
|
||||
'@next/swc-win32-x64-msvc@16.4.5':
|
||||
optional: true
|
||||
|
||||
'@noble/hashes@2.4.0': {}
|
||||
@@ -8730,7 +8732,7 @@ snapshots:
|
||||
'@oxc-resolver/binding-win32-x64-msvc@11.24.2':
|
||||
optional: true
|
||||
|
||||
'@parse/node-apn@8.1.1(supports-color@7.2.0)':
|
||||
'@parse/node-apn@8.2.0(supports-color@7.2.0)':
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@7.2.0)
|
||||
jsonwebtoken: 9.0.3
|
||||
@@ -10095,22 +10097,22 @@ snapshots:
|
||||
postcss: 8.5.26
|
||||
tailwindcss: 4.3.3
|
||||
|
||||
'@tanstack/query-core@5.105.1': {}
|
||||
'@tanstack/query-core@5.105.3': {}
|
||||
|
||||
'@tanstack/react-query@5.105.1(@types/react@19.3.0)(react@19.3.1)':
|
||||
'@tanstack/react-query@5.105.3(@types/react@19.3.0)(react@19.3.1)':
|
||||
dependencies:
|
||||
'@tanstack/query-core': 5.105.1
|
||||
'@tanstack/query-core': 5.105.3
|
||||
react: 19.3.1
|
||||
optionalDependencies:
|
||||
'@types/react': 19.3.0
|
||||
|
||||
'@tanstack/react-virtual@3.14.25(react-dom@19.3.1(react@19.3.1))(react@19.3.1)':
|
||||
'@tanstack/react-virtual@3.14.26(react-dom@19.3.1(react@19.3.1))(react@19.3.1)':
|
||||
dependencies:
|
||||
'@tanstack/virtual-core': 3.19.10
|
||||
'@tanstack/virtual-core': 3.19.11
|
||||
react: 19.3.1
|
||||
react-dom: 19.3.1(react@19.3.1)
|
||||
|
||||
'@tanstack/virtual-core@3.19.10': {}
|
||||
'@tanstack/virtual-core@3.19.11': {}
|
||||
|
||||
'@tesseract.js-data/deu@1.0.0': {}
|
||||
|
||||
@@ -10357,7 +10359,7 @@ snapshots:
|
||||
'@typescript-eslint/visitor-keys': 8.71.0
|
||||
debug: 4.4.3(supports-color@7.2.0)
|
||||
minimatch: 10.2.6
|
||||
semver: 7.8.5
|
||||
semver: 7.8.6
|
||||
tinyglobby: 0.2.17
|
||||
ts-api-utils: 2.5.0(typescript@6.0.3)
|
||||
typescript: 6.0.3
|
||||
@@ -11084,7 +11086,7 @@ snapshots:
|
||||
json-schema-typed: 7.0.3
|
||||
onetime: 5.1.2
|
||||
pkg-up: 3.1.0
|
||||
semver: 7.8.5
|
||||
semver: 7.8.6
|
||||
|
||||
confbox@0.2.4: {}
|
||||
|
||||
@@ -11518,9 +11520,9 @@ snapshots:
|
||||
|
||||
escape-string-regexp@4.0.0: {}
|
||||
|
||||
eslint-config-next@16.4.3(@typescript-eslint/parser@8.71.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3):
|
||||
eslint-config-next@16.4.5(@typescript-eslint/parser@8.71.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3):
|
||||
dependencies:
|
||||
'@next/eslint-plugin-next': 16.4.3(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))
|
||||
'@next/eslint-plugin-next': 16.4.5(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))
|
||||
eslint: 9.39.3(jiti@2.7.0)(supports-color@7.2.0)
|
||||
eslint-import-resolver-node: 0.3.10(supports-color@7.2.0)
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)
|
||||
@@ -12386,9 +12388,7 @@ snapshots:
|
||||
|
||||
jiti@2.7.0: {}
|
||||
|
||||
jose@6.2.13: {}
|
||||
|
||||
jose@6.2.14: {}
|
||||
jose@6.2.15: {}
|
||||
|
||||
js-md4@0.3.2: {}
|
||||
|
||||
@@ -12439,7 +12439,7 @@ snapshots:
|
||||
lodash.isstring: 4.0.1
|
||||
lodash.once: 4.1.1
|
||||
ms: 2.1.3
|
||||
semver: 7.8.5
|
||||
semver: 7.8.6
|
||||
|
||||
jspdf-autotable@5.0.9(jspdf@4.2.1):
|
||||
dependencies:
|
||||
@@ -12711,7 +12711,7 @@ snapshots:
|
||||
|
||||
lru.min@1.1.5: {}
|
||||
|
||||
lucide-react@1.66.0(@types/react@19.3.0)(react@19.3.1):
|
||||
lucide-react@1.67.0(@types/react@19.3.0)(react@19.3.1):
|
||||
dependencies:
|
||||
react: 19.3.1
|
||||
optionalDependencies:
|
||||
@@ -12786,7 +12786,7 @@ snapshots:
|
||||
|
||||
mkdirp@3.0.1: {}
|
||||
|
||||
mmdb-lib@3.0.3: {}
|
||||
mmdb-lib@3.0.4: {}
|
||||
|
||||
mrmime@2.0.1: {}
|
||||
|
||||
@@ -12860,25 +12860,25 @@ snapshots:
|
||||
|
||||
negotiator@1.0.0: {}
|
||||
|
||||
next@16.4.3(@babel/core@8.0.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.64.1)(@types/node@26.6.6)(react-dom@19.3.1(react@19.3.1))(react@19.3.1):
|
||||
next@16.4.5(@babel/core@7.29.7(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.64.1)(@types/node@26.6.6)(react-dom@19.3.1(react@19.3.1))(react@19.3.1):
|
||||
dependencies:
|
||||
'@next/env': 16.4.3
|
||||
'@next/env': 16.4.5
|
||||
'@swc/helpers': 0.5.23
|
||||
baseline-browser-mapping: 2.11.27
|
||||
caniuse-lite: 1.0.30001815
|
||||
postcss: 8.5.23
|
||||
react: 19.3.1
|
||||
react-dom: 19.3.1(react@19.3.1)
|
||||
styled-jsx: 5.1.6(@babel/core@8.0.7)(react@19.3.1)
|
||||
styled-jsx: 5.1.6(@babel/core@7.29.7(supports-color@7.2.0))(react@19.3.1)
|
||||
optionalDependencies:
|
||||
'@next/swc-darwin-arm64': 16.4.3
|
||||
'@next/swc-darwin-x64': 16.4.3
|
||||
'@next/swc-linux-arm64-gnu': 16.4.3
|
||||
'@next/swc-linux-arm64-musl': 16.4.3
|
||||
'@next/swc-linux-x64-gnu': 16.4.3
|
||||
'@next/swc-linux-x64-musl': 16.4.3
|
||||
'@next/swc-win32-arm64-msvc': 16.4.3
|
||||
'@next/swc-win32-x64-msvc': 16.4.3
|
||||
'@next/swc-darwin-arm64': 16.4.5
|
||||
'@next/swc-darwin-x64': 16.4.5
|
||||
'@next/swc-linux-arm64-gnu': 16.4.5
|
||||
'@next/swc-linux-arm64-musl': 16.4.5
|
||||
'@next/swc-linux-x64-gnu': 16.4.5
|
||||
'@next/swc-linux-x64-musl': 16.4.5
|
||||
'@next/swc-win32-arm64-msvc': 16.4.5
|
||||
'@next/swc-win32-x64-msvc': 16.4.5
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@playwright/test': 1.64.1
|
||||
sharp: 0.35.5(@types/node@26.6.6)
|
||||
@@ -13021,7 +13021,7 @@ snapshots:
|
||||
string-width: 7.2.0
|
||||
strip-ansi: 7.2.0
|
||||
|
||||
otpauth@9.5.2:
|
||||
otpauth@9.6.0:
|
||||
dependencies:
|
||||
'@noble/hashes': 2.4.0
|
||||
|
||||
@@ -13730,6 +13730,8 @@ snapshots:
|
||||
|
||||
semver@7.8.5: {}
|
||||
|
||||
semver@7.8.6: {}
|
||||
|
||||
send@1.2.1(supports-color@7.2.0):
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@7.2.0)
|
||||
@@ -14090,12 +14092,12 @@ snapshots:
|
||||
|
||||
strip-json-comments@5.0.3: {}
|
||||
|
||||
styled-jsx@5.1.6(@babel/core@8.0.7)(react@19.3.1):
|
||||
styled-jsx@5.1.6(@babel/core@7.29.7(supports-color@7.2.0))(react@19.3.1):
|
||||
dependencies:
|
||||
client-only: 0.0.1
|
||||
react: 19.3.1
|
||||
optionalDependencies:
|
||||
'@babel/core': 8.0.7
|
||||
'@babel/core': 7.29.7(supports-color@7.2.0)
|
||||
|
||||
supports-color@7.2.0:
|
||||
dependencies:
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ try {
|
||||
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.47.0";
|
||||
/* @sw-version-fallback */ "v1.47.1";
|
||||
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
* One transaction: the live feed is revoked and its successor minted with the
|
||||
* same options, so there is never a moment with two live links or with none.
|
||||
* Calendars holding the old link stop updating on their next poll. The new
|
||||
* URL is returned exactly once.
|
||||
* URL is returned exactly once. Two rotations racing both revoke the same
|
||||
* feed, and the second successor then meets the one-live-feed index: that
|
||||
* one gets the mint path's 409 `cycle.feed.exists`, and the winner's link
|
||||
* stands.
|
||||
*
|
||||
* Same gates as turning the subscription on: cookie session only, the owner or
|
||||
* the guardian of a managed profile (`requireGuardianAuth`), a fresh proof,
|
||||
@@ -26,6 +29,7 @@ import {
|
||||
FEED_MINT_LIMIT_PER_HOUR,
|
||||
FEED_MINT_WINDOW_MS,
|
||||
cookieOnlyRefusal,
|
||||
feedExists,
|
||||
feedLinks,
|
||||
noLiveFeed,
|
||||
} from "@/lib/cycle/feed/manage";
|
||||
@@ -34,6 +38,7 @@ import { mintFeedToken } from "@/lib/cycle/feed/token";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { resolveOrigin } from "@/lib/http/origin";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { isP2002 } from "@/lib/prisma-errors";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
@@ -63,46 +68,52 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
const minted = mintFeedToken();
|
||||
const rotated = await prisma.$transaction(async (tx) => {
|
||||
const live = await tx.cycleCalendarFeed.findFirst({
|
||||
where: { userId: user.id, revokedAt: null },
|
||||
select: {
|
||||
id: true,
|
||||
horizonCycles: true,
|
||||
historyCycles: true,
|
||||
includeFertile: true,
|
||||
titleMode: true,
|
||||
},
|
||||
});
|
||||
if (!live) return null;
|
||||
await tx.cycleCalendarFeed.update({
|
||||
where: { id: live.id },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
const created = await tx.cycleCalendarFeed.create({
|
||||
data: {
|
||||
const rotated = await prisma
|
||||
.$transaction(async (tx) => {
|
||||
const live = await tx.cycleCalendarFeed.findFirst({
|
||||
where: { userId: user.id, revokedAt: null },
|
||||
select: {
|
||||
id: true,
|
||||
horizonCycles: true,
|
||||
historyCycles: true,
|
||||
includeFertile: true,
|
||||
titleMode: true,
|
||||
},
|
||||
});
|
||||
if (!live) return null;
|
||||
await tx.cycleCalendarFeed.update({
|
||||
where: { id: live.id },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
const created = await tx.cycleCalendarFeed.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
createdById: actor.id,
|
||||
tokenHash: minted.hash,
|
||||
tokenPrefix: minted.prefix,
|
||||
horizonCycles: live.horizonCycles,
|
||||
historyCycles: live.historyCycles,
|
||||
includeFertile: live.includeFertile,
|
||||
titleMode: live.titleMode,
|
||||
},
|
||||
select: FEED_SUMMARY_SELECT,
|
||||
});
|
||||
await auditLog("cycle.feed.rotate", {
|
||||
userId: user.id,
|
||||
createdById: actor.id,
|
||||
tokenHash: minted.hash,
|
||||
tokenPrefix: minted.prefix,
|
||||
horizonCycles: live.horizonCycles,
|
||||
historyCycles: live.historyCycles,
|
||||
includeFertile: live.includeFertile,
|
||||
titleMode: live.titleMode,
|
||||
},
|
||||
select: FEED_SUMMARY_SELECT,
|
||||
details: {
|
||||
feedId: created.id,
|
||||
previousFeedId: live.id,
|
||||
tokenPrefix: created.tokenPrefix,
|
||||
},
|
||||
client: tx,
|
||||
});
|
||||
return created;
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (isP2002(err)) return "raced" as const;
|
||||
throw err;
|
||||
});
|
||||
await auditLog("cycle.feed.rotate", {
|
||||
userId: user.id,
|
||||
details: {
|
||||
feedId: created.id,
|
||||
previousFeedId: live.id,
|
||||
tokenPrefix: created.tokenPrefix,
|
||||
},
|
||||
client: tx,
|
||||
});
|
||||
return created;
|
||||
});
|
||||
if (rotated === "raced") return feedExists();
|
||||
if (!rotated) return noLiveFeed();
|
||||
|
||||
return apiSuccess(
|
||||
|
||||
@@ -146,7 +146,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
}
|
||||
|
||||
const range = normaliseDateRange(input.range ?? undefined);
|
||||
const userTz = await resolveUserTimezone(user.id);
|
||||
const range = normaliseDateRange(input.range, undefined, userTz);
|
||||
const practiceName = sanitisePracticeName(input.practiceName);
|
||||
const includeCharts = input.includeCharts ?? true;
|
||||
|
||||
@@ -168,38 +169,36 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
input.germanAtc ??
|
||||
(GERMAN_ATC_DEFAULT_LOCALES as readonly string[]).includes(locale);
|
||||
|
||||
const [data, userTz, userRow, allergyRows, familyHistoryRows] =
|
||||
await Promise.all([
|
||||
collectDoctorReportData(user.id, range, selection, { practiceName }),
|
||||
resolveUserTimezone(user.id),
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
insuranceNumberEncrypted: true,
|
||||
timeFormat: true,
|
||||
// Issue #922 — the exported PDF is spelled the way the person
|
||||
// who exported it reads dates, not the locale default.
|
||||
dateFormat: true,
|
||||
},
|
||||
}),
|
||||
// Structured records are always-available reference data (not
|
||||
// time-windowed), so they ride alongside the report rather than through
|
||||
// the aggregator. Owner-scoped, live rows only — and each read is gated
|
||||
// on its own leaf, so a deselected section is not loaded, not merely
|
||||
// dropped later.
|
||||
selection.has("ALLERGIES")
|
||||
? prisma.allergy.findMany({
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
selection.has("FAMILY_HISTORY")
|
||||
? prisma.familyHistoryEntry.findMany({
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
const [data, userRow, allergyRows, familyHistoryRows] = await Promise.all([
|
||||
collectDoctorReportData(user.id, range, selection, { practiceName }),
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
insuranceNumberEncrypted: true,
|
||||
timeFormat: true,
|
||||
// Issue #922 — the exported PDF is spelled the way the person
|
||||
// who exported it reads dates, not the locale default.
|
||||
dateFormat: true,
|
||||
},
|
||||
}),
|
||||
// Structured records are always-available reference data (not
|
||||
// time-windowed), so they ride alongside the report rather than through
|
||||
// the aggregator. Owner-scoped, live rows only — and each read is gated
|
||||
// on its own leaf, so a deselected section is not loaded, not merely
|
||||
// dropped later.
|
||||
selection.has("ALLERGIES")
|
||||
? prisma.allergy.findMany({
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
selection.has("FAMILY_HISTORY")
|
||||
? prisma.familyHistoryEntry.findMany({
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
const fhirRecords = {
|
||||
allergies: allergyRows.map(toAllergyDTO),
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* `GET /api/sync/changes?start=now` (#1339) — a cursor at the present.
|
||||
*
|
||||
* The answer is an empty page whose cursor sits at `serverNow` in every
|
||||
* domain, in the feed's own opaque format, and nothing is read: the point is
|
||||
* The answer is an empty page whose cursor sits a minute before `serverNow`
|
||||
* in every domain (a write stamped before it and committed after it is not
|
||||
* skipped), in the feed's own opaque format, and nothing is read: the point is
|
||||
* to skip the history walk, so a read here would be the bug. The database is
|
||||
* deliberately not mocked and not reachable: any read would fail the request,
|
||||
* so the 200 below is itself the proof that none happened.
|
||||
@@ -54,7 +55,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("GET /api/sync/changes?start=now", () => {
|
||||
it("answers an empty page with a cursor at serverNow in every domain", async () => {
|
||||
it("answers an empty page with a cursor a minute before serverNow in every domain", async () => {
|
||||
const before = Date.now();
|
||||
const res = await GET(req("?start=now"));
|
||||
const after = Date.now();
|
||||
@@ -80,7 +81,10 @@ describe("GET /api/sync/changes?start=now", () => {
|
||||
const cursor = decodeCursor(data.cursor);
|
||||
expect(cursor).not.toBeNull();
|
||||
for (const domain of SYNC_DOMAINS) {
|
||||
expect(cursor![domain], domain).toEqual({ updatedAtMs: nowMs, id: "0" });
|
||||
expect(cursor![domain], domain).toEqual({
|
||||
updatedAtMs: nowMs - 60_000,
|
||||
id: "0",
|
||||
});
|
||||
}
|
||||
expect(decodeCursorIssuedAt(data.cursor)).toBe(nowMs);
|
||||
});
|
||||
|
||||
@@ -43,8 +43,9 @@
|
||||
* cycle tracking off gets both blocks empty (never a 403 — see the
|
||||
* gate note in the handler) and their cursor watermarks untouched.
|
||||
*
|
||||
* `start=now` (#1339) answers an empty page whose cursor sits at
|
||||
* `serverNow` in every domain, in the same opaque format, so a client that
|
||||
* `start=now` (#1339) answers an empty page whose cursor sits a minute
|
||||
* before `serverNow` in every domain (`START_NOW_OVERLAP_MS`), in the same
|
||||
* opaque format, so a client that
|
||||
* only wants what changes from now on (the Apple Health write-back) pages
|
||||
* forward from there instead of draining the whole history first.
|
||||
*
|
||||
|
||||
@@ -70,6 +70,7 @@ export const GET = apiHandler(async (request: Request) => {
|
||||
items: timeline.lanes.reduce((n, l) => n + l.items.length, 0),
|
||||
series: timeline.series.length,
|
||||
labSeries: timeline.labSeries.length,
|
||||
symptomSeries: timeline.symptomSeries.length,
|
||||
notable: timeline.notable.length,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -290,7 +290,7 @@ export function InviteTokensSection() {
|
||||
const days = daysUntil(invite.expiresAt, now);
|
||||
if (days <= 0) return "—";
|
||||
return days === 1
|
||||
? t("admin.invites.expiresInDaysOne")
|
||||
? t("admin.invites.expiresInDaysOne", { count: days })
|
||||
: t("admin.invites.expiresInDaysOther", { count: days });
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ export function TrendHint({ count }: TrendHintProps) {
|
||||
|
||||
const description =
|
||||
result.remaining === 1
|
||||
? t("trendHints.remainingOne")
|
||||
? t("trendHints.remainingOne", { count: result.remaining })
|
||||
: t("trendHints.remainingMany", { count: result.remaining });
|
||||
|
||||
return (
|
||||
|
||||
@@ -74,7 +74,9 @@ export function IllnessInsightsCard({
|
||||
<div className="space-y-4 text-sm">
|
||||
<p className="text-foreground">
|
||||
{data!.episodeCount === 1
|
||||
? t("illness.insights.countOne")
|
||||
? t("illness.insights.countOne", {
|
||||
count: data!.episodeCount,
|
||||
})
|
||||
: t("illness.insights.countOther", {
|
||||
count: data!.episodeCount,
|
||||
})}
|
||||
|
||||
@@ -77,7 +77,7 @@ function NestedEpisodes({ episodes }: { episodes?: IllnessEpisodeDTO[] }) {
|
||||
{allFlares ? (
|
||||
<p className="text-muted-foreground text-xs font-medium">
|
||||
{count === 1
|
||||
? t("illness.flares.countOne")
|
||||
? t("illness.flares.countOne", { count })
|
||||
: t("illness.flares.countOther", { count })}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -52,7 +52,7 @@ export function EcgCrossLink({ enabled = true, className }: EcgCrossLinkProps) {
|
||||
|
||||
const countText =
|
||||
count === 1
|
||||
? t("insights.ecg.crossLink.recordingsOne")
|
||||
? t("insights.ecg.crossLink.recordingsOne", { count })
|
||||
: t("insights.ecg.crossLink.recordingsMany", { count });
|
||||
const resultText = resultKey
|
||||
? t("insights.ecg.crossLink.latestResult", { result: t(resultKey) })
|
||||
|
||||
@@ -123,6 +123,7 @@ export function GlucoseAdvancedDisclosure({
|
||||
{summary.count === 1
|
||||
? t(
|
||||
"insights.bloodGlucose.clinical.advanced.byContext.readingsShortOne",
|
||||
{ count: summary.count },
|
||||
)
|
||||
: t(
|
||||
"insights.bloodGlucose.clinical.advanced.byContext.readingsShort",
|
||||
|
||||
@@ -90,14 +90,14 @@ export function GlucoseClinicalPanel() {
|
||||
const hasAny = clinical.readingCount > 0;
|
||||
const readingsLabel =
|
||||
clinical.readingCount === 1
|
||||
? t("insights.bloodGlucose.clinical.readingsCountOne")
|
||||
? t("insights.bloodGlucose.clinical.readingsCountOne", { count: 1 })
|
||||
: t("insights.bloodGlucose.clinical.readingsCount", {
|
||||
count: clinical.readingCount,
|
||||
});
|
||||
const spanDays = Math.max(1, Math.round(clinical.actualSpanDays));
|
||||
const daysLabel =
|
||||
spanDays === 1
|
||||
? t("insights.bloodGlucose.clinical.daysCountOne")
|
||||
? t("insights.bloodGlucose.clinical.daysCountOne", { count: spanDays })
|
||||
: t("insights.bloodGlucose.clinical.daysCount", { days: spanDays });
|
||||
|
||||
return (
|
||||
|
||||
@@ -183,12 +183,12 @@ export function IntradayPulseChart({
|
||||
: t("insights.intradayPulse.caption");
|
||||
|
||||
const coverageNote = coverage
|
||||
? coverage.readingCount === 1
|
||||
? t("insights.intradayPulse.coverageOne", { hours: coverage.hours })
|
||||
: t("insights.intradayPulse.coverageMany", {
|
||||
count: coverage.readingCount,
|
||||
hours: coverage.hours,
|
||||
})
|
||||
? t(
|
||||
coverage.readingCount === 1
|
||||
? "insights.intradayPulse.coverageOne"
|
||||
: "insights.intradayPulse.coverageMany",
|
||||
{ count: coverage.readingCount, hours: coverage.hours },
|
||||
)
|
||||
: null;
|
||||
|
||||
const dayLabel = isToday
|
||||
|
||||
@@ -698,7 +698,7 @@ function MedicationTableRowItem({
|
||||
)}
|
||||
>
|
||||
{stock === 1
|
||||
? t("medications.tableStockDoseOne")
|
||||
? t("medications.tableStockDoseOne", { count: stock })
|
||||
: t("medications.tableStockDoses", { count: stock })}
|
||||
</span>
|
||||
{runwayDays !== null && (
|
||||
|
||||
@@ -82,6 +82,12 @@ interface ScheduleRevisionsResponse {
|
||||
revisions: ScheduleRevisionRow[];
|
||||
}
|
||||
|
||||
/** The "show earlier eras" toggle, by how many there are. */
|
||||
const SHOW_ERAS_KEY = {
|
||||
one: "medications.detail.zeitplan.history.showErasOne",
|
||||
other: "medications.detail.zeitplan.history.showErasOther",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Dose-time readout of one era: the union of its entries' times,
|
||||
* deduped + sorted ("07:00 / 19:00"). PRN-only eras have no times —
|
||||
@@ -284,11 +290,9 @@ export function ScheduleHistoryTimeline({
|
||||
/>
|
||||
{expanded
|
||||
? t("medications.detail.zeitplan.history.hideEras")
|
||||
: revisions.length === 1
|
||||
? t("medications.detail.zeitplan.history.showErasOne")
|
||||
: t("medications.detail.zeitplan.history.showErasOther", {
|
||||
count: revisions.length,
|
||||
})}
|
||||
: t(SHOW_ERAS_KEY[revisions.length === 1 ? "one" : "other"], {
|
||||
count: revisions.length,
|
||||
})}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
|
||||
@@ -286,6 +286,8 @@ function everyKind(): TimelineResponse {
|
||||
},
|
||||
],
|
||||
availableLabs: [],
|
||||
symptomSeries: [],
|
||||
availableSymptoms: [],
|
||||
notable: [],
|
||||
};
|
||||
// The fixture itself must reach every kind, or the guard proves less.
|
||||
|
||||
@@ -286,6 +286,8 @@ export function fullTimeline(): TimelineResponse {
|
||||
availableSeries: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "WEIGHT"],
|
||||
labSeries: [],
|
||||
availableLabs: [],
|
||||
symptomSeries: [],
|
||||
availableSymptoms: [],
|
||||
notable: [{ date: "2026-01-04", kind: "extremeHigh" }],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -854,3 +854,46 @@ describe("label room (v1.42 final)", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("layoutSeries, a symptom line (#981)", () => {
|
||||
const window = { from: "2026-01-01", to: "2026-06-30" };
|
||||
const scale = createScale(window, 164, 1200);
|
||||
const lay = (points: Array<{ t: string; mean: number; count: number }>) =>
|
||||
layoutSeries(
|
||||
[{ key: "symptom:sd1", unit: null, points }],
|
||||
0,
|
||||
window,
|
||||
scale,
|
||||
"month",
|
||||
)[0];
|
||||
|
||||
it("sits on the fixed 0 to 10 scale, whatever the window holds", () => {
|
||||
const quiet = lay([
|
||||
{ t: "2026-01-01", mean: 2, count: 4 },
|
||||
{ t: "2026-02-01", mean: 3, count: 1 },
|
||||
]);
|
||||
const loud = lay([
|
||||
{ t: "2026-01-01", mean: 2, count: 4 },
|
||||
{ t: "2026-02-01", mean: 9, count: 5 },
|
||||
]);
|
||||
// The same mean lands on the same height in both rows.
|
||||
expect(quiet.points[0].y).toBe(loud.points[0].y);
|
||||
// 0 and 10 are inside the row, 5 halfway between them.
|
||||
const mid = lay([{ t: "2026-01-01", mean: 5, count: 3 }]).points[0].y;
|
||||
const top = lay([{ t: "2026-01-01", mean: 10, count: 3 }]).points[0].y;
|
||||
const bottom = lay([{ t: "2026-01-01", mean: 0, count: 3 }]).points[0].y;
|
||||
expect(top).toBeLessThan(mid);
|
||||
expect(mid).toBeLessThan(bottom);
|
||||
expect(mid).toBeCloseTo((top + bottom) / 2, 5);
|
||||
expect(top).toBeGreaterThan(quiet.top);
|
||||
expect(bottom).toBeLessThan(quiet.top + quiet.height);
|
||||
});
|
||||
|
||||
it("draws a mean resting on fewer than three days hollow", () => {
|
||||
const line = lay([
|
||||
{ t: "2026-01-01", mean: 4, count: 2 },
|
||||
{ t: "2026-02-01", mean: 4, count: 3 },
|
||||
]);
|
||||
expect(line.points.map((p) => p.thin)).toEqual([true, false]);
|
||||
});
|
||||
});
|
||||
@@ -138,6 +138,8 @@ function timeline(values: readonly string[]): TimelineResponse {
|
||||
unit: "ng/mL",
|
||||
},
|
||||
],
|
||||
symptomSeries: [],
|
||||
availableSymptoms: [],
|
||||
notable: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -149,6 +149,8 @@ function twoMedications(): TimelineResponse {
|
||||
availableSeries: [],
|
||||
labSeries: [],
|
||||
availableLabs: [],
|
||||
symptomSeries: [],
|
||||
availableSymptoms: [],
|
||||
notable: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* The person's own symptoms on the timeline page (#981), in a real browser:
|
||||
* the page as mounted, its reads answered by a stubbed network.
|
||||
*
|
||||
* - A stored choice that names a symptom reaches the request, and the
|
||||
* symptom is drawn as a value line on the fixed 0 to 10 scale, a point
|
||||
* resting on fewer than three days drawn hollow.
|
||||
* - The values menu offers the record's logged symptoms, a hidden one
|
||||
* marked as such; choosing one adds it to the stored list and to the
|
||||
* next request.
|
||||
* - With the illness module off, no symptom is offered or requested.
|
||||
* - A point reads on its own by keyboard: the bucket, the value out of 10
|
||||
* and the days behind it.
|
||||
*
|
||||
* All data invented.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { page } from "vitest/browser";
|
||||
|
||||
import type { TimelineResponse } from "@/lib/day/contract";
|
||||
|
||||
import {
|
||||
stubFetch,
|
||||
type FetchStub,
|
||||
} from "../../../../tests/browser/fetch-stub";
|
||||
import { renderWithApp } from "../../../../tests/browser/render";
|
||||
import { setSearch } from "../../../../tests/browser/url";
|
||||
import { TimelineView } from "../timeline-view";
|
||||
|
||||
const VALUES_KEY = "healthlog.timeline.values";
|
||||
|
||||
const HEADACHE = "symptom:sd-headache";
|
||||
const NAUSEA = "symptom:sd-nausea";
|
||||
|
||||
function timeline(values: readonly string[]): TimelineResponse {
|
||||
const line = (
|
||||
key: string,
|
||||
label: string,
|
||||
isActive: boolean,
|
||||
points: Array<{ t: string; mean: number; count: number }>,
|
||||
) => ({
|
||||
key,
|
||||
definitionId: key.slice("symptom:".length),
|
||||
label,
|
||||
icon: null,
|
||||
isActive,
|
||||
scale: { min: 0 as const, max: 10 as const },
|
||||
points,
|
||||
});
|
||||
const lines = [
|
||||
line(HEADACHE, "Headache", true, [
|
||||
{ t: "2025-10-01", mean: 6.5, count: 4 },
|
||||
{ t: "2026-01-01", mean: 3, count: 1 },
|
||||
]),
|
||||
line(NAUSEA, "Nausea", false, [{ t: "2025-04-01", mean: 2, count: 2 }]),
|
||||
];
|
||||
return {
|
||||
zoom: "all",
|
||||
range: { from: "2024-01-01", to: "2026-06-30", dataFrom: "2024-01-01" },
|
||||
bucket: "quarter",
|
||||
lanes: [
|
||||
{
|
||||
key: "illness",
|
||||
items: [
|
||||
{
|
||||
id: "episode-1",
|
||||
kind: "episode",
|
||||
start: "2025-10-12",
|
||||
end: "2025-10-20",
|
||||
open: false,
|
||||
precision: "DAY",
|
||||
startKnown: true,
|
||||
label: "Flu",
|
||||
sub: null,
|
||||
href: null,
|
||||
group: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
standing: [],
|
||||
series: [],
|
||||
availableSeries: ["WEIGHT"],
|
||||
labSeries: [],
|
||||
availableLabs: [],
|
||||
symptomSeries: lines.filter((l) => values.includes(l.key)),
|
||||
availableSymptoms: lines.map(
|
||||
({ key, definitionId, label, icon, isActive }) => ({
|
||||
key,
|
||||
definitionId,
|
||||
label,
|
||||
icon,
|
||||
isActive,
|
||||
}),
|
||||
),
|
||||
notable: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** The `values` a request named, as a list. */
|
||||
function valuesOf(path: string): string[] {
|
||||
const raw = new URL(path, window.location.origin).searchParams.get("values");
|
||||
return raw ? raw.split(",") : [];
|
||||
}
|
||||
|
||||
function stubTimeline(): FetchStub {
|
||||
return stubFetch()
|
||||
.on("GET", "/api/timeline", (request) => ({
|
||||
data: timeline(valuesOf(request.path)),
|
||||
}))
|
||||
.on("GET", "/api/timeline/readiness", {
|
||||
data: { verdict: "carries", since: "2024-01-01", lanes: [] },
|
||||
})
|
||||
.on("GET", "/api/life-events", { data: { events: [] } });
|
||||
}
|
||||
|
||||
async function mount(
|
||||
stored: string[] | null,
|
||||
modules: Record<string, boolean> = { timeline: true, illness: true },
|
||||
) {
|
||||
if (stored) window.localStorage.setItem(VALUES_KEY, JSON.stringify(stored));
|
||||
setSearch("?zoom=all");
|
||||
const net = stubTimeline();
|
||||
const { screen } = await renderWithApp(<TimelineView />, {
|
||||
user: { timezone: "Europe/Berlin", modules },
|
||||
});
|
||||
return { net, screen };
|
||||
}
|
||||
|
||||
function lastTimelineValues(net: FetchStub): string[] {
|
||||
const calls = net.calls("GET", "/api/timeline");
|
||||
return valuesOf(calls[calls.length - 1]?.path ?? "");
|
||||
}
|
||||
|
||||
describe("symptom lines on the timeline page", () => {
|
||||
it("asks for a stored symptom and draws it as a value line on the fixed scale", async () => {
|
||||
await page.viewport(1280, 900);
|
||||
const { net } = await mount([HEADACHE]);
|
||||
const points = () =>
|
||||
document.querySelectorAll<SVGCircleElement>(
|
||||
`[data-series="${HEADACHE}"] [data-slot="timeline-series-point"]`,
|
||||
);
|
||||
await expect.poll(() => points().length).toBe(2);
|
||||
expect(lastTimelineValues(net)).toEqual([HEADACHE]);
|
||||
// The mean of four days is a full point, the one of a single day hollow.
|
||||
const [autumn, winter] = [...points()];
|
||||
expect(autumn.dataset.count).toBe("4");
|
||||
expect(autumn.dataset.thin).toBe("false");
|
||||
expect(winter.dataset.thin).toBe("true");
|
||||
// Fixed scale: 6.5 sits higher than 3, and both well inside the row,
|
||||
// where a self-fitted row would push the two towards its edges.
|
||||
const y = (el: SVGCircleElement) => Number(el.getAttribute("cy"));
|
||||
expect(y(autumn)).toBeLessThan(y(winter));
|
||||
const span = y(winter) - y(autumn);
|
||||
// 3.5 of 11 scale units across a 44 px row: about 14 px.
|
||||
expect(span).toBeGreaterThan(10);
|
||||
expect(span).toBeLessThan(20);
|
||||
// The latest value reads out of 10, and the legend says what a point is.
|
||||
expect(
|
||||
document.querySelector(`g[data-series="${HEADACHE}"]`)?.textContent,
|
||||
).toContain("3/10");
|
||||
expect(
|
||||
document.querySelector('[data-slot="timeline-legend-symptom"]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("reads a point on its own with the days behind it", async () => {
|
||||
await page.viewport(1280, 900);
|
||||
await mount([HEADACHE]);
|
||||
const target = (t: string) =>
|
||||
document.querySelector<HTMLButtonElement>(
|
||||
`[data-slot="timeline-point-target"][data-series="${HEADACHE}"][data-t="${t}"]`,
|
||||
);
|
||||
await expect.poll(() => target("2025-10-01") !== null).toBe(true);
|
||||
const label = target("2025-10-01")!.getAttribute("aria-label") ?? "";
|
||||
expect(label).toContain("Headache");
|
||||
expect(label).toContain("6.5/10");
|
||||
expect(label).toContain("mean of 4 days");
|
||||
expect(target("2026-01-01")!.getAttribute("aria-label")).toContain(
|
||||
"on 1 day",
|
||||
);
|
||||
});
|
||||
|
||||
it("offers the record's symptoms, marks a hidden one, and a choice persists", async () => {
|
||||
await page.viewport(1280, 900);
|
||||
const { net, screen } = await mount(["WEIGHT"]);
|
||||
const trigger = screen.getByRole("button", { name: /Values \(1\)/ });
|
||||
await expect.element(trigger).toBeVisible();
|
||||
await trigger.click();
|
||||
await expect.element(screen.getByText("Symptoms")).toBeVisible();
|
||||
await expect
|
||||
.element(screen.getByRole("menuitemcheckbox", { name: /Nausea.*hidden/ }))
|
||||
.toBeVisible();
|
||||
const headache = screen.getByRole("menuitemcheckbox", {
|
||||
name: "Headache",
|
||||
});
|
||||
await expect.element(headache).toHaveAttribute("aria-checked", "false");
|
||||
await headache.click();
|
||||
await expect.element(headache).toHaveAttribute("aria-checked", "true");
|
||||
expect(JSON.parse(window.localStorage.getItem(VALUES_KEY)!)).toEqual([
|
||||
"WEIGHT",
|
||||
HEADACHE,
|
||||
]);
|
||||
await expect
|
||||
.poll(() => lastTimelineValues(net))
|
||||
.toEqual(["WEIGHT", HEADACHE]);
|
||||
});
|
||||
|
||||
it("neither offers nor asks for a symptom with the illness module off", async () => {
|
||||
await page.viewport(1280, 900);
|
||||
const { net, screen } = await mount(["WEIGHT", HEADACHE], {
|
||||
timeline: true,
|
||||
illness: false,
|
||||
});
|
||||
const trigger = screen.getByRole("button", { name: /Values \(1\)/ });
|
||||
await expect.element(trigger).toBeVisible();
|
||||
expect(lastTimelineValues(net)).toEqual(["WEIGHT"]);
|
||||
await trigger.click();
|
||||
await expect
|
||||
.element(screen.getByRole("menuitemcheckbox", { name: "Weight" }))
|
||||
.toBeVisible();
|
||||
expect(
|
||||
document.querySelector('[data-slot="timeline-values-symptoms"]'),
|
||||
).toBe(null);
|
||||
});
|
||||
|
||||
it("fits a phone and names the symptom's means in the chronicle", async () => {
|
||||
await page.viewport(390, 844);
|
||||
const { screen } = await mount([HEADACHE]);
|
||||
await expect
|
||||
.element(screen.getByRole("button", { name: "Values (1)" }))
|
||||
.toBeVisible();
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
document.querySelectorAll(
|
||||
`[data-slot="timeline-mean-part"][data-series="${HEADACHE}"]`,
|
||||
).length,
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
const root = document.documentElement;
|
||||
expect(root.scrollWidth).toBeLessThanOrEqual(root.clientWidth);
|
||||
});
|
||||
});
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
import { useItemWords } from "./item-words";
|
||||
import { LabReadingsLine, labReadingsIn, useLabValueText } from "./lab-lines";
|
||||
import { itemsInPeriod } from "./timeline-geometry";
|
||||
import { useSeriesCountText } from "./use-series-value-format";
|
||||
import { MEAN_PARTS_ROW, MeanPartsLine, laneDotStyle } from "./timeline-chart";
|
||||
import {
|
||||
bucketValues,
|
||||
@@ -78,7 +79,8 @@ export function SelectionBar({
|
||||
onOpenDay,
|
||||
onEditLifeEvent,
|
||||
}: SelectionBarProps) {
|
||||
const { t, tCount, locale } = useTranslations();
|
||||
const { t, locale } = useTranslations();
|
||||
const countText = useSeriesCountText();
|
||||
const words = useItemWords();
|
||||
const intl = resolveIntlLocale(locale);
|
||||
const bucket = timeline.bucket;
|
||||
@@ -218,7 +220,7 @@ export function SelectionBar({
|
||||
label: seriesLabel,
|
||||
bloodPressure: t("timeline.values.bloodPressure"),
|
||||
noValue: t("timeline.values.noValue"),
|
||||
readings: (n) => tCount("timeline.values.readings", n),
|
||||
readings: countText,
|
||||
})}
|
||||
seriesColor={seriesColor}
|
||||
/>
|
||||
|
||||
@@ -179,7 +179,8 @@ export function labelledMeanParts(
|
||||
label: (key: string) => string;
|
||||
bloodPressure: string;
|
||||
noValue: string;
|
||||
readings: (count: number) => string;
|
||||
/** The readings (a symptom: the days) behind a series' mean. */
|
||||
readings: (key: string, count: number) => string;
|
||||
},
|
||||
): MeanPart[] {
|
||||
const sys = means.find(
|
||||
@@ -196,7 +197,7 @@ export function labelledMeanParts(
|
||||
parts.push({
|
||||
key: m.key,
|
||||
text: `${words.bloodPressure} ${pressure(sys, dia!, format)}`,
|
||||
detail: words.readings(sys.count ?? 0),
|
||||
detail: words.readings(sys.key, sys.count ?? 0),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -206,8 +207,34 @@ export function labelledMeanParts(
|
||||
m.mean === null
|
||||
? `${words.label(m.key)} ${words.noValue}`
|
||||
: `${words.label(m.key)} ${formatSeriesValue(m.key, m.mean, m.unit, format)}`,
|
||||
...(m.mean === null ? {} : { detail: words.readings(m.count ?? 0) }),
|
||||
...(m.mean === null
|
||||
? {}
|
||||
: { detail: words.readings(m.key, m.count ?? 0) }),
|
||||
});
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
/**
|
||||
* The timeline with its symptom lines (#981) folded into the value lines.
|
||||
* A symptom line is a bucket mean like any value line, so the chart, the
|
||||
* selection bar, the tables and the chronicle draw and word it as one: on
|
||||
* its fixed scale (`layoutSeries`), with the days behind each mean
|
||||
* (`useSeriesCountText`). The server's points are kept as they are.
|
||||
*/
|
||||
export function withSymptomLines<
|
||||
T extends Pick<TimelineResponse, "series" | "symptomSeries">,
|
||||
>(timeline: T): T {
|
||||
if (timeline.symptomSeries.length === 0) return timeline;
|
||||
return {
|
||||
...timeline,
|
||||
series: [
|
||||
...timeline.series,
|
||||
...timeline.symptomSeries.map((s) => ({
|
||||
key: s.key,
|
||||
unit: null,
|
||||
points: s.points,
|
||||
})),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -3,11 +3,13 @@
|
||||
* geometry (v1.42, #613; lab lines #1299). `timeline-geometry.ts` stacks
|
||||
* them under the lanes; each row is laid out here on its own scale.
|
||||
*/
|
||||
import type {
|
||||
TimelineBucket,
|
||||
TimelineLabPoint,
|
||||
TimelineLabSeries,
|
||||
TimelineSeries,
|
||||
import {
|
||||
TIMELINE_SYMPTOM_SCALE,
|
||||
timelineSymptomDefinitionId,
|
||||
type TimelineBucket,
|
||||
type TimelineLabPoint,
|
||||
type TimelineLabSeries,
|
||||
type TimelineSeries,
|
||||
} from "@/lib/day/contract";
|
||||
|
||||
import { bucketAfter, bucketIndex, dayNumber } from "./timeline-dates";
|
||||
@@ -70,7 +72,8 @@ export const MAX_BRIDGED_GAP: Readonly<Record<TimelineBucket, number>> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* One neutral line per series, scaled to its own range inside the window.
|
||||
* One neutral line per series, scaled to its own range inside the window (a
|
||||
* symptom's to the fixed intensity scale, `TIMELINE_SYMPTOM_SCALE`).
|
||||
* A point sits in the middle of its bucket, and every bucket with a reading
|
||||
* gets its point, so one month with no neighbours stays visible. Missing
|
||||
* buckets stay missing: a short gap is bridged by a dashed stroke
|
||||
@@ -104,9 +107,17 @@ export function layoutSeries(
|
||||
points: [],
|
||||
};
|
||||
}
|
||||
let lo = Math.min(...inside.map((p) => p.mean));
|
||||
let hi = Math.max(...inside.map((p) => p.mean));
|
||||
const pad = Math.max((hi - lo) * 0.12, Math.abs(hi) * 0.01, 0.5);
|
||||
// A symptom line sits on the intensity scale whatever the window
|
||||
// holds (#981): a 3 reads as a 3, not as the top of a quiet stretch.
|
||||
const fixed =
|
||||
timelineSymptomDefinitionId(s.key) !== null
|
||||
? TIMELINE_SYMPTOM_SCALE
|
||||
: null;
|
||||
let lo = fixed ? fixed.min : Math.min(...inside.map((p) => p.mean));
|
||||
let hi = fixed ? fixed.max : Math.max(...inside.map((p) => p.mean));
|
||||
const pad = fixed
|
||||
? (hi - lo) * 0.05
|
||||
: Math.max((hi - lo) * 0.12, Math.abs(hi) * 0.01, 0.5);
|
||||
lo -= pad;
|
||||
hi += pad;
|
||||
const y = (v: number) => rowTop + height * (1 - (v - lo) / (hi - lo));
|
||||
|
||||
@@ -53,6 +53,7 @@ import { useLabReference, useLabValueText, useLabVerdict } from "./lab-lines";
|
||||
import { formatSeriesValue, type SeriesValueFormat } from "./series-format";
|
||||
import { formatDayMonthYear } from "./timeline-dates";
|
||||
import type { TimelineLayout } from "./timeline-geometry";
|
||||
import { useSeriesCountText } from "./use-series-value-format";
|
||||
|
||||
/** Hit target across, in px: bigger than the dot, a comfortable aim. */
|
||||
export const POINT_TARGET = 24;
|
||||
@@ -401,7 +402,8 @@ function useTipLines({
|
||||
fmt: SeriesValueFormat;
|
||||
bucketLabel: (t: string) => string;
|
||||
}): Array<{ key: string; points: TipPoint[] }> {
|
||||
const { t: tr, tCount, locale } = useTranslations();
|
||||
const { t: tr, locale } = useTranslations();
|
||||
const countText = useSeriesCountText();
|
||||
const intl = resolveIntlLocale(locale);
|
||||
const verdict = useLabVerdict();
|
||||
const labValueText = useLabValueText();
|
||||
@@ -420,13 +422,13 @@ function useTipLines({
|
||||
x: p.x,
|
||||
y: p.y,
|
||||
day: dayOf(p.t),
|
||||
ariaLabel: `${words(s.key, p.t, p.mean, s.unit)}, ${tCount("timeline.values.readings", p.count)}`,
|
||||
ariaLabel: `${words(s.key, p.t, p.mean, s.unit)}, ${countText(s.key, p.count)}`,
|
||||
label: bucketLabel(p.t),
|
||||
row: {
|
||||
name: seriesLabel(s.key),
|
||||
value: formatSeriesValue(s.key, p.mean, s.unit, fmt),
|
||||
color: seriesColor(s.key),
|
||||
delta: tCount("timeline.values.readings", p.count),
|
||||
delta: countText(s.key, p.count),
|
||||
},
|
||||
report: null,
|
||||
})),
|
||||
|
||||
@@ -19,11 +19,17 @@ import { LabExcludedNote } from "./lab-lines";
|
||||
export function TimelineLegend({
|
||||
bucket,
|
||||
hasSeries,
|
||||
hasSymptoms = false,
|
||||
labSeries = [],
|
||||
label = (key) => key,
|
||||
}: {
|
||||
bucket: TimelineBucket;
|
||||
hasSeries: boolean;
|
||||
/**
|
||||
* A symptom line is drawn (#981): what its points are is said once, since
|
||||
* its mean is of day peaks over logged days, not of readings.
|
||||
*/
|
||||
hasSymptoms?: boolean;
|
||||
/**
|
||||
* The lab lines drawn: their three marks are explained, and what a line
|
||||
* left out is said here, where the marks are (#1299).
|
||||
@@ -113,6 +119,14 @@ export function TimelineLegend({
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{hasSymptoms && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5"
|
||||
data-slot="timeline-legend-symptom"
|
||||
>
|
||||
{t("timeline.legendSymptom")}
|
||||
</span>
|
||||
)}
|
||||
{hasLabs && (
|
||||
<>
|
||||
<span
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
* list sorts them into; a lab line is chosen and stored like any other, up
|
||||
* to `TIMELINE_LAB_SERIES_MAX` at once: past it the unchosen markers are
|
||||
* held back with a line that says why, and a chosen one can always go.
|
||||
* Between the two it offers the person's own symptoms (#981) that have been
|
||||
* logged, hidden ones included and marked as such, on the same terms up to
|
||||
* `TIMELINE_SYMPTOM_SERIES_MAX`.
|
||||
*/
|
||||
import { ChartLine, ChevronDown, Layers } from "lucide-react";
|
||||
|
||||
@@ -28,7 +31,9 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
TIMELINE_LAB_SERIES_MAX,
|
||||
TIMELINE_SYMPTOM_SERIES_MAX,
|
||||
timelineLabMarkerId,
|
||||
timelineSymptomDefinitionId,
|
||||
type TimelineLaneKey,
|
||||
type TimelineResponse,
|
||||
} from "@/lib/day/contract";
|
||||
@@ -43,8 +48,15 @@ export interface LabOptionGroup {
|
||||
keys: readonly string[];
|
||||
}
|
||||
|
||||
/** A symptom the menu offers: its key, and whether it is hidden from entry. */
|
||||
export interface SymptomOption {
|
||||
key: string;
|
||||
hidden: boolean;
|
||||
}
|
||||
|
||||
export function ValueSeriesMenu({
|
||||
options,
|
||||
symptoms = [],
|
||||
labGroups = [],
|
||||
selected,
|
||||
label,
|
||||
@@ -53,6 +65,8 @@ export function ValueSeriesMenu({
|
||||
iconOnly = false,
|
||||
}: {
|
||||
options: readonly string[];
|
||||
/** The record's logged symptoms, in the record's own order. */
|
||||
symptoms?: readonly SymptomOption[];
|
||||
/** The record's lab markers, grouped as the labs list groups them. */
|
||||
labGroups?: readonly LabOptionGroup[];
|
||||
selected: readonly string[];
|
||||
@@ -67,13 +81,20 @@ export function ValueSeriesMenu({
|
||||
const labsFull =
|
||||
selected.filter((key) => timelineLabMarkerId(key) !== null).length >=
|
||||
TIMELINE_LAB_SERIES_MAX;
|
||||
const option = (key: string) => {
|
||||
const symptomsFull =
|
||||
selected.filter((key) => timelineSymptomDefinitionId(key) !== null)
|
||||
.length >= TIMELINE_SYMPTOM_SERIES_MAX;
|
||||
const option = (key: string, hint?: string) => {
|
||||
const checked = selected.includes(key);
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={key}
|
||||
checked={checked}
|
||||
disabled={labsFull && !checked && timelineLabMarkerId(key) !== null}
|
||||
disabled={
|
||||
!checked &&
|
||||
((labsFull && timelineLabMarkerId(key) !== null) ||
|
||||
(symptomsFull && timelineSymptomDefinitionId(key) !== null))
|
||||
}
|
||||
data-slot="timeline-values-option"
|
||||
data-key={key}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
@@ -92,6 +113,9 @@ export function ValueSeriesMenu({
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{label(key)}
|
||||
{hint ? (
|
||||
<span className="text-muted-foreground ml-auto text-xs">{hint}</span>
|
||||
) : null}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
};
|
||||
@@ -121,7 +145,33 @@ export function ValueSeriesMenu({
|
||||
data-slot="timeline-values-menu"
|
||||
>
|
||||
<DropdownMenuLabel>{t("timeline.values.menuTitle")}</DropdownMenuLabel>
|
||||
{options.map(option)}
|
||||
{options.map((key) => option(key))}
|
||||
{symptoms.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel data-slot="timeline-values-symptoms">
|
||||
{t("timeline.symptoms.menuTitle")}
|
||||
</DropdownMenuLabel>
|
||||
{symptomsFull && (
|
||||
<p
|
||||
data-slot="timeline-values-symptoms-full"
|
||||
className="text-muted-foreground px-2 pb-1 text-xs"
|
||||
>
|
||||
{t("timeline.symptoms.full", {
|
||||
max: TIMELINE_SYMPTOM_SERIES_MAX,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<DropdownMenuGroup data-slot="timeline-values-symptom-group">
|
||||
{symptoms.map((s) =>
|
||||
option(
|
||||
s.key,
|
||||
s.hidden ? t("timeline.symptoms.hidden") : undefined,
|
||||
),
|
||||
)}
|
||||
</DropdownMenuGroup>
|
||||
</>
|
||||
)}
|
||||
{labGroups.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
@@ -146,7 +196,7 @@ export function ValueSeriesMenu({
|
||||
{group.panel ?? t("timeline.labs.otherPanel")}
|
||||
</DropdownMenuLabel>
|
||||
)}
|
||||
{group.keys.map(option)}
|
||||
{group.keys.map((key) => option(key))}
|
||||
</DropdownMenuGroup>
|
||||
))}
|
||||
</>
|
||||
@@ -251,10 +301,26 @@ export function labMarkerNames(
|
||||
);
|
||||
}
|
||||
|
||||
/** Each symptom's name by its key, from what the server sent. */
|
||||
export function symptomNames(
|
||||
timeline:
|
||||
Pick<TimelineResponse, "availableSymptoms" | "symptomSeries"> | undefined,
|
||||
): Map<string, string> {
|
||||
const out = new Map<string, string>();
|
||||
for (const s of [
|
||||
...(timeline?.availableSymptoms ?? []),
|
||||
...(timeline?.symptomSeries ?? []),
|
||||
]) {
|
||||
if (s.label !== null) out.set(s.key, s.label);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The values menu as the page holds it: every series the record has, the
|
||||
* chosen ones kept even when they have nothing yet, in `rank`'s order and
|
||||
* then the record's; then the record's lab markers by group. It counts and
|
||||
* then the record's; then the record's logged symptoms in its own order;
|
||||
* then the record's lab markers by group. It counts and
|
||||
* toggles the lines it can name, so a stored marker the record no longer
|
||||
* has drops out with the next change.
|
||||
*/
|
||||
@@ -264,6 +330,7 @@ export function TimelineValuesMenu({
|
||||
allowed,
|
||||
rank,
|
||||
labsOn,
|
||||
symptomsOn,
|
||||
label,
|
||||
color,
|
||||
onChange,
|
||||
@@ -274,6 +341,7 @@ export function TimelineValuesMenu({
|
||||
allowed: (key: string) => boolean;
|
||||
rank: readonly string[];
|
||||
labsOn: boolean;
|
||||
symptomsOn: boolean;
|
||||
label: (key: string) => string;
|
||||
color: (key: string) => string;
|
||||
onChange: (next: string[]) => void;
|
||||
@@ -283,7 +351,11 @@ export function TimelineValuesMenu({
|
||||
const options = [
|
||||
...new Set([
|
||||
...(timeline?.availableSeries ?? []).filter(allowed),
|
||||
...values.filter((key) => timelineLabMarkerId(key) === null),
|
||||
...values.filter(
|
||||
(key) =>
|
||||
timelineLabMarkerId(key) === null &&
|
||||
timelineSymptomDefinitionId(key) === null,
|
||||
),
|
||||
]),
|
||||
].sort((a, b) => rank.indexOf(a) - rank.indexOf(b));
|
||||
const markers = new Map(
|
||||
@@ -291,15 +363,29 @@ export function TimelineValuesMenu({
|
||||
(m) => [m.key, m],
|
||||
),
|
||||
);
|
||||
// A chosen symptom is offered even when the list no longer holds it (a
|
||||
// line sent for it names it), so it can be unticked.
|
||||
const symptomMap = new Map(
|
||||
[
|
||||
...(timeline?.availableSymptoms ?? []),
|
||||
...(timeline?.symptomSeries ?? []),
|
||||
].map((s) => [s.key, { key: s.key, hidden: !s.isActive }]),
|
||||
);
|
||||
const symptoms = symptomsOn ? [...symptomMap.values()] : [];
|
||||
const labGroups = labsOn
|
||||
? labOptionGroups([...markers.values()], resolveIntlLocale(locale))
|
||||
: [];
|
||||
return (
|
||||
<ValueSeriesMenu
|
||||
options={options}
|
||||
symptoms={symptoms}
|
||||
labGroups={labGroups}
|
||||
selected={values.filter(
|
||||
(key) => timelineLabMarkerId(key) === null || markers.has(key),
|
||||
selected={values.filter((key) =>
|
||||
timelineLabMarkerId(key) !== null
|
||||
? markers.has(key)
|
||||
: timelineSymptomDefinitionId(key) !== null
|
||||
? symptomMap.has(key)
|
||||
: true,
|
||||
)}
|
||||
label={label}
|
||||
color={color}
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
formatBucket,
|
||||
} from "./timeline-dates";
|
||||
import { isSpan } from "./timeline-geometry";
|
||||
import { useSeriesCountText } from "./use-series-value-format";
|
||||
|
||||
/**
|
||||
* The chart as a table, for a screen reader: every entry with its lane and
|
||||
@@ -116,7 +117,8 @@ export function SeriesTable({
|
||||
fmt: SeriesValueFormat;
|
||||
intl: string;
|
||||
}) {
|
||||
const { t, tCount } = useTranslations();
|
||||
const { t } = useTranslations();
|
||||
const countText = useSeriesCountText();
|
||||
const { bucket, series } = timeline;
|
||||
const inWindow = (start: string) =>
|
||||
start <= window.to && bucketAfter(start, bucket) > window.from;
|
||||
@@ -163,7 +165,7 @@ export function SeriesTable({
|
||||
return (
|
||||
<td key={s.key}>
|
||||
{point
|
||||
? `${formatSeriesValue(s.key, point.mean, s.unit, fmt)}, ${tCount("timeline.values.readings", point.count)}`
|
||||
? `${formatSeriesValue(s.key, point.mean, s.unit, fmt)}, ${countText(s.key, point.count)}`
|
||||
: t("timeline.values.noValue")}
|
||||
</td>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
* Lab markers the record has values for are offered beside the measurements
|
||||
* (#1299): a chosen marker is stored and requested like any other line, as
|
||||
* `lab:<biomarkerId>`, and drawn as its single readings, never as means.
|
||||
* The person's own symptoms (#981) are offered the same way, as
|
||||
* `symptom:<definitionId>`, and drawn as value lines on the fixed intensity
|
||||
* scale: each point the mean of the day peaks the server averaged.
|
||||
*
|
||||
* At 768 px and up the chart; below it the chronicle, which is also the
|
||||
* list form of the chart. The readiness card sits on top while a lane is
|
||||
@@ -37,10 +40,11 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import {
|
||||
capTimelineLabKeys,
|
||||
capTimelineSeriesKeys,
|
||||
DAY_QUERY_PARAM,
|
||||
TIMELINE_ZOOMS,
|
||||
timelineLabMarkerId,
|
||||
timelineSymptomDefinitionId,
|
||||
type LifeEventDTO,
|
||||
type TimelineLaneKey,
|
||||
type TimelineResponse,
|
||||
@@ -88,7 +92,9 @@ import {
|
||||
LayersMenu,
|
||||
TimelineValuesMenu,
|
||||
labMarkerNames,
|
||||
symptomNames,
|
||||
} from "./timeline-menus";
|
||||
import { withSymptomLines } from "./series-format";
|
||||
import {
|
||||
isBoolean,
|
||||
isStringArray,
|
||||
@@ -151,7 +157,8 @@ function timelineIsEmpty(data: TimelineResponse): boolean {
|
||||
data.lanes.every((l) => l.items.length === 0) &&
|
||||
data.standing.length === 0 &&
|
||||
data.series.every((s) => s.points.length === 0) &&
|
||||
data.labSeries.every((s) => s.points.length === 0)
|
||||
data.labSeries.every((s) => s.points.length === 0) &&
|
||||
data.symptomSeries.every((s) => s.points.length === 0)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -178,6 +185,10 @@ function useValueLines(
|
||||
// A lab line belongs to the labs module; whether the record has the
|
||||
// marker is the server's to say.
|
||||
if (timelineLabMarkerId(key) !== null) return modules?.labs !== false;
|
||||
// A symptom line rides the illness module, as symptoms do everywhere.
|
||||
if (timelineSymptomDefinitionId(key) !== null) {
|
||||
return modules?.illness !== false;
|
||||
}
|
||||
if (!KNOWN_SERIES.has(key)) return false;
|
||||
const owner: ModuleKey | null | undefined =
|
||||
VALUE_OPTION_MODULE[key as keyof typeof VALUE_OPTION_MODULE];
|
||||
@@ -185,10 +196,10 @@ function useValueLines(
|
||||
},
|
||||
[modules],
|
||||
);
|
||||
// Lab markers past the cap are not asked for (the server would leave
|
||||
// them out); the menu holds further ones back.
|
||||
// Lab markers and symptoms past their caps are not asked for (the server
|
||||
// would leave them out); the menu holds further ones back.
|
||||
const values = useMemo(
|
||||
() => capTimelineLabKeys(storedValues.filter(allowed)),
|
||||
() => capTimelineSeriesKeys(storedValues.filter(allowed)),
|
||||
[storedValues, allowed],
|
||||
);
|
||||
// One colour per chosen line, shared by the chart, the selection bar, the
|
||||
@@ -202,6 +213,39 @@ function useValueLines(
|
||||
return { values, allowed, setStoredValues, seriesColor };
|
||||
}
|
||||
|
||||
/**
|
||||
* The timeline as the page draws it: symptom lines (#981) are drawn and
|
||||
* worded as value lines from here on.
|
||||
*/
|
||||
function useWithSymptomLines(
|
||||
raw: TimelineResponse | undefined,
|
||||
): TimelineResponse | undefined {
|
||||
return useMemo(() => (raw ? withSymptomLines(raw) : undefined), [raw]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Each value line's name. Blood pressure reads as its own name here; the
|
||||
* list labels ("Sys", "Dia") lean on a column header the chart does not
|
||||
* have. A lab marker reads by its own name, as on the labs list, and a
|
||||
* symptom by the name the person gave it.
|
||||
*/
|
||||
function useSeriesLabel(
|
||||
data: TimelineResponse | undefined,
|
||||
): (key: string) => string {
|
||||
const { t } = useTranslations();
|
||||
const labNames = labMarkerNames(data);
|
||||
const symptomLabels = symptomNames(data);
|
||||
return (key: string) =>
|
||||
key === "BLOOD_PRESSURE_SYS" || key === "BLOOD_PRESSURE_DIA"
|
||||
? t(TIMELINE_BLOOD_PRESSURE_SERIES_KEY[key])
|
||||
: key === "MOOD"
|
||||
? t("timeline.readiness.lanes.mood")
|
||||
: timelineSymptomDefinitionId(key) !== null
|
||||
? (symptomLabels.get(key) ?? t("timeline.symptoms.unnamed"))
|
||||
: (labNames.get(key) ??
|
||||
t(MEASUREMENT_TYPE_LABEL_KEYS[key as MeasurementType] ?? key));
|
||||
}
|
||||
|
||||
export function TimelineView() {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
@@ -318,7 +362,7 @@ export function TimelineView() {
|
||||
}
|
||||
}
|
||||
|
||||
const data = timeline.data;
|
||||
const data = useWithSymptomLines(timeline.data);
|
||||
// A pick outside a newly chosen range no longer counts as one.
|
||||
const chosen =
|
||||
selected !== null &&
|
||||
@@ -362,17 +406,7 @@ export function TimelineView() {
|
||||
setLifeEventOpen(true);
|
||||
}
|
||||
|
||||
const labNames = labMarkerNames(data);
|
||||
// Blood pressure reads as its own name here; the list labels ("Sys",
|
||||
// "Dia") lean on a column header the chart does not have. A lab marker
|
||||
// reads by its own name, as on the labs list.
|
||||
const seriesLabel = (key: string) =>
|
||||
key === "BLOOD_PRESSURE_SYS" || key === "BLOOD_PRESSURE_DIA"
|
||||
? t(TIMELINE_BLOOD_PRESSURE_SERIES_KEY[key])
|
||||
: key === "MOOD"
|
||||
? t("timeline.readiness.lanes.mood")
|
||||
: (labNames.get(key) ??
|
||||
t(MEASUREMENT_TYPE_LABEL_KEYS[key as MeasurementType] ?? key));
|
||||
const seriesLabel = useSeriesLabel(data);
|
||||
|
||||
const shownWindow = data
|
||||
? windowFor(
|
||||
@@ -405,6 +439,7 @@ export function TimelineView() {
|
||||
allowed={allowed}
|
||||
rank={SERIES_RANK}
|
||||
labsOn={modules?.labs !== false}
|
||||
symptomsOn={modules?.illness !== false}
|
||||
label={seriesLabel}
|
||||
color={seriesColor}
|
||||
onChange={setStoredValues}
|
||||
@@ -603,6 +638,7 @@ export function TimelineView() {
|
||||
<TimelineLegend
|
||||
bucket={data.bucket}
|
||||
hasSeries={data.series.length > 0}
|
||||
hasSymptoms={data.symptomSeries.length > 0}
|
||||
labSeries={data.labSeries}
|
||||
label={seriesLabel}
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
import { useDayValueFormat } from "@/components/day/use-day-value-format";
|
||||
import { useFormatters } from "@/lib/i18n/context";
|
||||
import {
|
||||
TIMELINE_SYMPTOM_SCALE,
|
||||
timelineSymptomDefinitionId,
|
||||
} from "@/lib/day/contract";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import { plainSeriesFormat, type SeriesValueFormat } from "./series-format";
|
||||
|
||||
@@ -14,18 +18,26 @@ const MOOD_SERIES_KEY = "MOOD";
|
||||
* The timeline's value format: the day view's formatter for every
|
||||
* measurement series (unit preference, whole numbers for pressure and
|
||||
* pulse, sleep as a duration), one plain decimal for mood, which has no
|
||||
* measurement type behind it.
|
||||
* measurement type behind it, and at most one decimal out of the scale's top
|
||||
* for a symptom ("6.5/10", "3/10", #981).
|
||||
*/
|
||||
export function useSeriesValueFormat(): SeriesValueFormat {
|
||||
const fmt = useFormatters();
|
||||
const { number, unitFor } = useDayValueFormat();
|
||||
return useMemo(() => {
|
||||
const plain = plainSeriesFormat(fmt);
|
||||
// At most one decimal, none for a whole intensity: "3/10", "6.5/10".
|
||||
const symptomNumber = (value: number) => {
|
||||
const rounded = Math.round(value * 10) / 10;
|
||||
return fmt.number(rounded, Number.isInteger(rounded) ? 0 : 1);
|
||||
};
|
||||
return {
|
||||
number: (key, value, unit) =>
|
||||
key === MOOD_SERIES_KEY || unit === null
|
||||
? plain.number(key, value, unit)
|
||||
: number({ type: key, unit }, value),
|
||||
timelineSymptomDefinitionId(key) !== null
|
||||
? `${symptomNumber(value)}/${TIMELINE_SYMPTOM_SCALE.max}`
|
||||
: key === MOOD_SERIES_KEY || unit === null
|
||||
? plain.number(key, value, unit)
|
||||
: number({ type: key, unit }, value),
|
||||
unit: (key, unit) =>
|
||||
key === MOOD_SERIES_KEY || unit === null
|
||||
? plain.unit(key, unit)
|
||||
@@ -33,3 +45,19 @@ export function useSeriesValueFormat(): SeriesValueFormat {
|
||||
};
|
||||
}, [fmt, number, unitFor]);
|
||||
}
|
||||
|
||||
/**
|
||||
* What stands behind one point of a value line: "mean of 4 readings" for a
|
||||
* measurement or mood, "on 3 days" for a symptom, whose count is the days
|
||||
* it was logged (#981).
|
||||
*/
|
||||
export function useSeriesCountText(): (key: string, count: number) => string {
|
||||
const { tCount } = useTranslations();
|
||||
return useCallback(
|
||||
(key, count) =>
|
||||
timelineSymptomDefinitionId(key) !== null
|
||||
? tCount("timeline.symptoms.days", count)
|
||||
: tCount("timeline.values.readings", count),
|
||||
[tCount],
|
||||
);
|
||||
}
|
||||
@@ -140,6 +140,24 @@ describe("normaliseDateRange", () => {
|
||||
expect(range.start.toISOString()).toBe("2024-01-02T00:00:00.001Z");
|
||||
});
|
||||
|
||||
it("clamps to whole local days across an odd number of clock changes", () => {
|
||||
// The last day is 2026-03-29 in Berlin, the day summer time starts; the
|
||||
// 730th day before it, 2024-03-30, is still in winter time. Five clock
|
||||
// changes lie between, so 730 times 24 hours lands an hour off midnight.
|
||||
const range = normaliseDateRange(
|
||||
{
|
||||
startDate: "2023-01-01T00:00:00.000Z",
|
||||
endDate: "2026-03-29T21:59:59.999Z",
|
||||
},
|
||||
NOW,
|
||||
"Europe/Berlin",
|
||||
);
|
||||
expect(range.days).toBe(730);
|
||||
// Midnight on 2024-03-30, Berlin winter time.
|
||||
expect(range.start.toISOString()).toBe("2024-03-29T23:00:00.000Z");
|
||||
expect(range.end.toISOString()).toBe("2026-03-29T21:59:59.999Z");
|
||||
});
|
||||
|
||||
it("counts 730 local days as 730 across a net autumn clock change", () => {
|
||||
// 2024-10-26 .. 2026-10-25 in Berlin, cut the way the panel cuts it:
|
||||
// the first day starts in summer time, the last ends in winter time, so
|
||||
|
||||
@@ -184,7 +184,7 @@
|
||||
},
|
||||
{
|
||||
"name": "get_cycle",
|
||||
"description": "Fetch the user's menstrual-cycle context: current phase + day-of-cycle, the next predicted event, and the headline phase-correlation finding. Descriptive only — never a contraception-grade or 'safe day' claim. Returns { present: false } when cycle tracking is off or there is no data.",
|
||||
"description": "Fetch the user's menstrual-cycle context: current phase + day-of-cycle, the next predicted event, the upcoming period starts after it (`upcoming`: each a likely start with its earliest-to-latest band, widening and self-capped), and the headline phase-correlation finding. Descriptive only — never a contraception-grade or 'safe day' claim. Returns { present: false } when cycle tracking is off or there is no data.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
||||
@@ -366,7 +366,7 @@ export const COACH_TOOL_DEFS: AiToolDef[] = [
|
||||
{
|
||||
name: "get_cycle",
|
||||
description:
|
||||
"Fetch the user's menstrual-cycle context: current phase + day-of-cycle, the next predicted event, and the headline phase-correlation finding. Descriptive only — never a contraception-grade or 'safe day' claim. Returns { present: false } when cycle tracking is off or there is no data.",
|
||||
"Fetch the user's menstrual-cycle context: current phase + day-of-cycle, the next predicted event, the upcoming period starts after it (`upcoming`: each a likely start with its earliest-to-latest band, widening and self-capped), and the headline phase-correlation finding. Descriptive only — never a contraception-grade or 'safe day' claim. Returns { present: false } when cycle tracking is off or there is no data.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
|
||||
@@ -145,7 +145,7 @@ describe("serveCycleFeed", () => {
|
||||
expect(limits.surface).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
"cycle-feed",
|
||||
120,
|
||||
600,
|
||||
3_600_000,
|
||||
);
|
||||
});
|
||||
@@ -156,7 +156,7 @@ describe("serveCycleFeed", () => {
|
||||
expect(res.status).toBe(429);
|
||||
expect(limits.token).toHaveBeenCalledWith(
|
||||
`cycle-feed:${hashToken(TOKEN)}`,
|
||||
30,
|
||||
120,
|
||||
3_600_000,
|
||||
);
|
||||
expect(built.build).not.toHaveBeenCalled();
|
||||
|
||||
@@ -56,12 +56,18 @@ import { parseFeedTokenSegment } from "./token";
|
||||
|
||||
/**
|
||||
* Per source address, across every feed. Generous because the big calendar
|
||||
* providers poll from a pool of addresses that churns; the per-token bucket
|
||||
* below is the one that bounds a single link.
|
||||
* providers poll from a pool of addresses that churns, and a household behind
|
||||
* one address may hold several links on several devices; the per-token bucket
|
||||
* below is the one that bounds a single link. The token is a random secret
|
||||
* checked by its HMAC, so guessing links is not what these limits stop.
|
||||
*/
|
||||
const SURFACE_LIMIT_PER_HOUR = 120;
|
||||
/** Per link. A provider polling every few minutes still fits. */
|
||||
const TOKEN_LIMIT_PER_HOUR = 30;
|
||||
const SURFACE_LIMIT_PER_HOUR = 600;
|
||||
/**
|
||||
* Per link. One link subscribed on a phone, a tablet and a computer, each
|
||||
* refreshing every five minutes, is 36 fetches an hour; 120 leaves room for
|
||||
* a fourth device and for retries.
|
||||
*/
|
||||
const TOKEN_LIMIT_PER_HOUR = 120;
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
|
||||
/** The longest User-Agent kept for the status read. */
|
||||
|
||||
@@ -91,11 +91,15 @@ export async function createSubordinatePeriodDays(
|
||||
|
||||
/**
|
||||
* Stamp the last day of a finished period on the cycle that opens at
|
||||
* `start`, when that cycle has no period end yet. A person's own end, or one
|
||||
* an earlier pass stamped, stays. Returns whether a stamp was written.
|
||||
* `start`, when that cycle has no period end yet and the wearable opened it:
|
||||
* its first day is the wearable's own live row. A cycle the person opened
|
||||
* herself on that date carries her account of the period (her days, her end
|
||||
* or none yet), and a wearable never writes over it. An end already set, by
|
||||
* her or an earlier pass, stays. Returns whether a stamp was written.
|
||||
*/
|
||||
export async function stampSubordinatePeriodEnd(
|
||||
userId: string,
|
||||
source: SubordinateCycleSource,
|
||||
start: string,
|
||||
lastDay: string,
|
||||
): Promise<boolean> {
|
||||
@@ -106,6 +110,9 @@ export async function stampSubordinatePeriodEnd(
|
||||
startDate: start,
|
||||
deletedAt: null,
|
||||
periodEndDate: null,
|
||||
dayLogs: {
|
||||
some: { userId, date: start, source, deletedAt: null },
|
||||
},
|
||||
},
|
||||
data: { periodEndDate: lastDay, syncVersion: { increment: 1 } },
|
||||
});
|
||||
|
||||
+51
-10
@@ -428,6 +428,10 @@ export type TimelineSeries = TimelineResponse["series"][number];
|
||||
export type TimelineLabSeries = TimelineResponse["labSeries"][number];
|
||||
export type TimelineLabPoint = TimelineLabSeries["points"][number];
|
||||
export type TimelineLabMarker = TimelineResponse["availableLabs"][number];
|
||||
export type TimelineSymptomSeries = TimelineResponse["symptomSeries"][number];
|
||||
export type TimelineSymptomPoint = TimelineSymptomSeries["points"][number];
|
||||
export type TimelineSymptomOption =
|
||||
TimelineResponse["availableSymptoms"][number];
|
||||
|
||||
/**
|
||||
* A lab marker's line is named in `values` as `lab:<biomarkerId>` (#1299),
|
||||
@@ -450,18 +454,55 @@ export function timelineLabMarkerId(key: string): string | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* `keys` with every lab marker past the first `TIMELINE_LAB_SERIES_MAX` left
|
||||
* out, everything else in its place. The server applies it to what it is
|
||||
* asked for and the page to what it asks, so a stored list longer than the
|
||||
* cap (another device, an older build) loads its first markers instead of
|
||||
* failing the page.
|
||||
* A person-defined symptom's line is named in `values` as
|
||||
* `symptom:<definitionId>` (#981), beside the lab markers and on the same
|
||||
* terms: checked for its shape only, capped, and left out without an error
|
||||
* when the record does not have it.
|
||||
*/
|
||||
export function capTimelineLabKeys(keys: readonly string[]): string[] {
|
||||
export const TIMELINE_SYMPTOM_KEY_PREFIX = "symptom:";
|
||||
|
||||
/** The most symptoms one request draws. Definition ids are not a closed set. */
|
||||
export const TIMELINE_SYMPTOM_SERIES_MAX = 24;
|
||||
|
||||
/**
|
||||
* The scale a symptom line is drawn on: the intensity a symptom is logged
|
||||
* with, 0 to 10, whatever the window holds, so two symptoms (and one symptom
|
||||
* in two windows) read against the same marks.
|
||||
*/
|
||||
export const TIMELINE_SYMPTOM_SCALE = { min: 0, max: 10 } as const;
|
||||
|
||||
/** The definition id a `values` entry names, or null when it names none. */
|
||||
export function timelineSymptomDefinitionId(key: string): string | null {
|
||||
if (!key.startsWith(TIMELINE_SYMPTOM_KEY_PREFIX)) return null;
|
||||
const id = key.slice(TIMELINE_SYMPTOM_KEY_PREFIX.length);
|
||||
return /^[A-Za-z0-9_-]{1,64}$/.test(id) ? id : null;
|
||||
}
|
||||
|
||||
/** The `values` entry for a symptom definition. */
|
||||
export function timelineSymptomKey(definitionId: string): string {
|
||||
return `${TIMELINE_SYMPTOM_KEY_PREFIX}${definitionId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* `keys` with every lab marker past the first `TIMELINE_LAB_SERIES_MAX` and
|
||||
* every symptom past the first `TIMELINE_SYMPTOM_SERIES_MAX` left out,
|
||||
* everything else in its place. The server applies it to what it is asked
|
||||
* for and the page to what it asks, so a stored list longer than a cap
|
||||
* (another device, an older build) loads its first lines instead of failing
|
||||
* the page.
|
||||
*/
|
||||
export function capTimelineSeriesKeys(keys: readonly string[]): string[] {
|
||||
let labs = 0;
|
||||
return keys.filter(
|
||||
(key) =>
|
||||
timelineLabMarkerId(key) === null || ++labs <= TIMELINE_LAB_SERIES_MAX,
|
||||
);
|
||||
let symptoms = 0;
|
||||
return keys.filter((key) => {
|
||||
if (timelineLabMarkerId(key) !== null) {
|
||||
return ++labs <= TIMELINE_LAB_SERIES_MAX;
|
||||
}
|
||||
if (timelineSymptomDefinitionId(key) !== null) {
|
||||
return ++symptoms <= TIMELINE_SYMPTOM_SERIES_MAX;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/** The `values` entry for a lab marker. */
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* The local calendar date every day-view and timeline schema speaks in. Its
|
||||
* own module so the schema files beside `./wire-schemas.ts` can share it.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { isCalendarDateKey } from "@/lib/tz/date-only";
|
||||
|
||||
export const dateKeySchema = z
|
||||
.string()
|
||||
.refine(isCalendarDateKey, { message: "Expected a YYYY-MM-DD calendar date" })
|
||||
.describe("A local calendar date, `YYYY-MM-DD`.");
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* The person-defined symptom lines of `GET /api/timeline` (#981): one
|
||||
* symptom's points, bucket means of its day peaks over the days it was
|
||||
* logged, and the symptoms the record offers. Server and OpenAPI side only,
|
||||
* like `./wire-schemas.ts`, which places them in the timeline's response.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { TIMELINE_SYMPTOM_SCALE } from "./contract";
|
||||
import { dateKeySchema } from "./date-key-schema";
|
||||
|
||||
const timelineSymptomPoint = z
|
||||
.object({
|
||||
t: dateKeySchema.describe("The bucket's first day."),
|
||||
mean: z
|
||||
.number()
|
||||
.min(TIMELINE_SYMPTOM_SCALE.min)
|
||||
.max(TIMELINE_SYMPTOM_SCALE.max)
|
||||
.describe(
|
||||
"The mean of the bucket's day values over the days the symptom was logged. A day's value is the highest intensity (0 to 10) logged on it; a day without an entry is not a zero and does not count.",
|
||||
),
|
||||
count: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.describe(
|
||||
"Days the symptom was logged in the bucket, the days behind the mean. Clients draw a point from fewer than three as thin.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "TimelineSymptomPoint" });
|
||||
|
||||
export const timelineSymptomSeries = z
|
||||
.object({
|
||||
key: z.string().describe("`symptom:<definitionId>`, as named in `values`."),
|
||||
definitionId: z.string(),
|
||||
label: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe(
|
||||
"The person's own name for the symptom. Null when it cannot be read; the client words a stand-in.",
|
||||
),
|
||||
icon: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("The symptom's icon name; null for the default glyph."),
|
||||
isActive: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"False for a hidden symptom: no longer offered for entry, its history kept and drawn.",
|
||||
),
|
||||
scale: z
|
||||
.object({
|
||||
min: z.literal(TIMELINE_SYMPTOM_SCALE.min),
|
||||
max: z.literal(TIMELINE_SYMPTOM_SCALE.max),
|
||||
})
|
||||
.describe(
|
||||
"The fixed scale the line is drawn on, the intensity scale, whatever the window holds.",
|
||||
),
|
||||
points: z
|
||||
.array(timelineSymptomPoint)
|
||||
.describe(
|
||||
"Buckets of the response's `bucket` with at least one logged day, oldest first. A bucket without one is absent, never zero and never interpolated.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "TimelineSymptomSeries" });
|
||||
|
||||
export const timelineSymptomOption = z
|
||||
.object({
|
||||
key: z.string().describe("`symptom:<definitionId>`, to name in `values`."),
|
||||
definitionId: z.string(),
|
||||
label: z.string().nullable(),
|
||||
icon: z.string().nullable(),
|
||||
isActive: z.boolean(),
|
||||
})
|
||||
.meta({ id: "TimelineSymptomOption" });
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { dateOnlyAtNoonUtc, isCalendarDateKey } from "@/lib/tz/date-only";
|
||||
import { dateOnlyAtNoonUtc } from "@/lib/tz/date-only";
|
||||
|
||||
import {
|
||||
DAY_EVENT_KINDS,
|
||||
@@ -34,12 +34,13 @@ import {
|
||||
} from "./contract";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
|
||||
/* ─── Shared pieces ───────────────────────────────────────────────────────── */
|
||||
import { dateKeySchema } from "./date-key-schema";
|
||||
import {
|
||||
timelineSymptomOption,
|
||||
timelineSymptomSeries,
|
||||
} from "./timeline-symptom-schemas";
|
||||
|
||||
export const dateKeySchema = z
|
||||
.string()
|
||||
.refine(isCalendarDateKey, { message: "Expected a YYYY-MM-DD calendar date" })
|
||||
.describe("A local calendar date, `YYYY-MM-DD`.");
|
||||
/* ─── Shared pieces ───────────────────────────────────────────────────────── */
|
||||
|
||||
const instant = isoDateTime();
|
||||
|
||||
@@ -401,7 +402,7 @@ export const timelineQuerySchema = withWindowRules(
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Comma-separated value series to include, as `MeasurementType` names, `MOOD`, or a lab marker as `lab:<biomarkerId>` (see `availableLabs`). A name given twice counts once, so the list is bounded by the names that exist; an unknown measurement name or a malformed marker key answers 422. At most 24 lab markers are drawn (ids, unlike names, are not a closed set): the first 24 are kept and any further ones left out without an error. A lab marker that is not in the record (deleted, or another record's) is likewise left out of `labSeries`, so a stored choice never breaks the page.",
|
||||
"Comma-separated value series to include, as `MeasurementType` names, `MOOD`, a lab marker as `lab:<biomarkerId>` (see `availableLabs`), or a person-defined symptom as `symptom:<definitionId>` (see `availableSymptoms`). A name given twice counts once, so the list is bounded by the names that exist; an unknown measurement name or a malformed marker or symptom key answers 422. At most 24 lab markers and 24 symptoms are drawn (ids, unlike names, are not a closed set): the first 24 of each are kept and any further ones left out without an error. A lab marker or symptom that is not in the record (deleted, or another record's) is likewise left out of `labSeries` / `symptomSeries`, so a stored choice never breaks the page.",
|
||||
),
|
||||
}),
|
||||
);
|
||||
@@ -616,6 +617,16 @@ export const timelineResponseSchema = z
|
||||
.describe(
|
||||
"Every lab marker this record has a numeric reading for, whatever the window, when the caller may see labs. Each may be named in `values` by its `key`.",
|
||||
),
|
||||
symptomSeries: z
|
||||
.array(timelineSymptomSeries)
|
||||
.describe(
|
||||
"The person-defined symptoms named in `values`, in the order named, each averaged per `bucket` over the days it was logged. A symptom named but not found in the record is left out.",
|
||||
),
|
||||
availableSymptoms: z
|
||||
.array(timelineSymptomOption)
|
||||
.describe(
|
||||
"Every person-defined symptom this record has logged at least once, hidden ones included (their history is kept), in the record's own order, when the caller may see illness data. Each may be named in `values` by its `key`.",
|
||||
),
|
||||
notable: z.array(
|
||||
z.object({ date: dateKeySchema, kind: z.enum(DAY_NOTABLE_KINDS) }),
|
||||
),
|
||||
|
||||
@@ -19,6 +19,7 @@ import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority";
|
||||
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { metricKeyForType } from "@/lib/measurements/cumulative-day-sum";
|
||||
import { userDayKey } from "@/lib/tz/resolver";
|
||||
import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock";
|
||||
import { resolveCanonicalRecovery } from "@/lib/insights/derived/recovery-resolve";
|
||||
import {
|
||||
buildComplianceMedicationContext,
|
||||
@@ -320,6 +321,28 @@ function parseIsoDate(value: unknown): Date | null {
|
||||
return d;
|
||||
}
|
||||
|
||||
/**
|
||||
* The instant 1 ms after the same wall clock `days` calendar days before
|
||||
* `end`, in `tz`. Offsets are whole minutes, so the milliseconds within the
|
||||
* second carry over unchanged.
|
||||
*/
|
||||
function localDaysBefore(end: Date, days: number, tz: string): Date {
|
||||
const at = wallClockInTz(end, tz);
|
||||
const day = new Date(Date.UTC(at.year, at.month - 1, at.day - days));
|
||||
const start = zonedWallClockToUtc(
|
||||
{
|
||||
year: day.getUTCFullYear(),
|
||||
month: day.getUTCMonth() + 1,
|
||||
day: day.getUTCDate(),
|
||||
hour: at.hour,
|
||||
minute: at.minute,
|
||||
second: at.second,
|
||||
},
|
||||
tz,
|
||||
);
|
||||
return new Date(start.getTime() + end.getUTCMilliseconds() + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn an arbitrary `{ startDate?, endDate?, days? }` payload into a validated
|
||||
* inclusive reporting window.
|
||||
@@ -330,6 +353,10 @@ function parseIsoDate(value: unknown): Date | null {
|
||||
* keeps its end and is clamped to the last 730 days before it; it used
|
||||
* to fall through to the 90-day default, a much shorter report than the
|
||||
* one asked for. The cover line prints the window the report covers.
|
||||
* The 730 days are local days in `tz`: the start is the end's wall clock
|
||||
* 730 calendar days back, plus 1 ms, so an end at 23:59:59.999 clamps to
|
||||
* midnight however many clock changes lie between. Counting 730 × 24 h
|
||||
* instead lands an hour off midnight across an odd number of them.
|
||||
* 2. Neither/invalid range, but a valid `days` integer (1..365) is provided
|
||||
* → fall back to "last `days` days ending now".
|
||||
* 3. Otherwise fall back to "last 90 days ending now" (the v1.4.14 default).
|
||||
@@ -341,6 +368,8 @@ function parseIsoDate(value: unknown): Date | null {
|
||||
export function normaliseDateRange(
|
||||
value: unknown,
|
||||
now: Date = new Date(),
|
||||
/** The record's timezone; the clamp counts local days in it. */
|
||||
tz = "UTC",
|
||||
): DoctorReportRange {
|
||||
const body =
|
||||
typeof value === "object" && value !== null
|
||||
@@ -358,7 +387,7 @@ export function normaliseDateRange(
|
||||
const spanDays = Math.ceil((spanMs - DST_SLACK_MS) / DAY_MS) || 1;
|
||||
if (spanDays > REPORT_MAX_RANGE_DAYS) {
|
||||
return {
|
||||
start: new Date(endMs - REPORT_MAX_RANGE_DAYS * DAY_MS + 1),
|
||||
start: localDaysBefore(endCandidate, REPORT_MAX_RANGE_DAYS, tz),
|
||||
end: endCandidate,
|
||||
days: REPORT_MAX_RANGE_DAYS,
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* The write goes through `createSubordinatePeriodDays`, which attributes each
|
||||
* created day to its cycle and opens the cycle a first bleeding day starts,
|
||||
* as every other cycle writer does. A finished period also stamps its last
|
||||
* day on that cycle, when the cycle has no period end yet.
|
||||
* day on that cycle, when Garmin opened it and it has no period end yet.
|
||||
*
|
||||
* `GET /periodichealth-service/menstrualcycle/summary/{date}` describes the
|
||||
* cycle a date falls in: its start and its period length. A cycle Garmin
|
||||
@@ -98,7 +98,12 @@ export async function writeGarminPeriod(
|
||||
period.days,
|
||||
);
|
||||
if (period.ended) {
|
||||
await stampSubordinatePeriodEnd(userId, period.start, period.days.at(-1)!);
|
||||
await stampSubordinatePeriodEnd(
|
||||
userId,
|
||||
"GARMIN",
|
||||
period.start,
|
||||
period.days.at(-1)!,
|
||||
);
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* A `*One` string carries the count wherever "one" is more than 1.
|
||||
*
|
||||
* `pluralTier` takes the tier from CLDR, and in several shipped languages the
|
||||
* "one" category is not the number 1 alone: Russian files 21, 31, 101 under
|
||||
* it, French and Brazilian Portuguese file 0 there. A `*One` value that
|
||||
* spells the number out ("за 1 день") then reads "1" for 21 days. In those
|
||||
* locales the value takes the `{count}` that `tCount` passes, and a call that
|
||||
* reaches such a key directly passes the count too.
|
||||
*
|
||||
* Both checks are functions of their input, and each is shown failing once on
|
||||
* an input built to break it.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { readSourceFolder } from "@/__tests__/helpers/source-files";
|
||||
import { resolveIntlLocale } from "@/lib/format-locale";
|
||||
|
||||
import { locales, type Locale } from "../config";
|
||||
import { pluralKey } from "../plural";
|
||||
import { getServerTranslator } from "../server-translator";
|
||||
import { allMessages } from "../shared-resolve";
|
||||
|
||||
/** Locales whose CLDR "one" category holds an integer other than 1. */
|
||||
function localesWithWideOne(): Locale[] {
|
||||
return locales.filter((locale) => {
|
||||
const rules = new Intl.PluralRules(resolveIntlLocale(locale));
|
||||
for (let n = 0; n <= 1000; n++) {
|
||||
if (n !== 1 && rules.select(n) === "one") return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
function flatten(
|
||||
node: unknown,
|
||||
prefix = "",
|
||||
out = new Map<string, string>(),
|
||||
): Map<string, string> {
|
||||
if (typeof node === "string") {
|
||||
out.set(prefix, node);
|
||||
} else if (node && typeof node === "object") {
|
||||
for (const [key, value] of Object.entries(node)) {
|
||||
flatten(value, prefix ? `${prefix}.${key}` : key, out);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function bundle(locale: Locale): Map<string, string> {
|
||||
return flatten(allMessages[locale]);
|
||||
}
|
||||
|
||||
/** A digit 1 standing on its own, not part of a larger number. */
|
||||
const BARE_ONE = /(?<!\d)1(?!\d)/;
|
||||
|
||||
/** `*One` values that spell out the 1 instead of carrying `{count}`. */
|
||||
function spelledOutOnes(messages: Map<string, string>): string[] {
|
||||
const offenders: string[] = [];
|
||||
for (const [key, value] of messages) {
|
||||
if (!key.endsWith("One")) continue;
|
||||
if (BARE_ONE.test(value) && !value.includes("{count}")) {
|
||||
offenders.push(key);
|
||||
}
|
||||
}
|
||||
return offenders.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* `t("<key>One")` without a `count` param, for a key whose value needs
|
||||
* `{count}`: no params at all, or an inline params object that lacks it.
|
||||
*/
|
||||
function directCallsWithoutCount(
|
||||
sources: ReadonlyArray<{ file: string; text: string }>,
|
||||
needsCount: ReadonlySet<string>,
|
||||
): string[] {
|
||||
const call = /\bt\(\s*"([\w.]+One)"\s*(?:,\s*\{([^}]*)\})?\s*,?\s*\)/g;
|
||||
const offenders: string[] = [];
|
||||
for (const { file, text } of sources) {
|
||||
for (const match of text.matchAll(call)) {
|
||||
const params = match[2];
|
||||
if (params !== undefined && /\bcount\b/.test(params)) continue;
|
||||
if (needsCount.has(match[1]!)) offenders.push(`${file}: ${match[1]}`);
|
||||
}
|
||||
}
|
||||
return offenders.sort();
|
||||
}
|
||||
|
||||
describe("plural One strings", () => {
|
||||
const wide = localesWithWideOne();
|
||||
|
||||
it("finds the locales whose one is not just 1", () => {
|
||||
expect(wide).toEqual(expect.arrayContaining(["ru", "fr", "pt-BR"]));
|
||||
expect(wide).not.toContain("en");
|
||||
expect(wide).not.toContain("pl");
|
||||
});
|
||||
|
||||
it("says 21 days in Russian, not 1", () => {
|
||||
const { t } = getServerTranslator("ru");
|
||||
expect(
|
||||
t(pluralKey("timeline.symptoms.days", 21, "ru"), { count: 21 }),
|
||||
).toBe("за 21 день");
|
||||
});
|
||||
|
||||
it("gives every One string in those locales the count", () => {
|
||||
const offenders = wide.flatMap((locale) =>
|
||||
spelledOutOnes(bundle(locale)).map((key) => `${locale}: ${key}`),
|
||||
);
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("passes the count wherever such a key is called directly", () => {
|
||||
const needsCount = new Set<string>();
|
||||
for (const locale of wide) {
|
||||
for (const [key, value] of bundle(locale)) {
|
||||
if (key.endsWith("One") && value.includes("{count}")) {
|
||||
needsCount.add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(needsCount.size).toBeGreaterThan(0);
|
||||
const sources = readSourceFolder("src", {
|
||||
floor: 1000,
|
||||
include: (rel) =>
|
||||
!rel.includes("__tests__") && !rel.includes("generated/"),
|
||||
}).map(([file, text]) => ({ file, text }));
|
||||
expect(directCallsWithoutCount(sources, needsCount)).toEqual([]);
|
||||
});
|
||||
|
||||
it("would catch a spelled-out one and a call without the count", () => {
|
||||
expect(
|
||||
spelledOutOnes(
|
||||
new Map([
|
||||
["timeline.symptoms.daysOne", "за 1 день"],
|
||||
["timeline.symptoms.daysOther", "за {count} дней"],
|
||||
["a.bOne", "за {count} день"],
|
||||
["a.cOne", "Скрыто (11)"],
|
||||
]),
|
||||
),
|
||||
).toEqual(["timeline.symptoms.daysOne"]);
|
||||
expect(
|
||||
directCallsWithoutCount(
|
||||
[
|
||||
{ file: "x.tsx", text: 'n === 1 ? t("a.bOne") : t("a.bOther")' },
|
||||
{ file: "y.tsx", text: 't("a.bOne", { hours: h })' },
|
||||
{ file: "z.tsx", text: 't("a.bOne", { count: n, hours: h })' },
|
||||
],
|
||||
new Set(["a.bOne"]),
|
||||
),
|
||||
).toEqual(["x.tsx: a.bOne", "y.tsx: a.bOne"]);
|
||||
});
|
||||
});
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
type MarkerIndex,
|
||||
} from "@/lib/labs/biomarker-resolve";
|
||||
import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store";
|
||||
import { mintedBounds } from "@/lib/labs/minted-bounds";
|
||||
import { parseReferenceRange } from "@/lib/labs/parse-reference-range";
|
||||
import { readingInMarkerUnit } from "@/lib/labs/reading-conversion";
|
||||
import {
|
||||
@@ -255,7 +256,21 @@ interface Planned {
|
||||
marker: IndexedMarker | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The window the write keeps on the reading: the printed one for a number, none
|
||||
* for a text result (its range seeds a new marker, not the reading).
|
||||
*/
|
||||
function readingWindow(row: LabCsvRow): {
|
||||
low: number | null;
|
||||
high: number | null;
|
||||
} {
|
||||
return row.valueText === null
|
||||
? { low: row.sourceReferenceLow, high: row.sourceReferenceHigh }
|
||||
: { low: null, high: null };
|
||||
}
|
||||
|
||||
function displayRow(row: LabCsvRow, marker: string) {
|
||||
const window = readingWindow(row);
|
||||
return {
|
||||
line: row.line,
|
||||
marker,
|
||||
@@ -263,8 +278,8 @@ function displayRow(row: LabCsvRow, marker: string) {
|
||||
valueText: row.valueText,
|
||||
unit: row.unit,
|
||||
takenAt: row.takenAt.toISOString(),
|
||||
referenceLow: row.sourceReferenceLow,
|
||||
referenceHigh: row.sourceReferenceHigh,
|
||||
referenceLow: window.low,
|
||||
referenceHigh: window.high,
|
||||
panel: row.panel,
|
||||
note: row.note !== null,
|
||||
};
|
||||
@@ -346,8 +361,7 @@ export async function importLabsCsv(args: {
|
||||
id: `pending:${pendingId++}`,
|
||||
name: row.analyte,
|
||||
unit,
|
||||
lowerBound: row.sourceReferenceLow,
|
||||
upperBound: row.sourceReferenceHigh,
|
||||
...mintedBounds(row.sourceReferenceLow, row.sourceReferenceHigh),
|
||||
panel: row.panel,
|
||||
analyteKey: matchAnalyteKey({ name: row.analyte, unit }),
|
||||
hidden: false,
|
||||
@@ -484,8 +498,7 @@ export async function importLabsCsv(args: {
|
||||
if (!marker) created.add(row.analyte);
|
||||
|
||||
let storedValue = row.value;
|
||||
let storedLow = row.valueText === null ? row.sourceReferenceLow : null;
|
||||
let storedHigh = row.valueText === null ? row.sourceReferenceHigh : null;
|
||||
let { low: storedLow, high: storedHigh } = readingWindow(row);
|
||||
let convertedFrom: { sourceValue: number; sourceUnit: string } | null =
|
||||
null;
|
||||
if (row.value !== null) {
|
||||
|
||||
@@ -19,6 +19,18 @@
|
||||
* pass converges: a second run finds nothing, and a boot with nothing to
|
||||
* repair costs one query.
|
||||
*
|
||||
* It repairs, and never re-decides what a person took back. A cycle she
|
||||
* deleted (or whose first day she deleted or cleared) stays as a tombstone
|
||||
* with no `absorbedIntoId`; its wearable days are left without a cycle when
|
||||
* nothing lies before them, which looks exactly like the backlog. A day that
|
||||
* lies within the hard minimum cycle length after such a removed start is
|
||||
* therefore never a candidate: inside that span it can only be the period she
|
||||
* removed, and reopening it on the next boot would undo her delete. A start
|
||||
* folded into another one is not a removal and does not count.
|
||||
*
|
||||
* Accounts with the cycle module off are left alone, as the syncs leave them
|
||||
* (`isCycleAvailableForUser`), and their rows do not make a boot enqueue.
|
||||
*
|
||||
* Only cycles and the `cycleId` link move. No day-log's own content is
|
||||
* touched, a person's own rows included. A Garmin period whose cycle this pass
|
||||
* opened and that is over (a later cycle has started) has its last day stamped
|
||||
@@ -30,6 +42,7 @@ import { Prisma, type PrismaClient } from "@/generated/prisma/client";
|
||||
import { activeAccountSql } from "@/lib/account-deletion/active-account";
|
||||
import { ensureCycleForBleedingDay } from "@/lib/cycle/cycle-boundaries";
|
||||
import { addDays } from "@/lib/cycle/day-math";
|
||||
import { isCycleAvailableForUser } from "@/lib/cycle/gate";
|
||||
import { stampSubordinatePeriodEnd } from "@/lib/cycle/subordinate-period-days";
|
||||
import { HARD_CYCLE_MIN, PERIOD_MAX } from "@/lib/cycle/types";
|
||||
import { prisma as defaultPrisma } from "@/lib/db";
|
||||
@@ -57,7 +70,8 @@ interface CandidateRow {
|
||||
* The SQL half of the rule. `limit` turns it into the boot's existence check.
|
||||
* The second arm mirrors `ensureCycleForBleedingDay`: with no cycle on or
|
||||
* before the day, it opens one unless a later start lies within the hard
|
||||
* minimum cycle length without being the same period.
|
||||
* minimum cycle length without being the same period. The last arm keeps
|
||||
* out every day a removed start still speaks for (see the file comment).
|
||||
*/
|
||||
function candidateQuery(limit: number | null): Prisma.Sql {
|
||||
return Prisma.sql`
|
||||
@@ -82,6 +96,14 @@ function candidateQuery(limit: number | null): Prisma.Sql {
|
||||
AND c."start_date" < to_char(d."date"::date + ${HARD_CYCLE_MIN}::int, 'YYYY-MM-DD')
|
||||
)
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "menstrual_cycles" r
|
||||
WHERE r."user_id" = d."user_id"
|
||||
AND r."deleted_at" IS NOT NULL
|
||||
AND r."absorbed_into_id" IS NULL
|
||||
AND r."start_date" <= d."date"
|
||||
AND r."start_date" > to_char(d."date"::date - ${HARD_CYCLE_MIN}::int, 'YYYY-MM-DD')
|
||||
)
|
||||
AND ${activeAccountSql('d."user_id"')}
|
||||
ORDER BY d."user_id", d."date"
|
||||
${limit === null ? Prisma.empty : Prisma.sql`LIMIT ${limit}`}
|
||||
@@ -97,7 +119,7 @@ export interface WearableCycleRepairResult {
|
||||
attributed: number;
|
||||
/** Garmin period ends stamped on an opened cycle. */
|
||||
periodEnds: number;
|
||||
/** Accounts touched. */
|
||||
/** Accounts repaired (the cycle module on). */
|
||||
accounts: number;
|
||||
}
|
||||
|
||||
@@ -182,7 +204,10 @@ async function repairAccount(
|
||||
// An open cycle may still be bleeding; the hourly sync stamps its end.
|
||||
if (!cycle?.endDate) continue;
|
||||
const end = await garminRunEnd(prisma, userId, start);
|
||||
if (end && (await stampSubordinatePeriodEnd(userId, start, end))) {
|
||||
if (
|
||||
end &&
|
||||
(await stampSubordinatePeriodEnd(userId, "GARMIN", start, end))
|
||||
) {
|
||||
result.periodEnds += 1;
|
||||
}
|
||||
}
|
||||
@@ -204,9 +229,11 @@ export async function repairWearableCycleDays(
|
||||
opened: 0,
|
||||
attributed: 0,
|
||||
periodEnds: 0,
|
||||
accounts: byUser.size,
|
||||
accounts: 0,
|
||||
};
|
||||
for (const [userId, list] of byUser) {
|
||||
if (!(await isCycleAvailableForUser(userId))) continue;
|
||||
result.accounts += 1;
|
||||
await repairAccount(prisma, userId, list, result);
|
||||
}
|
||||
return result;
|
||||
@@ -244,9 +271,24 @@ export async function handleWearableCycleRepair(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any account with candidates has the cycle module on. Candidates
|
||||
* come in account order, so the first account that may be repaired ends it.
|
||||
*/
|
||||
async function anyRepairableAccount(prisma: PrismaClient): Promise<boolean> {
|
||||
const rows = await prisma.$queryRaw<CandidateRow[]>(candidateQuery(null));
|
||||
const seen = new Set<string>();
|
||||
for (const row of rows) {
|
||||
if (seen.has(row.userId)) continue;
|
||||
seen.add(row.userId);
|
||||
if (await isCycleAvailableForUser(row.userId)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot discovery: one existence query, and the one job only when there is
|
||||
* something to repair.
|
||||
* something to repair for an account that tracks its cycle.
|
||||
*/
|
||||
export async function enqueueBootTimeWearableCycleRepair(
|
||||
prisma: PrismaClient = defaultPrisma,
|
||||
@@ -256,6 +298,9 @@ export async function enqueueBootTimeWearableCycleRepair(
|
||||
try {
|
||||
const any = await prisma.$queryRaw<CandidateRow[]>(candidateQuery(1));
|
||||
if (any.length === 0) return { enqueued: false, error: null };
|
||||
if (!(await anyRepairableAccount(prisma))) {
|
||||
return { enqueued: false, error: null };
|
||||
}
|
||||
const id = await boss.send(
|
||||
WEARABLE_CYCLE_REPAIR_QUEUE,
|
||||
{},
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
type IndexedMarker,
|
||||
type MarkerIndex,
|
||||
} from "@/lib/labs/biomarker-resolve";
|
||||
import { mintedBounds } from "@/lib/labs/minted-bounds";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
|
||||
/** Encrypt a UTF-8 context note into the `Bytes` payload the schema stores. */
|
||||
@@ -89,7 +90,10 @@ export interface MintedBiomarker {
|
||||
* bounds are the report's printed window whether the reading is a number or a
|
||||
* text result ("SEE NOTE" beside "10 - 20"): the marker outlives the reading,
|
||||
* and its next number is judged against them. A window that cannot be true
|
||||
* (low above high) is not minted; nothing says which of the two is wrong. The
|
||||
* (low above high) is not minted; nothing says which of the two is wrong. A
|
||||
* text result that states no unit mints a marker with none, and its range is
|
||||
* then a unitless one: the first number keeps it only under the unity "1"
|
||||
* (`adoptFirstNumericUnit` in `./reading-conversion.ts`). The
|
||||
* `@@unique([userId, name])` index is the structural backstop; a concurrent
|
||||
* double-write converges via the catch-and-refetch.
|
||||
*
|
||||
@@ -113,18 +117,18 @@ export async function resolveOrMintBiomarker(
|
||||
const hit = resolveInIndex(index, name, input.unit || null);
|
||||
if (hit) return toMinted(hit.marker);
|
||||
|
||||
const transposed =
|
||||
input.referenceLow !== null &&
|
||||
input.referenceHigh !== null &&
|
||||
input.referenceLow > input.referenceHigh;
|
||||
const { lowerBound, upperBound } = mintedBounds(
|
||||
input.referenceLow,
|
||||
input.referenceHigh,
|
||||
);
|
||||
try {
|
||||
const created = await prisma.biomarker.create({
|
||||
data: {
|
||||
userId,
|
||||
name,
|
||||
unit: input.unit,
|
||||
lowerBound: transposed ? null : input.referenceLow,
|
||||
upperBound: transposed ? null : input.referenceHigh,
|
||||
lowerBound,
|
||||
upperBound,
|
||||
panel: input.panel,
|
||||
analyteKey: matchAnalyteKey({ name, unit: input.unit }),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* The range a new marker is minted with: the printed window, unless it cannot
|
||||
* be true (low above high), when nothing says which of the two is wrong and
|
||||
* neither is kept. Every place that mints a marker, or pretends to for an
|
||||
* import preview, takes its bounds from here.
|
||||
*
|
||||
* Pure and dependency-free.
|
||||
*/
|
||||
export function mintedBounds(
|
||||
low: number | null,
|
||||
high: number | null,
|
||||
): { lowerBound: number | null; upperBound: number | null } {
|
||||
if (low !== null && high !== null && low > high) {
|
||||
return { lowerBound: null, upperBound: null };
|
||||
}
|
||||
return { lowerBound: low, upperBound: high };
|
||||
}
|
||||
@@ -16,6 +16,8 @@
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
import { ensureAnalyteKey } from "./analyte-key-store";
|
||||
import { mintedBounds } from "./minted-bounds";
|
||||
import { isDimensionlessUnit } from "./dimensionless";
|
||||
import {
|
||||
convertLabRange,
|
||||
convertLabValue,
|
||||
@@ -28,6 +30,9 @@ export interface ReadingMarker {
|
||||
name: string;
|
||||
unit: string;
|
||||
analyteKey?: string | null;
|
||||
/** Set on the caller's object when the first numeric unit replaces them. */
|
||||
lowerBound?: number | null;
|
||||
upperBound?: number | null;
|
||||
}
|
||||
|
||||
export type ReadingInMarkerUnit =
|
||||
@@ -66,7 +71,10 @@ export async function readingInMarkerUnit(
|
||||
input;
|
||||
if (
|
||||
unit !== undefined &&
|
||||
(await adoptFirstNumericUnit(client, marker, unit))
|
||||
(await adoptFirstNumericUnit(client, marker, unit, {
|
||||
low: sourceReferenceLow,
|
||||
high: sourceReferenceHigh,
|
||||
}))
|
||||
) {
|
||||
return {
|
||||
ok: true,
|
||||
@@ -128,11 +136,21 @@ export async function readingInMarkerUnit(
|
||||
* caller's object, which writes the reading under it. The adoption is not in
|
||||
* the reading's transaction: if the write fails after it, the marker keeps
|
||||
* the unit the reading stated, which is the unit its next reading will state.
|
||||
*
|
||||
* The range the marker holds came with no unit: the text result it was minted
|
||||
* from stated none. Numbers stated without a unit are dimensionless, so they
|
||||
* stay only when the adopted unit is the unity ("1"), the ratio a text result
|
||||
* beside "10 - 20" usually is. Under any other unit they are not a window
|
||||
* (a "30 - 100" printed in ng/mL is not one in nmol/L, and there is no unit
|
||||
* to convert from), so they give way to the window this reading printed in
|
||||
* the unit it states, or to none, the way a marker this reading had minted
|
||||
* would have started.
|
||||
*/
|
||||
async function adoptFirstNumericUnit(
|
||||
client: Pick<PrismaClient, "biomarker" | "labResult">,
|
||||
marker: ReadingMarker,
|
||||
unit: string,
|
||||
printed: { low: number | null; high: number | null },
|
||||
): Promise<boolean> {
|
||||
const stated = unit.trim();
|
||||
if (marker.unit.trim() !== "" || stated === "") return false;
|
||||
@@ -140,9 +158,12 @@ async function adoptFirstNumericUnit(
|
||||
where: { biomarkerId: marker.id, value: { not: null }, deletedAt: null },
|
||||
});
|
||||
if (numeric > 0) return false;
|
||||
const bounds = isDimensionlessUnit(stated)
|
||||
? null
|
||||
: mintedBounds(printed.low, printed.high);
|
||||
const { count } = await client.biomarker.updateMany({
|
||||
where: { id: marker.id, unit: "" },
|
||||
data: { unit: stated },
|
||||
data: { unit: stated, ...bounds },
|
||||
});
|
||||
if (count === 0) {
|
||||
// Another reading set the unit first: carry on with the one it chose,
|
||||
@@ -155,5 +176,9 @@ async function adoptFirstNumericUnit(
|
||||
return false;
|
||||
}
|
||||
marker.unit = stated;
|
||||
if (bounds) {
|
||||
marker.lowerBound = bounds.lowerBound;
|
||||
marker.upperBound = bounds.upperBound;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -1844,7 +1844,7 @@
|
||||
{
|
||||
"name": "get_cycle",
|
||||
"title": "Get menstrual cycle",
|
||||
"description": "Fetch the user's menstrual-cycle context: current phase + day-of-cycle, the next predicted event, and the headline phase-correlation finding. Descriptive only — never a contraception-grade or 'safe day' claim. Gated on cycle tracking being enabled for the account; returns { present: false } when cycle tracking is off or there is no data.",
|
||||
"description": "Fetch the user's menstrual-cycle context: current phase + day-of-cycle, the next predicted event, the upcoming period starts after it (`upcoming`: each a likely start with its earliest-to-latest band, widening and self-capped), and the headline phase-correlation finding. Descriptive only — never a contraception-grade or 'safe day' claim. Gated on cycle tracking being enabled for the account; returns { present: false } when cycle tracking is off or there is no data.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
|
||||
@@ -326,6 +326,11 @@ describe("MCP tool registry — surface", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("tells a client that get_cycle returns the upcoming periods", () => {
|
||||
const def = MCP_TOOLS.find((t) => t.name === "get_cycle");
|
||||
expect(def?.description).toContain("`upcoming`");
|
||||
});
|
||||
|
||||
it("annotates every tool read-only / closed-world (cloud-connector requirement)", () => {
|
||||
for (const def of MCP_TOOLS) {
|
||||
expect(def.annotations.readOnlyHint).toBe(true);
|
||||
|
||||
@@ -6,7 +6,7 @@ export const getCycleTool: McpToolDefinition = {
|
||||
name: "get_cycle",
|
||||
title: "Get menstrual cycle",
|
||||
description:
|
||||
"Fetch the user's menstrual-cycle context: current phase + day-of-cycle, the next predicted event, and the headline phase-correlation finding. Descriptive only — never a contraception-grade or 'safe day' claim. Gated on cycle tracking being enabled for the account; returns { present: false } when cycle tracking is off or there is no data.",
|
||||
"Fetch the user's menstrual-cycle context: current phase + day-of-cycle, the next predicted event, the upcoming period starts after it (`upcoming`: each a likely start with its earliest-to-latest band, widening and self-capped), and the headline phase-correlation finding. Descriptive only — never a contraception-grade or 'safe day' claim. Gated on cycle tracking being enabled for the account; returns { present: false } when cycle tracking is off or there is no data.",
|
||||
inputShape: {},
|
||||
annotations: READ_ONLY_ANNOTATIONS,
|
||||
outputShape: coachReadOutput,
|
||||
|
||||
@@ -15,8 +15,8 @@ import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { LAB_UNIT_DESCRIPTION } from "@/lib/labs/dimensionless";
|
||||
import { dateKeySchema } from "@/lib/day/date-key-schema";
|
||||
import {
|
||||
dateKeySchema,
|
||||
labSeriesQuerySchema,
|
||||
timelineLabSeries,
|
||||
} from "@/lib/day/wire-schemas";
|
||||
|
||||
@@ -271,13 +271,18 @@ export const cycleFeedPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Cycle"],
|
||||
summary: "Replace the calendar subscription's link (#1350)",
|
||||
description:
|
||||
"One transaction: the live link is revoked and a new one minted with the same options. Returns the new URL EXACTLY ONCE. Calendars using the old link stop updating.",
|
||||
"One transaction: the live link is revoked and a new one minted with the same options. Returns the new URL EXACTLY ONCE. Calendars using the old link stop updating. Of two rotations racing, the one that loses answers 409 `cycle.feed.exists` and the winner's link stands.",
|
||||
responses: {
|
||||
...mintResponses,
|
||||
"404": {
|
||||
description: "No subscription is on (`cycle.feed.none`).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"409": {
|
||||
description:
|
||||
"Another rotation replaced the link at the same moment (`cycle.feed.exists`).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -129,7 +129,7 @@ export const dayPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Records"],
|
||||
summary: "The record over the years",
|
||||
description:
|
||||
"Lanes of conditions, allergies, medications, vaccinations, visits, lab days, documents and life events, the standing items without a start, and monthly (or finer, by zoom) value series. Empty lanes are not sent; lanes of a switched-off module never are. The `life` lane (life events and travel periods) is the owner's only and is never sent to a delegate. Requires the opt-in `timeline` module.",
|
||||
"Lanes of conditions, allergies, medications, vaccinations, visits, lab days, documents and life events, the standing items without a start, and monthly (or finer, by zoom) value series, among them the lab markers (`labSeries`, single readings) and the person-defined symptoms (`symptomSeries`, the day's highest intensity averaged over logged days, on a fixed 0 to 10 scale; read only with the illness module on and the illness section visible) named in `values`. Empty lanes are not sent; lanes of a switched-off module never are. The `life` lane (life events and travel periods) is the owner's only and is never sent to a delegate. Requires the opt-in `timeline` module.",
|
||||
requestParams: { query: timelineQuerySchema },
|
||||
responses: {
|
||||
"200": {
|
||||
|
||||
@@ -256,7 +256,7 @@ const syncChangesQuery = z
|
||||
.literal("now")
|
||||
.optional()
|
||||
.describe(
|
||||
"`now` answers an empty page (every domain empty, `hasMore: false`) whose `cursor` sits at `serverNow` in every domain, in the same opaque format as any other page. Paging forward from it yields only what changes from then on, including `measurementMirror`, without walking the history first. Mutually exclusive with `cursor` (422). A server without it ignores the parameter and starts from the oldest row.",
|
||||
"`now` answers an empty page (every domain empty, `hasMore: false`) whose `cursor` sits one minute before `serverNow` in every domain, in the same opaque format as any other page. Paging forward from it yields what changes from then on, plus anything written in that last minute (a write stamped before `serverNow` can commit after it, and is not skipped; upserts are idempotent, so a row seen twice is harmless), including `measurementMirror`, without walking the history first. Mutually exclusive with `cursor` (422). A server without it ignores the parameter and starts from the oldest row.",
|
||||
),
|
||||
limit: z.coerce
|
||||
.number()
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
decodeCursor,
|
||||
decodeCursorIssuedAt,
|
||||
encodeCursorAtNow,
|
||||
START_NOW_OVERLAP_MS,
|
||||
SYNC_DOMAINS,
|
||||
type SyncCursor,
|
||||
} from "../cursor";
|
||||
@@ -138,12 +139,18 @@ describe("cursor issue time (sync reset)", () => {
|
||||
});
|
||||
|
||||
describe("encodeCursorAtNow", () => {
|
||||
it("places every domain at the instant, before any id in that millisecond", () => {
|
||||
it("places every domain a minute back, before any id in that millisecond", () => {
|
||||
const nowMs = 1_790_000_000_123;
|
||||
const token = encodeCursorAtNow(nowMs);
|
||||
const cursor = decodeCursor(token);
|
||||
// A write stamped before the instant but committed after it is still
|
||||
// ahead of the watermark.
|
||||
expect(START_NOW_OVERLAP_MS).toBe(60_000);
|
||||
for (const domain of SYNC_DOMAINS) {
|
||||
expect(cursor?.[domain], domain).toEqual({ updatedAtMs: nowMs, id: "0" });
|
||||
expect(cursor?.[domain], domain).toEqual({
|
||||
updatedAtMs: nowMs - 60_000,
|
||||
id: "0",
|
||||
});
|
||||
}
|
||||
expect(decodeCursorIssuedAt(token)).toBe(nowMs);
|
||||
// Every cuid sorts after the tie-breaker, so a row in the same
|
||||
|
||||
@@ -110,8 +110,8 @@ function emptyChanges(): SyncChangesBody["changes"] {
|
||||
}
|
||||
|
||||
/**
|
||||
* `start=now` (#1339): an empty page with a cursor at `serverNow` in every
|
||||
* domain, for a client that wants changes from now on (the Apple Health
|
||||
* `start=now` (#1339): an empty page with a cursor a minute before
|
||||
* `serverNow` in every domain (`encodeCursorAtNow`), for a client that wants changes from now on (the Apple Health
|
||||
* write-back) without paging through the history first. `hasMore` is false:
|
||||
* the client is caught up as of `serverNow` by definition.
|
||||
*/
|
||||
|
||||
+18
-7
@@ -103,17 +103,28 @@ export function encodeCursor(cursor: SyncCursor, issuedAtMs?: number): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* A token positioned at `nowMs` in every domain (#1339), the same opaque
|
||||
* format a drained page hands out: paging forward from it yields only what
|
||||
* changes from now on, with no walk through the history first. The id `"0"`
|
||||
* sorts before every cuid, so a row stamped in the very millisecond is
|
||||
* delivered rather than skipped; the client's upserts are idempotent, so an
|
||||
* overlap costs nothing and a gap would.
|
||||
* How far behind the present a `start=now` cursor is placed. `updatedAt` is
|
||||
* stamped by the application when the write is built, not when it commits, so
|
||||
* a transaction in flight at `nowMs` can commit a row stamped before it after
|
||||
* the cursor was handed out; a watermark at `nowMs` would skip that row for
|
||||
* good. A minute covers any write the app keeps open.
|
||||
*/
|
||||
export const START_NOW_OVERLAP_MS = 60_000;
|
||||
|
||||
/**
|
||||
* A token positioned just before `nowMs` in every domain (#1339), the same
|
||||
* opaque format a drained page hands out: paging forward from it yields what
|
||||
* changes from now on, with no walk through the history first. The watermarks
|
||||
* sit `START_NOW_OVERLAP_MS` back and the id `"0"` sorts before every cuid, so
|
||||
* a row stamped in that last minute is delivered rather than skipped; the
|
||||
* client's upserts are idempotent, so an overlap costs nothing and a gap would.
|
||||
* The issue time stays `nowMs`: it is when the token was handed out.
|
||||
*/
|
||||
export function encodeCursorAtNow(nowMs: number): string {
|
||||
const cursor: SyncCursor = {};
|
||||
const watermark = nowMs - START_NOW_OVERLAP_MS;
|
||||
for (const domain of SYNC_DOMAINS) {
|
||||
cursor[domain] = { updatedAtMs: nowMs, id: "0" };
|
||||
cursor[domain] = { updatedAtMs: watermark, id: "0" };
|
||||
}
|
||||
return encodeCursor(cursor, nowMs);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Person-defined symptoms as lines on the timeline (#981): the day value,
|
||||
* the bucket mean over logged days, and the `values` entries that name one.
|
||||
*
|
||||
* A day's value is its highest intensity; a bucket's point is the mean of
|
||||
* its logged days' values, with the number of those days; a day without an
|
||||
* entry is not a zero. The day is the record's local day, so a late-evening
|
||||
* entry and an entry on a clock-change day land where the person lived them.
|
||||
* All data invented.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({ prisma: {} }));
|
||||
|
||||
import {
|
||||
capTimelineSeriesKeys,
|
||||
TIMELINE_LAB_SERIES_MAX,
|
||||
TIMELINE_SYMPTOM_SERIES_MAX,
|
||||
timelineSymptomDefinitionId,
|
||||
timelineSymptomKey,
|
||||
} from "@/lib/day/contract";
|
||||
|
||||
import { parseSeriesKeys } from "../load-timeline";
|
||||
import {
|
||||
foldSymptomSeries,
|
||||
symptomDayPeaks,
|
||||
type SymptomOccurrence,
|
||||
} from "../symptom-series";
|
||||
|
||||
const at = (iso: string, intensity: number): SymptomOccurrence => ({
|
||||
occurredAt: new Date(iso),
|
||||
intensity,
|
||||
});
|
||||
|
||||
const BERLIN = { from: "2026-10-01", to: "2026-11-30", tz: "Europe/Berlin" };
|
||||
|
||||
describe("symptomDayPeaks", () => {
|
||||
it("takes the highest intensity of each local day", () => {
|
||||
const peaks = symptomDayPeaks(
|
||||
[
|
||||
at("2026-10-05T07:00:00.000Z", 3),
|
||||
at("2026-10-05T15:00:00.000Z", 7),
|
||||
at("2026-10-05T18:00:00.000Z", 5),
|
||||
at("2026-10-06T09:00:00.000Z", 0),
|
||||
],
|
||||
BERLIN,
|
||||
);
|
||||
expect([...peaks]).toEqual([
|
||||
["2026-10-05", 7],
|
||||
["2026-10-06", 0],
|
||||
]);
|
||||
});
|
||||
|
||||
it("puts a late-evening entry on the local day, not the UTC one", () => {
|
||||
// 22:30 UTC on 25 Oct is 23:30 in Berlin, an hour after the clocks went
|
||||
// back; 23:30 UTC is already 26 Oct there.
|
||||
const peaks = symptomDayPeaks(
|
||||
[at("2026-10-25T22:30:00.000Z", 4), at("2026-10-25T23:30:00.000Z", 6)],
|
||||
BERLIN,
|
||||
);
|
||||
expect([...peaks]).toEqual([
|
||||
["2026-10-25", 4],
|
||||
["2026-10-26", 6],
|
||||
]);
|
||||
// West of UTC the cut runs the other way: 03:30 UTC on 9 March is still
|
||||
// 8 March in New York, the evening the clocks went forward.
|
||||
const ny = symptomDayPeaks([at("2026-03-09T03:30:00.000Z", 8)], {
|
||||
from: "2026-03-01",
|
||||
to: "2026-03-31",
|
||||
tz: "America/New_York",
|
||||
});
|
||||
expect([...ny]).toEqual([["2026-03-08", 8]]);
|
||||
});
|
||||
|
||||
it("holds a 23-hour clock-change day whole", () => {
|
||||
// 29 March 2026 in Berlin runs from 23:00 UTC on the 28th to 22:00 UTC.
|
||||
const peaks = symptomDayPeaks(
|
||||
[
|
||||
at("2026-03-28T23:05:00.000Z", 2),
|
||||
at("2026-03-29T21:55:00.000Z", 5),
|
||||
at("2026-03-29T22:05:00.000Z", 9),
|
||||
],
|
||||
{ from: "2026-03-01", to: "2026-03-31", tz: "Europe/Berlin" },
|
||||
);
|
||||
expect([...peaks]).toEqual([
|
||||
["2026-03-29", 5],
|
||||
["2026-03-30", 9],
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves out entries outside the window", () => {
|
||||
const peaks = symptomDayPeaks(
|
||||
[at("2026-09-30T12:00:00.000Z", 9), at("2026-12-01T12:00:00.000Z", 9)],
|
||||
BERLIN,
|
||||
);
|
||||
expect(peaks.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("foldSymptomSeries", () => {
|
||||
it("averages the day peaks over the logged days only, never counting a silent day as zero", () => {
|
||||
const points = foldSymptomSeries(
|
||||
[
|
||||
// Week of 5 Oct: two logged days, peaks 7 and 4.
|
||||
at("2026-10-05T07:00:00.000Z", 3),
|
||||
at("2026-10-05T15:00:00.000Z", 7),
|
||||
at("2026-10-08T15:00:00.000Z", 4),
|
||||
// Week of 19 Oct: one day.
|
||||
at("2026-10-21T10:00:00.000Z", 2),
|
||||
],
|
||||
BERLIN,
|
||||
"week",
|
||||
);
|
||||
expect(points).toEqual([
|
||||
{ t: "2026-10-05", mean: 5.5, count: 2 },
|
||||
{ t: "2026-10-19", mean: 2, count: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("sends no point for a bucket without an entry", () => {
|
||||
const points = foldSymptomSeries(
|
||||
[at("2026-10-02T10:00:00.000Z", 6), at("2026-11-20T10:00:00.000Z", 3)],
|
||||
BERLIN,
|
||||
"week",
|
||||
);
|
||||
expect(points.map((p) => p.t)).toEqual(["2026-09-28", "2026-11-16"]);
|
||||
});
|
||||
|
||||
it("buckets a late-evening entry by its local day, across a week boundary and the clock change", () => {
|
||||
// Sunday 25 Oct 23:30 in Berlin closes the week of 19 Oct; half an hour
|
||||
// later it is Monday and the next week.
|
||||
const points = foldSymptomSeries(
|
||||
[at("2026-10-25T22:30:00.000Z", 4), at("2026-10-25T23:30:00.000Z", 6)],
|
||||
BERLIN,
|
||||
"week",
|
||||
);
|
||||
expect(points).toEqual([
|
||||
{ t: "2026-10-19", mean: 4, count: 1 },
|
||||
{ t: "2026-10-26", mean: 6, count: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("follows the month, quarter and day buckets", () => {
|
||||
const rows = [
|
||||
at("2026-10-05T10:00:00.000Z", 8),
|
||||
at("2026-10-05T12:00:00.000Z", 2),
|
||||
at("2026-10-06T10:00:00.000Z", 3),
|
||||
at("2026-11-02T10:00:00.000Z", 4),
|
||||
];
|
||||
expect(foldSymptomSeries(rows, BERLIN, "month")).toEqual([
|
||||
{ t: "2026-10-01", mean: 5.5, count: 2 },
|
||||
{ t: "2026-11-01", mean: 4, count: 1 },
|
||||
]);
|
||||
expect(foldSymptomSeries(rows, BERLIN, "quarter")).toEqual([
|
||||
{ t: "2026-10-01", mean: 5, count: 3 },
|
||||
]);
|
||||
expect(foldSymptomSeries(rows, BERLIN, "day")).toEqual([
|
||||
{ t: "2026-10-05", mean: 8, count: 1 },
|
||||
{ t: "2026-10-06", mean: 3, count: 1 },
|
||||
{ t: "2026-11-02", mean: 4, count: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a logged zero as a value", () => {
|
||||
expect(
|
||||
foldSymptomSeries([at("2026-10-05T10:00:00.000Z", 0)], BERLIN, "month"),
|
||||
).toEqual([{ t: "2026-10-01", mean: 0, count: 1 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("symptom keys in `values`", () => {
|
||||
it("round-trips a definition id and refuses a malformed one", () => {
|
||||
expect(timelineSymptomKey("sd1")).toBe("symptom:sd1");
|
||||
expect(timelineSymptomDefinitionId("symptom:sd1")).toBe("sd1");
|
||||
expect(timelineSymptomDefinitionId("symptom:")).toBeNull();
|
||||
expect(timelineSymptomDefinitionId("symptom:../x")).toBeNull();
|
||||
expect(timelineSymptomDefinitionId("lab:sd1")).toBeNull();
|
||||
});
|
||||
|
||||
it("rides beside measurement names and lab markers, and a malformed one refuses", () => {
|
||||
expect(
|
||||
parseSeriesKeys("WEIGHT,symptom:sd1,lab:bm1,symptom:sd1,MOOD"),
|
||||
).toEqual(["WEIGHT", "symptom:sd1", "lab:bm1", "MOOD"]);
|
||||
expect(parseSeriesKeys("symptom:../etc")).toBeNull();
|
||||
});
|
||||
|
||||
it("caps symptoms and lab markers each on their own", () => {
|
||||
const symptoms = Array.from(
|
||||
{ length: TIMELINE_SYMPTOM_SERIES_MAX + 3 },
|
||||
(_, i) => `symptom:s${i}`,
|
||||
);
|
||||
const labs = Array.from(
|
||||
{ length: TIMELINE_LAB_SERIES_MAX + 2 },
|
||||
(_, i) => `lab:m${i}`,
|
||||
);
|
||||
const capped = capTimelineSeriesKeys(["WEIGHT", ...symptoms, ...labs]);
|
||||
expect(capped.filter((k) => k.startsWith("symptom:"))).toEqual(
|
||||
symptoms.slice(0, TIMELINE_SYMPTOM_SERIES_MAX),
|
||||
);
|
||||
expect(capped.filter((k) => k.startsWith("lab:"))).toEqual(
|
||||
labs.slice(0, TIMELINE_LAB_SERIES_MAX),
|
||||
);
|
||||
expect(capped[0]).toBe("WEIGHT");
|
||||
});
|
||||
});
|
||||
@@ -17,10 +17,11 @@ import { MeasurementType as MeasurementTypes } from "@/generated/prisma/enums";
|
||||
import type { MeasurementType } from "@/generated/prisma/enums";
|
||||
|
||||
import {
|
||||
capTimelineLabKeys,
|
||||
capTimelineSeriesKeys,
|
||||
DAY_NOTABLE_MAX_SPAN_DAYS,
|
||||
TIMELINE_LANE_KEYS,
|
||||
timelineLabMarkerId,
|
||||
timelineSymptomDefinitionId,
|
||||
type TimelineBucket,
|
||||
type TimelineItem,
|
||||
type TimelineQuery,
|
||||
@@ -43,6 +44,11 @@ import {
|
||||
loadLabSeries,
|
||||
} from "@/lib/timeline/lab-series";
|
||||
import { loadTimelineSeries, MOOD_SERIES_KEY } from "@/lib/timeline/series";
|
||||
import {
|
||||
firstSymptomDay,
|
||||
loadAvailableSymptoms,
|
||||
loadSymptomSeries,
|
||||
} from "@/lib/timeline/symptom-series";
|
||||
import { daysBetweenDateKeys, shiftDateKey, userDayKey } from "@/lib/tz/format";
|
||||
import { EVENT_MEASUREMENT_TYPES } from "@/lib/validations/measurement";
|
||||
|
||||
@@ -141,9 +147,11 @@ const MEASUREMENT_TYPE_SET: ReadonlySet<string> = new Set(
|
||||
* grows past the names that exist, and each series is its own row under the
|
||||
* lanes. A lab marker (`lab:<biomarkerId>`, #1299) is checked for its shape
|
||||
* only: whether the record has it is the loader's question, and the answer
|
||||
* to a stale one is no line rather than an error. Marker ids are not a
|
||||
* closed set, so their count is capped: the first
|
||||
* `TIMELINE_LAB_SERIES_MAX` are kept and the rest left out. A refusal would
|
||||
* to a stale one is no line rather than an error. A person-defined symptom
|
||||
* (`symptom:<definitionId>`, #981) is checked the same way. Marker and
|
||||
* definition ids are not a closed set, so their counts are capped: the first
|
||||
* `TIMELINE_LAB_SERIES_MAX` markers and `TIMELINE_SYMPTOM_SERIES_MAX`
|
||||
* symptoms are kept and the rest left out. A refusal would
|
||||
* be stored with the choice and fail the page on every visit, with the menu
|
||||
* that could undo it never drawn.
|
||||
*/
|
||||
@@ -159,9 +167,10 @@ export function parseSeriesKeys(raw: string | undefined): string[] | null {
|
||||
];
|
||||
for (const key of keys) {
|
||||
if (timelineLabMarkerId(key) !== null) continue;
|
||||
if (timelineSymptomDefinitionId(key) !== null) continue;
|
||||
if (key !== MOOD_SERIES_KEY && !MEASUREMENT_TYPE_SET.has(key)) return null;
|
||||
}
|
||||
return capTimelineLabKeys(keys);
|
||||
return capTimelineSeriesKeys(keys);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -250,7 +259,13 @@ export async function loadTimeline(args: {
|
||||
laneVisible(key, access),
|
||||
);
|
||||
const measurementsVisible = access.domainVisible("measurements");
|
||||
const [reads, firstReading] = await Promise.all([
|
||||
const labIds = args.seriesKeys
|
||||
.map(timelineLabMarkerId)
|
||||
.filter((id): id is string => id !== null);
|
||||
const symptomIds = args.seriesKeys
|
||||
.map(timelineSymptomDefinitionId)
|
||||
.filter((id): id is string => id !== null);
|
||||
const [reads, firstReading, firstSymptom] = await Promise.all([
|
||||
Promise.all(visibleLanes.map((key) => LANE_READERS[key](frame))),
|
||||
measurementsVisible
|
||||
? prisma.measurement.findFirst({
|
||||
@@ -259,10 +274,13 @@ export async function loadTimeline(args: {
|
||||
select: { measuredAt: true },
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
// A chosen symptom has no lane of its own to carry its first day.
|
||||
firstSymptomDay({ recordId, ids: symptomIds, tz, access }),
|
||||
]);
|
||||
|
||||
const starts = reads.flatMap((r) => r.items.map((i) => i.start));
|
||||
if (firstReading) starts.push(userDayKey(firstReading.measuredAt, tz));
|
||||
if (firstSymptom) starts.push(firstSymptom);
|
||||
const dataFrom = starts.length > 0 ? starts.sort()[0] : null;
|
||||
|
||||
const { from, to } = resolveTimelineWindow(query, today, dataFrom);
|
||||
@@ -277,11 +295,9 @@ export async function loadTimeline(args: {
|
||||
standing.push(...reads[i].standing);
|
||||
});
|
||||
|
||||
const labIds = args.seriesKeys
|
||||
.map(timelineLabMarkerId)
|
||||
.filter((id): id is string => id !== null);
|
||||
const seriesKeys = args.seriesKeys.filter((key) =>
|
||||
timelineLabMarkerId(key) !== null
|
||||
timelineLabMarkerId(key) !== null ||
|
||||
timelineSymptomDefinitionId(key) !== null
|
||||
? false
|
||||
: key === MOOD_SERIES_KEY
|
||||
? access.modules.mood !== false && access.domainVisible("mind")
|
||||
@@ -296,33 +312,50 @@ export async function loadTimeline(args: {
|
||||
? shiftDateKey(to, -(DAY_NOTABLE_MAX_SPAN_DAYS - 1))
|
||||
: from;
|
||||
const bucket = timelineBucket(query.zoom, from, to);
|
||||
const [series, notable, availableSeries, labSeries, availableLabs] =
|
||||
await Promise.all([
|
||||
loadTimelineSeries({
|
||||
userId: recordId,
|
||||
keys: seriesKeys,
|
||||
from,
|
||||
to,
|
||||
tz,
|
||||
bucket,
|
||||
priorityJson,
|
||||
now,
|
||||
}),
|
||||
measurementsVisible
|
||||
? loadNotableRange({
|
||||
userId: recordId,
|
||||
from: notableFrom,
|
||||
to,
|
||||
tz,
|
||||
priorityJson,
|
||||
typeVisible: (t) => measurementTypeVisible(t, access.modules),
|
||||
gaps: false,
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
loadAvailableSeries({ recordId, access }),
|
||||
loadLabSeries({ recordId, ids: labIds, from, to, tz, access }),
|
||||
loadAvailableLabMarkers({ recordId, access }),
|
||||
]);
|
||||
const [
|
||||
series,
|
||||
notable,
|
||||
availableSeries,
|
||||
labSeries,
|
||||
availableLabs,
|
||||
symptomSeries,
|
||||
availableSymptoms,
|
||||
] = await Promise.all([
|
||||
loadTimelineSeries({
|
||||
userId: recordId,
|
||||
keys: seriesKeys,
|
||||
from,
|
||||
to,
|
||||
tz,
|
||||
bucket,
|
||||
priorityJson,
|
||||
now,
|
||||
}),
|
||||
measurementsVisible
|
||||
? loadNotableRange({
|
||||
userId: recordId,
|
||||
from: notableFrom,
|
||||
to,
|
||||
tz,
|
||||
priorityJson,
|
||||
typeVisible: (t) => measurementTypeVisible(t, access.modules),
|
||||
gaps: false,
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
loadAvailableSeries({ recordId, access }),
|
||||
loadLabSeries({ recordId, ids: labIds, from, to, tz, access }),
|
||||
loadAvailableLabMarkers({ recordId, access }),
|
||||
loadSymptomSeries({
|
||||
recordId,
|
||||
ids: symptomIds,
|
||||
from,
|
||||
to,
|
||||
tz,
|
||||
bucket,
|
||||
access,
|
||||
}),
|
||||
loadAvailableSymptoms({ recordId, access }),
|
||||
]);
|
||||
|
||||
return {
|
||||
zoom: query.zoom,
|
||||
@@ -334,6 +367,8 @@ export async function loadTimeline(args: {
|
||||
availableSeries,
|
||||
labSeries,
|
||||
availableLabs,
|
||||
symptomSeries,
|
||||
availableSymptoms,
|
||||
notable: notable.map(({ date, kind }) => ({ date, kind })),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Person-defined symptoms as lines on the timeline (#981).
|
||||
*
|
||||
* A symptom is logged as occurrences, each with an intensity from 0 to 10.
|
||||
* A day's value is the highest intensity logged on it: three entries of a
|
||||
* headache on one afternoon are one bad day, not three. The day is the
|
||||
* record's local day of `occurredAt`, the same cut the day view and every
|
||||
* lane make, so a point opens the days that list it.
|
||||
*
|
||||
* The line follows the zoom's bucket, as a measurement line does: a point is
|
||||
* the mean of the bucket's day values over the days the symptom was logged,
|
||||
* and carries how many days that was, so the chart can mark a mean resting
|
||||
* on one or two days. A day without an entry is not a zero: absence says
|
||||
* nothing was written down, not that nothing was felt. A bucket without an
|
||||
* entry is not sent.
|
||||
*
|
||||
* Symptoms ride the illness module and the `illness` sharing domain, as the
|
||||
* symptom routes, the day view's `symptoms` section and the illness lane do,
|
||||
* so the lines and the list of symptoms are read under the illness lane's
|
||||
* gate (`laneVisible("illness", ...)`). Labels are decrypted here and never
|
||||
* logged.
|
||||
*/
|
||||
import {
|
||||
TIMELINE_SYMPTOM_SCALE,
|
||||
timelineSymptomKey,
|
||||
type TimelineBucket,
|
||||
type TimelineSymptomOption,
|
||||
type TimelineSymptomPoint,
|
||||
type TimelineSymptomSeries,
|
||||
} from "@/lib/day/contract";
|
||||
import type { DayCell } from "@/lib/day/daily-stats";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decryptSymptomText } from "@/lib/symptoms/server";
|
||||
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
|
||||
import { startOfLocalDayKey } from "@/lib/tz/local-day";
|
||||
|
||||
import { laneVisible, type TimelineAccess } from "./lanes";
|
||||
import { foldDays } from "./series";
|
||||
|
||||
/** One logged occurrence, as the fold reads it. */
|
||||
export interface SymptomOccurrence {
|
||||
occurredAt: Date;
|
||||
intensity: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The peak intensity of every local day in `[from, to]` that has an entry.
|
||||
* Occurrences outside the window are left out.
|
||||
*/
|
||||
export function symptomDayPeaks(
|
||||
rows: readonly SymptomOccurrence[],
|
||||
window: { from: string; to: string; tz: string },
|
||||
): Map<string, number> {
|
||||
const peaks = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
const day = userDayKey(row.occurredAt, window.tz);
|
||||
if (day < window.from || day > window.to) continue;
|
||||
const prev = peaks.get(day);
|
||||
if (prev === undefined || row.intensity > prev)
|
||||
peaks.set(day, row.intensity);
|
||||
}
|
||||
return peaks;
|
||||
}
|
||||
|
||||
/**
|
||||
* A symptom's points over `[from, to]` in `bucket`: the mean of each
|
||||
* bucket's day peaks over its logged days, and the number of those days.
|
||||
*/
|
||||
export function foldSymptomSeries(
|
||||
rows: readonly SymptomOccurrence[],
|
||||
window: { from: string; to: string; tz: string },
|
||||
bucket: TimelineBucket,
|
||||
): TimelineSymptomPoint[] {
|
||||
const days = new Map<string, DayCell>();
|
||||
for (const [day, peak] of symptomDayPeaks(rows, window)) {
|
||||
// Each logged day weighs one, so `count` sums to the logged days.
|
||||
days.set(day, { value: peak, count: 1 });
|
||||
}
|
||||
return foldDays(days, bucket);
|
||||
}
|
||||
|
||||
const DEFINITION_SELECT = {
|
||||
id: true,
|
||||
labelEncrypted: true,
|
||||
icon: true,
|
||||
isActive: true,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The lines of the symptoms `ids` names, in that order, over `[from, to]`.
|
||||
* Every read is narrowed to the record: an id of another record's symptom
|
||||
* finds nothing and is left out, as is one deleted since it was chosen. A
|
||||
* symptom found but not logged in the window keeps its (empty) line, so the
|
||||
* page can still name and untick it.
|
||||
*/
|
||||
export async function loadSymptomSeries(args: {
|
||||
recordId: string;
|
||||
ids: readonly string[];
|
||||
from: string;
|
||||
to: string;
|
||||
tz: string;
|
||||
bucket: TimelineBucket;
|
||||
access: TimelineAccess;
|
||||
}): Promise<TimelineSymptomSeries[]> {
|
||||
const { recordId, ids, from, to, tz, bucket, access } = args;
|
||||
if (ids.length === 0 || !laneVisible("illness", access)) return [];
|
||||
const definitions = await prisma.symptomDefinition.findMany({
|
||||
where: { userId: recordId, id: { in: [...ids] } },
|
||||
select: DEFINITION_SELECT,
|
||||
});
|
||||
if (definitions.length === 0) return [];
|
||||
const rows = await prisma.symptomEvent.findMany({
|
||||
where: {
|
||||
userId: recordId,
|
||||
definitionId: { in: definitions.map((d) => d.id) },
|
||||
occurredAt: {
|
||||
gte: startOfLocalDayKey(from, tz),
|
||||
lt: startOfLocalDayKey(shiftDateKey(to, 1), tz),
|
||||
},
|
||||
},
|
||||
select: { definitionId: true, occurredAt: true, intensity: true },
|
||||
});
|
||||
const byDefinition = new Map<string, SymptomOccurrence[]>();
|
||||
for (const row of rows) {
|
||||
const list = byDefinition.get(row.definitionId) ?? [];
|
||||
list.push(row);
|
||||
byDefinition.set(row.definitionId, list);
|
||||
}
|
||||
const found = new Map(definitions.map((d) => [d.id, d]));
|
||||
return ids.flatMap((id) => {
|
||||
const definition = found.get(id);
|
||||
if (!definition) return [];
|
||||
return [
|
||||
{
|
||||
key: timelineSymptomKey(definition.id),
|
||||
definitionId: definition.id,
|
||||
label: decryptSymptomText(definition.labelEncrypted, "label"),
|
||||
icon: definition.icon,
|
||||
isActive: definition.isActive,
|
||||
scale: { ...TIMELINE_SYMPTOM_SCALE },
|
||||
points: foldSymptomSeries(
|
||||
byDefinition.get(id) ?? [],
|
||||
{ from, to, tz },
|
||||
bucket,
|
||||
),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The earliest local day any of the symptoms `ids` names was logged, or
|
||||
* null. An `all` window reaches back to it, so a chosen symptom logged
|
||||
* before anything else in the record is drawn whole.
|
||||
*/
|
||||
export async function firstSymptomDay(args: {
|
||||
recordId: string;
|
||||
ids: readonly string[];
|
||||
tz: string;
|
||||
access: TimelineAccess;
|
||||
}): Promise<string | null> {
|
||||
const { recordId, ids, tz, access } = args;
|
||||
if (ids.length === 0 || !laneVisible("illness", access)) return null;
|
||||
const first = await prisma.symptomEvent.findFirst({
|
||||
where: { userId: recordId, definitionId: { in: [...ids] } },
|
||||
orderBy: { occurredAt: "asc" },
|
||||
select: { occurredAt: true },
|
||||
});
|
||||
return first ? userDayKey(first.occurredAt, tz) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every symptom the record has logged at least once, whatever the window,
|
||||
* in the record's own order. A hidden symptom is offered too: hiding takes
|
||||
* it out of the quick entry, and its history is kept, so it can still be
|
||||
* looked at here.
|
||||
*/
|
||||
export async function loadAvailableSymptoms(args: {
|
||||
recordId: string;
|
||||
access: TimelineAccess;
|
||||
}): Promise<TimelineSymptomOption[]> {
|
||||
const { recordId, access } = args;
|
||||
if (!laneVisible("illness", access)) return [];
|
||||
const definitions = await prisma.symptomDefinition.findMany({
|
||||
where: { userId: recordId, events: { some: {} } },
|
||||
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }, { id: "asc" }],
|
||||
select: DEFINITION_SELECT,
|
||||
});
|
||||
return definitions.map((d) => ({
|
||||
key: timelineSymptomKey(d.id),
|
||||
definitionId: d.id,
|
||||
label: decryptSymptomText(d.labelEncrypted, "label"),
|
||||
icon: d.icon,
|
||||
isActive: d.isActive,
|
||||
}));
|
||||
}
|
||||
+15
-16
@@ -638,15 +638,14 @@ export function proxy(request: NextRequest) {
|
||||
response.headers.set(HEALTHLOG_PAGE_MARKER_HEADER, "1");
|
||||
}
|
||||
|
||||
// The document vault's decrypt-and-serve route is framed SAME-ORIGIN by
|
||||
// the document detail preview (an <iframe> for inline-class PDFs). The
|
||||
// blanket `X-Frame-Options: DENY` below would
|
||||
// refuse that embed, and the blanket page CSP would clobber the route's
|
||||
// own `Content-Security-Policy: sandbox` (middleware headers win over
|
||||
// route headers). Scope: exactly the `/original` path — list/detail API
|
||||
// routes keep the full DENY posture. The replacement CSP keeps the
|
||||
// sandbox (the served bytes are user-uploaded and must never script in
|
||||
// the app origin) and narrows framing to 'self' instead of dropping it.
|
||||
// The document vault's decrypt-and-serve route is framed SAME-ORIGIN by the
|
||||
// document detail preview (an <iframe> for inline-class PDFs). It gets a
|
||||
// document CSP, not the page CSP, which would replace the route's own CSP
|
||||
// (middleware headers win over route headers). Scope: exactly the
|
||||
// `/original` path; list/detail API routes keep the page posture. The
|
||||
// document CSP lets the user-uploaded bytes load nothing and permits only
|
||||
// same-origin framing (`frame-ancestors 'self'`); it carries no `sandbox`,
|
||||
// see the CSP note below.
|
||||
const isDocumentServeRoute =
|
||||
/^\/api\/documents\/inbound\/[^/]+\/original$/.test(pathname);
|
||||
|
||||
@@ -654,11 +653,10 @@ export function proxy(request: NextRequest) {
|
||||
// `/c/<token>/d/<id>` is the share analogue of the owner `/original` route:
|
||||
// it decrypts and serves a user-uploaded document that the clinician view
|
||||
// (`/c/<token>`) frames same-origin (an <iframe> for inline-class PDFs). It
|
||||
// needs the SAME narrow posture — SAMEORIGIN framing + a document CSP — and
|
||||
// NOT the `/c/` page posture, whose `X-Frame-Options: DENY` would refuse
|
||||
// the preview embed. Anchored to EXACTLY two dynamic segments so a
|
||||
// `/c/<token>` page, a `/c/<token>/d/<id>/extra`, or a `/c/<token>/dx/<id>`
|
||||
// lookalike never inherits the carve-out.
|
||||
// needs the SAME narrow posture (the document CSP) and NOT the `/c/` page
|
||||
// CSP, which is written for an app page. Anchored to EXACTLY two dynamic
|
||||
// segments so a `/c/<token>` page, a `/c/<token>/d/<id>/extra`, or a
|
||||
// `/c/<token>/dx/<id>` lookalike never inherits the carve-out.
|
||||
const isShareDocumentServeRoute = /^\/c\/[^/]+\/d\/[^/]+$/.test(pathname);
|
||||
|
||||
// Both serve routes take the narrow, document-only header posture.
|
||||
@@ -685,7 +683,8 @@ export function proxy(request: NextRequest) {
|
||||
response.headers.set("Cache-Control", "private, no-store");
|
||||
}
|
||||
|
||||
// Security headers
|
||||
// Security headers. Framing is refused by the CSP's `frame-ancestors`; a
|
||||
// browser that reads it ignores X-Frame-Options, kept for older ones.
|
||||
response.headers.set("X-Content-Type-Options", "nosniff");
|
||||
response.headers.set("X-Frame-Options", isServeRoute ? "SAMEORIGIN" : "DENY");
|
||||
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
@@ -778,7 +777,7 @@ export function proxy(request: NextRequest) {
|
||||
// them.
|
||||
const csp = isServeRoute
|
||||
? // Vault serve route (owner `/original` OR share `/c/<token>/d/<id>`; see
|
||||
// the X-Frame-Options note above): the
|
||||
// the framing note above): the
|
||||
// response is a user-uploaded document, not an app page.
|
||||
// `default-src 'none'` — a served document may load nothing as if
|
||||
// it were a page — and `frame-ancestors 'self'` permits exactly the
|
||||
|
||||
@@ -212,6 +212,31 @@ describe("turning the subscription on", () => {
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("answers the losing one of two racing rotations with 409, never 500", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
await mint(owner.id);
|
||||
// Both read the same live feed and revoke it; the second successor then
|
||||
// meets the one-live-feed index. Four rounds stay inside the mint bucket.
|
||||
const statuses: number[] = [];
|
||||
for (let round = 0; round < 4; round++) {
|
||||
const pair = await Promise.all([rotate(), rotate()]);
|
||||
for (const res of pair) {
|
||||
statuses.push(res.status);
|
||||
if (res.status === 409) {
|
||||
expect((await res.json()).meta.errorCode).toBe("cycle.feed.exists");
|
||||
}
|
||||
}
|
||||
expect(
|
||||
await db().cycleCalendarFeed.count({
|
||||
where: { userId: owner.id, revokedAt: null },
|
||||
}),
|
||||
).toBe(1);
|
||||
}
|
||||
expect(statuses.every((s) => s === 201 || s === 409)).toBe(true);
|
||||
// The race was really run, not sidestepped by one finishing first.
|
||||
expect(statuses).toContain(409);
|
||||
});
|
||||
|
||||
it("the partial unique index rejects a second live row written directly", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const base = {
|
||||
@@ -485,7 +510,7 @@ describe("fetching the feed", () => {
|
||||
|
||||
it("throttles a well-formed unknown token through the anonymous limit", async () => {
|
||||
let throttled = false;
|
||||
for (let i = 0; i < 130 && !throttled; i++) {
|
||||
for (let i = 0; i < 610 && !throttled; i++) {
|
||||
const res = await fetchFeed(`hlf_${"1".repeat(48)}.ics`);
|
||||
if (res.status === 429) throttled = true;
|
||||
else expect(res.status).toBe(404);
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Garmin's period end lands only on a cycle Garmin's own day opened
|
||||
* (real Postgres).
|
||||
*
|
||||
* The hourly Garmin leg stamps the last day of a finished period on the cycle
|
||||
* that starts on Garmin's start date. A cycle the person opened herself on
|
||||
* that date carries her own account of the period: her days, her end or none
|
||||
* yet. Garmin fills only the days no row covers, and it does not write her
|
||||
* cycle's period end either.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
import { writeGarminPeriod } from "@/lib/garmin/cycle-sync";
|
||||
|
||||
const GARMIN_OPENED = "user-garmin-stamp-own";
|
||||
const PERSON_OPENED = "user-garmin-stamp-person";
|
||||
|
||||
const START = "2026-09-01";
|
||||
const DAYS = ["2026-09-01", "2026-09-02", "2026-09-03", "2026-09-04"];
|
||||
|
||||
async function seedUser(id: string) {
|
||||
await getPrismaClient().user.create({
|
||||
data: {
|
||||
id,
|
||||
username: id,
|
||||
email: `${id}@example.test`,
|
||||
timezone: "UTC",
|
||||
gender: "FEMALE",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function cycleAt(userId: string) {
|
||||
return getPrismaClient().menstrualCycle.findFirstOrThrow({
|
||||
where: { userId, startDate: START, deletedAt: null },
|
||||
select: { id: true, periodEndDate: true },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
await seedUser(GARMIN_OPENED);
|
||||
await seedUser(PERSON_OPENED);
|
||||
});
|
||||
|
||||
describe("Garmin period end", () => {
|
||||
it("stamps the end on the cycle Garmin's first day opened", async () => {
|
||||
await writeGarminPeriod(GARMIN_OPENED, {
|
||||
start: START,
|
||||
days: DAYS,
|
||||
ended: true,
|
||||
});
|
||||
expect(await cycleAt(GARMIN_OPENED)).toMatchObject({
|
||||
periodEndDate: "2026-09-04",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves the period end of a cycle the person opened to her", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
// She tapped her start: the cycle and her own first day.
|
||||
const cycle = await prisma.menstrualCycle.create({
|
||||
data: { userId: PERSON_OPENED, startDate: START, tz: "UTC" },
|
||||
});
|
||||
await prisma.cycleDayLog.create({
|
||||
data: {
|
||||
userId: PERSON_OPENED,
|
||||
date: START,
|
||||
tz: "UTC",
|
||||
source: "MANUAL",
|
||||
flow: "MEDIUM",
|
||||
cycleId: cycle.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Every hourly tick after the period is over writes the same thing.
|
||||
for (let tick = 0; tick < 2; tick++) {
|
||||
await writeGarminPeriod(PERSON_OPENED, {
|
||||
start: START,
|
||||
days: DAYS,
|
||||
ended: true,
|
||||
});
|
||||
}
|
||||
expect(await cycleAt(PERSON_OPENED)).toMatchObject({
|
||||
id: cycle.id,
|
||||
periodEndDate: null,
|
||||
});
|
||||
// Garmin still filled the days she did not log.
|
||||
expect(
|
||||
await prisma.cycleDayLog.count({
|
||||
where: { userId: PERSON_OPENED, source: "GARMIN", cycleId: cycle.id },
|
||||
}),
|
||||
).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -289,18 +289,119 @@ describe("a free-text reading that is a text result", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("a text marker's range and the first unit it adopts", () => {
|
||||
it("drops a range stated without a unit when a real unit arrives", async () => {
|
||||
// The report printed "30 - 100" beside a pending result, in ng/mL, and
|
||||
// said so nowhere: the marker holds the numbers with no unit.
|
||||
await postLab({
|
||||
analyte: "Vitamin D",
|
||||
valueText: "PENDING",
|
||||
referenceLow: 30,
|
||||
referenceHigh: 100,
|
||||
});
|
||||
const { id } = await bounds("Vitamin D");
|
||||
// The next report states nmol/L. 30 - 100 is not a window in nmol/L.
|
||||
await postLab({
|
||||
biomarkerId: id,
|
||||
value: 60,
|
||||
unit: "nmol/L",
|
||||
takenAt: "2026-10-01T12:00:00.000Z",
|
||||
});
|
||||
const marker = await prisma.biomarker.findUniqueOrThrow({ where: { id } });
|
||||
expect(marker).toMatchObject({
|
||||
unit: "nmol/L",
|
||||
lowerBound: null,
|
||||
upperBound: null,
|
||||
});
|
||||
const reading = await prisma.labResult.findFirstOrThrow({
|
||||
where: { biomarkerId: id, value: { not: null } },
|
||||
});
|
||||
expect(reading).toMatchObject({
|
||||
unit: "nmol/L",
|
||||
referenceLow: null,
|
||||
referenceHigh: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("takes the window the first number printed in the unit it adopts", async () => {
|
||||
await postLab({
|
||||
analyte: "Vitamin D",
|
||||
valueText: "PENDING",
|
||||
referenceLow: 30,
|
||||
referenceHigh: 100,
|
||||
});
|
||||
const { id } = await bounds("Vitamin D");
|
||||
await postLab({
|
||||
biomarkerId: id,
|
||||
value: 60,
|
||||
unit: "nmol/L",
|
||||
sourceReferenceLow: 75,
|
||||
sourceReferenceHigh: 250,
|
||||
takenAt: "2026-10-01T12:00:00.000Z",
|
||||
});
|
||||
expect(await bounds("Vitamin D")).toMatchObject({ low: 75, high: 250 });
|
||||
});
|
||||
|
||||
it("keeps a unitless range when the unit adopted is the unity", async () => {
|
||||
await postLab({
|
||||
analyte: "BUN/Creatinine ratio",
|
||||
valueText: "SEE NOTE",
|
||||
referenceLow: 10,
|
||||
referenceHigh: 20,
|
||||
});
|
||||
const { id } = await bounds("BUN/Creatinine ratio");
|
||||
await postLab({
|
||||
biomarkerId: id,
|
||||
value: 18,
|
||||
unit: "1",
|
||||
sourceReferenceLow: 5,
|
||||
sourceReferenceHigh: 30,
|
||||
takenAt: "2026-10-01T12:00:00.000Z",
|
||||
});
|
||||
expect(await bounds("BUN/Creatinine ratio")).toMatchObject({
|
||||
low: 10,
|
||||
high: 20,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- CSV import -------------------------------------------------------------
|
||||
|
||||
describe("a CSV text result", () => {
|
||||
const query = () =>
|
||||
const query = (dryRun = false) =>
|
||||
labsCsvQuerySchema.parse({
|
||||
analyte: "0",
|
||||
value: "1",
|
||||
unit: "2",
|
||||
date: "3",
|
||||
referenceRange: "4",
|
||||
dryRun: dryRun ? "1" : "0",
|
||||
});
|
||||
|
||||
it("previews a text row the way it writes it", async () => {
|
||||
const csv = [
|
||||
"Analyte;Value;Unit;Date;Range",
|
||||
"BUN/Creatinine ratio;SEE NOTE;;2026-09-01;10 - 20",
|
||||
"Sodium;140;mmol/L;2026-09-01;135 - 145",
|
||||
].join("\n");
|
||||
const preview = await importLabsCsv({
|
||||
userId: USER,
|
||||
tz: "UTC",
|
||||
stream: textToStream(csv),
|
||||
maxBytes: 1_000_000,
|
||||
query: query(true),
|
||||
});
|
||||
// The text reading stores no window of its own; the number keeps its.
|
||||
expect(preview.preview).toMatchObject([
|
||||
{
|
||||
marker: "BUN/Creatinine ratio",
|
||||
referenceLow: null,
|
||||
referenceHigh: null,
|
||||
},
|
||||
{ marker: "Sodium", referenceLow: 135, referenceHigh: 145 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("gives a new marker a numeric range and leaves a text range out", async () => {
|
||||
const result = await importLabsCsv({
|
||||
userId: USER,
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
* and switching a token's write-back on afterwards must still deliver all of
|
||||
* that token's readings on `measurementMirror`, because the switch happens
|
||||
* after the cursor.
|
||||
*
|
||||
* The cursor sits a minute behind the present: `updatedAt` is stamped when a
|
||||
* write is built, not when it commits, so a row stamped just before the
|
||||
* cursor can become visible just after it, and must not be skipped.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
@@ -81,6 +85,8 @@ describe("a cursor that starts now", () => {
|
||||
ingestTokenId: scale.tokenId,
|
||||
measuredAt: new Date(Date.UTC(2026, 9, 1 + i, 6)),
|
||||
externalId: `old-${i}`,
|
||||
// Written well before the cursor's minute of overlap.
|
||||
updatedAt: new Date(Date.now() - 120_000),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -138,6 +144,26 @@ describe("a cursor that starts now", () => {
|
||||
expect(after.changes.measurementMirror.upserts).toEqual([]);
|
||||
});
|
||||
|
||||
it("delivers a row stamped before the cursor that commits after it", async () => {
|
||||
const start = await pull({ start: "now" });
|
||||
// A write in flight when the cursor was handed out: its stamp predates
|
||||
// the cursor, its commit does not.
|
||||
const late = await makeMeasurement(
|
||||
{ id: userId },
|
||||
{
|
||||
source: "EXTERNAL",
|
||||
ingestTokenId: scale.tokenId,
|
||||
measuredAt: new Date(Date.UTC(2026, 10, 4, 6)),
|
||||
externalId: "late",
|
||||
updatedAt: new Date(Date.now() - 30_000),
|
||||
},
|
||||
);
|
||||
const next = await pull({ cursor: start.cursor! });
|
||||
expect(next.changes.measurements.upserts.map((r) => r.id)).toEqual([
|
||||
late.id,
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses start=now beside a cursor", async () => {
|
||||
const res = await callRoute(syncChanges, {
|
||||
method: "GET",
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* Person-defined symptoms as lines on the timeline (#981), driven through
|
||||
* `GET /api/timeline` against real Postgres.
|
||||
*
|
||||
* What the unit tests cannot show: that the record's own logged symptoms
|
||||
* are offered (hidden ones included, never-logged ones not) and nobody
|
||||
* else's, that a definition id from another record finds nothing, that a
|
||||
* day is the record's local day and its value the day's highest intensity,
|
||||
* that an `all` window reaches back to a chosen symptom's first entry, that
|
||||
* a switched-off illness module reads nothing, and that a whole-record
|
||||
* grant shares the lines. Labels are decrypted server-side. All data
|
||||
* invented.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import type { TimelineResponse } from "@/lib/day/contract";
|
||||
import { shiftDateKey } from "@/lib/tz/format";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, switchSessionTo, truncateAllTables } from "./setup";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
type Handler = (
|
||||
request: NextRequest,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
context: { params: Promise<any> },
|
||||
) => Promise<Response>;
|
||||
|
||||
let counter = 0;
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
/** The calendar day `n` days before today (UTC), so data stays in range. */
|
||||
function daysAgo(n: number): string {
|
||||
return new Date(Date.now() - n * DAY_MS).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
const MODULES = { timeline: true, illness: true };
|
||||
|
||||
async function makeUser(label: string) {
|
||||
const suffix = `${label}-${counter++}`;
|
||||
return getPrismaClient().user.create({
|
||||
data: {
|
||||
username: `tl-sym-${suffix}`,
|
||||
email: `tl-sym-${suffix}@example.test`,
|
||||
displayName: `TL ${suffix}`,
|
||||
role: "USER",
|
||||
timezone: "Europe/Berlin",
|
||||
locale: "en",
|
||||
modulePreferencesJson: MODULES,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function signIn(userId: string) {
|
||||
const session = await getPrismaClient().session.create({
|
||||
data: { userId, expiresAt: new Date(Date.now() + 60_000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
return session;
|
||||
}
|
||||
|
||||
async function shareWholeRecord(ownerId: string, delegateId: string) {
|
||||
const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants");
|
||||
const invited = await inviteGrant({
|
||||
grantorId: ownerId,
|
||||
granteeId: delegateId,
|
||||
access: "READ",
|
||||
scope: null,
|
||||
});
|
||||
await acceptGrant({ grantId: invited.id, granteeId: delegateId });
|
||||
const session = await signIn(delegateId);
|
||||
await switchSessionTo(session.id, ownerId);
|
||||
}
|
||||
|
||||
async function getTimeline(query: string): Promise<Response> {
|
||||
const { GET } = await import("@/app/api/timeline/route");
|
||||
return (GET as Handler)(
|
||||
new NextRequest(`http://localhost/api/timeline?${query}`, {
|
||||
method: "GET",
|
||||
}),
|
||||
{ params: Promise.resolve({}) },
|
||||
);
|
||||
}
|
||||
|
||||
async function data(response: Response): Promise<TimelineResponse> {
|
||||
expect(response.status).toBe(200);
|
||||
return ((await response.json()) as { data: TimelineResponse }).data;
|
||||
}
|
||||
|
||||
/** Days of the seeded headache, as the record's local zone cuts them. */
|
||||
const DAYS = {
|
||||
first: () => daysAgo(30),
|
||||
// 23:30 UTC is already the next day in Berlin, summer or winter.
|
||||
lateNight: () => shiftDateKey(daysAgo(20), 1),
|
||||
twice: () => daysAgo(10),
|
||||
};
|
||||
|
||||
/**
|
||||
* A record with a headache logged on three days (one of them twice, one
|
||||
* late at night), a hidden nausea logged once long ago, and a dizziness
|
||||
* that was defined but never logged.
|
||||
*/
|
||||
async function seedSymptoms(userId: string) {
|
||||
const db = getPrismaClient();
|
||||
const define = (label: string, sortOrder: number, isActive = true) =>
|
||||
db.symptomDefinition.create({
|
||||
data: {
|
||||
userId,
|
||||
labelEncrypted: encryptToBytes(label),
|
||||
icon: "brain",
|
||||
sortOrder,
|
||||
isActive,
|
||||
},
|
||||
});
|
||||
const headache = await define("Headache", 0);
|
||||
const nausea = await define("Nausea", 1, false);
|
||||
const dizziness = await define("Dizziness", 2);
|
||||
const log = (definitionId: string, iso: string, intensity: number) =>
|
||||
db.symptomEvent.create({
|
||||
data: { userId, definitionId, occurredAt: new Date(iso), intensity },
|
||||
});
|
||||
await log(headache.id, `${daysAgo(30)}T12:00:00.000Z`, 4);
|
||||
await log(headache.id, `${daysAgo(20)}T23:30:00.000Z`, 7);
|
||||
await log(headache.id, `${daysAgo(10)}T08:00:00.000Z`, 2);
|
||||
await log(headache.id, `${daysAgo(10)}T15:00:00.000Z`, 9);
|
||||
// Long before anything else in the record.
|
||||
await log(nausea.id, `${daysAgo(900)}T12:00:00.000Z`, 3);
|
||||
return { headache, nausea, dizziness };
|
||||
}
|
||||
|
||||
/** A 40-day range: under six weeks, so every point is one day. */
|
||||
const DAY_WINDOW = () => `zoom=range&from=${daysAgo(39)}&to=${daysAgo(0)}`;
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
});
|
||||
|
||||
describe("symptom lines on the timeline", () => {
|
||||
it("offers the record's logged symptoms and draws a chosen one by local day and day peak", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const { headache, nausea } = await seedSymptoms(owner.id);
|
||||
await signIn(owner.id);
|
||||
|
||||
const timeline = await data(
|
||||
await getTimeline(`${DAY_WINDOW()}&values=WEIGHT,symptom:${headache.id}`),
|
||||
);
|
||||
expect(timeline.bucket).toBe("day");
|
||||
// Never-logged dizziness is not offered; hidden nausea is, marked.
|
||||
expect(timeline.availableSymptoms).toEqual([
|
||||
{
|
||||
key: `symptom:${headache.id}`,
|
||||
definitionId: headache.id,
|
||||
label: "Headache",
|
||||
icon: "brain",
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
key: `symptom:${nausea.id}`,
|
||||
definitionId: nausea.id,
|
||||
label: "Nausea",
|
||||
icon: "brain",
|
||||
isActive: false,
|
||||
},
|
||||
]);
|
||||
expect(timeline.symptomSeries).toHaveLength(1);
|
||||
const [line] = timeline.symptomSeries;
|
||||
expect(line).toMatchObject({
|
||||
key: `symptom:${headache.id}`,
|
||||
definitionId: headache.id,
|
||||
label: "Headache",
|
||||
isActive: true,
|
||||
scale: { min: 0, max: 10 },
|
||||
});
|
||||
expect(line.points).toEqual([
|
||||
{ t: DAYS.first(), mean: 4, count: 1 },
|
||||
{ t: DAYS.lateNight(), mean: 7, count: 1 },
|
||||
// Logged twice: the day's value is its highest intensity.
|
||||
{ t: DAYS.twice(), mean: 9, count: 1 },
|
||||
]);
|
||||
// A symptom does not touch the measurement lines.
|
||||
expect(timeline.series).toEqual([]);
|
||||
});
|
||||
|
||||
it("averages day peaks over the logged days in a coarser bucket", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const { headache } = await seedSymptoms(owner.id);
|
||||
await signIn(owner.id);
|
||||
|
||||
// Across 200 days the bucket is a month.
|
||||
const months = await data(
|
||||
await getTimeline(
|
||||
`zoom=range&from=${daysAgo(199)}&to=${daysAgo(0)}&values=symptom:${headache.id}`,
|
||||
),
|
||||
);
|
||||
expect(months.bucket).toBe("month");
|
||||
const points = months.symptomSeries[0].points;
|
||||
// Three logged days in all, never a silent day counted as zero.
|
||||
expect(points.reduce((n, p) => n + p.count, 0)).toBe(3);
|
||||
// Each mean is of its month's day peaks (rounded to two decimals).
|
||||
const total = points.reduce((sum, p) => sum + p.mean * p.count, 0);
|
||||
expect(total).toBeCloseTo(4 + 7 + 9, 1);
|
||||
});
|
||||
|
||||
it("reaches an `all` window back to a chosen symptom's first entry", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const { nausea, headache } = await seedSymptoms(owner.id);
|
||||
await signIn(owner.id);
|
||||
|
||||
const without = await data(
|
||||
await getTimeline(`zoom=all&values=symptom:${headache.id}`),
|
||||
);
|
||||
expect(without.range.dataFrom).toBe(DAYS.first());
|
||||
const withNausea = await data(
|
||||
await getTimeline(`zoom=all&values=symptom:${nausea.id}`),
|
||||
);
|
||||
expect(withNausea.range.dataFrom).toBe(daysAgo(900));
|
||||
expect(withNausea.symptomSeries[0].points.map((p) => p.count)).toEqual([1]);
|
||||
});
|
||||
|
||||
it("finds nothing under another record's definition id, and does not refuse", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const other = await makeUser("other");
|
||||
const { headache: theirs } = await seedSymptoms(other.id);
|
||||
await signIn(owner.id);
|
||||
|
||||
const timeline = await data(
|
||||
await getTimeline(`zoom=all&values=symptom:${theirs.id},symptom:gone123`),
|
||||
);
|
||||
expect(timeline.symptomSeries).toEqual([]);
|
||||
expect(timeline.availableSymptoms).toEqual([]);
|
||||
// Another record's first entry does not move this record's window.
|
||||
expect(timeline.range.dataFrom).toBeNull();
|
||||
|
||||
// A malformed id is refused.
|
||||
expect((await getTimeline(`values=symptom:..%2Fx`)).status).toBe(422);
|
||||
});
|
||||
|
||||
it("draws and offers nothing with the illness module off", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const { headache } = await seedSymptoms(owner.id);
|
||||
await getPrismaClient().user.update({
|
||||
where: { id: owner.id },
|
||||
data: { modulePreferencesJson: { ...MODULES, illness: false } },
|
||||
});
|
||||
await signIn(owner.id);
|
||||
|
||||
const timeline = await data(
|
||||
await getTimeline(`zoom=all&values=symptom:${headache.id}`),
|
||||
);
|
||||
expect(timeline.symptomSeries).toEqual([]);
|
||||
expect(timeline.availableSymptoms).toEqual([]);
|
||||
expect(timeline.range.dataFrom).toBeNull();
|
||||
});
|
||||
|
||||
it("shares the lines on a whole-record grant", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const { headache } = await seedSymptoms(owner.id);
|
||||
const whole = await makeUser("whole");
|
||||
await shareWholeRecord(owner.id, whole.id);
|
||||
|
||||
const shared = await data(
|
||||
await getTimeline(`${DAY_WINDOW()}&values=symptom:${headache.id}`),
|
||||
);
|
||||
expect(shared.symptomSeries[0].points).toHaveLength(3);
|
||||
expect(shared.availableSymptoms.map((s) => s.label)).toEqual([
|
||||
"Headache",
|
||||
"Nausea",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@ vi.mock("@/lib/jobs/boss-instance", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
import { removeCycleStartedOn } from "@/lib/cycle/cycle-boundaries";
|
||||
import {
|
||||
enqueueBootTimeWearableCycleRepair,
|
||||
repairWearableCycleDays,
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
const GARMIN_USER = "user-wearable-repair-garmin";
|
||||
const OURA_USER = "user-wearable-repair-oura";
|
||||
const BLOCKED_USER = "user-wearable-repair-blocked";
|
||||
const OFF_USER = "user-wearable-repair-off";
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
const addDays = (day: string, n: number) =>
|
||||
@@ -197,4 +199,79 @@ describe("wearable cycle repair", () => {
|
||||
}),
|
||||
).toEqual(daysBefore);
|
||||
});
|
||||
|
||||
it("never reopens a cycle the person deleted", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await repairWearableCycleDays();
|
||||
// The cycle delete: the first cycle goes, and with nothing before it
|
||||
// its Garmin days are left without a cycle.
|
||||
await prisma.$transaction((db) =>
|
||||
removeCycleStartedOn(db, GARMIN_USER, "2026-07-01"),
|
||||
);
|
||||
const orphaned = await prisma.cycleDayLog.count({
|
||||
where: { userId: GARMIN_USER, cycleId: null, deletedAt: null },
|
||||
});
|
||||
expect(orphaned).toBe(5);
|
||||
|
||||
sends.length = 0;
|
||||
expect(await enqueueBootTimeWearableCycleRepair()).toEqual({
|
||||
enqueued: false,
|
||||
error: null,
|
||||
});
|
||||
expect(await repairWearableCycleDays()).toMatchObject({
|
||||
scanned: 0,
|
||||
opened: 0,
|
||||
});
|
||||
expect((await liveCycles(GARMIN_USER)).map((c) => c.startDate)).toEqual([
|
||||
"2026-07-29",
|
||||
"2026-08-26",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not move the start to the next day when the first day is deleted", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await repairWearableCycleDays();
|
||||
// The day-log delete: the opening day goes, and its cycle with it.
|
||||
const first = await prisma.cycleDayLog.findFirstOrThrow({
|
||||
where: { userId: GARMIN_USER, date: "2026-07-01" },
|
||||
});
|
||||
await prisma.$transaction(async (db) => {
|
||||
await db.cycleDayLog.update({
|
||||
where: { id: first.id },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
return removeCycleStartedOn(db, GARMIN_USER, "2026-07-01");
|
||||
});
|
||||
|
||||
await repairWearableCycleDays();
|
||||
expect((await liveCycles(GARMIN_USER)).map((c) => c.startDate)).toEqual([
|
||||
"2026-07-29",
|
||||
"2026-08-26",
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves an account with cycle tracking off alone", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await seedUser(OFF_USER);
|
||||
await prisma.cycleProfile.create({
|
||||
data: { userId: OFF_USER, cycleTrackingEnabled: false },
|
||||
});
|
||||
await orphanDays(OFF_USER, "GARMIN", ["2026-07-01", "2026-07-02"]);
|
||||
|
||||
const result = await repairWearableCycleDays();
|
||||
expect(result.accounts).toBe(2);
|
||||
expect(await liveCycles(OFF_USER)).toEqual([]);
|
||||
expect(
|
||||
await prisma.cycleDayLog.count({
|
||||
where: { userId: OFF_USER, cycleId: null },
|
||||
}),
|
||||
).toBe(2);
|
||||
|
||||
// Its rows are no work while the module is off: the boot stays quiet.
|
||||
sends.length = 0;
|
||||
expect(await enqueueBootTimeWearableCycleRepair()).toEqual({
|
||||
enqueued: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user