mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
349 files changed
+19780
-2703
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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 ({
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Generated
+11
-10
@@ -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
@@ -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$;
|
||||
@@ -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
@@ -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
@@ -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":
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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.
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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" },
|
||||
});
|
||||
|
||||
@@ -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 () => []) },
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user