Compare commits

...
14 Commits
Author SHA1 Message Date
Marc-André Bombeck be7a65bdb9 ci(trivy): report only fixable findings from the published-image scan 2026-11-06 20:19:51 +01:00
Marc-André Bombeck a74d2df3c5 docs(changelog): keep the feed's 404 inside its sentence 2026-11-06 20:05:04 +01:00
Marc-André Bombeck 2522a784f9 docs(changelog): lab markers grouped by category and the cycle calendar subscription 2026-11-06 20:04:42 +01:00
Marc-André Bombeck 3b3ef5c0a1 fix(cycle): keep the calendar feed to the owner and a managed profile's guardian
A calendar feed is a standing credential whose contents end up in a
third party's calendar cloud. Putting a record's cycle dates there is
the decision of the person they are about, or of the guardian of
somebody who cannot make it, and no delegate of an adult's record,
MANAGE included.

The subscription routes resolve through requireGuardianAuth instead of
requireRecordAuth("manage", "cycle"): the owner on their own record, or
a MANAGE grant on a record carrying the managed-profile marker. Every
verb follows, the revoke too. The feed re-reads the marker on each fetch
as well as the guardian's grant, so a row minted for somebody else on an
adult's record is a 404 whatever grant stands behind it. The settings
card is offered only where the routes answer.

The routes move from the delegable and MANAGE literals to the guardian
list, and the MANAGE handler inventory is back to 93.
2026-11-06 18:03:37 +01:00
Marc-André Bombeck e8ef590af7 docs: describe the cycle calendar subscription
A how-to for subscribing from Google, Apple and Outlook and what the
feed shows; a section on the reproductive-health page saying that once
it is on, the dates also sit with the calendar provider and turning it
off does not reach those copies; a reverse-proxy note with Caddy and
Nginx examples for keeping path credentials out of access logs; and
API_TOKEN_HMAC_KEY rotation named as ending every feed link.
2026-11-06 18:03:37 +01:00
Marc-André Bombeck d9a31524a2 feat(cycle): serve the cycle forecast as a subscribable calendar
GET /api/cycle/feed/<hlf_token>[.ics] answers any calendar app with the
record's forecast as RFC 5545: all-day events for the next few periods
over their widening windows, optionally past periods, and for a
conception goal that asks for it the next fertile window. Dates and a
title only; discreet titles and the calendar name "HealthLog" by default.
The body is built live on every fetch in the record's own zone and
language, never the request's. ETag is the content hash and a matching
If-None-Match answers 304; DTSTAMP moves only when the content does.

The feed is anonymous, so every gate is read per fetch: the auth-surface
limit before any lookup, the token's shape, the HMAC lookup of a live
feed, a per-token bucket, then the record and carrier not pending
deletion, the operator's external-access switch, cycle tracking for the
record, and for a feed a delegate minted, a live MANAGE grant covering
cycle. Every miss is the same 404.

/api/cycle/feed-subscription (status, turn on, options, turn off) and
its rotate are cookie-only through requireRecordAuth("manage", "cycle"),
with a fresh proof on turning on and rotating and a per-actor mint
bucket. The URL is returned once; the status carries the prefix, the
options, the fetch statistics as a calendar-app family and the
projection state.

The hlf_ family is registered with the path and token redactors, the
secret-shape refusal, the proxy's public paths and edge headers, the
URL-credential, sharing, recent-proof, step-up and module-gate guards,
the error-code catalogue, the activity feed and the destructive-control
registry. The cycle settings tab gains the subscription card and the
predictions panel lists the later cycles.
2026-11-06 18:03:37 +01:00
Marc-André Bombeck ac31ac40b5 feat(cycle): project the cycles after the next one
The calendar read, the Coach and MCP cycle block and the coming calendar
feed now share one loader for the forecast's inputs (cycles, day logs,
overnight wrist temperatures) and one gate (`gatedPrediction`: no
forecast while prediction is off or raw-chart mode is on). The Coach
block reads the calendar's default day-log window, so its next period
is the date the calendar page shows, and resolves the luteal length
through the engine's own clamp.

`projectCycles` extends the engine's next-period forecast a few cycles
ahead. Cycle 1 is the engine's result unchanged; later starts are
anchored on it from the fractional median length, so six cycles out
carry no rounding drift; later bands widen as the SD of a sum of k
lengths with an estimated mean, with the engine's sparsity penalty, and
never narrow. The list stops where a band passes ten days or half a
cycle, holds the next period only while the engine is still learning or
for perimenopause, and is empty once the next period's band has passed
with nothing logged.

`/api/cycle/calendar` publishes it additively as `projection` and
`projectionState`; the Coach block carries it as `upcoming`.
2026-11-06 18:03:27 +01:00
Marc-André Bombeck 2480a0138d feat(cycle): add the calendar-feed credential table
A record's cycle calendar subscription is a URL credential: one row per
feed, only the HMAC of the raw hlf_ token stored, and at most one live
feed per record through a partial unique index on user_id where
revoked_at is null. Both foreign keys cascade, so deleting the record
or the carrier who minted the feed ends it.

The row is a credential everywhere else too. It stays out of the
backup (a restore would bring back a revoked link), Delete All Data
takes it on both sides, a credential remediation revokes the feeds on
the record and the ones the account minted elsewhere, and a
managed-profile handover claim revokes any feed a guardian minted.

backupVerdict moves beside the plan into its own module so the plan
stays the lists and their reasons.
2026-11-06 18:03:27 +01:00
Marc-André Bombeck c0470df14c test(labs): tie the category fields to their readers
A per-envelope guard walks the category leaves of LabOverviewResponse,
customPanels on ListBiomarkersResponse and adoptedPanel on
BiomarkerMergeResult out of the Zod schemas and ties each to the web
reader that renders it, so a new leaf without a reader fails. The
resolved-field guard gains a rule that bans the category ingredients
(catalogKeyForName, resolveMarkerCategory, summariseCategory,
categoriseOverview, ALIAS_PANEL, buildLocalisedLabelIndex) from client
code: the category, its order and its status are read, never rebuilt.

Refs #1349
2026-11-06 17:57:28 +01:00
Marc-André Bombeck c96e0e0f78 chore(demo): seed lab readings without a draw label as their panel
A draw label such as "Annual blood panel" is a panel the labs list
would show as one category holding every marker, so the demo list
would stay flat with categories turned on. Without a panel each marker
lands in its catalog category by name, both before and after the boot
backfill links the rows.
2026-11-06 17:54:02 +01:00
Marc-André Bombeck 65f1a3db91 feat(labs): keep a marker's panel when it is merged into one without
A merge into a target without a panel now takes over the first panel
a source carries, in the order the sources were given, so the merged
marker keeps its category. The result reports it as adoptedPanel, on
a dry run as well, and the merge preview says so before the person
confirms. A target with its own panel keeps it. The ledger does not
record panels; a merge has no undo.

The integration suite also proves the clear path end to end: an
explicit null on PUT clears panel, context and both bounds.

Refs #1349
2026-11-06 17:53:56 +01:00
Marc-André Bombeck 910f28dcfe feat(labs): suggest panels in the marker form and let an edit clear fields
The panel field offers the catalog panels in the page language and the
person's own panels, which GET /api/biomarkers now lists as
customPanels (one spelling per folded form). The field stays free
text; a catalog panel typed in any language lands in the same category.

An edit used to omit an emptied panel, context or bound, so the stored
value survived the save. The form now sends null for a field that held
a value and was emptied. A new marker still omits empty fields, and a
field that was empty when the edit began stays out of the body, so a
context the list could not decrypt is never wiped by an unrelated save.

Refs #1349
2026-11-06 17:51:34 +01:00
Marc-André Bombeck 92638ec089 feat(labs): show the labs list under server-resolved categories
GET /api/labs/overview now answers categories and grouped next to the
markers. A marker's category is its panel: a catalog panel in any
shipped language folds to one key, any other panel becomes the
person's own category under an opaque hash key, and a marker without
a panel falls back to the catalog panel of its name. ALIAS_PANEL
places the ten analytes that have names but no seed. Each category
carries its label in the caller's language, its members, counts per
range status and one combined status (above wins over below; no
alarm tone). grouped is true only when grouping adds something.

The list renders this as given: with groupBy set to category it shows
a disclosure header per category in both views, the person's sort
applies inside each category, and groupsOpen decides which start open.
A category opened or closed by hand stays so in this browser until the
choice changes. Settings > Arrangement > Labs gets both choices. The
overview query key carries the locale, since the labels are localised.

Refs #1349
2026-11-06 17:49:38 +01:00
Marc-André Bombeck e2170115c7 feat(settings): carry category choices in the labs list layout
The list layout gains two choices that only the labs list reads:
groupBy (none or category, default none) and groupsOpen (expanded,
flagged or collapsed, default expanded). The resolver keeps a valid
stored value and drops anything else to the default, the PUT merges
both field by field, and the hook exposes a setter for each. A
browser value from before the server column never held them, so the
legacy parse fills them from the defaults.
2026-11-06 17:44:38 +01:00
135 changed files with 10578 additions and 1315 deletions

No files matched your search

