Merge pull request #1337 from MBombeck/release/v1.47.1

release/v1.47.1
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-11-07 02:01:37 +01:00
commit 8eb2fb773f
349 files changed
+19780 -2703

No files matched your search

+6
View File
@@ -73,6 +73,12 @@ updates:
# (runtime-package-pins.test.ts fails while the two differ).
- dependency-name: "postgres"
update-types: ["version-update:semver-major"]
# The Node major in the build and runner stages is the one `engines`,
# `.nvmrc` and `.node-version` name, and the native modules are built
# against it. Moving it is a release decision, so it arrives by hand.
# Digest and minor updates of the pinned major still come as PRs.
- dependency-name: "node"
update-types: ["version-update:semver-major"]
# The `docker` ecosystem above reads the Dockerfile only. The bundled
# database lives in `docker-compose.yml`, which GitHub treats as its own
# ecosystem, so without this block a new PostgreSQL minor release never
+9 -2
View File
@@ -142,6 +142,13 @@ jobs:
output: trivy-published-image.sarif
severity: "CRITICAL,HIGH,MEDIUM"
limit-severities-for-sarif: true
# Only findings with a published fix reach code scanning. Debian
# leaves many base-package advisories unfixed in stable (no-dsa,
# postponed), and nothing in this repository can act on those: the
# build's `apt-get upgrade` takes the fix the day Debian ships one,
# and that is when the alert should appear. Listing them all made
# the page a hundred-odd rows deep without one to act on.
ignore-unfixed: true
exit-code: "0"
- name: Upload results
@@ -162,6 +169,6 @@ jobs:
format: table
severity: "CRITICAL"
exit-code: "1"
# Same rule as the blocking gate in security.yml: a finding with a
# published fix fails, one without is in the SARIF report above.
# Same rule as the blocking gate in security.yml and the SARIF
# report above: only a finding with a published fix counts.
ignore-unfixed: true
+163
View File
@@ -2,6 +2,169 @@
## [Unreleased]
## [1.47.1] — 2026-11-07
### Added
- `GET /api/integrations/healthkit` lists the HealthKit sample types the
server accepts as `acceptedTypes`, taken from the same mapping the batch
upload uses. The iOS app can then ask for and send only types the server
stores, instead of reading a type the server would skip. (#1340)
- `GET /api/sync/changes?start=now` answers an empty page with a cursor
that starts at the present. The iOS app can page forward from it to
write readings to Apple Health in the background, without first walking
years of history. (#1339)
- `GET /api/biomarkers/{id}/series` returns one lab marker's line, the same
points and window the timeline draws for it, under the Labs module. The
iOS app can show a marker's chart with the timeline switched off. (#1342)
- Resting energy from Apple Health is stored as a metric of its own,
`BASAL_ENERGY_BURNED` in kcal, next to active energy and never added to
it. It used to be skipped. It is summed per day, charted, listed with your
measurements, included in backups and exports, and readable over MCP. It
feeds no score and no insight. (#1340)
- When the Coach looks for readings older than the lookback limit you set
for it, its live trail now says so on the wire: the `step` and `activity`
frames carry `reason: "outside_reach"` instead of looking like an empty
window. The native client can then tell "nothing in this window" from
"further back than the Coach may read". The answer still names the limit,
and the web trail keeps its wording.
- Turning on workout routes no longer helps only workouts recorded from
then on. When the app sends a route again for a workout that is already
stored without one, the server now keeps it, sealed like any other
track, and fills the workout's climb if it had none; the entry reports
`enriched`, as it does for a heart-rate series. A workout that already
has a route keeps it, and with routes switched off nothing is stored.
- **Lab markers grouped by category.** Under Settings, Arrangement, Labs,
the list can now group markers by their panel (Lipids, Liver, Thyroid
and so on), each group with a header that says whether its latest values
are all in range or how many are outside it. A catalog panel is
recognised in every language, a panel you typed yourself becomes its own
group, and a marker without a panel falls back to the catalog panel its
name belongs to, or to Other. You choose which groups open on load: all,
only those with a value outside range, or none. Grouping is off by
default. The server resolves the groups once: `GET /api/labs/overview`
carries `categories` and `grouped`, the layout preferences gain
`groupBy` and `groupsOpen`, `GET /api/biomarkers` lists the panels you
already use as `customPanels` for the panel field's suggestions, and a
merge preview names the panel the kept marker takes over as
`adoptedPanel`. No migration. (#1349)
- **Your cycle forecast as a calendar subscription.** Cycle, Settings,
Calendar subscription publishes the next period, and once three cycles
are logged up to six after it, as an ICS feed for Google, Apple or
Outlook calendar. Each event spans the window the period may start in,
wider the further ahead it is, and the feed stops where a date would be
more guess than estimate. Past cycles (up to twelve) and the fertile
window of the next cycle (only with the goal of trying to conceive) are
opt-in. Titles are a dot by default, and events never carry symptoms,
notes or other log details. The link is shown once, can be rotated or
turned off, and is turned off by a password change or a deletion
request. Only the record's owner, or a managed profile's guardian, can
turn it on, from a signed-in browser after confirming it is them. The
feed answers with an `ETag`, and every unknown or revoked link gets the
same plain 404. `GET /api/cycle/calendar` adds `projection` and `projectionState`
so the web and iOS calendars draw the same cycles ahead, and the Coach
and MCP cycle context list them as `upcoming`. Migration
`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
- **The report offered for a visit is the report you get.** When the last
matching visit was more than two years ago, the next-visit card offered a
report from that day, but a report covers two years at most, and the
export quietly made a 90-day report instead. The window before a visit
now stops at two years and says "The last two years", and the visit list
marks it with `preparation.capped`. A report range longer than two years,
however it is asked for, now covers the last two years of it rather than
falling back to 90 days. (Two years across a clock change also no longer
count as one day too many.)
- **PDF previews show in Safari.** On macOS and iOS the preview of an
uploaded PDF stayed an empty box. The page's security policy told the
browser that nothing may frame it, and Safari applied that to the
preview the page itself opens. The policy now allows framing by the app
itself and still refuses every other site. (#1311)
- **Periods from Garmin and Oura open their cycles.** Period days imported
from a watch or a ring were stored as days only, so the cycle tracker saw
no cycle start, no lengths and no forecast. They now open and fill their
cycles like a period logged by hand, a finished Garmin period also marks
its last day, and periods imported earlier are repaired once on the next
start. A day you logged yourself is never changed. (#1333)
- **Garmin in the setup questions.** The onboarding question about where
your readings come from now offers Garmin, and can end on connecting it.
(#1333)
- **Fitbit in the setup questions leads to Google Health.** Fitbit shut its
classic connection down on 30 October, but answering "Fitbit" still ended
the setup on that card. Fitbit stays an answer, since the devices are still
in use; the last step now opens the Google Health card, reads that
connection, and says that Fitbit data comes through Google Health now. The
question shows the same note under the Fitbit choice.
- **Cycle days in the integration details.** The Garmin and Oura cards list
the newest imported cycle day and how many there are under "Data
received", next to the other data types. (#1333)
- **Typing into the smoking history.** "When you stopped" only took text
that was already a whole YYYY-MM month, so every keystroke was dropped
and only a paste got in. The start year clamped each keystroke into its
range, so the first digit turned into 1900. Both fields now keep what is
typed and commit it once it is a valid value, and leaving the month field
pads "2019-3" to "2019-03". (#1334)
- **Crisis lines for the country you are in.** A questionnaire answered
from a phone set to English in Belgium (`en-BE`), French in Switzerland,
or English in Austria got the international crisis set, because the
country was only read for some languages. The region in the language tag
now picks the country's lines whatever the language; a bare language
code keeps the combined sets as before. (#1338)
- **SSO sign-in in the app names the deletion date.** Signing in with SSO
on the phone to an account pending deletion now carries the date the
account is erased, as the web sign-in already did, so the app can say
until when an admin can still undo it. (#1341)
- **A marker started from a text result keeps its range.** When a report
printed a text result such as "SEE NOTE" with a numeric range beside it,
the new marker was saved without that range, so its first number later
had nothing to be judged against. The range now goes onto the new marker,
from a document, a lab scan, a CSV import or a typed reading, and the
scan review shows it for such a row. A range that is text itself, like
"NEGATIVE", is not turned into bounds, and a marker that already exists
keeps its own. (#1313)
- **Several files dropped at once all arrive.** The server takes two
uploads per person at a time and asks the next one to come back in a
second, but the documents page sent three at once and showed that
answer as an error telling you to wait a few minutes. The page now sends
two at a time, and a file the server turns away for a moment, say while
another tab is still uploading, waits and goes again on its own for up
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
- Dependabot no longer proposes a new Node major for the image. The major
is tied to `engines`, `.nvmrc` and `.node-version` and moves by hand.
### Security
- `@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
was in the tree. `systeminformation`, under the shadcn CLI at dev time,
moves past a Windows command injection.
## [1.47.0] — 2026-11-04
### Added
+1 -1
View File
@@ -133,7 +133,7 @@ RUN mkdir -p /opt/prisma-cli && \
npm init -y && \
npm pkg set 'overrides.deepmerge-ts=^8.0.0' && \
npm pkg set 'overrides.mysql2=^3.22.0' && \
npm pkg set 'overrides.@hono/node-server=^1.19.15' && \
npm pkg set 'overrides.@hono/node-server=^2.1.3' && \
npm pkg set 'overrides.valibot=^1.4.2' && \
npm pkg set 'allowScripts.@prisma/engines=true' 'allowScripts.prisma=true' 'allowScripts.esbuild=true' --json && \
npm install --omit=dev prisma@7.10.1 @prisma/engines@7.10.1 tsx@4.23.1 dotenv@18.0.9 --no-fund --no-audit
+2
View File
@@ -45,6 +45,8 @@ notes, and machine-readable specs that ship alongside the source:
code. [`using/search.md`](./using/search.md) covers the command palette's
record search: opening it, what it finds, how matching works, and its
limits on very large and shared records.
[`using/cycle-calendar-feed.md`](./using/cycle-calendar-feed.md) covers
subscribing to the cycle forecast from Google, Apple or Outlook.
- [`migration/`](./migration/) — migration notes. Currently only
v1.3 to v1.4; later releases are covered by `CHANGELOG.md` and
<https://docs.healthlog.dev>.
+1704 -796
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -194,6 +194,7 @@ hand-authored.
| `WALKING_ASYMMETRY` | percent (0–100) |
| `WALKING_DOUBLE_SUPPORT` | percent (0–100) |
| `ACTIVE_ENERGY_BURNED` | kcal |
| `BASAL_ENERGY_BURNED` | kcal |
| `ENERGY_EXPENDITURE_KJ` | kJ |
| `VO2_MAX` | mL/(kg·min) |
| `PULSE_WAVE_VELOCITY` | m/s |
+7 -7
View File
@@ -72,11 +72,11 @@ echo "ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env
echo "API_TOKEN_HMAC_KEY=$(openssl rand -hex 32)" >> .env
```
| Variable | Purpose | Rotation cost |
| -------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| `POSTGRES_PASSWORD` | Bundled Postgres password — used by both the `db` service and `DATABASE_URL` | Database restart + connection-string update |
| `ENCRYPTION_KEY` | AES-256-GCM key for at-rest secrets (Withings tokens, AI provider keys, VAPID secrets) | Coordinated; see `docs/ops/encryption-key-rotation.md` |
| `API_TOKEN_HMAC_KEY` | HMAC-SHA256 key for hashing Bearer API tokens and web session cookies before storage | Invalidates every issued `hlk_*` token and signs out every browser session |
| Variable | Purpose | Rotation cost |
| -------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `POSTGRES_PASSWORD` | Bundled Postgres password — used by both the `db` service and `DATABASE_URL` | Database restart + connection-string update |
| `ENCRYPTION_KEY` | AES-256-GCM key for at-rest secrets (Withings tokens, AI provider keys, VAPID secrets) | Coordinated; see `docs/ops/encryption-key-rotation.md` |
| `API_TOKEN_HMAC_KEY` | HMAC-SHA256 key for hashing Bearer API tokens and web session cookies before storage | Invalidates every issued `hlk_*` token, share link and cycle calendar-feed link, and signs out every browser session |
You do **not** edit `DATABASE_URL` on the bundled `docker-compose.yml`
stack: compose constructs the connection string inside the `app`
@@ -289,8 +289,8 @@ this server needs:
retired id for as long as a stored or off-host backup copy still needs it;
- `BACKUP_ENCRYPTION_KEY` and any `BACKUP_ENCRYPTION_PREVIOUS_KEYS`, when
off-host copies are set up;
- `API_TOKEN_HMAC_KEY`, without which every session, token and share link ends
(no data is lost).
- `API_TOKEN_HMAC_KEY`, without which every session, token, share link and
calendar-feed link ends (no data is lost).
Each key is shown by id and a fingerprint (the first 12 hex characters of
SHA-256 over the key), never the key itself, and **Check my copy** compares a
@@ -121,6 +121,27 @@ the grace window to 0 or wait it out, remove the old volume, and let the
dumps age out or delete them
([backup-restore.md](../ops/backup-restore.md)).
## Calendar subscription
The cycle settings can publish your forecast as a calendar feed that Google
Calendar, Apple Calendar or Outlook subscribes to. It is off by default, and
turning it on changes the arrangement this page describes: from then on the
feed's dates also sit with that calendar provider, on its servers, under its
policies. That is exactly the cloud vendor self-hosting otherwise keeps out of
the picture.
What the feed carries is deliberately small. Each event is an all-day date
range and a title, nothing else: no symptoms, notes, flow, temperatures, test
results, contraception or intercourse fields. By default the title is a dot
and the calendar is named "HealthLog", so a glance at a shared screen says
nothing; past cycles are left out unless you ask for them, and the fertile
window is only offered when your goal is trying to conceive.
Turning the subscription off, or rotating its link, stops the provider's next
poll. It does not reach the copies the provider already fetched: remove the
subscribed calendar there as well. The link itself is a credential; see
[keeping it out of access logs](./reverse-proxy.md#credentials-in-the-url-path-keep-them-out-of-access-logs).
## Practical hardening
If reproductive-health privacy is the reason you self-host, the
+43
View File
@@ -336,6 +336,49 @@ A `TRUST_PROXY_HOPS` value that is not a whole number resolves no client
address at all (requests keep working) and is named in the `[proxy-trust]`
boot line.
## Credentials in the URL path: keep them out of access logs
A few HealthLog URLs carry their credential in the path, because the caller
cannot send a header: the cycle calendar feed (`/api/cycle/feed/hlf_…`,
polled by Google, Apple or Outlook), the clinician share view (`/c/hls_…`)
and the provider webhooks. HealthLog scrubs these segments from its own logs
and error reports. It cannot reach your proxy's access log, which records the
full path of every request by default, so anyone who can read that log can
read a live feed link.
Either drop those requests from the access log or rewrite the path before it
is written. For Caddy, skip logging the token-bearing paths:
```caddyfile
your-instance.example.com {
@credential_paths path /api/cycle/feed/* /c/*
log_skip @credential_paths
reverse_proxy app:3000
}
```
For Nginx, log a redacted path instead of `$request`:
```nginx
map $request_uri $redacted_uri {
~^/api/cycle/feed/ /api/cycle/feed/[REDACTED];
~^/c/ /c/[REDACTED];
default $request_uri;
}
log_format redacted '$remote_addr - [$time_local] "$request_method $redacted_uri" '
'$status $body_bytes_sent "$http_user_agent"';
access_log /var/log/nginx/access.log redacted;
```
Traefik's access log has no per-path filter; turn `accessLog` off, or keep it
on a host only you can read. If a feed link has been in a log you do not
control, rotate it in the app (Cycle, Settings, Calendar subscription, Rotate
link): the old one stops working on the provider's next poll.
The feed answers with `Cache-Control: private, no-cache` and an `ETag`, so a
caching proxy in front must pass `If-None-Match` through rather than cache the
body for everyone.
## Running over plain HTTP on a LAN (no reverse proxy)
The session cookie carries the `Secure` flag by default under
+55
View File
@@ -0,0 +1,55 @@
# Subscribing to your cycle forecast
HealthLog can publish your cycle forecast as a calendar subscription, so the
next few periods show up in the calendar app you already use. The feed is
computed fresh every time your calendar app asks for it; HealthLog stays the
only place the data is kept.
## Turning it on
Open **Cycle**, then **Settings**, and find **Calendar subscription**. Before
the button, the card says what it means: your calendar provider stores the
dates on its servers, and turning the subscription off later does not remove
copies it already holds. HealthLog asks you to confirm it is you (a password or
passkey) and then shows the link once. Copy it, or use **Open in calendar app**.
Only you can turn it on for your own record. For a managed profile (a child's
record, for instance), its guardian can. Someone you have shared your record
with cannot, whatever level of access you gave them: the feed puts your dates
in their calendar provider's cloud, and that is your decision alone. It is
managed on the web; the iOS app shows where to find it.
## Adding it to your calendar
- **Google Calendar:** Other calendars, From URL, paste the link. Google
refreshes subscriptions on its own schedule, roughly every 12 to 24 hours.
- **Apple Calendar:** on a Mac, File, New Calendar Subscription. On iPhone,
Settings, Calendar, Accounts, Add Account, Other, Add Subscribed Calendar.
Apple lets you choose how often it refreshes.
- **Outlook:** Add calendar, Subscribe from web, paste the link. Outlook
refreshes every few hours.
## What the feed shows
- The next period and, once three cycles are logged, a few after it (three
by default, up to six). Each event covers the whole window the period may
fall in, and the window widens the further out it is. The list stops itself
where a date would be more guess than estimate (beyond about ±10 days, or
half a cycle).
- Optionally, your past cycles (up to twelve).
- Optionally, when your goal is trying to conceive, the estimated fertile
window of the next cycle. It is an estimate, not a contraceptive method.
Titles are discreet by default: each event is a dot and the calendar is called
"HealthLog". Descriptive titles name the period instead. Events never carry
symptoms, notes or any other detail of your log.
When a period is later than its window, the feed shows no forecast until you
log it, instead of guessing a new date. With predictions switched off, only
past cycles appear (if you chose to include them).
## Rotating and turning off
**Rotate link** replaces the link; calendars using the old one stop updating.
**Turn off** ends the link. In both cases, remove the old subscribed calendar
in your calendar app too: what it already fetched stays there until you do.
+43
View File
@@ -820,6 +820,49 @@ test.describe("document vault", () => {
await expect(openButton(page, `${marker}-copy.pdf`)).not.toBeVisible();
});
test("a busy server is waited out and the file still lands", async ({
page,
isMobile,
}) => {
test.skip(isMobile, "runs once on the desktop project (shared bucket)");
// The route answers the first POST the way a full upload slot does; the
// queue must retry it after Retry-After instead of showing an error.
let refused = 0;
await page.route("**/api/documents/inbound", async (route) => {
if (route.request().method() === "POST" && refused === 0) {
refused += 1;
await route.fulfill({
status: 429,
headers: { "Retry-After": "1", "Content-Type": "application/json" },
body: JSON.stringify({
data: null,
error: "Too many uploads are already in progress.",
meta: {
errorCode: "documents.inbound.uploadBusy",
reason: "uploadBusy",
},
}),
});
return;
}
await route.fallback();
});
await page.goto("/documents");
const marker = `busy-probe-${Date.now()}`;
await uploadViaPicker(page, `${marker}.pdf`, uniquePdf(marker));
await page
.getByRole("searchbox", { name: "Search documents" })
.fill(marker);
await expect(openButton(page, `${marker}.pdf`)).toBeVisible({
timeout: 15_000,
});
expect(refused).toBe(1);
await expect(
page.locator('[data-slot="document-upload-card"]'),
).toHaveCount(0);
});
// ── Admin cap: commit-on-Enter + rejection copy naming the limit ──────
test("a 1 MB admin cap rejects an oversized upload with the limit in the copy", async ({
+104
View File
@@ -0,0 +1,104 @@
import AxeBuilder from "@axe-core/playwright";
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup";
import {
expectNoHorizontalOverflow,
settleForOverflowMeasurement,
} from "./utils/horizontal-overflow";
/**
* The labs list under its categories.
*
* The account is the one `globalSetup` seeds for the mobile route sweep: one
* marker under its own panel and one qualitative reading without a panel, so
* the server answers two categories and `grouped: true`. The grouping choice
* is served to this page only (the layout read is answered in the browser
* with the account's own layout plus `groupBy: "category"`), so the shared
* account's stored preferences stay as other specs expect them.
*/
const LAYOUT = "**/api/settings/module-list-layout";
const GROUP = '[data-slot="lab-list-group"]';
async function serveGrouped(page: Page, groupsOpen = "expanded") {
await page.route(LAYOUT, async (route) => {
if (route.request().method() !== "GET") return route.continue();
const response = await route.fetch();
const body = (await response.json()) as {
data: { lists: { labs: Record<string, unknown> } };
};
body.data.lists.labs = {
...body.data.lists.labs,
groupBy: "category",
groupsOpen,
saved: true,
};
await route.fulfill({ response, json: body });
});
}
test.describe("the lab list under its categories", () => {
test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH });
test.beforeEach(async ({ context }, testInfo) => {
const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000";
await context.addCookies([
{ name: "healthlog-locale", value: "de", url: baseURL },
]);
});
test("shows one disclosure per category and folds a category away", async ({
page,
}) => {
await serveGrouped(page);
await page.goto("/labs", { waitUntil: "domcontentloaded" });
const headers = page.locator(GROUP);
await expect(headers).toHaveCount(2);
await expect(headers.first()).toHaveAttribute("aria-expanded", "true");
const names = page.locator('[data-slot="lab-list-analyte"]');
const before = await names.count();
expect(before).toBeGreaterThan(1);
await headers.first().click();
await expect(headers.first()).toHaveAttribute("aria-expanded", "false");
await expect(names).toHaveCount(before - 1);
// The choice holds across a reload in this browser.
await page.reload({ waitUntil: "domcontentloaded" });
await expect(page.locator(GROUP).first()).toHaveAttribute(
"aria-expanded",
"false",
);
});
test("starts with every category closed when the person chose so", async ({
page,
}) => {
await serveGrouped(page, "collapsed");
await page.goto("/labs", { waitUntil: "domcontentloaded" });
const headers = page.locator(GROUP);
await expect(headers).toHaveCount(2);
for (const header of await headers.all()) {
await expect(header).toHaveAttribute("aria-expanded", "false");
}
await expect(page.locator('[data-slot="lab-list-analyte"]')).toHaveCount(0);
});
test("fits a phone and passes axe", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await serveGrouped(page);
await page.goto("/labs", { waitUntil: "domcontentloaded" });
await expect(page.locator(GROUP)).toHaveCount(2);
await settleForOverflowMeasurement(page);
await expectNoHorizontalOverflow(page, "/labs grouped at 390 px");
const results = await new AxeBuilder({ page })
.include('[data-slot="lab-list"]')
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
expect(results.violations.map((v) => v.id)).toEqual([]);
});
});
+46
View File
@@ -0,0 +1,46 @@
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* The tobacco section's typed fields take what a person types one key at a
* time. Through v1.47.0 neither did: "When you stopped" only accepted text
* that was already a whole YYYY-MM month, so every keystroke was thrown away
* and only a paste got in, and the start year clamped each keystroke into
* 1900..this year, so the first digit became 1900.
*
* The test types and leaves the fields without saving, so the shared
* account keeps whatever answers it had.
*/
test.describe("life circumstances typed fields", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("start year and quit month take keystrokes", async ({ page }) => {
await page.goto("/settings/anamnesis", { waitUntil: "domcontentloaded" });
const section = page.locator('[data-slot="life-section-tobacco"]');
await section.locator("summary").click();
await section.locator("#life-tobacco-status").selectOption("FORMER");
const start = section.locator("#life-tobacco-start");
await start.fill("");
await start.pressSequentially("1985");
await expect(start).toHaveValue("1985");
await start.blur();
await expect(start).toHaveValue("1985");
const quit = section.locator("#life-tobacco-quit");
await quit.fill("");
await quit.pressSequentially("2019-03");
await expect(quit).toHaveValue("2019-03");
await quit.blur();
await expect(quit).toHaveValue("2019-03");
// A single-digit month is padded when the field is left.
await quit.fill("");
await quit.pressSequentially("2018-7");
await expect(quit).toHaveValue("2018-7");
await quit.blur();
await expect(quit).toHaveValue("2018-07");
});
});
+95 -7
View File
@@ -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
@@ -866,13 +869,18 @@ test("a lab marker is picked, drawn as its readings, read on its own and kept",
'[data-slot="timeline-point-target"][data-series="lab:bm-ferritin"][data-t="fe-1"]',
);
const tip = page.locator('[data-slot="timeline-point-tip"]');
// A CI run focused the target and no tip opened. The cause is not pinned
// down (the tips layer unmounts while the chart has no layout, which is
// one candidate); focus again until the tip opens.
await expect(async () => {
await target.focus();
await expect(tip).toHaveAttribute("data-t", "fe-1", { timeout: 1_000 });
}).toPass({ timeout: 10_000 });
// A pointer resting on another line's point, as the menu click can leave
// it: the focus takes the card, and that point losing the pointer (the
// focus scrolls the chart from under it) must not close the card.
await page
.locator('[data-slot="timeline-point-target"][data-series="WEIGHT"]')
.last()
.hover();
await expect(tip).toHaveAttribute("data-series", "WEIGHT");
await target.focus();
await expect(tip).toHaveAttribute("data-t", "fe-1");
await page.mouse.move(0, 0);
await expect(tip).toHaveAttribute("data-t", "fe-1");
await expect(tip).toContainText("22 ng/mL");
await expect(tip).toContainText("Laborbefund März");
@@ -914,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,
}) => {
+63 -1
View File
@@ -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
+112 -7
View File
@@ -819,6 +819,7 @@
"typeHeartRateVariability": "Herzfrequenzvariabilität",
"typeRestingHeartRate": "Ruheherzfrequenz",
"typeActiveEnergyBurned": "Aktive Energie",
"typeBasalEnergyBurned": "Ruheenergie",
"typeFlightsClimbed": "Treppen",
"typeWalkingRunningDistance": "Geh- und Laufstrecke",
"typeVo2Max": "VO₂max",
@@ -5277,6 +5278,7 @@
"oura": "Oura",
"whoop": "WHOOP",
"polar": "Polar",
"garmin": "Garmin",
"fitbit": "Fitbit",
"strava": "Strava",
"nightscout": "Nightscout",
@@ -5284,7 +5286,8 @@
},
"hints": {
"apple-health": "Wird von der iOS-App synchronisiert oder aus einem Export importiert.",
"file": "Wird unter Einstellungen importiert, sobald du hier fertig bist."
"file": "Wird unter Einstellungen importiert, sobald du hier fertig bist.",
"fitbit": "Wird jetzt über Google Health verbunden."
}
},
"visit": {
@@ -5338,6 +5341,7 @@
"waiting": "Noch nicht verbunden.",
"openSettings": "Einstellungen → Integrationen öffnen",
"noDate": "unbekannt",
"throughGoogleHealth": "{source}-Daten kommen jetzt über {target}: Die klassische {source}-Verbindung wurde abgeschaltet. Verbinde {target} mit dem Google-Konto, an dem dein {source} angemeldet ist, dann kommen deine Werte von dort.",
"needsCredentials": {
"title": "Diese Instanz hat noch keine {target}-App hinterlegt.",
"detail": "Trage deine eigenen Zugangsdaten unter Einstellungen → Integrationen ein, dann kann die Verbindung starten."
@@ -6903,7 +6907,9 @@
"summary": "{count} Datentypen, neuestes {relative}",
"quiet": "{count} still",
"workouts": "Workouts",
"medicationDoses": "Medikamenteneinnahmen"
"medicationDoses": "Medikamenteneinnahmen",
"cycleDays": "Zyklustage",
"cycleDaysCount": "Zyklustage ({count})"
},
"integrationFallback": {
"description": "Für dieses Konto eingerichtet, hat aber keine eigene Einstellungskarte."
@@ -10020,7 +10026,70 @@
"startedPeriodOnDate": "Periode hat an diesem Tag begonnen",
"endedPeriodOnDate": "Periode an diesem Tag beendet",
"startAbsorbed": "Periodenbeginn gespeichert. Dein Beginn am {absorbed} wurde mit dem {date} zusammengeführt.",
"startRestored": "Gespeichert. Der Beginn am {dates} ist wieder da."
"startRestored": "Gespeichert. Der Beginn am {dates} ist wieder da.",
"feed": {
"title": "Kalender-Abo",
"description": "Deine nächsten Perioden als ganztägige Termine in der Kalender-App, die du schon nutzt.",
"disclosure": "Dein Kalenderanbieter (Google, iCloud, Outlook) speichert diese Daten auf seinen Servern. Schaltest du das Abo später ab, bleiben Kopien, die er schon hat, dort liegen.",
"turnOn": "Abo einschalten",
"error": "Das Kalender-Abo konnte nicht geändert werden. Versuch es noch einmal.",
"linkHidden": "Ein Link, der mit {prefix} beginnt, ist aktiv. Er wird nur einmal gezeigt, beim Erstellen; erneuere ihn, wenn du einen neuen brauchst.",
"linkLabel": "Abo-Link",
"linkOnce": "Nur jetzt sichtbar. Füge ihn in deine Kalender-App ein oder erneuere ihn später für einen neuen.",
"copy": "Link kopieren",
"copied": "Link kopiert.",
"copyFailed": "Kopieren hat nicht geklappt. Markiere den Link und kopiere ihn von Hand.",
"openInCalendar": "In Kalender-App öffnen",
"notFetched": "Noch nicht abgerufen.",
"lastFetched": "Zuletzt {when} von {fetcher} abgerufen.",
"fetcher": {
"GOOGLE": "Google Kalender",
"APPLE": "Apple Kalender",
"MICROSOFT": "Outlook",
"OTHER": "einer Kalender-App"
},
"horizon": "Zyklen voraus",
"history": "Vergangene Zyklen",
"historyNone": "Keine",
"titles": "Termintitel",
"titleMode": {
"DISCREET": "Diskret (●)",
"DESCRIPTIVE": "Beschreibend"
},
"fertile": "Fruchtbares Fenster",
"fertileDescription": "Fügt das geschätzte fruchtbare Fenster des nächsten Zyklus hinzu. Eine Schätzung, keine Verhütungsmethode.",
"rotate": "Link erneuern",
"rotateTitle": "Link erneuern?",
"rotateBody": "Kalender mit dem alten Link werden nicht mehr aktualisiert. Du bekommst einen neuen Link, den du stattdessen einträgst.",
"turnOff": "Ausschalten",
"turnOffTitle": "Kalender-Abo ausschalten?",
"turnOffBody": "Der Link funktioniert nicht mehr. Kopien bei deinem Kalenderanbieter bleiben, bis du den Kalender dort entfernst.",
"help": {
"title": "So trägst du es ein",
"google": "Google Kalender: Weitere Kalender, Per URL, dann den Link einfügen. Google aktualisiert etwa alle 12 bis 24 Stunden.",
"apple": "Apple Kalender: am Mac Ablage, Neues Kalenderabonnement; am iPhone Einstellungen, Kalender, Accounts, Account hinzufügen, Andere, Kalenderabo hinzufügen.",
"outlook": "Outlook: Kalender hinzufügen, Aus dem Internet abonnieren, dann den Link einfügen. Outlook aktualisiert alle paar Stunden."
},
"ics": {
"calendarName": "Zyklus (HealthLog)",
"periodEstimate": "Periode (Schätzung)",
"periodLogged": "Periode (erfasst)",
"fertileEstimate": "Fruchtbares Fenster (Schätzung)",
"likelyStart": "Wahrscheinlicher Beginn {date}, {band}",
"bandOne": "±{count} Tag",
"bandFew": "±{count} Tage",
"bandOther": "±{count} Tage"
}
},
"projection": {
"title": "Spätere Zyklen",
"around": "Um den {date}",
"state": {
"LEARNING": "Bis 3 Zyklen erfasst sind, ist nur die nächste Periode enthalten.",
"LATE": "Keine Prognose, solange diese Periode später kommt als erwartet; sie ist zurück, sobald du sie erfasst.",
"OFF": "Prognosen sind ausgeschaltet; enthalten sind nur vergangene Zyklen, falls gewählt."
}
}
},
"cycleReminders": {
"periodSoonTitle": "Periode steht wahrscheinlich bevor",
@@ -10316,6 +10385,7 @@
"unitLockedHint": "Sobald dieser Marker Werte hat, bleibt seine Einheit, wie sie ist. Sonst würden die gespeicherten Werte in einer neuen Einheit angezeigt, ohne umgerechnet zu werden.",
"panel": "Panel",
"panelPlaceholder": "z. B. Lipide",
"panelHint": "Wähle einen Vorschlag oder gib ein eigenes ein. Panels aus dem Katalog werden in jeder Sprache erkannt.",
"lowerBound": "Referenz unten",
"upperBound": "Referenz oben",
"lowerPlaceholder": "Untere Grenze",
@@ -10499,6 +10569,23 @@
"loadMoreReadings": "Weitere Werte laden",
"documentsLink": "Befund-Dokumente",
"notLinkedYet": "Noch keinem Biomarker zugeordnet",
"groups": {
"other": "Sonstige",
"allInRange": "Alle im Bereich",
"outsideCountOne": "{count} von {total} außerhalb des Bereichs",
"outsideCountFew": "{count} von {total} außerhalb des Bereichs",
"outsideCountOther": "{count} von {total} außerhalb des Bereichs",
"heading": "Kategorien",
"description": "Gruppiert die Werte nach Panel, etwa Lipide oder Leber, mit einem Status je Gruppe.",
"label": "Gruppierung",
"none": "Keine",
"category": "Nach Kategorie",
"openLabel": "Beim Öffnen aufgeklappt",
"openExpanded": "Alle",
"openFlagged": "Nur außerhalb des Bereichs",
"openCollapsed": "Keine",
"orderWithinGroups": "Die Reihenfolge gilt innerhalb jeder Kategorie."
},
"sourceRange": {
"fromReport": "Befundbereich {range}",
"catalogDiffers": "dein Bereich {range}",
@@ -10545,6 +10632,7 @@
"convertedOther": "{count} Messungen, umgerechnet von {unit} in {targetUnit}",
"refused": "{unit} lässt sich nicht in {targetUnit} umrechnen.",
"aliasesAdded": "Diese Namen führen danach zum behaltenen Biomarker:",
"adoptsPanel": "Der behaltene Biomarker übernimmt das Panel {panel}.",
"blocked": "Solange sich eine Einheit nicht umrechnen lässt, wird nichts zusammengeführt. Lass diesen Biomarker weg oder behalte einen anderen.",
"noUndo": "Das Zusammenführen lässt sich nicht rückgängig machen.",
"doneToastOne": "In {name} zusammengeführt, {count} Messung verschoben",
@@ -12005,7 +12093,8 @@
"unsupportedType": "Dieser Dateityp wird nicht unterstützt.",
"purged": "Dieses Dokument wurde endgültig entfernt und kann nicht wiederhergestellt werden.",
"duplicateExists": "Ein identisches Dokument ist bereits gespeichert.",
"rateLimited": "Zu viele Uploads. Versuche es in ein paar Minuten erneut.",
"uploadBusy": "Der Server war mit anderen Uploads beschäftigt. Versuche es jetzt erneut.",
"rateLimited": "Das Upload-Limit für diese Stunde ist erreicht. Versuche es später erneut.",
"generic": "Upload fehlgeschlagen. Bitte erneut versuchen."
},
"toast": {
@@ -13156,7 +13245,11 @@
"biomarkerMerge": "{name} hat Biomarker zusammengeführt",
"medicationIngredientsUpdate": "{name} hat die Zusammensetzung eines Präparats geändert",
"auditCCreate": "{name} hat den AUDIT-C-Fragebogen ausgefüllt",
"auditCDelete": "{name} hat einen AUDIT-C-Eintrag gelöscht"
"auditCDelete": "{name} hat einen AUDIT-C-Eintrag gelöscht",
"cycleFeedCreate": "{name} hat das Kalender-Abo eingeschaltet",
"cycleFeedUpdate": "{name} hat das Kalender-Abo geändert",
"cycleFeedRotate": "{name} hat den Link des Kalender-Abos erneuert",
"cycleFeedRevoke": "{name} hat das Kalender-Abo ausgeschaltet"
},
"section": {
"measurements": {
@@ -13333,7 +13426,9 @@
"specialty": "Seit deinem letzten Termin ({specialty}) am {date}",
"kind": "Seit deinem letzten Termin der gleichen Art ({kind}) am {date}",
"default": "Die letzten {days} Tage",
"defaultReason": "Kein früherer Termin bei derselben Praxis, in derselben Fachrichtung oder derselben Art."
"defaultReason": "Kein früherer Termin bei derselben Praxis, in derselben Fachrichtung oder derselben Art.",
"capped": "Die letzten zwei Jahre",
"cappedReason": "Der letzte passende Termin liegt mehr als zwei Jahre zurück, und ein Bericht umfasst höchstens zwei Jahre."
}
},
"checkups": {
@@ -13849,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",
+112 -7
View File
@@ -819,6 +819,7 @@
"typeHeartRateVariability": "Heart-rate variability",
"typeRestingHeartRate": "Resting heart rate",
"typeActiveEnergyBurned": "Active energy",
"typeBasalEnergyBurned": "Resting energy",
"typeFlightsClimbed": "Flights climbed",
"typeWalkingRunningDistance": "Walking + running distance",
"typeVo2Max": "VO₂ max",
@@ -5277,6 +5278,7 @@
"oura": "Oura",
"whoop": "WHOOP",
"polar": "Polar",
"garmin": "Garmin",
"fitbit": "Fitbit",
"strava": "Strava",
"nightscout": "Nightscout",
@@ -5284,7 +5286,8 @@
},
"hints": {
"apple-health": "Synced by the iOS app, or imported from an export.",
"file": "Imported under Settings once you are done here."
"file": "Imported under Settings once you are done here.",
"fitbit": "Connected through Google Health now."
}
},
"visit": {
@@ -5338,6 +5341,7 @@
"waiting": "Not connected yet.",
"openSettings": "Open Settings → Integrations",
"noDate": "unknown",
"throughGoogleHealth": "{source} data now comes through {target}: the classic {source} connection has been shut down. Connect {target} with the Google account your {source} is signed in to, and your readings arrive from there.",
"needsCredentials": {
"title": "This instance has no {target} app on file yet.",
"detail": "Enter your own credentials under Settings → Integrations, then the connection can start."
@@ -6903,7 +6907,9 @@
"summary": "{count} data types, newest {relative}",
"quiet": "{count} quiet",
"workouts": "Workouts",
"medicationDoses": "Medication doses"
"medicationDoses": "Medication doses",
"cycleDays": "Cycle days",
"cycleDaysCount": "Cycle days ({count})"
},
"integrationFallback": {
"description": "Configured on this account without a settings card of its own."
@@ -10020,7 +10026,70 @@
"startedPeriodOnDate": "My period started this day",
"endedPeriodOnDate": "My period ended this day",
"startAbsorbed": "Period start saved. Your start on {absorbed} was merged into {date}.",
"startRestored": "Saved. The start on {dates} is back."
"startRestored": "Saved. The start on {dates} is back.",
"feed": {
"title": "Calendar subscription",
"description": "Your next periods as all-day events in the calendar app you already use.",
"disclosure": "Your calendar provider (Google, iCloud, Outlook) stores these dates on its servers. Turning this off later does not remove copies it already holds.",
"turnOn": "Turn on subscription",
"error": "The calendar subscription could not be changed. Try again.",
"linkHidden": "A link starting with {prefix} is active. It is shown only once, when it is made; rotate it to get a new one.",
"linkLabel": "Subscription link",
"linkOnce": "Shown only now. Paste it into your calendar app, or rotate it later for a new one.",
"copy": "Copy link",
"copied": "Link copied.",
"copyFailed": "Copying did not work. Select the link and copy it by hand.",
"openInCalendar": "Open in calendar app",
"notFetched": "Not fetched yet.",
"lastFetched": "Last fetched {when} by {fetcher}.",
"fetcher": {
"GOOGLE": "Google Calendar",
"APPLE": "Apple Calendar",
"MICROSOFT": "Outlook",
"OTHER": "a calendar app"
},
"horizon": "Cycles ahead",
"history": "Past cycles",
"historyNone": "None",
"titles": "Event titles",
"titleMode": {
"DISCREET": "Discreet (●)",
"DESCRIPTIVE": "Descriptive"
},
"fertile": "Fertile window",
"fertileDescription": "Adds the estimated fertile window of the next cycle. An estimate, not a contraceptive method.",
"rotate": "Rotate link",
"rotateTitle": "Rotate the link?",
"rotateBody": "Calendars using the old link stop updating. You get a new link to add instead.",
"turnOff": "Turn off",
"turnOffTitle": "Turn off the calendar subscription?",
"turnOffBody": "The link stops working. Copies your calendar provider already holds stay until you remove the calendar there.",
"help": {
"title": "Adding it to your calendar",
"google": "Google Calendar: Other calendars, From URL, then paste the link. Google refreshes about every 12 to 24 hours.",
"apple": "Apple Calendar: on a Mac, File, New Calendar Subscription; on iPhone, Settings, Calendar, Accounts, Add Account, Other, Add Subscribed Calendar.",
"outlook": "Outlook: Add calendar, Subscribe from web, then paste the link. Outlook refreshes every few hours."
},
"ics": {
"calendarName": "Cycle (HealthLog)",
"periodEstimate": "Period (estimate)",
"periodLogged": "Period (logged)",
"fertileEstimate": "Fertile window (estimate)",
"likelyStart": "Most likely start {date}, {band}",
"bandOne": "±{count} day",
"bandFew": "±{count} days",
"bandOther": "±{count} days"
}
},
"projection": {
"title": "Later cycles",
"around": "Around {date}",
"state": {
"LEARNING": "Until 3 cycles are logged, only the next period is included.",
"LATE": "No forecast while this period is later than expected; it returns once you log it.",
"OFF": "Forecasts are turned off; only past cycles are included, if chosen."
}
}
},
"cycleReminders": {
"periodSoonTitle": "Period likely soon",
@@ -10316,6 +10385,7 @@
"unitLockedHint": "Once this marker has readings, its unit stays as it is. The stored values would otherwise be shown in a new unit without being converted.",
"panel": "Panel",
"panelPlaceholder": "e.g. Lipids",
"panelHint": "Pick a suggestion or type your own. Catalog panels are recognised in every language.",
"lowerBound": "Reference low",
"upperBound": "Reference high",
"lowerPlaceholder": "Lower bound",
@@ -10499,6 +10569,23 @@
"loadMoreReadings": "Load more readings",
"documentsLink": "Lab report documents",
"notLinkedYet": "Not linked to a biomarker yet",
"groups": {
"other": "Other",
"allInRange": "All in range",
"outsideCountOne": "{count} of {total} outside range",
"outsideCountFew": "{count} of {total} outside range",
"outsideCountOther": "{count} of {total} outside range",
"heading": "Categories",
"description": "Group the values by panel, such as Lipids or Liver, with a range status per group.",
"label": "Grouping",
"none": "None",
"category": "By category",
"openLabel": "Open on load",
"openExpanded": "All",
"openFlagged": "Outside range only",
"openCollapsed": "None",
"orderWithinGroups": "The order applies within each category."
},
"sourceRange": {
"fromReport": "Report range {range}",
"catalogDiffers": "your range {range}",
@@ -10545,6 +10632,7 @@
"convertedOther": "{count} readings, converted from {unit} to {targetUnit}",
"refused": "{unit} cannot be converted to {targetUnit}.",
"aliasesAdded": "These names will lead to the kept biomarker:",
"adoptsPanel": "The kept biomarker takes over the panel {panel}.",
"blocked": "Nothing can be merged while a unit cannot be converted. Leave that biomarker out or keep another one.",
"noUndo": "A merge cannot be undone.",
"doneToastOne": "Merged into {name}, {count} reading moved",
@@ -12005,7 +12093,8 @@
"unsupportedType": "This file type is not supported.",
"purged": "This document was permanently removed and cannot be restored.",
"duplicateExists": "An identical document is already stored.",
"rateLimited": "Too many uploads right now. Try again in a few minutes.",
"uploadBusy": "The server was busy with other uploads. Try again now.",
"rateLimited": "You have reached the upload limit for this hour. Try again later.",
"generic": "Upload failed. Please try again."
},
"toast": {
@@ -13156,7 +13245,11 @@
"biomarkerMerge": "{name} merged biomarkers",
"medicationIngredientsUpdate": "{name} changed what a supplement contains",
"auditCCreate": "{name} answered the AUDIT-C questions",
"auditCDelete": "{name} removed an AUDIT-C entry"
"auditCDelete": "{name} removed an AUDIT-C entry",
"cycleFeedCreate": "{name} turned on the calendar subscription",
"cycleFeedUpdate": "{name} changed the calendar subscription",
"cycleFeedRotate": "{name} replaced the calendar subscription link",
"cycleFeedRevoke": "{name} turned off the calendar subscription"
},
"section": {
"measurements": {
@@ -13333,7 +13426,9 @@
"specialty": "Since your last {specialty} visit on {date}",
"kind": "Since your last visit of the same kind ({kind}) on {date}",
"default": "The last {days} days",
"defaultReason": "No earlier visit with the same practice, specialty or kind."
"defaultReason": "No earlier visit with the same practice, specialty or kind.",
"capped": "The last two years",
"cappedReason": "The last matching visit is more than two years ago, and a report covers two years at most."
}
},
"checkups": {
@@ -13849,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",
+112 -7
View File
@@ -819,6 +819,7 @@
"typeHeartRateVariability": "Variabilidad de la frecuencia cardiaca",
"typeRestingHeartRate": "Frecuencia cardíaca en reposo",
"typeActiveEnergyBurned": "Energía activa",
"typeBasalEnergyBurned": "Energía en reposo",
"typeFlightsClimbed": "Escaleras",
"typeWalkingRunningDistance": "Distancia a pie y corriendo",
"typeVo2Max": "VO₂ máx",
@@ -5277,6 +5278,7 @@
"oura": "Oura",
"whoop": "WHOOP",
"polar": "Polar",
"garmin": "Garmin",
"fitbit": "Fitbit",
"strava": "Strava",
"nightscout": "Nightscout",
@@ -5284,7 +5286,8 @@
},
"hints": {
"apple-health": "Sincronizado por la app de iOS o importado desde una exportación.",
"file": "Se importa en Ajustes cuando termines aquí."
"file": "Se importa en Ajustes cuando termines aquí.",
"fitbit": "Ahora se conecta a través de Google Health."
}
},
"visit": {
@@ -5338,6 +5341,7 @@
"waiting": "Aún no conectado.",
"openSettings": "Abrir Ajustes → Integraciones",
"noDate": "desconocido",
"throughGoogleHealth": "Los datos de {source} llegan ahora a través de {target}: la conexión clásica de {source} se ha cerrado. Conecta {target} con la cuenta de Google en la que tienes iniciada la sesión de {source} y tus lecturas llegarán desde ahí.",
"needsCredentials": {
"title": "Esta instancia aún no tiene una aplicación de {target} registrada.",
"detail": "Introduce tus propias credenciales en Ajustes → Integraciones y la conexión podrá empezar."
@@ -6903,7 +6907,9 @@
"summary": "{count} tipos de datos, más reciente {relative}",
"quiet": "{count} en silencio",
"workouts": "Entrenamientos",
"medicationDoses": "Tomas de medicación"
"medicationDoses": "Tomas de medicación",
"cycleDays": "Días del ciclo",
"cycleDaysCount": "Días del ciclo ({count})"
},
"integrationFallback": {
"description": "Configurado en esta cuenta pero sin tarjeta de ajustes propia."
@@ -10020,7 +10026,70 @@
"startedPeriodOnDate": "Mi periodo empezó este día",
"endedPeriodOnDate": "Mi periodo terminó este día",
"startAbsorbed": "Inicio del periodo guardado. Tu inicio del {absorbed} se ha unido al del {date}.",
"startRestored": "Guardado. El inicio del {dates} vuelve a estar."
"startRestored": "Guardado. El inicio del {dates} vuelve a estar.",
"feed": {
"title": "Suscripción de calendario",
"description": "Tus próximos periodos como eventos de todo el día en la app de calendario que ya usas.",
"disclosure": "Tu proveedor de calendario (Google, iCloud, Outlook) guarda estas fechas en sus servidores. Desactivarlo más tarde no borra las copias que ya tiene.",
"turnOn": "Activar suscripción",
"error": "No se pudo cambiar la suscripción de calendario. Inténtalo de nuevo.",
"linkHidden": "Hay activo un enlace que empieza por {prefix}. Solo se muestra una vez, al crearlo; renuévalo para obtener uno nuevo.",
"linkLabel": "Enlace de suscripción",
"linkOnce": "Solo se muestra ahora. Pégalo en tu app de calendario o renuévalo más tarde para obtener uno nuevo.",
"copy": "Copiar enlace",
"copied": "Enlace copiado.",
"copyFailed": "No se pudo copiar. Selecciona el enlace y cópialo a mano.",
"openInCalendar": "Abrir en la app de calendario",
"notFetched": "Aún no se ha consultado.",
"lastFetched": "Última consulta {when} por {fetcher}.",
"fetcher": {
"GOOGLE": "Google Calendar",
"APPLE": "Calendario de Apple",
"MICROSOFT": "Outlook",
"OTHER": "una app de calendario"
},
"horizon": "Ciclos por delante",
"history": "Ciclos pasados",
"historyNone": "Ninguno",
"titles": "Títulos de los eventos",
"titleMode": {
"DISCREET": "Discreto (●)",
"DESCRIPTIVE": "Descriptivo"
},
"fertile": "Ventana fértil",
"fertileDescription": "Añade la ventana fértil estimada del próximo ciclo. Una estimación, no un método anticonceptivo.",
"rotate": "Renovar enlace",
"rotateTitle": "¿Renovar el enlace?",
"rotateBody": "Los calendarios que usan el enlace antiguo dejan de actualizarse. Recibes un enlace nuevo para añadirlo en su lugar.",
"turnOff": "Desactivar",
"turnOffTitle": "¿Desactivar la suscripción de calendario?",
"turnOffBody": "El enlace deja de funcionar. Las copias que ya tiene tu proveedor de calendario se quedan hasta que elimines allí el calendario.",
"help": {
"title": "Añadirlo a tu calendario",
"google": "Google Calendar: Otros calendarios, Desde URL y pega el enlace. Google actualiza más o menos cada 12 a 24 horas.",
"apple": "Calendario de Apple: en Mac, Archivo, Nueva suscripción a calendario; en iPhone, Ajustes, Calendario, Cuentas, Añadir cuenta, Otra, Añadir calendario suscrito.",
"outlook": "Outlook: Agregar calendario, Suscribirse desde la web y pega el enlace. Outlook actualiza cada pocas horas."
},
"ics": {
"calendarName": "Ciclo (HealthLog)",
"periodEstimate": "Periodo (estimación)",
"periodLogged": "Periodo (registrado)",
"fertileEstimate": "Ventana fértil (estimación)",
"likelyStart": "Inicio más probable {date}, {band}",
"bandOne": "±{count} día",
"bandFew": "±{count} días",
"bandOther": "±{count} días"
}
},
"projection": {
"title": "Ciclos siguientes",
"around": "Hacia el {date}",
"state": {
"LEARNING": "Hasta que haya 3 ciclos registrados, solo se incluye el próximo periodo.",
"LATE": "Sin previsión mientras este periodo llegue más tarde de lo esperado; vuelve en cuanto lo registres.",
"OFF": "Las previsiones están desactivadas; solo se incluyen ciclos pasados, si los elegiste."
}
}
},
"cycleReminders": {
"periodSoonTitle": "El periodo llegará pronto",
@@ -10316,6 +10385,7 @@
"unitLockedHint": "Cuando este marcador ya tiene valores, su unidad no cambia. De lo contrario, los valores guardados se mostrarían en una unidad nueva sin convertirse.",
"panel": "Panel",
"panelPlaceholder": "p. ej. Lípidos",
"panelHint": "Elige una sugerencia o escribe la tuya. Los paneles del catálogo se reconocen en todos los idiomas.",
"lowerBound": "Referencia mínima",
"upperBound": "Referencia máxima",
"lowerPlaceholder": "Límite inferior",
@@ -10499,6 +10569,23 @@
"loadMoreReadings": "Cargar más mediciones",
"documentsLink": "Documentos de resultados",
"notLinkedYet": "Aún no vinculado a un biomarcador",
"groups": {
"other": "Otros",
"allInRange": "Todo en rango",
"outsideCountOne": "{count} de {total} fuera de rango",
"outsideCountFew": "{count} de {total} fuera de rango",
"outsideCountOther": "{count} de {total} fuera de rango",
"heading": "Categorías",
"description": "Agrupa los valores por panel, como Lípidos o Hígado, con un estado por grupo.",
"label": "Agrupación",
"none": "Ninguna",
"category": "Por categoría",
"openLabel": "Abiertas al cargar",
"openExpanded": "Todas",
"openFlagged": "Solo fuera de rango",
"openCollapsed": "Ninguna",
"orderWithinGroups": "El orden se aplica dentro de cada categoría."
},
"sourceRange": {
"fromReport": "Rango del informe {range}",
"catalogDiffers": "tu rango {range}",
@@ -10545,6 +10632,7 @@
"convertedOther": "{count} mediciones, convertidas de {unit} a {targetUnit}",
"refused": "{unit} no se puede convertir a {targetUnit}.",
"aliasesAdded": "Estos nombres llevarán al biomarcador conservado:",
"adoptsPanel": "El biomarcador conservado adopta el panel {panel}.",
"blocked": "No se combina nada mientras una unidad no se pueda convertir. Deja fuera ese biomarcador o conserva otro.",
"noUndo": "Una combinación no se puede deshacer.",
"doneToastOne": "Combinado en {name}, {count} medición movida",
@@ -12005,7 +12093,8 @@
"unsupportedType": "Este tipo de archivo no es compatible.",
"purged": "Este documento se eliminó definitivamente y no se puede restaurar.",
"duplicateExists": "Ya hay guardado un documento idéntico.",
"rateLimited": "Demasiadas subidas. Inténtalo de nuevo en unos minutos.",
"uploadBusy": "El servidor estaba ocupado con otras subidas. Inténtalo de nuevo ahora.",
"rateLimited": "Has alcanzado el límite de subidas de esta hora. Inténtalo de nuevo más tarde.",
"generic": "La subida falló. Inténtalo de nuevo."
},
"toast": {
@@ -13156,7 +13245,11 @@
"biomarkerMerge": "{name} combinó biomarcadores",
"medicationIngredientsUpdate": "{name} cambió la composición de un suplemento",
"auditCCreate": "{name} respondió el cuestionario AUDIT-C",
"auditCDelete": "{name} eliminó una entrada del AUDIT-C"
"auditCDelete": "{name} eliminó una entrada del AUDIT-C",
"cycleFeedCreate": "{name} activó la suscripción de calendario",
"cycleFeedUpdate": "{name} cambió la suscripción de calendario",
"cycleFeedRotate": "{name} renovó el enlace de la suscripción de calendario",
"cycleFeedRevoke": "{name} desactivó la suscripción de calendario"
},
"section": {
"measurements": {
@@ -13333,7 +13426,9 @@
"specialty": "Desde tu última visita ({specialty}) el {date}",
"kind": "Desde tu última visita del mismo tipo ({kind}) el {date}",
"default": "Los últimos {days} días",
"defaultReason": "No hay ninguna visita anterior con la misma consulta, especialidad o tipo."
"defaultReason": "No hay ninguna visita anterior con la misma consulta, especialidad o tipo.",
"capped": "Los últimos dos años",
"cappedReason": "La última visita coincidente fue hace más de dos años, y un informe abarca como máximo dos años."
}
},
"checkups": {
@@ -13849,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",
+141 -36
View File
@@ -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": {
@@ -819,6 +819,7 @@
"typeHeartRateVariability": "Variabilité de la fréquence cardiaque",
"typeRestingHeartRate": "Fréquence cardiaque au repos",
"typeActiveEnergyBurned": "Énergie active",
"typeBasalEnergyBurned": "Énergie au repos",
"typeFlightsClimbed": "Étages",
"typeWalkingRunningDistance": "Distance à pied et en course",
"typeVo2Max": "VO₂ max",
@@ -2286,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",
@@ -2358,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",
@@ -3237,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": {
@@ -3337,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.",
@@ -3941,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).",
@@ -3995,7 +3996,7 @@
"bedtime": "Au coucher",
"unspecified": "Non précisée",
"readingsShort": "{count} mesures",
"readingsShortOne": "1 mesure"
"readingsShortOne": "{count} mesure"
}
}
}
@@ -4934,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"
@@ -4948,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",
@@ -5277,6 +5278,7 @@
"oura": "Oura",
"whoop": "WHOOP",
"polar": "Polar",
"garmin": "Garmin",
"fitbit": "Fitbit",
"strava": "Strava",
"nightscout": "Nightscout",
@@ -5284,7 +5286,8 @@
},
"hints": {
"apple-health": "Synchronisé par l'app iOS, ou importé depuis un export.",
"file": "À importer dans les réglages une fois cette étape terminée."
"file": "À importer dans les réglages une fois cette étape terminée.",
"fitbit": "Se connecte désormais via Google Health."
}
},
"visit": {
@@ -5338,6 +5341,7 @@
"waiting": "Pas encore connecté.",
"openSettings": "Ouvrir Paramètres → Intégrations",
"noDate": "inconnue",
"throughGoogleHealth": "Les données {source} passent désormais par {target} : l'ancienne connexion {source} a été arrêtée. Connectez {target} avec le compte Google auquel votre {source} est associé, et vos mesures arriveront par là.",
"needsCredentials": {
"title": "Cette instance n’a pas encore d’application {target} enregistrée.",
"detail": "Saisissez vos propres identifiants dans Paramètres → Intégrations, la connexion pourra alors démarrer."
@@ -5438,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": {
@@ -6903,7 +6907,9 @@
"summary": "{count} types de données, plus récent {relative}",
"quiet": "{count} silencieux",
"workouts": "Séances",
"medicationDoses": "Prises de médicaments"
"medicationDoses": "Prises de médicaments",
"cycleDays": "Jours du cycle",
"cycleDaysCount": "Jours du cycle ({count})"
},
"integrationFallback": {
"description": "Configuré sur ce compte sans carte de réglages dédiée."
@@ -8009,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",
@@ -10020,7 +10026,70 @@
"startedPeriodOnDate": "Mes règles ont commencé ce jour-là",
"endedPeriodOnDate": "Mes règles se sont terminées ce jour-là",
"startAbsorbed": "Début des règles enregistré. Votre début du {absorbed} a été fusionné avec celui du {date}.",
"startRestored": "Enregistré. Le début du {dates} est de retour."
"startRestored": "Enregistré. Le début du {dates} est de retour.",
"feed": {
"title": "Abonnement au calendrier",
"description": "Vos prochaines règles en événements sur la journée, dans l'application de calendrier que vous utilisez déjà.",
"disclosure": "Votre fournisseur de calendrier (Google, iCloud, Outlook) conserve ces dates sur ses serveurs. Désactiver l'abonnement plus tard ne supprime pas les copies qu'il détient déjà.",
"turnOn": "Activer l'abonnement",
"error": "L'abonnement au calendrier n'a pas pu être modifié. Réessayez.",
"linkHidden": "Un lien commençant par {prefix} est actif. Il n'est affiché qu'une fois, à sa création ; renouvelez-le pour en obtenir un nouveau.",
"linkLabel": "Lien d'abonnement",
"linkOnce": "Affiché uniquement maintenant. Collez-le dans votre application de calendrier, ou renouvelez-le plus tard pour en obtenir un nouveau.",
"copy": "Copier le lien",
"copied": "Lien copié.",
"copyFailed": "La copie n'a pas fonctionné. Sélectionnez le lien et copiez-le manuellement.",
"openInCalendar": "Ouvrir dans l'application de calendrier",
"notFetched": "Pas encore récupéré.",
"lastFetched": "Dernière récupération {when} par {fetcher}.",
"fetcher": {
"GOOGLE": "Google Agenda",
"APPLE": "Calendrier Apple",
"MICROSOFT": "Outlook",
"OTHER": "une application de calendrier"
},
"horizon": "Cycles à venir",
"history": "Cycles passés",
"historyNone": "Aucun",
"titles": "Titres des événements",
"titleMode": {
"DISCREET": "Discret (●)",
"DESCRIPTIVE": "Explicite"
},
"fertile": "Fenêtre fertile",
"fertileDescription": "Ajoute la fenêtre fertile estimée du prochain cycle. Une estimation, pas une méthode contraceptive.",
"rotate": "Renouveler le lien",
"rotateTitle": "Renouveler le lien ?",
"rotateBody": "Les calendriers qui utilisent l'ancien lien ne sont plus mis à jour. Vous recevez un nouveau lien à ajouter à la place.",
"turnOff": "Désactiver",
"turnOffTitle": "Désactiver l'abonnement au calendrier ?",
"turnOffBody": "Le lien ne fonctionne plus. Les copies que détient déjà votre fournisseur de calendrier restent jusqu'à ce que vous y supprimiez le calendrier.",
"help": {
"title": "L'ajouter à votre calendrier",
"google": "Google Agenda : Autres agendas, À partir de l'URL, puis collez le lien. Google actualise environ toutes les 12 à 24 heures.",
"apple": "Calendrier Apple : sur Mac, Fichier, Nouvel abonnement à un calendrier ; sur iPhone, Réglages, Calendrier, Comptes, Ajouter un compte, Autre, Ajouter un calendrier avec abonnement.",
"outlook": "Outlook : Ajouter un calendrier, S'abonner à partir du web, puis collez le lien. Outlook actualise toutes les quelques heures."
},
"ics": {
"calendarName": "Cycle (HealthLog)",
"periodEstimate": "Règles (estimation)",
"periodLogged": "Règles (enregistrées)",
"fertileEstimate": "Fenêtre fertile (estimation)",
"likelyStart": "Début le plus probable {date}, {band}",
"bandOne": "±{count} jour",
"bandFew": "±{count} jours",
"bandOther": "±{count} jours"
}
},
"projection": {
"title": "Cycles suivants",
"around": "Vers le {date}",
"state": {
"LEARNING": "Tant que 3 cycles ne sont pas enregistrés, seules les prochaines règles sont incluses.",
"LATE": "Pas de prévision tant que ces règles sont plus tardives que prévu ; elle revient dès que vous les enregistrez.",
"OFF": "Les prévisions sont désactivées ; seuls les cycles passés sont inclus, si vous les avez choisis."
}
}
},
"cycleReminders": {
"periodSoonTitle": "Règles probablement bientôt",
@@ -10316,6 +10385,7 @@
"unitLockedHint": "Dès que ce marqueur a des valeurs, son unité reste la même. Sinon, les valeurs enregistrées s'afficheraient dans une nouvelle unité sans être converties.",
"panel": "Panel",
"panelPlaceholder": "ex. Lipides",
"panelHint": "Choisissez une suggestion ou saisissez la vôtre. Les panels du catalogue sont reconnus dans toutes les langues.",
"lowerBound": "Référence basse",
"upperBound": "Référence haute",
"lowerPlaceholder": "Borne inférieure",
@@ -10499,6 +10569,23 @@
"loadMoreReadings": "Charger plus de mesures",
"documentsLink": "Documents de résultats",
"notLinkedYet": "Pas encore lié à un biomarqueur",
"groups": {
"other": "Autres",
"allInRange": "Tout dans la norme",
"outsideCountOne": "{count} sur {total} hors norme",
"outsideCountFew": "{count} sur {total} hors norme",
"outsideCountOther": "{count} sur {total} hors norme",
"heading": "Catégories",
"description": "Regroupe les valeurs par panel, par exemple Lipides ou Foie, avec un statut par groupe.",
"label": "Regroupement",
"none": "Aucun",
"category": "Par catégorie",
"openLabel": "Ouvertes à l’affichage",
"openExpanded": "Toutes",
"openFlagged": "Hors norme seulement",
"openCollapsed": "Aucune",
"orderWithinGroups": "L’ordre s’applique au sein de chaque catégorie."
},
"sourceRange": {
"fromReport": "Plage du compte rendu {range}",
"catalogDiffers": "votre plage {range}",
@@ -10545,6 +10632,7 @@
"convertedOther": "{count} mesures, converties de {unit} en {targetUnit}",
"refused": "{unit} ne peut pas être converti en {targetUnit}.",
"aliasesAdded": "Ces noms mèneront au biomarqueur conservé :",
"adoptsPanel": "Le biomarqueur conservé reprend le panel {panel}.",
"blocked": "Rien n'est fusionné tant qu'une unité ne peut pas être convertie. Laissez ce biomarqueur de côté ou conservez-en un autre.",
"noUndo": "Une fusion ne peut pas être annulée.",
"doneToastOne": "Fusionné dans {name}, {count} mesure déplacée",
@@ -10947,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.",
@@ -10960,7 +11048,7 @@
"resolved": "Résolus"
},
"flares": {
"countOne": "1 poussée",
"countOne": "{count} poussée",
"countOther": "{count} poussées"
},
"timeline": {
@@ -11092,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": {
@@ -11202,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": {
@@ -12005,7 +12093,8 @@
"unsupportedType": "Ce type de fichier n'est pas pris en charge.",
"purged": "Ce document a été définitivement supprimé et ne peut pas être restauré.",
"duplicateExists": "Un document identique est déjà enregistré.",
"rateLimited": "Trop de téléversements. Réessayez dans quelques minutes.",
"uploadBusy": "Le serveur était occupé par d’autres téléversements. Réessayez maintenant.",
"rateLimited": "Vous avez atteint la limite de téléversements pour cette heure. Réessayez plus tard.",
"generic": "Le téléversement a échoué. Veuillez réessayer."
},
"toast": {
@@ -12159,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",
@@ -12191,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.",
@@ -13156,7 +13245,11 @@
"biomarkerMerge": "{name} a fusionné des biomarqueurs",
"medicationIngredientsUpdate": "{name} a modifié la composition d’un complément",
"auditCCreate": "{name} a répondu au questionnaire AUDIT-C",
"auditCDelete": "{name} a supprimé une entrée AUDIT-C"
"auditCDelete": "{name} a supprimé une entrée AUDIT-C",
"cycleFeedCreate": "{name} a activé l'abonnement au calendrier",
"cycleFeedUpdate": "{name} a modifié l'abonnement au calendrier",
"cycleFeedRotate": "{name} a renouvelé le lien de l'abonnement au calendrier",
"cycleFeedRevoke": "{name} a désactivé l'abonnement au calendrier"
},
"section": {
"measurements": {
@@ -13333,7 +13426,9 @@
"specialty": "Depuis votre dernière visite ({specialty}) le {date}",
"kind": "Depuis votre dernière visite du même type ({kind}) le {date}",
"default": "Les {days} derniers jours",
"defaultReason": "Aucune visite antérieure au même cabinet, dans la même spécialité ou du même type."
"defaultReason": "Aucune visite antérieure au même cabinet, dans la même spécialité ou du même type.",
"capped": "Les deux dernières années",
"cappedReason": "La dernière consultation correspondante date de plus de deux ans, et un rapport couvre au plus deux ans."
}
},
"checkups": {
@@ -13510,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",
@@ -13577,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",
@@ -13773,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"
},
@@ -13806,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": {
@@ -13830,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"
},
@@ -13849,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",
+112 -7
View File
@@ -819,6 +819,7 @@
"typeHeartRateVariability": "Variabilità della frequenza cardiaca",
"typeRestingHeartRate": "Frequenza cardiaca a riposo",
"typeActiveEnergyBurned": "Energia attiva",
"typeBasalEnergyBurned": "Energia a riposo",
"typeFlightsClimbed": "Piani saliti",
"typeWalkingRunningDistance": "Distanza a piedi e di corsa",
"typeVo2Max": "VO₂ max",
@@ -5277,6 +5278,7 @@
"oura": "Oura",
"whoop": "WHOOP",
"polar": "Polar",
"garmin": "Garmin",
"fitbit": "Fitbit",
"strava": "Strava",
"nightscout": "Nightscout",
@@ -5284,7 +5286,8 @@
},
"hints": {
"apple-health": "Sincronizzato dall'app iOS, oppure importato da un'esportazione.",
"file": "Si importa nelle Impostazioni quando hai finito qui."
"file": "Si importa nelle Impostazioni quando hai finito qui.",
"fitbit": "Ora si collega tramite Google Health."
}
},
"visit": {
@@ -5338,6 +5341,7 @@
"waiting": "Non ancora collegato.",
"openSettings": "Apri Impostazioni → Integrazioni",
"noDate": "sconosciuto",
"throughGoogleHealth": "I dati {source} ora arrivano tramite {target}: la connessione classica a {source} è stata chiusa. Collega {target} con l'account Google a cui è associato il tuo {source} e le tue misurazioni arriveranno da lì.",
"needsCredentials": {
"title": "Questa istanza non ha ancora un’app {target} registrata.",
"detail": "Inserisci le tue credenziali in Impostazioni → Integrazioni e la connessione potrà partire."
@@ -6903,7 +6907,9 @@
"summary": "{count} tipi di dati, più recente {relative}",
"quiet": "{count} in silenzio",
"workouts": "Allenamenti",
"medicationDoses": "Assunzioni di farmaci"
"medicationDoses": "Assunzioni di farmaci",
"cycleDays": "Giorni del ciclo",
"cycleDaysCount": "Giorni del ciclo ({count})"
},
"integrationFallback": {
"description": "Configurato su questo account ma senza una scheda di impostazioni propria."
@@ -10020,7 +10026,70 @@
"startedPeriodOnDate": "Le mestruazioni sono iniziate questo giorno",
"endedPeriodOnDate": "Le mestruazioni sono finite questo giorno",
"startAbsorbed": "Inizio delle mestruazioni salvato. Il tuo inizio del {absorbed} è stato unito a quello del {date}.",
"startRestored": "Salvato. L’inizio del {dates} è di nuovo presente."
"startRestored": "Salvato. L’inizio del {dates} è di nuovo presente.",
"feed": {
"title": "Abbonamento al calendario",
"description": "Le tue prossime mestruazioni come eventi di un'intera giornata nell'app di calendario che usi già.",
"disclosure": "Il tuo fornitore di calendario (Google, iCloud, Outlook) conserva queste date sui suoi server. Disattivare l'abbonamento in seguito non elimina le copie che ha già.",
"turnOn": "Attiva abbonamento",
"error": "Non è stato possibile modificare l'abbonamento al calendario. Riprova.",
"linkHidden": "È attivo un link che inizia con {prefix}. Viene mostrato una sola volta, quando viene creato; rinnovalo per averne uno nuovo.",
"linkLabel": "Link di abbonamento",
"linkOnce": "Visibile solo adesso. Incollalo nella tua app di calendario, oppure rinnovalo più tardi per averne uno nuovo.",
"copy": "Copia link",
"copied": "Link copiato.",
"copyFailed": "La copia non è riuscita. Seleziona il link e copialo a mano.",
"openInCalendar": "Apri nell'app di calendario",
"notFetched": "Non ancora scaricato.",
"lastFetched": "Ultimo scaricamento {when} da {fetcher}.",
"fetcher": {
"GOOGLE": "Google Calendar",
"APPLE": "Calendario di Apple",
"MICROSOFT": "Outlook",
"OTHER": "un'app di calendario"
},
"horizon": "Cicli in avanti",
"history": "Cicli passati",
"historyNone": "Nessuno",
"titles": "Titoli degli eventi",
"titleMode": {
"DISCREET": "Discreto (●)",
"DESCRIPTIVE": "Descrittivo"
},
"fertile": "Finestra fertile",
"fertileDescription": "Aggiunge la finestra fertile stimata del prossimo ciclo. Una stima, non un metodo contraccettivo.",
"rotate": "Rinnova link",
"rotateTitle": "Rinnovare il link?",
"rotateBody": "I calendari che usano il vecchio link smettono di aggiornarsi. Ricevi un nuovo link da aggiungere al suo posto.",
"turnOff": "Disattiva",
"turnOffTitle": "Disattivare l'abbonamento al calendario?",
"turnOffBody": "Il link smette di funzionare. Le copie che il tuo fornitore di calendario ha già restano finché non rimuovi lì il calendario.",
"help": {
"title": "Aggiungerlo al calendario",
"google": "Google Calendar: Altri calendari, Da URL, poi incolla il link. Google aggiorna circa ogni 12-24 ore.",
"apple": "Calendario di Apple: sul Mac, File, Nuovo abbonamento a calendario; su iPhone, Impostazioni, Calendario, Account, Aggiungi account, Altro, Aggiungi calendario sottoscritto.",
"outlook": "Outlook: Aggiungi calendario, Sottoscrivi dal Web, poi incolla il link. Outlook aggiorna ogni poche ore."
},
"ics": {
"calendarName": "Ciclo (HealthLog)",
"periodEstimate": "Mestruazioni (stima)",
"periodLogged": "Mestruazioni (registrate)",
"fertileEstimate": "Finestra fertile (stima)",
"likelyStart": "Inizio più probabile {date}, {band}",
"bandOne": "±{count} giorno",
"bandFew": "±{count} giorni",
"bandOther": "±{count} giorni"
}
},
"projection": {
"title": "Cicli successivi",
"around": "Intorno al {date}",
"state": {
"LEARNING": "Finché non sono registrati 3 cicli, è incluso solo il prossimo ciclo mestruale.",
"LATE": "Nessuna previsione finché queste mestruazioni sono più in ritardo del previsto; torna appena le registri.",
"OFF": "Le previsioni sono disattivate; sono inclusi solo i cicli passati, se li hai scelti."
}
}
},
"cycleReminders": {
"periodSoonTitle": "Ciclo probabilmente in arrivo",
@@ -10316,6 +10385,7 @@
"unitLockedHint": "Quando questo marcatore ha già dei valori, la sua unità resta invariata. Altrimenti i valori salvati verrebbero mostrati in una nuova unità senza essere convertiti.",
"panel": "Panel",
"panelPlaceholder": "es. Lipidi",
"panelHint": "Scegli un suggerimento o scrivine uno tuo. I panel del catalogo vengono riconosciuti in ogni lingua.",
"lowerBound": "Riferimento minimo",
"upperBound": "Riferimento massimo",
"lowerPlaceholder": "Limite inferiore",
@@ -10499,6 +10569,23 @@
"loadMoreReadings": "Carica altre misurazioni",
"documentsLink": "Documenti dei referti",
"notLinkedYet": "Non ancora collegato a un biomarcatore",
"groups": {
"other": "Altri",
"allInRange": "Tutti nella norma",
"outsideCountOne": "{count} su {total} fuori norma",
"outsideCountFew": "{count} su {total} fuori norma",
"outsideCountOther": "{count} su {total} fuori norma",
"heading": "Categorie",
"description": "Raggruppa i valori per panel, ad esempio Lipidi o Fegato, con uno stato per gruppo.",
"label": "Raggruppamento",
"none": "Nessuno",
"category": "Per categoria",
"openLabel": "Aperte al caricamento",
"openExpanded": "Tutte",
"openFlagged": "Solo fuori norma",
"openCollapsed": "Nessuna",
"orderWithinGroups": "L’ordine vale all’interno di ogni categoria."
},
"sourceRange": {
"fromReport": "Intervallo del referto {range}",
"catalogDiffers": "il tuo intervallo {range}",
@@ -10545,6 +10632,7 @@
"convertedOther": "{count} misurazioni, convertite da {unit} a {targetUnit}",
"refused": "{unit} non può essere convertito in {targetUnit}.",
"aliasesAdded": "Questi nomi porteranno al biomarcatore mantenuto:",
"adoptsPanel": "Il biomarcatore mantenuto assume il panel {panel}.",
"blocked": "Non si unisce nulla finché un'unità non può essere convertita. Escludi quel biomarcatore o mantienine un altro.",
"noUndo": "Un'unione non può essere annullata.",
"doneToastOne": "Unito in {name}, {count} misurazione spostata",
@@ -12005,7 +12093,8 @@
"unsupportedType": "Questo tipo di file non è supportato.",
"purged": "Questo documento è stato rimosso definitivamente e non può essere ripristinato.",
"duplicateExists": "Un documento identico è già archiviato.",
"rateLimited": "Troppi caricamenti. Riprova tra qualche minuto.",
"uploadBusy": "Il server era occupato con altri caricamenti. Riprova adesso.",
"rateLimited": "Hai raggiunto il limite di caricamenti per quest’ora. Riprova più tardi.",
"generic": "Caricamento non riuscito. Riprova."
},
"toast": {
@@ -13156,7 +13245,11 @@
"biomarkerMerge": "{name} ha unito dei biomarcatori",
"medicationIngredientsUpdate": "{name} ha modificato la composizione di un integratore",
"auditCCreate": "{name} ha compilato il questionario AUDIT-C",
"auditCDelete": "{name} ha eliminato una voce AUDIT-C"
"auditCDelete": "{name} ha eliminato una voce AUDIT-C",
"cycleFeedCreate": "{name} ha attivato l'abbonamento al calendario",
"cycleFeedUpdate": "{name} ha modificato l'abbonamento al calendario",
"cycleFeedRotate": "{name} ha rinnovato il link dell'abbonamento al calendario",
"cycleFeedRevoke": "{name} ha disattivato l'abbonamento al calendario"
},
"section": {
"measurements": {
@@ -13333,7 +13426,9 @@
"specialty": "Dalla tua ultima visita ({specialty}) del {date}",
"kind": "Dalla tua ultima visita dello stesso tipo ({kind}) del {date}",
"default": "Gli ultimi {days} giorni",
"defaultReason": "Nessuna visita precedente presso lo stesso studio, nella stessa specialità o dello stesso tipo."
"defaultReason": "Nessuna visita precedente presso lo stesso studio, nella stessa specialità o dello stesso tipo.",
"capped": "Gli ultimi due anni",
"cappedReason": "L'ultima visita corrispondente risale a più di due anni fa, e un report copre al massimo due anni."
}
},
"checkups": {
@@ -13849,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",
+112 -7
View File
@@ -819,6 +819,7 @@
"typeHeartRateVariability": "심박변이도",
"typeRestingHeartRate": "안정 시 심박수",
"typeActiveEnergyBurned": "활동 에너지",
"typeBasalEnergyBurned": "휴식 에너지",
"typeFlightsClimbed": "오른 층수",
"typeWalkingRunningDistance": "걷기 + 달리기 거리",
"typeVo2Max": "VO₂ max",
@@ -5277,6 +5278,7 @@
"oura": "Oura",
"whoop": "WHOOP",
"polar": "Polar",
"garmin": "Garmin",
"fitbit": "Fitbit",
"strava": "Strava",
"nightscout": "Nightscout",
@@ -5284,7 +5286,8 @@
},
"hints": {
"apple-health": "iOS 앱이 동기화하거나 내보내기 파일에서 가져옵니다.",
"file": "여기서 마친 뒤 설정에서 가져옵니다."
"file": "여기서 마친 뒤 설정에서 가져옵니다.",
"fitbit": "이제 Google Health를 통해 연결됩니다."
}
},
"visit": {
@@ -5338,6 +5341,7 @@
"waiting": "아직 연결되지 않았습니다.",
"openSettings": "설정 → 연동 열기",
"noDate": "알 수 없음",
"throughGoogleHealth": "{source} 데이터는 이제 {target}를 통해 들어옵니다. 기존 {source} 연결은 종료되었습니다. {source}에 로그인한 Google 계정으로 {target}를 연결하면 측정값이 그곳에서 들어옵니다.",
"needsCredentials": {
"title": "이 인스턴스에는 아직 {target} 앱이 등록되어 있지 않습니다.",
"detail": "설정 → 연동에서 직접 발급받은 인증 정보를 입력하면 연결을 시작할 수 있습니다."
@@ -6903,7 +6907,9 @@
"summary": "데이터 유형 {count}개, 최신 {relative}",
"quiet": "{count}개 잠잠",
"workouts": "운동",
"medicationDoses": "약 복용"
"medicationDoses": "약 복용",
"cycleDays": "주기 기록일",
"cycleDaysCount": "주기 기록일 ({count})"
},
"integrationFallback": {
"description": "이 계정에 설정돼 있지만 전용 설정 카드는 없어요."
@@ -10020,7 +10026,70 @@
"startedPeriodOnDate": "이 날 생리를 시작했어요",
"endedPeriodOnDate": "이 날 생리가 끝났어요",
"startAbsorbed": "생리 시작을 저장했어요. {absorbed}의 시작은 {date}로 합쳐졌어요.",
"startRestored": "저장했어요. {dates}의 시작이 다시 표시돼요."
"startRestored": "저장했어요. {dates}의 시작이 다시 표시돼요.",
"feed": {
"title": "캘린더 구독",
"description": "다음 생리 예정일을 지금 쓰는 캘린더 앱에 종일 일정으로 보여줘요.",
"disclosure": "캘린더 제공업체(Google, iCloud, Outlook)는 이 날짜를 자사 서버에 저장해요. 나중에 구독을 꺼도 이미 가져간 사본은 지워지지 않아요.",
"turnOn": "구독 켜기",
"error": "캘린더 구독을 변경하지 못했어요. 다시 시도해 주세요.",
"linkHidden": "{prefix}(으)로 시작하는 링크가 활성 상태예요. 링크는 만들 때 한 번만 보여요. 새 링크가 필요하면 다시 발급하세요.",
"linkLabel": "구독 링크",
"linkOnce": "지금만 보여요. 캘린더 앱에 붙여 넣거나, 나중에 다시 발급해 새 링크를 받으세요.",
"copy": "링크 복사",
"copied": "링크를 복사했어요.",
"copyFailed": "복사하지 못했어요. 링크를 선택해 직접 복사하세요.",
"openInCalendar": "캘린더 앱에서 열기",
"notFetched": "아직 가져가지 않았어요.",
"lastFetched": "{fetcher}에서 마지막으로 가져감: {when}.",
"fetcher": {
"GOOGLE": "Google 캘린더",
"APPLE": "Apple 캘린더",
"MICROSOFT": "Outlook",
"OTHER": "캘린더 앱"
},
"horizon": "앞으로의 주기",
"history": "지난 주기",
"historyNone": "없음",
"titles": "일정 제목",
"titleMode": {
"DISCREET": "드러나지 않게 (●)",
"DESCRIPTIVE": "내용 표시"
},
"fertile": "가임기",
"fertileDescription": "다음 주기의 예상 가임기를 추가해요. 추정일 뿐 피임 방법이 아니에요.",
"rotate": "링크 다시 발급",
"rotateTitle": "링크를 다시 발급할까요?",
"rotateBody": "이전 링크를 쓰는 캘린더는 더 이상 업데이트되지 않아요. 대신 추가할 새 링크를 받게 돼요.",
"turnOff": "끄기",
"turnOffTitle": "캘린더 구독을 끌까요?",
"turnOffBody": "링크가 더 이상 작동하지 않아요. 캘린더 제공업체가 이미 가진 사본은 거기서 캘린더를 삭제할 때까지 남아 있어요.",
"help": {
"title": "캘린더에 추가하는 방법",
"google": "Google 캘린더: 다른 캘린더, URL로 추가에서 링크를 붙여 넣으세요. Google은 대략 12~24시간마다 새로 고쳐요.",
"apple": "Apple 캘린더: Mac에서는 파일, 새로운 캘린더 구독. iPhone에서는 설정, 캘린더, 계정, 계정 추가, 기타, 구독 캘린더 추가.",
"outlook": "Outlook: 캘린더 추가, 웹에서 구독에서 링크를 붙여 넣으세요. Outlook은 몇 시간마다 새로 고쳐요."
},
"ics": {
"calendarName": "주기 (HealthLog)",
"periodEstimate": "생리 (예상)",
"periodLogged": "생리 (기록)",
"fertileEstimate": "가임기 (예상)",
"likelyStart": "가장 가능성 높은 시작일 {date}, {band}",
"bandOne": "±{count}일",
"bandFew": "±{count}일",
"bandOther": "±{count}일"
}
},
"projection": {
"title": "이후 주기",
"around": "{date}쯤",
"state": {
"LEARNING": "주기가 3번 기록될 때까지는 다음 생리만 포함돼요.",
"LATE": "이번 생리가 예상보다 늦어지는 동안에는 예측이 없어요. 기록하면 다시 나타나요.",
"OFF": "예측이 꺼져 있어요. 선택한 경우 지난 주기만 포함돼요."
}
}
},
"cycleReminders": {
"periodSoonTitle": "생리가 곧 시작될 것 같아요",
@@ -10316,6 +10385,7 @@
"unitLockedHint": "이 마커에 기록된 값이 있으면 단위는 그대로 유지돼요. 그렇지 않으면 저장된 값이 변환되지 않은 채 새 단위로 표시돼요.",
"panel": "패널",
"panelPlaceholder": "예: 지질",
"panelHint": "제안에서 고르거나 직접 입력하세요. 카탈로그 패널은 모든 언어에서 인식돼요.",
"lowerBound": "참고 하한",
"upperBound": "참고 상한",
"lowerPlaceholder": "하한값",
@@ -10499,6 +10569,23 @@
"loadMoreReadings": "측정값 더 보기",
"documentsLink": "검사 결과지 문서",
"notLinkedYet": "아직 바이오마커에 연결되지 않았어요",
"groups": {
"other": "기타",
"allInRange": "모두 범위 내",
"outsideCountOne": "{total}개 중 {count}개 범위 밖",
"outsideCountFew": "{total}개 중 {count}개 범위 밖",
"outsideCountOther": "{total}개 중 {count}개 범위 밖",
"heading": "카테고리",
"description": "지질, 간처럼 패널별로 값을 묶고 그룹마다 상태를 보여줘요.",
"label": "묶기",
"none": "없음",
"category": "카테고리별",
"openLabel": "열 때 펼치기",
"openExpanded": "모두",
"openFlagged": "범위 밖만",
"openCollapsed": "없음",
"orderWithinGroups": "순서는 각 카테고리 안에서 적용돼요."
},
"sourceRange": {
"fromReport": "결과지 범위 {range}",
"catalogDiffers": "내 범위 {range}",
@@ -10545,6 +10632,7 @@
"convertedOther": "측정값 {count}개, {unit}에서 {targetUnit}(으)로 환산",
"refused": "{unit}은(는) {targetUnit}(으)로 환산할 수 없어요.",
"aliasesAdded": "이 이름들은 남긴 바이오마커로 연결돼요:",
"adoptsPanel": "남긴 바이오마커가 {panel} 패널을 이어받아요.",
"blocked": "환산할 수 없는 단위가 있으면 아무것도 합치지 않아요. 그 바이오마커를 빼거나 다른 항목을 남겨 주세요.",
"noUndo": "합친 뒤에는 되돌릴 수 없어요.",
"doneToastOne": "{name}(으)로 합쳤어요, 측정값 {count}개 이동",
@@ -12005,7 +12093,8 @@
"unsupportedType": "지원하지 않는 파일 형식이에요.",
"purged": "이 문서는 완전히 삭제되어 복구할 수 없어요.",
"duplicateExists": "같은 문서가 이미 저장돼 있어요.",
"rateLimited": "지금은 업로드가 너무 많아요. 몇 분 뒤에 다시 시도해 주세요.",
"uploadBusy": "서버가 다른 업로드를 처리하느라 바빴어요. 지금 다시 시도해 주세요.",
"rateLimited": "이번 시간의 업로드 한도에 도달했어요. 나중에 다시 시도해 주세요.",
"generic": "업로드하지 못했어요. 다시 시도해 주세요."
},
"toast": {
@@ -13156,7 +13245,11 @@
"biomarkerMerge": "{name} 님이 바이오마커를 합쳤어요",
"medicationIngredientsUpdate": "{name} 님이 보충제 성분을 수정했어요",
"auditCCreate": "{name} 님이 AUDIT-C 문항에 답했어요",
"auditCDelete": "{name} 님이 AUDIT-C 기록을 삭제했어요"
"auditCDelete": "{name} 님이 AUDIT-C 기록을 삭제했어요",
"cycleFeedCreate": "{name} 님이 캘린더 구독을 켰어요",
"cycleFeedUpdate": "{name} 님이 캘린더 구독을 변경했어요",
"cycleFeedRotate": "{name} 님이 캘린더 구독 링크를 다시 발급했어요",
"cycleFeedRevoke": "{name} 님이 캘린더 구독을 껐어요"
},
"section": {
"measurements": {
@@ -13333,7 +13426,9 @@
"specialty": "{date} 마지막 {specialty} 방문 이후",
"kind": "{date} 같은 유형({kind})의 마지막 방문 이후",
"default": "최근 {days}일",
"defaultReason": "같은 병원, 진료과 또는 유형의 이전 방문이 없어요."
"defaultReason": "같은 병원, 진료과 또는 유형의 이전 방문이 없어요.",
"capped": "최근 2년",
"cappedReason": "마지막으로 일치하는 진료가 2년 이상 지났으며, 보고서는 최대 2년까지 다룹니다."
}
},
"checkups": {
@@ -13849,7 +13944,17 @@
},
"legendLabValue": "측정된 그대로의 검사 수치",
"legendLabOutside": "참고 범위 밖",
"legendLabRange": "참고 범위"
"legendLabRange": "참고 범위",
"legendSymptom": "증상 선: 기록한 날마다의 가장 높은 강도(0~10)를 기록한 날 기준으로 평균한 값",
"symptoms": {
"menuTitle": "증상",
"full": "증상은 한 번에 최대 {max}개까지 표시할 수 있습니다. 다른 증상을 추가하려면 하나를 제거하세요.",
"hidden": "숨김",
"unnamed": "증상",
"daysOne": "1일",
"daysFew": "{count}일 평균",
"daysOther": "{count}일 평균"
}
},
"lifeEvents": {
"title": "삶의 사건",
+112 -7
View File
@@ -819,6 +819,7 @@
"typeHeartRateVariability": "Hartslagvariabiliteit",
"typeRestingHeartRate": "Rusthartslag",
"typeActiveEnergyBurned": "Actieve energie",
"typeBasalEnergyBurned": "Rustenergie",
"typeFlightsClimbed": "Beklommen verdiepingen",
"typeWalkingRunningDistance": "Afstand wandelen + hardlopen",
"typeVo2Max": "VO₂max",
@@ -5277,6 +5278,7 @@
"oura": "Oura",
"whoop": "WHOOP",
"polar": "Polar",
"garmin": "Garmin",
"fitbit": "Fitbit",
"strava": "Strava",
"nightscout": "Nightscout",
@@ -5284,7 +5286,8 @@
},
"hints": {
"apple-health": "Gesynchroniseerd door de iOS-app, of geïmporteerd uit een export.",
"file": "Te importeren onder Instellingen zodra je hier klaar bent."
"file": "Te importeren onder Instellingen zodra je hier klaar bent.",
"fitbit": "Wordt nu gekoppeld via Google Health."
}
},
"visit": {
@@ -5338,6 +5341,7 @@
"waiting": "Nog niet gekoppeld.",
"openSettings": "Instellingen → Koppelingen openen",
"noDate": "onbekend",
"throughGoogleHealth": "{source}-gegevens komen nu binnen via {target}: de klassieke {source}-koppeling is stopgezet. Koppel {target} met het Google-account waarmee je {source} is aangemeld, dan komen je metingen daarvandaan.",
"needsCredentials": {
"title": "Deze instantie heeft nog geen {target}-app geregistreerd.",
"detail": "Voer je eigen inloggegevens in onder Instellingen → Koppelingen, daarna kan de koppeling starten."
@@ -6903,7 +6907,9 @@
"summary": "{count} gegevenstypen, nieuwste {relative}",
"quiet": "{count} stil",
"workouts": "Trainingen",
"medicationDoses": "Medicatiedoses"
"medicationDoses": "Medicatiedoses",
"cycleDays": "Cyclusdagen",
"cycleDaysCount": "Cyclusdagen ({count})"
},
"integrationFallback": {
"description": "Ingesteld op dit account zonder eigen instellingenkaart."
@@ -10020,7 +10026,70 @@
"startedPeriodOnDate": "Mijn menstruatie begon op deze dag",
"endedPeriodOnDate": "Mijn menstruatie eindigde op deze dag",
"startAbsorbed": "Begin van menstruatie opgeslagen. Je begin op {absorbed} is samengevoegd met {date}.",
"startRestored": "Opgeslagen. Het begin op {dates} is terug."
"startRestored": "Opgeslagen. Het begin op {dates} is terug.",
"feed": {
"title": "Agenda-abonnement",
"description": "Je volgende menstruaties als afspraken voor de hele dag in de agenda-app die je al gebruikt.",
"disclosure": "Je agendaprovider (Google, iCloud, Outlook) bewaart deze datums op zijn servers. Als je dit later uitzet, blijven kopieën die hij al heeft daar staan.",
"turnOn": "Abonnement aanzetten",
"error": "Het agenda-abonnement kon niet worden gewijzigd. Probeer het opnieuw.",
"linkHidden": "Er is een link actief die begint met {prefix}. Die wordt maar één keer getoond, bij het aanmaken; vernieuw hem voor een nieuwe.",
"linkLabel": "Abonnementslink",
"linkOnce": "Alleen nu zichtbaar. Plak hem in je agenda-app, of vernieuw hem later voor een nieuwe.",
"copy": "Link kopiëren",
"copied": "Link gekopieerd.",
"copyFailed": "Kopiëren is niet gelukt. Selecteer de link en kopieer hem zelf.",
"openInCalendar": "Openen in agenda-app",
"notFetched": "Nog niet opgehaald.",
"lastFetched": "Laatst opgehaald {when} door {fetcher}.",
"fetcher": {
"GOOGLE": "Google Agenda",
"APPLE": "Apple Agenda",
"MICROSOFT": "Outlook",
"OTHER": "een agenda-app"
},
"horizon": "Cycli vooruit",
"history": "Eerdere cycli",
"historyNone": "Geen",
"titles": "Titels van afspraken",
"titleMode": {
"DISCREET": "Discreet (●)",
"DESCRIPTIVE": "Beschrijvend"
},
"fertile": "Vruchtbare periode",
"fertileDescription": "Voegt de geschatte vruchtbare periode van de volgende cyclus toe. Een schatting, geen anticonceptiemethode.",
"rotate": "Link vernieuwen",
"rotateTitle": "Link vernieuwen?",
"rotateBody": "Agenda's met de oude link worden niet meer bijgewerkt. Je krijgt een nieuwe link om in plaats daarvan toe te voegen.",
"turnOff": "Uitzetten",
"turnOffTitle": "Agenda-abonnement uitzetten?",
"turnOffBody": "De link werkt niet meer. Kopieën die je agendaprovider al heeft, blijven staan tot je de agenda daar verwijdert.",
"help": {
"title": "Toevoegen aan je agenda",
"google": "Google Agenda: Andere agenda's, Via URL, en plak de link. Google ververst ongeveer elke 12 tot 24 uur.",
"apple": "Apple Agenda: op de Mac Archief, Nieuw agenda-abonnement; op de iPhone Instellingen, Agenda, Accounts, Voeg account toe, Andere, Voeg agenda-abonnement toe.",
"outlook": "Outlook: Agenda toevoegen, Abonneren vanaf internet, en plak de link. Outlook ververst om de paar uur."
},
"ics": {
"calendarName": "Cyclus (HealthLog)",
"periodEstimate": "Menstruatie (schatting)",
"periodLogged": "Menstruatie (geregistreerd)",
"fertileEstimate": "Vruchtbare periode (schatting)",
"likelyStart": "Meest waarschijnlijke begin {date}, {band}",
"bandOne": "±{count} dag",
"bandFew": "±{count} dagen",
"bandOther": "±{count} dagen"
}
},
"projection": {
"title": "Latere cycli",
"around": "Rond {date}",
"state": {
"LEARNING": "Tot er 3 cycli zijn geregistreerd, is alleen de volgende menstruatie opgenomen.",
"LATE": "Geen voorspelling zolang deze menstruatie later is dan verwacht; die komt terug zodra je haar registreert.",
"OFF": "Voorspellingen staan uit; alleen eerdere cycli zijn opgenomen, als je die hebt gekozen."
}
}
},
"cycleReminders": {
"periodSoonTitle": "Menstruatie waarschijnlijk binnenkort",
@@ -10316,6 +10385,7 @@
"unitLockedHint": "Zodra deze marker metingen heeft, blijft de eenheid zoals die is. Anders zouden de opgeslagen waarden zonder omrekening in een nieuwe eenheid worden getoond.",
"panel": "Groep",
"panelPlaceholder": "bijv. Lipiden",
"panelHint": "Kies een suggestie of typ je eigen groep. Groepen uit de catalogus worden in elke taal herkend.",
"lowerBound": "Referentie ondergrens",
"upperBound": "Referentie bovengrens",
"lowerPlaceholder": "Ondergrens",
@@ -10499,6 +10569,23 @@
"loadMoreReadings": "Meer metingen laden",
"documentsLink": "Documenten met labrapporten",
"notLinkedYet": "Nog niet gekoppeld aan een biomarker",
"groups": {
"other": "Overig",
"allInRange": "Alles binnen bereik",
"outsideCountOne": "{count} van {total} buiten bereik",
"outsideCountFew": "{count} van {total} buiten bereik",
"outsideCountOther": "{count} van {total} buiten bereik",
"heading": "Categorieën",
"description": "Groepeert de waarden per groep, zoals Lipiden of Lever, met een status per groep.",
"label": "Groeperen",
"none": "Niet",
"category": "Per categorie",
"openLabel": "Open bij laden",
"openExpanded": "Alle",
"openFlagged": "Alleen buiten bereik",
"openCollapsed": "Geen",
"orderWithinGroups": "De volgorde geldt binnen elke categorie."
},
"sourceRange": {
"fromReport": "Bereik volgens rapport: {range}",
"catalogDiffers": "jouw bereik: {range}",
@@ -10545,6 +10632,7 @@
"convertedOther": "{count} metingen, omgerekend van {unit} naar {targetUnit}",
"refused": "{unit} kan niet worden omgerekend naar {targetUnit}.",
"aliasesAdded": "Deze namen verwijzen voortaan naar de behouden biomarker:",
"adoptsPanel": "De behouden biomarker neemt de groep {panel} over.",
"blocked": "Er kan niets worden samengevoegd zolang een eenheid niet kan worden omgerekend. Laat die biomarker weg of behoud een andere.",
"noUndo": "Samenvoegen kan niet ongedaan worden gemaakt.",
"doneToastOne": "Samengevoegd in {name}, {count} meting verplaatst",
@@ -12005,7 +12093,8 @@
"unsupportedType": "Dit bestandstype wordt niet ondersteund.",
"purged": "Dit document is definitief verwijderd en kan niet worden hersteld.",
"duplicateExists": "Er is al een identiek document opgeslagen.",
"rateLimited": "Te veel uploads op dit moment. Probeer het over een paar minuten opnieuw.",
"uploadBusy": "De server was bezig met andere uploads. Probeer het nu opnieuw.",
"rateLimited": "Je hebt de uploadlimiet voor dit uur bereikt. Probeer het later opnieuw.",
"generic": "Uploaden is mislukt. Probeer het opnieuw."
},
"toast": {
@@ -13156,7 +13245,11 @@
"biomarkerMerge": "{name} heeft biomarkers samengevoegd",
"medicationIngredientsUpdate": "{name} heeft de samenstelling van een supplement gewijzigd",
"auditCCreate": "{name} heeft de AUDIT-C-vragen beantwoord",
"auditCDelete": "{name} heeft een AUDIT-C-registratie verwijderd"
"auditCDelete": "{name} heeft een AUDIT-C-registratie verwijderd",
"cycleFeedCreate": "{name} heeft het agenda-abonnement aangezet",
"cycleFeedUpdate": "{name} heeft het agenda-abonnement gewijzigd",
"cycleFeedRotate": "{name} heeft de link van het agenda-abonnement vernieuwd",
"cycleFeedRevoke": "{name} heeft het agenda-abonnement uitgezet"
},
"section": {
"measurements": {
@@ -13333,7 +13426,9 @@
"specialty": "Sinds je laatste bezoek ({specialty}) op {date}",
"kind": "Sinds je laatste bezoek van hetzelfde soort ({kind}) op {date}",
"default": "De afgelopen {days} dagen",
"defaultReason": "Geen eerder bezoek bij dezelfde praktijk, in hetzelfde specialisme of van hetzelfde soort."
"defaultReason": "Geen eerder bezoek bij dezelfde praktijk, in hetzelfde specialisme of van hetzelfde soort.",
"capped": "De afgelopen twee jaar",
"cappedReason": "Het laatste passende bezoek is meer dan twee jaar geleden, en een rapport beslaat hooguit twee jaar."
}
},
"checkups": {
@@ -13849,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",
+112 -7
View File
@@ -819,6 +819,7 @@
"typeHeartRateVariability": "Zmienność rytmu serca",
"typeRestingHeartRate": "Tętno spoczynkowe",
"typeActiveEnergyBurned": "Aktywna energia",
"typeBasalEnergyBurned": "Energia spoczynkowa",
"typeFlightsClimbed": "Pokonane piętra",
"typeWalkingRunningDistance": "Dystans marszu i biegu",
"typeVo2Max": "VO₂ max",
@@ -5277,6 +5278,7 @@
"oura": "Oura",
"whoop": "WHOOP",
"polar": "Polar",
"garmin": "Garmin",
"fitbit": "Fitbit",
"strava": "Strava",
"nightscout": "Nightscout",
@@ -5284,7 +5286,8 @@
},
"hints": {
"apple-health": "Synchronizowane przez aplikację iOS lub importowane z eksportu.",
"file": "Import w Ustawieniach, gdy skończysz tutaj."
"file": "Import w Ustawieniach, gdy skończysz tutaj.",
"fitbit": "Teraz łączy się przez Google Health."
}
},
"visit": {
@@ -5338,6 +5341,7 @@
"waiting": "Jeszcze nie połączono.",
"openSettings": "Otwórz Ustawienia → Integracje",
"noDate": "nieznany",
"throughGoogleHealth": "Dane z {source} trafiają teraz przez {target}: klasyczne połączenie z {source} zostało wyłączone. Połącz {target} z kontem Google, na którym zalogowany jest Twój {source}, a pomiary zaczną napływać stamtąd.",
"needsCredentials": {
"title": "Ta instancja nie ma jeszcze zapisanej aplikacji {target}.",
"detail": "Wpisz własne dane dostępowe w Ustawieniach → Integracje, wtedy połączenie będzie mogło ruszyć."
@@ -6903,7 +6907,9 @@
"summary": "{count} typów danych, najnowsze {relative}",
"quiet": "{count} bez danych",
"workouts": "Treningi",
"medicationDoses": "Dawki leków"
"medicationDoses": "Dawki leków",
"cycleDays": "Dni cyklu",
"cycleDaysCount": "Dni cyklu ({count})"
},
"integrationFallback": {
"description": "Skonfigurowane na tym koncie, ale bez własnej karty ustawień."
@@ -10020,7 +10026,70 @@
"startedPeriodOnDate": "Miesiączka zaczęła się tego dnia",
"endedPeriodOnDate": "Miesiączka skończyła się tego dnia",
"startAbsorbed": "Początek miesiączki zapisany. Twój początek z {absorbed} został połączony z {date}.",
"startRestored": "Zapisano. Początek z {dates} jest z powrotem."
"startRestored": "Zapisano. Początek z {dates} jest z powrotem.",
"feed": {
"title": "Subskrypcja kalendarza",
"description": "Twoje kolejne miesiączki jako wydarzenia całodniowe w aplikacji kalendarza, której już używasz.",
"disclosure": "Twój dostawca kalendarza (Google, iCloud, Outlook) przechowuje te daty na swoich serwerach. Późniejsze wyłączenie nie usuwa kopii, które już ma.",
"turnOn": "Włącz subskrypcję",
"error": "Nie udało się zmienić subskrypcji kalendarza. Spróbuj ponownie.",
"linkHidden": "Aktywny jest link zaczynający się od {prefix}. Jest pokazywany tylko raz, przy utworzeniu; odnów go, aby dostać nowy.",
"linkLabel": "Link subskrypcji",
"linkOnce": "Widoczny tylko teraz. Wklej go do aplikacji kalendarza albo odnów go później, aby dostać nowy.",
"copy": "Kopiuj link",
"copied": "Link skopiowany.",
"copyFailed": "Kopiowanie nie zadziałało. Zaznacz link i skopiuj go ręcznie.",
"openInCalendar": "Otwórz w aplikacji kalendarza",
"notFetched": "Jeszcze nie pobrano.",
"lastFetched": "Ostatnio pobrane {when} przez: {fetcher}.",
"fetcher": {
"GOOGLE": "Kalendarz Google",
"APPLE": "Kalendarz Apple",
"MICROSOFT": "Outlook",
"OTHER": "aplikacja kalendarza"
},
"horizon": "Cykle do przodu",
"history": "Poprzednie cykle",
"historyNone": "Brak",
"titles": "Tytuły wydarzeń",
"titleMode": {
"DISCREET": "Dyskretne (●)",
"DESCRIPTIVE": "Opisowe"
},
"fertile": "Okno płodne",
"fertileDescription": "Dodaje szacowane okno płodne następnego cyklu. To szacunek, nie metoda antykoncepcji.",
"rotate": "Odnów link",
"rotateTitle": "Odnowić link?",
"rotateBody": "Kalendarze ze starym linkiem przestaną się aktualizować. Dostaniesz nowy link, który dodasz zamiast niego.",
"turnOff": "Wyłącz",
"turnOffTitle": "Wyłączyć subskrypcję kalendarza?",
"turnOffBody": "Link przestanie działać. Kopie, które ma już Twój dostawca kalendarza, zostaną, dopóki nie usuniesz tam kalendarza.",
"help": {
"title": "Jak dodać go do kalendarza",
"google": "Kalendarz Google: Inne kalendarze, Z adresu URL, potem wklej link. Google odświeża mniej więcej co 12 do 24 godzin.",
"apple": "Kalendarz Apple: na Macu Plik, Nowa subskrypcja kalendarza; na iPhonie Ustawienia, Kalendarz, Konta, Dodaj konto, Inne, Dodaj subskrybowany kalendarz.",
"outlook": "Outlook: Dodaj kalendarz, Subskrybuj z sieci Web, potem wklej link. Outlook odświeża co kilka godzin."
},
"ics": {
"calendarName": "Cykl (HealthLog)",
"periodEstimate": "Miesiączka (szacunek)",
"periodLogged": "Miesiączka (zapisana)",
"fertileEstimate": "Okno płodne (szacunek)",
"likelyStart": "Najbardziej prawdopodobny początek {date}, {band}",
"bandOne": "±{count} dzień",
"bandFew": "±{count} dni",
"bandOther": "±{count} dni"
}
},
"projection": {
"title": "Kolejne cykle",
"around": "Około {date}",
"state": {
"LEARNING": "Dopóki nie zapiszesz 3 cykli, uwzględniana jest tylko najbliższa miesiączka.",
"LATE": "Brak prognozy, dopóki ta miesiączka spóźnia się bardziej niż oczekiwano; wróci, gdy ją zapiszesz.",
"OFF": "Prognozy są wyłączone; uwzględniane są tylko poprzednie cykle, jeśli je wybrano."
}
}
},
"cycleReminders": {
"periodSoonTitle": "Miesiączka prawdopodobnie wkrótce",
@@ -10316,6 +10385,7 @@
"unitLockedHint": "Gdy ten marker ma już wyniki, jego jednostka pozostaje bez zmian. W przeciwnym razie zapisane wartości byłyby pokazywane w nowej jednostce bez przeliczenia.",
"panel": "Panel",
"panelPlaceholder": "np. Lipidy",
"panelHint": "Wybierz podpowiedź lub wpisz własny. Panele z katalogu są rozpoznawane w każdym języku.",
"lowerBound": "Dolna granica",
"upperBound": "Górna granica",
"lowerPlaceholder": "Dolna granica",
@@ -10499,6 +10569,23 @@
"loadMoreReadings": "Wczytaj więcej pomiarów",
"documentsLink": "Dokumenty wyników badań",
"notLinkedYet": "Jeszcze niepowiązane z biomarkerem",
"groups": {
"other": "Inne",
"allInRange": "Wszystkie w normie",
"outsideCountOne": "{count} z {total} poza normą",
"outsideCountFew": "{count} z {total} poza normą",
"outsideCountOther": "{count} z {total} poza normą",
"heading": "Kategorie",
"description": "Grupuje wartości według panelu, np. Lipidy lub Wątroba, ze statusem dla każdej grupy.",
"label": "Grupowanie",
"none": "Brak",
"category": "Według kategorii",
"openLabel": "Rozwinięte po otwarciu",
"openExpanded": "Wszystkie",
"openFlagged": "Tylko poza normą",
"openCollapsed": "Żadne",
"orderWithinGroups": "Kolejność obowiązuje w obrębie każdej kategorii."
},
"sourceRange": {
"fromReport": "Zakres z wyniku {range}",
"catalogDiffers": "twój zakres {range}",
@@ -10545,6 +10632,7 @@
"convertedOther": "{count} pomiarów, przeliczonych z {unit} na {targetUnit}",
"refused": "Nie można przeliczyć {unit} na {targetUnit}.",
"aliasesAdded": "Te nazwy będą prowadzić do zachowanego biomarkera:",
"adoptsPanel": "Zachowany biomarker przejmuje panel {panel}.",
"blocked": "Nic nie zostanie scalone, dopóki jednej jednostki nie da się przeliczyć. Pomiń ten biomarker albo zachowaj inny.",
"noUndo": "Scalenia nie można cofnąć.",
"doneToastOne": "Scalono w {name}, przeniesiono {count} pomiar",
@@ -12005,7 +12093,8 @@
"unsupportedType": "Ten typ pliku nie jest obsługiwany.",
"purged": "Ten dokument został trwale usunięty i nie można go przywrócić.",
"duplicateExists": "Identyczny dokument jest już zapisany.",
"rateLimited": "Zbyt wiele przesyłań. Spróbuj ponownie za kilka minut.",
"uploadBusy": "Serwer był zajęty innymi przesyłaniami. Spróbuj ponownie teraz.",
"rateLimited": "Osiągnięto limit przesyłań na tę godzinę. Spróbuj ponownie później.",
"generic": "Przesyłanie nie powiodło się. Spróbuj ponownie."
},
"toast": {
@@ -13156,7 +13245,11 @@
"biomarkerMerge": "{name} scalił biomarkery",
"medicationIngredientsUpdate": "{name} zmienił skład suplementu",
"auditCCreate": "{name} wypełnił kwestionariusz AUDIT-C",
"auditCDelete": "{name} usunął wpis AUDIT-C"
"auditCDelete": "{name} usunął wpis AUDIT-C",
"cycleFeedCreate": "{name} włączył subskrypcję kalendarza",
"cycleFeedUpdate": "{name} zmienił subskrypcję kalendarza",
"cycleFeedRotate": "{name} odnowił link subskrypcji kalendarza",
"cycleFeedRevoke": "{name} wyłączył subskrypcję kalendarza"
},
"section": {
"measurements": {
@@ -13333,7 +13426,9 @@
"specialty": "Od ostatniej wizyty ({specialty}) {date}",
"kind": "Od ostatniej wizyty tego samego rodzaju ({kind}) {date}",
"default": "Ostatnie {days} dni",
"defaultReason": "Brak wcześniejszej wizyty w tym samym gabinecie, tej samej specjalizacji lub tego samego rodzaju."
"defaultReason": "Brak wcześniejszej wizyty w tym samym gabinecie, tej samej specjalizacji lub tego samego rodzaju.",
"capped": "Ostatnie dwa lata",
"cappedReason": "Ostatnia pasująca wizyta była ponad dwa lata temu, a raport obejmuje najwyżej dwa lata."
}
},
"checkups": {
@@ -13849,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",
+141 -36
View File
@@ -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": {
@@ -819,6 +819,7 @@
"typeHeartRateVariability": "Variabilidade da frequência cardíaca",
"typeRestingHeartRate": "Frequência cardíaca em repouso",
"typeActiveEnergyBurned": "Energia ativa",
"typeBasalEnergyBurned": "Energia em repouso",
"typeFlightsClimbed": "Andares subidos",
"typeWalkingRunningDistance": "Distância caminhada + corrida",
"typeVo2Max": "VO2 máx",
@@ -2286,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",
@@ -2358,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",
@@ -3237,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": {
@@ -3337,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.",
@@ -3941,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).",
@@ -3995,7 +3996,7 @@
"bedtime": "Hora de dormir",
"unspecified": "Sem marcação",
"readingsShort": "{count} leituras",
"readingsShortOne": "1 leitura"
"readingsShortOne": "{count} leitura"
}
}
}
@@ -4934,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"
@@ -4948,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",
@@ -5277,6 +5278,7 @@
"oura": "Oura",
"whoop": "WHOOP",
"polar": "Polar",
"garmin": "Garmin",
"fitbit": "Fitbit",
"strava": "Strava",
"nightscout": "Nightscout",
@@ -5284,7 +5286,8 @@
},
"hints": {
"apple-health": "Sincronizado pelo aplicativo iOS ou importado de uma exportação.",
"file": "Importado nas Configurações quando terminar aqui."
"file": "Importado nas Configurações quando terminar aqui.",
"fitbit": "Agora é conectado pelo Google Health."
}
},
"visit": {
@@ -5338,6 +5341,7 @@
"waiting": "Ainda não conectado.",
"openSettings": "Abrir Configurações → Integrações",
"noDate": "desconhecido",
"throughGoogleHealth": "Os dados do {source} agora chegam pelo {target}: a conexão clássica com o {source} foi desativada. Conecte o {target} com a conta Google em que o seu {source} está conectado, e suas leituras chegam por lá.",
"needsCredentials": {
"title": "Esta instância ainda não tem um aplicativo {target} registrado.",
"detail": "Insira suas próprias credenciais em Configurações → Integrações, então a conexão pode começar."
@@ -5438,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": {
@@ -6903,7 +6907,9 @@
"summary": "{count} tipos de dados, mais recente {relative}",
"quiet": "{count} sem atividade",
"workouts": "Treinos",
"medicationDoses": "Doses de medicamentos"
"medicationDoses": "Doses de medicamentos",
"cycleDays": "Dias do ciclo",
"cycleDaysCount": "Dias do ciclo ({count})"
},
"integrationFallback": {
"description": "Configurado nesta conta sem um cartão de configurações próprio."
@@ -8009,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",
@@ -10020,7 +10026,70 @@
"startedPeriodOnDate": "Minha menstruação começou neste dia",
"endedPeriodOnDate": "Minha menstruação terminou neste dia",
"startAbsorbed": "Início da menstruação salvo. Seu início em {absorbed} foi mesclado em {date}.",
"startRestored": "Salvo. O início em {dates} está de volta."
"startRestored": "Salvo. O início em {dates} está de volta.",
"feed": {
"title": "Assinatura de calendário",
"description": "Suas próximas menstruações como eventos de dia inteiro no app de calendário que você já usa.",
"disclosure": "Seu provedor de calendário (Google, iCloud, Outlook) guarda essas datas nos servidores dele. Desativar depois não apaga as cópias que ele já tem.",
"turnOn": "Ativar assinatura",
"error": "Não foi possível alterar a assinatura de calendário. Tente de novo.",
"linkHidden": "Um link que começa com {prefix} está ativo. Ele só aparece uma vez, ao ser criado; renove-o para ter um novo.",
"linkLabel": "Link da assinatura",
"linkOnce": "Só aparece agora. Cole no seu app de calendário ou renove depois para ter um novo.",
"copy": "Copiar link",
"copied": "Link copiado.",
"copyFailed": "Não deu para copiar. Selecione o link e copie manualmente.",
"openInCalendar": "Abrir no app de calendário",
"notFetched": "Ainda não buscado.",
"lastFetched": "Última busca {when} por {fetcher}.",
"fetcher": {
"GOOGLE": "Google Agenda",
"APPLE": "Calendário da Apple",
"MICROSOFT": "Outlook",
"OTHER": "um app de calendário"
},
"horizon": "Ciclos à frente",
"history": "Ciclos anteriores",
"historyNone": "Nenhum",
"titles": "Títulos dos eventos",
"titleMode": {
"DISCREET": "Discreto (●)",
"DESCRIPTIVE": "Descritivo"
},
"fertile": "Janela fértil",
"fertileDescription": "Adiciona a janela fértil estimada do próximo ciclo. Uma estimativa, não um método contraceptivo.",
"rotate": "Renovar link",
"rotateTitle": "Renovar o link?",
"rotateBody": "Calendários com o link antigo deixam de ser atualizados. Você recebe um novo link para adicionar no lugar.",
"turnOff": "Desativar",
"turnOffTitle": "Desativar a assinatura de calendário?",
"turnOffBody": "O link para de funcionar. As cópias que seu provedor de calendário já tem ficam lá até você remover o calendário.",
"help": {
"title": "Como adicionar ao seu calendário",
"google": "Google Agenda: Outras agendas, Do URL, depois cole o link. O Google atualiza mais ou menos a cada 12 a 24 horas.",
"apple": "Calendário da Apple: no Mac, Arquivo, Nova Assinatura de Calendário; no iPhone, Ajustes, Calendário, Contas, Adicionar Conta, Outra, Adicionar Calendário Assinado.",
"outlook": "Outlook: Adicionar calendário, Assinar da Web, depois cole o link. O Outlook atualiza a cada poucas horas."
},
"ics": {
"calendarName": "Ciclo (HealthLog)",
"periodEstimate": "Menstruação (estimativa)",
"periodLogged": "Menstruação (registrada)",
"fertileEstimate": "Janela fértil (estimativa)",
"likelyStart": "Início mais provável {date}, {band}",
"bandOne": "±{count} dia",
"bandFew": "±{count} dias",
"bandOther": "±{count} dias"
}
},
"projection": {
"title": "Ciclos seguintes",
"around": "Por volta de {date}",
"state": {
"LEARNING": "Até haver 3 ciclos registrados, só a próxima menstruação é incluída.",
"LATE": "Sem previsão enquanto esta menstruação estiver mais atrasada que o esperado; ela volta assim que você a registrar.",
"OFF": "As previsões estão desativadas; só ciclos anteriores são incluídos, se escolhidos."
}
}
},
"cycleReminders": {
"periodSoonTitle": "Menstruação provavelmente em breve",
@@ -10316,6 +10385,7 @@
"unitLockedHint": "Depois que este marcador tiver leituras, sua unidade permanece como está. Caso contrário, os valores armazenados seriam exibidos em uma nova unidade sem serem convertidos.",
"panel": "Painel",
"panelPlaceholder": "ex.: Lipídios",
"panelHint": "Escolha uma sugestão ou digite o seu. Os painéis do catálogo são reconhecidos em todos os idiomas.",
"lowerBound": "Referência baixa",
"upperBound": "Referência alta",
"lowerPlaceholder": "Limite inferior",
@@ -10499,6 +10569,23 @@
"loadMoreReadings": "Carregar mais leituras",
"documentsLink": "Documentos de laudos laboratoriais",
"notLinkedYet": "Ainda não vinculado a um biomarcador",
"groups": {
"other": "Outros",
"allInRange": "Tudo na faixa",
"outsideCountOne": "{count} de {total} fora da faixa",
"outsideCountFew": "{count} de {total} fora da faixa",
"outsideCountOther": "{count} de {total} fora da faixa",
"heading": "Categorias",
"description": "Agrupa os valores por painel, como Lipídios ou Fígado, com um status por grupo.",
"label": "Agrupamento",
"none": "Nenhum",
"category": "Por categoria",
"openLabel": "Abertas ao carregar",
"openExpanded": "Todas",
"openFlagged": "Só fora da faixa",
"openCollapsed": "Nenhuma",
"orderWithinGroups": "A ordem vale dentro de cada categoria."
},
"sourceRange": {
"fromReport": "Faixa do laudo {range}",
"catalogDiffers": "sua faixa {range}",
@@ -10545,6 +10632,7 @@
"convertedOther": "{count} leituras, convertidas de {unit} para {targetUnit}",
"refused": "{unit} não pode ser convertida para {targetUnit}.",
"aliasesAdded": "Estes nomes levarão ao biomarcador mantido:",
"adoptsPanel": "O biomarcador mantido assume o painel {panel}.",
"blocked": "Nada pode ser mesclado enquanto uma unidade não puder ser convertida. Deixe esse biomarcador fora ou mantenha outro.",
"noUndo": "Uma mesclagem não pode ser desfeita.",
"doneToastOne": "Mesclado em {name}, {count} leitura movida",
@@ -10947,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.",
@@ -10960,7 +11048,7 @@
"resolved": "Resolvidos"
},
"flares": {
"countOne": "1 surto",
"countOne": "{count} surto",
"countOther": "{count} surtos"
},
"timeline": {
@@ -11092,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": {
@@ -11202,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": {
@@ -12005,7 +12093,8 @@
"unsupportedType": "Este tipo de arquivo não é suportado.",
"purged": "Este documento foi removido permanentemente e não pode ser restaurado.",
"duplicateExists": "Um documento idêntico já está armazenado.",
"rateLimited": "Envios demais agora. Tente novamente em alguns minutos.",
"uploadBusy": "O servidor estava ocupado com outros envios. Tente novamente agora.",
"rateLimited": "Você atingiu o limite de envios desta hora. Tente novamente mais tarde.",
"generic": "Falha no envio. Tente novamente."
},
"toast": {
@@ -12159,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",
@@ -12191,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.",
@@ -13156,7 +13245,11 @@
"biomarkerMerge": "{name} mesclou biomarcadores",
"medicationIngredientsUpdate": "{name} alterou o que um suplemento contém",
"auditCCreate": "{name} respondeu às perguntas AUDIT-C",
"auditCDelete": "{name} removeu uma entrada AUDIT-C"
"auditCDelete": "{name} removeu uma entrada AUDIT-C",
"cycleFeedCreate": "{name} ativou a assinatura de calendário",
"cycleFeedUpdate": "{name} alterou a assinatura de calendário",
"cycleFeedRotate": "{name} renovou o link da assinatura de calendário",
"cycleFeedRevoke": "{name} desativou a assinatura de calendário"
},
"section": {
"measurements": {
@@ -13333,7 +13426,9 @@
"specialty": "Desde a sua última consulta de {specialty}, em {date}",
"kind": "Desde a sua última consulta do mesmo tipo ({kind}), em {date}",
"default": "Os últimos {days} dias",
"defaultReason": "Nenhuma consulta anterior com o mesmo consultório, especialidade ou tipo."
"defaultReason": "Nenhuma consulta anterior com o mesmo consultório, especialidade ou tipo.",
"capped": "Os últimos dois anos",
"cappedReason": "A última consulta correspondente foi há mais de dois anos, e um relatório cobre no máximo dois anos."
}
},
"checkups": {
@@ -13510,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",
@@ -13577,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",
@@ -13773,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"
},
@@ -13806,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": {
@@ -13830,7 +13925,7 @@
"apply": "Aplicar"
},
"item": {
"labValuesOne": "1 valor laboratorial",
"labValuesOne": "{count} valor laboratorial",
"labValuesFew": "{count} valores laboratoriais",
"labValuesOther": "{count} valores laboratoriais"
},
@@ -13849,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",
+144 -39
View File
@@ -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": {
@@ -819,6 +819,7 @@
"typeHeartRateVariability": "Вариабельность сердечного ритма",
"typeRestingHeartRate": "ЧСС в покое",
"typeActiveEnergyBurned": "Активная энергия",
"typeBasalEnergyBurned": "Энергия в покое",
"typeFlightsClimbed": "Этажи",
"typeWalkingRunningDistance": "Расстояние ходьбы + бега",
"typeVo2Max": "VO₂ max",
@@ -2286,7 +2287,7 @@
"currentSince": "Текущий план с {date}",
"noTimes": "Без фиксированного времени",
"manualChip": "Добавлено вручную",
"showErasOne": "Показать 1 прошлый период",
"showErasOne": "Показать {count} прошлый период",
"showErasOther": "Показать прошлые периоды ({count})",
"hideEras": "Скрыть прошлые периоды",
"addEra": "Добавить прошлый период",
@@ -2358,7 +2359,7 @@
"tableColActions": "Действия",
"tableSortBy": "Сортировать по {column}",
"tableComplianceDays": "{days} дн.",
"tableStockDoseOne": "1 доза",
"tableStockDoseOne": "{count} доза",
"tableStockDoses": "{count} доз",
"tableStockRunway": "≈ {days} дн. осталось",
"cardLowStockRunway": "Запас: ≈ {days} дн.",
@@ -3237,7 +3238,7 @@
"settingsClustersLabel": "Данные, которые видит коуч",
"settingsClustersHint": "Выберите, какие кластеры ваших данных может читать коуч. Больше данных — богаче ответы и больше запрос.",
"settingsClusterNoData": "Данных пока нет",
"sourcesMemberCountOne": "1 показатель",
"sourcesMemberCountOne": "{count} показатель",
"sourcesMemberCountFew": "{count} показателя",
"sourcesMemberCountOther": "{count} показателей",
"lookback": {
@@ -3337,7 +3338,7 @@
"indexing": "Индексация…",
"indexingHint": "Документ ещё индексируется. Отправить можно, когда он будет готов.",
"fencedHint": "Этот разговор идёт без доступа к вашим данным о здоровье. Коуч читает только прикреплённые документы.",
"documentsAttachedOne": "Прикреплён 1 документ",
"documentsAttachedOne": "Прикреплён {count} документ",
"documentsAttachedOther": "Прикреплено документов: {count}",
"railBadge": "Изолированный чат по документам",
"uploadError": "Загрузка не удалась. Попробуйте ещё раз.",
@@ -3941,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} — цель).",
@@ -3995,7 +3996,7 @@
"bedtime": "Перед сном",
"unspecified": "Без метки",
"readingsShort": "Измерений: {count}",
"readingsShortOne": "1 измерение"
"readingsShortOne": "{count} измерение"
}
}
}
@@ -4934,7 +4935,7 @@
},
"crossLink": {
"title": "Записи ЭКГ",
"recordingsOne": "1 запись в архиве.",
"recordingsOne": "{count} запись в архиве.",
"recordingsMany": "Записей в архиве: {count}.",
"latestResult": "Последний результат устройства: {result}.",
"cta": "Посмотреть записи"
@@ -4948,7 +4949,7 @@
"caption": "Усреднено шагами по 10 минут.",
"hourlyNote": "Показано среднее по часам за этот день; для недавних дней доступна полная детализация по 10 минут.",
"empty": "Пока недостаточно измерений ЧСС за этот день, чтобы показать его форму.",
"coverageOne": "На основе 1 измерения за {hours} ч.",
"coverageOne": "На основе {count} измерения за {hours} ч.",
"coverageMany": "На основе {count} измерений за {hours} ч.",
"baseline": "В покое",
"today": "Сегодня",
@@ -5277,6 +5278,7 @@
"oura": "Oura",
"whoop": "WHOOP",
"polar": "Polar",
"garmin": "Garmin",
"fitbit": "Fitbit",
"strava": "Strava",
"nightscout": "Nightscout",
@@ -5284,7 +5286,8 @@
},
"hints": {
"apple-health": "Синхронизируется приложением iOS или импортируется из экспорта.",
"file": "Импортируется в настройках, когда вы здесь закончите."
"file": "Импортируется в настройках, когда вы здесь закончите.",
"fitbit": "Теперь подключается через Google Health."
}
},
"visit": {
@@ -5332,12 +5335,13 @@
"connect-source": {
"title": "Подключить {target}",
"body": "Одно подключение, выбранное по вашим ответам. Затем измерения будут поступать сами.",
"howTo": "Подключение к {target} выполняется на странице интеграций. Вернитесь сюда afterwards; этот экран заметит, когда всё будет готово.",
"howTo": "Подключение к {target} выполняется на странице интеграций. Потом вернитесь сюда: этот экран заметит, когда всё будет готово.",
"cta": "Подключить {target}",
"checking": "Проверка подключения.",
"waiting": "Пока не подключено.",
"openSettings": "Открыть Настройки → Интеграции",
"noDate": "неизвестно",
"throughGoogleHealth": "Данные {source} теперь поступают через {target}: классическое подключение {source} отключено. Подключите {target} с тем аккаунтом Google, в который выполнен вход на вашем {source}, и показания будут приходить оттуда.",
"needsCredentials": {
"title": "В этом экземпляре пока нет приложения {target}.",
"detail": "Введите свои учётные данные в разделе Настройки → Интеграции, и подключение сможет начаться."
@@ -5438,7 +5442,7 @@
},
"trendHints": {
"title": "Первая динамика после 5 измерений",
"remainingOne": "Осталось 1 измерение.",
"remainingOne": "Осталось {count} измерение.",
"remainingMany": "Осталось измерений: {count}."
},
"settings": {
@@ -6903,7 +6907,9 @@
"summary": "Типов данных: {count}, новейшие {relative}",
"quiet": "Без новых данных: {count}",
"workouts": "Тренировки",
"medicationDoses": "Дозы лекарств"
"medicationDoses": "Дозы лекарств",
"cycleDays": "Дни цикла",
"cycleDaysCount": "Дни цикла ({count})"
},
"integrationFallback": {
"description": "Настроено в этом аккаунте без собственной карточки настроек."
@@ -8009,7 +8015,7 @@
"colRedeemedBy": "Активировано",
"colActions": "Действия",
"byCreator": "от {by}",
"expiresInDaysOne": "через 1 день",
"expiresInDaysOne": "через {count} день",
"expiresInDaysOther": "через {count} дней",
"statusActive": "Активно",
"statusExpired": "Истекло",
@@ -10020,7 +10026,70 @@
"startedPeriodOnDate": "У меня началась менструация в этот день",
"endedPeriodOnDate": "У меня закончилась менструация в этот день",
"startAbsorbed": "Начало менструации сохранено. Ваше начало {absorbed} объединено с {date}.",
"startRestored": "Сохранено. Начало {dates} восстановлено."
"startRestored": "Сохранено. Начало {dates} восстановлено.",
"feed": {
"title": "Подписка на календарь",
"description": "Ваши следующие месячные в виде событий на весь день в календаре, которым вы уже пользуетесь.",
"disclosure": "Ваш поставщик календаря (Google, iCloud, Outlook) хранит эти даты на своих серверах. Если позже отключить подписку, уже полученные им копии не удалятся.",
"turnOn": "Включить подписку",
"error": "Не удалось изменить подписку на календарь. Попробуйте ещё раз.",
"linkHidden": "Активна ссылка, начинающаяся с {prefix}. Она показывается только один раз, при создании; обновите её, чтобы получить новую.",
"linkLabel": "Ссылка подписки",
"linkOnce": "Видна только сейчас. Вставьте её в приложение календаря или обновите позже, чтобы получить новую.",
"copy": "Копировать ссылку",
"copied": "Ссылка скопирована.",
"copyFailed": "Скопировать не удалось. Выделите ссылку и скопируйте её вручную.",
"openInCalendar": "Открыть в календаре",
"notFetched": "Ещё не загружалась.",
"lastFetched": "Последняя загрузка {when}, клиент: {fetcher}.",
"fetcher": {
"GOOGLE": "Google Календарь",
"APPLE": "Календарь Apple",
"MICROSOFT": "Outlook",
"OTHER": "приложение календаря"
},
"horizon": "Циклов вперёд",
"history": "Прошлые циклы",
"historyNone": "Нет",
"titles": "Названия событий",
"titleMode": {
"DISCREET": "Незаметно (●)",
"DESCRIPTIVE": "Подробно"
},
"fertile": "Фертильное окно",
"fertileDescription": "Добавляет предполагаемое фертильное окно следующего цикла. Это оценка, а не метод контрацепции.",
"rotate": "Обновить ссылку",
"rotateTitle": "Обновить ссылку?",
"rotateBody": "Календари со старой ссылкой перестанут обновляться. Вы получите новую ссылку, которую нужно добавить вместо неё.",
"turnOff": "Отключить",
"turnOffTitle": "Отключить подписку на календарь?",
"turnOffBody": "Ссылка перестанет работать. Копии, которые уже есть у поставщика календаря, останутся, пока вы не удалите там календарь.",
"help": {
"title": "Как добавить в календарь",
"google": "Google Календарь: Другие календари, Добавить по URL, затем вставьте ссылку. Google обновляет примерно раз в 12-24 часа.",
"apple": "Календарь Apple: на Mac Файл, Новая подписка на календарь; на iPhone Настройки, Календарь, Учетные записи, Добавить учетную запись, Другое, Добавить подписной календарь.",
"outlook": "Outlook: Добавить календарь, Подписаться из Интернета, затем вставьте ссылку. Outlook обновляет каждые несколько часов."
},
"ics": {
"calendarName": "Цикл (HealthLog)",
"periodEstimate": "Месячные (оценка)",
"periodLogged": "Месячные (записаны)",
"fertileEstimate": "Фертильное окно (оценка)",
"likelyStart": "Наиболее вероятное начало {date}, {band}",
"bandOne": "±{count} день",
"bandFew": "±{count} дня",
"bandOther": "±{count} дней"
}
},
"projection": {
"title": "Следующие циклы",
"around": "Около {date}",
"state": {
"LEARNING": "Пока не записаны 3 цикла, включаются только ближайшие месячные.",
"LATE": "Прогноза нет, пока эти месячные запаздывают сильнее ожидаемого; он вернётся, как только вы их запишете.",
"OFF": "Прогнозы отключены; включаются только прошлые циклы, если они выбраны."
}
}
},
"cycleReminders": {
"periodSoonTitle": "Скоро вероятная менструация",
@@ -10316,6 +10385,7 @@
"unitLockedHint": "Когда у показателя есть измерения, его единица остаётся прежней. Иначе сохранённые значения показывались бы в новой единице без пересчёта.",
"panel": "Панель",
"panelPlaceholder": "например Липиды",
"panelHint": "Выберите подсказку или введите свою. Панели из каталога распознаются на любом языке.",
"lowerBound": "Нижняя граница",
"upperBound": "Верхняя граница",
"lowerPlaceholder": "Нижняя граница",
@@ -10499,6 +10569,23 @@
"loadMoreReadings": "Загрузить больше измерений",
"documentsLink": "Документы анализов",
"notLinkedYet": "Пока не связано с показателем",
"groups": {
"other": "Другое",
"allInRange": "Все в диапазоне",
"outsideCountOne": "{count} из {total} вне диапазона",
"outsideCountFew": "{count} из {total} вне диапазона",
"outsideCountOther": "{count} из {total} вне диапазона",
"heading": "Категории",
"description": "Группирует значения по панелям, например Липиды или Печень, со статусом для каждой группы.",
"label": "Группировка",
"none": "Нет",
"category": "По категориям",
"openLabel": "Раскрыто при открытии",
"openExpanded": "Все",
"openFlagged": "Только вне диапазона",
"openCollapsed": "Ни одной",
"orderWithinGroups": "Порядок действует внутри каждой категории."
},
"sourceRange": {
"fromReport": "Диапазон из отчёта {range}",
"catalogDiffers": "ваш диапазон {range}",
@@ -10545,6 +10632,7 @@
"convertedOther": "{count} измерений, пересчитаны из {unit} в {targetUnit}",
"refused": "{unit} нельзя пересчитать в {targetUnit}.",
"aliasesAdded": "Эти имена будут вести к оставленному показателю:",
"adoptsPanel": "Оставленный показатель получает панель {panel}.",
"blocked": "Объединить нельзя, пока единицу не удаётся пересчитать. Исключите этот показатель или оставьте другой.",
"noUndo": "Объединение нельзя отменить.",
"doneToastOne": "Объединено в {name}, перенесено измерений: {count}",
@@ -10947,7 +11035,7 @@
},
"insights": {
"title": "Ваша история заболеваний",
"countOne": "За этот период вы записали 1 эпизод.",
"countOne": "За этот период вы записали {count} эпизод.",
"countOther": "За этот период вы записали эпизодов: {count}.",
"typicalGap": "Типичный разрыв восстановления: {days} дней между улучшением самочувствия и нормализацией показателей.",
"typicalGapWithDriver": "Типичный разрыв восстановления: {days} дней; вы обычно чувствуете себя лучше раньше, чем нормализуется {vital}.",
@@ -10960,7 +11048,7 @@
"resolved": "Завершённые"
},
"flares": {
"countOne": "1 обострение",
"countOne": "{count} обострение",
"countOther": "{count} обострений"
},
"timeline": {
@@ -11092,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": {
@@ -11202,7 +11290,7 @@
"entry": "{domain}, {window}: {detail}",
"entryNoWindow": "{domain}: {detail}",
"reused": "из более раннего ответа",
"readingsOne": "1 измерение",
"readingsOne": "{count} измерение",
"readingsFew": "Измерений: {count}",
"readingsOther": "Измерений: {count}",
"aggregation": {
@@ -12005,7 +12093,8 @@
"unsupportedType": "Этот тип файла не поддерживается.",
"purged": "Этот документ удалён навсегда и не может быть восстановлен.",
"duplicateExists": "Идентичный документ уже сохранён.",
"rateLimited": "Слишком много загрузок сейчас. Попробуйте через несколько минут.",
"uploadBusy": "Сервер был занят другими загрузками. Попробуйте ещё раз сейчас.",
"rateLimited": "Достигнут лимит загрузок на этот час. Попробуйте позже.",
"generic": "Загрузка не удалась. Попробуйте ещё раз."
},
"toast": {
@@ -12135,7 +12224,7 @@
"errorGeneric": "Не удалось прочитать документ. Попробуйте ещё раз.",
"errorOcr": "Не удалось прочитать текст с этого изображения.",
"errorRateLimited": "Достигнут часовой лимит чтения с ИИ. Попробуйте позже.",
"errorRateLimitedWaitOne": "Достигнут часовой лимит чтения с ИИ. Попробуйте примерно через минуту.",
"errorRateLimitedWaitOne": "Достигнут часовой лимит чтения с ИИ. Попробуйте примерно через {count} минуту.",
"errorRateLimitedWaitFew": "Достигнут часовой лимит чтения с ИИ. Попробуйте примерно через {count} минуты.",
"errorRateLimitedWaitOther": "Достигнут часовой лимит чтения с ИИ. Попробуйте примерно через {count} минут.",
"errorBudget": "Ваш бюджет ИИ на сегодня исчерпан.",
@@ -12159,7 +12248,7 @@
},
"ai": {
"readDone": "ИИ прочитал ваш документ.",
"readStagedOne": "ИИ прочитал ваш документ. 1 результат анализа готов к проверке.",
"readStagedOne": "ИИ прочитал ваш документ. {count} результат анализа готов к проверке.",
"readStagedFew": "ИИ прочитал ваш документ. Результатов анализов готово к проверке: {count}.",
"readStagedOther": "ИИ прочитал ваш документ. Результатов анализов готово к проверке: {count}.",
"read": "Прочитать с ИИ",
@@ -12191,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": "Проверка сохранена. Значения не добавлены.",
@@ -13156,7 +13245,11 @@
"biomarkerMerge": "{name} объединил(а) показатели",
"medicationIngredientsUpdate": "{name} изменил(а) состав добавки",
"auditCCreate": "{name} ответил(а) на вопросы AUDIT-C",
"auditCDelete": "{name} убрал(а) запись AUDIT-C"
"auditCDelete": "{name} убрал(а) запись AUDIT-C",
"cycleFeedCreate": "{name} включил(а) подписку на календарь",
"cycleFeedUpdate": "{name} изменил(а) подписку на календарь",
"cycleFeedRotate": "{name} обновил(а) ссылку подписки на календарь",
"cycleFeedRevoke": "{name} отключил(а) подписку на календарь"
},
"section": {
"measurements": {
@@ -13333,7 +13426,9 @@
"specialty": "С вашего последнего визита к специалисту «{specialty}» {date}",
"kind": "С вашего последнего визита того же вида ({kind}) {date}",
"default": "Последние {days} дней",
"defaultReason": "Нет более раннего визита с той же клиникой, специальностью или видом."
"defaultReason": "Нет более раннего визита с той же клиникой, специальностью или видом.",
"capped": "Последние два года",
"cappedReason": "Последний подходящий визит был более двух лет назад, а отчёт охватывает не больше двух лет."
}
},
"checkups": {
@@ -13497,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}",
@@ -13510,7 +13605,7 @@
"deleteBody": "«{symptom}» и каждая его запись будут удалены. Чтобы сохранить историю, скройте его вместо этого.",
"deleteEventTitle": "Удалить запись?",
"deleteEventBody": "Эта запись будет удалена.",
"hiddenOne": "Скрыто (1)",
"hiddenOne": "Скрыто ({count})",
"hiddenFew": "Скрыто ({count})",
"hiddenOther": "Скрыто ({count})",
"recent": "Последние 30 дней",
@@ -13577,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": "Показать все",
@@ -13773,7 +13868,7 @@
},
"noValue": "нет значения",
"bloodPressure": "АД",
"readingsOne": "1 измерение",
"readingsOne": "{count} измерение",
"readingsFew": "среднее по {count} измерениям",
"readingsOther": "среднее по {count} измерениям"
},
@@ -13806,7 +13901,7 @@
"empty": "Нет записей с {from} по {to}",
"emptySingle": "Нет записей в {period}",
"ended": "{label} завершено",
"afterDaysOne": "через 1 день",
"afterDaysOne": "через {count} день",
"afterDaysFew": "через {count} дня",
"afterDaysOther": "через {count} дней",
"notable": {
@@ -13830,7 +13925,7 @@
"apply": "Применить"
},
"item": {
"labValuesOne": "1 результат анализа",
"labValuesOne": "{count} результат анализа",
"labValuesFew": "{count} результата анализов",
"labValuesOther": "{count} результатов анализов"
},
@@ -13849,7 +13944,17 @@
},
"legendLabValue": "Результат анализа, как измерено",
"legendLabOutside": "Вне референсного диапазона",
"legendLabRange": "Референсный диапазон"
"legendLabRange": "Референсный диапазон",
"legendSymptom": "Линия симптома: наибольшая сила за каждый отмеченный день (от 0 до 10), усреднённая по отмеченным дням",
"symptoms": {
"menuTitle": "Симптомы",
"full": "До {max} симптомов одновременно. Уберите один, чтобы добавить другой.",
"hidden": "скрыт",
"unnamed": "Симптом",
"daysOne": "за {count} день",
"daysFew": "среднее за {count} дня",
"daysOther": "среднее за {count} дней"
}
},
"lifeEvents": {
"title": "Событие жизни",
+1 -1
View File
@@ -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",
+11 -10
View File
@@ -12,12 +12,13 @@ overrides:
dompurify@<3.4.17: ^3.4.17
postcss@<8.5.23: ^8.5.23
nanoid@<3.3.18: ^3.3.18
'@hono/node-server@<1.19.15': ^1.19.15
'@hono/node-server@<2.1.3': ^2.1.3
picomatch@<4.0.4: ^4.0.4
body-parser@<2.4.0: ^2.4.0
source-map-js@<1.2.2: ^1.2.2
postcss-selector-parser@<7.1.6: ^7.1.6
smol-toml@<1.9.0: ^1.9.0
systeminformation@<5.33.16: ^5.33.16
qs@<6.16.0: ^6.16.0
fast-uri@<3.1.9: ^3.1.9
flatted@<3.4.2: ^3.4.2
@@ -870,9 +871,9 @@ packages:
'@hexagon/base64@1.1.28':
resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==}
'@hono/node-server@1.19.17':
resolution: {integrity: sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==}
engines: {node: '>=18.14.1'}
'@hono/node-server@2.1.6':
resolution: {integrity: sha512-p/KH9MnfNcsm3sR6x4lNgm5j+QN4Et8srKredl9DZfXiyZ1UQ3aQ2YVpFLbqktYN5JI+DB+VZFKM2PqE5hLKjQ==}
engines: {node: '>=20'}
peerDependencies:
hono: ^4.13.11
@@ -6679,8 +6680,8 @@ packages:
resolution: {integrity: sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==}
engines: {node: '>=12.0.0'}
systeminformation@5.33.15:
resolution: {integrity: sha512-s5RloL0F7xhN0ZjfHtLGnbf7EW74EI0N8NFL+1xR7Oxz9QA33obNZsl7lyfnJOhN04E/kGFPoJPlvY7TZ5fzmw==}
systeminformation@5.33.19:
resolution: {integrity: sha512-Zm7wGeCG7PqfvzegMJIzrxICopWqYCeLATVmvxOOci1isLcNVKgY4Fr0hhYQzvc7cgtK3377jQVChTFAnznOiA==}
engines: {node: '>=10.0.0'}
os: [darwin, linux, win32, freebsd, openbsd, netbsd, sunos, android]
hasBin: true
@@ -7925,7 +7926,7 @@ snapshots:
object-treeify: 1.1.33
open: 8.4.2
picomatch: 4.0.7
systeminformation: 5.33.15
systeminformation: 5.33.19
undici: 7.30.1
which: 4.0.0
yocto-spinner: 1.2.2
@@ -8079,7 +8080,7 @@ snapshots:
'@hexagon/base64@1.1.28': {}
'@hono/node-server@1.19.17(hono@4.13.13)':
'@hono/node-server@2.1.6(hono@4.13.13)':
dependencies:
hono: 4.13.13
@@ -8374,7 +8375,7 @@ snapshots:
'@modelcontextprotocol/sdk@1.32.2(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@4.6.7)':
dependencies:
'@hono/node-server': 1.19.17(hono@4.13.13)
'@hono/node-server': 2.1.6(hono@4.13.13)
ajv: 8.20.0
ajv-formats: 3.0.1(ajv@8.20.0)
content-type: 1.0.5
@@ -14105,7 +14106,7 @@ snapshots:
svg-pathdata@6.0.3:
optional: true
systeminformation@5.33.15: {}
systeminformation@5.33.19: {}
tagged-tag@1.0.0: {}
+10 -7
View File
@@ -22,13 +22,13 @@ overrides:
# bump; the fix landed in 8.5.23.
"postcss@<8.5.23": "^8.5.23"
"nanoid@<3.3.18": "^3.3.18"
# GHSA-frvp-7c67-39w9 raised the floor from 1.19.13 to 1.19.15. Mirrored for
# /opt/prisma-cli in the Dockerfile, which is where the flagged 1.19.11
# actually landed: prisma pulls it through @prisma/dev, and that install
# reads npm `overrides`, not this file. The bound stays on the 1.x line
# because the advisory is fixed there too and 2.x is a major nothing asked
# for.
"@hono/node-server@<1.19.15": "^1.19.15"
# GHSA-frvp-7c67-39w9 raised the floor from 1.19.13 to 1.19.15. The
# serveStatic double decode fixed in 2.1.3 has no fix on the 1.x line, so
# the bound moves to 2.x, which the MCP SDK accepts (`^1.19.9 || ^2.0.5`).
# The app never calls serveStatic; the copy sits on the MCP path all the
# same. Mirrored for /opt/prisma-cli in the Dockerfile, where an older
# @prisma/dev once pulled it in.
"@hono/node-server@<2.1.3": "^2.1.3"
"picomatch@<4.0.4": "^4.0.4"
# CVE-2026-111181 (HIGH) raised the floor from 2.3.0. The copy arrives
# under express in the MCP SDK, on a production path.
@@ -40,6 +40,9 @@ overrides:
"source-map-js@<1.2.2": "^1.2.2"
"postcss-selector-parser@<7.1.6": "^7.1.6"
"smol-toml@<1.9.0": "^1.9.0"
# Command injection through services() on Windows, under shadcn's CLI at
# dev time only. 5.33.16 has the fix.
"systeminformation@<5.33.16": "^5.33.16"
# Array-limit bypass through bracket-key comma parsing, and a denial of
# service through an attacker-controlled isBuffer. 6.16.0 covers both.
"qs@<6.16.0": "^6.16.0"
@@ -0,0 +1,10 @@
-- Extend `measurement_type` with resting energy, which sat in the deferred
-- list until now:
--
-- BASAL_ENERGY_BURNED <- HKQuantityTypeIdentifierBasalEnergyBurned (kcal)
--
-- Purely additive: no existing row carries the value. IF NOT EXISTS keeps a
-- replay on a partially applied database harmless. Enum values cannot be
-- dropped in PostgreSQL, so there is no down path.
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'BASAL_ENERGY_BURNED';
@@ -0,0 +1,85 @@
-- Subscribable cycle forecast (#1350): one table holding the calendar-feed
-- credential of a record, and the enum for how its events are titled.
--
-- The row is a URL credential. Only the HMAC of the raw `hlf_` token is
-- stored; the forecast itself is computed on every fetch and never persisted.
-- Revocation stamps `revoked_at` and never deletes, so the uniqueness that
-- matters (at most one LIVE feed per record) is a partial index, the shape
-- `account_grants_live_pair_key` uses. Prisma cannot express it, so it lives
-- here only.
--
-- Both foreign keys cascade: deleting the record ends its feed, and deleting
-- the carrier who minted it does too, so a guardian's account going away
-- cannot leave a link behind that nobody can revoke.
--
-- One DO block, so the file is one statement that rolls back whole and the
-- foreign keys' lock wait on `users` is bounded. Only the new table and the
-- new type are created; nothing existing changes shape. Reversibility:
-- dropping the table and the type ends every feed URL; subscribers see 404 and
-- the owner mints again.
--
-- @retry-on-lock-timeout
DO $migration$
BEGIN
PERFORM set_config('lock_timeout', '5s', true);
IF NOT EXISTS (
SELECT 1 FROM pg_type WHERE typname = 'cycle_calendar_feed_title_mode'
) THEN
CREATE TYPE "cycle_calendar_feed_title_mode" AS ENUM ('DISCREET', 'DESCRIPTIVE');
END IF;
CREATE TABLE IF NOT EXISTS "cycle_calendar_feeds" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"created_by_id" TEXT NOT NULL,
"token_hash" TEXT NOT NULL,
"token_prefix" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"revoked_at" TIMESTAMP(3),
"last_fetched_at" TIMESTAMP(3),
"fetch_count" INTEGER NOT NULL DEFAULT 0,
"last_fetcher_ua" TEXT,
"body_hash" TEXT,
"content_changed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"horizon_cycles" INTEGER NOT NULL DEFAULT 3,
"history_cycles" INTEGER NOT NULL DEFAULT 0,
"include_fertile" BOOLEAN NOT NULL DEFAULT false,
"title_mode" "cycle_calendar_feed_title_mode" NOT NULL DEFAULT 'DISCREET',
CONSTRAINT "cycle_calendar_feeds_pkey" PRIMARY KEY ("id"),
-- The ranges the options route validates, held again by the table so a
-- write that bypassed the route cannot widen the horizon.
CONSTRAINT "cycle_calendar_feeds_horizon_check"
CHECK ("horizon_cycles" BETWEEN 1 AND 6),
CONSTRAINT "cycle_calendar_feeds_history_check"
CHECK ("history_cycles" BETWEEN 0 AND 12)
);
CREATE UNIQUE INDEX IF NOT EXISTS "cycle_calendar_feeds_token_hash_key"
ON "cycle_calendar_feeds" ("token_hash");
CREATE INDEX IF NOT EXISTS "cycle_calendar_feeds_user_id_created_at_idx"
ON "cycle_calendar_feeds" ("user_id", "created_at" DESC);
CREATE INDEX IF NOT EXISTS "cycle_calendar_feeds_created_by_id_idx"
ON "cycle_calendar_feeds" ("created_by_id");
-- At most one live feed per record. Revoked rows fall out of the index, so a
-- rotate (revoke, then mint, in one transaction) never collides with itself.
CREATE UNIQUE INDEX IF NOT EXISTS "cycle_calendar_feeds_live_user_key"
ON "cycle_calendar_feeds" ("user_id")
WHERE "revoked_at" IS NULL;
ALTER TABLE "cycle_calendar_feeds"
ADD CONSTRAINT "cycle_calendar_feeds_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "cycle_calendar_feeds"
ADD CONSTRAINT "cycle_calendar_feeds_created_by_id_fkey"
FOREIGN KEY ("created_by_id") REFERENCES "users"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
END
$migration$;
+79
View File
@@ -1166,6 +1166,12 @@ model User {
// v1.15.0 — owner of a per-user custom CycleSymptom (NULL = the global
// seeded catalogue).
cycleSymptomsCustom CycleSymptom[] @relation("UserCycleSymptoms")
// #1350 — the subscribable cycle forecast. `cycleCalendarFeeds` is the
// record the feed serves; `cycleCalendarFeedsMinted` is the carrier who
// minted it, which is the owner on their own record and the guardian on a
// managed profile.
cycleCalendarFeeds CycleCalendarFeed[] @relation("CycleCalendarFeedSubject")
cycleCalendarFeedsMinted CycleCalendarFeed[] @relation("CycleCalendarFeedCreator")
// v1.39 — the needs-based onboarding's answers and step ledger for THIS
// record. 1:1, and keyed by record rather than by the person at the
@@ -1807,6 +1813,9 @@ enum MeasurementType {
STRESS_LEVEL // score (0–100) — Garmin's all-day average stress level. Device-native and kept apart from the COMPUTED STRESS_SCORE, whose readers are single-source.
TRAINING_READINESS // score (0–100) — Garmin Training Readiness. Not in the Personal API; written by a bridge through the ingest token (source EXTERNAL).
ACUTE_TRAINING_LOAD // load (unit-less) — Garmin acute training load. Not in the Personal API; written by a bridge through the ingest token (source EXTERNAL).
// v1.47.1 — resting energy (#1340). Its own type beside ACTIVE_ENERGY_BURNED,
// never folded into it and never summed with it into a total.
BASAL_ENERGY_BURNED // kcal — HealthKit `basalEnergyBurned`, the energy the body uses at rest; cumulative, daily SUM.
@@map("measurement_type")
}
@@ -8325,6 +8334,76 @@ model CycleProfile {
@@map("cycle_profiles")
}
/// #1350 — how the feed titles its events. DISCREET shows a dot and names the
/// calendar "HealthLog"; DESCRIPTIVE names the period and the calendar.
enum CycleCalendarFeedTitleMode {
DISCREET
DESCRIPTIVE
@@map("cycle_calendar_feed_title_mode")
}
/// #1350 — a subscribable calendar feed of one record's cycle forecast.
///
/// The row is a URL credential. Only the HMAC of the raw `hlf_` token is
/// stored (`hashToken`, the same key as `ApiToken.tokenHash`); the raw token is
/// returned once at mint and is unrecoverable afterwards. The feed is computed
/// live on every fetch from the record's own rows, so nothing about the
/// forecast is stored here.
///
/// At most one live feed per record: a partial unique index on `user_id WHERE
/// revoked_at IS NULL`, written in the migration because Prisma cannot express
/// it. Revocation stamps `revokedAt` and never deletes, so the status read can
/// say when a link stopped working. Not in the backup: a restore would bring
/// back a link the owner revoked (see `backup-plan.ts`).
model CycleCalendarFeed {
id String @id @default(cuid())
/// The record whose forecast the feed serves.
userId String @map("user_id")
/// Who minted it: the owner, or the guardian of a managed profile. A
/// guardian's feed is re-checked against their live MANAGE grant and the
/// managed-profile marker on every fetch.
createdById String @map("created_by_id")
/// HMAC-SHA256 of the raw `hlf_` token.
tokenHash String @unique @map("token_hash")
/// The first 8 characters of the raw token, shown in settings so the owner
/// can tell which link is live. Not enough to rebuild it.
tokenPrefix String @map("token_prefix")
createdAt DateTime @default(now()) @map("created_at")
revokedAt DateTime? @map("revoked_at")
/// Fetch statistics, written fire-and-forget by the feed route and read by
/// the status endpoint ("last fetched 3 h ago by Google Calendar").
lastFetchedAt DateTime? @map("last_fetched_at")
fetchCount Int @default(0) @map("fetch_count")
/// The last fetcher's User-Agent, truncated to 80 characters.
lastFetcherUa String? @map("last_fetcher_ua")
/// SHA-256 of the last served body without its timestamps. When a fetch
/// builds a different body, `contentChangedAt` moves; both drive DTSTAMP,
/// LAST-MODIFIED and the ETag.
bodyHash String? @map("body_hash")
contentChangedAt DateTime @default(now()) @map("content_changed_at")
/// Forecast cycles ahead (1..6). The feed caps itself further once the
/// band grows past ±10 days or half a cycle.
horizonCycles Int @default(3) @map("horizon_cycles")
/// Observed cycles included as history (0..12).
historyCycles Int @default(0) @map("history_cycles")
/// The fertile window of the next cycle, for TRYING_TO_CONCEIVE only.
includeFertile Boolean @default(false) @map("include_fertile")
titleMode CycleCalendarFeedTitleMode @default(DISCREET) @map("title_mode")
user User @relation("CycleCalendarFeedSubject", fields: [userId], references: [id], onDelete: Cascade)
createdBy User @relation("CycleCalendarFeedCreator", fields: [createdById], references: [id], onDelete: Cascade)
@@index([userId, createdAt(sort: Desc)])
@@index([createdById])
@@map("cycle_calendar_feeds")
}
/// One row per menstrual cycle (a period-start anchor + derived span).
/// `isPredicted` rows are forward-minted forecasts; observed rows are
/// confirmed from logged flow. Soft-deleted + syncVersion-bumped so the
+1 -1
View File
@@ -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/*`
+17 -6
View File
@@ -1824,7 +1824,11 @@ export async function seed() {
{ analyte: "Ferritin", value: 88, unit: "ng/mL", low: 30, high: 400 },
];
const insertQuant = async (panel: string, lab: QuantLab, takenAt: Date) => {
const insertQuant = async (
panel: string | null,
lab: QuantLab,
takenAt: Date,
) => {
await client.query(
`INSERT INTO lab_results (id, user_id, panel, analyte, value, value_text, unit, reference_low, reference_high, source_value, source_unit, taken_at, source, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, NULL, $6, $7, $8, $9, $10, $11, 'MANUAL', NOW(), NOW())`,
@@ -1843,7 +1847,11 @@ export async function seed() {
],
);
};
const insertQual = async (panel: string, lab: QualLab, takenAt: Date) => {
const insertQual = async (
panel: string | null,
lab: QualLab,
takenAt: Date,
) => {
await client.query(
`INSERT INTO lab_results (id, user_id, panel, analyte, value, value_text, unit, reference_low, reference_high, taken_at, source, created_at, updated_at)
VALUES ($1, $2, $3, $4, NULL, $5, $6, NULL, NULL, $7, 'MANUAL', NOW(), NOW())`,
@@ -1851,14 +1859,17 @@ export async function seed() {
);
};
// No panel on the readings: a draw label ("Annual blood panel") would
// become one category holding every marker, and the labs list would stay
// flat. Without one each marker lands in its catalog category by name.
for (const lab of recentQuant) {
await insertQuant("Annual blood panel", lab, recentPanelAt);
await insertQuant(null, lab, recentPanelAt);
}
for (const lab of recentQual) {
await insertQual("Annual blood panel", lab, recentPanelAt);
await insertQual(null, lab, recentPanelAt);
}
for (const lab of olderQuant) {
await insertQuant("Blood panel (last year)", lab, olderPanelAt);
await insertQuant(null, lab, olderPanelAt);
}
const labResults = [...recentQuant, ...recentQual, ...olderQuant];
@@ -2901,7 +2912,7 @@ export async function seed() {
for (const panel of historyPanels) {
for (const [key, value] of Object.entries(panel.values)) {
await insertQuant(
"Blood panel",
null,
{ ...panelLabs[key], value },
daysAgoAt(panel.n, 9, 0),
);
@@ -9,8 +9,8 @@ import {
DERIVED_MODELS,
NOT_IN_BACKUP_MODELS,
RAW_SQL_TABLES,
backupVerdict,
} from "@/lib/export/backup-plan";
import { backupVerdict } from "@/lib/export/backup-verdict";
/**
* Every model in the schema has a written verdict about the backup.
@@ -1,13 +1,13 @@
{
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 84,
"total": 94309,
"total": 94047,
"entries": {
"app/medications/page-client.tsx": 839,
"components/charts/mood-chart.tsx": 1166,
"components/cycle/log-day-sheet.tsx": 1639,
"components/insights/coach-panel/chat-bubble.tsx": 822,
"components/insights/coach-panel/coach-conversation.tsx": 1286,
"components/insights/coach-panel/coach-conversation.tsx": 1278,
"components/insights/coach-panel/use-coach.ts": 1486,
"components/insights/derived/vitals-dashboard.tsx": 1033,
"components/insights/health-score-card.tsx": 1023,
@@ -17,7 +17,7 @@
"components/medications/medication-table.tsx": 853,
"components/medications/wizard/wizard-payload.ts": 1414,
"components/mood/mood-list.tsx": 1408,
"components/records/life-circumstances-manager.tsx": 1080,
"components/records/life-circumstances-manager.tsx": 828,
"components/timeline/timeline-chart.tsx": 1093,
"components/timeline/timeline-geometry.ts": 1288,
"lib/ai/anthropic-client.ts": 804,
@@ -26,8 +26,8 @@
"lib/ai/coach/persistence.ts": 1218,
"lib/ai/coach/results/metric-table-tool.ts": 1102,
"lib/ai/coach/snapshot.ts": 1346,
"lib/ai/coach/stream-events.ts": 817,
"lib/ai/coach/types.ts": 1011,
"lib/ai/coach/stream-events.ts": 811,
"lib/ai/coach/types.ts": 1008,
"lib/ai/codex-client.ts": 907,
"lib/ai/prompts/insight-generator.ts": 1115,
"lib/ai/provider.ts": 1628,
@@ -42,7 +42,7 @@
"lib/dashboard-layout.ts": 976,
"lib/dashboard/snapshot.ts": 1550,
"lib/day/records.ts": 1174,
"lib/export/backup-plan.ts": 853,
"lib/export/backup-plan.ts": 838,
"lib/export/coach-backup.ts": 1070,
"lib/export/profile-backup.ts": 1019,
"lib/fitbit/client.ts": 1262,
@@ -86,6 +86,6 @@
"lib/validations/inbound-documents.ts": 1018,
"lib/withings/sync-sleep.ts": 884,
"lib/withings/sync.ts": 933,
"proxy.ts": 870
"proxy.ts": 846
}
}
@@ -1,18 +1,17 @@
{
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 345,
"total": 111894,
"count": 344,
"total": 111520,
"entries": {
"app/achievements/page.tsx :: AchievementsPage": 206,
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275,
"app/api/admin/notifications/reminder-check/route.ts :: apiHandler(arg)": 206,
"app/api/admin/settings/route.ts :: apiHandler(arg) #2": 211,
"app/api/auth/login/route.ts :: apiHandler(arg)": 262,
"app/api/auth/login/route.ts :: apiHandler(arg)": 260,
"app/api/auth/me/route.ts :: apiHandler(arg)": 378,
"app/api/auth/oidc/callback/route.ts :: apiHandler(arg)": 258,
"app/api/auth/register/route.ts :: apiHandler(arg)": 268,
"app/api/auth/register/route.ts :: apiHandler(arg)": 266,
"app/api/coach/about-me/route.ts :: apiHandler(arg) #2": 309,
"app/api/cycle/calendar/route.ts :: apiHandler(arg)": 230,
"app/api/cycle/day-logs/bulk/route.ts :: postBulk": 213,
"app/api/cycle/insights/route.ts :: apiHandler(arg)": 226,
"app/api/devices/route.ts :: apiHandler(arg)": 226,
@@ -24,7 +23,7 @@
"app/api/integrations/status/route.ts :: apiHandler(arg)": 476,
"app/api/labs/[id]/route.ts :: apiHandler(arg) #2": 239,
"app/api/labs/ocr/commit/route.ts :: commitOcrRows": 280,
"app/api/labs/route.ts :: postLabResult": 242,
"app/api/labs/route.ts :: postLabResult": 241,
"app/api/mcp/oauth/token/route.ts :: POST": 250,
"app/api/mcp/oauth/token/route.ts :: POST > withBackgroundEvent(arg)": 248,
"app/api/measurement-reminders/[id]/route.ts :: apiHandler(arg) #2": 239,
@@ -81,7 +80,7 @@
"components/cycle/bbt-chart.tsx :: BbtChart": 244,
"components/cycle/cycle-calendar.tsx :: CycleCalendar": 292,
"components/cycle/cycle-settings.tsx :: CycleSettings": 228,
"components/cycle/cycle-view.tsx :: CycleView": 408,
"components/cycle/cycle-view.tsx :: CycleView": 407,
"components/cycle/log-day-sheet.tsx :: LogDaySheet": 753,
"components/daily/today-hero.tsx :: TodayHero": 488,
"components/dashboard/dashboard-header.tsx :: DashboardHeader": 215,
@@ -103,7 +102,7 @@
"components/illness/new-episode-sheet.tsx :: NewEpisodeSheet": 264,
"components/insights/coach-panel/attachment-picker.tsx :: AttachmentPicker": 208,
"components/insights/coach-panel/chat-bubble.tsx :: ChatBubbleImpl": 419,
"components/insights/coach-panel/coach-conversation.tsx :: CoachConversation": 1011,
"components/insights/coach-panel/coach-conversation.tsx :: CoachConversation": 1002,
"components/insights/coach-panel/coach-input.tsx :: CoachInput": 259,
"components/insights/coach-panel/coach-model-picker.tsx :: CoachModelPicker": 212,
"components/insights/coach-panel/conversations-panel.tsx :: ConversationsPanel": 317,
@@ -130,16 +129,15 @@
"components/insights/sleep-stage-stacked-bar.tsx :: SleepStageStackedBar": 354,
"components/insights/sub-page-shell.tsx :: SubPageShell": 266,
"components/insights/trends-row.tsx :: TrendsRow": 215,
"components/labs/biomarker-form.tsx :: BiomarkerForm": 244,
"components/labs/biomarker-form.tsx :: BiomarkerForm": 229,
"components/labs/biomarker-manager.tsx :: BiomarkerManager": 242,
"components/labs/biomarker-merge-sheet.tsx :: BiomarkerMergeSheet": 250,
"components/labs/biomarker-merge-sheet.tsx :: BiomarkerMergeSheet": 239,
"components/labs/lab-biomarker-chart.tsx :: LabBiomarkerChart": 370,
"components/labs/lab-biomarker-detail.tsx :: LabBiomarkerDetail": 481,
"components/labs/lab-form.tsx :: LabForm": 473,
"components/labs/lab-history-list.tsx :: LabHistoryList": 534,
"components/labs/lab-list.tsx :: LabList": 308,
"components/labs/ocr-review-dialog.tsx :: OcrReviewDialog": 359,
"components/labs/ocr-row-editor.tsx :: OcrRowEditor": 386,
"components/labs/ocr-row-editor.tsx :: OcrRowEditor": 378,
"components/layout/auth-shell.tsx :: AuthShell": 518,
"components/layout/bottom-nav.tsx :: BottomNav": 262,
"components/layout/capture-picker.tsx :: CapturePicker": 235,
@@ -180,14 +178,14 @@
"components/mood/mood-tag-picker.tsx :: MoodTagPicker": 257,
"components/onboarding/confirm-screen.tsx :: ConfirmScreen": 255,
"components/onboarding/done-screen.tsx :: DoneScreen": 408,
"components/onboarding/getting-started-checklist.tsx :: GettingStartedChecklist": 425,
"components/onboarding/getting-started-checklist.tsx :: GettingStartedChecklist": 423,
"components/onboarding/tour.tsx :: OnboardingTour": 544,
"components/practitioners/practitioner-list.tsx :: PractitionerList": 230,
"components/provenance/source-provenance-view.tsx :: SourceProvenanceView": 363,
"components/records/allergy-form.tsx :: AllergyForm": 225,
"components/records/audit-c-panel.tsx :: AuditCPanel": 285,
"components/records/free-text-take-over.tsx :: FreeTextTakeOver": 201,
"components/records/life-circumstances-manager.tsx :: SectionBody": 482,
"components/records/life-circumstances-manager.tsx :: SectionBody": 472,
"components/settings/about-section.tsx :: AboutSection": 236,
"components/settings/access/grant-invite-card.tsx :: GrantInviteCard": 311,
"components/settings/access/managed-profile-create-form.tsx :: ManagedProfileCreateForm": 235,
@@ -207,7 +205,7 @@
"components/settings/import-panel/health-connect-import-card.tsx :: HealthConnectImportCard": 293,
"components/settings/import-panel/record-csv-import-card.tsx :: RecordCsvImportSheet": 458,
"components/settings/insights-pill-order-section.tsx :: InsightsPillOrderSection": 255,
"components/settings/integrations/connections-panel.tsx :: ConnectionsPanel": 230,
"components/settings/integrations/connections-panel.tsx :: ConnectionsPanel": 227,
"components/settings/integrations/document-sources-card.tsx :: SourceForm": 306,
"components/settings/integrations/google-health-card.tsx :: GoogleHealthCard": 300,
"components/settings/integrations/integration-card.tsx :: IntegrationCard": 209,
@@ -243,11 +241,11 @@
"hooks/use-sheet-drag.ts :: createSheetDrag": 312,
"lib/activity/calendar-math.ts :: buildActivityCalendar": 204,
"lib/ai/anthropic-client.ts :: generateCompletion": 294,
"lib/ai/coach/cycle-snapshot.ts :: buildCycleSnapshotBlock": 218,
"lib/ai/coach/cycle-snapshot.ts :: buildCycleSnapshotBlock": 202,
"lib/ai/coach/glp1-snapshot.ts :: buildGlp1SnapshotBlock": 218,
"lib/ai/coach/snapshot.ts :: buildCoachSnapshotImpl": 880,
"lib/ai/coach/tools/loop.ts :: runCoachToolLoop": 438,
"lib/ai/coach/turn/model.ts :: runTurnModel": 538,
"lib/ai/coach/turn/model.ts :: runTurnModel": 534,
"lib/ai/coach/turn/pipeline.ts :: runTurn": 274,
"lib/ai/coach/turn/reply-guards.ts :: guardReply": 331,
"lib/ai/codex-client.ts :: parseResponse": 302,
@@ -286,8 +284,8 @@
"lib/google-health/data-points.ts :: forEachDataPointPage > walk": 223,
"lib/google-health/sync-core.ts :: upsertGoogleHealthMeasurements": 452,
"lib/illness/correlation.ts :: computeIllnessCorrelation": 233,
"lib/import/labs-csv.ts :: importLabsCsv": 309,
"lib/import/labs-csv.ts :: importLabsCsv > flush": 231,
"lib/import/labs-csv.ts :: importLabsCsv": 307,
"lib/import/labs-csv.ts :: importLabsCsv > flush": 229,
"lib/insights/comprehensive-aggregator.ts :: buildFromRollups": 217,
"lib/insights/comprehensive-generate.ts :: generateComprehensiveInsight": 876,
"lib/insights/features.ts :: extractFeatures": 796,
@@ -300,7 +298,7 @@
"lib/jobs/coach-nudge.ts :: runCoachNudgeTick": 292,
"lib/jobs/coach-reminder-sweep.ts :: runCoachReminderSweep": 222,
"lib/jobs/cycle-reminder.ts :: runCycleReminderTick": 243,
"lib/jobs/document-summary.ts :: runDocumentSummaryJob": 239,
"lib/jobs/document-summary.ts :: runDocumentSummaryJob": 238,
"lib/jobs/google-health-backfill.ts :: runGoogleHealthBackfillForUser": 276,
"lib/jobs/insight-pregenerate.ts :: forceWarmUser": 282,
"lib/jobs/insight-pregenerate.ts :: forceWarmUser > withFeatureCacheScope(arg)": 252,
@@ -315,7 +313,7 @@
"lib/jobs/reminder/medication-reminder-check.ts :: handleReminderCheck": 592,
"lib/jobs/reminder/medication-reminder-check.ts :: handleReminderCheck > withBackgroundEvent(arg)": 587,
"lib/jobs/workout-insight-generate.ts :: runWorkoutInsightGenerate": 324,
"lib/labs/biomarker-merge.ts :: runMerge": 254,
"lib/labs/biomarker-merge.ts :: runMerge": 232,
"lib/measurements/consolidate-daily-mean.ts :: consolidateDailyMean": 278,
"lib/measurements/consolidate-legacy-steps.ts :: consolidateLegacySteps": 263,
"lib/measurements/dense-intraday-retention.ts :: runDenseIntradayRetention": 539,
@@ -336,7 +334,7 @@
"lib/notifications/senders/web-push.ts :: sendViaWebPush": 268,
"lib/personal-records/pr-detection-worker.ts :: detectPersonalRecordsForUser": 239,
"lib/personal-records/pr-direction.ts :: getPRDirection": 208,
"lib/record-activity/activity-verb.ts :: recordActivityVerbLine": 272,
"lib/record-activity/activity-verb.ts :: recordActivityVerbLine": 261,
"lib/strava/sync.ts :: runStravaSync": 219,
"lib/support/report.ts :: collectSupportReportFacts": 332,
"lib/targets/build-response.ts :: buildTargetsResponse": 330,
@@ -347,6 +345,6 @@
"lib/withings/sync-ecg.ts :: syncUserEcg": 202,
"lib/withings/sync-sleep.ts :: syncUserSleep": 414,
"lib/withings/sync.ts :: syncUserMeasurements": 337,
"proxy.ts :: proxy": 498
"proxy.ts :: proxy": 471
}
}
@@ -1,7 +1,7 @@
{
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
"count": 2894,
"total": 9223,
"count": 2892,
"total": 9220,
"entries": {
"__tests__/account-payload-consumer-guard.test.ts": 2,
"__tests__/acting-account-boundary-guard.test.ts": 7,
@@ -1072,7 +1072,7 @@
"components/labs/lab-biomarker-detail.tsx": 13,
"components/labs/lab-form.tsx": 6,
"components/labs/lab-history-list.tsx": 4,
"components/labs/lab-list.tsx": 6,
"components/labs/lab-list.tsx": 5,
"components/labs/lab-reference-range-bar.tsx": 1,
"components/labs/lab-trend-card.tsx": 1,
"components/labs/lab-trend-sparkline.tsx": 1,
@@ -2892,7 +2892,7 @@
"lib/withings/sync.ts": 6,
"lib/withings/webhook-handler.ts": 3,
"lib/workouts/list-read.ts": 1,
"proxy.ts": 29,
"proxy.ts": 27,
"types/analytics.ts": 8,
"types/backups.ts": 1
}
@@ -0,0 +1,185 @@
/**
* #1350 — the calendar feed carries dates and a title, and nothing else.
*
* A calendar provider keeps everything it fetches, beyond the reach of any
* revoke. So this seeds every kind of day-log content the record can hold —
* a note, custom symptom labels, a basal temperature, an ovulation test, the
* mucus and cervix signs, intercourse, contraception, a pregnancy test — as
* recognisable sentinels on the very rows the feed reads, as if a select had
* been widened by mistake, and asserts that no rendered body contains any of
* them, in either title mode, with history and the fertile window on.
*
* Written, then broken once on purpose (a description that interpolated the
* day's note) to prove it fails.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
cycleProfile: { findUnique: vi.fn() },
menstrualCycle: { findMany: vi.fn() },
cycleDayLog: { findMany: vi.fn() },
measurement: { findMany: vi.fn() },
},
}));
import { prisma } from "@/lib/db";
import { addDays } from "@/lib/cycle/day-math";
import { buildCycleFeed } from "@/lib/cycle/feed/build";
import { renderIcs } from "@/lib/cycle/ics";
import {
FEED,
SUBJECT,
profileRow,
regularCycles,
} from "@/lib/cycle/feed/__tests__/fixtures";
const db = prisma as unknown as {
cycleProfile: { findUnique: ReturnType<typeof vi.fn> };
menstrualCycle: { findMany: ReturnType<typeof vi.fn> };
cycleDayLog: { findMany: ReturnType<typeof vi.fn> };
measurement: { findMany: ReturnType<typeof vi.fn> };
};
const SENTINELS = {
note: "SENTINEL-NOTE-zebra",
noteEncrypted: "SENTINEL-CIPHER-okapi",
symptom: "SENTINEL-SYMPTOM-quokka",
symptomKey: "sentinel_symptom_key",
bbt: "36.987",
wrist: "35.4321",
opk: "POSITIVE_LH_SURGE",
mucus: "EGG_WHITE",
cervix: "HIGH",
sex: "SENTINEL-SEX-tapir",
contraceptive: "SENTINEL-CONTRACEPTIVE-ibis",
pregnancyTest: "SENTINEL-PREGNANCY-lynx",
userId: "subject",
};
const FIRST = "2025-11-18";
const NOW = new Date("2026-11-06T12:00:00.000Z");
function stuffedDayLogs() {
const cycles = regularCycles(FIRST);
const out = [];
for (const c of cycles) {
for (let i = 0; i < 6; i++) {
out.push({
id: `log-${c.startDate}-${i}`,
userId: SENTINELS.userId,
date: addDays(c.startDate, i),
flow: i < 4 ? "MEDIUM" : "NONE",
basalBodyTempC: Number(SENTINELS.bbt),
temperatureExcluded: false,
ovulationTest: SENTINELS.opk,
cervicalMucus: SENTINELS.mucus,
cervixPosition: SENTINELS.cervix,
cervixFirmness: null,
cervixOpening: null,
intermenstrualBleeding: true,
sexualActivity: SENTINELS.sex,
protectedSex: false,
pregnancyTest: SENTINELS.pregnancyTest,
progesteroneTest: SENTINELS.pregnancyTest,
contraceptive: SENTINELS.contraceptive,
notes: SENTINELS.note,
notesEncrypted: SENTINELS.noteEncrypted,
sensitiveEncrypted: SENTINELS.noteEncrypted,
symptomLinks: [
{
symptom: {
key: SENTINELS.symptomKey,
label: SENTINELS.symptom,
},
},
],
_count: { symptomLinks: 1 },
});
}
}
return out;
}
beforeEach(() => {
vi.clearAllMocks();
db.cycleProfile.findUnique.mockResolvedValue(
profileRow("TRYING_TO_CONCEIVE"),
);
db.menstrualCycle.findMany.mockResolvedValue(regularCycles(FIRST));
db.cycleDayLog.findMany.mockResolvedValue(stuffedDayLogs());
db.measurement.findMany.mockResolvedValue([
{
measuredAt: new Date("2026-11-01T03:00:00Z"),
value: Number(SENTINELS.wrist),
},
]);
});
describe("the calendar feed body", () => {
it.each(["DISCREET", "DESCRIPTIVE"] as const)(
"carries no day-log content in %s mode",
async (titleMode) => {
const built = await buildCycleFeed(
{
...FEED,
titleMode,
historyCycles: 12,
horizonCycles: 6,
includeFertile: true,
},
SUBJECT,
NOW,
);
// Non-empty, or the assertion below would hold for nothing.
expect(built.calendar.events.length).toBeGreaterThan(10);
const body = renderIcs(built.calendar, NOW);
for (const [name, value] of Object.entries(SENTINELS)) {
expect(body, `${name} leaked into the feed`).not.toContain(value);
}
},
);
it("carries only the properties an all-day date event needs", async () => {
const built = await buildCycleFeed(
{
...FEED,
titleMode: "DESCRIPTIVE",
historyCycles: 6,
includeFertile: true,
},
SUBJECT,
NOW,
);
const body = renderIcs(built.calendar, NOW);
const properties = new Set(
body
.split("\r\n")
.filter((line) => line && !line.startsWith(" "))
.map((line) => line.split(/[:;]/)[0]),
);
expect([...properties].sort()).toEqual(
[
"BEGIN",
"CALSCALE",
"CLASS",
"DESCRIPTION",
"DTEND",
"DTSTAMP",
"DTSTART",
"END",
"LAST-MODIFIED",
"METHOD",
"PRODID",
"REFRESH-INTERVAL",
"SUMMARY",
"TRANSP",
"UID",
"VERSION",
"X-PUBLISHED-TTL",
"X-WR-CALNAME",
].sort(),
);
});
});
@@ -511,6 +511,14 @@ const REGISTRY: DestructiveEntry[] = [
recovery: "tombstoned-no-restore",
confirm: ["ConfirmButton", "ConfirmDialog"],
},
{
file: "components/cycle/use-cycle-feed.ts",
destroys:
"the record's calendar-feed link (#1350); calendars subscribed to it stop updating, and turning it on again issues a new link",
triggers: ["components/cycle/cycle-calendar-feed-card.tsx"],
recovery: "permanent",
confirm: ["ConfirmButton"],
},
// ── Medications ─────────────────────────────────────────────────────────
{
@@ -70,8 +70,8 @@ describe("report scope gating — structural guard", () => {
it("reads a plausible catalogue", () => {
// Sanity floor: a silently degraded catalogue would pass everything below
// vacuously.
expect(ALL_LEAF_IDS.length).toBe(110);
expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(90);
expect(ALL_LEAF_IDS.length).toBe(111);
expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(91);
expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(20);
});
@@ -75,6 +75,10 @@ function isMaskPlaceholder(words: string[]): boolean {
* reason. Prune an entry only when the value genuinely diverges in a locale.
*/
const LEGIT_IDENTICAL = new Set<string>([
// #1350 — "Google Calendar" is the product's own name in the Spanish and
// Italian UI, and "Cycle (HealthLog)" is the French word for the same thing.
"cycle.feed.fetcher.GOOGLE",
"cycle.feed.ics.calendarName",
// Vaccine catalogue: clinical proper nouns whose accepted written form is the
// same across these locales. "Hib" carries its Latin binomial verbatim, and
// "Hepatitis A" / "Hepatitis B" read identically in Spanish. The catalogue
@@ -0,0 +1,166 @@
/**
* Per-envelope response/consumer guard for the labs categories.
*
* The registered Zod schemas are the producer: `LabOverviewResponse`
* (`GET /api/labs/overview`), `ListBiomarkersResponse` (`GET /api/biomarkers`)
* and `BiomarkerMergeResult` (`POST /api/biomarkers/{id}/merge`). Every leaf
* of the parts this guard owns is walked out of the schema itself and must be
* tied to the web reader that renders it, or to a named exemption with its
* reason. A leaf added to a schema without either fails, so a field cannot
* ship write-only. Matchers are qualified (`category.status`, not `status`),
* so another response's field of the same name cannot satisfy them.
*
* Scope: the category fields and their two companions. The overview's
* `markers` subtree predates this guard and is read through `LabResultDto`;
* it is exempt as a whole, by name.
*/
import { describe, expect, it } from "vitest";
import type { z } from "zod/v4";
import {
biomarkerListResponse,
biomarkerMergeResult,
} from "@/lib/openapi/routes/biomarkers";
import { labOverviewResponse } from "@/lib/openapi/routes/labs";
import { readSource } from "./helpers/source-files";
interface Def {
type: string;
shape?: Record<string, z.ZodType>;
element?: z.ZodType;
innerType?: z.ZodType;
in?: z.ZodType;
}
function defOf(schema: z.ZodType): Def {
return (schema as unknown as { _zod: { def: Def } })._zod.def;
}
/** Leaf paths of a schema: `a.b`, arrays as `a[]`. */
function leaves(schema: z.ZodType, prefix: string): string[] {
const def = defOf(schema);
if (def.type === "object" && def.shape) {
return Object.entries(def.shape).flatMap(([key, child]) =>
leaves(child, prefix ? `${prefix}.${key}` : key),
);
}
if (def.type === "array" && def.element) {
return leaves(def.element, `${prefix}[]`);
}
const inner = def.innerType ?? def.in;
if (inner) return leaves(inner, prefix);
return [prefix];
}
interface Reader {
file: string;
pattern: RegExp;
}
const LIST = "src/components/labs/lab-list.tsx";
const GROUPS = "src/components/labs/lab-list-groups.tsx";
const FORM_PANEL = "src/components/labs/biomarker-panel-field.tsx";
const MERGE_SHEET = "src/components/labs/biomarker-merge-sheet.tsx";
const COUNTS_DESTRUCTURE = (name: string) =>
new RegExp(`\\{[^}]*\\b${name}\\b[^}]*\\}\\s*=\\s*category\\.counts\\b`);
const READERS: Record<string, Reader> = {
"overview:grouped": { file: LIST, pattern: /\bdata\?\.grouped\b/ },
"overview:categories[].key": { file: GROUPS, pattern: /\bcategory\.key\b/ },
"overview:categories[].label": {
file: GROUPS,
pattern: /\{category\.label\}/,
},
"overview:categories[].markerKeys[]": {
file: GROUPS,
pattern: /\bof\s+category\.markerKeys\b/,
},
"overview:categories[].counts.inRange": {
file: GROUPS,
pattern: COUNTS_DESTRUCTURE("inRange"),
},
"overview:categories[].counts.below": {
file: GROUPS,
pattern: COUNTS_DESTRUCTURE("below"),
},
"overview:categories[].counts.above": {
file: GROUPS,
pattern: COUNTS_DESTRUCTURE("above"),
},
"overview:categories[].counts.unknown": {
file: GROUPS,
pattern: COUNTS_DESTRUCTURE("unknown"),
},
"overview:categories[].status": {
file: GROUPS,
pattern: /\bcategory\.status\s*===\s*"above"/,
},
"biomarkers:customPanels[]": {
file: FORM_PANEL,
pattern: /\bdata\?\.customPanels\b/,
},
"merge:adoptedPanel": {
file: MERGE_SHEET,
pattern: /\bpreview\.adoptedPanel\b/,
},
};
/** Leaves this guard does not own, with the reason. Prefix match on `[]`. */
const EXEMPT: Record<string, string> = {
"overview:markers[]":
"the per-marker rows predate the categories and are rendered through LabResultDto in lab-list-rows.tsx",
};
function ownedLeaves(): string[] {
const overview = leaves(labOverviewResponse, "").map((l) => `overview:${l}`);
const list = leaves(biomarkerListResponse, "")
.filter((l) => l.startsWith("customPanels"))
.map((l) => `biomarkers:${l}`);
const merge = leaves(biomarkerMergeResult, "")
.filter((l) => l === "adoptedPanel")
.map((l) => `merge:${l}`);
return [...overview, ...list, ...merge];
}
function isExempt(leaf: string): boolean {
return Object.keys(EXEMPT).some((prefix) => leaf.startsWith(prefix));
}
describe("labs categories response/consumer guard", () => {
const owned = ownedLeaves();
it("walks the schemas it guards", () => {
// Nine category leaves, `grouped`, the panels and the adopted panel, plus
// the exempt marker rows; far fewer means the walk broke.
expect(owned.filter((leaf) => !isExempt(leaf)).length).toBe(
Object.keys(READERS).length,
);
expect(owned.filter(isExempt).length).toBeGreaterThan(10);
});
it("ties every leaf to a reader or a named exemption", () => {
const orphans = owned.filter((leaf) => !isExempt(leaf) && !READERS[leaf]);
expect(orphans).toEqual([]);
const stale = Object.keys(READERS).filter((leaf) => !owned.includes(leaf));
expect(stale).toEqual([]);
});
for (const [leaf, reader] of Object.entries(READERS)) {
it(`${leaf} is read in ${reader.file}`, () => {
// `readSource` throws when the anchor is gone from the file.
const source = readSource(reader.file, reader.pattern);
expect(reader.pattern.test(source)).toBe(true);
});
}
it("cannot pass on a reader that reads nothing", () => {
expect(
COUNTS_DESTRUCTURE("inRange").test("const { below } = category.counts;"),
).toBe(false);
expect(READERS["overview:grouped"].pattern.test("data?.groupedX")).toBe(
false,
);
});
});
@@ -0,0 +1,69 @@
/**
* Every analyte the catalog can recognise by name has a catalog category.
*
* The labs list groups a marker without a panel by the analyte its name
* resolves to (`catalogKeyForName`), and that analyte's panel comes from its
* seed or, for an analyte with no seed, from `ALIAS_PANEL`. An alias key added
* to `ANALYTE_ALIASES` or `LAB_NAME_ALIASES` without an `ALIAS_PANEL` entry
* would put every marker of that analyte under "Other" without a word. This
* holds the lists together.
*
* The check is a function of the alias table, so the guard proves it can
* fail: the same check over the table minus one entry must name that entry.
*/
import { describe, expect, it } from "vitest";
import {
ALIAS_PANEL,
ANALYTE_ALIASES,
BIOMARKER_CATALOG,
BIOMARKER_PANELS,
LAB_NAME_ALIASES,
type BiomarkerPanelKey,
} from "@/lib/labs/biomarker-catalog";
function uncategorised(
aliasPanel: Readonly<Record<string, BiomarkerPanelKey>>,
): { checked: number; missing: string[] } {
const panels = new Set<string>(BIOMARKER_PANELS);
const seedPanel = new Map(BIOMARKER_CATALOG.map((s) => [s.slug, s.panel]));
const keys = new Set<string>([
...BIOMARKER_CATALOG.map((s) => s.slug),
...ANALYTE_ALIASES.map((a) => a.key),
...LAB_NAME_ALIASES.map((a) => a.key),
]);
const missing: string[] = [];
for (const key of keys) {
const panel = seedPanel.get(key) ?? aliasPanel[key];
if (panel === undefined || !panels.has(panel)) missing.push(key);
}
return { checked: keys.size, missing };
}
describe("marker category coverage", () => {
it("gives every seed and alias key a catalog panel", () => {
const { checked, missing } = uncategorised(ALIAS_PANEL);
expect(checked).toBeGreaterThan(BIOMARKER_CATALOG.length);
expect(missing).toEqual([]);
});
it("lists no alias panel for a key the catalog does not know", () => {
const known = new Set<string>([
...ANALYTE_ALIASES.map((a) => a.key),
...LAB_NAME_ALIASES.map((a) => a.key),
]);
const seeds = new Set(BIOMARKER_CATALOG.map((s) => s.slug));
const stale = Object.keys(ALIAS_PANEL).filter(
(key) => !known.has(key) || seeds.has(key),
);
expect(stale).toEqual([]);
});
it("fails when an alias panel entry is removed", () => {
const [dropped] = Object.keys(ALIAS_PANEL);
const reduced = Object.fromEntries(
Object.entries(ALIAS_PANEL).filter(([key]) => key !== dropped),
);
expect(uncategorised(reduced).missing).toEqual([dropped]);
});
});
@@ -77,6 +77,8 @@ const UNAUTHENTICATED: Readonly<Record<string, string>> = {
"Starts the identity-provider handoff, for the browser and for the native client.",
"/api/auth/oidc/status":
"Says whether single sign-on is configured, so the sign-in screen can offer it.",
"/api/cycle/feed/{token}":
"A calendar provider polls it from its own servers with no session; the `hlf_` path token is the credential.",
"/api/notifications/vapid":
"The instance's public Web Push key. Public by definition.",
"/api/auth/claim/preview":
+5 -2
View File
@@ -11,7 +11,7 @@ import { NextRequest } from "next/server";
* - `frame-src` exists and admits precisely 'self' and blob: — nothing else.
* - `img-src` admits blob: alongside 'self' and data:.
* - The boundaries that make blob: safe stay untouched: `object-src 'none'`,
* `frame-ancestors 'none'`, `default-src 'self'`, nonce-bound script-src.
* `frame-ancestors 'self'`, `default-src 'self'`, nonce-bound script-src.
* - The vault SERVE route keeps its own stricter document CSP unchanged.
*
* Security rationale (mirrors the comment in proxy.ts): a `blob:` URL can only
@@ -73,7 +73,10 @@ describe("proxy.ts blob preview CSP (#787)", () => {
const res = proxy(makeRequest("/documents"));
const csp = res.headers.get("content-security-policy") ?? "";
expect(directive(csp, "object-src")).toEqual(["'none'"]);
expect(directive(csp, "frame-ancestors")).toEqual(["'none'"]);
// 'self', never 'none': the blob: preview inherits this policy, and
// WebKit applies the inherited frame-ancestors to the preview's own
// parent, so 'none' blanks every PDF preview in Safari.
expect(directive(csp, "frame-ancestors")).toEqual(["'self'"]);
expect(directive(csp, "default-src")).toEqual(["'self'"]);
expect(csp).toMatch(/script-src 'self' 'nonce-/);
});
@@ -0,0 +1,78 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
/**
* #1350 — the cycle calendar feed at `/api/cycle/feed/<hlf_token>.ics`.
*
* A calendar provider polls it from its own servers with no cookie, so the
* proxy must pass the request through untouched, keep it out of indexes and
* referrers, and leave its caching to the route (`private, no-cache` plus an
* ETag) rather than stamping `no-store` over the validator path. The
* management routes beside it stay cookie-only and are not on the list.
*/
vi.mock("@/lib/process-type", () => ({
shouldRunWeb: () => true,
}));
import { PUBLIC_PATHS, proxy } from "../proxy";
// Built at run time so no token-shaped literal sits in the source.
const TOKEN = `hlf_${"7d".repeat(24)}`;
afterEach(() => {
vi.unstubAllEnvs();
});
describe("proxy on /api/cycle/feed/<token>", () => {
it("lists the feed prefix, trailing slash included, and not the management namespace", () => {
expect(PUBLIC_PATHS).toContain("/api/cycle/feed/");
expect(
PUBLIC_PATHS.some((p) => "/api/cycle/feed-subscription".startsWith(p)),
).toBe(false);
});
it("passes a cookie-less GET through to the route", () => {
const res = proxy(
new NextRequest(`http://localhost/api/cycle/feed/${TOKEN}.ics`, {
headers: { "user-agent": "Google-Calendar-Importer" },
}),
);
expect(res.status).toBe(200);
expect(res.headers.get("x-middleware-next")).toBe("1");
expect(res.headers.get("location")).toBeNull();
});
it("keeps the token out of indexes and referrers", () => {
const res = proxy(
new NextRequest(`http://localhost/api/cycle/feed/${TOKEN}.ics`),
);
expect(res.headers.get("x-robots-tag")).toBe("noindex, nofollow");
expect(res.headers.get("referrer-policy")).toBe("no-referrer");
});
it("leaves the route's Cache-Control alone, so the ETag path survives", () => {
const res = proxy(
new NextRequest(`http://localhost/api/cycle/feed/${TOKEN}.ics`),
);
expect(res.headers.get("cache-control")).toBeNull();
});
it("serves a cookie-less GET in demo mode too: a read, not a mutation", () => {
vi.stubEnv("DEMO_MODE", "true");
const res = proxy(
new NextRequest(`http://localhost/api/cycle/feed/${TOKEN}.ics`),
);
expect(res.status).toBe(200);
});
it("refuses a demo-mode mint at the edge", () => {
vi.stubEnv("DEMO_MODE", "true");
const res = proxy(
new NextRequest("http://localhost/api/cycle/feed-subscription", {
method: "POST",
}),
);
expect(res.status).toBe(403);
});
});
@@ -4,8 +4,7 @@ import { NextRequest } from "next/server";
/**
* The document vault's decrypt-and-serve route is framed same-origin by the
* detail preview (`<iframe>` for inline-class PDFs). The proxy's blanket
* `X-Frame-Options: DENY` + `frame-ancestors 'none'` would refuse that
* embed, and the blanket page CSP overrides whatever the route sets
* `X-Frame-Options: DENY` would refuse that embed, and the blanket page CSP overrides whatever the route sets
* (middleware headers win) — so the proxy carves out EXACTLY the
* `/original` path with a same-origin framing posture and a document CSP
* (`default-src 'none'`; deliberately no `sandbox` — Chromium
@@ -58,7 +57,7 @@ describe("proxy.ts document serve-route framing carve-out", () => {
const res = proxy(makeRequest("/api/documents/inbound"));
expect(res.headers.get("x-frame-options")).toBe("DENY");
expect(res.headers.get("content-security-policy") ?? "").toContain(
"frame-ancestors 'none'",
"frame-ancestors 'self'",
);
});
@@ -82,7 +81,7 @@ describe("proxy.ts document serve-route framing carve-out", () => {
const res = proxy(makeRequest("/documents"));
expect(res.headers.get("x-frame-options")).toBe("DENY");
expect(res.headers.get("content-security-policy") ?? "").toContain(
"frame-ancestors 'none'",
"frame-ancestors 'self'",
);
});
});
@@ -103,11 +102,12 @@ describe("proxy.ts share document serve-route framing carve-out", () => {
it("keeps the DENY + page-CSP posture on the /c/<token> view page itself", () => {
const res = proxy(makeRequest(`/c/${shareToken}`));
// The clinician view is a PAGE — it must not be framable and keeps the
// page CSP; only the blob subresource under it takes the serve posture.
// The clinician view is a PAGE — it keeps the page CSP, framable by its
// own origin only (see the blob: note in proxy.ts); only the served
// document under it takes the serve posture.
expect(res.headers.get("x-frame-options")).toBe("DENY");
expect(res.headers.get("content-security-policy") ?? "").toContain(
"frame-ancestors 'none'",
"frame-ancestors 'self'",
);
});
@@ -97,6 +97,17 @@ const TOKEN_ARM_COOKIE_PROOF_ROUTES = [
"app/api/fhir/Patient/$everything/route.ts",
].sort();
/**
* Routes that resolve through `requireGuardianAuth` and are cookie-only: they
* refuse a Bearer caller outright, then take the cookie arm. The cycle
* calendar feed's mint and rotate (#1350) hand out a standing URL credential,
* so no token may reach them and the session needs a fresh proof.
*/
const RECORD_COOKIE_ONLY_PROOF_ROUTES = [
"app/api/cycle/feed-subscription/route.ts",
"app/api/cycle/feed-subscription/rotate/route.ts",
].sort();
/**
* The connection consent. It resolves the session through `getSession`
* (OAuth answers in its own error format, not the envelope) and asks for the
@@ -143,11 +154,30 @@ describe("the recent-proof gate", () => {
[
...COOKIE_PROOF_ROUTES,
...TOKEN_ARM_COOKIE_PROOF_ROUTES,
...RECORD_COOKIE_ONLY_PROOF_ROUTES,
...CONSENT_PROOF_ROUTES,
].sort(),
);
});
it("a cookie-only record route refuses a token before it asks", () => {
for (const rel of RECORD_COOKIE_ONLY_PROOF_ROUTES) {
const src = read(rel);
expect(importedAs(rel, "requireGuardianAuth"), rel).toHaveLength(1);
// Every proof call sits after a Bearer refusal in the same handler.
const proofs = [...src.matchAll(/await assertRecentCookieProof\(/g)];
expect(proofs.length, rel).toBeGreaterThan(0);
for (const proof of proofs) {
const before = src.slice(0, proof.index);
const handlerStart = before.lastIndexOf("apiHandler(");
expect(handlerStart, rel).toBeGreaterThan(-1);
expect(before.slice(handlerStart), rel).toMatch(
/if \(auth\.authMethod !== "cookie"\) return cookieOnlyRefusal\(\);/,
);
}
}
});
it("a route that resolves either transport asks only on the cookie", () => {
for (const rel of TOKEN_ARM_COOKIE_PROOF_ROUTES) {
expect(read(rel), rel).toMatch(
@@ -173,6 +173,31 @@ const RULES: ResolvedFieldRule[] = [
"the `/me` payload normaliser: types and coerces the field, decides nothing",
},
},
{
field: "marker category (labs list categories)",
servedBy:
"`categories` / `grouped` on `GET /api/labs/overview` (`src/lib/labs/marker-category.ts`)",
// Symbols, not modules: the marker form imports the catalog module for
// its seed list, which is fine. Resolving a category or its status from
// a name or a panel in the browser is the rebuild.
patterns: [
/\bcatalogKeyForName\s*\(/,
/\bresolveMarkerCategory\s*\(/,
/\bsummariseCategory\s*\(/,
/\bcategoriseOverview\s*\(/,
/\bALIAS_PANEL\b/,
/\bbuildLocalisedLabelIndex\s*\(/,
],
samples: [
"const key = catalogKeyForName(row.analyte);",
"const ref = resolveMarkerCategory({ panel, name });",
"const { status } = summariseCategory(statuses);",
"const { categories } = categoriseOverview(markers, locale);",
"const panel = ALIAS_PANEL[key];",
"const index = buildLocalisedLabelIndex(entries);",
],
allow: {},
},
];
function isClientModule(rel: string, source: string): boolean {
@@ -294,6 +294,14 @@ describe("S2 — the URL-token authentication set is frozen", () => {
// action-bound, short-lived token in the path is the credential. Rate
// limited before verification, single-use through the mood entry's unique
// key, and scrubbed from logs by `PATH_SECRET_PATHS`.
// #1350 — the cycle calendar feed. A calendar provider polls it from its
// own servers with no session, so the `hlf_` path segment is the
// credential. Rate limited before the lookup, HMAC-hashed at rest, every
// gate (account, operator switch, cycle module, a carrier's MANAGE grant)
// re-read per fetch behind one 404, dates-only content, and scrubbed from
// logs by `PATH_SECRET_PATHS`.
"app/api/cycle/feed/[token]/route.ts":
"calendar provider polls without a session; the hlf_ path token is the credential",
"app/api/notifications/action/[token]/route.ts":
"signed single-use quick-answer token from a notification action",
};
@@ -329,6 +337,7 @@ describe("S2 — the URL-token authentication set is frozen", () => {
expect(bySegment.length).toBeGreaterThan(0);
expect(bySegment).toEqual([
"app/api/c/[token]/unlock/route.ts",
"app/api/cycle/feed/[token]/route.ts",
"app/api/notifications/action/[token]/route.ts",
"app/api/whoop/webhook/[token]/route.ts",
"app/api/withings/webhook/[token]/route.ts",
+14 -1
View File
@@ -940,6 +940,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
domain: "labs",
why: "One biomarker of the record, fetch-then-guard against the resolved user.",
},
"app/api/biomarkers/[id]/series/route.ts": {
domain: "labs",
why: "One marker's lab line (#1342), the timeline's `labSeries` for that marker: the marker is looked up under the resolved user, and the report behind a point is named only when the grant also covers documents. Read only.",
},
"app/api/biomarkers/[id]/merge/route.ts": {
domain: "labs",
why: "Merging markers of the record into one (v1.43): every read and write is narrowed to the resolved user, and the sources must be that record's markers.",
@@ -2150,6 +2154,10 @@ const GUARDIAN_ROUTES: Record<string, string> = {
"Starts an OAuth connect for the managed record a guardian administers. It returns the consent URL and the state names the managed record; the callback re-checks the browser's live MANAGE grant before any token lands. Refused on an adult record by the marker, at every grant level.",
"app/api/record-settings/integrations/[provider]/disconnect/route.ts":
"Disconnects a provider from the managed record a guardian administers, through the same disconnect the owner runs. Imported data stays. Refused on an adult record by the marker, at every grant level.",
"app/api/cycle/feed-subscription/route.ts":
"The record's cycle calendar subscription (#1350): status, turning it on, its options, turning it off. A standing credential whose contents land in a third party's calendar cloud, so only the person the dates are about or the guardian of a managed profile may hold one; refused on an adult record at every grant level, MANAGE included. Cookie-only, fresh proof on the mint; the feed re-reads the grant and the marker on every fetch and a handover claim revokes it.",
"app/api/cycle/feed-subscription/rotate/route.ts":
"A new link for the record's cycle calendar subscription, the old one revoked in the same transaction (#1350). Same argument as the subscription route: owner or managed-profile guardian only, cookie-only, fresh proof.",
};
/**
@@ -2291,8 +2299,13 @@ const ACTOR_ROUTES: Record<string, string> = {
* Ending a running course (#1280) joins the record list and the
* manage literal: it was refused under a switch while the medication's PUT,
* which used to carry the end, admitted a manager. 272 -> 274.
*
* The cycle calendar subscription (#1350) and its rotate join the guardian
* list: a standing credential to a third-party calendar is the owner's to
* make, or a managed profile's guardian's, never an adult record's delegate's.
* 276 -> 278.
*/
const FROZEN_ENTRY_COUNT = 275;
const FROZEN_ENTRY_COUNT = 278;
/**
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
@@ -133,6 +133,8 @@ const GATED_ROUTES: Record<string, string> = {
"POST /api/account/grants": "web",
"POST /api/admin/backups/[id]/restore": "web",
"POST /api/admin/backups/upload": "web",
"POST /api/cycle/feed-subscription": "web",
"POST /api/cycle/feed-subscription/rotate": "web",
"POST /api/admin/encryption/rotate": "web",
"POST /api/admin/users/[id]/reset-password": "web",
"POST /api/auth/me/mfa/disable": "web",
@@ -210,6 +210,14 @@ const MODULE_ROUTE_TREES: ReadonlyArray<string> = [
* ungated. Each carries its reason inline per the bucket-4 contract.
*/
const EXEMPT_ROUTES: ReadonlyArray<string> = [
// ── CYCLE CALENDAR FEED (#1350) ───────────────────────────────────
// Not ungated: the anonymous feed cannot answer the module's 403 (that
// would tell a prober the token is real), so `serveCycleFeed` re-reads
// `isCycleAvailableForUser` (the user toggle AND the operator switch) on
// every fetch and answers its blunt 404. `requireCycleEnabled` would also
// upsert a profile on an anonymous request. The behaviour is pinned in
// BEHAVIOURAL_GATE_TESTS below.
"src/app/api/cycle/feed/[token]/route.ts",
// ── DATA LAYER (mood) ─────────────────────────────────────────────
// Raw MoodEntry CRUD. The module gate governs whether mood SURFACES
// (nav, dashboard tile, analysis, doctor report), not whether the row
@@ -508,6 +516,11 @@ const REQUIRED_TREE_MODULE: Readonly<Record<string, ModuleKey>> = {
*/
const BEHAVIOURAL_GATE_TESTS: ReadonlyArray<{ path: string; proves: string }> =
[
{
path: "src/lib/cycle/feed/__tests__/serve.test.ts",
proves:
"the anonymous cycle calendar feed answers its blunt 404, and builds nothing, when cycle tracking is unavailable for the record",
},
{
path: "tests/integration/routes/fhir-module-gate.test.ts",
proves:
@@ -653,6 +653,9 @@ describe("GET /api/auth/oidc/callback — duplicate-callback idempotency", () =>
});
});
/** The two columns `pendingDeletionOf` reads off the user row. */
type PendingRow = Awaited<ReturnType<typeof prisma.user.findUnique>>;
describe("GET /api/auth/oidc/callback — native branch", () => {
beforeEach(() => {
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
@@ -751,6 +754,40 @@ describe("GET /api/auth/oidc/callback — native branch", () => {
);
});
it("carries purgeAt, and nothing else, on a native sign-in to an account pending deletion", async () => {
mockIdentity();
mockUserLookups({
byIdentity: userRow({ oidcIssuer: METADATA.issuer, oidcSub: "sub-1" }),
});
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
deletionRequestedAt: new Date("2026-11-01T10:00:00.000Z"),
deletionPurgeAt: new Date("2026-12-01T10:00:00.000Z"),
} as PendingRow);
const res = await GET(nativeRequest());
expect(res.headers.get("location")).toBe(
"healthlog://oidc-callback?error=account_deletion_pending&purgeAt=2026-12-01T10%3A00%3A00.000Z",
);
expect(mintNativeHandoff).not.toHaveBeenCalled();
expect(createSession).not.toHaveBeenCalled();
});
it("omits purgeAt on the native leg when the purge date is unknown", async () => {
mockIdentity();
mockUserLookups({
byIdentity: userRow({ oidcIssuer: METADATA.issuer, oidcSub: "sub-1" }),
});
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
deletionRequestedAt: new Date("2026-11-01T10:00:00.000Z"),
deletionPurgeAt: null,
} as PendingRow);
const res = await GET(nativeRequest());
expect(res.headers.get("location")).toBe(
"healthlog://oidc-callback?error=account_deletion_pending",
);
});
it("delivers an IdP denial to the custom scheme", async () => {
const res = await GET(
makeRequest({
+29 -9
View File
@@ -95,6 +95,26 @@ function secondFactorRedirect(
return response;
}
/**
* A failure on the native leg: the custom scheme with `error` and, at most,
* a non-secret detail beside it. An error redirect carries no code, ticket,
* or session.
*/
function nativeErrorRedirect(params: Record<string, string>): NextResponse {
const response = NextResponse.redirect(buildNativeCallbackUrl(params));
deleteStateCookie(response);
return response;
}
/**
* The purge date as the `purgeAt` parameter the native leg carries beside
* `account_deletion_pending`, mirroring `deletionPendingRedirect` (#1341).
* Only the date: nothing else about the account rides the URL.
*/
function purgeAtParam(purgeAt: Date | null): Record<string, string> {
return purgeAt ? { purgeAt: purgeAt.toISOString() } : {};
}
/**
* The web sign-in page for an account pending deletion, with the date it is
* erased, so the page can say until when an admin can undo it.
@@ -180,16 +200,13 @@ export const GET = apiHandler(async (req: NextRequest) => {
return response;
};
const failRedirect = (reason: string): NextResponse => {
const failRedirect = (
reason: string,
nativeExtra: Record<string, string> = {},
): NextResponse => {
if (isReproof) return reproofBack(reason);
if (isClaim) return claimRedirect(reason);
if (isNative) {
const response = NextResponse.redirect(
buildNativeCallbackUrl({ error: reason }),
);
deleteStateCookie(response);
return response;
}
if (isNative) return nativeErrorRedirect({ error: reason, ...nativeExtra });
return errorRedirect(reason);
};
@@ -349,7 +366,10 @@ export const GET = apiHandler(async (req: NextRequest) => {
// the caller is this account, so the sign-in page may name the date.
const pendingDeletion = await pendingDeletionOf(user.id);
if (pendingDeletion) {
if (isNative || isClaim) return failRedirect("account_deletion_pending");
if (isNative || isClaim) {
const extra = purgeAtParam(pendingDeletion.purgeAt);
return failRedirect("account_deletion_pending", extra);
}
return deletionPendingRedirect(pendingDeletion.purgeAt);
}
@@ -0,0 +1,98 @@
/**
* `GET /api/biomarkers/{id}/series?zoom=&from=&to=` — one lab marker's line
* (#1342).
*
* The same line `labSeries` on `/api/timeline` draws for the marker, by the
* same zoom and window rules, for a client that shows it on the Labs screen:
* a lab line belongs to Labs, so it is gated on the `labs` module rather
* than on the opt-in `timeline`. Admission is the labs read every other
* biomarker read declares; a delegate's grant must cover labs, and the
* report behind a point is named only when it also covers documents.
* Cross-user ids answer 404, and a merged marker names where it went, as
* `GET /api/biomarkers/{id}` does.
*/
import type { NextRequest } from "next/server";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { labSeriesQuerySchema } from "@/lib/day/wire-schemas";
import { prisma } from "@/lib/db";
import { loadBiomarkerSeries } from "@/lib/labs/biomarker-series";
import { mergedInto } from "@/lib/labs/biomarker-merge";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled, resolveModuleMap } from "@/lib/modules/gate";
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
import { resolveUserTimezone } from "@/lib/tz/resolver";
type RouteParams = { params: Promise<{ id: string }> };
export const GET = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user, grantId } = await requireRecordAuth("read", "labs");
const gate = await requireModuleEnabled(user.id, "labs");
if (!gate.enabled) return gate.response;
const { id } = await params;
const query = new URL(request.url).searchParams;
const parsed = labSeriesQuerySchema.safeParse({
zoom: query.get("zoom") ?? undefined,
from: query.get("from") ?? undefined,
to: query.get("to") ?? undefined,
});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "labs.series.invalid",
});
}
if (
parsed.data.from &&
parsed.data.to &&
parsed.data.from > parsed.data.to
) {
return apiError("`from` must not be after `to`", 422, {
errorCode: "labs.series.invalid",
});
}
const marker = await prisma.biomarker.findFirst({
where: { id, userId: user.id },
select: { id: true },
});
if (!marker) {
const into = await mergedInto(user.id, id);
return apiError("Biomarker not found", 404, {
errorCode: "labs.biomarker.notFound",
...(into ? { mergedInto: into } : {}),
});
}
const [modules, domainVisible, tz] = await Promise.all([
resolveModuleMap(user.id),
actingDomainVisibility(prisma, grantId),
resolveUserTimezone(user.id),
]);
const result = await loadBiomarkerSeries({
recordId: user.id,
biomarkerId: id,
query: parsed.data,
access: { modules, domainVisible, owner: grantId === null },
tz,
});
if (!result) {
return apiError("Biomarker not found", 404, {
errorCode: "labs.biomarker.notFound",
});
}
annotate({
action: { name: "labs.biomarker.series" },
meta: {
biomarkerId: id,
zoom: result.zoom,
points: result.series.points.length,
excluded: result.series.excluded,
},
});
return apiSuccess(result);
},
);
+4
View File
@@ -15,6 +15,7 @@ import { aliasNamesByMarker } from "@/lib/labs/biomarker-aliases";
import { serialiseBiomarker } from "@/lib/labs/biomarker-dto";
import { encryptContextToBytes } from "@/lib/labs/biomarker-store";
import { matchAnalyteKey } from "@/lib/labs/analyte-match";
import { customPanelSuggestions } from "@/lib/labs/marker-category";
import { annotate } from "@/lib/logging/context";
import { createBiomarkerSchema } from "@/lib/validations/biomarkers";
@@ -64,6 +65,9 @@ export const GET = apiHandler(async () => {
biomarkers: rows.map((row) =>
serialiseBiomarker(row, aliases.get(row.id) ?? [], { soft: true }),
),
// The person's own panel spellings, for the panel suggestions in the
// marker form; catalog panels are offered there from the bundle.
customPanels: customPanelSuggestions(rows.map((row) => row.panel)),
});
});
@@ -167,6 +167,52 @@ describe("GET /api/cycle/calendar is a read", () => {
});
});
describe("GET /api/cycle/calendar carries the projection (#1350)", () => {
it("publishes the projection whose first row is the prediction", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-10T12:00:00Z"));
const { status, body } = await callCalendar();
expect(status).toBe(200);
const data = body.data as {
prediction: { nextPeriodStart: string; nextPeriodStartLow: string };
projection: Array<Record<string, unknown>>;
projectionState: string;
};
expect(data.projectionState).not.toBe("OFF");
expect(data.projection.length).toBeGreaterThan(0);
{
expect(data.projection[0]).toMatchObject({
k: 1,
start: data.prediction.nextPeriodStart,
bandStart: data.prediction.nextPeriodStartLow,
});
// Dates and bands only: the fertile window stays in `prediction`.
expect(Object.keys(data.projection[0]).sort()).toEqual([
"bandEnd",
"bandStart",
"k",
"periodLength",
"start",
]);
}
});
it("publishes an empty projection, state OFF, with prediction switched off", async () => {
// The mock answers with one object; switching it off there switches it
// off for the route's own call.
const gate = await requireCycleEnabled("user-1", null);
if (gate.enabled) gate.profile.predictionEnabled = false;
const { body } = await callCalendar();
const data = body.data as {
projection: unknown[];
projectionState: string;
};
expect(data.projection).toEqual([]);
expect(data.projectionState).toBe("OFF");
});
});
describe("GET /api/cycle/calendar carries the resolved verdict", () => {
it("puts the whole verdict on the wire", async () => {
// 2026-06-10 12:00 UTC is already 2026-06-11 for a user at UTC+14.
+38 -103
View File
@@ -15,7 +15,6 @@
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import {
@@ -28,24 +27,27 @@ import { requireCycleEnabled } from "@/lib/cycle/gate";
import { cycleCalendarQuerySchema } from "@/lib/validations/cycle";
import {
buildCalendar,
type CalendarDayLogRow,
toCycleInputs,
toDayLogInputs,
} from "@/lib/cycle/engine-adapter";
import {
CALENDAR_DEFAULT_PAST_DAYS,
dayLogWindowStart,
loadCalendarInputs,
} from "@/lib/cycle/load-inputs";
import { PROJECTION_MAX_HORIZON, projectCycles } from "@/lib/cycle/projection";
import {
toCyclePredictionDTO,
goalAllowsFertileWindow,
cycleDisclaimerKey,
resolveSensitiveFields,
} from "@/lib/cycle/dto";
import { resolveCycleVerdict } from "@/lib/cycle/verdict";
import { addDays, dayDiff } from "@/lib/cycle/day-math";
import { BBT_WINDOW } from "@/lib/cycle/types";
import { addDays } from "@/lib/cycle/day-math";
import { moodDateKey } from "@/lib/mood/date-key";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { startOfLocalDayKey } from "@/lib/tz/local-day";
const DEFAULT_PAST_DAYS = 90;
const DEFAULT_FORWARD_DAYS = 180;
/** Hard cap on the rendered span (days) to bound the day-grid build. */
const MAX_SPAN_DAYS = 400;
@@ -75,7 +77,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
const tz = user.timezone ?? DEFAULT_TIMEZONE;
const today = moodDateKey(new Date(), tz);
const from = parsed.data.from ?? addDays(today, -DEFAULT_PAST_DAYS);
const from = parsed.data.from ?? addDays(today, -CALENDAR_DEFAULT_PAST_DAYS);
const to = parsed.data.to ?? addDays(today, DEFAULT_FORWARD_DAYS);
// Reject an inverted or absurdly wide range up front.
@@ -87,103 +89,19 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
}
const [cycles, dayLogRows, nightlyTemps] = await Promise.all([
prisma.menstrualCycle.findMany({
where: { userId: user.id, deletedAt: null },
orderBy: { startDate: "asc" },
}),
// Bound the day-log read to the rendered span plus the symptothermal
// lookback — the earlier of `from` and (today − BBT_WINDOW). Cycle-length
// stats run off MenstrualCycle rows, so day-logs can be windowed (QA: perf
// — unbounded full-history read on a hot, per-navigation route).
prisma.cycleDayLog.findMany({
where: {
userId: user.id,
deletedAt: null,
date: {
gte:
dayDiff(from, addDays(today, -BBT_WINDOW)) <= 0
? from
: addDays(today, -BBT_WINDOW),
},
},
orderBy: { date: "asc" },
select: {
date: true,
flow: true,
basalBodyTempC: true,
temperatureExcluded: true,
ovulationTest: true,
cervicalMucus: true,
cervixPosition: true,
cervixFirmness: true,
cervixOpening: true,
intermenstrualBleeding: true,
// The intent fields and their encrypted envelope, resolved below
// through the same reader the day-log route uses. Same `cycle` grant
// as that route (see sharing-surface-guard), so the grid shows a
// delegate nothing the day log would not.
sexualActivity: true,
protectedSex: true,
pregnancyTest: true,
progesteroneTest: true,
contraceptive: true,
sensitiveEncrypted: true,
// Selected for presence only; the text is never decrypted here.
notesEncrypted: true,
_count: { select: { symptomLinks: true } },
},
}),
// Apple Watch wrist/skin temperature feeds the temperature-trend
// ovulation layer. Read the WRIST_TEMPERATURE measurements as nightly
// values; the engine derives the trailing-mean deviation itself.
//
// This is a measurements read on a route declared under `cycle`, and it
// needs no section predicate for one reason: no value from it reaches the
// response. It goes into `predictCycle` and what comes out the other side
// is a shifted date. The crosstab next door reads the same channel and IS
// fenced, because there the values are the answer.
prisma.measurement.findMany({
where: {
userId: user.id,
deletedAt: null,
type: "WRIST_TEMPERATURE",
measuredAt: {
gte: startOfLocalDayKey(addDays(today, -90), tz),
},
},
orderBy: { measuredAt: "asc" },
select: { measuredAt: true, value: true },
}),
]);
const dayLogs: CalendarDayLogRow[] = dayLogRows.map((l) => {
const sensitive = resolveSensitiveFields(l);
return {
date: l.date,
flow: l.flow,
basalBodyTempC: l.basalBodyTempC,
temperatureExcluded: l.temperatureExcluded,
ovulationTest: l.ovulationTest,
cervicalMucus: l.cervicalMucus,
cervixPosition: l.cervixPosition,
cervixFirmness: l.cervixFirmness,
cervixOpening: l.cervixOpening,
intermenstrualBleeding: l.intermenstrualBleeding,
sexualActivity: sensitive.sexualActivity,
pregnancyTest: sensitive.pregnancyTest,
progesteroneTest: sensitive.progesteroneTest,
contraceptive: sensitive.contraceptive,
hasSymptoms: l._count.symptomLinks > 0,
hasNote: l.notesEncrypted != null && l.notesEncrypted !== "",
};
// One loader for the forecast's inputs, shared with the Coach block and the
// calendar feed (`load-inputs.ts`). The day-log read is bounded to the
// rendered span plus the symptothermal lookback. Cycle lengths come from the
// MenstrualCycle rows; the window only reaches the period-length fallback,
// which is why the readers that promise to agree with this page read the
// default view's window (`defaultCalendarDayLogStart`).
const { cycles, dayLogs, nights } = await loadCalendarInputs({
userId: user.id,
today,
tz,
dayLogsFrom: dayLogWindowStart(from, today),
});
const nights = nightlyTemps.map((m) => ({
date: moodDateKey(m.measuredAt, tz),
valueC: m.value,
}));
const goalAllowsFertile = goalAllowsFertileWindow(profile.goal);
const { prediction, stillLearning, days } = buildCalendar(
@@ -244,6 +162,19 @@ export const GET = apiHandler(async (request: NextRequest) => {
cycles.filter((c) => c.startDate <= today).at(-1)?.startDate ?? null,
});
// The cycles beyond the next one, from the same engine run. Read by the
// predictions panel; the calendar feed serves the same projection, so a
// client never extrapolates further dates itself. Fertile fields stay in
// `prediction.*`, where the goal and cold-start gates already apply.
const projection = projectCycles({
prediction,
cycles: toCycleInputs(cycles),
dayLogs: toDayLogInputs(dayLogs),
goal: profile.goal,
today,
horizon: PROJECTION_MAX_HORIZON,
});
annotate({
action: { name: "cycle.calendar.read" },
meta: {
@@ -276,6 +207,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
// client renders a calm "learning your cycle" banner over the grid. Additive
// + back-compatible — older iOS builds ignore the field.
stillLearning,
// Additive (#1350): the next cycles with their widening bands, and why
// the list holds what it holds (OK / LEARNING / LATE / OFF).
projection: projection.cycles,
projectionState: projection.state,
days,
meta: { generatedAt },
});
@@ -0,0 +1,126 @@
/**
* `POST /api/cycle/feed-subscription/rotate` — a new link for the record's
* calendar subscription (#1350).
*
* 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. 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,
* the operator's external-access switch, and the per-actor mint bucket.
*/
import { NextRequest } from "next/server";
import {
apiHandler,
assertRecentCookieProof,
requireGuardianAuth,
} from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import { auditLog } from "@/lib/auth/audit";
import { requireCycleEnabled } from "@/lib/cycle/gate";
import {
FEED_MINT_LIMIT_PER_HOUR,
FEED_MINT_WINDOW_MS,
cookieOnlyRefusal,
feedExists,
feedLinks,
noLiveFeed,
} from "@/lib/cycle/feed/manage";
import { FEED_SUMMARY_SELECT, toFeedSummary } from "@/lib/cycle/feed/status";
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) => {
const auth = await requireGuardianAuth();
if (auth.authMethod !== "cookie") return cookieOnlyRefusal();
const { user, actor } = auth;
await assertRecentCookieProof(actor, auth.session.id);
annotate({ action: { name: "cycle.feed.rotate" } });
if (!(await isApiGloballyEnabled())) {
return apiError("API is globally disabled", 403, {
errorCode: "cycle.feed.api_disabled",
});
}
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
const rl = await checkRateLimit(
`cycle-feed:mint:${actor.id}`,
FEED_MINT_LIMIT_PER_HOUR,
FEED_MINT_WINDOW_MS,
);
if (!rl.allowed) {
return apiError("Too many calendar links, try again later", 429, {
errorCode: "cycle.feed.rateLimited",
});
}
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: {
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,
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;
});
if (rotated === "raced") return feedExists();
if (!rotated) return noLiveFeed();
return apiSuccess(
{
...feedLinks(resolveOrigin(request), minted.raw),
feed: toFeedSummary(rotated),
},
201,
);
});
@@ -0,0 +1,244 @@
/**
* `/api/cycle/feed-subscription` — the record's calendar subscription
* (#1350).
*
* GET → the status: token prefix, options, fetch statistics, and what
* the feed serves right now. Never the hash, never the URL.
* POST → turn it on. Mints an `hlf_` token, stores only its HMAC, and
* returns the URL exactly once.
* PATCH → change the options.
* DELETE → turn it off. The row is revoked, never deleted.
*
* A separate namespace from the feed itself (`/api/cycle/feed/<token>`), so
* neither the path redactor nor the public-path prefix can ever catch it.
*
* Auth: cookie session only, through `requireGuardianAuth()`: the record's
* owner on their own record, or the guardian of a managed profile (a MANAGE
* grant on a record that carries the managed-profile marker). A delegate on
* an adult's record is refused at every grant level, MANAGE included: the
* feed is a standing credential whose contents end up in a third party's
* calendar cloud, and only the person the data is about, or the guardian of
* somebody who cannot decide for themselves, may put them there. Turning it
* on and rotating also need a fresh proof. A guardian's feed is re-checked
* against the live grant and the marker on every fetch, and a handover claim
* ends it.
*/
import { NextRequest } from "next/server";
import {
apiHandler,
assertRecentCookieProof,
requireGuardianAuth,
} from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import { auditLog } from "@/lib/auth/audit";
import { requireCycleEnabled } from "@/lib/cycle/gate";
import {
FEED_MINT_LIMIT_PER_HOUR,
FEED_MINT_WINDOW_MS,
cookieOnlyRefusal,
feedExists,
feedLinks,
noLiveFeed,
} from "@/lib/cycle/feed/manage";
import {
FEED_SUMMARY_SELECT,
cycleFeedStatus,
findLiveFeed,
toFeedSummary,
} from "@/lib/cycle/feed/status";
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";
import {
destroyedDetails,
overwriteDetails,
} from "@/lib/sharing/audit-details";
import { cycleFeedOptionsPatchSchema } from "@/lib/validations/cycle-feed";
export const GET = apiHandler(async () => {
const auth = await requireGuardianAuth();
if (auth.authMethod !== "cookie") return cookieOnlyRefusal();
const { user } = auth;
annotate({ action: { name: "cycle.feed.status" } });
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
return apiSuccess(await cycleFeedStatus(user, gate.profile.goal));
});
export const POST = apiHandler(async (request: NextRequest) => {
const auth = await requireGuardianAuth();
if (auth.authMethod !== "cookie") return cookieOnlyRefusal();
const { user, actor } = auth;
// The proof is the person at the keyboard, so it is the actor's session.
await assertRecentCookieProof(actor, auth.session.id);
annotate({ action: { name: "cycle.feed.create" } });
// The operator's external-access switch covers this credential too.
if (!(await isApiGloballyEnabled())) {
return apiError("API is globally disabled", 403, {
errorCode: "cycle.feed.api_disabled",
});
}
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
const rl = await checkRateLimit(
`cycle-feed:mint:${actor.id}`,
FEED_MINT_LIMIT_PER_HOUR,
FEED_MINT_WINDOW_MS,
);
if (!rl.allowed) {
return apiError("Too many calendar links, try again later", 429, {
errorCode: "cycle.feed.rateLimited",
});
}
if (await findLiveFeed(user.id)) return feedExists();
const minted = mintFeedToken();
let created;
try {
created = await prisma.cycleCalendarFeed.create({
data: {
userId: user.id,
createdById: actor.id,
tokenHash: minted.hash,
tokenPrefix: minted.prefix,
},
select: FEED_SUMMARY_SELECT,
});
} catch (err) {
// A second mint racing this one lost to the one-live-feed index.
if (isP2002(err)) return feedExists();
throw err;
}
await auditLog("cycle.feed.create", {
userId: user.id,
details: { feedId: created.id, tokenPrefix: created.tokenPrefix },
});
return apiSuccess(
{
...feedLinks(resolveOrigin(request), minted.raw),
feed: toFeedSummary(created),
},
201,
);
});
export const PATCH = apiHandler(async (request: NextRequest) => {
const auth = await requireGuardianAuth();
if (auth.authMethod !== "cookie") return cookieOnlyRefusal();
const { user } = auth;
annotate({ action: { name: "cycle.feed.update" } });
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
const parsed = await parseJsonBody(request, cycleFeedOptionsPatchSchema, {
maxBytes: 4 * 1024,
});
if (!parsed.ok) return parsed.error;
const input = parsed.data;
// The fertile window is a conception-goal option and nothing else. Refused
// here rather than stored and ignored, so a later goal change cannot switch
// on something the person never chose under that goal.
if (
input.includeFertile === true &&
gate.profile.goal !== "TRYING_TO_CONCEIVE"
) {
return apiError(
"The fertile window is only offered when trying to conceive",
422,
{
errorCode: "cycle.feed.fertile_goal",
},
);
}
const live = await findLiveFeed(user.id);
if (!live) return noLiveFeed();
const data = {
...(input.horizonCycles !== undefined
? { horizonCycles: input.horizonCycles }
: {}),
...(input.historyCycles !== undefined
? { historyCycles: input.historyCycles }
: {}),
...(input.includeFertile !== undefined
? { includeFertile: input.includeFertile }
: {}),
...(input.titleMode !== undefined ? { titleMode: input.titleMode } : {}),
};
const updated = await prisma.cycleCalendarFeed.update({
where: { id: live.id },
data,
select: FEED_SUMMARY_SELECT,
});
await auditLog("cycle.feed.update", {
userId: user.id,
details: {
feedId: live.id,
...overwriteDetails({
before: {
horizonCycles: live.horizonCycles,
historyCycles: live.historyCycles,
includeFertile: live.includeFertile,
titleMode: live.titleMode,
},
after: {
horizonCycles: updated.horizonCycles,
historyCycles: updated.historyCycles,
includeFertile: updated.includeFertile,
titleMode: updated.titleMode,
},
}),
},
});
return apiSuccess(await cycleFeedStatus(user, gate.profile.goal));
});
export const DELETE = apiHandler(async () => {
const auth = await requireGuardianAuth();
if (auth.authMethod !== "cookie") return cookieOnlyRefusal();
const { user } = auth;
annotate({ action: { name: "cycle.feed.revoke" } });
const live = await findLiveFeed(user.id);
if (!live) return noLiveFeed();
await prisma.cycleCalendarFeed.update({
where: { id: live.id },
data: { revokedAt: new Date() },
});
// The link stops working on the provider's next poll. What the provider
// already copied is beyond reach, which the settings card says before the
// link is ever made.
await auditLog("cycle.feed.revoke", {
userId: user.id,
details: {
feedId: live.id,
...destroyedDetails({
model: "CycleCalendarFeed",
id: live.id,
label: live.tokenPrefix,
effectiveAt: live.createdAt,
}),
},
});
return apiSuccess({ revoked: true });
});
+25
View File
@@ -0,0 +1,25 @@
/**
* `GET /api/cycle/feed/<hlf_token>[.ics]` — the subscribable cycle forecast
* (#1350).
*
* Anonymous by design: a calendar provider polls this URL from its own
* servers, so the `hlf_` path segment is the only credential. Rate-limited
* before the lookup, one blunt 404 for every miss, every gate re-read on each
* fetch. The pipeline and its reasoning live in `serveCycleFeed`; this file
* only binds it to the route.
*/
import { NextRequest } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { serveCycleFeed } from "@/lib/cycle/feed/serve";
type RouteParams = { params: Promise<{ token: string }> };
export const dynamic = "force-dynamic";
export const GET = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { token } = await params;
return serveCycleFeed(request, token);
},
);
@@ -159,8 +159,8 @@ import { getAiCapability } from "@/lib/ai/capabilities/gate";
import {
acquireDocumentUploadSlot,
DOCUMENT_UPLOAD_GLOBAL_CONCURRENCY,
DOCUMENT_UPLOAD_PER_USER_CONCURRENCY,
} from "@/lib/documents/upload-policy";
import { DOCUMENT_UPLOAD_PER_USER_CONCURRENCY } from "@/lib/documents/inbound-constants";
const post = POST as unknown as (r: Request) => Promise<Response>;
const get = GET as unknown as (r: Request) => Promise<Response>;
@@ -413,6 +413,8 @@ describe("POST /api/documents/inbound (vault upload)", () => {
try {
const res = await post(request);
expect(res.status).toBe(429);
// The web upload queue waits this long and retries the same file.
expect(res.headers.get("Retry-After")).toBe("1");
await expect(res.json()).resolves.toMatchObject({
meta: { reason: "uploadBusy" },
});
@@ -431,6 +433,8 @@ describe("POST /api/documents/inbound (vault upload)", () => {
try {
const res = await post(mkUpload());
expect(res.status).toBe(429);
// The web upload queue waits this long and retries the same file.
expect(res.headers.get("Retry-After")).toBe("1");
await expect(res.json()).resolves.toMatchObject({
meta: { reason: "uploadBusy" },
});
+32 -33
View File
@@ -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),
@@ -46,6 +46,8 @@ export interface DialogWorld {
resultsWithheld: boolean;
/** The provider's rounds, consumed in order. */
script: ScriptRound[];
/** The stored `User.coachPrefsJson`; null is the defaults. */
coachPrefs: Record<string, unknown> | null;
}
export const world: DialogWorld = {
@@ -56,6 +58,7 @@ export const world: DialogWorld = {
storedProvenance: {},
resultsWithheld: false,
script: [],
coachPrefs: null,
};
/** Everything the scripted provider was sent and answered, in order. */
@@ -412,6 +415,7 @@ export function resetDialog(): void {
world.storedProvenance = {};
world.resultsWithheld = false;
world.script = [];
world.coachPrefs = null;
providerCalls.length = 0;
messageSeq = 0;
for (const fn of Object.values(m)) fn.mockReset();
@@ -465,7 +469,7 @@ export const modules = {
prisma: {
user: {
findUnique: async () => ({
coachPrefsJson: null,
coachPrefsJson: world.coachPrefs,
displayName: null,
aiResponseTimeoutSeconds: null,
}),
@@ -171,6 +171,62 @@ describe("the live trail", () => {
expect(interim).toBeLessThan(firstToken);
});
it("names a read past the lookback limit on its step and its fetch entry", async () => {
world.coachPrefs = { defaultWindow: "last7days" };
world.script = [
{ calls: [{ name: "get_day", args: { date: "2026-06-01" } }] },
{ text: "That day lies beyond the lookback limit you set." },
];
const { status, frames } = await h.postTurn(post, {
message: "What happened on 1 June?",
locale: "en",
});
expect(status).toBe(200);
const step = framesOf<{ step: { id: string; status: string } }>(
frames,
"step",
)
.map((f) => f.step)
.at(-1);
expect(step).toMatchObject({
tool: "get_day",
status: "empty",
reason: "outside_reach",
});
const fetch = framesOf<{ activity: CoachActivity }>(frames, "activity")
.map((f) => f.activity)
.filter((a) => a.phase === "fetch")
.at(-1);
expect(fetch).toMatchObject({
status: "empty",
stepRef: step?.id,
reason: "outside_reach",
});
});
it("names no reason on a fetch inside the limit", async () => {
world.script = [
{
calls: [
{
name: "get_metric_table",
args: { metric: "bp", window: "last30days" },
},
],
},
{ text: "Steady. result:r1" },
];
const { frames } = await h.postTurn(post, {
message: "How was my blood pressure?",
locale: "en",
});
const fetches = framesOf<{ activity: CoachActivity }>(frames, "activity")
.map((f) => f.activity)
.filter((a) => a.phase === "fetch");
expect(fetches.length).toBeGreaterThan(0);
for (const entry of fetches) expect(entry).not.toHaveProperty("reason");
});
it("persists the structure on the provenance and the text in the trail", async () => {
world.script = [
{
@@ -45,6 +45,10 @@ import { getSession } from "@/lib/auth/session";
import { listLiveMeasurementTypes } from "@/lib/measurements/live-types";
import { APPLE_HEALTH_DATA_STALE_AFTER_MS } from "@/lib/integrations/sync-verdict";
import { auditLog } from "@/lib/auth/audit";
import {
APPLE_HEALTH_TYPE_MAP,
HK_QUANTITY_TYPE_DEFERRED,
} from "@/lib/measurements/apple-health-mapping";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -110,6 +114,23 @@ describe("GET /api/integrations/healthkit", () => {
const bodyMass = body.data.entries.find((e) => e.id === "bodyMass");
expect(bodyMass?.direction).toBe("bidirectional");
});
it("lists the sample types the batch route maps, never a deferred one", async () => {
vi.mocked(getSession).mockResolvedValue(
SESSION_OK as Awaited<ReturnType<typeof getSession>>,
);
const res = await callGet(makeGetReq());
const body = (await res.json()) as { data: { acceptedTypes: string[] } };
expect(body.data.acceptedTypes).toEqual(
Object.keys(APPLE_HEALTH_TYPE_MAP).sort(),
);
expect(body.data.acceptedTypes).toContain(
"HKCategoryTypeIdentifierSleepAnalysis",
);
for (const id of HK_QUANTITY_TYPE_DEFERRED) {
expect(body.data.acceptedTypes).not.toContain(id);
}
});
});
describe("PATCH /api/integrations/healthkit", () => {
@@ -21,6 +21,7 @@ import { annotate } from "@/lib/logging/context";
import { prisma, toJson } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { getAppleHealthSyncProgress } from "@/lib/integrations/apple-health-progress";
import { acceptedHealthKitTypes } from "@/lib/measurements/healthkit-accepted-types";
import {
getHealthKitTypeArrivals,
healthKitWriteBackTypes,
@@ -241,6 +242,8 @@ export const GET = apiHandler(async () => {
),
syncProgress,
health,
// #1340 — what the batch route maps, so the app asks only for those.
acceptedTypes: acceptedHealthKitTypes(),
});
});
@@ -19,6 +19,7 @@ vi.mock("@/lib/db", () => ({
measurement: { findFirst: vi.fn(async () => null) },
$queryRaw: vi.fn(async () => []),
workout: { groupBy: vi.fn(async () => []) },
cycleDayLog: { groupBy: vi.fn(async () => []) },
},
}));
+3 -3
View File
@@ -249,10 +249,10 @@ async function commitOcrRows(request: NextRequest) {
user.id,
{
analyte: row.analyte,
// A qualitative reading has no numeric unit / range.
// A text result has no unit; its printed window still seeds.
unit: isQualitative ? (row.unit ?? "") : (row.unit as string),
referenceLow: isQualitative ? null : sourceLow,
referenceHigh: isQualitative ? null : sourceHigh,
referenceLow: sourceLow,
referenceHigh: sourceHigh,
panel: row.panel ?? null,
},
{ index: markerIndex },
+15 -3
View File
@@ -2,6 +2,8 @@ import { NextRequest } from "next/server";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { categoriseOverview } from "@/lib/labs/marker-category";
import { loadLabOverview } from "@/lib/labs/overview";
import { annotate } from "@/lib/logging/context";
import { labOverviewQuerySchema } from "@/lib/validations/labs";
@@ -12,9 +14,14 @@ import { labOverviewQuerySchema } from "@/lib/validations/labs";
* The Labs list renders from this instead of grouping a capped page of raw
* rows, so a marker measured only long ago still has its row. Same read gate
* as `GET /api/labs` (`requireRecordAuth("read", "labs")`).
*
* `categories` and `grouped` are the list's categories, resolved here once
* (`src/lib/labs/marker-category.ts`): order, label, members, counts and a
* combined status. Labels follow the viewer's locale, so on a managed record
* the caller's own language wins, not the record owner's.
*/
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read", "labs");
const { user, actor } = await requireRecordAuth("read", "labs");
const parsed = labOverviewQuerySchema.safeParse(
Object.fromEntries(request.nextUrl.searchParams),
@@ -28,9 +35,14 @@ export const GET = apiHandler(async (request: NextRequest) => {
}
const markers = await loadLabOverview(user.id);
const locale = await resolveServerLocale({
request,
userLocale: actor.locale ?? null,
});
const { categories, grouped } = categoriseOverview(markers, locale);
annotate({
action: { name: "labs.overview" },
meta: { markers: markers.length },
meta: { markers: markers.length, categories: categories.length, grouped },
});
return apiSuccess({ markers });
return apiSuccess({ markers, categories, grouped });
});
+4 -5
View File
@@ -234,12 +234,11 @@ async function postLabResult(request: NextRequest) {
} else {
biomarker = await resolveOrMintBiomarker(user.id, {
analyte: analyte as string,
// A qualitative reading has no numeric unit / range — mint the catalog
// marker with an empty unit label and no bounds. A numeric reading
// supplies its unit (Zod requires it on the free-text numeric path).
// A qualitative reading mints with an empty unit but the printed range;
// a numeric one supplies its unit (Zod requires it on this path).
unit: isQualitative ? (unit ?? "") : (unit as string),
referenceLow: isQualitative ? null : (referenceLow ?? null),
referenceHigh: isQualitative ? null : (referenceHigh ?? null),
referenceLow: referenceLow ?? null,
referenceHigh: referenceHigh ?? null,
panel: panel ?? null,
});
}
@@ -65,7 +65,7 @@ export const PUT = apiHandler(async (request: Request) => {
});
if (!parsed.ok) return parsed.error;
const { list, view, order, sortDir } = parsed.data;
const { list, view, order, sortDir, groupBy, groupsOpen } = parsed.data;
// Preserve-when-absent: one stored read fills whichever field the body
// left out, and every other list stays as stored. Only fields a person has
// chosen are written, so a field never picked keeps following the default.
@@ -77,6 +77,8 @@ export const PUT = apiHandler(async (request: Request) => {
...(view ? { view } : {}),
...(order ? { order: [...new Set(order)] } : {}),
...(sortDir ? { sortDir } : {}),
...(groupBy ? { groupBy } : {}),
...(groupsOpen ? { groupsOpen } : {}),
};
const next = { ...stored, [list]: nextChoices };
await tx.user.update({
@@ -93,6 +95,8 @@ export const PUT = apiHandler(async (request: Request) => {
list,
view: layout.lists[list].view,
sortDir: layout.lists[list].sortDir,
groupBy: layout.lists[list].groupBy,
groupsOpen: layout.lists[list].groupsOpen,
orderLength: layout.lists[list].order.length,
},
});
+1 -17
View File
@@ -23,6 +23,7 @@ import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { checkRateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/db";
import { resolveOrigin } from "@/lib/http/origin";
import {
createShareLinkSchema,
SHARE_LINK_FORBIDDEN_LEAVES,
@@ -41,23 +42,6 @@ import {
} from "@/lib/clinician-share/passphrase";
import type { Prisma } from "@/generated/prisma/client";
/**
* The absolute origin to build the public share URL from. Prefers the
* forwarded host/proto a reverse proxy sets (the documented self-hosting path
* fronts the app with Caddy/Traefik/Nginx), falling back to the request URL's
* own origin. The QR payload carries the passphrase in the FRAGMENT only.
*/
function resolveOrigin(request: NextRequest): string {
const proto =
request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim() ||
new URL(request.url).protocol.replace(":", "");
const host =
request.headers.get("x-forwarded-host")?.split(",")[0]?.trim() ||
request.headers.get("host") ||
new URL(request.url).host;
return `${proto}://${host}`;
}
/**
* The row selection every owner-facing projection reads. `_count.documents`
* surfaces the size of the frozen document set without ever loading the ids
@@ -0,0 +1,104 @@
/**
* `GET /api/sync/changes?start=now` (#1339) — a cursor at the present.
*
* 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.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/modules/gate", () => ({ isModuleEnabled: vi.fn() }));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import {
decodeCursor,
decodeCursorIssuedAt,
SYNC_DOMAINS,
} from "@/lib/sync/cursor";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const },
};
function req(query: string): NextRequest {
return new NextRequest(`http://localhost/api/sync/changes${query}`);
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(
SESSION_OK as Awaited<ReturnType<typeof getSession>>,
);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 119,
resetAt: Date.now() + 60_000,
} as Awaited<ReturnType<typeof checkRateLimit>>);
});
describe("GET /api/sync/changes?start=now", () => {
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();
expect(res.status).toBe(200);
const { data } = (await res.json()) as {
data: {
serverNow: string;
cursor: string;
hasMore: boolean;
cursorExpired: boolean;
changes: Record<string, { upserts: unknown[]; tombstones: unknown[] }>;
};
};
expect(data.hasMore).toBe(false);
expect(data.cursorExpired).toBe(false);
expect(Object.keys(data.changes).sort()).toEqual([...SYNC_DOMAINS].sort());
for (const block of Object.values(data.changes)) {
expect(block).toEqual({ upserts: [], tombstones: [] });
}
const nowMs = Date.parse(data.serverNow);
expect(nowMs).toBeGreaterThanOrEqual(before);
expect(nowMs).toBeLessThanOrEqual(after);
const cursor = decodeCursor(data.cursor);
expect(cursor).not.toBeNull();
for (const domain of SYNC_DOMAINS) {
expect(cursor![domain], domain).toEqual({
updatedAtMs: nowMs - 60_000,
id: "0",
});
}
expect(decodeCursorIssuedAt(data.cursor)).toBe(nowMs);
});
it("refuses start together with a cursor, and any start value but now", async () => {
const both = await GET(req("?start=now&cursor=abc"));
expect(both.status).toBe(422);
const other = await GET(req("?start=oldest"));
expect(other.status).toBe(422);
});
it("still requires a session", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await GET(req("?start=now"));
expect(res.status).toBe(401);
});
});
+24 -5
View File
@@ -43,6 +43,12 @@
* 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 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.
*
* `apiHandler` + `requireAuth` (cookie OR Bearer; iOS uses Bearer).
* Read-only — no idempotency, no write side-effect (unlike the legacy
* `/api/sync/state` checkpoint bump). The cursor is owned by the client.
@@ -59,15 +65,23 @@ import {
sanitiseZodIssues,
} from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { pullSyncChanges } from "@/lib/sync/changes/pull";
import { pullSyncChanges, syncChangesFromNow } from "@/lib/sync/changes/pull";
const DEFAULT_LIMIT = 200;
const MAX_LIMIT = 500;
const querySchema = z.object({
cursor: z.string().min(1).max(2048).optional(),
limit: z.coerce.number().int().min(1).max(MAX_LIMIT).optional(),
});
// `start=now` (#1339) asks for a cursor at the present instead of a page;
// it contradicts an echoed cursor, so the two together are refused.
const querySchema = z
.object({
cursor: z.string().min(1).max(2048).optional(),
limit: z.coerce.number().int().min(1).max(MAX_LIMIT).optional(),
start: z.literal("now").optional(),
})
.refine((q) => !(q.start && q.cursor), {
message: "start and cursor are mutually exclusive",
path: ["start"],
});
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
@@ -83,6 +97,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
const parsed = querySchema.safeParse({
cursor: url.searchParams.get("cursor") ?? undefined,
limit: url.searchParams.get("limit") ?? undefined,
start: url.searchParams.get("start") ?? undefined,
});
if (!parsed.success) {
return apiValidationError(
@@ -92,6 +107,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
);
}
if (parsed.data.start === "now") {
return apiSuccess(syncChangesFromNow(new Date()));
}
return apiSuccess(
await pullSyncChanges({
userId: user.id,
+1
View File
@@ -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,
},
});
@@ -20,7 +20,7 @@ vi.mock("@/lib/db", () => ({
createManyAndReturn: vi.fn(),
},
workoutRoute: {
createMany: vi.fn(),
createManyAndReturn: vi.fn(),
},
workoutSamples: {
createManyAndReturn: vi.fn(),
@@ -201,13 +201,16 @@ beforeEach(() => {
}) as never,
);
vi.mocked(prisma.workout.findFirst).mockResolvedValue(null);
vi.mocked(prisma.workoutRoute.createMany).mockResolvedValue({ count: 0 });
// The series write reports which rows landed, so an enrichment can tell
// an attach from a row a concurrent writer got in first. The fake
// echoes every row back as landed.
// The series and track writes report which rows landed, so an
// enrichment can tell an attach from a row a concurrent writer got in
// first. The fakes echo every row back as landed.
const echoLanded = (async (args: { data: Array<{ workoutId: string }> }) =>
args.data.map((row) => ({ workoutId: row.workoutId }))) as never;
vi.mocked(prisma.workoutRoute.createManyAndReturn).mockImplementation(
echoLanded,
);
vi.mocked(prisma.workoutSamples.createManyAndReturn).mockImplementation(
(async (args: { data: Array<{ workoutId: string }> }) =>
args.data.map((row) => ({ workoutId: row.workoutId }))) as never,
echoLanded,
);
// v1.4.43 — default to no per-user source-priority override so
// the write-time picker walks the canonical default ladder. Tests
@@ -550,7 +553,7 @@ describe("POST /api/workouts/batch — workout-route opt-in", () => {
};
// Same per-entry status as a stored track: the client needs no branch.
expect(body.data.entries[0]?.status).toBe("inserted");
expect(prisma.workoutRoute.createMany).not.toHaveBeenCalled();
expect(prisma.workoutRoute.createManyAndReturn).not.toHaveBeenCalled();
});
it("drops the track when the account row carries no opt-in at all", async () => {
@@ -564,7 +567,7 @@ describe("POST /api/workouts/batch — workout-route opt-in", () => {
const res = await POST(makeRequest({ workouts: [withRoute()] }));
expect(res.status).toBe(200);
expect(prisma.workoutRoute.createMany).not.toHaveBeenCalled();
expect(prisma.workoutRoute.createManyAndReturn).not.toHaveBeenCalled();
});
});
@@ -599,8 +602,9 @@ describe("POST /api/workouts/batch — nested route attachment", () => {
}),
);
expect(res.status).toBe(200);
expect(prisma.workoutRoute.createMany).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.workoutRoute.createMany).mock.calls[0]?.[0];
expect(prisma.workoutRoute.createManyAndReturn).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.workoutRoute.createManyAndReturn).mock
.calls[0]?.[0];
const rows = (
call as {
data: Array<{
@@ -654,7 +658,7 @@ describe("POST /api/workouts/batch — per-workout HR series (v1.10.0)", () => {
);
expect(res.status).toBe(200);
// The route table is untouched — an indoor workout has no GPS route.
expect(prisma.workoutRoute.createMany).not.toHaveBeenCalled();
expect(prisma.workoutRoute.createManyAndReturn).not.toHaveBeenCalled();
// The HR series lands in the dedicated child table by FK.
expect(prisma.workoutSamples.createManyAndReturn).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.workoutSamples.createManyAndReturn).mock
@@ -703,10 +707,10 @@ describe("POST /api/workouts/batch — per-workout HR series (v1.10.0)", () => {
}),
);
expect(res.status).toBe(200);
expect(prisma.workoutRoute.createMany).toHaveBeenCalledTimes(1);
expect(prisma.workoutRoute.createManyAndReturn).toHaveBeenCalledTimes(1);
expect(prisma.workoutSamples.createManyAndReturn).toHaveBeenCalledTimes(1);
const routeRows = (
vi.mocked(prisma.workoutRoute.createMany).mock.calls[0]?.[0] as {
vi.mocked(prisma.workoutRoute.createManyAndReturn).mock.calls[0]?.[0] as {
data: Array<{ workoutId: string }>;
}
).data;
@@ -27,7 +27,7 @@ vi.mock("@/lib/db", () => ({
const txClient = {
workout: { createManyAndReturn: vi.fn() },
workoutRoute: { createMany: vi.fn() },
workoutRoute: { createManyAndReturn: vi.fn() },
workoutSamples: { createMany: vi.fn() },
};
@@ -133,7 +133,7 @@ beforeEach(() => {
}));
},
);
txClient.workoutRoute.createMany.mockResolvedValue({ count: 0 });
txClient.workoutRoute.createManyAndReturn.mockResolvedValue([]);
txClient.workoutSamples.createMany.mockResolvedValue({ count: 0 });
});
+11 -8
View File
@@ -36,8 +36,8 @@
* - `Content-Length` ceiling at 5 MB before parsing — anything
* larger returns 413 so the iOS client falls back to one workout
* per call.
* - Per-entry status (`inserted | duplicate | enriched | skipped`) so
* the iOS sync cursor can checkpoint accurately.
* - Per-entry status (`inserted | duplicate | enriched | updated |
* skipped`) so the iOS sync cursor can checkpoint accurately.
*
* Idempotency contract:
* - HTTP-level via `Idempotency-Key` (replays cached envelope).
@@ -45,12 +45,15 @@
* composite index. Re-posting the same batch surfaces duplicates
* rather than failing the call.
*
* Heart-rate series enrichment: a re-post that matches a stored
* workout and carries a `samples` array attaches that series when the
* workout has none. The workout row itself stays first-write-wins —
* nothing on it is written, not one column — and a workout that already
* holds a series is a no-op, so the client's history sweep is repeatable
* as often as it likes. See `src/lib/workouts/batch-ingest/plan.ts`.
* Enrichment: a re-post that matches a stored workout and carries a
* `samples` array attaches that series when the workout has none, and one
* that carries a `route` attaches that track when the workout has none and
* the account keeps tracks. The workout row itself stays first-write-wins:
* a series writes nothing on it, an attached track fills only an empty
* `elevationM` with its climb (as an insert would). A workout that already
* holds a series or a track keeps it, so the client's history sweep is
* repeatable as often as it likes. See
* `src/lib/workouts/batch-ingest/plan.ts`.
*
* Race reconciliation uses PostgreSQL's `INSERT ... RETURNING` through
* Prisma's `createManyAndReturn`. The returned composite keys identify the
@@ -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 });
}
+1 -1
View File
@@ -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 (
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { CycleCalendarFeedCard } from "../cycle-calendar-feed-card";
import type { CycleFeedStatus } from "../use-cycle-feed";
function render(status: CycleFeedStatus) {
const client = new QueryClient();
client.setQueryData(queryKeys.cycleFeedSubscription(), status);
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<CycleCalendarFeedCard />
</I18nProvider>
</QueryClientProvider>,
);
}
const FEED = {
tokenPrefix: "hlf_ab12",
createdAt: "2026-11-01T10:00:00.000Z",
lastFetchedAt: null,
fetchCount: 0,
lastFetcher: null,
horizonCycles: 3,
historyCycles: 0,
includeFertile: false,
titleMode: "DISCREET" as const,
};
describe("<CycleCalendarFeedCard>", () => {
it("says what a provider keeps before it offers the button", () => {
const html = render({
feed: null,
projectionState: null,
fertileAvailable: false,
});
const disclosure = html.indexOf("stores these dates on its servers");
const button = html.indexOf("Turn on subscription");
expect(disclosure).toBeGreaterThan(-1);
expect(button).toBeGreaterThan(disclosure);
});
it("shows only the prefix of a live link, and no URL", () => {
const html = render({
feed: FEED,
projectionState: "OK",
fertileAvailable: false,
});
expect(html).toContain("hlf_ab12");
expect(html).not.toContain("/api/cycle/feed/");
expect(html).toContain("Not fetched yet.");
// The fertile option exists only for a conception goal.
expect(html).not.toContain("Fertile window");
});
it("explains a shorter feed in the muted tone", () => {
const html = render({
feed: FEED,
projectionState: "LATE",
fertileAvailable: true,
});
expect(html).toContain('data-slot="cycle-feed-state"');
expect(html).toContain("later than expected");
expect(html).toContain("Fertile window");
});
});
@@ -0,0 +1,53 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
const caps = vi.hoisted(() => ({
value: { inSharedRecord: false, recordKind: "self", level: null } as {
inSharedRecord: boolean;
recordKind: string;
level: string | null;
},
}));
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => caps.value,
}));
vi.mock("../cycle-settings", () => ({
CycleSettings: () => <div data-slot="cycle-settings-form" />,
}));
vi.mock("../cycle-calendar-feed-card", () => ({
CycleCalendarFeedCard: () => <div data-slot="cycle-calendar-feed" />,
}));
import { CycleSettingsTab } from "../cycle-settings-tab";
import type { CycleProfileDTO } from "../types";
const PROFILE = { updatedAt: "2026-11-01T00:00:00.000Z" } as CycleProfileDTO;
function render(value: typeof caps.value) {
caps.value = value;
return renderToStaticMarkup(<CycleSettingsTab profile={PROFILE} />);
}
describe("<CycleSettingsTab> calendar subscription (#1350)", () => {
it("offers it on one's own record", () => {
expect(
render({ inSharedRecord: false, recordKind: "self", level: null }),
).toContain("cycle-calendar-feed");
});
it("offers it to the guardian of a managed profile", () => {
expect(
render({ inSharedRecord: true, recordKind: "managed", level: "manage" }),
).toContain("cycle-calendar-feed");
});
it("does not offer it to a MANAGE delegate on an adult's record", () => {
const html = render({
inSharedRecord: true,
recordKind: "shared",
level: "manage",
});
expect(html).toContain("cycle-settings-form");
expect(html).not.toContain("cycle-calendar-feed");
});
});
@@ -104,3 +104,59 @@ describe("<PredictionsPanel>", () => {
expect(html).toContain("Prediction is off");
});
});
describe("<PredictionsPanel> later cycles (#1350)", () => {
const projection = [
{
k: 1,
start: "2026-07-01",
periodLength: 5,
bandStart: "2026-06-29",
bandEnd: "2026-07-03",
},
{
k: 2,
start: "2026-07-29",
periodLength: 5,
bandStart: "2026-07-26",
bandEnd: "2026-08-01",
},
{
k: 3,
start: "2026-08-26",
periodLength: 5,
bandStart: "2026-08-22",
bandEnd: "2026-08-30",
},
];
it("lists the cycles after the next one, as the server's windows", () => {
const html = render(
<PredictionsPanel
prediction={basePrediction}
rawChartMode={false}
history={emptyHistory}
projection={projection}
projectionState="OK"
/>,
);
expect(html).toContain('data-slot="cycle-projection"');
expect(html).toContain("Later cycles");
// Two later rows; cycle 1 is the next-period row above them.
expect(html.match(/Around /g)).toHaveLength(2);
});
it("states a late period instead of listing later cycles", () => {
const html = render(
<PredictionsPanel
prediction={basePrediction}
rawChartMode={false}
history={emptyHistory}
projection={[]}
projectionState="LATE"
/>,
);
expect(html).toContain('data-slot="cycle-projection-late"');
expect(html).not.toContain('data-slot="cycle-projection"');
});
});
@@ -0,0 +1,395 @@
"use client";
import { useState } from "react";
import { CalendarSync, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import {
recentProofErrorMessage,
useRecentProof,
} from "@/components/settings/security-section/use-recent-proof";
import { formatRelativeTime } from "@/lib/i18n/relative-time";
import { useTranslations } from "@/lib/i18n/context";
import {
useCycleFeedStatus,
useMintCycleFeed,
useRevokeCycleFeed,
useUpdateCycleFeed,
type CycleFeedMinted,
type CycleFeedOptionsPatch,
type CycleFeedStatus,
type FeedTitleMode,
} from "./use-cycle-feed";
/**
* #1350 — the calendar subscription card on the cycle settings tab.
*
* Off by default, and the disclosure comes before the button: a calendar
* provider keeps what it fetched, and turning the link off later reaches none
* of it. The link itself is shown once, right after it is made; after that the
* card knows only its prefix. Rendered only for the owner and a MANAGE
* delegate (the tab's own gate); the routes refuse everyone else regardless.
*/
const HORIZONS = [1, 3, 6] as const;
const HISTORIES = [0, 3, 6, 12] as const;
const TITLE_MODES: FeedTitleMode[] = ["DISCREET", "DESCRIPTIVE"];
/** A long URL with its middle elided, so both ends stay recognisable. */
function middleTruncate(url: string, keep = 28): string {
return url.length <= keep * 2 + 1
? url
: `${url.slice(0, keep)}…${url.slice(-keep / 2)}`;
}
export function CycleCalendarFeedCard() {
const { t } = useTranslations();
const status = useCycleFeedStatus();
const proof = useRecentProof();
const mint = useMintCycleFeed(proof);
const [minted, setMinted] = useState<CycleFeedMinted | null>(null);
const [mintError, setMintError] = useState<string | null>(null);
async function handleMint(kind: "create" | "rotate") {
setMintError(null);
try {
setMinted(await mint.mutateAsync(kind));
} catch (err) {
setMintError(recentProofErrorMessage(err, t("cycle.feed.error")));
}
}
return (
<SettingsCard data-slot="cycle-calendar-feed">
<SettingsCardHeader
icon={CalendarSync}
title={t("cycle.feed.title")}
description={t("cycle.feed.description")}
/>
{status.isError ? (
<QueryErrorCard
error={status.error}
title={t("cycle.loadError")}
onRetry={() => void status.refetch()}
/>
) : status.isLoading || !status.data ? (
<div className="flex h-16 items-center justify-center">
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin motion-reduce:animate-none" />
</div>
) : status.data.feed === null ? (
<OffState
pending={mint.isPending}
error={mintError}
onTurnOn={() => void handleMint("create")}
/>
) : (
<OnState
status={status.data}
minted={minted}
pending={mint.isPending}
error={mintError}
onRotate={() => void handleMint("rotate")}
onRevoked={() => setMinted(null)}
/>
)}
{proof.dialog}
</SettingsCard>
);
}
function OffState({
pending,
error,
onTurnOn,
}: {
pending: boolean;
error: string | null;
onTurnOn: () => void;
}) {
const { t } = useTranslations();
return (
<>
<p className="text-sm">{t("cycle.feed.disclosure")}</p>
{error ? (
<p className="text-destructive text-sm" role="alert">
{error}
</p>
) : null}
<SettingsCardActions>
<Button
size="sm"
className="min-h-11 w-full sm:w-auto"
onClick={onTurnOn}
disabled={pending}
>
{pending ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("cycle.feed.turnOn")}
</Button>
</SettingsCardActions>
</>
);
}
function OnState({
status,
minted,
pending,
error,
onRotate,
onRevoked,
}: {
status: CycleFeedStatus;
minted: CycleFeedMinted | null;
pending: boolean;
error: string | null;
onRotate: () => void;
onRevoked: () => void;
}) {
const { t } = useTranslations();
const revoke = useRevokeCycleFeed();
const feed = status.feed!;
const state = status.projectionState;
return (
<>
{minted ? (
<LinkPanel minted={minted} />
) : (
<p className="text-muted-foreground text-sm">
{t("cycle.feed.linkHidden", { prefix: feed.tokenPrefix })}
</p>
)}
<FetchLine status={status} />
{state === "LEARNING" || state === "LATE" || state === "OFF" ? (
<p
className="text-muted-foreground text-sm"
data-slot="cycle-feed-state"
>
{t(`cycle.projection.state.${state}`)}
</p>
) : null}
<Separator />
<FeedOptions status={status} />
<HelpList />
{error || revoke.isError ? (
<p className="text-destructive text-sm" role="alert">
{error ?? t("cycle.feed.error")}
</p>
) : null}
<SettingsCardActions className="flex-col items-stretch sm:flex-row sm:items-center">
<ConfirmButton
label={t("cycle.feed.turnOff")}
title={t("cycle.feed.turnOffTitle")}
body={t("cycle.feed.turnOffBody")}
confirmLabel={t("cycle.feed.turnOff")}
onConfirm={() => revoke.mutate(undefined, { onSuccess: onRevoked })}
pending={revoke.isPending}
variant="destructive"
destructive
size="sm"
className="min-h-11 w-full sm:w-auto"
/>
<ConfirmButton
label={t("cycle.feed.rotate")}
title={t("cycle.feed.rotateTitle")}
body={t("cycle.feed.rotateBody")}
confirmLabel={t("cycle.feed.rotate")}
onConfirm={onRotate}
pending={pending}
size="sm"
className="min-h-11 w-full sm:w-auto"
/>
</SettingsCardActions>
</>
);
}
/** The link, the one time it exists in the client. */
function LinkPanel({ minted }: { minted: CycleFeedMinted }) {
const { t } = useTranslations();
const [copy, setCopy] = useState<"idle" | "copied" | "failed">("idle");
async function handleCopy() {
try {
await navigator.clipboard.writeText(minted.url);
setCopy("copied");
} catch {
setCopy("failed");
}
}
return (
<div className="space-y-3" data-slot="cycle-feed-link">
<div className="space-y-1.5">
<Label htmlFor="cycle-feed-url">{t("cycle.feed.linkLabel")}</Label>
<code
id="cycle-feed-url"
className="bg-muted block rounded-md px-3 py-2 text-sm break-all"
title={minted.url}
>
{middleTruncate(minted.url)}
</code>
<p className="text-muted-foreground text-xs">
{t("cycle.feed.linkOnce")}
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Button
variant="outline"
size="sm"
className="min-h-11 w-full sm:w-auto"
onClick={() => void handleCopy()}
>
{t("cycle.feed.copy")}
</Button>
<Button
variant="outline"
size="sm"
asChild
className="min-h-11 w-full sm:w-auto"
>
<a href={minted.webcalUrl}>{t("cycle.feed.openInCalendar")}</a>
</Button>
</div>
<p className="text-muted-foreground text-sm" aria-live="polite">
{copy === "copied"
? t("cycle.feed.copied")
: copy === "failed"
? t("cycle.feed.copyFailed")
: ""}
</p>
</div>
);
}
function FetchLine({ status }: { status: CycleFeedStatus }) {
const { t, locale } = useTranslations();
const feed = status.feed!;
if (feed.lastFetchedAt === null) {
return (
<p className="text-muted-foreground text-sm">
{t("cycle.feed.notFetched")}
</p>
);
}
const when = formatRelativeTime(feed.lastFetchedAt, t, locale);
return (
<p className="text-muted-foreground text-sm">
{t("cycle.feed.lastFetched", {
when,
fetcher: t(`cycle.feed.fetcher.${feed.lastFetcher ?? "OTHER"}`),
})}
</p>
);
}
function FeedOptions({ status }: { status: CycleFeedStatus }) {
const { t } = useTranslations();
const update = useUpdateCycleFeed();
const feed = status.feed!;
const save = (patch: CycleFeedOptionsPatch) => update.mutate(patch);
return (
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-1.5">
<Label htmlFor="cycle-feed-horizon">{t("cycle.feed.horizon")}</Label>
<NativeSelect
id="cycle-feed-horizon"
value={String(feed.horizonCycles)}
disabled={update.isPending}
onChange={(e) => save({ horizonCycles: Number(e.target.value) })}
>
{HORIZONS.map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</NativeSelect>
</div>
<div className="space-y-1.5">
<Label htmlFor="cycle-feed-history">{t("cycle.feed.history")}</Label>
<NativeSelect
id="cycle-feed-history"
value={String(feed.historyCycles)}
disabled={update.isPending}
onChange={(e) => save({ historyCycles: Number(e.target.value) })}
>
{HISTORIES.map((n) => (
<option key={n} value={n}>
{n === 0 ? t("cycle.feed.historyNone") : n}
</option>
))}
</NativeSelect>
</div>
<div className="space-y-1.5">
<Label htmlFor="cycle-feed-title">{t("cycle.feed.titles")}</Label>
<NativeSelect
id="cycle-feed-title"
value={feed.titleMode}
disabled={update.isPending}
onChange={(e) =>
save({ titleMode: e.target.value as FeedTitleMode })
}
>
{TITLE_MODES.map((m) => (
<option key={m} value={m}>
{t(`cycle.feed.titleMode.${m}`)}
</option>
))}
</NativeSelect>
</div>
</div>
{status.fertileAvailable ? (
<div className="flex items-start justify-between gap-4">
<div className="space-y-0.5">
<Label htmlFor="cycle-feed-fertile" className="text-sm">
{t("cycle.feed.fertile")}
</Label>
<p className="text-muted-foreground text-sm">
{t("cycle.feed.fertileDescription")}
</p>
</div>
<Switch
id="cycle-feed-fertile"
checked={feed.includeFertile}
disabled={update.isPending}
onCheckedChange={(v) => save({ includeFertile: v })}
className="mt-0.5 shrink-0"
/>
</div>
) : null}
{update.isError ? (
<p className="text-destructive text-sm" role="alert">
{t("cycle.feed.error")}
</p>
) : null}
</div>
);
}
function HelpList() {
const { t } = useTranslations();
return (
<div className="space-y-1.5">
<p className="text-sm font-medium">{t("cycle.feed.help.title")}</p>
<ul className="text-muted-foreground list-disc space-y-1 pl-5 text-sm">
<li>{t("cycle.feed.help.google")}</li>
<li>{t("cycle.feed.help.apple")}</li>
<li>{t("cycle.feed.help.outlook")}</li>
</ul>
</div>
);
}
@@ -0,0 +1,29 @@
"use client";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { CycleCalendarFeedCard } from "./cycle-calendar-feed-card";
import { CycleSettings } from "./cycle-settings";
import type { CycleProfileDTO } from "./types";
/**
* The cycle page's settings tab: the profile form, then the calendar
* subscription (#1350). The form remounts on a profile refetch (`key`), the
* subscription card keeps its own state, including a link it just showed.
*
* The card is the owner's on their own record, and a guardian's on a managed
* profile they manage: the routes behind it (`requireGuardianAuth`) refuse a
* delegate on an adult's record at every level, so the card is not offered
* there rather than opening onto a refusal.
*/
export function CycleSettingsTab({ profile }: { profile: CycleProfileDTO }) {
const { inSharedRecord, recordKind, level } = useRecordCapabilities();
const mayHoldFeed =
!inSharedRecord || (recordKind === "managed" && level === "manage");
return (
<div className="space-y-4">
<CycleSettings key={profile.updatedAt} profile={profile} />
{mayHoldFeed ? <CycleCalendarFeedCard /> : null}
</div>
);
}
+4 -5
View File
@@ -23,7 +23,7 @@ import { PhaseEducationCard } from "./phase-education-card";
import { PredictionsPanel } from "./predictions-panel";
import { CyclePhaseHeadline, CyclePhaseCrosstab } from "./cycle-phase-crosstab";
import { CycleSymptomPatterns } from "./cycle-symptom-patterns";
import { CycleSettings } from "./cycle-settings";
import { CycleSettingsTab } from "./cycle-settings-tab";
import { PHASE_HUE } from "./phase-tokens";
import { monthOf, monthWindowOutside } from "./calendar-window";
import {
@@ -417,6 +417,8 @@ export function CycleView() {
prediction={calendar.data?.prediction ?? null}
rawChartMode={calendar.data?.profile.rawChartMode ?? false}
history={history.data?.data}
projection={calendar.data?.projection}
projectionState={calendar.data?.projectionState ?? null}
/>
<CycleHistoryChart
history={history.data?.data}
@@ -471,10 +473,7 @@ export function CycleView() {
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin motion-reduce:animate-none" />
</div>
) : profileQuery.data && canManageCycle ? (
<CycleSettings
key={profileQuery.data.updatedAt}
profile={profileQuery.data}
/>
<CycleSettingsTab profile={profileQuery.data} />
) : null}
</TabsContent>
</Tabs>
+14 -1
View File
@@ -14,7 +14,13 @@ import { TileHeader } from "@/components/insights/tile-header";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import type { Formatters } from "@/lib/format-locale";
import { FERTILE_HUE, FLOW_HUE, OVULATION_HUE } from "./phase-tokens";
import type { CyclePrediction, CycleHistoryResponse } from "./types";
import type {
CyclePrediction,
CycleHistoryResponse,
ProjectedCycle,
ProjectionState,
} from "./types";
import { UpcomingCycles } from "./upcoming-cycles";
import { EmptyState } from "@/components/ui/empty-state";
/**
@@ -59,12 +65,17 @@ export interface PredictionsPanelProps {
prediction: CyclePrediction | null;
rawChartMode: boolean;
history: CycleHistoryResponse | undefined;
/** #1350 — the server's projection of the cycles after the next one. */
projection?: readonly ProjectedCycle[];
projectionState?: ProjectionState | null;
}
export function PredictionsPanel({
prediction,
rawChartMode,
history,
projection = [],
projectionState = null,
}: PredictionsPanelProps) {
const { t } = useTranslations();
const fmt = useFormatters();
@@ -94,6 +105,7 @@ export function PredictionsPanel({
body={t("cycle.predictions.stillLearningBody")}
/>
<NextPeriod prediction={prediction} />
<UpcomingCycles projection={projection} state={projectionState} />
</>
) : (
<>
@@ -119,6 +131,7 @@ export function PredictionsPanel({
value={formatDate(prediction.predictedOvulation, fmt)}
/>
) : null}
<UpcomingCycles projection={projection} state={projectionState} />
</>
)}
</CardContent>
+18
View File
@@ -114,10 +114,28 @@ export interface CalendarResponse {
* phase band — render the calm "learning your cycle" state over the grid.
*/
stillLearning: boolean;
/**
* #1350 — the next cycles with widening bands, resolved server-side (the
* calendar feed serves the same list). Render as given; never extrapolate.
*/
projection: ProjectedCycle[];
projectionState: ProjectionState;
days: CalendarDay[];
meta: { generatedAt: string };
}
/** Mirrors `ProjectionState` in `src/lib/cycle/projection.ts`. */
export type ProjectionState = "OK" | "LEARNING" | "LATE" | "OFF";
/** Mirrors `ProjectedCycle` in `src/lib/cycle/projection.ts`. */
export interface ProjectedCycle {
k: number;
start: string;
periodLength: number;
bandStart: string;
bandEnd: string;
}
export interface MenstrualCycleDTO {
id: string;
startDate: string;
+72
View File
@@ -0,0 +1,72 @@
"use client";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { FLOW_HUE } from "./phase-tokens";
import type { ProjectedCycle, ProjectionState } from "./types";
/**
* #1350 — the periods after the next one, each as the window its start may
* fall in. The list and its bands are the server's (`/api/cycle/calendar`
* `projection`, the same list the calendar feed serves); this only renders
* them. The band widens from one row to the next, and the server stops the
* list where a date would be a guess.
*
* The next period itself is the row above this one in the predictions card,
* so cycle 1 is skipped here. A late period is stated, in the muted tone: a
* late period is a fact about the calendar, not a warning.
*/
export function UpcomingCycles({
projection,
state,
}: {
projection: readonly ProjectedCycle[];
state: ProjectionState | null;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
if (state === "LATE") {
return (
<p
className="text-muted-foreground text-sm"
data-slot="cycle-projection-late"
>
{t("cycle.projection.state.LATE")}
</p>
);
}
const later = projection.filter((c) => c.k >= 2);
if (later.length === 0) return null;
return (
<div className="space-y-2" data-slot="cycle-projection">
<p className="text-muted-foreground text-xs font-medium">
{t("cycle.projection.title")}
</p>
<ul className="divide-border divide-y">
{later.map((c) => (
<li key={c.k} className="flex items-center gap-2 py-2 text-sm">
<span
aria-hidden="true"
className="h-2.5 w-2.5 shrink-0 rounded-full opacity-60"
style={{ backgroundColor: FLOW_HUE }}
/>
<span className="text-muted-foreground">
{t("cycle.projection.around", {
date: fmt.dateShortSmartCalendar(c.start),
})}
</span>
<span className="text-foreground ml-auto font-medium tabular-nums">
{t("cycle.predictions.nextPeriodWindow", {
low: fmt.dateShortSmartCalendar(c.bandStart),
high: fmt.dateShortSmartCalendar(c.bandEnd),
})}
</span>
</li>
))}
</ul>
</div>
);
}
+110
View File
@@ -0,0 +1,110 @@
"use client";
/**
* #1350 — the calendar subscription's read and write hooks.
*
* Every value the card shows is the server's: the status read carries the
* options, the fetch statistics as a calendar-app family, and the projection
* state that explains a shorter feed. The URL exists in the client only in
* the one mint or rotate response that returns it, held in the card's state
* and gone when the card unmounts.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import type { ProjectionState } from "./types";
export type FeedTitleMode = "DISCREET" | "DESCRIPTIVE";
export type FeedFetcher = "GOOGLE" | "APPLE" | "MICROSOFT" | "OTHER";
export interface CycleFeedSummary {
tokenPrefix: string;
createdAt: string;
lastFetchedAt: string | null;
fetchCount: number;
lastFetcher: FeedFetcher | null;
horizonCycles: number;
historyCycles: number;
includeFertile: boolean;
titleMode: FeedTitleMode;
}
export interface CycleFeedStatus {
feed: CycleFeedSummary | null;
projectionState: ProjectionState | null;
fertileAvailable: boolean;
}
export interface CycleFeedMinted {
url: string;
webcalUrl: string;
feed: CycleFeedSummary;
}
export type CycleFeedOptionsPatch = Partial<
Pick<
CycleFeedSummary,
"horizonCycles" | "historyCycles" | "includeFertile" | "titleMode"
>
>;
const PATH = "/api/cycle/feed-subscription";
export function useCycleFeedStatus(enabled = true) {
return useQuery({
queryKey: queryKeys.cycleFeedSubscription(),
queryFn: () => apiGet<CycleFeedStatus>(PATH),
staleTime: 60_000,
enabled,
});
}
function useInvalidateStatus() {
const qc = useQueryClient();
return () =>
qc.invalidateQueries({ queryKey: queryKeys.cycleFeedSubscription() });
}
/**
* Turn the subscription on, or rotate its link. `proof` is the recent-proof
* flow: both answer 401 `auth.reproof.required` without a fresh proof, and
* `proof.run` opens the re-proof dialog and retries.
*/
export function useMintCycleFeed(proof: {
run: <T>(action: () => Promise<T>) => Promise<T>;
}) {
const invalidate = useInvalidateStatus();
return useMutation({
mutationKey: queryKeys.cycleFeedSubscription(),
mutationFn: (kind: "create" | "rotate") =>
kind === "create"
? proof.run(() =>
apiPost<CycleFeedMinted>("/api/cycle/feed-subscription"),
)
: proof.run(() =>
apiPost<CycleFeedMinted>("/api/cycle/feed-subscription/rotate"),
),
onSettled: () => invalidate(),
});
}
export function useUpdateCycleFeed() {
const invalidate = useInvalidateStatus();
return useMutation({
mutationKey: queryKeys.cycleFeedSubscription(),
mutationFn: (patch: CycleFeedOptionsPatch) =>
apiPatch<CycleFeedStatus>(PATH, patch),
onSettled: () => invalidate(),
});
}
export function useRevokeCycleFeed() {
const invalidate = useInvalidateStatus();
return useMutation({
mutationKey: queryKeys.cycleFeedSubscription(),
mutationFn: () => apiDelete<{ revoked: true }>(PATH),
onSettled: () => invalidate(),
});
}
+5 -5
View File
@@ -373,7 +373,7 @@ export function TodayHero({
<span
data-slot="today-hero-score-basis"
className={cn(
"text-muted-foreground text-xs text-balance md:max-w-[11rem] md:text-right",
"text-muted-foreground text-xs text-balance md:max-w-[11rem] md:text-center",
size === "sm" && "max-w-[11rem]",
)}
>
@@ -388,7 +388,7 @@ export function TodayHero({
footprint, so the column never collapses. The compact all-clear keeps
the ring and its meta lines in one centred column. */
const compactScoreColumn = (
<div className="flex shrink-0 flex-col items-center gap-1 text-center md:items-end">
<div className="flex shrink-0 flex-col items-center gap-1 text-center">
{scoreRing("sm")}
{scoreMetaLines("sm")}
</div>
@@ -507,8 +507,8 @@ export function TodayHero({
{todayBlock}
</div>
) : null}
{/* One column from md up (the ring, its meta lines under it,
trailing edge), as before. Below md the column dissolves
{/* One column from md up: the ring, its meta lines centred
under it. Below md the column dissolves
(`contents`) into two grid items: the dial in the top-right
cell beside the lead, and the meta lines on a full-width row
right after the read row, flush with the dial's edge: under
@@ -518,7 +518,7 @@ export function TodayHero({
<div
data-slot="today-hero-score-column"
className={cn(
"contents md:col-start-2 md:row-start-1 md:flex md:shrink-0 md:flex-col md:items-end md:gap-1 md:self-start md:text-right",
"contents md:col-start-2 md:row-start-1 md:flex md:shrink-0 md:flex-col md:items-center md:gap-1 md:self-start md:text-center",
leadBlock && todayBlock && "md:row-span-2",
)}
>
@@ -39,7 +39,10 @@ import {
// populated medications list; per-test mockReturnValue calls override.
const mockUseQuery = vi.fn();
const mockInvalidateQueries = vi.fn().mockResolvedValue(undefined);
const mockQueryClient = { invalidateQueries: mockInvalidateQueries };
const mockQueryClient = {
invalidateQueries: mockInvalidateQueries,
getQueryCache: () => ({ findAll: () => [] }),
};
vi.mock("@tanstack/react-query", () => ({
useQuery: (opts: unknown) => mockUseQuery(opts),
useQueryClient: () => mockQueryClient,
@@ -411,6 +414,7 @@ describe("submit invalidation — the quick-add is a capture-picker surface, not
const invalidateQueries = vi.fn().mockResolvedValue(undefined);
const queryClient = {
invalidateQueries,
getQueryCache: () => ({ findAll: () => [] }),
} as unknown as QueryClientType;
await invalidateMedicationReads(queryClient);
@@ -17,6 +17,7 @@ const WHOLE_NUMBER_TYPES: ReadonlySet<string> = new Set([
"ACTIVITY_STEPS",
"FLIGHTS_CLIMBED",
"ACTIVE_ENERGY_BURNED",
"BASAL_ENERGY_BURNED",
"TIME_IN_DAYLIGHT",
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
@@ -0,0 +1,157 @@
/**
* The upload queue's answer to the route's busy signal (429 `uploadBusy`,
* `Retry-After: 1`). Before this, the vault turned that one-second refusal
* into an error card that told the person to come back in a few minutes.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
busyRetryDelayMs,
parseRetryAfterSeconds,
UPLOAD_BUSY_BUDGET_MS,
UPLOAD_BUSY_MAX_ATTEMPTS,
uploadWithBusyRetry,
} from "../upload-busy-retry";
import type { UploadResult } from "../vault-utils";
const busy = (retryAfterSeconds?: number): UploadResult => ({
ok: false,
reason: "uploadBusy",
retryAfterSeconds,
});
const stored = {
ok: true,
duplicate: false,
document: { id: "doc-1" },
} as unknown as UploadResult;
const noJitter = () => 0;
describe("parseRetryAfterSeconds", () => {
it("reads whole seconds", () => {
expect(parseRetryAfterSeconds("1")).toBe(1);
expect(parseRetryAfterSeconds(" 12 ")).toBe(12);
});
it("ignores an absent, dated or malformed header", () => {
expect(parseRetryAfterSeconds(null)).toBeUndefined();
expect(parseRetryAfterSeconds("")).toBeUndefined();
expect(
parseRetryAfterSeconds("Wed, 21 Oct 2026 07:28:00 GMT"),
).toBeUndefined();
expect(parseRetryAfterSeconds("-3")).toBeUndefined();
expect(parseRetryAfterSeconds("1.5")).toBeUndefined();
});
});
describe("busyRetryDelayMs", () => {
it("waits Retry-After, clamped to 1 to 10 seconds", () => {
expect(busyRetryDelayMs(1, noJitter)).toBe(1000);
expect(busyRetryDelayMs(0, noJitter)).toBe(1000);
expect(busyRetryDelayMs(undefined, noJitter)).toBe(1000);
expect(busyRetryDelayMs(4, noJitter)).toBe(4000);
expect(busyRetryDelayMs(3600, noJitter)).toBe(10_000);
});
it("adds under half a second of jitter", () => {
expect(busyRetryDelayMs(1, () => 0.999)).toBe(1499);
});
});
describe("uploadWithBusyRetry", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("retries a busy answer after Retry-After and returns the success", async () => {
const attempt = vi
.fn<() => Promise<UploadResult>>()
.mockResolvedValueOnce(busy(1))
.mockResolvedValueOnce(stored);
const onRetry = vi.fn();
const pending = uploadWithBusyRetry(attempt, { random: noJitter, onRetry });
await vi.advanceTimersByTimeAsync(999);
expect(attempt).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1);
await expect(pending).resolves.toBe(stored);
expect(attempt).toHaveBeenCalledTimes(2);
expect(onRetry).toHaveBeenCalledTimes(1);
});
it("gives up after the attempt cap and hands back the busy result", async () => {
const attempt = vi
.fn<() => Promise<UploadResult>>()
.mockResolvedValue(busy(1));
const pending = uploadWithBusyRetry(attempt, { random: noJitter });
await vi.advanceTimersByTimeAsync(UPLOAD_BUSY_BUDGET_MS);
await expect(pending).resolves.toMatchObject({ reason: "uploadBusy" });
expect(attempt).toHaveBeenCalledTimes(UPLOAD_BUSY_MAX_ATTEMPTS);
});
it("stops before a wait that would overrun the time budget", async () => {
// 10 s per wait (clamped): attempts at 0, 10 and 20 s; a third wait
// would end at 30 s plus jitter, past the budget.
const attempt = vi
.fn<() => Promise<UploadResult>>()
.mockResolvedValue(busy(60));
const pending = uploadWithBusyRetry(attempt, { random: () => 0.5 });
await vi.advanceTimersByTimeAsync(UPLOAD_BUSY_BUDGET_MS * 2);
await expect(pending).resolves.toMatchObject({ reason: "uploadBusy" });
expect(attempt).toHaveBeenCalledTimes(3);
});
it("never retries any other failure, the hourly limit included", async () => {
for (const failure of [
{ ok: false, reason: "rateLimited" },
{ ok: false, reason: "generic" },
{ ok: false, reason: "fileTooLarge" },
] as UploadResult[]) {
const attempt = vi
.fn<() => Promise<UploadResult>>()
.mockResolvedValue(failure);
await expect(uploadWithBusyRetry(attempt)).resolves.toBe(failure);
expect(attempt).toHaveBeenCalledTimes(1);
}
});
it("an abort during the wait clears the timer and sends nothing more", async () => {
const attempt = vi
.fn<() => Promise<UploadResult>>()
.mockResolvedValue(busy(5));
const controller = new AbortController();
const pending = uploadWithBusyRetry(attempt, {
signal: controller.signal,
random: noJitter,
});
await vi.advanceTimersByTimeAsync(1000);
controller.abort();
await expect(pending).resolves.toMatchObject({ reason: "uploadBusy" });
expect(vi.getTimerCount()).toBe(0);
await vi.advanceTimersByTimeAsync(UPLOAD_BUSY_BUDGET_MS);
expect(attempt).toHaveBeenCalledTimes(1);
});
it("an already-aborted signal makes no second attempt", async () => {
const attempt = vi
.fn<() => Promise<UploadResult>>()
.mockResolvedValue(busy(1));
const controller = new AbortController();
controller.abort();
await expect(
uploadWithBusyRetry(attempt, { signal: controller.signal }),
).resolves.toMatchObject({ reason: "uploadBusy" });
expect(attempt).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,58 @@
/**
* The vault's XHR transport carries the busy refusal's `Retry-After` into
* the upload result, which is what the queue's retry waits on. Without the
* header the retry still happens, but at the floor delay instead of the one
* the server named.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { uploadViaXhr } from "../use-document-upload";
class BusyXhr {
status = 0;
responseText = "";
upload = { addEventListener: () => {} };
private listeners = new Map<string, () => void>();
open(): void {}
setRequestHeader(): void {}
getResponseHeader(name: string): string | null {
return name.toLowerCase() === "retry-after" ? "3" : null;
}
addEventListener(type: string, listener: () => void): void {
this.listeners.set(type, listener);
}
send(): void {
this.status = 429;
this.responseText = JSON.stringify({
data: null,
error: "Too many uploads are already in progress. Try again shortly.",
meta: { errorCode: "documents.inbound.uploadBusy", reason: "uploadBusy" },
});
this.listeners.get("load")?.();
}
}
beforeEach(() => {
vi.stubGlobal("XMLHttpRequest", BusyXhr);
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("uploadViaXhr on a busy server", () => {
it("reports uploadBusy with the server's Retry-After", async () => {
const file = new File([new Uint8Array([1])], "scan.pdf", {
type: "application/pdf",
});
await expect(uploadViaXhr(file, {}, () => {})).resolves.toEqual({
ok: false,
reason: "uploadBusy",
retryAfterSeconds: 3,
});
});
});
@@ -69,6 +69,10 @@ class FakeXhr {
this.request.headers[name] = value;
}
getResponseHeader(): string | null {
return null;
}
addEventListener(type: string, listener: () => void): void {
this.listeners.set(type, listener);
}
@@ -304,6 +304,27 @@ describe("upload response contract (§3.2)", () => {
});
expect(parseUploadResponse(500, "")).toMatchObject({ reason: "generic" });
});
it("tells the busy refusal apart from the hourly limit and keeps its Retry-After", () => {
const busyBody = JSON.stringify({
data: null,
error: "Too many uploads are already in progress.",
meta: { errorCode: "documents.inbound.uploadBusy", reason: "uploadBusy" },
});
expect(parseUploadResponse(429, busyBody, "1")).toEqual({
ok: false,
reason: "uploadBusy",
retryAfterSeconds: 1,
});
const limitBody = JSON.stringify({
data: null,
error: "Too many uploads. Try again later.",
meta: { errorCode: "documents.inbound.rateLimited" },
});
expect(parseUploadResponse(429, limitBody, "1800")).toMatchObject({
reason: "rateLimited",
});
});
});
describe("shift-click range selection", () => {
Loaded 100 of 349 files, more files were not shown because too many files have changed in this diff. Show more