+9 -2
View File
@@ -142,6 +142,13 @@ jobs:
output: trivy-published-image.sarif
severity: "CRITICAL,HIGH,MEDIUM"
limit-severities-for-sarif: true
# Only findings with a published fix reach code scanning. Debian
# leaves many base-package advisories unfixed in stable (no-dsa,
# postponed), and nothing in this repository can act on those: the
# build's `apt-get upgrade` takes the fix the day Debian ships one,
# and that is when the alert should appear. Listing them all made
# the page a hundred-odd rows deep without one to act on.
ignore-unfixed: true
exit-code: "0"
- name: Upload results
@@ -162,6 +169,6 @@ jobs:
format: table
severity: "CRITICAL"
exit-code: "1"
# Same rule as the blocking gate in security.yml: a finding with a
# published fix fails, one without is in the SARIF report above.
# Same rule as the blocking gate in security.yml and the SARIF
# report above: only a finding with a published fix counts.
ignore-unfixed: true
+33
View File
@@ -32,6 +32,39 @@
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)
### Fixed
+2
View File
@@ -45,6 +45,8 @@ notes, and machine-readable specs that ship alongside the source:
code. [`using/search.md`](./using/search.md) covers the command palette's
record search: opening it, what it finds, how matching works, and its
limits on very large and shared records.
[`using/cycle-calendar-feed.md`](./using/cycle-calendar-feed.md) covers
subscribing to the cycle forecast from Google, Apple or Outlook.
- [`migration/`](./migration/) — migration notes. Currently only
v1.3 to v1.4; later releases are covered by `CHANGELOG.md` and
<https://docs.healthlog.dev>.
+1122 -557
View File
File diff suppressed because it is too large. Load diff
+7 -7
View File
@@ -72,11 +72,11 @@ echo "ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env
echo "API_TOKEN_HMAC_KEY=$(openssl rand -hex 32)" >> .env
```
| Variable | Purpose | Rotation cost |
| -------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| `POSTGRES_PASSWORD` | Bundled Postgres password — used by both the `db` service and `DATABASE_URL` | Database restart + connection-string update |
| `ENCRYPTION_KEY` | AES-256-GCM key for at-rest secrets (Withings tokens, AI provider keys, VAPID secrets) | Coordinated; see `docs/ops/encryption-key-rotation.md` |
| `API_TOKEN_HMAC_KEY` | HMAC-SHA256 key for hashing Bearer API tokens and web session cookies before storage | Invalidates every issued `hlk_*` token and signs out every browser session |
| Variable | Purpose | Rotation cost |
| -------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `POSTGRES_PASSWORD` | Bundled Postgres password — used by both the `db` service and `DATABASE_URL` | Database restart + connection-string update |
| `ENCRYPTION_KEY` | AES-256-GCM key for at-rest secrets (Withings tokens, AI provider keys, VAPID secrets) | Coordinated; see `docs/ops/encryption-key-rotation.md` |
| `API_TOKEN_HMAC_KEY` | HMAC-SHA256 key for hashing Bearer API tokens and web session cookies before storage | Invalidates every issued `hlk_*` token, share link and cycle calendar-feed link, and signs out every browser session |
You do **not** edit `DATABASE_URL` on the bundled `docker-compose.yml`
stack: compose constructs the connection string inside the `app`
@@ -289,8 +289,8 @@ this server needs:
retired id for as long as a stored or off-host backup copy still needs it;
- `BACKUP_ENCRYPTION_KEY` and any `BACKUP_ENCRYPTION_PREVIOUS_KEYS`, when
off-host copies are set up;
- `API_TOKEN_HMAC_KEY`, without which every session, token and share link ends
(no data is lost).
- `API_TOKEN_HMAC_KEY`, without which every session, token, share link and
calendar-feed link ends (no data is lost).
Each key is shown by id and a fingerprint (the first 12 hex characters of
SHA-256 over the key), never the key itself, and **Check my copy** compares a
@@ -121,6 +121,27 @@ the grace window to 0 or wait it out, remove the old volume, and let the
dumps age out or delete them
([backup-restore.md](../ops/backup-restore.md)).
## Calendar subscription
The cycle settings can publish your forecast as a calendar feed that Google
Calendar, Apple Calendar or Outlook subscribes to. It is off by default, and
turning it on changes the arrangement this page describes: from then on the
feed's dates also sit with that calendar provider, on its servers, under its
policies. That is exactly the cloud vendor self-hosting otherwise keeps out of
the picture.
What the feed carries is deliberately small. Each event is an all-day date
range and a title, nothing else: no symptoms, notes, flow, temperatures, test
results, contraception or intercourse fields. By default the title is a dot
and the calendar is named "HealthLog", so a glance at a shared screen says
nothing; past cycles are left out unless you ask for them, and the fertile
window is only offered when your goal is trying to conceive.
Turning the subscription off, or rotating its link, stops the provider's next
poll. It does not reach the copies the provider already fetched: remove the
subscribed calendar there as well. The link itself is a credential; see
[keeping it out of access logs](./reverse-proxy.md#credentials-in-the-url-path-keep-them-out-of-access-logs).
## Practical hardening
If reproductive-health privacy is the reason you self-host, the
+43
View File
@@ -336,6 +336,49 @@ A `TRUST_PROXY_HOPS` value that is not a whole number resolves no client
address at all (requests keep working) and is named in the `[proxy-trust]`
boot line.
## Credentials in the URL path: keep them out of access logs
A few HealthLog URLs carry their credential in the path, because the caller
cannot send a header: the cycle calendar feed (`/api/cycle/feed/hlf_…`,
polled by Google, Apple or Outlook), the clinician share view (`/c/hls_…`)
and the provider webhooks. HealthLog scrubs these segments from its own logs
and error reports. It cannot reach your proxy's access log, which records the
full path of every request by default, so anyone who can read that log can
read a live feed link.
Either drop those requests from the access log or rewrite the path before it
is written. For Caddy, skip logging the token-bearing paths:
```caddyfile
your-instance.example.com {
@credential_paths path /api/cycle/feed/* /c/*
log_skip @credential_paths
reverse_proxy app:3000
}
```
For Nginx, log a redacted path instead of `$request`:
```nginx
map $request_uri $redacted_uri {
~^/api/cycle/feed/ /api/cycle/feed/[REDACTED];
~^/c/ /c/[REDACTED];
default $request_uri;
}
log_format redacted '$remote_addr - [$time_local] "$request_method $redacted_uri" '
'$status $body_bytes_sent "$http_user_agent"';
access_log /var/log/nginx/access.log redacted;
```
Traefik's access log has no per-path filter; turn `accessLog` off, or keep it
on a host only you can read. If a feed link has been in a log you do not
control, rotate it in the app (Cycle, Settings, Calendar subscription, Rotate
link): the old one stops working on the provider's next poll.
The feed answers with `Cache-Control: private, no-cache` and an `ETag`, so a
caching proxy in front must pass `If-None-Match` through rather than cache the
body for everyone.
## Running over plain HTTP on a LAN (no reverse proxy)
The session cookie carries the `Secure` flag by default under
+55
View File
@@ -0,0 +1,55 @@
# Subscribing to your cycle forecast
HealthLog can publish your cycle forecast as a calendar subscription, so the
next few periods show up in the calendar app you already use. The feed is
computed fresh every time your calendar app asks for it; HealthLog stays the
only place the data is kept.
## Turning it on
Open **Cycle**, then **Settings**, and find **Calendar subscription**. Before
the button, the card says what it means: your calendar provider stores the
dates on its servers, and turning the subscription off later does not remove
copies it already holds. HealthLog asks you to confirm it is you (a password or
passkey) and then shows the link once. Copy it, or use **Open in calendar app**.
Only you can turn it on for your own record. For a managed profile (a child's
record, for instance), its guardian can. Someone you have shared your record
with cannot, whatever level of access you gave them: the feed puts your dates
in their calendar provider's cloud, and that is your decision alone. It is
managed on the web; the iOS app shows where to find it.
## Adding it to your calendar
- **Google Calendar:** Other calendars, From URL, paste the link. Google
refreshes subscriptions on its own schedule, roughly every 12 to 24 hours.
- **Apple Calendar:** on a Mac, File, New Calendar Subscription. On iPhone,
Settings, Calendar, Accounts, Add Account, Other, Add Subscribed Calendar.
Apple lets you choose how often it refreshes.
- **Outlook:** Add calendar, Subscribe from web, paste the link. Outlook
refreshes every few hours.
## What the feed shows
- The next period and, once three cycles are logged, a few after it (three
by default, up to six). Each event covers the whole window the period may
fall in, and the window widens the further out it is. The list stops itself
where a date would be more guess than estimate (beyond about ±10 days, or
half a cycle).
- Optionally, your past cycles (up to twelve).
- Optionally, when your goal is trying to conceive, the estimated fertile
window of the next cycle. It is an estimate, not a contraceptive method.
Titles are discreet by default: each event is a dot and the calendar is called
"HealthLog". Descriptive titles name the period instead. Events never carry
symptoms, notes or any other detail of your log.
When a period is later than its window, the feed shows no forecast until you
log it, instead of guessing a new date. With predictions switched off, only
past cycles appear (if you chose to include them).
## Rotating and turning off
**Rotate link** replaces the link; calendars using the old one stop updating.
**Turn off** ends the link. In both cases, remove the old subscribed calendar
in your calendar app too: what it already fetched stays there until you do.
+104
View File
@@ -0,0 +1,104 @@
import AxeBuilder from "@axe-core/playwright";
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup";
import {
expectNoHorizontalOverflow,
settleForOverflowMeasurement,
} from "./utils/horizontal-overflow";
/**
* The labs list under its categories.
*
* The account is the one `globalSetup` seeds for the mobile route sweep: one
* marker under its own panel and one qualitative reading without a panel, so
* the server answers two categories and `grouped: true`. The grouping choice
* is served to this page only (the layout read is answered in the browser
* with the account's own layout plus `groupBy: "category"`), so the shared
* account's stored preferences stay as other specs expect them.
*/
const LAYOUT = "**/api/settings/module-list-layout";
const GROUP = '[data-slot="lab-list-group"]';
async function serveGrouped(page: Page, groupsOpen = "expanded") {
await page.route(LAYOUT, async (route) => {
if (route.request().method() !== "GET") return route.continue();
const response = await route.fetch();
const body = (await response.json()) as {
data: { lists: { labs: Record<string, unknown> } };
};
body.data.lists.labs = {
...body.data.lists.labs,
groupBy: "category",
groupsOpen,
saved: true,
};
await route.fulfill({ response, json: body });
});
}
test.describe("the lab list under its categories", () => {
test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH });
test.beforeEach(async ({ context }, testInfo) => {
const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000";
await context.addCookies([
{ name: "healthlog-locale", value: "de", url: baseURL },
]);
});
test("shows one disclosure per category and folds a category away", async ({
page,
}) => {
await serveGrouped(page);
await page.goto("/labs", { waitUntil: "domcontentloaded" });
const headers = page.locator(GROUP);
await expect(headers).toHaveCount(2);
await expect(headers.first()).toHaveAttribute("aria-expanded", "true");
const names = page.locator('[data-slot="lab-list-analyte"]');
const before = await names.count();
expect(before).toBeGreaterThan(1);
await headers.first().click();
await expect(headers.first()).toHaveAttribute("aria-expanded", "false");
await expect(names).toHaveCount(before - 1);
// The choice holds across a reload in this browser.
await page.reload({ waitUntil: "domcontentloaded" });
await expect(page.locator(GROUP).first()).toHaveAttribute(
"aria-expanded",
"false",
);
});
test("starts with every category closed when the person chose so", async ({
page,
}) => {
await serveGrouped(page, "collapsed");
await page.goto("/labs", { waitUntil: "domcontentloaded" });
const headers = page.locator(GROUP);
await expect(headers).toHaveCount(2);
for (const header of await headers.all()) {
await expect(header).toHaveAttribute("aria-expanded", "false");
}
await expect(page.locator('[data-slot="lab-list-analyte"]')).toHaveCount(0);
});
test("fits a phone and passes axe", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await serveGrouped(page);
await page.goto("/labs", { waitUntil: "domcontentloaded" });
await expect(page.locator(GROUP)).toHaveCount(2);
await settleForOverflowMeasurement(page);
await expectNoHorizontalOverflow(page, "/labs grouped at 390 px");
const results = await new AxeBuilder({ page })
.include('[data-slot="lab-list"]')
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
expect(results.violations.map((v) => v.id)).toEqual([]);
});
});
+88 -2
View File
@@ -10026,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",
@@ -10322,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",
@@ -10505,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}",
@@ -10551,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",
@@ -13163,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": {
+88 -2
View File
@@ -10026,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",
@@ -10322,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",
@@ -10505,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}",
@@ -10551,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",
@@ -13163,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": {
+88 -2
View File
@@ -10026,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",
@@ -10322,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",
@@ -10505,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}",
@@ -10551,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",
@@ -13163,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": {
+88 -2
View File
@@ -10026,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",
@@ -10322,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",
@@ -10505,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}",
@@ -10551,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",
@@ -13163,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": {
+88 -2
View File
@@ -10026,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",
@@ -10322,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",
@@ -10505,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}",
@@ -10551,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",
@@ -13163,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": {
+88 -2
View File
@@ -10026,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": "생리가 곧 시작될 것 같아요",
@@ -10322,6 +10385,7 @@
"unitLockedHint": "이 마커에 기록된 값이 있으면 단위는 그대로 유지돼요. 그렇지 않으면 저장된 값이 변환되지 않은 채 새 단위로 표시돼요.",
"panel": "패널",
"panelPlaceholder": "예: 지질",
"panelHint": "제안에서 고르거나 직접 입력하세요. 카탈로그 패널은 모든 언어에서 인식돼요.",
"lowerBound": "참고 하한",
"upperBound": "참고 상한",
"lowerPlaceholder": "하한값",
@@ -10505,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}",
@@ -10551,6 +10632,7 @@
"convertedOther": "측정값 {count}개, {unit}에서 {targetUnit}(으)로 환산",
"refused": "{unit}은(는) {targetUnit}(으)로 환산할 수 없어요.",
"aliasesAdded": "이 이름들은 남긴 바이오마커로 연결돼요:",
"adoptsPanel": "남긴 바이오마커가 {panel} 패널을 이어받아요.",
"blocked": "환산할 수 없는 단위가 있으면 아무것도 합치지 않아요. 그 바이오마커를 빼거나 다른 항목을 남겨 주세요.",
"noUndo": "합친 뒤에는 되돌릴 수 없어요.",
"doneToastOne": "{name}(으)로 합쳤어요, 측정값 {count}개 이동",
@@ -13163,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": {
+88 -2
View File
@@ -10026,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",
@@ -10322,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",
@@ -10505,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}",
@@ -10551,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",
@@ -13163,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": {
+88 -2
View File
@@ -10026,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",
@@ -10322,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",
@@ -10505,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}",
@@ -10551,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",
@@ -13163,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": {
+88 -2
View File
@@ -10026,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",
@@ -10322,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",
@@ -10505,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}",
@@ -10551,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",
@@ -13163,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": {
+88 -2
View File
@@ -10026,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": "Скоро вероятная менструация",
@@ -10322,6 +10385,7 @@
"unitLockedHint": "Когда у показателя есть измерения, его единица остаётся прежней. Иначе сохранённые значения показывались бы в новой единице без пересчёта.",
"panel": "Панель",
"panelPlaceholder": "например Липиды",
"panelHint": "Выберите подсказку или введите свою. Панели из каталога распознаются на любом языке.",
"lowerBound": "Нижняя граница",
"upperBound": "Верхняя граница",
"lowerPlaceholder": "Нижняя граница",
@@ -10505,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}",
@@ -10551,6 +10632,7 @@
"convertedOther": "{count} измерений, пересчитаны из {unit} в {targetUnit}",
"refused": "{unit} нельзя пересчитать в {targetUnit}.",
"aliasesAdded": "Эти имена будут вести к оставленному показателю:",
"adoptsPanel": "Оставленный показатель получает панель {panel}.",
"blocked": "Объединить нельзя, пока единицу не удаётся пересчитать. Исключите этот показатель или оставьте другой.",
"noUndo": "Объединение нельзя отменить.",
"doneToastOne": "Объединено в {name}, перенесено измерений: {count}",
@@ -13163,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": {
@@ -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$;
+76
View File
@@ -1166,6 +1166,12 @@ model User {
// v1.15.0 — owner of a per-user custom CycleSymptom (NULL = the global
// seeded catalogue).
cycleSymptomsCustom CycleSymptom[] @relation("UserCycleSymptoms")
// #1350 — the subscribable cycle forecast. `cycleCalendarFeeds` is the
// record the feed serves; `cycleCalendarFeedsMinted` is the carrier who
// minted it, which is the owner on their own record and the guardian on a
// managed profile.
cycleCalendarFeeds CycleCalendarFeed[] @relation("CycleCalendarFeedSubject")
cycleCalendarFeedsMinted CycleCalendarFeed[] @relation("CycleCalendarFeedCreator")
// v1.39 — the needs-based onboarding's answers and step ledger for THIS
// record. 1:1, and keyed by record rather than by the person at the
@@ -8328,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
+17 -6
View File
@@ -1824,7 +1824,11 @@ export async function seed() {
{ analyte: "Ferritin", value: 88, unit: "ng/mL", low: 30, high: 400 },
];
const insertQuant = async (panel: string, lab: QuantLab, takenAt: Date) => {
const insertQuant = async (
panel: string | null,
lab: QuantLab,
takenAt: Date,
) => {
await client.query(
`INSERT INTO lab_results (id, user_id, panel, analyte, value, value_text, unit, reference_low, reference_high, source_value, source_unit, taken_at, source, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, NULL, $6, $7, $8, $9, $10, $11, 'MANUAL', NOW(), NOW())`,
@@ -1843,7 +1847,11 @@ export async function seed() {
],
);
};
const insertQual = async (panel: string, lab: QualLab, takenAt: Date) => {
const insertQual = async (
panel: string | null,
lab: QualLab,
takenAt: Date,
) => {
await client.query(
`INSERT INTO lab_results (id, user_id, panel, analyte, value, value_text, unit, reference_low, reference_high, taken_at, source, created_at, updated_at)
VALUES ($1, $2, $3, $4, NULL, $5, $6, NULL, NULL, $7, 'MANUAL', NOW(), NOW())`,
@@ -1851,14 +1859,17 @@ export async function seed() {
);
};
// No panel on the readings: a draw label ("Annual blood panel") would
// become one category holding every marker, and the labs list would stay
// flat. Without one each marker lands in its catalog category by name.
for (const lab of recentQuant) {
await insertQuant("Annual blood panel", lab, recentPanelAt);
await insertQuant(null, lab, recentPanelAt);
}
for (const lab of recentQual) {
await insertQual("Annual blood panel", lab, recentPanelAt);
await insertQual(null, lab, recentPanelAt);
}
for (const lab of olderQuant) {
await insertQuant("Blood panel (last year)", lab, olderPanelAt);
await insertQuant(null, lab, olderPanelAt);
}
const labResults = [...recentQuant, ...recentQual, ...olderQuant];
@@ -2901,7 +2912,7 @@ export async function seed() {
for (const panel of historyPanels) {
for (const [key, value] of Object.entries(panel.values)) {
await insertQuant(
"Blood panel",
null,
{ ...panelLabs[key], value },
daysAgoAt(panel.n, 9, 0),
);
@@ -9,8 +9,8 @@ import {
DERIVED_MODELS,
NOT_IN_BACKUP_MODELS,
RAW_SQL_TABLES,
backupVerdict,
} from "@/lib/export/backup-plan";
import { backupVerdict } from "@/lib/export/backup-verdict";
/**
* Every model in the schema has a written verdict about the backup.
@@ -1,13 +1,13 @@
{
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 84,
"total": 94064,
"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,
@@ -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": 865
"proxy.ts": 846
}
}
@@ -1,7 +1,7 @@
{
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 345,
"total": 111889,
"count": 344,
"total": 111520,
"entries": {
"app/achievements/page.tsx :: AchievementsPage": 206,
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275,
@@ -12,7 +12,6 @@
"app/api/auth/oidc/callback/route.ts :: apiHandler(arg)": 258,
"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,
@@ -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,14 +129,13 @@
"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": 378,
"components/layout/auth-shell.tsx :: AuthShell": 518,
@@ -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,
@@ -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": 493
"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 ─────────────────────────────────────────────────────────
{
@@ -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":
@@ -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);
});
});
@@ -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",
+10 -1
View File
@@ -2154,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.",
};
/**
@@ -2295,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 = 276;
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:
+4
View File
@@ -15,6 +15,7 @@ import { aliasNamesByMarker } from "@/lib/labs/biomarker-aliases";
import { serialiseBiomarker } from "@/lib/labs/biomarker-dto";
import { encryptContextToBytes } from "@/lib/labs/biomarker-store";
import { matchAnalyteKey } from "@/lib/labs/analyte-match";
import { customPanelSuggestions } from "@/lib/labs/marker-category";
import { annotate } from "@/lib/logging/context";
import { createBiomarkerSchema } from "@/lib/validations/biomarkers";
@@ -64,6 +65,9 @@ export const GET = apiHandler(async () => {
biomarkers: rows.map((row) =>
serialiseBiomarker(row, aliases.get(row.id) ?? [], { soft: true }),
),
// The person's own panel spellings, for the panel suggestions in the
// marker form; catalog panels are offered there from the bundle.
customPanels: customPanelSuggestions(rows.map((row) => row.panel)),
});
});
@@ -167,6 +167,52 @@ describe("GET /api/cycle/calendar is a read", () => {
});
});
describe("GET /api/cycle/calendar carries the projection (#1350)", () => {
it("publishes the projection whose first row is the prediction", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-10T12:00:00Z"));
const { status, body } = await callCalendar();
expect(status).toBe(200);
const data = body.data as {
prediction: { nextPeriodStart: string; nextPeriodStartLow: string };
projection: Array<Record<string, unknown>>;
projectionState: string;
};
expect(data.projectionState).not.toBe("OFF");
expect(data.projection.length).toBeGreaterThan(0);
{
expect(data.projection[0]).toMatchObject({
k: 1,
start: data.prediction.nextPeriodStart,
bandStart: data.prediction.nextPeriodStartLow,
});
// Dates and bands only: the fertile window stays in `prediction`.
expect(Object.keys(data.projection[0]).sort()).toEqual([
"bandEnd",
"bandStart",
"k",
"periodLength",
"start",
]);
}
});
it("publishes an empty projection, state OFF, with prediction switched off", async () => {
// The mock answers with one object; switching it off there switches it
// off for the route's own call.
const gate = await requireCycleEnabled("user-1", null);
if (gate.enabled) gate.profile.predictionEnabled = false;
const { body } = await callCalendar();
const data = body.data as {
projection: unknown[];
projectionState: string;
};
expect(data.projection).toEqual([]);
expect(data.projectionState).toBe("OFF");
});
});
describe("GET /api/cycle/calendar carries the resolved verdict", () => {
it("puts the whole verdict on the wire", async () => {
// 2026-06-10 12:00 UTC is already 2026-06-11 for a user at UTC+14.
+38 -103
View File
@@ -15,7 +15,6 @@
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import {
@@ -28,24 +27,27 @@ import { requireCycleEnabled } from "@/lib/cycle/gate";
import { cycleCalendarQuerySchema } from "@/lib/validations/cycle";
import {
buildCalendar,
type CalendarDayLogRow,
toCycleInputs,
toDayLogInputs,
} from "@/lib/cycle/engine-adapter";
import {
CALENDAR_DEFAULT_PAST_DAYS,
dayLogWindowStart,
loadCalendarInputs,
} from "@/lib/cycle/load-inputs";
import { PROJECTION_MAX_HORIZON, projectCycles } from "@/lib/cycle/projection";
import {
toCyclePredictionDTO,
goalAllowsFertileWindow,
cycleDisclaimerKey,
resolveSensitiveFields,
} from "@/lib/cycle/dto";
import { resolveCycleVerdict } from "@/lib/cycle/verdict";
import { addDays, dayDiff } from "@/lib/cycle/day-math";
import { BBT_WINDOW } from "@/lib/cycle/types";
import { addDays } from "@/lib/cycle/day-math";
import { moodDateKey } from "@/lib/mood/date-key";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { startOfLocalDayKey } from "@/lib/tz/local-day";
const DEFAULT_PAST_DAYS = 90;
const DEFAULT_FORWARD_DAYS = 180;
/** Hard cap on the rendered span (days) to bound the day-grid build. */
const MAX_SPAN_DAYS = 400;
@@ -75,7 +77,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
const tz = user.timezone ?? DEFAULT_TIMEZONE;
const today = moodDateKey(new Date(), tz);
const from = parsed.data.from ?? addDays(today, -DEFAULT_PAST_DAYS);
const from = parsed.data.from ?? addDays(today, -CALENDAR_DEFAULT_PAST_DAYS);
const to = parsed.data.to ?? addDays(today, DEFAULT_FORWARD_DAYS);
// Reject an inverted or absurdly wide range up front.
@@ -87,103 +89,19 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
}
const [cycles, dayLogRows, nightlyTemps] = await Promise.all([
prisma.menstrualCycle.findMany({
where: { userId: user.id, deletedAt: null },
orderBy: { startDate: "asc" },
}),
// Bound the day-log read to the rendered span plus the symptothermal
// lookback — the earlier of `from` and (today − BBT_WINDOW). Cycle-length
// stats run off MenstrualCycle rows, so day-logs can be windowed (QA: perf
// — unbounded full-history read on a hot, per-navigation route).
prisma.cycleDayLog.findMany({
where: {
userId: user.id,
deletedAt: null,
date: {
gte:
dayDiff(from, addDays(today, -BBT_WINDOW)) <= 0
? from
: addDays(today, -BBT_WINDOW),
},
},
orderBy: { date: "asc" },
select: {
date: true,
flow: true,
basalBodyTempC: true,
temperatureExcluded: true,
ovulationTest: true,
cervicalMucus: true,
cervixPosition: true,
cervixFirmness: true,
cervixOpening: true,
intermenstrualBleeding: true,
// The intent fields and their encrypted envelope, resolved below
// through the same reader the day-log route uses. Same `cycle` grant
// as that route (see sharing-surface-guard), so the grid shows a
// delegate nothing the day log would not.
sexualActivity: true,
protectedSex: true,
pregnancyTest: true,
progesteroneTest: true,
contraceptive: true,
sensitiveEncrypted: true,
// Selected for presence only; the text is never decrypted here.
notesEncrypted: true,
_count: { select: { symptomLinks: true } },
},
}),
// Apple Watch wrist/skin temperature feeds the temperature-trend
// ovulation layer. Read the WRIST_TEMPERATURE measurements as nightly
// values; the engine derives the trailing-mean deviation itself.
//
// This is a measurements read on a route declared under `cycle`, and it
// needs no section predicate for one reason: no value from it reaches the
// response. It goes into `predictCycle` and what comes out the other side
// is a shifted date. The crosstab next door reads the same channel and IS
// fenced, because there the values are the answer.
prisma.measurement.findMany({
where: {
userId: user.id,
deletedAt: null,
type: "WRIST_TEMPERATURE",
measuredAt: {
gte: startOfLocalDayKey(addDays(today, -90), tz),
},
},
orderBy: { measuredAt: "asc" },
select: { measuredAt: true, value: true },
}),
]);
const dayLogs: CalendarDayLogRow[] = dayLogRows.map((l) => {
const sensitive = resolveSensitiveFields(l);
return {
date: l.date,
flow: l.flow,
basalBodyTempC: l.basalBodyTempC,
temperatureExcluded: l.temperatureExcluded,
ovulationTest: l.ovulationTest,
cervicalMucus: l.cervicalMucus,
cervixPosition: l.cervixPosition,
cervixFirmness: l.cervixFirmness,
cervixOpening: l.cervixOpening,
intermenstrualBleeding: l.intermenstrualBleeding,
sexualActivity: sensitive.sexualActivity,
pregnancyTest: sensitive.pregnancyTest,
progesteroneTest: sensitive.progesteroneTest,
contraceptive: sensitive.contraceptive,
hasSymptoms: l._count.symptomLinks > 0,
hasNote: l.notesEncrypted != null && l.notesEncrypted !== "",
};
// One loader for the forecast's inputs, shared with the Coach block and the
// calendar feed (`load-inputs.ts`). The day-log read is bounded to the
// rendered span plus the symptothermal lookback. Cycle lengths come from the
// MenstrualCycle rows; the window only reaches the period-length fallback,
// which is why the readers that promise to agree with this page read the
// default view's window (`defaultCalendarDayLogStart`).
const { cycles, dayLogs, nights } = await loadCalendarInputs({
userId: user.id,
today,
tz,
dayLogsFrom: dayLogWindowStart(from, today),
});
const nights = nightlyTemps.map((m) => ({
date: moodDateKey(m.measuredAt, tz),
valueC: m.value,
}));
const goalAllowsFertile = goalAllowsFertileWindow(profile.goal);
const { prediction, stillLearning, days } = buildCalendar(
@@ -244,6 +162,19 @@ export const GET = apiHandler(async (request: NextRequest) => {
cycles.filter((c) => c.startDate <= today).at(-1)?.startDate ?? null,
});
// The cycles beyond the next one, from the same engine run. Read by the
// predictions panel; the calendar feed serves the same projection, so a
// client never extrapolates further dates itself. Fertile fields stay in
// `prediction.*`, where the goal and cold-start gates already apply.
const projection = projectCycles({
prediction,
cycles: toCycleInputs(cycles),
dayLogs: toDayLogInputs(dayLogs),
goal: profile.goal,
today,
horizon: PROJECTION_MAX_HORIZON,
});
annotate({
action: { name: "cycle.calendar.read" },
meta: {
@@ -276,6 +207,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
// client renders a calm "learning your cycle" banner over the grid. Additive
// + back-compatible — older iOS builds ignore the field.
stillLearning,
// Additive (#1350): the next cycles with their widening bands, and why
// the list holds what it holds (OK / LEARNING / LATE / OFF).
projection: projection.cycles,
projectionState: projection.state,
days,
meta: { generatedAt },
});
@@ -0,0 +1,115 @@
/**
* `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.
*
* 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,
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 { 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;
});
if (!rotated) return noLiveFeed();
return apiSuccess(
{
...feedLinks(resolveOrigin(request), minted.raw),
feed: toFeedSummary(rotated),
},
201,
);
});
@@ -0,0 +1,244 @@
/**
* `/api/cycle/feed-subscription` — the record's calendar subscription
* (#1350).
*
* GET → the status: token prefix, options, fetch statistics, and what
* the feed serves right now. Never the hash, never the URL.
* POST → turn it on. Mints an `hlf_` token, stores only its HMAC, and
* returns the URL exactly once.
* PATCH → change the options.
* DELETE → turn it off. The row is revoked, never deleted.
*
* A separate namespace from the feed itself (`/api/cycle/feed/<token>`), so
* neither the path redactor nor the public-path prefix can ever catch it.
*
* Auth: cookie session only, through `requireGuardianAuth()`: the record's
* owner on their own record, or the guardian of a managed profile (a MANAGE
* grant on a record that carries the managed-profile marker). A delegate on
* an adult's record is refused at every grant level, MANAGE included: the
* feed is a standing credential whose contents end up in a third party's
* calendar cloud, and only the person the data is about, or the guardian of
* somebody who cannot decide for themselves, may put them there. Turning it
* on and rotating also need a fresh proof. A guardian's feed is re-checked
* against the live grant and the marker on every fetch, and a handover claim
* ends it.
*/
import { NextRequest } from "next/server";
import {
apiHandler,
assertRecentCookieProof,
requireGuardianAuth,
} from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import { auditLog } from "@/lib/auth/audit";
import { requireCycleEnabled } from "@/lib/cycle/gate";
import {
FEED_MINT_LIMIT_PER_HOUR,
FEED_MINT_WINDOW_MS,
cookieOnlyRefusal,
feedExists,
feedLinks,
noLiveFeed,
} from "@/lib/cycle/feed/manage";
import {
FEED_SUMMARY_SELECT,
cycleFeedStatus,
findLiveFeed,
toFeedSummary,
} from "@/lib/cycle/feed/status";
import { mintFeedToken } from "@/lib/cycle/feed/token";
import { prisma } from "@/lib/db";
import { resolveOrigin } from "@/lib/http/origin";
import { annotate } from "@/lib/logging/context";
import { isP2002 } from "@/lib/prisma-errors";
import { checkRateLimit } from "@/lib/rate-limit";
import {
destroyedDetails,
overwriteDetails,
} from "@/lib/sharing/audit-details";
import { cycleFeedOptionsPatchSchema } from "@/lib/validations/cycle-feed";
export const GET = apiHandler(async () => {
const auth = await requireGuardianAuth();
if (auth.authMethod !== "cookie") return cookieOnlyRefusal();
const { user } = auth;
annotate({ action: { name: "cycle.feed.status" } });
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
return apiSuccess(await cycleFeedStatus(user, gate.profile.goal));
});
export const POST = apiHandler(async (request: NextRequest) => {
const auth = await requireGuardianAuth();
if (auth.authMethod !== "cookie") return cookieOnlyRefusal();
const { user, actor } = auth;
// The proof is the person at the keyboard, so it is the actor's session.
await assertRecentCookieProof(actor, auth.session.id);
annotate({ action: { name: "cycle.feed.create" } });
// The operator's external-access switch covers this credential too.
if (!(await isApiGloballyEnabled())) {
return apiError("API is globally disabled", 403, {
errorCode: "cycle.feed.api_disabled",
});
}
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
const rl = await checkRateLimit(
`cycle-feed:mint:${actor.id}`,
FEED_MINT_LIMIT_PER_HOUR,
FEED_MINT_WINDOW_MS,
);
if (!rl.allowed) {
return apiError("Too many calendar links, try again later", 429, {
errorCode: "cycle.feed.rateLimited",
});
}
if (await findLiveFeed(user.id)) return feedExists();
const minted = mintFeedToken();
let created;
try {
created = await prisma.cycleCalendarFeed.create({
data: {
userId: user.id,
createdById: actor.id,
tokenHash: minted.hash,
tokenPrefix: minted.prefix,
},
select: FEED_SUMMARY_SELECT,
});
} catch (err) {
// A second mint racing this one lost to the one-live-feed index.
if (isP2002(err)) return feedExists();
throw err;
}
await auditLog("cycle.feed.create", {
userId: user.id,
details: { feedId: created.id, tokenPrefix: created.tokenPrefix },
});
return apiSuccess(
{
...feedLinks(resolveOrigin(request), minted.raw),
feed: toFeedSummary(created),
},
201,
);
});
export const PATCH = apiHandler(async (request: NextRequest) => {
const auth = await requireGuardianAuth();
if (auth.authMethod !== "cookie") return cookieOnlyRefusal();
const { user } = auth;
annotate({ action: { name: "cycle.feed.update" } });
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
const parsed = await parseJsonBody(request, cycleFeedOptionsPatchSchema, {
maxBytes: 4 * 1024,
});
if (!parsed.ok) return parsed.error;
const input = parsed.data;
// The fertile window is a conception-goal option and nothing else. Refused
// here rather than stored and ignored, so a later goal change cannot switch
// on something the person never chose under that goal.
if (
input.includeFertile === true &&
gate.profile.goal !== "TRYING_TO_CONCEIVE"
) {
return apiError(
"The fertile window is only offered when trying to conceive",
422,
{
errorCode: "cycle.feed.fertile_goal",
},
);
}
const live = await findLiveFeed(user.id);
if (!live) return noLiveFeed();
const data = {
...(input.horizonCycles !== undefined
? { horizonCycles: input.horizonCycles }
: {}),
...(input.historyCycles !== undefined
? { historyCycles: input.historyCycles }
: {}),
...(input.includeFertile !== undefined
? { includeFertile: input.includeFertile }
: {}),
...(input.titleMode !== undefined ? { titleMode: input.titleMode } : {}),
};
const updated = await prisma.cycleCalendarFeed.update({
where: { id: live.id },
data,
select: FEED_SUMMARY_SELECT,
});
await auditLog("cycle.feed.update", {
userId: user.id,
details: {
feedId: live.id,
...overwriteDetails({
before: {
horizonCycles: live.horizonCycles,
historyCycles: live.historyCycles,
includeFertile: live.includeFertile,
titleMode: live.titleMode,
},
after: {
horizonCycles: updated.horizonCycles,
historyCycles: updated.historyCycles,
includeFertile: updated.includeFertile,
titleMode: updated.titleMode,
},
}),
},
});
return apiSuccess(await cycleFeedStatus(user, gate.profile.goal));
});
export const DELETE = apiHandler(async () => {
const auth = await requireGuardianAuth();
if (auth.authMethod !== "cookie") return cookieOnlyRefusal();
const { user } = auth;
annotate({ action: { name: "cycle.feed.revoke" } });
const live = await findLiveFeed(user.id);
if (!live) return noLiveFeed();
await prisma.cycleCalendarFeed.update({
where: { id: live.id },
data: { revokedAt: new Date() },
});
// The link stops working on the provider's next poll. What the provider
// already copied is beyond reach, which the settings card says before the
// link is ever made.
await auditLog("cycle.feed.revoke", {
userId: user.id,
details: {
feedId: live.id,
...destroyedDetails({
model: "CycleCalendarFeed",
id: live.id,
label: live.tokenPrefix,
effectiveAt: live.createdAt,
}),
},
});
return apiSuccess({ revoked: true });
});
+25
View File
@@ -0,0 +1,25 @@
/**
* `GET /api/cycle/feed/<hlf_token>[.ics]` — the subscribable cycle forecast
* (#1350).
*
* Anonymous by design: a calendar provider polls this URL from its own
* servers, so the `hlf_` path segment is the only credential. Rate-limited
* before the lookup, one blunt 404 for every miss, every gate re-read on each
* fetch. The pipeline and its reasoning live in `serveCycleFeed`; this file
* only binds it to the route.
*/
import { NextRequest } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { serveCycleFeed } from "@/lib/cycle/feed/serve";
type RouteParams = { params: Promise<{ token: string }> };
export const dynamic = "force-dynamic";
export const GET = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { token } = await params;
return serveCycleFeed(request, token);
},
);
+15 -3
View File
@@ -2,6 +2,8 @@ import { NextRequest } from "next/server";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { categoriseOverview } from "@/lib/labs/marker-category";
import { loadLabOverview } from "@/lib/labs/overview";
import { annotate } from "@/lib/logging/context";
import { labOverviewQuerySchema } from "@/lib/validations/labs";
@@ -12,9 +14,14 @@ import { labOverviewQuerySchema } from "@/lib/validations/labs";
* The Labs list renders from this instead of grouping a capped page of raw
* rows, so a marker measured only long ago still has its row. Same read gate
* as `GET /api/labs` (`requireRecordAuth("read", "labs")`).
*
* `categories` and `grouped` are the list's categories, resolved here once
* (`src/lib/labs/marker-category.ts`): order, label, members, counts and a
* combined status. Labels follow the viewer's locale, so on a managed record
* the caller's own language wins, not the record owner's.
*/
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read", "labs");
const { user, actor } = await requireRecordAuth("read", "labs");
const parsed = labOverviewQuerySchema.safeParse(
Object.fromEntries(request.nextUrl.searchParams),
@@ -28,9 +35,14 @@ export const GET = apiHandler(async (request: NextRequest) => {
}
const markers = await loadLabOverview(user.id);
const locale = await resolveServerLocale({
request,
userLocale: actor.locale ?? null,
});
const { categories, grouped } = categoriseOverview(markers, locale);
annotate({
action: { name: "labs.overview" },
meta: { markers: markers.length },
meta: { markers: markers.length, categories: categories.length, grouped },
});
return apiSuccess({ markers });
return apiSuccess({ markers, categories, grouped });
});
@@ -65,7 +65,7 @@ export const PUT = apiHandler(async (request: Request) => {
});
if (!parsed.ok) return parsed.error;
const { list, view, order, sortDir } = parsed.data;
const { list, view, order, sortDir, groupBy, groupsOpen } = parsed.data;
// Preserve-when-absent: one stored read fills whichever field the body
// left out, and every other list stays as stored. Only fields a person has
// chosen are written, so a field never picked keeps following the default.
@@ -77,6 +77,8 @@ export const PUT = apiHandler(async (request: Request) => {
...(view ? { view } : {}),
...(order ? { order: [...new Set(order)] } : {}),
...(sortDir ? { sortDir } : {}),
...(groupBy ? { groupBy } : {}),
...(groupsOpen ? { groupsOpen } : {}),
};
const next = { ...stored, [list]: nextChoices };
await tx.user.update({
@@ -93,6 +95,8 @@ export const PUT = apiHandler(async (request: Request) => {
list,
view: layout.lists[list].view,
sortDir: layout.lists[list].sortDir,
groupBy: layout.lists[list].groupBy,
groupsOpen: layout.lists[list].groupsOpen,
orderLength: layout.lists[list].order.length,
},
});
+1 -17
View File
@@ -23,6 +23,7 @@ import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { checkRateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/db";
import { resolveOrigin } from "@/lib/http/origin";
import {
createShareLinkSchema,
SHARE_LINK_FORBIDDEN_LEAVES,
@@ -41,23 +42,6 @@ import {
} from "@/lib/clinician-share/passphrase";
import type { Prisma } from "@/generated/prisma/client";
/**
* The absolute origin to build the public share URL from. Prefers the
* forwarded host/proto a reverse proxy sets (the documented self-hosting path
* fronts the app with Caddy/Traefik/Nginx), falling back to the request URL's
* own origin. The QR payload carries the passphrase in the FRAGMENT only.
*/
function resolveOrigin(request: NextRequest): string {
const proto =
request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim() ||
new URL(request.url).protocol.replace(":", "");
const host =
request.headers.get("x-forwarded-host")?.split(",")[0]?.trim() ||
request.headers.get("host") ||
new URL(request.url).host;
return `${proto}://${host}`;
}
/**
* The row selection every owner-facing projection reads. `_count.documents`
* surfaces the size of the frozen document set without ever loading the ids
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { CycleCalendarFeedCard } from "../cycle-calendar-feed-card";
import type { CycleFeedStatus } from "../use-cycle-feed";
function render(status: CycleFeedStatus) {
const client = new QueryClient();
client.setQueryData(queryKeys.cycleFeedSubscription(), status);
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<CycleCalendarFeedCard />
</I18nProvider>
</QueryClientProvider>,
);
}
const FEED = {
tokenPrefix: "hlf_ab12",
createdAt: "2026-11-01T10:00:00.000Z",
lastFetchedAt: null,
fetchCount: 0,
lastFetcher: null,
horizonCycles: 3,
historyCycles: 0,
includeFertile: false,
titleMode: "DISCREET" as const,
};
describe("<CycleCalendarFeedCard>", () => {
it("says what a provider keeps before it offers the button", () => {
const html = render({
feed: null,
projectionState: null,
fertileAvailable: false,
});
const disclosure = html.indexOf("stores these dates on its servers");
const button = html.indexOf("Turn on subscription");
expect(disclosure).toBeGreaterThan(-1);
expect(button).toBeGreaterThan(disclosure);
});
it("shows only the prefix of a live link, and no URL", () => {
const html = render({
feed: FEED,
projectionState: "OK",
fertileAvailable: false,
});
expect(html).toContain("hlf_ab12");
expect(html).not.toContain("/api/cycle/feed/");
expect(html).toContain("Not fetched yet.");
// The fertile option exists only for a conception goal.
expect(html).not.toContain("Fertile window");
});
it("explains a shorter feed in the muted tone", () => {
const html = render({
feed: FEED,
projectionState: "LATE",
fertileAvailable: true,
});
expect(html).toContain('data-slot="cycle-feed-state"');
expect(html).toContain("later than expected");
expect(html).toContain("Fertile window");
});
});
@@ -0,0 +1,53 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
const caps = vi.hoisted(() => ({
value: { inSharedRecord: false, recordKind: "self", level: null } as {
inSharedRecord: boolean;
recordKind: string;
level: string | null;
},
}));
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => caps.value,
}));
vi.mock("../cycle-settings", () => ({
CycleSettings: () => <div data-slot="cycle-settings-form" />,
}));
vi.mock("../cycle-calendar-feed-card", () => ({
CycleCalendarFeedCard: () => <div data-slot="cycle-calendar-feed" />,
}));
import { CycleSettingsTab } from "../cycle-settings-tab";
import type { CycleProfileDTO } from "../types";
const PROFILE = { updatedAt: "2026-11-01T00:00:00.000Z" } as CycleProfileDTO;
function render(value: typeof caps.value) {
caps.value = value;
return renderToStaticMarkup(<CycleSettingsTab profile={PROFILE} />);
}
describe("<CycleSettingsTab> calendar subscription (#1350)", () => {
it("offers it on one's own record", () => {
expect(
render({ inSharedRecord: false, recordKind: "self", level: null }),
).toContain("cycle-calendar-feed");
});
it("offers it to the guardian of a managed profile", () => {
expect(
render({ inSharedRecord: true, recordKind: "managed", level: "manage" }),
).toContain("cycle-calendar-feed");
});
it("does not offer it to a MANAGE delegate on an adult's record", () => {
const html = render({
inSharedRecord: true,
recordKind: "shared",
level: "manage",
});
expect(html).toContain("cycle-settings-form");
expect(html).not.toContain("cycle-calendar-feed");
});
});
@@ -104,3 +104,59 @@ describe("<PredictionsPanel>", () => {
expect(html).toContain("Prediction is off");
});
});
describe("<PredictionsPanel> later cycles (#1350)", () => {
const projection = [
{
k: 1,
start: "2026-07-01",
periodLength: 5,
bandStart: "2026-06-29",
bandEnd: "2026-07-03",
},
{
k: 2,
start: "2026-07-29",
periodLength: 5,
bandStart: "2026-07-26",
bandEnd: "2026-08-01",
},
{
k: 3,
start: "2026-08-26",
periodLength: 5,
bandStart: "2026-08-22",
bandEnd: "2026-08-30",
},
];
it("lists the cycles after the next one, as the server's windows", () => {
const html = render(
<PredictionsPanel
prediction={basePrediction}
rawChartMode={false}
history={emptyHistory}
projection={projection}
projectionState="OK"
/>,
);
expect(html).toContain('data-slot="cycle-projection"');
expect(html).toContain("Later cycles");
// Two later rows; cycle 1 is the next-period row above them.
expect(html.match(/Around /g)).toHaveLength(2);
});
it("states a late period instead of listing later cycles", () => {
const html = render(
<PredictionsPanel
prediction={basePrediction}
rawChartMode={false}
history={emptyHistory}
projection={[]}
projectionState="LATE"
/>,
);
expect(html).toContain('data-slot="cycle-projection-late"');
expect(html).not.toContain('data-slot="cycle-projection"');
});
});
@@ -0,0 +1,395 @@
"use client";
import { useState } from "react";
import { CalendarSync, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import {
recentProofErrorMessage,
useRecentProof,
} from "@/components/settings/security-section/use-recent-proof";
import { formatRelativeTime } from "@/lib/i18n/relative-time";
import { useTranslations } from "@/lib/i18n/context";
import {
useCycleFeedStatus,
useMintCycleFeed,
useRevokeCycleFeed,
useUpdateCycleFeed,
type CycleFeedMinted,
type CycleFeedOptionsPatch,
type CycleFeedStatus,
type FeedTitleMode,
} from "./use-cycle-feed";
/**
* #1350 — the calendar subscription card on the cycle settings tab.
*
* Off by default, and the disclosure comes before the button: a calendar
* provider keeps what it fetched, and turning the link off later reaches none
* of it. The link itself is shown once, right after it is made; after that the
* card knows only its prefix. Rendered only for the owner and a MANAGE
* delegate (the tab's own gate); the routes refuse everyone else regardless.
*/
const HORIZONS = [1, 3, 6] as const;
const HISTORIES = [0, 3, 6, 12] as const;
const TITLE_MODES: FeedTitleMode[] = ["DISCREET", "DESCRIPTIVE"];
/** A long URL with its middle elided, so both ends stay recognisable. */
function middleTruncate(url: string, keep = 28): string {
return url.length <= keep * 2 + 1
? url
: `${url.slice(0, keep)}…${url.slice(-keep / 2)}`;
}
export function CycleCalendarFeedCard() {
const { t } = useTranslations();
const status = useCycleFeedStatus();
const proof = useRecentProof();
const mint = useMintCycleFeed(proof);
const [minted, setMinted] = useState<CycleFeedMinted | null>(null);
const [mintError, setMintError] = useState<string | null>(null);
async function handleMint(kind: "create" | "rotate") {
setMintError(null);
try {
setMinted(await mint.mutateAsync(kind));
} catch (err) {
setMintError(recentProofErrorMessage(err, t("cycle.feed.error")));
}
}
return (
<SettingsCard data-slot="cycle-calendar-feed">
<SettingsCardHeader
icon={CalendarSync}
title={t("cycle.feed.title")}
description={t("cycle.feed.description")}
/>
{status.isError ? (
<QueryErrorCard
error={status.error}
title={t("cycle.loadError")}
onRetry={() => void status.refetch()}
/>
) : status.isLoading || !status.data ? (
<div className="flex h-16 items-center justify-center">
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin motion-reduce:animate-none" />
</div>
) : status.data.feed === null ? (
<OffState
pending={mint.isPending}
error={mintError}
onTurnOn={() => void handleMint("create")}
/>
) : (
<OnState
status={status.data}
minted={minted}
pending={mint.isPending}
error={mintError}
onRotate={() => void handleMint("rotate")}
onRevoked={() => setMinted(null)}
/>
)}
{proof.dialog}
</SettingsCard>
);
}
function OffState({
pending,
error,
onTurnOn,
}: {
pending: boolean;
error: string | null;
onTurnOn: () => void;
}) {
const { t } = useTranslations();
return (
<>
<p className="text-sm">{t("cycle.feed.disclosure")}</p>
{error ? (
<p className="text-destructive text-sm" role="alert">
{error}
</p>
) : null}
<SettingsCardActions>
<Button
size="sm"
className="min-h-11 w-full sm:w-auto"
onClick={onTurnOn}
disabled={pending}
>
{pending ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("cycle.feed.turnOn")}
</Button>
</SettingsCardActions>
</>
);
}
function OnState({
status,
minted,
pending,
error,
onRotate,
onRevoked,
}: {
status: CycleFeedStatus;
minted: CycleFeedMinted | null;
pending: boolean;
error: string | null;
onRotate: () => void;
onRevoked: () => void;
}) {
const { t } = useTranslations();
const revoke = useRevokeCycleFeed();
const feed = status.feed!;
const state = status.projectionState;
return (
<>
{minted ? (
<LinkPanel minted={minted} />
) : (
<p className="text-muted-foreground text-sm">
{t("cycle.feed.linkHidden", { prefix: feed.tokenPrefix })}
</p>
)}
<FetchLine status={status} />
{state === "LEARNING" || state === "LATE" || state === "OFF" ? (
<p
className="text-muted-foreground text-sm"
data-slot="cycle-feed-state"
>
{t(`cycle.projection.state.${state}`)}
</p>
) : null}
<Separator />
<FeedOptions status={status} />
<HelpList />
{error || revoke.isError ? (
<p className="text-destructive text-sm" role="alert">
{error ?? t("cycle.feed.error")}
</p>
) : null}
<SettingsCardActions className="flex-col items-stretch sm:flex-row sm:items-center">
<ConfirmButton
label={t("cycle.feed.turnOff")}
title={t("cycle.feed.turnOffTitle")}
body={t("cycle.feed.turnOffBody")}
confirmLabel={t("cycle.feed.turnOff")}
onConfirm={() => revoke.mutate(undefined, { onSuccess: onRevoked })}
pending={revoke.isPending}
variant="destructive"
destructive
size="sm"
className="min-h-11 w-full sm:w-auto"
/>
<ConfirmButton
label={t("cycle.feed.rotate")}
title={t("cycle.feed.rotateTitle")}
body={t("cycle.feed.rotateBody")}
confirmLabel={t("cycle.feed.rotate")}
onConfirm={onRotate}
pending={pending}
size="sm"
className="min-h-11 w-full sm:w-auto"
/>
</SettingsCardActions>
</>
);
}
/** The link, the one time it exists in the client. */
function LinkPanel({ minted }: { minted: CycleFeedMinted }) {
const { t } = useTranslations();
const [copy, setCopy] = useState<"idle" | "copied" | "failed">("idle");
async function handleCopy() {
try {
await navigator.clipboard.writeText(minted.url);
setCopy("copied");
} catch {
setCopy("failed");
}
}
return (
<div className="space-y-3" data-slot="cycle-feed-link">
<div className="space-y-1.5">
<Label htmlFor="cycle-feed-url">{t("cycle.feed.linkLabel")}</Label>
<code
id="cycle-feed-url"
className="bg-muted block rounded-md px-3 py-2 text-sm break-all"
title={minted.url}
>
{middleTruncate(minted.url)}
</code>
<p className="text-muted-foreground text-xs">
{t("cycle.feed.linkOnce")}
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Button
variant="outline"
size="sm"
className="min-h-11 w-full sm:w-auto"
onClick={() => void handleCopy()}
>
{t("cycle.feed.copy")}
</Button>
<Button
variant="outline"
size="sm"
asChild
className="min-h-11 w-full sm:w-auto"
>
<a href={minted.webcalUrl}>{t("cycle.feed.openInCalendar")}</a>
</Button>
</div>
<p className="text-muted-foreground text-sm" aria-live="polite">
{copy === "copied"
? t("cycle.feed.copied")
: copy === "failed"
? t("cycle.feed.copyFailed")
: ""}
</p>
</div>
);
}
function FetchLine({ status }: { status: CycleFeedStatus }) {
const { t, locale } = useTranslations();
const feed = status.feed!;
if (feed.lastFetchedAt === null) {
return (
<p className="text-muted-foreground text-sm">
{t("cycle.feed.notFetched")}
</p>
);
}
const when = formatRelativeTime(feed.lastFetchedAt, t, locale);
return (
<p className="text-muted-foreground text-sm">
{t("cycle.feed.lastFetched", {
when,
fetcher: t(`cycle.feed.fetcher.${feed.lastFetcher ?? "OTHER"}`),
})}
</p>
);
}
function FeedOptions({ status }: { status: CycleFeedStatus }) {
const { t } = useTranslations();
const update = useUpdateCycleFeed();
const feed = status.feed!;
const save = (patch: CycleFeedOptionsPatch) => update.mutate(patch);
return (
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-1.5">
<Label htmlFor="cycle-feed-horizon">{t("cycle.feed.horizon")}</Label>
<NativeSelect
id="cycle-feed-horizon"
value={String(feed.horizonCycles)}
disabled={update.isPending}
onChange={(e) => save({ horizonCycles: Number(e.target.value) })}
>
{HORIZONS.map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</NativeSelect>
</div>
<div className="space-y-1.5">
<Label htmlFor="cycle-feed-history">{t("cycle.feed.history")}</Label>
<NativeSelect
id="cycle-feed-history"
value={String(feed.historyCycles)}
disabled={update.isPending}
onChange={(e) => save({ historyCycles: Number(e.target.value) })}
>
{HISTORIES.map((n) => (
<option key={n} value={n}>
{n === 0 ? t("cycle.feed.historyNone") : n}
</option>
))}
</NativeSelect>
</div>
<div className="space-y-1.5">
<Label htmlFor="cycle-feed-title">{t("cycle.feed.titles")}</Label>
<NativeSelect
id="cycle-feed-title"
value={feed.titleMode}
disabled={update.isPending}
onChange={(e) =>
save({ titleMode: e.target.value as FeedTitleMode })
}
>
{TITLE_MODES.map((m) => (
<option key={m} value={m}>
{t(`cycle.feed.titleMode.${m}`)}
</option>
))}
</NativeSelect>
</div>
</div>
{status.fertileAvailable ? (
<div className="flex items-start justify-between gap-4">
<div className="space-y-0.5">
<Label htmlFor="cycle-feed-fertile" className="text-sm">
{t("cycle.feed.fertile")}
</Label>
<p className="text-muted-foreground text-sm">
{t("cycle.feed.fertileDescription")}
</p>
</div>
<Switch
id="cycle-feed-fertile"
checked={feed.includeFertile}
disabled={update.isPending}
onCheckedChange={(v) => save({ includeFertile: v })}
className="mt-0.5 shrink-0"
/>
</div>
) : null}
{update.isError ? (
<p className="text-destructive text-sm" role="alert">
{t("cycle.feed.error")}
</p>
) : null}
</div>
);
}
function HelpList() {
const { t } = useTranslations();
return (
<div className="space-y-1.5">
<p className="text-sm font-medium">{t("cycle.feed.help.title")}</p>
<ul className="text-muted-foreground list-disc space-y-1 pl-5 text-sm">
<li>{t("cycle.feed.help.google")}</li>
<li>{t("cycle.feed.help.apple")}</li>
<li>{t("cycle.feed.help.outlook")}</li>
</ul>
</div>
);
}
@@ -0,0 +1,29 @@
"use client";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { CycleCalendarFeedCard } from "./cycle-calendar-feed-card";
import { CycleSettings } from "./cycle-settings";
import type { CycleProfileDTO } from "./types";
/**
* The cycle page's settings tab: the profile form, then the calendar
* subscription (#1350). The form remounts on a profile refetch (`key`), the
* subscription card keeps its own state, including a link it just showed.
*
* The card is the owner's on their own record, and a guardian's on a managed
* profile they manage: the routes behind it (`requireGuardianAuth`) refuse a
* delegate on an adult's record at every level, so the card is not offered
* there rather than opening onto a refusal.
*/
export function CycleSettingsTab({ profile }: { profile: CycleProfileDTO }) {
const { inSharedRecord, recordKind, level } = useRecordCapabilities();
const mayHoldFeed =
!inSharedRecord || (recordKind === "managed" && level === "manage");
return (
<div className="space-y-4">
<CycleSettings key={profile.updatedAt} profile={profile} />
{mayHoldFeed ? <CycleCalendarFeedCard /> : null}
</div>
);
}
+4 -5
View File
@@ -23,7 +23,7 @@ import { PhaseEducationCard } from "./phase-education-card";
import { PredictionsPanel } from "./predictions-panel";
import { CyclePhaseHeadline, CyclePhaseCrosstab } from "./cycle-phase-crosstab";
import { CycleSymptomPatterns } from "./cycle-symptom-patterns";
import { CycleSettings } from "./cycle-settings";
import { CycleSettingsTab } from "./cycle-settings-tab";
import { PHASE_HUE } from "./phase-tokens";
import { monthOf, monthWindowOutside } from "./calendar-window";
import {
@@ -417,6 +417,8 @@ export function CycleView() {
prediction={calendar.data?.prediction ?? null}
rawChartMode={calendar.data?.profile.rawChartMode ?? false}
history={history.data?.data}
projection={calendar.data?.projection}
projectionState={calendar.data?.projectionState ?? null}
/>
<CycleHistoryChart
history={history.data?.data}
@@ -471,10 +473,7 @@ export function CycleView() {
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin motion-reduce:animate-none" />
</div>
) : profileQuery.data && canManageCycle ? (
<CycleSettings
key={profileQuery.data.updatedAt}
profile={profileQuery.data}
/>
<CycleSettingsTab profile={profileQuery.data} />
) : null}
</TabsContent>
</Tabs>
+14 -1
View File
@@ -14,7 +14,13 @@ import { TileHeader } from "@/components/insights/tile-header";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import type { Formatters } from "@/lib/format-locale";
import { FERTILE_HUE, FLOW_HUE, OVULATION_HUE } from "./phase-tokens";
import type { CyclePrediction, CycleHistoryResponse } from "./types";
import type {
CyclePrediction,
CycleHistoryResponse,
ProjectedCycle,
ProjectionState,
} from "./types";
import { UpcomingCycles } from "./upcoming-cycles";
import { EmptyState } from "@/components/ui/empty-state";
/**
@@ -59,12 +65,17 @@ export interface PredictionsPanelProps {
prediction: CyclePrediction | null;
rawChartMode: boolean;
history: CycleHistoryResponse | undefined;
/** #1350 — the server's projection of the cycles after the next one. */
projection?: readonly ProjectedCycle[];
projectionState?: ProjectionState | null;
}
export function PredictionsPanel({
prediction,
rawChartMode,
history,
projection = [],
projectionState = null,
}: PredictionsPanelProps) {
const { t } = useTranslations();
const fmt = useFormatters();
@@ -94,6 +105,7 @@ export function PredictionsPanel({
body={t("cycle.predictions.stillLearningBody")}
/>
<NextPeriod prediction={prediction} />
<UpcomingCycles projection={projection} state={projectionState} />
</>
) : (
<>
@@ -119,6 +131,7 @@ export function PredictionsPanel({
value={formatDate(prediction.predictedOvulation, fmt)}
/>
) : null}
<UpcomingCycles projection={projection} state={projectionState} />
</>
)}
</CardContent>
+18
View File
@@ -114,10 +114,28 @@ export interface CalendarResponse {
* phase band — render the calm "learning your cycle" state over the grid.
*/
stillLearning: boolean;
/**
* #1350 — the next cycles with widening bands, resolved server-side (the
* calendar feed serves the same list). Render as given; never extrapolate.
*/
projection: ProjectedCycle[];
projectionState: ProjectionState;
days: CalendarDay[];
meta: { generatedAt: string };
}
/** Mirrors `ProjectionState` in `src/lib/cycle/projection.ts`. */
export type ProjectionState = "OK" | "LEARNING" | "LATE" | "OFF";
/** Mirrors `ProjectedCycle` in `src/lib/cycle/projection.ts`. */
export interface ProjectedCycle {
k: number;
start: string;
periodLength: number;
bandStart: string;
bandEnd: string;
}
export interface MenstrualCycleDTO {
id: string;
startDate: string;
+72
View File
@@ -0,0 +1,72 @@
"use client";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { FLOW_HUE } from "./phase-tokens";
import type { ProjectedCycle, ProjectionState } from "./types";
/**
* #1350 — the periods after the next one, each as the window its start may
* fall in. The list and its bands are the server's (`/api/cycle/calendar`
* `projection`, the same list the calendar feed serves); this only renders
* them. The band widens from one row to the next, and the server stops the
* list where a date would be a guess.
*
* The next period itself is the row above this one in the predictions card,
* so cycle 1 is skipped here. A late period is stated, in the muted tone: a
* late period is a fact about the calendar, not a warning.
*/
export function UpcomingCycles({
projection,
state,
}: {
projection: readonly ProjectedCycle[];
state: ProjectionState | null;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
if (state === "LATE") {
return (
<p
className="text-muted-foreground text-sm"
data-slot="cycle-projection-late"
>
{t("cycle.projection.state.LATE")}
</p>
);
}
const later = projection.filter((c) => c.k >= 2);
if (later.length === 0) return null;
return (
<div className="space-y-2" data-slot="cycle-projection">
<p className="text-muted-foreground text-xs font-medium">
{t("cycle.projection.title")}
</p>
<ul className="divide-border divide-y">
{later.map((c) => (
<li key={c.k} className="flex items-center gap-2 py-2 text-sm">
<span
aria-hidden="true"
className="h-2.5 w-2.5 shrink-0 rounded-full opacity-60"
style={{ backgroundColor: FLOW_HUE }}
/>
<span className="text-muted-foreground">
{t("cycle.projection.around", {
date: fmt.dateShortSmartCalendar(c.start),
})}
</span>
<span className="text-foreground ml-auto font-medium tabular-nums">
{t("cycle.predictions.nextPeriodWindow", {
low: fmt.dateShortSmartCalendar(c.bandStart),
high: fmt.dateShortSmartCalendar(c.bandEnd),
})}
</span>
</li>
))}
</ul>
</div>
);
}
+110
View File
@@ -0,0 +1,110 @@
"use client";
/**
* #1350 — the calendar subscription's read and write hooks.
*
* Every value the card shows is the server's: the status read carries the
* options, the fetch statistics as a calendar-app family, and the projection
* state that explains a shorter feed. The URL exists in the client only in
* the one mint or rotate response that returns it, held in the card's state
* and gone when the card unmounts.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import type { ProjectionState } from "./types";
export type FeedTitleMode = "DISCREET" | "DESCRIPTIVE";
export type FeedFetcher = "GOOGLE" | "APPLE" | "MICROSOFT" | "OTHER";
export interface CycleFeedSummary {
tokenPrefix: string;
createdAt: string;
lastFetchedAt: string | null;
fetchCount: number;
lastFetcher: FeedFetcher | null;
horizonCycles: number;
historyCycles: number;
includeFertile: boolean;
titleMode: FeedTitleMode;
}
export interface CycleFeedStatus {
feed: CycleFeedSummary | null;
projectionState: ProjectionState | null;
fertileAvailable: boolean;
}
export interface CycleFeedMinted {
url: string;
webcalUrl: string;
feed: CycleFeedSummary;
}
export type CycleFeedOptionsPatch = Partial<
Pick<
CycleFeedSummary,
"horizonCycles" | "historyCycles" | "includeFertile" | "titleMode"
>
>;
const PATH = "/api/cycle/feed-subscription";
export function useCycleFeedStatus(enabled = true) {
return useQuery({
queryKey: queryKeys.cycleFeedSubscription(),
queryFn: () => apiGet<CycleFeedStatus>(PATH),
staleTime: 60_000,
enabled,
});
}
function useInvalidateStatus() {
const qc = useQueryClient();
return () =>
qc.invalidateQueries({ queryKey: queryKeys.cycleFeedSubscription() });
}
/**
* Turn the subscription on, or rotate its link. `proof` is the recent-proof
* flow: both answer 401 `auth.reproof.required` without a fresh proof, and
* `proof.run` opens the re-proof dialog and retries.
*/
export function useMintCycleFeed(proof: {
run: <T>(action: () => Promise<T>) => Promise<T>;
}) {
const invalidate = useInvalidateStatus();
return useMutation({
mutationKey: queryKeys.cycleFeedSubscription(),
mutationFn: (kind: "create" | "rotate") =>
kind === "create"
? proof.run(() =>
apiPost<CycleFeedMinted>("/api/cycle/feed-subscription"),
)
: proof.run(() =>
apiPost<CycleFeedMinted>("/api/cycle/feed-subscription/rotate"),
),
onSettled: () => invalidate(),
});
}
export function useUpdateCycleFeed() {
const invalidate = useInvalidateStatus();
return useMutation({
mutationKey: queryKeys.cycleFeedSubscription(),
mutationFn: (patch: CycleFeedOptionsPatch) =>
apiPatch<CycleFeedStatus>(PATH, patch),
onSettled: () => invalidate(),
});
}
export function useRevokeCycleFeed() {
const invalidate = useInvalidateStatus();
return useMutation({
mutationKey: queryKeys.cycleFeedSubscription(),
mutationFn: () => apiDelete<{ revoked: true }>(PATH),
onSettled: () => invalidate(),
});
}
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import { buildBiomarkerBody } from "../biomarker-form-body";
const empty = {
name: " LDL ",
unit: "mg/dL",
lower: null,
upper: null,
panel: " ",
context: "",
};
describe("buildBiomarkerBody", () => {
it("omits every empty field on a new marker", () => {
expect(buildBiomarkerBody(empty, null)).toEqual({
name: "LDL",
unit: "mg/dL",
});
});
it("sends the filled fields trimmed", () => {
expect(
buildBiomarkerBody(
{ ...empty, lower: 0, upper: 116, panel: " Lipids ", context: " x " },
null,
),
).toEqual({
name: "LDL",
unit: "mg/dL",
lowerBound: 0,
upperBound: 116,
panel: "Lipids",
context: "x",
});
});
it("sends null for every field an edit emptied", () => {
expect(
buildBiomarkerBody(empty, {
lowerBound: 10,
upperBound: 0,
panel: "Lipids",
context: "note",
}),
).toEqual({
name: "LDL",
unit: "mg/dL",
lowerBound: null,
upperBound: null,
panel: null,
context: null,
});
});
it("leaves a field out that was empty when the edit began", () => {
expect(
buildBiomarkerBody(empty, {
lowerBound: null,
upperBound: null,
panel: null,
context: null,
}),
).toEqual({ name: "LDL", unit: "mg/dL" });
});
});
@@ -1,9 +1,11 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { ApiError } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { queryKeys } from "@/lib/query-keys";
import en from "../../../../messages/en.json";
/**
@@ -36,9 +38,18 @@ const MARKER: BiomarkerDto = {
updatedAt: "2026-01-01T00:00:00.000Z",
};
function render(node: React.ReactNode) {
function render(node: React.ReactNode, customPanels: string[] = []) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
});
queryClient.setQueryData(queryKeys.biomarkers(), {
biomarkers: [],
customPanels,
});
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">{node}</I18nProvider>
</QueryClientProvider>,
);
}
@@ -79,3 +90,20 @@ describe("<BiomarkerForm> unit lock", () => {
);
});
});
describe("<BiomarkerForm> panel suggestions", () => {
it("suggests every catalog panel and the person's own panels", () => {
const html = render(<BiomarkerForm />, ["Annual check"]);
const list = html.match(
/<datalist[^>]*data-slot="biomarker-panel-suggestions"[^>]*>(.*?)<\/datalist>/,
);
expect(list).not.toBeNull();
const values = [...list![1].matchAll(/value="([^"]*)"/g)].map((m) => m[1]);
expect(values).toContain("Lipids");
expect(values).toContain("Blood count");
expect(values).toContain("Annual check");
expect(values).toHaveLength(11);
const id = /<datalist[^>]*id="([^"]+)"/.exec(html)![1];
expect(html).toContain(`list="${id}"`);
});
});
@@ -4,7 +4,15 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { LabOverviewResponse, LabResultDto } from "../types";
import {
resolveModuleListLayout,
type ModuleListPrefs,
} from "@/lib/settings/module-list-layout";
import type {
LabOverviewCategoryDto,
LabOverviewResponse,
LabResultDto,
} from "../types";
/**
* Every row of the compact list is five grid cells from `lg` up. The range
@@ -51,7 +59,14 @@ function reading(over: Partial<LabResultDto>): LabResultDto {
} as LabResultDto;
}
function renderList(results: LabResultDto[]) {
function renderList(
results: LabResultDto[],
options: {
prefs?: Partial<ModuleListPrefs>;
categories?: LabOverviewCategoryDto[];
grouped?: boolean;
} = {},
) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
});
@@ -76,8 +91,16 @@ function renderList(results: LabResultDto[]) {
latest: ordered[ordered.length - 1],
};
}),
categories: options.categories ?? [],
grouped: options.grouped ?? false,
};
queryClient.setQueryData(queryKeys.labResultsOverview(), data);
queryClient.setQueryData(queryKeys.labResultsOverview("en"), data);
if (options.prefs) {
queryClient.setQueryData(
queryKeys.moduleListLayout(),
resolveModuleListLayout({ version: 1, labs: options.prefs }),
);
}
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
@@ -197,3 +220,110 @@ describe("<LabList> columns", () => {
expect(html).toContain("converted from 5.6 mmol/L");
});
});
describe("<LabList> categories", () => {
const readings = [
reading({ id: "l1", biomarkerId: "b1", analyte: "LDL", panel: "Lipids" }),
reading({
id: "l2",
biomarkerId: "b2",
analyte: "HDL",
panel: "Lipids",
rangeStatus: "above",
}),
reading({ id: "t1", biomarkerId: "b3", analyte: "TSH", panel: "Thyroid" }),
];
const categories: LabOverviewCategoryDto[] = [
{
key: "lipids",
label: "Lipids",
markerKeys: ["bm:b1", "bm:b2"],
counts: { inRange: 1, below: 0, above: 1, unknown: 0 },
status: "above",
},
{
key: "thyroid",
label: "Thyroid",
markerKeys: ["bm:b3"],
counts: { inRange: 1, below: 0, above: 0, unknown: 0 },
status: "in-range",
},
];
const headers = (html: string) =>
[...html.matchAll(/<button[^>]*data-slot="lab-list-group"[^>]*>/g)].map(
(m) => m[0],
);
it("stays flat while grouping is off, or while the server says it adds nothing", () => {
expect(
headers(renderList(readings, { categories, grouped: true })),
).toHaveLength(0);
expect(
headers(
renderList(readings, {
categories,
grouped: false,
prefs: { groupBy: "category" },
}),
),
).toHaveLength(0);
});
it("renders one disclosure per category, open by default, with the status in its name", () => {
const html = renderList(readings, {
categories,
grouped: true,
prefs: { groupBy: "category" },
});
const found = headers(html);
expect(found).toHaveLength(2);
for (const button of found) {
expect(button).toContain('aria-expanded="true"');
expect(button).toMatch(/aria-controls="[^"]+"/);
}
expect(html).toContain("1 of 2 outside range");
expect(html).toContain("All in range");
expect(count(html, 'href="/labs/')).toBe(3);
// Server order: lipids first.
expect(html.indexOf("Lipids")).toBeLessThan(html.indexOf("Thyroid"));
// The header spans the table's columns, the rows keep the subgrid.
expect(html).toContain("lg:col-span-full");
expect(html).toContain("lg:grid-cols-subgrid");
});
it("opens only flagged categories, or none, as the person chose", () => {
const flagged = renderList(readings, {
categories,
grouped: true,
prefs: { groupBy: "category", groupsOpen: "flagged" },
});
const [lipids, thyroid] = headers(flagged);
expect(lipids).toContain('aria-expanded="true"');
expect(thyroid).toContain('aria-expanded="false"');
expect(count(flagged, 'href="/labs/')).toBe(2);
const collapsed = renderList(readings, {
categories,
grouped: true,
prefs: { groupBy: "category", groupsOpen: "collapsed" },
});
expect(
headers(collapsed).every((h) => h.includes('aria-expanded="false"')),
).toBe(true);
expect(count(collapsed, 'href="/labs/')).toBe(0);
});
it("names the unit instead of the panel on a card under its category", () => {
const grouped = renderList(readings, {
categories,
grouped: true,
prefs: { groupBy: "category", view: "cards" },
});
expect(headers(grouped)).toHaveLength(2);
// Each panel label appears once, in its header, not again on the cards.
expect(count(grouped, ">Lipids<")).toBe(1);
const flat = renderList(readings, { prefs: { view: "cards" } });
expect(count(flat, ">Lipids<")).toBe(2);
});
});
@@ -0,0 +1,83 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
groupMarkerRows,
readStoredOpen,
writeStoredOpen,
} from "../lab-list-groups";
import type { MarkerRow } from "../lab-list-rows";
import type { LabOverviewCategoryDto } from "../types";
function category(key: string, markerKeys: string[]): LabOverviewCategoryDto {
return {
key,
label: key,
markerKeys,
counts: { inRange: markerKeys.length, below: 0, above: 0, unknown: 0 },
status: "in-range",
};
}
const row = (key: string) => ({ key }) as MarkerRow;
afterEach(() => {
vi.unstubAllGlobals();
});
describe("groupMarkerRows", () => {
it("keeps the rows' own order inside a category and the server order across them", () => {
const groups = groupMarkerRows(
[row("c"), row("a"), row("b")],
[category("second", ["a", "b"]), category("first", ["c"])],
);
expect(groups.map((g) => g.category.key)).toEqual(["second", "first"]);
expect(groups[0].rows.map((r) => r.key)).toEqual(["a", "b"]);
});
it("drops a category with no row present", () => {
expect(
groupMarkerRows([row("a")], [category("x", ["a"]), category("y", ["z"])]),
).toHaveLength(1);
});
});
describe("stored open state", () => {
function stubStorage(store: Map<string, string>) {
vi.stubGlobal("window", {
localStorage: {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
},
});
}
it("round-trips the toggles made under one choice and forgets them under another", () => {
const store = new Map<string, string>();
stubStorage(store);
writeStoredOpen("flagged", { lipids: true, "custom:abc": false });
expect(readStoredOpen("flagged")).toEqual({
lipids: true,
"custom:abc": false,
});
expect(readStoredOpen("expanded")).toEqual({});
});
it("survives a storage that throws or holds garbage", () => {
vi.stubGlobal("window", {
localStorage: {
getItem: () => {
throw new Error("denied");
},
setItem: () => {
throw new Error("denied");
},
},
});
expect(readStoredOpen("expanded")).toEqual({});
expect(() => writeStoredOpen("expanded", { a: true })).not.toThrow();
const store = new Map([["healthlog:labs-groups-open", "{not json"]]);
stubStorage(store);
expect(readStoredOpen("expanded")).toEqual({});
});
});
@@ -1,4 +1,5 @@
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { describe, expect, it, vi } from "vitest";
import { I18nProvider } from "@/lib/i18n/context";
@@ -22,8 +23,14 @@ import { OcrRowEditor } from "../ocr-row-editor";
*/
function render(node: React.ReactNode): string {
// The marker form reads the biomarker list for its panel suggestions.
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, enabled: false } },
});
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">{node}</I18nProvider>
</QueryClientProvider>,
);
}
@@ -0,0 +1,63 @@
import { describe, expect, it, vi } 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 {
resolveModuleListLayout,
type ModuleListPrefs,
} from "@/lib/settings/module-list-layout";
vi.mock("@/lib/api/api-fetch", () => ({
apiGet: () => new Promise(() => {}),
apiPut: vi.fn(),
}));
import { LabsSettings } from "../labs-settings";
function render(prefs: Partial<ModuleListPrefs>) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
});
queryClient.setQueryData(
queryKeys.moduleListLayout(),
resolveModuleListLayout({ version: 1, labs: prefs }),
);
queryClient.setQueryData(queryKeys.biomarkers(), {
biomarkers: [],
customPanels: [],
});
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<LabsSettings />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("<LabsSettings> categories", () => {
it("hides the open-on-load choice while categories are off", () => {
const html = render({});
expect(html).toContain('data-slot="labs-group-by-none"');
expect(html).not.toContain('data-slot="labs-groups-open"');
});
it("offers the three open-on-load choices while categories are on", () => {
const html = render({ groupBy: "category", groupsOpen: "flagged" });
expect(html).toContain('data-slot="labs-groups-open"');
for (const value of ["expanded", "flagged", "collapsed"]) {
expect(html).toContain(`data-slot="labs-groups-open-${value}"`);
}
});
it("says the manual order applies within each category", () => {
expect(render({ groupBy: "category", sortDir: "manual" })).toContain(
"The order applies within each category.",
);
expect(render({ sortDir: "manual" })).not.toContain(
"The order applies within each category.",
);
});
});
@@ -0,0 +1,57 @@
/**
* The request body the marker form sends.
*
* A new marker omits every empty field: the create schema has no "clear",
* and an omitted field simply stays unset. An edit omits a field that was
* empty and still is, so it stays as stored, and sends `null` for a field the
* person emptied: panel, context or a bound that held a value and now holds
* none is cleared, which an omitted key never did. Only a field that held a
* value when the form opened can be emptied, so a context the list could not
* decrypt (shown empty) is never wiped by saving another field.
*/
export interface BiomarkerFormFields {
name: string;
unit: string;
lower: number | null;
upper: number | null;
panel: string;
context: string;
}
export interface BiomarkerFormInitial {
lowerBound: number | null;
upperBound: number | null;
panel: string | null;
context: string | null;
}
export type BiomarkerBody = {
name: string;
unit: string;
lowerBound?: number | null;
upperBound?: number | null;
panel?: string | null;
context?: string | null;
};
export function buildBiomarkerBody(
fields: BiomarkerFormFields,
initial: BiomarkerFormInitial | null,
): BiomarkerBody {
const panel = fields.panel.trim();
const context = fields.context.trim();
const body: BiomarkerBody = {
name: fields.name.trim(),
unit: fields.unit.trim(),
};
if (fields.lower !== null) body.lowerBound = fields.lower;
else if (initial?.lowerBound != null) body.lowerBound = null;
if (fields.upper !== null) body.upperBound = fields.upper;
else if (initial?.upperBound != null) body.upperBound = null;
if (panel) body.panel = panel;
else if (initial?.panel) body.panel = null;
if (context) body.context = context;
else if (initial?.context) body.context = null;
return body;
}
+7 -20
View File
@@ -26,6 +26,8 @@ import {
} from "@/lib/labs/biomarker-catalog";
import { useTranslations } from "@/lib/i18n/context";
import { buildBiomarkerBody } from "./biomarker-form-body";
import { BiomarkerPanelField } from "./biomarker-panel-field";
import { LabUnitInput } from "./lab-unit-input";
import type { BiomarkerDto } from "./types";
@@ -113,14 +115,10 @@ export function BiomarkerForm({
return;
}
const body = {
name: name.trim(),
unit: unit.trim(),
...(low !== null ? { lowerBound: low } : {}),
...(high !== null ? { upperBound: high } : {}),
...(panel.trim() ? { panel: panel.trim() } : {}),
...(context.trim() ? { context: context.trim() } : {}),
};
const body = buildBiomarkerBody(
{ name, unit, lower: low, upper: high, panel, context },
existing ?? null,
);
setSubmitting(true);
try {
@@ -238,18 +236,7 @@ export function BiomarkerForm({
</p>
) : null}
</div>
<div className="space-y-1.5">
<Label htmlFor="biomarker-panel">
{t("labs.biomarker.form.panel")}
</Label>
<Input
id="biomarker-panel"
value={panel}
onChange={(e) => setPanel(e.target.value)}
placeholder={t("labs.biomarker.form.panelPlaceholder")}
maxLength={120}
/>
</div>
<BiomarkerPanelField value={panel} onChange={setPanel} />
<div className="space-y-1.5">
<Label htmlFor="biomarker-lower">
{t("labs.biomarker.form.lowerBound")}
+27 -12
View File
@@ -204,18 +204,7 @@ export function BiomarkerMergeSheet({
</li>
))}
</ul>
{preview.aliasesAdded.length > 0 ? (
<div className="space-y-1.5">
<p className="text-muted-foreground text-xs">
{t("labs.merge.aliasesAdded")}
</p>
<div className="flex flex-wrap gap-1.5">
{preview.aliasesAdded.map((alias) => (
<TagChip key={alias}>{alias}</TagChip>
))}
</div>
</div>
) : null}
<MergePreviewNotes preview={preview} />
{(refused?.length ?? 0) > 0 ? (
<p role="alert" className="text-destructive text-sm">
{t("labs.merge.blocked")}
@@ -283,3 +272,29 @@ export function BiomarkerMergeSheet({
</ResponsiveSheet>
);
}
/** What the kept marker gains besides the readings: spellings and a panel. */
function MergePreviewNotes({ preview }: { preview: BiomarkerMergeResultDto }) {
const { t } = useTranslations();
return (
<>
{preview.aliasesAdded.length > 0 ? (
<div className="space-y-1.5">
<p className="text-muted-foreground text-xs">
{t("labs.merge.aliasesAdded")}
</p>
<div className="flex flex-wrap gap-1.5">
{preview.aliasesAdded.map((alias) => (
<TagChip key={alias}>{alias}</TagChip>
))}
</div>
</div>
) : null}
{preview.adoptedPanel ? (
<p className="text-sm" data-slot="biomarker-merge-adopts-panel">
{t("labs.merge.adoptsPanel", { panel: preview.adoptedPanel })}
</p>
) : null}
</>
);
}
@@ -0,0 +1,65 @@
"use client";
/**
* The marker form's panel field: free text with suggestions. The catalog
* panels come from the bundle in the page language; the person's own panels
* come from `customPanels` on `GET /api/biomarkers`, the same read the
* pickers around the form already hold. A catalog panel typed in any
* language lands in the same category, so the suggestions only spare typing.
*/
import { useId } from "react";
import { useQuery } from "@tanstack/react-query";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { BIOMARKER_PANELS } from "@/lib/labs/biomarker-catalog";
import { queryKeys } from "@/lib/query-keys";
import type { BiomarkerListResponse } from "./types";
export function BiomarkerPanelField({
value,
onChange,
}: {
value: string;
onChange: (value: string) => void;
}) {
const { t } = useTranslations();
const listId = `${useId()}-panels`;
const hintId = `${listId}-hint`;
const { data } = useQuery({
queryKey: queryKeys.biomarkers(),
queryFn: () => apiGet<BiomarkerListResponse>("/api/biomarkers"),
});
const suggestions = [
...new Set([
...BIOMARKER_PANELS.map((key) => t(`labs.catalog.panel.${key}`)),
...(data?.customPanels ?? []),
]),
];
return (
<div className="space-y-1.5">
<Label htmlFor="biomarker-panel">{t("labs.biomarker.form.panel")}</Label>
<Input
id="biomarker-panel"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={t("labs.biomarker.form.panelPlaceholder")}
maxLength={120}
list={listId}
aria-describedby={hintId}
autoComplete="off"
/>
<datalist id={listId} data-slot="biomarker-panel-suggestions">
{suggestions.map((panel) => (
<option key={panel} value={panel} />
))}
</datalist>
<p id={hintId} className="text-muted-foreground text-xs">
{t("labs.biomarker.form.panelHint")}
</p>
</div>
);
}
+202
View File
@@ -0,0 +1,202 @@
"use client";
/**
* The categories of the labs list: which markers sit under which header, the
* header itself, and which categories are open.
*
* Everything about a category comes from the server (`categories` on
* `GET /api/labs/overview`): its members, label, order, counts and status.
* This module only places the already-sorted rows under them and builds the
* status sentence from the counts; it never decides a category or a status.
*/
import { useMemo, useState } from "react";
import { ChevronDown } from "lucide-react";
import { ScorePill } from "@/components/ui/score-pill";
import { TagChip } from "@/components/ui/tag-chip";
import { useMounted } from "@/hooks/use-mounted";
import { useTranslations } from "@/lib/i18n/context";
import type { ModuleGroupsOpen } from "@/lib/module-list-prefs";
import { cn } from "@/lib/utils";
import type { MarkerRow } from "./lab-list-rows";
import type { LabOverviewCategoryDto } from "./types";
export interface MarkerGroup {
category: LabOverviewCategoryDto;
rows: MarkerRow[];
}
/**
* The rows under each category, in the order the rows already have (the
* person's sort applies inside a category); the categories keep the server
* order. A category none of whose rows is present drops out.
*/
export function groupMarkerRows(
rows: readonly MarkerRow[],
categories: readonly LabOverviewCategoryDto[],
): MarkerGroup[] {
const categoryOf = new Map<string, string>();
for (const category of categories) {
for (const key of category.markerKeys) categoryOf.set(key, category.key);
}
const byCategory = new Map<string, MarkerRow[]>();
for (const row of rows) {
const key = categoryOf.get(row.key);
if (key === undefined) continue;
const list = byCategory.get(key) ?? [];
list.push(row);
byCategory.set(key, list);
}
return categories
.map((category) => ({
category,
rows: byCategory.get(category.key) ?? [],
}))
.filter((group) => group.rows.length > 0);
}
const STORAGE_KEY = "healthlog:labs-groups-open";
interface StoredOpen {
/** The `groupsOpen` choice the toggles were made under. */
mode: ModuleGroupsOpen;
open: Record<string, boolean>;
}
export function readStoredOpen(
mode: ModuleGroupsOpen,
): Record<string, boolean> {
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw) as Partial<StoredOpen>;
if (parsed.mode !== mode || typeof parsed.open !== "object") return {};
const out: Record<string, boolean> = {};
for (const [key, value] of Object.entries(parsed.open ?? {})) {
if (typeof value === "boolean") out[key] = value;
}
return out;
} catch {
return {};
}
}
export function writeStoredOpen(
mode: ModuleGroupsOpen,
open: Record<string, boolean>,
): void {
try {
window.localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ mode, open } satisfies StoredOpen),
);
} catch {
// Storage unavailable: the toggle still holds for this page.
}
}
function openByDefault(
category: LabOverviewCategoryDto,
mode: ModuleGroupsOpen,
): boolean {
if (mode === "expanded") return true;
if (mode === "collapsed") return false;
return category.status === "above" || category.status === "below";
}
/**
* Which categories are open. The `groupsOpen` choice sets the start; a
* category the person opened or closed by hand stays so in this browser
* (keyed by the opaque category key, never by its label) until the choice
* itself changes. Storage is read only after hydration and may be missing.
*/
export function useGroupsOpen(mode: ModuleGroupsOpen): {
isOpen: (category: LabOverviewCategoryDto) => boolean;
toggle: (category: LabOverviewCategoryDto) => void;
} {
const mounted = useMounted();
const stored = useMemo(
() => (mounted ? readStoredOpen(mode) : {}),
[mounted, mode],
);
const [toggled, setToggled] = useState<StoredOpen>({ mode, open: {} });
const own = toggled.mode === mode ? toggled.open : {};
const isOpen = (category: LabOverviewCategoryDto) =>
own[category.key] ?? stored[category.key] ?? openByDefault(category, mode);
const toggle = (category: LabOverviewCategoryDto) => {
const next = { ...stored, ...own, [category.key]: !isOpen(category) };
setToggled({ mode, open: next });
writeStoredOpen(mode, next);
};
return { isOpen, toggle };
}
/** The status of a category, from the counts the server sent. */
function GroupStatus({ category }: { category: LabOverviewCategoryDto }) {
const { t, tCount } = useTranslations();
if (category.status === "unknown") return null;
if (category.status === "in-range") {
return <ScorePill tone="neutral">{t("labs.groups.allInRange")}</ScorePill>;
}
const { inRange, below, above, unknown } = category.counts;
return (
<ScorePill tone={category.status === "above" ? "warning" : "info"}>
{tCount("labs.groups.outsideCount", above + below, {
total: inRange + below + above + unknown,
})}
</ScorePill>
);
}
/**
* A category header: a heading around one disclosure button holding the
* chevron, the label, the member count and the status, so the status is part
* of what the button announces. No region role: a list can carry more than a
* handful of categories.
*/
export function LabGroupHeader({
category,
open,
onToggle,
panelId,
className,
}: {
category: LabOverviewCategoryDto;
open: boolean;
onToggle: () => void;
panelId: string;
className?: string;
}) {
return (
<h2 className={cn("min-w-0", className)}>
<button
type="button"
data-slot="lab-list-group"
data-category={category.key}
aria-expanded={open}
aria-controls={panelId}
onClick={onToggle}
className="focus-visible:ring-ring/50 flex min-h-11 w-full min-w-0 items-center gap-2 rounded-md text-left outline-none focus-visible:ring-[3px] sm:min-h-9"
>
<ChevronDown
aria-hidden
className={cn(
"text-muted-foreground size-4 shrink-0 transition-transform motion-reduce:transition-none",
!open && "-rotate-90",
)}
/>
<span className="text-muted-foreground min-w-0 truncate text-xs font-medium tracking-wide uppercase">
{category.label}
</span>
<TagChip className="shrink-0 tabular-nums">
{category.markerKeys.length}
</TagChip>
<span className="ml-auto shrink-0">
<GroupStatus category={category} />
</span>
</button>
</h2>
);
}
+305
View File
@@ -0,0 +1,305 @@
"use client";
/**
* One marker of the labs list, in both view shapes: a row of the compact
* five-column table and a tile of the card grid. `lab-list.tsx` decides which
* markers show and in what order (flat, or under their categories); these
* render one marker each.
*/
import Link from "next/link";
import { ChevronRight } from "lucide-react";
import { MedicationCardHeader } from "@/components/medications/medication-card-header";
import { Card, CardContent } from "@/components/ui/card";
import { useTranslations } from "@/lib/i18n/context";
import { labUnitLabel } from "@/lib/labs/dimensionless";
import { formatLabReading } from "@/lib/labs/format-value";
import { formatReferenceRange } from "@/lib/labs/reference-range";
import { cn } from "@/lib/utils";
import { LabConvertedNote } from "./lab-converted-note";
import { LabReferenceRangeBar } from "./lab-reference-range-bar";
import { LabTrendSparkline } from "./lab-trend-sparkline";
import { ReferenceRangeBadge } from "./reference-range-badge";
import { SourceRangeNote } from "./source-range-note";
import type { LabOverviewMarkerDto, LabResultDto } from "./types";
import { useLabDate, useLabNumber } from "./use-lab-format";
export interface MarkerRow {
/** Stable key: `bm:<biomarkerId>`, or `analyte:<lower>` for legacy rows. */
key: string;
/** Link target when the marker is catalog-linked; null for legacy rows. */
biomarkerId: string | null;
analyte: string;
panel: string | null;
/** The marker's unit as the card names it ("No unit" for a ratio). */
unit: string;
/** Every live reading of the marker, counted by the server. */
count: number;
/** Values of the newest readings, oldest first, for the sparkline. */
trend: (number | null)[];
latest: LabResultDto;
}
/**
* One row per marker, as the server resolved it over the whole history. A
* catalog-linked row deep-links to its detail chart; legacy rows render inert
* until the backfill links them. The server already orders by the newest
* reading.
*/
export function toMarkerRows(
markers: LabOverviewMarkerDto[],
t: (key: string) => string,
): MarkerRow[] {
return markers.map((marker) => ({
key: marker.key,
biomarkerId: marker.latest.biomarkerId,
analyte: marker.latest.analyte,
panel: marker.latest.panel,
unit: labUnitLabel(marker.latest.unit, t),
count: marker.count,
trend: marker.trend,
latest: marker.latest,
}));
}
/**
* The compact list is a five-column table from `lg` up: name and reading, range
* badge, range bar, trend, chevron. Below that the same five cells fold into
* two lines per row, because the fixed columns alone (badge + 12rem bar + 72px
* trend + gaps) are wider than a phone, and the only flexible column — the
* name — was the one that gave way: it collapsed to nothing and the row
* panned sideways off the card.
*
* Phone layout, per row:
*
* name / reading · date badge ›
* range bar trend
*
* The placement classes are all `max-lg:` so the desktop table keeps exactly
* the subgrid it had.
*
* `@container` on the card makes the columns follow the width the list has,
* not the window's: below `@5xl` (64rem of card) the row is the `lg` table it
* always was (the two tiers exclude each other, so the result does not depend
* on the order the CSS is emitted in), from there up the range bar takes part
* of the extra width instead of all of it going to the name. The trend column
* stays 72px in both tiers: the sparkline is a fixed 72px glyph, and a wider
* column would only open an empty strip between it and the chevron.
*/
export const LIST_GRID_CLASS =
"divide-border grid grid-cols-1 divide-y p-0 lg:gap-x-3 lg:@max-5xl:grid-cols-[minmax(0,1fr)_max-content_12rem_72px_auto] @5xl:grid-cols-[minmax(0,1fr)_max-content_minmax(12rem,18rem)_72px_auto]";
/** A block of rows inside the table that keeps the table's five columns. */
export const LIST_SUBGRID_CLASS =
"divide-border grid min-w-0 grid-cols-1 divide-y lg:col-span-full lg:grid-cols-subgrid";
const ROW_CLASS =
"grid min-w-0 grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-x-3 gap-y-2 px-4 py-2.5 lg:col-span-full lg:grid-cols-subgrid lg:gap-y-0";
const MOBILE_CELL = {
reading: "max-lg:col-start-1 max-lg:row-start-1",
badge: "max-lg:col-start-2 max-lg:row-start-1 max-lg:justify-self-end",
rangeBar: "max-lg:col-start-1 max-lg:row-start-2 max-lg:empty:hidden",
trend:
"max-lg:col-start-2 max-lg:row-start-2 max-lg:justify-self-end max-lg:empty:hidden",
end: "max-lg:col-start-3 max-lg:row-start-1",
notLinked: "max-lg:col-span-full max-lg:row-start-3",
} as const;
/**
* The badge's grid cell. The badge renders nothing for a reading without a
* reference range, and a missing cell would pull the range bar and the trend
* one column to the left; the wrapper keeps every row at five cells.
*/
function RangeBadgeCell({ status }: { status: LabResultDto["rangeStatus"] }) {
return (
<div data-slot="lab-list-badge-cell" className={MOBILE_CELL.badge}>
<ReferenceRangeBadge status={status} compact className="lg:w-full" />
</div>
);
}
function LabRangeBarSlot({ reading }: { reading: LabResultDto }) {
return (
<div className={cn("w-full", MOBILE_CELL.rangeBar)}>
{/* A phone keeps the bar's own 12rem cap; from `lg` the bar fills its
column, which is 12rem until the list is wide enough to grow it. */}
<LabReferenceRangeBar
className="lg:max-w-none"
value={reading.value}
referenceLow={reading.referenceLow}
referenceHigh={reading.referenceHigh}
unit={reading.unit}
/>
</div>
);
}
function TrendCell({ row }: { row: MarkerRow }) {
return (
<div className={cn("w-[72px]", MOBILE_CELL.trend)}>
<LabTrendSparkline
values={row.trend}
referenceLow={row.latest.referenceLow}
referenceHigh={row.latest.referenceHigh}
/>
</div>
);
}
/** One marker as a row of the compact table. */
export function LabListRow({ row }: { row: MarkerRow }) {
const { t } = useTranslations();
const labNumber = useLabNumber();
const labDate = useLabDate();
const inner = (
<div className={cn("min-w-0 flex-1", MOBILE_CELL.reading)}>
<div className="flex flex-wrap items-baseline gap-x-2 text-sm">
{/* One line with an ellipsis on a phone; from `lg` the name wraps
instead, so a long one (the schema allows 120 characters) always
reads in full. It wraps between words and hyphenates in the page
language; a word is only cut when it is wider than the column on
its own (`anywhere` cut "Hämoglobin" in the middle of the word
whenever the column was a little short). */}
<span
data-slot="lab-list-analyte"
className="truncate font-medium lg:overflow-visible lg:break-words lg:text-clip lg:hyphens-auto lg:whitespace-normal"
>
{row.analyte}
</span>
</div>
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 text-xs">
<span className="text-foreground font-semibold tabular-nums">
{formatLabReading(row.latest, labNumber)}
</span>
<span>{labDate(row.latest.takenAt)}</span>
<LabConvertedNote reading={row.latest} />
</div>
</div>
);
// The whole row navigates into the detail (where delete lives).
if (row.biomarkerId) {
return (
<Link
href={`/labs/${row.biomarkerId}`}
className={cn(ROW_CLASS, "hover:bg-muted/40 transition-colors")}
>
{inner}
<RangeBadgeCell status={row.latest.rangeStatus} />
<LabRangeBarSlot reading={row.latest} />
<TrendCell row={row} />
{/* With a pointer the row's hover already says it opens; a phone has
no hover, so the chevron stays there. */}
<ChevronRight
className={cn(
"text-muted-foreground h-4 w-4 shrink-0 lg:invisible",
MOBILE_CELL.end,
)}
/>
</Link>
);
}
// Un-linked marker (not backfilled to a catalog biomarker): render inert
// but say so, so it doesn't read as a broken link next to its clickable
// neighbours.
return (
<div className={ROW_CLASS}>
{inner}
<RangeBadgeCell status={row.latest.rangeStatus} />
<LabRangeBarSlot reading={row.latest} />
<TrendCell row={row} />
<span
className={cn(
"text-muted-foreground shrink-0 text-xs italic",
MOBILE_CELL.notLinked,
)}
>
{t("labs.notLinkedYet")}
</span>
</div>
);
}
/**
* One marker as a tile. The tile reuses the medication module's
* `MedicationCardHeader` so a lab tile reads identically to a medication or
* Vorsorge tile side-by-side: name on line 1, the reference-range badge as the
* line-1 chip, the panel as the category badge. Under a category heading the
* panel would say the heading again, so the badge names the unit there. No
* delete control on the tile; the trash icon lives only on the value detail
* view.
*/
export function LabCard({
row,
underCategory,
}: {
row: MarkerRow;
underCategory: boolean;
}) {
const { t } = useTranslations();
const labNumber = useLabNumber();
const labDate = useLabDate();
const latest = row.latest;
return (
<li className="contents">
<Card className="h-full gap-3">
<MedicationCardHeader
name={row.analyte}
dose=""
categoryLabel={underCategory ? row.unit : (row.panel ?? row.unit)}
nameChip={<ReferenceRangeBadge status={latest.rangeStatus} compact />}
href={row.biomarkerId ? `/labs/${row.biomarkerId}` : undefined}
linkLabel={row.analyte}
/>
<CardContent>
<div className="flex items-end justify-between gap-3">
<div className="text-muted-foreground flex min-w-0 flex-1 flex-wrap items-center gap-x-2 text-sm">
<span className="text-foreground font-semibold tabular-nums">
{formatLabReading(latest, labNumber)}
</span>
{latest.value !== null &&
(latest.referenceLow !== null ||
latest.referenceHigh !== null) ? (
<span className="text-xs">
{t("labs.referenceLabel")}{" "}
{formatReferenceRange(
latest.referenceLow,
latest.referenceHigh,
labNumber,
)}
</span>
) : null}
<SourceRangeNote reading={latest} className="text-xs" />
<span className="text-xs">{labDate(latest.takenAt)}</span>
<LabConvertedNote reading={latest} />
{row.count > 1 ? (
<span className="text-xs">
{t("labs.readingsCount", { count: row.count })}
</span>
) : null}
<LabReferenceRangeBar
value={latest.value}
referenceLow={latest.referenceLow}
referenceHigh={latest.referenceHigh}
unit={latest.unit}
/>
</div>
<LabTrendSparkline
values={row.trend}
referenceLow={latest.referenceLow}
referenceHigh={latest.referenceHigh}
/>
</div>
{/* Un-linked marker: the header renders no link, so name the reason
rather than leaving a card that silently does nothing on tap. */}
{!row.biomarkerId ? (
<p className="text-muted-foreground text-xs italic">
{t("labs.notLinkedYet")}
</p>
) : null}
</CardContent>
</Card>
</li>
);
}
+186 -322
View File
@@ -1,133 +1,186 @@
"use client";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useMemo, useEffect } from "react";
import Link from "next/link";
import { useId, useMemo, useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { ChevronRight, FlaskConical } from "lucide-react";
import { FlaskConical } from "lucide-react";
import { MedicationCardHeader } from "@/components/medications/medication-card-header";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Skeleton } from "@/components/ui/skeleton";
import { apiGet } from "@/lib/api/api-fetch";
import { formatReferenceRange } from "@/lib/labs/reference-range";
import { formatLabReading } from "@/lib/labs/format-value";
import { LabConvertedNote } from "./lab-converted-note";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { queryKeys } from "@/lib/query-keys";
import { applyOrder, useModuleListPrefs } from "@/lib/module-list-prefs";
import {
applyOrder,
useModuleListPrefs,
type ModuleListPrefs,
} from "@/lib/module-list-prefs";
import { LabTrendSparkline } from "./lab-trend-sparkline";
import { useLabDate, useLabNumber } from "./use-lab-format";
import { LabReferenceRangeBar } from "./lab-reference-range-bar";
import { ReferenceRangeBadge } from "./reference-range-badge";
import { SourceRangeNote } from "./source-range-note";
import type {
LabOverviewMarkerDto,
LabOverviewResponse,
LabResultDto,
} from "./types";
import { labUnitLabel } from "@/lib/labs/dimensionless";
interface MarkerGroup {
/** Stable group key: `bm:<biomarkerId>`, or `analyte:<lower>` for legacy rows. */
key: string;
/** Link target when the group is catalog-linked; null for legacy rows. */
biomarkerId: string | null;
analyte: string;
panel: string | null;
/** The marker's unit as the card names it ("No unit" for a ratio). */
unit: string;
/** Every live reading of the marker, counted by the server. */
count: number;
/** Values of the newest readings, oldest first, for the sparkline. */
trend: (number | null)[];
latest: LabResultDto;
}
import {
groupMarkerRows,
LabGroupHeader,
useGroupsOpen,
type MarkerGroup,
} from "./lab-list-groups";
import {
LabCard,
LabListRow,
LIST_GRID_CLASS,
LIST_SUBGRID_CLASS,
toMarkerRows,
type MarkerRow,
} from "./lab-list-rows";
import type { LabOverviewResponse } from "./types";
/**
* One group per marker, as the server resolved it over the whole history. A
* catalog-linked group deep-links to its detail chart; legacy groups render
* inert until the backfill links them. The server already orders by the
* newest reading.
* The person's Labs sort over the server's most-recent-first order:
* `recentAsc` reverses, the alpha options sort by analyte name (#43), and
* `manual` applies the persisted biomarker order (legacy un-linked rows, which
* carry no biomarkerId, sort after the ordered block). Under categories the
* same order holds inside each category.
*/
function toGroups(
markers: LabOverviewMarkerDto[],
t: (key: string) => string,
): MarkerGroup[] {
return markers.map((marker) => ({
key: marker.key,
biomarkerId: marker.latest.biomarkerId,
analyte: marker.latest.analyte,
panel: marker.latest.panel,
unit: labUnitLabel(marker.latest.unit, t),
count: marker.count,
trend: marker.trend,
latest: marker.latest,
}));
function sortRows(rows: MarkerRow[], prefs: ModuleListPrefs): MarkerRow[] {
if (prefs.sortDir === "recentAsc") return [...rows].reverse();
if (prefs.sortDir === "alphaAsc" || prefs.sortDir === "alphaDesc") {
const dir = prefs.sortDir === "alphaAsc" ? 1 : -1;
return [...rows].sort(
(a, b) =>
dir *
a.analyte.localeCompare(b.analyte, undefined, { sensitivity: "base" }),
);
}
if (prefs.sortDir === "manual") {
return applyOrder(rows, prefs.order, (row) => row.biomarkerId ?? row.key);
}
return rows;
}
/**
* The badge's grid cell. The badge renders nothing for a reading without a
* reference range, and a missing cell would pull the range bar and the trend
* one column to the left; the wrapper keeps every row at five cells.
*/
function RangeBadgeCell({ status }: { status: LabResultDto["rangeStatus"] }) {
type GroupsOpen = ReturnType<typeof useGroupsOpen>;
/** The compact table: one bordered card of divided rows (#40). */
function LabTable({
rows,
groups,
groupsOpen,
}: {
rows: MarkerRow[];
groups: MarkerGroup[] | null;
groupsOpen: GroupsOpen;
}) {
return (
<div data-slot="lab-list-badge-cell" className={MOBILE_CELL.badge}>
<ReferenceRangeBadge status={status} compact className="lg:w-full" />
// `lab-list` marks the read results in both view shapes. The loading
// branch carries `lab-list-loading` instead, so the marker cannot stand
// for a silhouette.
<div data-slot="lab-list" className="space-y-3">
<Card className="@container">
<CardContent className={LIST_GRID_CLASS}>
{groups
? groups.map((group) => (
<TableGroup
key={group.category.key}
group={group}
groupsOpen={groupsOpen}
/>
))
: rows.map((row) => <LabListRow key={row.key} row={row} />)}
</CardContent>
</Card>
</div>
);
}
function LabRangeBarSlot({ reading }: { reading: LabResultDto }) {
function useGroupToggle(group: MarkerGroup, groupsOpen: GroupsOpen) {
const panelId = `${useId()}-panel`;
return {
open: groupsOpen.isOpen(group.category),
onToggle: () => groupsOpen.toggle(group.category),
panelId,
};
}
/**
* One category inside the table. The header spans every column and the rows
* below keep the table's subgrid, so the columns line up across categories.
*/
function TableGroup({
group,
groupsOpen,
}: {
group: MarkerGroup;
groupsOpen: GroupsOpen;
}) {
const { open, onToggle, panelId } = useGroupToggle(group, groupsOpen);
return (
<div className={cn("w-full", MOBILE_CELL.rangeBar)}>
{/* A phone keeps the bar's own 12rem cap; from `lg` the bar fills its
column, which is 12rem until the list is wide enough to grow it. */}
<LabReferenceRangeBar
className="lg:max-w-none"
value={reading.value}
referenceLow={reading.referenceLow}
referenceHigh={reading.referenceHigh}
unit={reading.unit}
<div className={cn(LIST_SUBGRID_CLASS, "divide-y-0")}>
<LabGroupHeader
category={group.category}
open={open}
onToggle={onToggle}
panelId={panelId}
className="px-4 py-1 lg:col-span-full"
/>
<div
id={panelId}
hidden={!open}
className={cn(LIST_SUBGRID_CLASS, "border-border border-t")}
>
{open
? group.rows.map((row) => <LabListRow key={row.key} row={row} />)
: null}
</div>
</div>
);
}
/**
* The compact list is a five-column table from `lg` up: name and reading, range
* badge, range bar, trend, chevron. Below that the same five cells fold into
* two lines per row, because the fixed columns alone (badge + 12rem bar + 72px
* trend + gaps) are wider than a phone, and the only flexible column — the
* name — was the one that gave way: it collapsed to nothing and the row
* panned sideways off the card.
*
* Phone layout, per row:
*
* name / reading · date badge ›
* range bar trend
*
* The placement classes are all `max-lg:` so the desktop table keeps exactly
* the subgrid it had.
*/
const ROW_CLASS =
"grid min-w-0 grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-x-3 gap-y-2 px-4 py-2.5 lg:col-span-full lg:grid-cols-subgrid lg:gap-y-0";
function CardGrid({
rows,
underCategory,
}: {
rows: MarkerRow[];
underCategory: boolean;
}) {
return (
<ul
className={cn(
"grid list-none gap-4 p-0",
// A lone lab spans the full row rather than orphaning half of it;
// two or more fall into the two-up grid.
rows.length > 1 && "sm:grid-cols-2",
)}
>
{rows.map((row) => (
<LabCard key={row.key} row={row} underCategory={underCategory} />
))}
</ul>
);
}
const MOBILE_CELL = {
reading: "max-lg:col-start-1 max-lg:row-start-1",
badge: "max-lg:col-start-2 max-lg:row-start-1 max-lg:justify-self-end",
rangeBar: "max-lg:col-start-1 max-lg:row-start-2 max-lg:empty:hidden",
trend:
"max-lg:col-start-2 max-lg:row-start-2 max-lg:justify-self-end max-lg:empty:hidden",
end: "max-lg:col-start-3 max-lg:row-start-1",
notLinked: "max-lg:col-span-full max-lg:row-start-3",
} as const;
function CardGroup({
group,
groupsOpen,
}: {
group: MarkerGroup;
groupsOpen: GroupsOpen;
}) {
const { open, onToggle, panelId } = useGroupToggle(group, groupsOpen);
return (
<section className="space-y-4">
<LabGroupHeader
category={group.category}
open={open}
onToggle={onToggle}
panelId={panelId}
/>
<div id={panelId} hidden={!open}>
{open ? <CardGrid rows={group.rows} underCategory /> : null}
</div>
</section>
);
}
export function LabList({
onAddFirst,
@@ -137,12 +190,11 @@ export function LabList({
/** Told whether the list is empty, so the page can drop its header add. */
onEmptyChange?: (empty: boolean) => void;
} = {}) {
const { t } = useTranslations();
const labNumber = useLabNumber();
const labDate = useLabDate();
const { t, locale } = useTranslations();
const { canWriteDomain } = useRecordCapabilities();
const canAddLab = canWriteDomain("labs");
const { prefs } = useModuleListPrefs("labs");
const groupsOpen = useGroupsOpen(prefs.groupsOpen);
// v1.22 — a short, factual line under each marker heading describing what the
// biomarker measures, sourced from the catalog via i18n. Free-text markers
@@ -152,35 +204,24 @@ export function LabList({
// One entry per biomarker over the whole history, grouped server-side: a
// capped page of raw rows dropped every marker measured only long ago.
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: queryKeys.labResultsOverview(),
queryKey: queryKeys.labResultsOverview(locale),
queryFn: () => apiGet<LabOverviewResponse>("/api/labs/overview"),
});
const groups = useMemo(() => {
const base = toGroups(data?.markers ?? [], t);
// v1.18.6 — honour the user's Labs sort choice. The server
// order is most-recent-first; `recentAsc` reverses, the alpha
// options sort by analyte name (#43), and `manual` applies the persisted
// biomarker order (legacy un-linked groups, which carry no biomarkerId,
// sort after the ordered block).
if (prefs.sortDir === "recentAsc") return [...base].reverse();
if (prefs.sortDir === "alphaAsc" || prefs.sortDir === "alphaDesc") {
const dir = prefs.sortDir === "alphaAsc" ? 1 : -1;
return [...base].sort(
(a, b) =>
dir *
a.analyte.localeCompare(b.analyte, undefined, {
sensitivity: "base",
}),
);
}
if (prefs.sortDir === "manual") {
return applyOrder(base, prefs.order, (g) => g.biomarkerId ?? g.key);
}
return base;
}, [data?.markers, prefs.sortDir, prefs.order, t]);
const rows = useMemo(
() => sortRows(toMarkerRows(data?.markers ?? [], t), prefs),
[data?.markers, prefs, t],
);
// Categories only when the person asked for them and the server says they
// add something; otherwise the flat list it always was.
const grouped = prefs.groupBy === "category" && data?.grouped === true;
const categories = data?.categories;
const groups = useMemo(
() => (grouped && categories ? groupMarkerRows(rows, categories) : null),
[grouped, categories, rows],
);
const listEmpty = !isLoading && !isError && groups.length === 0;
const listEmpty = !isLoading && !isError && rows.length === 0;
useEffect(() => {
onEmptyChange?.(listEmpty);
}, [listEmpty, onEmptyChange]);
@@ -215,7 +256,7 @@ export function LabList({
);
}
if (groups.length === 0) {
if (rows.length === 0) {
return (
<EmptyState
icon={<FlaskConical className="size-6" />}
@@ -234,206 +275,29 @@ export function LabList({
// not on these overview rows. The list/tile rows navigate; the trash icon was
// removed here so a stray tap can't delete a biomarker from the overview.
// v1.18.6 — compact list view: one bordered card holding tight
// divided rows instead of a card per biomarker. This is the default view
// (#40); the card/tile view is the alternative the settings toggle selects.
// v1.18.6 — compact list view is the default (#40); the card/tile view is
// the alternative the settings toggle selects.
if (prefs.view === "list") {
return <LabTable rows={rows} groups={groups} groupsOpen={groupsOpen} />;
}
// v1.18.9 (#40) — card/tile view.
if (groups) {
return (
// `lab-list` marks the read results in both view shapes. The loading
// branch above carries `lab-list-loading` instead, so the marker
// cannot stand for a silhouette.
<div data-slot="lab-list" className="space-y-3">
{/* `@container` makes the columns follow the width the list has, not the
window's: below `@5xl` (64rem of card) the row is the `lg` table it
always was (the two tiers exclude each other, so the result does not
depend on the order the CSS is emitted in), from there up the range
bar takes part of the extra width instead of all of it going to the
name. The trend column stays 72px in both tiers: the sparkline is a
fixed 72px glyph, and a wider column would only open an empty strip
between it and the chevron. */}
<Card className="@container">
<CardContent className="divide-border grid grid-cols-1 divide-y p-0 lg:gap-x-3 lg:@max-5xl:grid-cols-[minmax(0,1fr)_max-content_12rem_72px_auto] @5xl:grid-cols-[minmax(0,1fr)_max-content_minmax(12rem,18rem)_72px_auto]">
{groups.map((group) => {
const inner = (
<div className={cn("min-w-0 flex-1", MOBILE_CELL.reading)}>
<div className="flex flex-wrap items-baseline gap-x-2 text-sm">
{/* One line with an ellipsis on a phone; from `lg` the name
wraps instead, so a long one (the schema allows 120
characters) always reads in full. It wraps between
words and hyphenates in the page language; a word is
only cut when it is wider than the column on its own
(`anywhere` cut "Hämoglobin" in the middle of the
word whenever the column was a little short). */}
<span
data-slot="lab-list-analyte"
className="truncate font-medium lg:overflow-visible lg:break-words lg:text-clip lg:hyphens-auto lg:whitespace-normal"
>
{group.analyte}
</span>
</div>
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 text-xs">
<span className="text-foreground font-semibold tabular-nums">
{formatLabReading(group.latest, labNumber)}
</span>
<span>{labDate(group.latest.takenAt)}</span>
<LabConvertedNote reading={group.latest} />
</div>
</div>
);
// The whole row navigates into the detail (where delete lives).
return group.biomarkerId ? (
<Link
key={group.key}
href={`/labs/${group.biomarkerId}`}
className={cn(
ROW_CLASS,
"hover:bg-muted/40 transition-colors",
)}
>
{inner}
<RangeBadgeCell status={group.latest.rangeStatus} />
<LabRangeBarSlot reading={group.latest} />
<div className={cn("w-[72px]", MOBILE_CELL.trend)}>
<LabTrendSparkline
values={group.trend}
referenceLow={group.latest.referenceLow}
referenceHigh={group.latest.referenceHigh}
/>
</div>
{/* With a pointer the row's hover already says it opens; a
phone has no hover, so the chevron stays there. */}
<ChevronRight
className={cn(
"text-muted-foreground h-4 w-4 shrink-0 lg:invisible",
MOBILE_CELL.end,
)}
/>
</Link>
) : (
// Un-linked group (not backfilled to a catalog biomarker):
// render inert but say so, so it doesn't read as a broken
// link next to its clickable neighbours.
<div key={group.key} className={ROW_CLASS}>
{inner}
<RangeBadgeCell status={group.latest.rangeStatus} />
<LabRangeBarSlot reading={group.latest} />
<div className={cn("w-[72px]", MOBILE_CELL.trend)}>
<LabTrendSparkline
values={group.trend}
referenceLow={group.latest.referenceLow}
referenceHigh={group.latest.referenceHigh}
/>
</div>
<span
className={cn(
"text-muted-foreground shrink-0 text-xs italic",
MOBILE_CELL.notLinked,
)}
>
{t("labs.notLinkedYet")}
</span>
</div>
);
})}
</CardContent>
</Card>
<div data-slot="lab-list" className="space-y-10">
{groups.map((group) => (
<CardGroup
key={group.category.key}
group={group}
groupsOpen={groupsOpen}
/>
))}
</div>
);
}
// v1.18.9 (#40) — card/tile view. The tile reuses the medication module's
// `MedicationCardHeader` so a lab tile reads identically to a medication or
// Vorsorge tile side-by-side: name on line 1, the reference-range badge as
// the line-1 chip, the panel as the category badge. v1.18.10 (#3) — no delete
// control on the tile; the trash icon lives only on the value detail view.
return (
<ul
data-slot="lab-list"
className={cn(
"grid list-none gap-4 p-0",
// A lone lab spans the full row rather than orphaning half of it;
// two or more fall into the two-up grid.
groups.length > 1 && "sm:grid-cols-2",
)}
>
{groups.map((group) => {
return (
<li key={group.key} className="contents">
<Card className="h-full gap-3">
<MedicationCardHeader
name={group.analyte}
dose=""
categoryLabel={group.panel ?? group.unit}
nameChip={
<ReferenceRangeBadge
status={group.latest.rangeStatus}
compact
/>
}
href={
group.biomarkerId ? `/labs/${group.biomarkerId}` : undefined
}
linkLabel={group.analyte}
/>
<CardContent>
<div className="flex items-end justify-between gap-3">
<div className="text-muted-foreground flex min-w-0 flex-1 flex-wrap items-center gap-x-2 text-sm">
<span className="text-foreground font-semibold tabular-nums">
{formatLabReading(group.latest, labNumber)}
</span>
{group.latest.value !== null &&
(group.latest.referenceLow !== null ||
group.latest.referenceHigh !== null) ? (
<span className="text-xs">
{t("labs.referenceLabel")}{" "}
{formatReferenceRange(
group.latest.referenceLow,
group.latest.referenceHigh,
labNumber,
)}
</span>
) : null}
<SourceRangeNote
reading={group.latest}
className="text-xs"
/>
<span className="text-xs">
{labDate(group.latest.takenAt)}
</span>
<LabConvertedNote reading={group.latest} />
{group.count > 1 ? (
<span className="text-xs">
{t("labs.readingsCount", {
count: group.count,
})}
</span>
) : null}
<LabReferenceRangeBar
value={group.latest.value}
referenceLow={group.latest.referenceLow}
referenceHigh={group.latest.referenceHigh}
unit={group.latest.unit}
/>
</div>
<LabTrendSparkline
values={group.trend}
referenceLow={group.latest.referenceLow}
referenceHigh={group.latest.referenceHigh}
/>
</div>
{/* Un-linked group: the header renders no link, so name the
reason rather than leaving a card that silently does
nothing on tap. */}
{!group.biomarkerId ? (
<p className="text-muted-foreground text-xs italic">
{t("labs.notLinkedYet")}
</p>
) : null}
</CardContent>
</Card>
</li>
);
})}
</ul>
<div data-slot="lab-list">
<CardGrid rows={rows} underCategory={false} />
</div>
);
}
+78 -2
View File
@@ -5,7 +5,8 @@
*
* `LabsSettings` — Settings > Arrangement > Labs: the card-vs-list view
* (MOD-03), the sort (recency, alphabetical or manual, MOD-04) and the manual
* order of the biomarkers. Order and view only, persisted per account through
* order of the biomarkers, and whether the list shows its categories and
* which of them start open. Persisted per account through
* `useModuleListPrefs("labs")`.
*
* `LabsModuleSettings` — Settings > Modules > Labs: the on-device text
@@ -17,6 +18,7 @@ import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import {
ArrowDownUp,
FlaskConical,
FolderTree,
LayoutGrid,
ListOrdered,
ScanText,
@@ -27,6 +29,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
import { NativeSelect } from "@/components/ui/native-select";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { Segmented } from "@/components/ui/segmented";
import { Switch } from "@/components/ui/switch";
import { apiGet } from "@/lib/api/api-fetch";
import { showErrorToast } from "@/lib/errors/error-toast";
@@ -40,6 +43,8 @@ import {
import {
applyOrder,
useModuleListPrefs,
type ModuleGroupBy,
type ModuleGroupsOpen,
type ModuleSortDir,
} from "@/lib/module-list-prefs";
@@ -50,7 +55,8 @@ import { labUnitLabel } from "@/lib/labs/dimensionless";
export function LabsSettings() {
const { t } = useTranslations();
const { prefs, setView, setOrder, setSortDir } = useModuleListPrefs("labs");
const { prefs, setView, setOrder, setSortDir, setGroupBy, setGroupsOpen } =
useModuleListPrefs("labs");
const { data, isLoading } = useQuery({
queryKey: queryKeys.biomarkers(),
@@ -107,11 +113,23 @@ export function LabsSettings() {
</div>
</SettingsCard>
<LabsGroupingCard
groupBy={prefs.groupBy}
groupsOpen={prefs.groupsOpen}
onGroupBy={setGroupBy}
onGroupsOpen={setGroupsOpen}
/>
{manual ? (
<SettingsCard id="labs-order" className="scroll-mt-28">
<SettingsCardHeader
icon={ListOrdered}
title={t("moduleList.reorder.heading")}
description={
prefs.groupBy === "category"
? t("labs.groups.orderWithinGroups")
: undefined
}
/>
{isLoading ? (
<div className="space-y-2">
@@ -128,6 +146,64 @@ export function LabsSettings() {
);
}
/**
* Categories on or off, and which of them start open. The second choice only
* shows while categories are on; it means nothing for a flat list.
*/
function LabsGroupingCard({
groupBy,
groupsOpen,
onGroupBy,
onGroupsOpen,
}: {
groupBy: ModuleGroupBy;
groupsOpen: ModuleGroupsOpen;
onGroupBy: (value: ModuleGroupBy) => void;
onGroupsOpen: (value: ModuleGroupsOpen) => void;
}) {
const { t } = useTranslations();
return (
<SettingsCard id="labs-groups" className="scroll-mt-28">
<SettingsCardHeader
icon={FolderTree}
title={t("labs.groups.heading")}
description={t("labs.groups.description")}
/>
<div className="space-y-2">
<p className="text-sm font-medium">{t("labs.groups.label")}</p>
<Segmented
mode="toggle"
value={groupBy}
onChange={onGroupBy}
label={t("labs.groups.label")}
optionSlot={(value) => `labs-group-by-${value}`}
options={[
{ value: "none", label: t("labs.groups.none") },
{ value: "category", label: t("labs.groups.category") },
]}
/>
</div>
{groupBy === "category" ? (
<div className="space-y-2" data-slot="labs-groups-open">
<p className="text-sm font-medium">{t("labs.groups.openLabel")}</p>
<Segmented
mode="toggle"
value={groupsOpen}
onChange={onGroupsOpen}
label={t("labs.groups.openLabel")}
optionSlot={(value) => `labs-groups-open-${value}`}
options={[
{ value: "expanded", label: t("labs.groups.openExpanded") },
{ value: "flagged", label: t("labs.groups.openFlagged") },
{ value: "collapsed", label: t("labs.groups.openCollapsed") },
]}
/>
</div>
) : null}
</SettingsCard>
);
}
export function LabsModuleSettings() {
const { t } = useTranslations();
// v1.18.10 — local (in-browser) OCR opt-in. Lets a user whose AI provider
+21
View File
@@ -80,8 +80,22 @@ export interface LabOverviewMarkerDto {
latest: LabResultDto;
}
/** One category of the labs list, resolved by the server; rendered as given. */
export interface LabOverviewCategoryDto {
/** Opaque: a catalog panel key, `custom:<hash>`, or `other`. */
key: string;
label: string;
/** The `markers[].key` values in this category. */
markerKeys: string[];
counts: { inRange: number; below: number; above: number; unknown: number };
status: LabResultDto["rangeStatus"];
}
export interface LabOverviewResponse {
markers: LabOverviewMarkerDto[];
categories: LabOverviewCategoryDto[];
/** True when grouping adds anything; a flat list otherwise. */
grouped: boolean;
}
/** A user-scoped Biomarker catalog entry as the API serialises it. */
@@ -147,6 +161,11 @@ export type BiomarkerTrendDto =
export interface BiomarkerListResponse {
biomarkers: BiomarkerDto[];
/**
* The person's own panels that are no catalog panel, one spelling each,
* for the panel suggestions.
*/
customPanels: string[];
}
/** v1.43 — `POST /api/biomarkers/{id}/merge`, for the preview and the result. */
@@ -164,5 +183,7 @@ export interface BiomarkerMergeResultDto {
movedReadings: number;
convertedReadings: number;
aliasesAdded: string[];
/** The panel the target takes over from a source, or null. */
adoptedPanel: string | null;
mergeId: string | null;
}
@@ -101,6 +101,16 @@ describe("GlitchTip forwarder — path-segment secret redaction", () => {
expect(input.url).not.toContain(shareToken);
});
it("does not forward a cycle calendar-feed token verbatim", async () => {
// Built at run time so no token-shaped literal sits in the source.
const feedToken = `hlf_${"3c".repeat(24)}`;
const input = await forwardedPayload(
`http://localhost/api/cycle/feed/${feedToken}.ics`,
);
expect(input.url).not.toContain(feedToken);
expect(input.url).toContain("/api/cycle/feed/");
});
it("still strips the query string", async () => {
const input = await forwardedPayload(
"http://localhost/api/auth/oidc/callback?code=abc123&state=xyz789",
+7 -1
View File
@@ -62,7 +62,13 @@ describe("parseModuleListPrefs", () => {
JSON.stringify({ view: "grid", sortDir: "weird", order: ["a", "b"] }),
defaults,
),
).toEqual({ view: "cards", sortDir: "recentDesc", order: ["a", "b"] });
).toEqual({
view: "cards",
sortDir: "recentDesc",
order: ["a", "b"],
groupBy: "none",
groupsOpen: "expanded",
});
});
it("applies the labs default (list view, #40) when nothing is stored", () => {
+4
View File
@@ -14,6 +14,10 @@ describe("looksSecretShaped", () => {
["an MCP authorization code", "hlac_eyJ0eXAi"],
["an MCP refresh token", "hlrt_eyJ0eXAi"],
["an MCP client id", "hlc_eyJ0eXAi"],
[
"a cycle calendar-feed token",
`url /api/cycle/feed/hlf_${"9e".repeat(24)}.ics`,
],
["an OpenAI key", "echoed sk-1234567890"],
["an Anthropic key", "sk-ant-api03-xyz12345"],
])("matches %s", (_label, text) => {
@@ -190,6 +190,59 @@ describe("buildCycleSnapshotBlock", () => {
/descriptive pattern, not a cause/,
);
});
it("carries the projected later periods as ranges (#1350)", async () => {
prismaMock.cycleProfile.findUnique.mockResolvedValue(profile());
// Thirteen regular cycles: past the cold start, so the projection reaches
// beyond the next period.
const many = Array.from({ length: 13 }, (_, i) => {
const start = new Date(Date.UTC(2025, 3, 7 + i * 28));
return {
startDate: start.toISOString().slice(0, 10),
endDate: null,
periodEndDate: new Date(start.getTime() + 4 * 86_400_000)
.toISOString()
.slice(0, 10),
ovulationDate: null,
ovulationConfirmed: false,
};
});
prismaMock.menstrualCycle.findMany.mockResolvedValue(many);
const block = await buildCycleSnapshotBlock("u1", "FEMALE", NOW);
expect(block!.upcoming.length).toBeGreaterThan(1);
expect(block!.upcoming[0].start).toBe(block!.nextEvent!.nextPeriodStart);
expect(block!.upcoming[0].earliestStart).toBe(
block!.nextEvent!.nextPeriodStartLow,
);
for (const u of block!.upcoming) {
expect(Object.keys(u).sort()).toEqual([
"earliestStart",
"latestStart",
"start",
]);
}
});
it("carries no upcoming periods when prediction is off", async () => {
prismaMock.cycleProfile.findUnique.mockResolvedValue(
profile({ predictionEnabled: false }),
);
prismaMock.menstrualCycle.findMany.mockResolvedValue(cycles());
const block = await buildCycleSnapshotBlock("u1", "FEMALE", NOW);
expect(block!.upcoming).toEqual([]);
expect(block!.nextEvent).toBeNull();
});
it("reads day logs from the calendar's default window, not the whole history", async () => {
prismaMock.cycleProfile.findUnique.mockResolvedValue(profile());
prismaMock.menstrualCycle.findMany.mockResolvedValue(cycles());
await buildCycleSnapshotBlock("u1", "FEMALE", NOW);
expect(prismaMock.cycleDayLog.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ date: { gte: "2025-12-22" } }),
}),
);
});
});
describe("buildCycleSnapshotBlock — the reader's units", () => {
+74 -72
View File
@@ -29,14 +29,19 @@ import {
resolveUnitPreferences,
type UnitPreferences,
} from "@/lib/measurements/display-transform";
import { predictCycle, type NightlyTempInput } from "@/lib/cycle";
import { LUTEAL_DEFAULT, type CyclePhase } from "@/lib/cycle/types";
import { resolveLuteal } from "@/lib/cycle";
import type { CyclePhase } from "@/lib/cycle/types";
import {
phaseForDate,
buildPhaseDayMap,
gatedPrediction,
toCycleInputs,
toDayLogInputs,
} from "@/lib/cycle/engine-adapter";
import {
defaultCalendarDayLogStart,
loadCycleEngineInputs,
} from "@/lib/cycle/load-inputs";
import { PROJECTION_MAX_HORIZON, projectCycles } from "@/lib/cycle/projection";
import type { CycleProfileInput } from "@/lib/cycle/types";
import { goalAllowsFertileWindow } from "@/lib/cycle/dto";
import {
@@ -110,12 +115,25 @@ interface NextEventBlock {
fertileWindowEnd?: string;
}
/** One projected period start with its band (#1350). */
interface UpcomingCycleBlock {
start: string;
earliestStart: string;
latestStart: string;
}
export interface CycleSnapshotBlock {
phase: CyclePhase | null;
dayOfCycle: number | null;
goal: string;
cyclesObserved: number;
nextEvent: NextEventBlock | null;
/**
* The next period and the ones after it, widening, self-capped where the
* band passes ±10 days or half a cycle. Empty while prediction is off, while
* the next period is later than its band, or with no logged cycle.
*/
upcoming: UpcomingCycleBlock[];
phaseInsight: PhaseInsightBlock | null;
}
@@ -206,80 +224,47 @@ export async function buildCycleSnapshotBlock(
const today = moodDateKey(now, tz);
const from = addDays(today, -WINDOW_DAYS);
const [cycles, dayLogRows, nightlyTempRows, measurementRows, userRow] =
await Promise.all([
prisma.menstrualCycle.findMany({
where: { userId, deletedAt: null },
orderBy: { startDate: "asc" },
}),
prisma.cycleDayLog.findMany({
where: { userId, deletedAt: null },
orderBy: { date: "asc" },
select: {
date: true,
flow: true,
basalBodyTempC: true,
temperatureExcluded: true,
ovulationTest: true,
cervicalMucus: true,
cervixPosition: true,
cervixFirmness: true,
cervixOpening: true,
},
}),
prisma.measurement.findMany({
where: {
userId,
deletedAt: null,
type: "WRIST_TEMPERATURE",
measuredAt: {
gte: startOfLocalDayKey(addDays(today, -90), tz),
},
},
orderBy: { measuredAt: "asc" },
select: { measuredAt: true, value: true },
}),
// A year of steps, heart-rate variability and CGM glucose is far too
// many readings to hold as objects (#1023). The crosstab only needs
// each day's sum and count per source and device, which is what the
// per-source picker decides on, so the fold runs in SQL. Days are the
// user's days, the ones `metricDayMap` keys on and the phase days are
// dated in.
readSourceDayAggregates({
userId,
types: PHASE_CROSSTAB_METRIC_TYPES,
since: startOfLocalDayKey(from, tz),
timeZone: tz,
}),
prisma.user.findUnique({
where: { id: userId },
select: {
sourcePriorityJson: true,
unitPreference: true,
glucoseUnit: true,
},
}),
]);
const [inputs, measurementRows, userRow] = await Promise.all([
// The forecast's inputs through the calendar's own loader and the
// calendar's default day-log window, so "next period" here is the date the
// calendar page shows (`load-inputs.ts`).
loadCycleEngineInputs({
userId,
today,
tz,
dayLogsFrom: defaultCalendarDayLogStart(today),
}),
// A year of steps, heart-rate variability and CGM glucose is far too
// many readings to hold as objects (#1023). The crosstab only needs
// each day's sum and count per source and device, which is what the
// per-source picker decides on, so the fold runs in SQL. Days are the
// user's days, the ones `metricDayMap` keys on and the phase days are
// dated in.
readSourceDayAggregates({
userId,
types: PHASE_CROSSTAB_METRIC_TYPES,
since: startOfLocalDayKey(from, tz),
timeZone: tz,
}),
prisma.user.findUnique({
where: { id: userId },
select: {
sourcePriorityJson: true,
unitPreference: true,
glucoseUnit: true,
},
}),
]);
const { cycles } = inputs;
if (cycles.length === 0) return null;
const lutealLength = profile.lutealPhaseLength ?? LUTEAL_DEFAULT;
// The engine's own clamp, so the phase this block names agrees with the
// calendar's band for an out-of-range stored luteal length.
const lutealLength = resolveLuteal(profileInput);
const goalAllowsFertile = goalAllowsFertileWindow(profile.goal);
const nights: NightlyTempInput[] = nightlyTempRows.map((m) => ({
date: moodDateKey(m.measuredAt, tz),
valueC: m.value,
}));
const prediction =
profile.predictionEnabled && !profile.rawChartMode
? predictCycle(
toCycleInputs(cycles),
toDayLogInputs(dayLogRows),
profileInput,
today,
nights,
)
: null;
const prediction = gatedPrediction(profileInput, inputs, today);
const { phase, dayOfCycle } = phaseForDate(
today,
@@ -318,6 +303,22 @@ export async function buildCycleSnapshotBlock(
};
}
// The cycles after the next one, from the same projection the calendar
// publishes and the calendar feed serves. Dates and bands only.
const projection = projectCycles({
prediction,
cycles: toCycleInputs(cycles),
dayLogs: inputs.dayLogs,
goal: profile.goal,
today,
horizon: PROJECTION_MAX_HORIZON,
});
const upcoming: UpcomingCycleBlock[] = projection.cycles.map((c) => ({
start: c.start,
earliestStart: c.bandStart,
latestStart: c.bandEnd,
}));
// Headline phase insight — the FDR-surviving luteal-vs-follicular contrast.
const phaseByDay = buildPhaseDayMap(
cycles,
@@ -378,6 +379,7 @@ export async function buildCycleSnapshotBlock(
goal: profile.goal,
cyclesObserved: cycles.length,
nextEvent,
upcoming,
phaseInsight,
};
}
+4 -2
View File
@@ -186,7 +186,8 @@ GROUND RULES
12. Cycle data is DESCRIPTIVE ONLY. When a "cycle" block is present
in the SNAPSHOT you may state the user's current phase and
day-of-cycle, the next predicted period as a RANGE (reading
"nextEvent" straight from the block, always conditional), and
"nextEvent" straight from the block, always conditional), the
periods after it only as the ranges "upcoming" carries, and
the headline phase finding ("phaseInsight") in the block's own
observational wording — never as a cause. NEVER frame any
prediction as contraception-grade, NEVER name a "safe day" or
@@ -756,7 +757,8 @@ GRUNDREGELN
"cycle"-Block im SNAPSHOT vorhanden ist, darfst du die aktuelle
Phase und den Zyklustag nennen, die nächste vorhergesagte
Periode als BEREICH ("nextEvent" direkt aus dem Block, immer
konditional) und das Phasen-Hauptmuster ("phaseInsight") in der
konditional), die Perioden danach nur als die Bereiche, die
"upcoming" enthält, und das Phasen-Hauptmuster ("phaseInsight") in der
beobachtenden Formulierung des Blocks — nie als Ursache. Stelle
eine Vorhersage NIE als verhütungstauglich dar, nenne NIE einen
"sicheren" oder "unsicheren" Tag, berate NIE zum
+10
View File
@@ -52,6 +52,7 @@ vi.mock("@/lib/db", () => ({
trustedDevice: { deleteMany: vi.fn() },
mcpOAuthConnection: { updateMany: vi.fn() },
clinicianShareLink: { updateMany: vi.fn() },
cycleCalendarFeed: { updateMany: vi.fn() },
stepUpElevation: { deleteMany: vi.fn() },
$transaction: vi.fn(async (ops: Promise<unknown>[]) => Promise.all(ops)),
},
@@ -584,6 +585,15 @@ describe("destroyAllSessions", () => {
where: { userId: "user-rotated", revokedAt: null },
data: { revokedAt: expect.any(Date) },
});
// The calendar feeds on the record and the ones this account minted on a
// record it manages are both URL credentials, and both end here.
expect(prisma.cycleCalendarFeed.updateMany).toHaveBeenCalledWith({
where: {
OR: [{ userId: "user-rotated" }, { createdById: "user-rotated" }],
revokedAt: null,
},
data: { revokedAt: expect.any(Date) },
});
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
});
});
+9
View File
@@ -15,6 +15,7 @@ type CredentialStore = Pick<
| "trustedDevice"
| "mcpOAuthConnection"
| "clinicianShareLink"
| "cycleCalendarFeed"
| "stepUpElevation"
>;
@@ -56,6 +57,14 @@ export function credentialRevocations(
where: { userId, revokedAt: null },
data: { revokedAt: now },
}),
// A cycle calendar feed is the same kind of door (#1350): a URL that
// serves the record's forecast to whoever holds it. Both the feeds on this
// record and the ones this account minted on a record it manages end here,
// so a remediation never leaves a link it cannot see.
db.cycleCalendarFeed.updateMany({
where: { OR: [{ userId }, { createdById: userId }], revokedAt: null },
data: { revokedAt: now },
}),
db.stepUpElevation.deleteMany({ where: { userId } }),
];
}
+154
View File
@@ -0,0 +1,154 @@
/**
* #1350 — the RFC 5545 writer behind the calendar feed.
*/
import { describe, expect, it } from "vitest";
import {
escapeText,
eventUid,
foldLine,
icsContentHash,
icsStamp,
renderIcs,
type IcsCalendar,
} from "../ics";
const CAL: IcsCalendar = {
name: "HealthLog",
events: [
{
uid: "abc@healthlog.invalid",
start: "2026-11-10",
endExclusive: "2026-11-17",
summary: "●",
description: "±2 days",
},
],
};
const STAMP = new Date("2026-11-06T09:30:00.000Z");
describe("renderIcs", () => {
it("matches the golden body", () => {
expect(renderIcs(CAL, STAMP)).toBe(
[
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//HealthLog//Calendar feed//EN",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
"X-WR-CALNAME:HealthLog",
"REFRESH-INTERVAL;VALUE=DURATION:PT6H",
"X-PUBLISHED-TTL:PT6H",
"BEGIN:VEVENT",
"UID:abc@healthlog.invalid",
"DTSTAMP:20261106T093000Z",
"LAST-MODIFIED:20261106T093000Z",
"DTSTART;VALUE=DATE:20261110",
"DTEND;VALUE=DATE:20261117",
"SUMMARY:●",
"DESCRIPTION:±2 days",
"TRANSP:TRANSPARENT",
"CLASS:PRIVATE",
"END:VEVENT",
"END:VCALENDAR",
"",
].join("\r\n"),
);
});
it("ends every line with CRLF and carries no bare LF", () => {
const body = renderIcs(CAL, STAMP);
expect(body.endsWith("\r\n")).toBe(true);
expect(body.replace(/\r\n/g, "")).not.toMatch(/[\r\n]/);
});
it("writes all-day events with an exclusive DTEND and no time zone", () => {
const body = renderIcs(CAL, STAMP);
expect(body).toContain("DTSTART;VALUE=DATE:20261110");
expect(body).toContain("DTEND;VALUE=DATE:20261117");
expect(body).not.toContain("VTIMEZONE");
expect(body).not.toContain("TZID");
});
it("refuses a malformed day rather than writing a broken calendar", () => {
expect(() =>
renderIcs(
{ name: "x", events: [{ ...CAL.events[0], start: "2026-11-1" }] },
STAMP,
),
).toThrow();
});
});
describe("escapeText", () => {
it("escapes backslash, semicolon, comma and newline", () => {
expect(escapeText("a\\b;c,d\ne")).toBe("a\\\\b\\;c\\,d\\ne");
expect(escapeText("x\r\ny")).toBe("x\\ny");
});
});
describe("foldLine", () => {
const octets = (s: string) => Buffer.byteLength(s, "utf8");
it("leaves a short line alone", () => {
expect(foldLine("SUMMARY:short")).toBe("SUMMARY:short");
});
it("folds at 75 octets with a leading space on each continuation", () => {
const folded = foldLine(`DESCRIPTION:${"a".repeat(200)}`);
const lines = folded.split("\r\n");
expect(lines.length).toBeGreaterThan(2);
for (const line of lines) expect(octets(line)).toBeLessThanOrEqual(75);
for (const line of lines.slice(1)) expect(line.startsWith(" ")).toBe(true);
expect(lines.map((l, i) => (i === 0 ? l : l.slice(1))).join("")).toBe(
`DESCRIPTION:${"a".repeat(200)}`,
);
});
it("never splits a multibyte character", () => {
// Cyrillic and Hangul are two and three octets each; a byte-counted cut
// would land inside one of them sooner or later.
const value = `SUMMARY:${"Месячные 생리 ".repeat(12)}`;
const lines = foldLine(value).split("\r\n");
for (const line of lines) {
expect(octets(line)).toBeLessThanOrEqual(75);
// A cut through a sequence would leave a replacement character after a
// round trip through bytes.
expect(Buffer.from(line, "utf8").toString("utf8")).toBe(line);
expect(line).not.toContain("�");
}
expect(lines.map((l, i) => (i === 0 ? l : l.slice(1))).join("")).toBe(
value,
);
});
});
describe("eventUid", () => {
it("is stable for a feed and a slot, and unrelated across feeds", () => {
const a = eventUid("feed-1", "pred:1");
expect(eventUid("feed-1", "pred:1")).toBe(a);
expect(eventUid("feed-1", "pred:2")).not.toBe(a);
expect(eventUid("feed-2", "pred:1")).not.toBe(a);
expect(a).toMatch(/^[a-z2-7]{32}@healthlog\.invalid$/);
});
it("carries neither the feed id nor the slot", () => {
const uid = eventUid("cmfeedid123", "obs:cmcycleid456");
expect(uid).not.toContain("cmfeedid123");
expect(uid).not.toContain("cmcycleid456");
});
});
describe("icsContentHash", () => {
it("does not depend on the stamp, only on the content", () => {
expect(icsContentHash(CAL)).toBe(icsContentHash({ ...CAL }));
expect(icsContentHash(CAL)).not.toBe(
icsContentHash({ ...CAL, events: [] }),
);
});
it("formats a stamp as basic UTC", () => {
expect(icsStamp(STAMP)).toBe("20261106T093000Z");
});
});
+262
View File
@@ -0,0 +1,262 @@
/**
* #1350 — the multi-cycle projection: widening, self-capping, honest about
* a late period, and the engine's own forecast as its first row.
*/
import { describe, expect, it } from "vitest";
import { addDays, dayDiff } from "../day-math";
import { predictCycle } from "../prediction";
import {
PROJECTION_BAND_CAP_DAYS,
PROJECTION_MAX_HORIZON,
projectCycles,
type ProjectionInput,
} from "../projection";
import type {
CycleInput,
CycleProfileInput,
CycleTrackingGoal,
DayLogInput,
} from "../types";
/** Cycles whose consecutive starts are `lengths` apart, the last one open. */
function cyclesFrom(first: string, lengths: number[]): CycleInput[] {
const starts = [first];
for (const L of lengths) starts.push(addDays(starts.at(-1)!, L));
return starts.map((startDate, i) => ({
startDate,
endDate: starts[i + 1] ?? null,
periodEndDate: addDays(startDate, 4),
ovulationDate: null,
ovulationConfirmed: false,
}));
}
/** A flow or "dry" log every day from `from` through `to`: dense logging. */
function denseLogs(from: string, to: string): DayLogInput[] {
const out: DayLogInput[] = [];
for (let d = from; dayDiff(to, d) >= 0; d = addDays(d, 1)) {
out.push({
date: d,
flow: "NONE",
basalBodyTempC: null,
ovulationTest: null,
cervicalMucus: null,
});
}
return out;
}
function profile(
goal: CycleTrackingGoal = "GENERAL_HEALTH",
): CycleProfileInput {
return {
goal,
typicalCycleLength: null,
typicalPeriodLength: null,
lutealPhaseLength: null,
predictionEnabled: true,
rawChartMode: false,
};
}
function run(
cycles: CycleInput[],
today: string,
options: {
dayLogs?: DayLogInput[];
goal?: CycleTrackingGoal;
horizon?: number;
} = {},
) {
const dayLogs = options.dayLogs ?? [];
const goal = options.goal ?? "GENERAL_HEALTH";
const input: ProjectionInput = {
prediction: predictCycle(cycles, dayLogs, profile(goal), today),
cycles,
dayLogs,
goal,
today,
horizon: options.horizon ?? PROJECTION_MAX_HORIZON,
};
return { input, out: projectCycles(input) };
}
const REGULAR = Array.from({ length: 12 }, () => 28);
describe("projectCycles", () => {
it("is OFF with no forecast and nothing projected", () => {
const cycles = cyclesFrom("2026-01-01", REGULAR);
expect(
projectCycles({
prediction: null,
cycles,
dayLogs: [],
goal: "GENERAL_HEALTH",
today: "2026-11-06",
horizon: 3,
}),
).toEqual({ state: "OFF", cycles: [] });
});
it("projects nothing for a record with no logged cycle", () => {
// The engine would anchor a population prior on today; that is not
// this record's forecast.
const { out } = run([], "2026-11-06");
expect(out).toEqual({ state: "LEARNING", cycles: [] });
});
it("gives the next period only while still learning", () => {
const cycles = cyclesFrom("2026-09-01", [28, 28]);
const last = cycles.at(-1)!.startDate;
const { input, out } = run(cycles, addDays(last, 5));
expect(out.state).toBe("LEARNING");
expect(out.cycles).toHaveLength(1);
expect(out.cycles[0].start).toBe(input.prediction!.nextPeriodStart);
});
it("gives the next period only for PERIMENOPAUSE", () => {
const cycles = cyclesFrom("2025-11-01", REGULAR);
const last = cycles.at(-1)!.startDate;
const { out } = run(cycles, addDays(last, 5), { goal: "PERIMENOPAUSE" });
expect(out.state).toBe("OK");
expect(out.cycles).toHaveLength(1);
});
it("projects nothing once the next period's band has passed (the late rule)", () => {
const cycles = cyclesFrom("2025-11-01", REGULAR);
const last = cycles.at(-1)!.startDate;
const { input } = run(cycles, addDays(last, 5));
const bandEnd = input.prediction!.nextPeriodStartHigh;
// On the band's last day the forecast still stands...
expect(run(cycles, bandEnd).out.state).toBe("OK");
// ...and the day after, it is withdrawn rather than re-anchored.
expect(run(cycles, addDays(bandEnd, 1)).out).toEqual({
state: "LATE",
cycles: [],
});
});
it("uses the engine's forecast, unchanged, as cycle 1", () => {
const cycles = cyclesFrom("2025-11-01", REGULAR);
const last = cycles.at(-1)!.startDate;
const { input, out } = run(cycles, addDays(last, 5));
const p = input.prediction!;
expect(out.cycles[0]).toEqual({
k: 1,
start: p.nextPeriodStart,
periodLength: p.predictedPeriodLength,
bandStart: p.nextPeriodStartLow,
bandEnd: p.nextPeriodStartHigh,
});
});
it("widens the band strictly from one cycle to the next", () => {
const cycles = cyclesFrom("2025-11-01", REGULAR);
const last = cycles.at(-1)!.startDate;
const today = addDays(last, 10);
const { out } = run(cycles, today, {
dayLogs: denseLogs(last, today),
});
expect(out.cycles.length).toBeGreaterThan(2);
const bands = out.cycles.map((c) => dayDiff(c.bandEnd, c.start));
for (let i = 1; i < bands.length; i++) {
expect(bands[i]).toBeGreaterThan(bands[i - 1]);
}
});
it("stays monotonic for n = 12 with the sparsity penalty at its maximum", () => {
// No day logs at all: logSparsity = 0.5. Without the engine's penalty on
// the later bands, cycle 2 could come out no wider than cycle 1.
const cycles = cyclesFrom("2025-11-01", REGULAR);
const last = cycles.at(-1)!.startDate;
const { out } = run(cycles, addDays(last, 10));
const bands = out.cycles.map((c) => dayDiff(c.bandEnd, c.start));
expect(bands.length).toBeGreaterThan(1);
for (let i = 1; i < bands.length; i++) {
expect(bands[i]).toBeGreaterThan(bands[i - 1]);
}
});
it("anchors every later start on cycle 1 with no accumulated rounding drift", () => {
// A median of 28.5: rounding each cycle to 29 would drift two days by
// cycle 6; rounding (k − 1) · 28.5 from the anchor does not.
const lengths = [28, 29, 28, 29, 28, 29, 28, 29, 28, 29, 28, 29];
const cycles = cyclesFrom("2025-11-01", lengths);
const last = cycles.at(-1)!.startDate;
const today = addDays(last, 10);
const { out } = run(cycles, today, {
dayLogs: denseLogs(last, today),
horizon: 6,
});
const first = out.cycles[0].start;
for (const c of out.cycles.slice(1)) {
expect(c.start).toBe(addDays(first, Math.round((c.k - 1) * 28.5)));
}
expect(out.cycles.length).toBeGreaterThan(1);
});
it("stops at ±10 days, however many cycles were asked for", () => {
// Irregular: a robust SD of several days, so the band passes the cap
// within a few cycles.
const lengths = [24, 31, 27, 34, 25, 30, 33, 26, 29, 35, 24, 31];
const cycles = cyclesFrom("2025-10-01", lengths);
const last = cycles.at(-1)!.startDate;
const { out } = run(cycles, addDays(last, 3), { horizon: 6 });
expect(out.cycles.length).toBeLessThan(6);
for (const c of out.cycles.slice(1)) {
expect(dayDiff(c.bandEnd, c.start)).toBeLessThanOrEqual(
PROJECTION_BAND_CAP_DAYS,
);
}
});
it("stops at half a cycle for short cycles", () => {
// 21-day cycles: the cap is 10.5, and each later band must sit within it.
const lengths = [20, 22, 21, 23, 19, 21, 22, 20, 21, 23, 20, 22];
const cycles = cyclesFrom("2026-01-01", lengths);
const last = cycles.at(-1)!.startDate;
const { out } = run(cycles, addDays(last, 3), { horizon: 6 });
for (const c of out.cycles.slice(1)) {
expect(dayDiff(c.bandEnd, c.start)).toBeLessThanOrEqual(21 / 2);
}
});
it("is not clamped at the engine's 14 days for later cycles", () => {
// The engine clamps cycle 1 at ±14. The projection has no such clamp for
// k ≥ 2; it has the cap, which sits below it. A band the cap admits is
// therefore the formula's own value, never a clamp's.
const cycles = cyclesFrom("2025-11-01", REGULAR);
const last = cycles.at(-1)!.startDate;
const { out } = run(cycles, addDays(last, 10), { horizon: 6 });
for (const c of out.cycles.slice(1)) {
expect(dayDiff(c.bandEnd, c.start)).toBeLessThan(14);
}
});
it("honours the requested horizon and clamps it to 1..6", () => {
const cycles = cyclesFrom("2025-11-01", REGULAR);
const last = cycles.at(-1)!.startDate;
const today = addDays(last, 10);
const dayLogs = denseLogs(last, today);
expect(run(cycles, today, { dayLogs, horizon: 2 }).out.cycles).toHaveLength(
2,
);
expect(run(cycles, today, { dayLogs, horizon: 0 }).out.cycles).toHaveLength(
1,
);
expect(
run(cycles, today, { dayLogs, horizon: 99 }).out.cycles.length,
).toBeLessThanOrEqual(PROJECTION_MAX_HORIZON);
});
it("carries the predicted period length on every row", () => {
const cycles = cyclesFrom("2025-11-01", REGULAR);
const last = cycles.at(-1)!.startDate;
const { input, out } = run(cycles, addDays(last, 10));
for (const c of out.cycles) {
expect(c.periodLength).toBe(input.prediction!.predictedPeriodLength);
}
});
});
+42 -10
View File
@@ -336,6 +336,43 @@ export function buildPhaseDayMap(
return out;
}
/** The rows every forecast reads: the record's cycles, day logs and nights. */
export interface CycleEngineInputs {
cycles: readonly Pick<
MenstrualCycle,
| "startDate"
| "endDate"
| "periodEndDate"
| "ovulationDate"
| "ovulationConfirmed"
>[];
dayLogs: readonly DayLogInput[];
nights: readonly NightlyTempInput[];
}
/**
* The forecast, or null when the profile asks for none.
*
* One predicate for every surface that shows a forecast: the calendar, the
* Coach and MCP cycle block, and the calendar feed. Prediction switched off
* and raw-chart mode both answer null, so no caller can forget one of the two
* and paint a forecast the settings refused.
*/
export function gatedPrediction(
profile: CycleProfileInput,
inputs: CycleEngineInputs,
today: string,
): CyclePredictionResult | null {
if (!profile.predictionEnabled || profile.rawChartMode) return null;
return predictCycle(
toCycleInputs(inputs.cycles),
inputs.dayLogs,
profile,
today,
inputs.nights,
);
}
export interface CalendarBuildResult {
prediction: CyclePredictionResult | null;
/**
@@ -385,16 +422,11 @@ export function buildCalendar(
const lutealLength = resolveLuteal(profile);
// Forecast (only when prediction is on + not raw-chart mode).
let prediction: CyclePredictionResult | null = null;
if (profile.predictionEnabled && !profile.rawChartMode) {
prediction = predictCycle(
toCycleInputs(cycles),
toDayLogInputs(dayLogs),
toProfileInput(profile),
today,
nights,
);
}
const prediction = gatedPrediction(
toProfileInput(profile),
{ cycles, dayLogs: toDayLogInputs(dayLogs), nights },
today,
);
// Cold-start gate: until ≥3 cycles are observed the engine's fertile/
// ovulation/phase output rests on a population prior, so the calendar must
+223
View File
@@ -0,0 +1,223 @@
/**
* #1350 — building a feed's body from the record's rows: the record's own
* day, zone and language; a hash that moves only with the content; and the
* goal and cold-start gates on the fertile window.
*/
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() },
},
}));
const localeSpy = vi.hoisted(() => vi.fn());
vi.mock("@/lib/i18n/server-locale", () => ({
resolveServerLocale: localeSpy,
}));
import { prisma } from "@/lib/db";
import { addDays } from "@/lib/cycle/day-math";
import { renderIcs } from "@/lib/cycle/ics";
import { buildCycleFeed, feedLocale } from "../build";
import { DISCREET_SUMMARY } from "../events";
import { FEED, SUBJECT, profileRow, regularCycles } from "./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> };
};
// The last start lands on 2026-10-20, so the next period is due mid-November.
const FIRST = "2025-11-18";
const NOW = new Date("2026-11-06T12:00:00.000Z");
beforeEach(() => {
vi.clearAllMocks();
db.cycleProfile.findUnique.mockResolvedValue(profileRow());
db.menstrualCycle.findMany.mockResolvedValue(regularCycles(FIRST));
db.cycleDayLog.findMany.mockResolvedValue([]);
db.measurement.findMany.mockResolvedValue([]);
});
describe("buildCycleFeed", () => {
it("serves the projected periods with discreet titles by default", async () => {
const built = await buildCycleFeed(FEED, SUBJECT, NOW);
expect(built.projectionState).toBe("OK");
expect(built.calendar.name).toBe("HealthLog");
expect(built.calendar.events.length).toBe(3);
for (const e of built.calendar.events) {
expect(e.summary).toBe(DISCREET_SUMMARY);
}
});
it("hashes the same inputs on the same day to the same value", async () => {
const a = await buildCycleFeed(FEED, SUBJECT, NOW);
const b = await buildCycleFeed(
FEED,
SUBJECT,
new Date("2026-11-06T18:00:00.000Z"),
);
expect(b.contentHash).toBe(a.contentHash);
// The events keep their UIDs across rebuilds.
expect(b.calendar.events.map((e) => e.uid)).toEqual(
a.calendar.events.map((e) => e.uid),
);
});
it("moves the hash when a cycle is deleted", async () => {
const before = await buildCycleFeed(FEED, SUBJECT, NOW);
db.menstrualCycle.findMany.mockResolvedValue(
regularCycles(FIRST).slice(0, -1),
);
const after = await buildCycleFeed(FEED, SUBJECT, NOW);
expect(after.contentHash).not.toBe(before.contentHash);
});
it("moves the hash when a day roll takes the forecast past its band", async () => {
const before = await buildCycleFeed(FEED, SUBJECT, NOW);
const late = await buildCycleFeed(
FEED,
SUBJECT,
new Date("2026-12-15T12:00:00.000Z"),
);
expect(late.projectionState).toBe("LATE");
expect(late.calendar.events).toEqual([]);
expect(late.contentHash).not.toBe(before.contentHash);
});
it("reads today in the record's zone, not in UTC", async () => {
// Find the next period's band end, then ask at 23:30 UTC on that day: in
// Berlin it is already the following day, which is past the band.
const onTime = await buildCycleFeed(FEED, SUBJECT, NOW);
const first = onTime.calendar.events[0];
// DTEND is the band end plus the period length; recover the band end.
const bandEnd = addDays(first.endExclusive, -5);
const lateEvening = new Date(`${bandEnd}T23:30:00.000Z`);
const utc = await buildCycleFeed(
FEED,
{ ...SUBJECT, timezone: "UTC" },
lateEvening,
);
const berlin = await buildCycleFeed(FEED, SUBJECT, lateEvening);
expect(utc.projectionState).toBe("OK");
expect(berlin.projectionState).toBe("LATE");
});
it("names nothing when the prediction is switched off, and history still works", async () => {
db.cycleProfile.findUnique.mockResolvedValue(
profileRow("GENERAL_HEALTH", { predictionEnabled: false }),
);
const off = await buildCycleFeed(
{ ...FEED, historyCycles: 3 },
SUBJECT,
NOW,
);
expect(off.projectionState).toBe("OFF");
expect(off.calendar.events).toHaveLength(3);
const starts = off.calendar.events.map((e) => e.start);
expect(starts.at(-1)).toBe("2026-10-20");
});
it("treats raw-chart mode as no forecast", async () => {
db.cycleProfile.findUnique.mockResolvedValue(
profileRow("GENERAL_HEALTH", { rawChartMode: true }),
);
const built = await buildCycleFeed(FEED, SUBJECT, NOW);
expect(built.projectionState).toBe("OFF");
expect(built.calendar.events).toEqual([]);
});
describe("the fertile window", () => {
it("is added for TRYING_TO_CONCEIVE when asked for", async () => {
db.cycleProfile.findUnique.mockResolvedValue(
profileRow("TRYING_TO_CONCEIVE"),
);
const built = await buildCycleFeed(
{ ...FEED, includeFertile: true, titleMode: "DESCRIPTIVE" },
SUBJECT,
NOW,
);
expect(
built.calendar.events.filter(
(e) => e.summary === "Fertile window (estimate)",
),
).toHaveLength(1);
});
it("is never added for AVOID_PREGNANCY, whatever the option says", async () => {
db.cycleProfile.findUnique.mockResolvedValue(
profileRow("AVOID_PREGNANCY"),
);
const built = await buildCycleFeed(
{ ...FEED, includeFertile: true, titleMode: "DESCRIPTIVE" },
SUBJECT,
NOW,
);
expect(built.calendar.events.map((e) => e.summary)).not.toContain(
"Fertile window (estimate)",
);
expect(built.calendar.events).toHaveLength(3);
});
it("is withheld while the engine is still learning", async () => {
db.cycleProfile.findUnique.mockResolvedValue(
profileRow("TRYING_TO_CONCEIVE"),
);
db.menstrualCycle.findMany.mockResolvedValue(
regularCycles("2026-09-22", 3),
);
const built = await buildCycleFeed(
{ ...FEED, includeFertile: true, titleMode: "DESCRIPTIVE" },
SUBJECT,
NOW,
);
expect(built.projectionState).toBe("LEARNING");
expect(built.calendar.events.map((e) => e.summary)).toEqual([
"Period (estimate)",
]);
});
});
it("uses the record's stored language for descriptive titles", async () => {
const built = await buildCycleFeed(
{ ...FEED, titleMode: "DESCRIPTIVE" },
{ ...SUBJECT, locale: "de" },
NOW,
);
expect(built.calendar.name).toBe("Zyklus (HealthLog)");
expect(built.calendar.events[0].summary).toBe("Periode (Schätzung)");
});
it("falls back to the instance default for a null locale and never asks the request", async () => {
expect(feedLocale(null)).toBe("en");
expect(feedLocale("xx")).toBe("en");
const built = await buildCycleFeed(
{ ...FEED, titleMode: "DESCRIPTIVE" },
{ ...SUBJECT, locale: null },
NOW,
);
expect(built.calendar.events[0].summary).toBe("Period (estimate)");
// A provider's Accept-Language must never reach the choice.
expect(localeSpy).not.toHaveBeenCalled();
});
it("renders the descriptive description with the likely start and the band", async () => {
const built = await buildCycleFeed(
{ ...FEED, titleMode: "DESCRIPTIVE" },
SUBJECT,
NOW,
);
const body = renderIcs(built.calendar, NOW);
expect(body).toMatch(
/DESCRIPTION:Most likely start [^\\\r]+\\, ±\d+ days?\r\n/,
);
});
});
+57
View File
@@ -0,0 +1,57 @@
/**
* Shared rows for the calendar-feed tests: a regular record with a year of
* 28-day cycles, mocked through the same Prisma calls the loader makes.
*/
import { addDays } from "@/lib/cycle/day-math";
export function regularCycles(first: string, count = 13, length = 28) {
const out = [];
for (let i = 0; i < count; i++) {
const startDate = addDays(first, i * length);
out.push({
id: `cycle-${i}`,
userId: "subject",
startDate,
endDate: i < count - 1 ? addDays(startDate, length) : null,
periodEndDate: i % 2 === 0 ? addDays(startDate, 4) : null,
ovulationDate: null,
ovulationConfirmed: false,
deletedAt: null,
});
}
return out;
}
export function profileRow(goal = "GENERAL_HEALTH", overrides = {}) {
return {
id: "profile",
userId: "subject",
goal,
cycleTrackingEnabled: true,
typicalCycleLength: null,
typicalPeriodLength: null,
lutealPhaseLength: null,
secondarySymptom: "MUCUS",
predictionEnabled: true,
rawChartMode: false,
discreetNotifications: false,
sensitiveCategoryEncryption: true,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
export const FEED = {
id: "feed-1",
horizonCycles: 3,
historyCycles: 0,
includeFertile: false,
titleMode: "DISCREET" as const,
};
export const SUBJECT = {
id: "subject",
timezone: "Europe/Berlin",
locale: "en",
};
+315
View File
@@ -0,0 +1,315 @@
/**
* #1350 — the anonymous feed fetch: the limit before any lookup, one 404 for
* every miss, every gate re-read per fetch, and the ETag / 304 path.
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
const db = vi.hoisted(() => ({
cycleCalendarFeed: { findFirst: vi.fn(), update: vi.fn() },
user: { findFirst: vi.fn() },
}));
vi.mock("@/lib/db", () => ({ prisma: db }));
const limits = vi.hoisted(() => ({
surface: vi.fn(),
token: vi.fn(),
}));
vi.mock("@/lib/rate-limit", () => ({
checkAuthSurfaceRateLimit: limits.surface,
checkRateLimit: limits.token,
rateLimitHeaders: () => ({ "Retry-After": "60" }),
}));
const gates = vi.hoisted(() => ({
api: vi.fn(),
cycle: vi.fn(),
grant: vi.fn(),
}));
vi.mock("@/lib/app-settings", () => ({ isApiGloballyEnabled: gates.api }));
vi.mock("@/lib/cycle/gate", () => ({ isCycleAvailableForUser: gates.cycle }));
vi.mock("@/lib/sharing/grant-access", async (orig) => ({
...(await orig<typeof import("@/lib/sharing/grant-access")>()),
findActiveGrant: gates.grant,
}));
const built = vi.hoisted(() => ({ build: vi.fn() }));
vi.mock("../build", () => ({ buildCycleFeed: built.build }));
import { hashToken } from "@/lib/auth/hmac";
import { settleBackgroundTasks } from "@/lib/logging/background-tasks";
import { serveCycleFeed } from "../serve";
const TOKEN = `hlf_${"ab".repeat(24)}`;
const ALLOWED = { allowed: true, limit: 1, remaining: 1, resetAt: new Date() };
const FEED_ROW = {
id: "feed-1",
userId: "subject",
createdById: "subject",
bodyHash: "old-hash",
contentChangedAt: new Date("2026-11-01T00:00:00Z"),
horizonCycles: 3,
historyCycles: 0,
includeFertile: false,
titleMode: "DISCREET",
};
function request(path = `${TOKEN}.ics`, headers: Record<string, string> = {}) {
return {
req: new NextRequest(`http://localhost/api/cycle/feed/${path}`, {
headers,
}),
segment: path,
};
}
async function fetchFeed(path?: string, headers?: Record<string, string>) {
const { req, segment } = request(path, headers);
const res = await serveCycleFeed(req, segment);
await settleBackgroundTasks();
return res;
}
beforeEach(() => {
vi.clearAllMocks();
vi.stubEnv("API_TOKEN_HMAC_KEY", "k".repeat(40));
limits.surface.mockResolvedValue({ ...ALLOWED, ip: "203.0.113.7" });
limits.token.mockResolvedValue(ALLOWED);
db.cycleCalendarFeed.findFirst.mockResolvedValue(FEED_ROW);
db.cycleCalendarFeed.update.mockResolvedValue({});
db.user.findFirst.mockResolvedValue({
id: "subject",
timezone: "Europe/Berlin",
locale: "en",
managedProfileAt: null,
});
gates.api.mockResolvedValue(true);
gates.cycle.mockResolvedValue(true);
gates.grant.mockResolvedValue(null);
built.build.mockResolvedValue({
calendar: {
name: "HealthLog",
events: [
{
uid: "u@healthlog.invalid",
start: "2026-11-15",
endExclusive: "2026-11-22",
summary: "●",
},
],
},
contentHash: "new-hash",
projectionState: "OK",
});
});
async function expectBlunt404(res: Response) {
expect(res.status).toBe(404);
const body = await res.json();
expect(body).toMatchObject({ error: "Not found" });
expect(body.meta?.errorCode).toBe("cycle.feed.notFound");
expect(res.headers.get("x-robots-tag")).toBe("noindex, nofollow");
}
describe("serveCycleFeed", () => {
it("serves the calendar with its validator and privacy headers", async () => {
const res = await fetchFeed();
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toBe(
"text/calendar; charset=utf-8",
);
expect(res.headers.get("cache-control")).toBe(
"private, no-cache, max-age=0",
);
expect(res.headers.get("etag")).toBe('"new-hash"');
expect(res.headers.get("referrer-policy")).toBe("no-referrer");
expect(await res.text()).toContain("BEGIN:VCALENDAR");
});
it("looks the token up by its HMAC, live rows only", async () => {
await fetchFeed();
expect(db.cycleCalendarFeed.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: { tokenHash: hashToken(TOKEN), revokedAt: null },
}),
);
});
it("takes the anonymous limit before any lookup", async () => {
limits.surface.mockResolvedValue({ ...ALLOWED, allowed: false, ip: null });
const res = await fetchFeed();
expect(res.status).toBe(429);
expect(db.cycleCalendarFeed.findFirst).not.toHaveBeenCalled();
expect(limits.surface).toHaveBeenCalledWith(
expect.anything(),
"cycle-feed",
120,
3_600_000,
);
});
it("throttles one link through its own bucket", async () => {
limits.token.mockResolvedValue({ ...ALLOWED, allowed: false });
const res = await fetchFeed();
expect(res.status).toBe(429);
expect(limits.token).toHaveBeenCalledWith(
`cycle-feed:${hashToken(TOKEN)}`,
30,
3_600_000,
);
expect(built.build).not.toHaveBeenCalled();
});
it("answers a malformed token without a lookup", async () => {
await expectBlunt404(await fetchFeed("hlf_short.ics"));
await expectBlunt404(await fetchFeed(`hlk_${"ab".repeat(24)}`));
expect(db.cycleCalendarFeed.findFirst).not.toHaveBeenCalled();
});
it.each([
[
"unknown or revoked",
() => db.cycleCalendarFeed.findFirst.mockResolvedValue(null),
],
[
"the record pending deletion or gone",
() => db.user.findFirst.mockResolvedValue(null),
],
[
"the operator's external-access switch off",
() => gates.api.mockResolvedValue(false),
],
["cycle tracking off", () => gates.cycle.mockResolvedValue(false)],
])("answers the same 404 for %s", async (_label, arrange) => {
arrange();
await expectBlunt404(await fetchFeed());
expect(built.build).not.toHaveBeenCalled();
});
describe("a feed a guardian minted on a managed profile", () => {
beforeEach(() => {
db.cycleCalendarFeed.findFirst.mockResolvedValue({
...FEED_ROW,
createdById: "manager",
});
db.user.findFirst.mockResolvedValue({
id: "subject",
timezone: "Europe/Berlin",
locale: "en",
managedProfileAt: new Date("2026-01-01"),
});
});
const grant = (overrides: Record<string, unknown> = {}) => ({
id: "g",
access: "MANAGE",
acceptedAt: new Date("2026-01-01"),
revokedAt: null,
expiresAt: null,
scopeJson: null,
...overrides,
});
it("is served while the manager's MANAGE grant over cycle is live", async () => {
gates.grant.mockResolvedValue(grant());
expect((await fetchFeed()).status).toBe(200);
expect(gates.grant).toHaveBeenCalledWith({
grantorId: "subject",
granteeId: "manager",
});
});
it.each([
["no live grant", null],
["a WRITE grant", grant({ access: "WRITE" })],
["a READ grant", grant({ access: "READ" })],
[
"a grant scoped away from cycle",
grant({ scopeJson: ["measurements"] }),
],
])("is a blunt 404 with %s", async (_label, row) => {
gates.grant.mockResolvedValue(row);
await expectBlunt404(await fetchFeed());
});
it("is a blunt 404 once the profile is an adult's account", async () => {
// A live MANAGE grant is not enough: only a guardian of a managed
// profile may hold a feed on somebody else's record.
gates.grant.mockResolvedValue(grant());
db.user.findFirst.mockResolvedValue({
id: "subject",
timezone: null,
locale: null,
managedProfileAt: null,
});
await expectBlunt404(await fetchFeed());
});
it("is a blunt 404 once the manager's account is gone", async () => {
gates.grant.mockResolvedValue(grant());
db.user.findFirst.mockImplementation(
async (args: { where: { id: string } }) =>
args.where.id === "manager"
? null
: {
id: "subject",
timezone: null,
locale: null,
managedProfileAt: new Date("2026-01-01"),
},
);
await expectBlunt404(await fetchFeed());
});
});
it("moves the stamp and the hash only when the content moved", async () => {
await fetchFeed();
expect(db.cycleCalendarFeed.update).toHaveBeenCalledWith({
where: { id: "feed-1" },
data: expect.objectContaining({
bodyHash: "new-hash",
contentChangedAt: expect.any(Date),
fetchCount: { increment: 1 },
}),
});
vi.clearAllMocks();
db.cycleCalendarFeed.findFirst.mockResolvedValue({
...FEED_ROW,
bodyHash: "new-hash",
});
db.cycleCalendarFeed.update.mockResolvedValue({});
const res = await fetchFeed();
const data = db.cycleCalendarFeed.update.mock.calls[0][0].data;
expect(data).not.toHaveProperty("bodyHash");
expect(data).not.toHaveProperty("contentChangedAt");
expect(await res.text()).toContain("DTSTAMP:20261101T000000Z");
});
it("answers 304 to a matching If-None-Match and still counts the fetch", async () => {
db.cycleCalendarFeed.findFirst.mockResolvedValue({
...FEED_ROW,
bodyHash: "new-hash",
});
const res = await fetchFeed(undefined, { "if-none-match": '"new-hash"' });
expect(res.status).toBe(304);
expect(res.headers.get("etag")).toBe('"new-hash"');
expect(await res.text()).toBe("");
expect(db.cycleCalendarFeed.update).toHaveBeenCalled();
});
it("keeps the User-Agent short", async () => {
await fetchFeed(undefined, {
"user-agent": `Google-Calendar-Importer ${"x".repeat(200)}`,
});
const data = db.cycleCalendarFeed.update.mock.calls[0][0].data;
expect(data.lastFetcherUa.length).toBe(80);
});
it("does not fail the fetch when the statistics write fails", async () => {
db.cycleCalendarFeed.update.mockRejectedValue(new Error("db down"));
expect((await fetchFeed()).status).toBe(200);
});
});
+161
View File
@@ -0,0 +1,161 @@
/**
* Build one calendar feed's body from the record's rows, live (#1350).
*
* Nothing is read from the persisted `CyclePrediction`: it can be a day
* stale, and the feed is computed on every fetch precisely so HealthLog stays
* the only source of truth. The day-log window is the calendar's default
* view, so the feed agrees with what the calendar page shows.
*
* `today`, the time zone and the language are the RECORD's stored settings,
* never the request's. A calendar provider fetches from a data centre, in a
* zone and with an Accept-Language that have nothing to do with the person.
*/
import { prisma } from "@/lib/db";
import {
defaultCalendarDayLogStart,
loadCycleEngineInputs,
loadFlowDays,
} from "@/lib/cycle/load-inputs";
import {
gatedPrediction,
toCycleInputs,
toProfileInput,
} from "@/lib/cycle/engine-adapter";
import { goalAllowsFertileWindow, toCyclePredictionDTO } from "@/lib/cycle/dto";
import { projectCycles, type ProjectionState } from "@/lib/cycle/projection";
import type { CycleProfileInput } from "@/lib/cycle/types";
import { defaultLocale, isLocale, type Locale } from "@/lib/i18n/config";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { moodDateKey } from "@/lib/mood/date-key";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { icsContentHash, type IcsCalendar } from "../ics";
import { buildFeedCalendar, type FeedTitleMode } from "./events";
import { historyPeriods, historyWindowStart } from "./history";
/** The feed options the body depends on. */
export interface FeedOptions {
id: string;
horizonCycles: number;
historyCycles: number;
includeFertile: boolean;
titleMode: FeedTitleMode;
}
/** The record the feed serves, as far as building it needs. */
export interface FeedSubject {
id: string;
timezone: string | null;
locale: string | null;
}
export interface BuiltFeed {
calendar: IcsCalendar;
/** SHA-256 of the body without its stamps. Doubles as the ETag. */
contentHash: string;
projectionState: ProjectionState;
}
/**
* The language the feed's descriptive titles use: the record's stored one, or
* the instance default. Deliberately not `resolveServerLocale`, which would
* fall back to the request's cookies and Accept-Language — the provider's,
* not the person's.
*/
export function feedLocale(stored: string | null): Locale {
return isLocale(stored) ? stored : defaultLocale;
}
/** The engine's defaults, for a record whose profile row was never created. */
const DEFAULT_PROFILE: CycleProfileInput = {
goal: "GENERAL_HEALTH",
typicalCycleLength: null,
typicalPeriodLength: null,
lutealPhaseLength: null,
predictionEnabled: true,
rawChartMode: false,
};
export async function buildCycleFeed(
feed: FeedOptions,
subject: FeedSubject,
now: Date = new Date(),
): Promise<BuiltFeed> {
const tz = subject.timezone ?? DEFAULT_TIMEZONE;
const today = moodDateKey(now, tz);
const [profileRow, inputs] = await Promise.all([
prisma.cycleProfile.findUnique({ where: { userId: subject.id } }),
loadCycleEngineInputs({
userId: subject.id,
today,
tz,
dayLogsFrom: defaultCalendarDayLogStart(today),
}),
]);
const profile = profileRow ? toProfileInput(profileRow) : DEFAULT_PROFILE;
const prediction = gatedPrediction(profile, inputs, today);
const projection = projectCycles({
prediction,
cycles: toCycleInputs(inputs.cycles),
dayLogs: inputs.dayLogs,
goal: profile.goal,
today,
horizon: feed.horizonCycles,
});
// The fertile window: the next cycle's, for a conception goal that asked
// for it, and read off the gated DTO rather than the raw result so the
// cold-start suppression applies here exactly as it does on every other
// surface. Never for AVOID_PREGNANCY: a calendar event cannot carry the
// "not a contraceptive method" caveat, and a fertile window without it is
// the one thing this feature must not hand out.
let fertile: { start: string; end: string } | null = null;
if (
feed.includeFertile &&
profile.goal === "TRYING_TO_CONCEIVE" &&
prediction !== null &&
projection.cycles.length > 0
) {
const dto = toCyclePredictionDTO(
prediction,
goalAllowsFertileWindow(profile.goal),
"",
);
if (dto.fertileWindowStart && dto.fertileWindowEnd) {
fertile = { start: dto.fertileWindowStart, end: dto.fertileWindowEnd };
}
}
let history: ReturnType<typeof historyPeriods> = [];
const historyFrom = historyWindowStart(
inputs.cycles,
today,
feed.historyCycles,
);
if (historyFrom !== null) {
const flowDays = await loadFlowDays(subject.id, historyFrom);
history = historyPeriods(
inputs.cycles,
flowDays,
today,
feed.historyCycles,
);
}
const calendar = buildFeedCalendar({
feedId: feed.id,
titleMode: feed.titleMode,
projected: projection.cycles,
history,
fertile,
translator: getServerTranslator(feedLocale(subject.locale)),
});
return {
calendar,
contentHash: icsContentHash(calendar),
projectionState: projection.state,
};
}
+129
View File
@@ -0,0 +1,129 @@
/**
* The calendar feed's events, from the projection, the record's history and
* (for a conception goal that asked for it) the next fertile window (#1350).
*
* Pure. What an event may carry is decided here and nowhere else, and it is
* dates and a title: never a symptom, a note, a flow level, a temperature, a
* test result, the intent fields or anything else the day log holds. The
* content-leak guard (`cycle-feed-content-guard.test.ts`) seeds every one of
* those and asserts the rendered body contains none of them.
*
* DISCREET (the default) titles every event with a dot and names the calendar
* "HealthLog", so a glance at a shared screen or a lock-screen notification
* says nothing. DESCRIPTIVE names the period, in the record's own language.
*/
import { addDays, dayDiff } from "@/lib/cycle/day-math";
import type { ProjectedCycle } from "@/lib/cycle/projection";
import { pluralKey } from "@/lib/i18n/plural";
import { resolveIntlLocale } from "@/lib/format-locale";
import type { ServerTranslator } from "@/lib/i18n/server-translator";
import { eventUid, type IcsCalendar, type IcsEvent } from "../ics";
export type FeedTitleMode = "DISCREET" | "DESCRIPTIVE";
/** The discreet title: one dot, no word. */
export const DISCREET_SUMMARY = "●";
/** The discreet calendar name. */
export const DISCREET_CALENDAR_NAME = "HealthLog";
/** One logged period, as far as the feed draws it. */
export interface HistoryPeriod {
cycleId: string;
/** First bleeding day. */
start: string;
/** Last bleeding day, inclusive. */
end: string;
}
export interface FeedEventInput {
feedId: string;
titleMode: FeedTitleMode;
projected: readonly ProjectedCycle[];
history: readonly HistoryPeriod[];
/** The next fertile window, already gated; null when not shown. */
fertile: { start: string; end: string } | null;
translator: ServerTranslator;
}
/** "12 Nov" in the record's language, for the descriptive description. */
function shortDate(day: string, translator: ServerTranslator): string {
return new Intl.DateTimeFormat(resolveIntlLocale(translator.locale), {
day: "numeric",
month: "short",
timeZone: "UTC",
}).format(new Date(`${day}T12:00:00Z`));
}
/** "±3 days", pluralised in the record's language. */
function bandText(days: number, translator: ServerTranslator): string {
return translator.t(
pluralKey("cycle.feed.ics.band", days, translator.locale),
{ count: days },
);
}
function forecastEvent(p: ProjectedCycle, input: FeedEventInput): IcsEvent {
const { translator } = input;
const band = dayDiff(p.bandEnd, p.start);
const descriptive = input.titleMode === "DESCRIPTIVE";
return {
uid: eventUid(input.feedId, `pred:${p.k}`),
// The whole window the period may fall in: earliest likely start through
// the last bleeding day of the latest likely start. DTEND is exclusive.
start: p.bandStart,
endExclusive: addDays(p.bandEnd, p.periodLength),
summary: descriptive
? translator.t("cycle.feed.ics.periodEstimate")
: DISCREET_SUMMARY,
description: descriptive
? translator.t("cycle.feed.ics.likelyStart", {
date: shortDate(p.start, translator),
band: bandText(band, translator),
})
: bandText(band, translator),
};
}
function historyEvent(h: HistoryPeriod, input: FeedEventInput): IcsEvent {
return {
uid: eventUid(input.feedId, `obs:${h.cycleId}`),
start: h.start,
endExclusive: addDays(h.end, 1),
summary:
input.titleMode === "DESCRIPTIVE"
? input.translator.t("cycle.feed.ics.periodLogged")
: DISCREET_SUMMARY,
};
}
function fertileEvent(
window: { start: string; end: string },
input: FeedEventInput,
): IcsEvent {
return {
uid: eventUid(input.feedId, "fertile:1"),
start: window.start,
endExclusive: addDays(window.end, 1),
summary:
input.titleMode === "DESCRIPTIVE"
? input.translator.t("cycle.feed.ics.fertileEstimate")
: DISCREET_SUMMARY,
};
}
/** The whole calendar the feed serves, events in date order. */
export function buildFeedCalendar(input: FeedEventInput): IcsCalendar {
const events: IcsEvent[] = [
...input.history.map((h) => historyEvent(h, input)),
...input.projected.map((p) => forecastEvent(p, input)),
...(input.fertile ? [fertileEvent(input.fertile, input)] : []),
].sort((a, b) => dayDiff(a.start, b.start) || a.uid.localeCompare(b.uid));
return {
name:
input.titleMode === "DESCRIPTIVE"
? input.translator.t("cycle.feed.ics.calendarName")
: DISCREET_CALENDAR_NAME,
events,
};
}
+23
View File
@@ -0,0 +1,23 @@
/**
* Which calendar app last fetched a feed, from its User-Agent (#1350).
*
* Only a family is ever published: the settings card says "by Google
* Calendar", and the wide event counts conditional fetches per family. The
* stored User-Agent itself stays on the row, truncated, and never leaves the
* server.
*/
export type FetcherFamily = "GOOGLE" | "APPLE" | "MICROSOFT" | "OTHER";
const FAMILIES: ReadonlyArray<[FetcherFamily, RegExp]> = [
["GOOGLE", /google/i],
// iOS and macOS fetch through `dataaccessd` / `CalendarAgent`; older macOS
// still says `iCal`.
["APPLE", /dataaccessd|calendaragent|ical\/|\bios\/|macos\/|mac os x/i],
["MICROSOFT", /microsoft|outlook|exchange/i],
];
export function fetcherFamily(ua: string | null): FetcherFamily | null {
if (!ua) return null;
for (const [family, re] of FAMILIES) if (re.test(ua)) return family;
return "OTHER";
}
+73
View File
@@ -0,0 +1,73 @@
/**
* The logged periods the calendar feed may include as history (#1350).
*
* Pure. A period runs from the cycle's start to its `periodEndDate` when one
* is recorded, otherwise across its observed bleeding days (the engine's own
* §2 run rule), otherwise it is the start day alone. It never runs past today
* or into the next cycle.
*/
import { addDays, dayDiff } from "@/lib/cycle/day-math";
import { observedPeriodLength } from "@/lib/cycle/prediction";
import type { DayLogInput, FlowLevel } from "@/lib/cycle/types";
import type { HistoryPeriod } from "./events";
export interface HistoryCycleRow {
id: string;
startDate: string;
periodEndDate: string | null;
}
/** The earliest start of the last `count` cycles at or before today. */
export function historyWindowStart(
cycles: readonly HistoryCycleRow[],
today: string,
count: number,
): string | null {
return recent(cycles, today, count)[0]?.startDate ?? null;
}
function recent(
cycles: readonly HistoryCycleRow[],
today: string,
count: number,
): HistoryCycleRow[] {
if (count <= 0) return [];
return [...cycles]
.filter((c) => dayDiff(today, c.startDate) >= 0)
.sort((a, b) => dayDiff(a.startDate, b.startDate))
.slice(-count);
}
export function historyPeriods(
cycles: readonly HistoryCycleRow[],
flowDays: ReadonlyArray<{ date: string; flow: FlowLevel | null }>,
today: string,
count: number,
): HistoryPeriod[] {
const chosen = recent(cycles, today, count);
const sortedStarts = [...cycles]
.map((c) => c.startDate)
.sort((a, b) => dayDiff(a, b));
const logs: DayLogInput[] = flowDays.map((d) => ({
date: d.date,
flow: d.flow,
basalBodyTempC: null,
ovulationTest: null,
cervicalMucus: null,
}));
return chosen.map((c) => {
let end = c.startDate;
if (c.periodEndDate && dayDiff(c.periodEndDate, c.startDate) >= 0) {
end = c.periodEndDate;
} else {
const observed = observedPeriodLength(c.startDate, logs);
if (observed > 1) end = addDays(c.startDate, observed - 1);
}
const nextStart = sortedStarts.find((s) => dayDiff(s, c.startDate) > 0);
if (nextStart && dayDiff(end, nextStart) >= 0) end = addDays(nextStart, -1);
if (dayDiff(end, today) > 0) end = today;
return { cycleId: c.id, start: c.startDate, end };
});
}
+47
View File
@@ -0,0 +1,47 @@
/**
* Pieces the calendar-feed management routes share (#1350).
*
* Management is cookie-only. Minting hands out a standing URL credential, so
* it needs the person at the keyboard (a fresh proof on the session) and is
* not reachable from a Bearer token at all; the native app shows "set up on
* the web" instead. The routes resolve through `requireGuardianAuth()`, so
* the owner and the guardian of a managed profile reach them, and every
* delegate on an adult's record (MANAGE included) does not.
*/
import { apiError } from "@/lib/api-response";
import { feedPath } from "./token";
/** Mint and rotate, per acting account. */
export const FEED_MINT_LIMIT_PER_HOUR = 10;
export const FEED_MINT_WINDOW_MS = 60 * 60 * 1000;
/** The refusal a Bearer caller gets: management is a web surface. */
export function cookieOnlyRefusal(): Response {
return apiError("Calendar subscriptions are managed on the web", 403, {
errorCode: "cycle.feed.cookie_only",
});
}
export function noLiveFeed(): Response {
return apiError("No calendar subscription is on", 404, {
errorCode: "cycle.feed.none",
});
}
export function feedExists(): Response {
return apiError(
"A calendar subscription is already on. Rotate it for a new link.",
409,
{ errorCode: "cycle.feed.exists" },
);
}
/** The two forms of the link, built once, at mint. */
export function feedLinks(
origin: string,
raw: string,
): { url: string; webcalUrl: string } {
const url = `${origin}${feedPath(raw)}`;
return { url, webcalUrl: url.replace(/^https?:/, "webcal:") };
}
+272
View File
@@ -0,0 +1,272 @@
/**
* `GET /api/cycle/feed/<hlf_token>` — the anonymous calendar-feed fetch
* (#1350).
*
* The token in the path is the whole credential. A calendar provider polls it
* from its own servers with no cookie and no header we could ask for, which is
* why it is a URL credential at all. Everything it may do is decided here, on
* every fetch, in this order:
*
* 1. The anonymous auth-surface limit, before anything is looked up, so a
* prober walking token space pays for every guess (the webhook rule:
* limit before verification).
* 2. The token's shape. Anything that is not `hlf_` plus 48 hex is not
* looked up at all.
* 3. The HMAC lookup of a live (unrevoked) feed.
* 4. The token's own bucket, so one leaked link cannot grind the engine.
* 5. The gates, all read live and none of them through a session: the
* record and the carrier are not pending deletion, the operator's
* external-access switch is on, cycle tracking is available for the
* record (user toggle AND operator module switch), and a feed a
* guardian minted still sits on a managed profile the guardian holds a
* live MANAGE grant over. Only a guardian of a managed profile can mint
* for somebody else (`requireGuardianAuth`); the marker is re-read here
* so a feed can never outlive the profile becoming an adult's account.
*
* Every miss is the same 404 with the same bytes: unknown, malformed,
* revoked, switched off, deleted, or a grant that has lapsed. A prober learns
* nothing from the difference because there is none.
*/
import { NextRequest } from "next/server";
import { apiError } from "@/lib/api-response";
import { hashToken } from "@/lib/auth/hmac";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import { activeAccount } from "@/lib/account-deletion/active-account";
import { isCycleAvailableForUser } from "@/lib/cycle/gate";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { runDetached } from "@/lib/logging/detached";
import { trackBackgroundTask } from "@/lib/logging/background-tasks";
import {
checkAuthSurfaceRateLimit,
checkRateLimit,
rateLimitHeaders,
} from "@/lib/rate-limit";
import {
findActiveGrant,
grantAllows,
grantCoversDomain,
} from "@/lib/sharing/grant-access";
import { renderIcs } from "../ics";
import { buildCycleFeed, type FeedSubject } from "./build";
import { fetcherFamily } from "./fetcher";
import { parseFeedTokenSegment } from "./token";
/**
* Per source address, across every feed. Generous because the big calendar
* providers poll from a pool of addresses that churns; the per-token bucket
* below is the one that bounds a single link.
*/
const SURFACE_LIMIT_PER_HOUR = 120;
/** Per link. A provider polling every few minutes still fits. */
const TOKEN_LIMIT_PER_HOUR = 30;
const HOUR_MS = 60 * 60 * 1000;
/** The longest User-Agent kept for the status read. */
const UA_MAX_LENGTH = 80;
/** Headers every answer carries, the 404 included. */
function baseHeaders(): Record<string, string> {
return {
"X-Robots-Tag": "noindex, nofollow",
"Referrer-Policy": "no-referrer",
};
}
/** One blunt 404 for every miss class. */
function notFound(reason: string): Response {
annotate({ meta: { feed_miss: reason } });
const response = apiError("Not found", 404, {
errorCode: "cycle.feed.notFound",
});
for (const [k, v] of Object.entries(baseHeaders()))
response.headers.set(k, v);
response.headers.set("Cache-Control", "no-store");
return response;
}
function tooMany(snapshot: Parameters<typeof rateLimitHeaders>[0]): Response {
const response = apiError("Too many requests. Try again later.", 429, {
errorCode: "cycle.feed.rateLimited",
});
for (const [k, v] of Object.entries({
...baseHeaders(),
...rateLimitHeaders(snapshot),
})) {
response.headers.set(k, v);
}
return response;
}
const FEED_SELECT = {
id: true,
userId: true,
createdById: true,
bodyHash: true,
contentChangedAt: true,
horizonCycles: true,
historyCycles: true,
includeFertile: true,
titleMode: true,
} as const;
/**
* Whether a feed may still be served. Every condition is read on this fetch;
* nothing is cached on the row, so a revocation of anything the feed rests on
* lands on the provider's next poll.
*/
async function feedStillAllowed(feed: {
userId: string;
createdById: string;
}): Promise<
{ ok: true; subject: FeedSubject } | { ok: false; reason: string }
> {
const [subject, apiOn, cycleOn] = await Promise.all([
prisma.user.findFirst({
where: activeAccount({ id: feed.userId }),
select: {
id: true,
timezone: true,
locale: true,
managedProfileAt: true,
},
}),
isApiGloballyEnabled(),
isCycleAvailableForUser(feed.userId),
]);
if (!subject) return { ok: false, reason: "subject" };
if (!apiOn) return { ok: false, reason: "api_disabled" };
if (!cycleOn) return { ok: false, reason: "cycle_disabled" };
if (feed.createdById !== feed.userId) {
if (subject.managedProfileAt === null) {
return { ok: false, reason: "not_managed" };
}
const [carrier, grant] = await Promise.all([
prisma.user.findFirst({
where: activeAccount({ id: feed.createdById }),
select: { id: true },
}),
findActiveGrant({ grantorId: feed.userId, granteeId: feed.createdById }),
]);
if (!carrier) return { ok: false, reason: "carrier" };
if (!grant || !grantAllows(grant, "manage")) {
return { ok: false, reason: "grant" };
}
if (!grantCoversDomain(grant, "cycle"))
return { ok: false, reason: "grant" };
}
return { ok: true, subject };
}
/** Does the request's `If-None-Match` name this ETag? */
function matchesEtag(request: NextRequest, etag: string): boolean {
const header = request.headers.get("if-none-match");
if (!header) return false;
return header
.split(",")
.map((v) => v.trim().replace(/^W\//, ""))
.some((v) => v === etag || v === "*");
}
export async function serveCycleFeed(
request: NextRequest,
segment: string,
): Promise<Response> {
annotate({ action: { name: "cycle.feed.fetch" } });
// 1. Before any lookup.
const surface = await checkAuthSurfaceRateLimit(
request,
"cycle-feed",
SURFACE_LIMIT_PER_HOUR,
HOUR_MS,
);
if (!surface.allowed) return tooMany(surface);
// 2. Shape.
const token = parseFeedTokenSegment(segment);
if (token === null) return notFound("malformed");
// 3. Lookup of a live feed.
const tokenHash = hashToken(token);
const feed = await prisma.cycleCalendarFeed.findFirst({
where: { tokenHash, revokedAt: null },
select: FEED_SELECT,
});
if (!feed) return notFound("unknown");
// 4. The link's own bucket.
const perToken = await checkRateLimit(
`cycle-feed:${tokenHash}`,
TOKEN_LIMIT_PER_HOUR,
HOUR_MS,
);
if (!perToken.allowed) return tooMany(perToken);
// 5. Gates.
const allowed = await feedStillAllowed(feed);
if (!allowed.ok) return notFound(allowed.reason);
// 6. The body, built live.
const now = new Date();
const built = await buildCycleFeed(feed, allowed.subject, now);
const changed = built.contentHash !== feed.bodyHash;
const changedAt = changed ? now : feed.contentChangedAt;
const etag = `"${built.contentHash}"`;
const conditional = request.headers.has("if-none-match");
const notModified = !changed && matchesEtag(request, etag);
const ua = request.headers.get("user-agent")?.slice(0, UA_MAX_LENGTH) ?? null;
trackBackgroundTask(
runDetached(() =>
prisma.cycleCalendarFeed
.update({
where: { id: feed.id },
data: {
lastFetchedAt: now,
fetchCount: { increment: 1 },
// A fetch without a User-Agent keeps the last one we know.
...(ua ? { lastFetcherUa: ua } : {}),
...(changed
? { bodyHash: built.contentHash, contentChangedAt: changedAt }
: {}),
},
})
.then(() => undefined)
.catch(() => {
// The statistics are best-effort; a failed write never fails the
// fetch, and the next fetch writes them again.
}),
),
);
annotate({
meta: {
status: notModified ? 304 : 200,
conditional,
uaFamily: fetcherFamily(ua),
projection_state: built.projectionState,
events: built.calendar.events.length,
},
});
const headers: Record<string, string> = {
...baseHeaders(),
// `no-cache`, not `no-store`: a client keeps the body and its validator
// and asks again with `If-None-Match`, which is what the 304 path is for.
"Cache-Control": "private, no-cache, max-age=0",
ETag: etag,
};
if (notModified) return new Response(null, { status: 304, headers });
return new Response(renderIcs(built.calendar, changedAt), {
status: 200,
headers: {
...headers,
"Content-Type": "text/calendar; charset=utf-8",
"Content-Disposition": 'inline; filename="healthlog.ics"',
},
});
}
+101
View File
@@ -0,0 +1,101 @@
/**
* What the settings card may know about a record's calendar feed (#1350).
*
* The token's prefix, the options and the fetch statistics. Never the hash
* and never the URL: the URL is returned once, by the mint, and after that it
* exists only where the person pasted it.
*/
import { prisma } from "@/lib/db";
import type { ProjectionState } from "@/lib/cycle/projection";
import { buildCycleFeed, type FeedSubject } from "./build";
import { fetcherFamily, type FetcherFamily } from "./fetcher";
import type { FeedTitleMode } from "./events";
export const FEED_SUMMARY_SELECT = {
id: true,
tokenPrefix: true,
createdAt: true,
lastFetchedAt: true,
fetchCount: true,
lastFetcherUa: true,
horizonCycles: true,
historyCycles: true,
includeFertile: true,
titleMode: true,
} as const;
export interface FeedSummaryRow {
id: string;
tokenPrefix: string;
createdAt: Date;
lastFetchedAt: Date | null;
fetchCount: number;
lastFetcherUa: string | null;
horizonCycles: number;
historyCycles: number;
includeFertile: boolean;
titleMode: FeedTitleMode;
}
export interface CycleFeedSummary {
tokenPrefix: string;
createdAt: string;
lastFetchedAt: string | null;
fetchCount: number;
/** Which calendar app fetched last, as a family. Never the raw UA. */
lastFetcher: FetcherFamily | null;
horizonCycles: number;
historyCycles: number;
includeFertile: boolean;
titleMode: FeedTitleMode;
}
export interface CycleFeedStatus {
/** The live feed, or null when the subscription is off. */
feed: CycleFeedSummary | null;
/**
* What the live feed serves right now (OK / LEARNING / LATE / OFF), so the
* card can say why it holds fewer cycles than asked. Null with no feed.
*/
projectionState: ProjectionState | null;
/** Whether the record's goal admits the fertile-window option at all. */
fertileAvailable: boolean;
}
export function toFeedSummary(row: FeedSummaryRow): CycleFeedSummary {
return {
tokenPrefix: row.tokenPrefix,
createdAt: row.createdAt.toISOString(),
lastFetchedAt: row.lastFetchedAt?.toISOString() ?? null,
fetchCount: row.fetchCount,
lastFetcher: fetcherFamily(row.lastFetcherUa),
horizonCycles: row.horizonCycles,
historyCycles: row.historyCycles,
includeFertile: row.includeFertile,
titleMode: row.titleMode,
};
}
/** The record's live feed, or null. The partial index admits at most one. */
export function findLiveFeed(userId: string): Promise<FeedSummaryRow | null> {
return prisma.cycleCalendarFeed.findFirst({
where: { userId, revokedAt: null },
select: FEED_SUMMARY_SELECT,
});
}
export async function cycleFeedStatus(
subject: FeedSubject,
goal: string,
): Promise<CycleFeedStatus> {
const live = await findLiveFeed(subject.id);
const fertileAvailable = goal === "TRYING_TO_CONCEIVE";
if (!live) return { feed: null, projectionState: null, fertileAvailable };
const built = await buildCycleFeed(live, subject);
return {
feed: toFeedSummary(live),
projectionState: built.projectionState,
fertileAvailable,
};
}
+50
View File
@@ -0,0 +1,50 @@
/**
* The `hlf_` calendar-feed credential (#1350).
*
* `hlf_` plus 48 hex characters: 192 random bits, the share link's strength.
* Only `hashToken(raw)` is stored, under the same `API_TOKEN_HMAC_KEY` as every
* other HealthLog credential, so rotating that key ends every feed URL along
* with every token (documented in the self-hosting guide). The raw token is
* returned once at mint and cannot be recovered afterwards.
*/
import { randomBytes } from "node:crypto";
import { hashToken } from "@/lib/auth/hmac";
export const FEED_TOKEN_PREFIX = "hlf_";
/** The exact shape a feed token has. Anything else is not looked up. */
const FEED_TOKEN_SHAPE = /^hlf_[0-9a-f]{48}$/;
/** How many leading characters the settings card shows. */
export const FEED_TOKEN_DISPLAY_PREFIX_LENGTH = 8;
export interface MintedFeedToken {
raw: string;
hash: string;
prefix: string;
}
export function mintFeedToken(): MintedFeedToken {
const raw = `${FEED_TOKEN_PREFIX}${randomBytes(24).toString("hex")}`;
return {
raw,
hash: hashToken(raw),
prefix: raw.slice(0, FEED_TOKEN_DISPLAY_PREFIX_LENGTH),
};
}
/**
* The token from a path segment, or null when it is not one. A trailing
* `.ics` is accepted and stripped, because calendar apps are happier with a
* URL that names its file type.
*/
export function parseFeedTokenSegment(segment: string): string | null {
const token = segment.endsWith(".ics") ? segment.slice(0, -4) : segment;
return FEED_TOKEN_SHAPE.test(token) ? token : null;
}
/** The feed path for a raw token, relative to the instance origin. */
export function feedPath(raw: string): string {
return `/api/cycle/feed/${raw}.ics`;
}
+166
View File
@@ -0,0 +1,166 @@
/**
* A small, hand-rolled RFC 5545 writer for the cycle calendar feed (#1350).
*
* The feed needs one shape of calendar: all-day events, a published
* subscription, no time zones and no recurrence. That is a few dozen lines of
* the specification, so it is written here rather than taken from a
* dependency whose defaults (descriptions, organisers, alarms, a VTIMEZONE)
* would each be something to switch off.
*
* What the specification asks for and this does:
* * CRLF line endings (§3.1).
* * Lines folded at 75 octets, never inside a UTF-8 sequence, continued with
* a single space (§3.1).
* * TEXT escaping of backslash, semicolon, comma and newline (§3.3.11).
* * All-day events as `VALUE=DATE`, with DTEND exclusive (§3.6.1).
*
* Pure: no clock, no database. The caller hands in the stamp.
*/
import { createHash, createHmac } from "node:crypto";
export interface IcsEvent {
/** Globally unique and stable across rebuilds of the same feed. */
uid: string;
/** First day, `YYYY-MM-DD`. */
start: string;
/** The day AFTER the last day, `YYYY-MM-DD` (DTEND is exclusive). */
endExclusive: string;
summary: string;
description?: string;
}
export interface IcsCalendar {
/** `X-WR-CALNAME`, the name a calendar app shows for the subscription. */
name: string;
events: readonly IcsEvent[];
}
const CRLF = "\r\n";
const MAX_OCTETS = 75;
/** The stamp a body is hashed with, so the hash never depends on the clock. */
const HASH_STAMP = "19700101T000000Z";
/** §3.3.11 — escape a TEXT value. */
export function escapeText(value: string): string {
return value
.replace(/\\/g, "\\\\")
.replace(/;/g, "\\;")
.replace(/,/g, "\\,")
.replace(/\r\n|\r|\n/g, "\\n");
}
/**
* §3.1 — fold a content line at 75 octets. Continuation lines begin with one
* space, which counts toward their own 75. Splits only between code points,
* so a multibyte character is never cut in half.
*/
export function foldLine(line: string): string {
const out: string[] = [];
let current = "";
let octets = 0;
for (const ch of line) {
const size = Buffer.byteLength(ch, "utf8");
if (octets + size > MAX_OCTETS) {
out.push(current);
current = " ";
octets = 1;
}
current += ch;
octets += size;
}
out.push(current);
return out.join(CRLF);
}
/** `YYYY-MM-DD` → `YYYYMMDD`. */
function icsDate(day: string): string {
if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) {
throw new Error(`Not a calendar day: ${day}`);
}
return day.replace(/-/g, "");
}
/** A UTC instant as `YYYYMMDDTHHMMSSZ`. */
export function icsStamp(at: Date): string {
return at
.toISOString()
.replace(/\.\d{3}Z$/, "Z")
.replace(/[-:]/g, "");
}
const BASE32 = "abcdefghijklmnopqrstuvwxyz234567";
/** RFC 4648 base32, lower case, unpadded. */
function base32(bytes: Uint8Array): string {
let out = "";
let bits = 0;
let value = 0;
for (const byte of bytes) {
value = (value << 8) | byte;
bits += 8;
while (bits >= 5) {
out += BASE32[(value >>> (bits - 5)) & 31];
bits -= 5;
}
}
if (bits > 0) out += BASE32[(value << (5 - bits)) & 31];
return out;
}
/**
* A stable event UID: the feed's id keys an HMAC over the slot (`obs:<cycle
* id>` or `pred:<k>`). Stable across rebuilds of one feed, so a calendar app
* updates an event in place; unrelated across feeds, so a rotated link starts
* a fresh set; and it carries neither a user id nor a host.
*/
export function eventUid(feedId: string, slot: string): string {
const mac = createHmac("sha256", feedId).update(slot).digest();
return `${base32(mac.subarray(0, 20))}@healthlog.invalid`;
}
function render(calendar: IcsCalendar, stamp: string): string {
const lines: string[] = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//HealthLog//Calendar feed//EN",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
`X-WR-CALNAME:${escapeText(calendar.name)}`,
"REFRESH-INTERVAL;VALUE=DURATION:PT6H",
"X-PUBLISHED-TTL:PT6H",
];
for (const event of calendar.events) {
lines.push(
"BEGIN:VEVENT",
`UID:${event.uid}`,
`DTSTAMP:${stamp}`,
`LAST-MODIFIED:${stamp}`,
`DTSTART;VALUE=DATE:${icsDate(event.start)}`,
`DTEND;VALUE=DATE:${icsDate(event.endExclusive)}`,
`SUMMARY:${escapeText(event.summary)}`,
);
if (event.description) {
lines.push(`DESCRIPTION:${escapeText(event.description)}`);
}
lines.push("TRANSP:TRANSPARENT", "CLASS:PRIVATE", "END:VEVENT");
}
lines.push("END:VCALENDAR");
return lines.map(foldLine).join(CRLF) + CRLF;
}
/**
* The SHA-256 of the calendar as it would render with a fixed stamp. Equal
* inputs give an equal hash whatever the clock says, which is what lets the
* feed move its stamps only when the content moved.
*/
export function icsContentHash(calendar: IcsCalendar): string {
return createHash("sha256")
.update(render(calendar, HASH_STAMP))
.digest("hex");
}
/** The calendar, every event stamped with `changedAt`. */
export function renderIcs(calendar: IcsCalendar, changedAt: Date): string {
return render(calendar, icsStamp(changedAt));
}
+223
View File
@@ -0,0 +1,223 @@
/**
* The one loader for the rows a cycle forecast reads.
*
* The calendar read, the Coach and MCP cycle block and the calendar feed all
* run the same engine over the same three inputs — the record's cycles, its
* day logs and its overnight wrist temperatures — and they only agree with
* each other when they read the same rows. The day-log window is the part that
* matters: `estimatePeriodLength` falls back to the logged flow days when a
* cycle carries no `periodEndDate`, so a reader with a wider window can predict
* a different period length than one with a narrower window. Every consumer
* therefore states its window here, and the ones that claim to agree with the
* calendar pass the calendar's default.
*
* Server-only. No consumer recomputes any of this on a client.
*/
import { prisma } from "@/lib/db";
import { addDays, dayDiff } from "@/lib/cycle/day-math";
import { BBT_WINDOW, type NightlyTempInput } from "@/lib/cycle/types";
import { resolveSensitiveFields } from "@/lib/cycle/dto";
import type {
CalendarDayLogRow,
CycleEngineInputs,
} from "@/lib/cycle/engine-adapter";
import { moodDateKey } from "@/lib/mood/date-key";
import { startOfLocalDayKey } from "@/lib/tz/local-day";
import type { MenstrualCycle } from "@/generated/prisma/client";
/** How far back the calendar shows by default, and its day-log window. */
export const CALENDAR_DEFAULT_PAST_DAYS = 90;
/** How far back the overnight wrist temperatures are read. */
const NIGHTS_LOOKBACK_DAYS = 90;
/**
* The earliest day log a calendar view starting at `from` needs: the view
* itself, or the symptothermal lookback before today, whichever is earlier.
*/
export function dayLogWindowStart(from: string, today: string): string {
const lookback = addDays(today, -BBT_WINDOW);
return dayDiff(from, lookback) <= 0 ? from : lookback;
}
/**
* The day-log window of the calendar's default view. A consumer that promises
* to agree with what the calendar page shows reads exactly this.
*/
export function defaultCalendarDayLogStart(today: string): string {
return dayLogWindowStart(addDays(today, -CALENDAR_DEFAULT_PAST_DAYS), today);
}
interface LoadWindow {
userId: string;
/** Today in the record's own timezone. */
today: string;
/** The record's timezone. */
tz: string;
/** The earliest day log to read, inclusive. */
dayLogsFrom: string;
}
/** Every live cycle of the record, oldest first. */
function loadCycles(userId: string): Promise<MenstrualCycle[]> {
return prisma.menstrualCycle.findMany({
where: { userId, deletedAt: null },
orderBy: { startDate: "asc" },
});
}
/**
* The record's overnight wrist temperatures as nightly values. A measurements
* read on a cycle surface, and it needs no section predicate for one reason:
* no value from it reaches any response. It goes into `predictCycle` and what
* comes out is a shifted date.
*/
async function loadNights(
userId: string,
today: string,
tz: string,
): Promise<NightlyTempInput[]> {
const rows = await prisma.measurement.findMany({
where: {
userId,
deletedAt: null,
type: "WRIST_TEMPERATURE",
measuredAt: {
gte: startOfLocalDayKey(addDays(today, -NIGHTS_LOOKBACK_DAYS), tz),
},
},
orderBy: { measuredAt: "asc" },
select: { measuredAt: true, value: true },
});
return rows.map((m) => ({
date: moodDateKey(m.measuredAt, tz),
valueC: m.value,
}));
}
/** The engine's own day-log fields, nothing the forecast does not read. */
const ENGINE_DAY_LOG_SELECT = {
date: true,
flow: true,
basalBodyTempC: true,
temperatureExcluded: true,
ovulationTest: true,
cervicalMucus: true,
cervixPosition: true,
cervixFirmness: true,
cervixOpening: true,
} as const;
/** The engine inputs, with the full cycle rows for callers that need them. */
export interface LoadedEngineInputs extends CycleEngineInputs {
cycles: MenstrualCycle[];
}
/**
* The forecast's inputs and nothing else. The intent fields, notes and
* symptoms are not selected at all, so a consumer built on this loader cannot
* reach them even by mistake.
*/
export async function loadCycleEngineInputs(
window: LoadWindow,
): Promise<LoadedEngineInputs> {
const [cycles, dayLogs, nights] = await Promise.all([
loadCycles(window.userId),
prisma.cycleDayLog.findMany({
where: {
userId: window.userId,
deletedAt: null,
date: { gte: window.dayLogsFrom },
},
orderBy: { date: "asc" },
select: ENGINE_DAY_LOG_SELECT,
}),
loadNights(window.userId, window.today, window.tz),
]);
return { cycles, dayLogs, nights };
}
/** The calendar grid's inputs: the engine's, plus what each day cell shows. */
export interface LoadedCalendarInputs {
cycles: MenstrualCycle[];
dayLogs: CalendarDayLogRow[];
nights: NightlyTempInput[];
}
/**
* The calendar read's inputs. Same cycles, same window rule and same nights
* as {@link loadCycleEngineInputs}; the day logs additionally carry the
* fields the grid paints, the intent fields resolved through the same reader
* the day-log route uses (same `cycle` grant, so the grid shows a delegate
* nothing the day log would not).
*/
export async function loadCalendarInputs(
window: LoadWindow,
): Promise<LoadedCalendarInputs> {
const [cycles, dayLogRows, nights] = await Promise.all([
loadCycles(window.userId),
prisma.cycleDayLog.findMany({
where: {
userId: window.userId,
deletedAt: null,
date: { gte: window.dayLogsFrom },
},
orderBy: { date: "asc" },
select: {
...ENGINE_DAY_LOG_SELECT,
intermenstrualBleeding: true,
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 } },
},
}),
loadNights(window.userId, window.today, window.tz),
]);
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 !== "",
};
});
return { cycles, dayLogs, nights };
}
/**
* The flow of every day log on or after `from`, for the history events of the
* calendar feed: a logged period without a `periodEndDate` is drawn from its
* observed bleeding days. Never fed to the forecast, which keeps its own
* window.
*/
export function loadFlowDays(
userId: string,
from: string,
): Promise<Array<{ date: string; flow: CalendarDayLogRow["flow"] }>> {
return prisma.cycleDayLog.findMany({
where: { userId, deletedAt: null, date: { gte: from } },
orderBy: { date: "asc" },
select: { date: true, flow: true },
});
}
+4 -4
View File
@@ -89,8 +89,8 @@ export function mad(values: readonly number[], centre: number): number {
/* §1 — cycle-length estimation */
/* ------------------------------------------------------------------ */
/** Completed cycle = one whose successor's start exists, giving a known length. */
function completedLengths(cycles: readonly CycleInput[]): number[] {
/** Completed cycle = one whose successor's start exists (shared with projection.ts). */
export function completedLengths(cycles: readonly CycleInput[]): number[] {
// Sort by startDate ascending (the canonical order), then diff consecutive
// starts. A cycle is completed iff a later start exists.
const starts = cycles.map((c) => c.startDate).sort((a, b) => dayDiff(a, b));
@@ -597,9 +597,9 @@ function varianceFactor(cv: number): number {
/**
* §3(b)/§3 — logging-density derived factors. Returns the adherence confidence
* factor and the LOG_SPARSITY band-penalty term.
* factor and the LOG_SPARSITY band-penalty term (also widens projection.ts).
*/
function adherenceFactors(
export function adherenceFactors(
lastStart: string,
estimatedLength: number,
dayLogs: readonly DayLogInput[],
+165
View File
@@ -0,0 +1,165 @@
/**
* The forecast beyond the next period: a few cycles ahead, each with a band
* that widens the further out it reaches (#1350).
*
* Pure and DB-free. It reads the engine's own result for the next cycle and
* the same length statistics the engine computed it from, and adds nothing the
* engine does not already know. Cycle 1 is the engine's forecast unchanged —
* a confirmed ovulation, an LH anchor or a cold-start band bonus all still
* apply to it — so every surface that shows "the next period" agrees with the
* projection's first row by construction. TypeScript only: the Swift engine
* mirrors algorithm.md §1–§5, and nothing here changes that output.
*
* The band of cycle k ≥ 2 is the robust SD of a sum of k cycle lengths whose
* mean is itself estimated from n observed ones, `σ·√(k + k²/n)`, widened by
* the same logging-sparsity penalty the engine applies to cycle 1. It never
* narrows from one cycle to the next. The horizon stops itself where the band
* passes ±10 days or half a cycle: beyond that a date is a guess, and the
* honest answer is not to print one.
*/
import {
adherenceFactors,
completedLengths,
estimateCycleLength,
} from "./prediction";
import { addDays, dayDiff } from "./day-math";
import type {
CycleInput,
CyclePredictionResult,
CycleTrackingGoal,
DayLogInput,
} from "./types";
/** The most cycles a projection ever reaches. */
export const PROJECTION_MAX_HORIZON = 6;
/** The widest band (± days) a projected cycle may carry and still be shown. */
export const PROJECTION_BAND_CAP_DAYS = 10;
/**
* Why the projection holds what it holds.
*
* `OK` the projected cycles, self-capped.
* `LEARNING` fewer than three observed cycles: the next period only, or
* nothing when no cycle is logged at all.
* `LATE` the next period's band has passed with no new period logged.
* No forecast until one is: a past-dated or re-anchored guess
* would be a claim the data does not support.
* `OFF` prediction is switched off, or raw-chart mode is on.
*/
export type ProjectionState = "OK" | "LEARNING" | "LATE" | "OFF";
export interface ProjectedCycle {
/** 1 = the next period; the engine's own forecast. */
k: number;
/** The most likely first day, `YYYY-MM-DD`. */
start: string;
/** Predicted bleeding days. */
periodLength: number;
/** The earliest likely first day (start − band). */
bandStart: string;
/** The latest likely first day (start + band). */
bandEnd: string;
}
export interface CycleProjection {
state: ProjectionState;
cycles: ProjectedCycle[];
}
export interface ProjectionInput {
/** The gated engine result (`gatedPrediction`), or null when gated off. */
prediction: CyclePredictionResult | null;
/** The record's cycles, any order. */
cycles: readonly CycleInput[];
/** The day logs the prediction was computed from. */
dayLogs: readonly DayLogInput[];
goal: CycleTrackingGoal;
/** Today in the record's timezone. */
today: string;
/** How many cycles the caller wants, at most. Clamped to 1..6. */
horizon: number;
}
function cycleOne(p: CyclePredictionResult): ProjectedCycle {
return {
k: 1,
start: p.nextPeriodStart,
periodLength: p.predictedPeriodLength,
bandStart: p.nextPeriodStartLow,
bandEnd: p.nextPeriodStartHigh,
};
}
/**
* Project the next `horizon` cycles. See the module comment for the rules;
* the order of the early returns is the order the states are decided in.
*/
export function projectCycles(input: ProjectionInput): CycleProjection {
const { prediction, today } = input;
if (prediction === null) return { state: "OFF", cycles: [] };
// No logged cycle at all: the engine would anchor a population prior on
// today. That is not the record's forecast, so nothing is projected.
if (input.cycles.length === 0) return { state: "LEARNING", cycles: [] };
// The late rule. The band of the next period ended before today and no new
// period was logged (a logged one would have moved the anchor). Deliberately
// stricter than the verdict's OVERDUE, which waits for typical + 14 days:
// a feed that kept printing a period in the past would be wrong every day.
if (dayDiff(today, prediction.nextPeriodStartHigh) > 0) {
return { state: "LATE", cycles: [] };
}
const first = cycleOne(prediction);
if (prediction.stillLearning) return { state: "LEARNING", cycles: [first] };
// Perimenopause makes cycle length the very thing that changes; a chain of
// further dates would extrapolate exactly the regularity that has gone.
if (input.goal === "PERIMENOPAUSE") return { state: "OK", cycles: [first] };
const horizon = Math.max(
1,
Math.min(PROJECTION_MAX_HORIZON, Math.trunc(input.horizon)),
);
const est = estimateCycleLength(completedLengths(input.cycles), input.goal);
const n = est.cyclesObserved;
if (!Number.isFinite(est.lengthFractional) || n < 1) {
return { state: "OK", cycles: [first] };
}
const lastStart =
[...input.cycles]
.map((c) => c.startDate)
.sort((a, b) => dayDiff(a, b))
.at(-1) ?? today;
const { logSparsity } = adherenceFactors(
lastStart,
est.lengthFractional,
input.dayLogs,
today,
);
const cap = Math.min(PROJECTION_BAND_CAP_DAYS, est.lengthFractional / 2);
const out: ProjectedCycle[] = [first];
let previousBand = dayDiff(first.bandEnd, first.start);
for (let k = 2; k <= horizon; k++) {
const spread = est.sigma * (1 + logSparsity) * Math.sqrt(k + (k * k) / n);
const band = Math.max(previousBand + 1, Math.round(spread));
if (band > cap) break;
// Rounded per cycle from the fractional length, anchored on cycle 1's
// start, so six cycles out carry no accumulated rounding drift.
const start = addDays(
first.start,
Math.round((k - 1) * est.lengthFractional),
);
out.push({
k,
start,
periodLength: prediction.predictedPeriodLength,
bandStart: addDays(start, -band),
bandEnd: addDays(start, band),
});
previousBand = band;
}
return { state: "OK", cycles: out };
}
+6
View File
@@ -223,6 +223,10 @@ export const WIPE_MODELS = [
// `resolveShareGateState` both resolve an unknown `tokenHash` to `null`, and
// null is the same blunt 404 a revoked link gets.
"ClinicianShareLink",
// #1350 — the cycle calendar feed, on both sides (see WIPE_OWNER_FIELDS):
// the feed on this record, and any this account minted on a record it
// manages. Deleting the row is the revocation, as for the share link.
"CycleCalendarFeed",
// v1.36.0 — account grants, on BOTH sides (see WIPE_OWNER_FIELDS): the ones
// this account made over its own record, and the ones it holds over someone
// else's.
@@ -587,6 +591,8 @@ export const WIPE_OWNER_FIELDS: Readonly<Record<string, readonly string[]>> = {
NotificationEvent: ["recordUserId"],
NotificationEgressAuthorization: ["recordUserId", "recipientUserId"],
ManagedProfileHandover: ["profileId", "createdById"],
// #1350 — the record the feed serves, and the carrier who minted it.
CycleCalendarFeed: ["userId", "createdById"],
// Both sides. Wiping only the grantor side would leave the account still
// holding read access to other people's records after it asked for
// everything of its own to be deleted.
Loaded 100 of 135 files, more files were not shown because too many files have changed in this diff. Show more