mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
Compare commits
14
Commits
2e14b2d8b5
...
be7a65bdb9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be7a65bdb9 | ||
|
|
a74d2df3c5 | ||
|
|
2522a784f9 | ||
|
|
3b3ef5c0a1 | ||
|
|
e8ef590af7 | ||
|
|
d9a31524a2 | ||
|
|
ac31ac40b5 | ||
|
|
2480a0138d | ||
|
|
c0470df14c | ||
|
|
c96e0e0f78 | ||
|
|
65f1a3db91 | ||
|
|
910f28dcfe | ||
|
|
92638ec089 | ||
|
|
e2170115c7 |
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -72,11 +72,11 @@ echo "ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env
|
||||
echo "API_TOKEN_HMAC_KEY=$(openssl rand -hex 32)" >> .env
|
||||
```
|
||||
|
||||
| Variable | Purpose | Rotation cost |
|
||||
| -------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| `POSTGRES_PASSWORD` | Bundled Postgres password — used by both the `db` service and `DATABASE_URL` | Database restart + connection-string update |
|
||||
| `ENCRYPTION_KEY` | AES-256-GCM key for at-rest secrets (Withings tokens, AI provider keys, VAPID secrets) | Coordinated; see `docs/ops/encryption-key-rotation.md` |
|
||||
| `API_TOKEN_HMAC_KEY` | HMAC-SHA256 key for hashing Bearer API tokens and web session cookies before storage | Invalidates every issued `hlk_*` token and signs out every browser session |
|
||||
| Variable | Purpose | Rotation cost |
|
||||
| -------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
|
||||
| `POSTGRES_PASSWORD` | Bundled Postgres password — used by both the `db` service and `DATABASE_URL` | Database restart + connection-string update |
|
||||
| `ENCRYPTION_KEY` | AES-256-GCM key for at-rest secrets (Withings tokens, AI provider keys, VAPID secrets) | Coordinated; see `docs/ops/encryption-key-rotation.md` |
|
||||
| `API_TOKEN_HMAC_KEY` | HMAC-SHA256 key for hashing Bearer API tokens and web session cookies before storage | Invalidates every issued `hlk_*` token, share link and cycle calendar-feed link, and signs out every browser session |
|
||||
|
||||
You do **not** edit `DATABASE_URL` on the bundled `docker-compose.yml`
|
||||
stack: compose constructs the connection string inside the `app`
|
||||
@@ -289,8 +289,8 @@ this server needs:
|
||||
retired id for as long as a stored or off-host backup copy still needs it;
|
||||
- `BACKUP_ENCRYPTION_KEY` and any `BACKUP_ENCRYPTION_PREVIOUS_KEYS`, when
|
||||
off-host copies are set up;
|
||||
- `API_TOKEN_HMAC_KEY`, without which every session, token and share link ends
|
||||
(no data is lost).
|
||||
- `API_TOKEN_HMAC_KEY`, without which every session, token, share link and
|
||||
calendar-feed link ends (no data is lost).
|
||||
|
||||
Each key is shown by id and a fingerprint (the first 12 hex characters of
|
||||
SHA-256 over the key), never the key itself, and **Check my copy** compares a
|
||||
|
||||
@@ -121,6 +121,27 @@ the grace window to 0 or wait it out, remove the old volume, and let the
|
||||
dumps age out or delete them
|
||||
([backup-restore.md](../ops/backup-restore.md)).
|
||||
|
||||
## Calendar subscription
|
||||
|
||||
The cycle settings can publish your forecast as a calendar feed that Google
|
||||
Calendar, Apple Calendar or Outlook subscribes to. It is off by default, and
|
||||
turning it on changes the arrangement this page describes: from then on the
|
||||
feed's dates also sit with that calendar provider, on its servers, under its
|
||||
policies. That is exactly the cloud vendor self-hosting otherwise keeps out of
|
||||
the picture.
|
||||
|
||||
What the feed carries is deliberately small. Each event is an all-day date
|
||||
range and a title, nothing else: no symptoms, notes, flow, temperatures, test
|
||||
results, contraception or intercourse fields. By default the title is a dot
|
||||
and the calendar is named "HealthLog", so a glance at a shared screen says
|
||||
nothing; past cycles are left out unless you ask for them, and the fertile
|
||||
window is only offered when your goal is trying to conceive.
|
||||
|
||||
Turning the subscription off, or rotating its link, stops the provider's next
|
||||
poll. It does not reach the copies the provider already fetched: remove the
|
||||
subscribed calendar there as well. The link itself is a credential; see
|
||||
[keeping it out of access logs](./reverse-proxy.md#credentials-in-the-url-path-keep-them-out-of-access-logs).
|
||||
|
||||
## Practical hardening
|
||||
|
||||
If reproductive-health privacy is the reason you self-host, the
|
||||
|
||||
@@ -336,6 +336,49 @@ A `TRUST_PROXY_HOPS` value that is not a whole number resolves no client
|
||||
address at all (requests keep working) and is named in the `[proxy-trust]`
|
||||
boot line.
|
||||
|
||||
## Credentials in the URL path: keep them out of access logs
|
||||
|
||||
A few HealthLog URLs carry their credential in the path, because the caller
|
||||
cannot send a header: the cycle calendar feed (`/api/cycle/feed/hlf_…`,
|
||||
polled by Google, Apple or Outlook), the clinician share view (`/c/hls_…`)
|
||||
and the provider webhooks. HealthLog scrubs these segments from its own logs
|
||||
and error reports. It cannot reach your proxy's access log, which records the
|
||||
full path of every request by default, so anyone who can read that log can
|
||||
read a live feed link.
|
||||
|
||||
Either drop those requests from the access log or rewrite the path before it
|
||||
is written. For Caddy, skip logging the token-bearing paths:
|
||||
|
||||
```caddyfile
|
||||
your-instance.example.com {
|
||||
@credential_paths path /api/cycle/feed/* /c/*
|
||||
log_skip @credential_paths
|
||||
reverse_proxy app:3000
|
||||
}
|
||||
```
|
||||
|
||||
For Nginx, log a redacted path instead of `$request`:
|
||||
|
||||
```nginx
|
||||
map $request_uri $redacted_uri {
|
||||
~^/api/cycle/feed/ /api/cycle/feed/[REDACTED];
|
||||
~^/c/ /c/[REDACTED];
|
||||
default $request_uri;
|
||||
}
|
||||
log_format redacted '$remote_addr - [$time_local] "$request_method $redacted_uri" '
|
||||
'$status $body_bytes_sent "$http_user_agent"';
|
||||
access_log /var/log/nginx/access.log redacted;
|
||||
```
|
||||
|
||||
Traefik's access log has no per-path filter; turn `accessLog` off, or keep it
|
||||
on a host only you can read. If a feed link has been in a log you do not
|
||||
control, rotate it in the app (Cycle, Settings, Calendar subscription, Rotate
|
||||
link): the old one stops working on the provider's next poll.
|
||||
|
||||
The feed answers with `Cache-Control: private, no-cache` and an `ETag`, so a
|
||||
caching proxy in front must pass `If-None-Match` through rather than cache the
|
||||
body for everyone.
|
||||
|
||||
## Running over plain HTTP on a LAN (no reverse proxy)
|
||||
|
||||
The session cookie carries the `Secure` flag by default under
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# Subscribing to your cycle forecast
|
||||
|
||||
HealthLog can publish your cycle forecast as a calendar subscription, so the
|
||||
next few periods show up in the calendar app you already use. The feed is
|
||||
computed fresh every time your calendar app asks for it; HealthLog stays the
|
||||
only place the data is kept.
|
||||
|
||||
## Turning it on
|
||||
|
||||
Open **Cycle**, then **Settings**, and find **Calendar subscription**. Before
|
||||
the button, the card says what it means: your calendar provider stores the
|
||||
dates on its servers, and turning the subscription off later does not remove
|
||||
copies it already holds. HealthLog asks you to confirm it is you (a password or
|
||||
passkey) and then shows the link once. Copy it, or use **Open in calendar app**.
|
||||
|
||||
Only you can turn it on for your own record. For a managed profile (a child's
|
||||
record, for instance), its guardian can. Someone you have shared your record
|
||||
with cannot, whatever level of access you gave them: the feed puts your dates
|
||||
in their calendar provider's cloud, and that is your decision alone. It is
|
||||
managed on the web; the iOS app shows where to find it.
|
||||
|
||||
## Adding it to your calendar
|
||||
|
||||
- **Google Calendar:** Other calendars, From URL, paste the link. Google
|
||||
refreshes subscriptions on its own schedule, roughly every 12 to 24 hours.
|
||||
- **Apple Calendar:** on a Mac, File, New Calendar Subscription. On iPhone,
|
||||
Settings, Calendar, Accounts, Add Account, Other, Add Subscribed Calendar.
|
||||
Apple lets you choose how often it refreshes.
|
||||
- **Outlook:** Add calendar, Subscribe from web, paste the link. Outlook
|
||||
refreshes every few hours.
|
||||
|
||||
## What the feed shows
|
||||
|
||||
- The next period and, once three cycles are logged, a few after it (three
|
||||
by default, up to six). Each event covers the whole window the period may
|
||||
fall in, and the window widens the further out it is. The list stops itself
|
||||
where a date would be more guess than estimate (beyond about ±10 days, or
|
||||
half a cycle).
|
||||
- Optionally, your past cycles (up to twelve).
|
||||
- Optionally, when your goal is trying to conceive, the estimated fertile
|
||||
window of the next cycle. It is an estimate, not a contraceptive method.
|
||||
|
||||
Titles are discreet by default: each event is a dot and the calendar is called
|
||||
"HealthLog". Descriptive titles name the period instead. Events never carry
|
||||
symptoms, notes or any other detail of your log.
|
||||
|
||||
When a period is later than its window, the feed shows no forecast until you
|
||||
log it, instead of guessing a new date. With predictions switched off, only
|
||||
past cycles appear (if you chose to include them).
|
||||
|
||||
## Rotating and turning off
|
||||
|
||||
**Rotate link** replaces the link; calendars using the old one stop updating.
|
||||
**Turn off** ends the link. In both cases, remove the old subscribed calendar
|
||||
in your calendar app too: what it already fetched stays there until you do.
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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$;
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -15,6 +15,7 @@ import { aliasNamesByMarker } from "@/lib/labs/biomarker-aliases";
|
||||
import { serialiseBiomarker } from "@/lib/labs/biomarker-dto";
|
||||
import { encryptContextToBytes } from "@/lib/labs/biomarker-store";
|
||||
import { matchAnalyteKey } from "@/lib/labs/analyte-match";
|
||||
import { customPanelSuggestions } from "@/lib/labs/marker-category";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { createBiomarkerSchema } from "@/lib/validations/biomarkers";
|
||||
|
||||
@@ -64,6 +65,9 @@ export const GET = apiHandler(async () => {
|
||||
biomarkers: rows.map((row) =>
|
||||
serialiseBiomarker(row, aliases.get(row.id) ?? [], { soft: true }),
|
||||
),
|
||||
// The person's own panel spellings, for the panel suggestions in the
|
||||
// marker form; catalog panels are offered there from the bundle.
|
||||
customPanels: customPanelSuggestions(rows.map((row) => row.panel)),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -167,6 +167,52 @@ describe("GET /api/cycle/calendar is a read", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/cycle/calendar carries the projection (#1350)", () => {
|
||||
it("publishes the projection whose first row is the prediction", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-06-10T12:00:00Z"));
|
||||
|
||||
const { status, body } = await callCalendar();
|
||||
expect(status).toBe(200);
|
||||
const data = body.data as {
|
||||
prediction: { nextPeriodStart: string; nextPeriodStartLow: string };
|
||||
projection: Array<Record<string, unknown>>;
|
||||
projectionState: string;
|
||||
};
|
||||
expect(data.projectionState).not.toBe("OFF");
|
||||
expect(data.projection.length).toBeGreaterThan(0);
|
||||
{
|
||||
expect(data.projection[0]).toMatchObject({
|
||||
k: 1,
|
||||
start: data.prediction.nextPeriodStart,
|
||||
bandStart: data.prediction.nextPeriodStartLow,
|
||||
});
|
||||
// Dates and bands only: the fertile window stays in `prediction`.
|
||||
expect(Object.keys(data.projection[0]).sort()).toEqual([
|
||||
"bandEnd",
|
||||
"bandStart",
|
||||
"k",
|
||||
"periodLength",
|
||||
"start",
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("publishes an empty projection, state OFF, with prediction switched off", async () => {
|
||||
// The mock answers with one object; switching it off there switches it
|
||||
// off for the route's own call.
|
||||
const gate = await requireCycleEnabled("user-1", null);
|
||||
if (gate.enabled) gate.profile.predictionEnabled = false;
|
||||
const { body } = await callCalendar();
|
||||
const data = body.data as {
|
||||
projection: unknown[];
|
||||
projectionState: string;
|
||||
};
|
||||
expect(data.projection).toEqual([]);
|
||||
expect(data.projectionState).toBe("OFF");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/cycle/calendar carries the resolved verdict", () => {
|
||||
it("puts the whole verdict on the wire", async () => {
|
||||
// 2026-06-10 12:00 UTC is already 2026-06-11 for a user at UTC+14.
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
@@ -28,24 +27,27 @@ import { requireCycleEnabled } from "@/lib/cycle/gate";
|
||||
import { cycleCalendarQuerySchema } from "@/lib/validations/cycle";
|
||||
import {
|
||||
buildCalendar,
|
||||
type CalendarDayLogRow,
|
||||
toCycleInputs,
|
||||
toDayLogInputs,
|
||||
} from "@/lib/cycle/engine-adapter";
|
||||
import {
|
||||
CALENDAR_DEFAULT_PAST_DAYS,
|
||||
dayLogWindowStart,
|
||||
loadCalendarInputs,
|
||||
} from "@/lib/cycle/load-inputs";
|
||||
import { PROJECTION_MAX_HORIZON, projectCycles } from "@/lib/cycle/projection";
|
||||
import {
|
||||
toCyclePredictionDTO,
|
||||
goalAllowsFertileWindow,
|
||||
cycleDisclaimerKey,
|
||||
resolveSensitiveFields,
|
||||
} from "@/lib/cycle/dto";
|
||||
import { resolveCycleVerdict } from "@/lib/cycle/verdict";
|
||||
import { addDays, dayDiff } from "@/lib/cycle/day-math";
|
||||
import { BBT_WINDOW } from "@/lib/cycle/types";
|
||||
import { addDays } from "@/lib/cycle/day-math";
|
||||
import { moodDateKey } from "@/lib/mood/date-key";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { startOfLocalDayKey } from "@/lib/tz/local-day";
|
||||
|
||||
const DEFAULT_PAST_DAYS = 90;
|
||||
const DEFAULT_FORWARD_DAYS = 180;
|
||||
/** Hard cap on the rendered span (days) to bound the day-grid build. */
|
||||
const MAX_SPAN_DAYS = 400;
|
||||
@@ -75,7 +77,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const tz = user.timezone ?? DEFAULT_TIMEZONE;
|
||||
const today = moodDateKey(new Date(), tz);
|
||||
const from = parsed.data.from ?? addDays(today, -DEFAULT_PAST_DAYS);
|
||||
const from = parsed.data.from ?? addDays(today, -CALENDAR_DEFAULT_PAST_DAYS);
|
||||
const to = parsed.data.to ?? addDays(today, DEFAULT_FORWARD_DAYS);
|
||||
|
||||
// Reject an inverted or absurdly wide range up front.
|
||||
@@ -87,103 +89,19 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
}
|
||||
|
||||
const [cycles, dayLogRows, nightlyTemps] = await Promise.all([
|
||||
prisma.menstrualCycle.findMany({
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
orderBy: { startDate: "asc" },
|
||||
}),
|
||||
// Bound the day-log read to the rendered span plus the symptothermal
|
||||
// lookback — the earlier of `from` and (today − BBT_WINDOW). Cycle-length
|
||||
// stats run off MenstrualCycle rows, so day-logs can be windowed (QA: perf
|
||||
// — unbounded full-history read on a hot, per-navigation route).
|
||||
prisma.cycleDayLog.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
date: {
|
||||
gte:
|
||||
dayDiff(from, addDays(today, -BBT_WINDOW)) <= 0
|
||||
? from
|
||||
: addDays(today, -BBT_WINDOW),
|
||||
},
|
||||
},
|
||||
orderBy: { date: "asc" },
|
||||
select: {
|
||||
date: true,
|
||||
flow: true,
|
||||
basalBodyTempC: true,
|
||||
temperatureExcluded: true,
|
||||
ovulationTest: true,
|
||||
cervicalMucus: true,
|
||||
cervixPosition: true,
|
||||
cervixFirmness: true,
|
||||
cervixOpening: true,
|
||||
intermenstrualBleeding: true,
|
||||
// The intent fields and their encrypted envelope, resolved below
|
||||
// through the same reader the day-log route uses. Same `cycle` grant
|
||||
// as that route (see sharing-surface-guard), so the grid shows a
|
||||
// delegate nothing the day log would not.
|
||||
sexualActivity: true,
|
||||
protectedSex: true,
|
||||
pregnancyTest: true,
|
||||
progesteroneTest: true,
|
||||
contraceptive: true,
|
||||
sensitiveEncrypted: true,
|
||||
// Selected for presence only; the text is never decrypted here.
|
||||
notesEncrypted: true,
|
||||
_count: { select: { symptomLinks: true } },
|
||||
},
|
||||
}),
|
||||
// Apple Watch wrist/skin temperature feeds the temperature-trend
|
||||
// ovulation layer. Read the WRIST_TEMPERATURE measurements as nightly
|
||||
// values; the engine derives the trailing-mean deviation itself.
|
||||
//
|
||||
// This is a measurements read on a route declared under `cycle`, and it
|
||||
// needs no section predicate for one reason: no value from it reaches the
|
||||
// response. It goes into `predictCycle` and what comes out the other side
|
||||
// is a shifted date. The crosstab next door reads the same channel and IS
|
||||
// fenced, because there the values are the answer.
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
type: "WRIST_TEMPERATURE",
|
||||
measuredAt: {
|
||||
gte: startOfLocalDayKey(addDays(today, -90), tz),
|
||||
},
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { measuredAt: true, value: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const dayLogs: CalendarDayLogRow[] = dayLogRows.map((l) => {
|
||||
const sensitive = resolveSensitiveFields(l);
|
||||
return {
|
||||
date: l.date,
|
||||
flow: l.flow,
|
||||
basalBodyTempC: l.basalBodyTempC,
|
||||
temperatureExcluded: l.temperatureExcluded,
|
||||
ovulationTest: l.ovulationTest,
|
||||
cervicalMucus: l.cervicalMucus,
|
||||
cervixPosition: l.cervixPosition,
|
||||
cervixFirmness: l.cervixFirmness,
|
||||
cervixOpening: l.cervixOpening,
|
||||
intermenstrualBleeding: l.intermenstrualBleeding,
|
||||
sexualActivity: sensitive.sexualActivity,
|
||||
pregnancyTest: sensitive.pregnancyTest,
|
||||
progesteroneTest: sensitive.progesteroneTest,
|
||||
contraceptive: sensitive.contraceptive,
|
||||
hasSymptoms: l._count.symptomLinks > 0,
|
||||
hasNote: l.notesEncrypted != null && l.notesEncrypted !== "",
|
||||
};
|
||||
// One loader for the forecast's inputs, shared with the Coach block and the
|
||||
// calendar feed (`load-inputs.ts`). The day-log read is bounded to the
|
||||
// rendered span plus the symptothermal lookback. Cycle lengths come from the
|
||||
// MenstrualCycle rows; the window only reaches the period-length fallback,
|
||||
// which is why the readers that promise to agree with this page read the
|
||||
// default view's window (`defaultCalendarDayLogStart`).
|
||||
const { cycles, dayLogs, nights } = await loadCalendarInputs({
|
||||
userId: user.id,
|
||||
today,
|
||||
tz,
|
||||
dayLogsFrom: dayLogWindowStart(from, today),
|
||||
});
|
||||
|
||||
const nights = nightlyTemps.map((m) => ({
|
||||
date: moodDateKey(m.measuredAt, tz),
|
||||
valueC: m.value,
|
||||
}));
|
||||
|
||||
const goalAllowsFertile = goalAllowsFertileWindow(profile.goal);
|
||||
|
||||
const { prediction, stillLearning, days } = buildCalendar(
|
||||
@@ -244,6 +162,19 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
cycles.filter((c) => c.startDate <= today).at(-1)?.startDate ?? null,
|
||||
});
|
||||
|
||||
// The cycles beyond the next one, from the same engine run. Read by the
|
||||
// predictions panel; the calendar feed serves the same projection, so a
|
||||
// client never extrapolates further dates itself. Fertile fields stay in
|
||||
// `prediction.*`, where the goal and cold-start gates already apply.
|
||||
const projection = projectCycles({
|
||||
prediction,
|
||||
cycles: toCycleInputs(cycles),
|
||||
dayLogs: toDayLogInputs(dayLogs),
|
||||
goal: profile.goal,
|
||||
today,
|
||||
horizon: PROJECTION_MAX_HORIZON,
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "cycle.calendar.read" },
|
||||
meta: {
|
||||
@@ -276,6 +207,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// client renders a calm "learning your cycle" banner over the grid. Additive
|
||||
// + back-compatible — older iOS builds ignore the field.
|
||||
stillLearning,
|
||||
// Additive (#1350): the next cycles with their widening bands, and why
|
||||
// the list holds what it holds (OK / LEARNING / LATE / OFF).
|
||||
projection: projection.cycles,
|
||||
projectionState: projection.state,
|
||||
days,
|
||||
meta: { generatedAt },
|
||||
});
|
||||
|
||||
@@ -0,0 +1,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 });
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import { PhaseEducationCard } from "./phase-education-card";
|
||||
import { PredictionsPanel } from "./predictions-panel";
|
||||
import { CyclePhaseHeadline, CyclePhaseCrosstab } from "./cycle-phase-crosstab";
|
||||
import { CycleSymptomPatterns } from "./cycle-symptom-patterns";
|
||||
import { CycleSettings } from "./cycle-settings";
|
||||
import { CycleSettingsTab } from "./cycle-settings-tab";
|
||||
import { PHASE_HUE } from "./phase-tokens";
|
||||
import { monthOf, monthWindowOutside } from "./calendar-window";
|
||||
import {
|
||||
@@ -417,6 +417,8 @@ export function CycleView() {
|
||||
prediction={calendar.data?.prediction ?? null}
|
||||
rawChartMode={calendar.data?.profile.rawChartMode ?? false}
|
||||
history={history.data?.data}
|
||||
projection={calendar.data?.projection}
|
||||
projectionState={calendar.data?.projectionState ?? null}
|
||||
/>
|
||||
<CycleHistoryChart
|
||||
history={history.data?.data}
|
||||
@@ -471,10 +473,7 @@ export function CycleView() {
|
||||
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
) : profileQuery.data && canManageCycle ? (
|
||||
<CycleSettings
|
||||
key={profileQuery.data.updatedAt}
|
||||
profile={profileQuery.data}
|
||||
/>
|
||||
<CycleSettingsTab profile={profileQuery.data} />
|
||||
) : null}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
@@ -14,7 +14,13 @@ import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import type { Formatters } from "@/lib/format-locale";
|
||||
import { FERTILE_HUE, FLOW_HUE, OVULATION_HUE } from "./phase-tokens";
|
||||
import type { CyclePrediction, CycleHistoryResponse } from "./types";
|
||||
import type {
|
||||
CyclePrediction,
|
||||
CycleHistoryResponse,
|
||||
ProjectedCycle,
|
||||
ProjectionState,
|
||||
} from "./types";
|
||||
import { UpcomingCycles } from "./upcoming-cycles";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
|
||||
/**
|
||||
@@ -59,12 +65,17 @@ export interface PredictionsPanelProps {
|
||||
prediction: CyclePrediction | null;
|
||||
rawChartMode: boolean;
|
||||
history: CycleHistoryResponse | undefined;
|
||||
/** #1350 — the server's projection of the cycles after the next one. */
|
||||
projection?: readonly ProjectedCycle[];
|
||||
projectionState?: ProjectionState | null;
|
||||
}
|
||||
|
||||
export function PredictionsPanel({
|
||||
prediction,
|
||||
rawChartMode,
|
||||
history,
|
||||
projection = [],
|
||||
projectionState = null,
|
||||
}: PredictionsPanelProps) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
@@ -94,6 +105,7 @@ export function PredictionsPanel({
|
||||
body={t("cycle.predictions.stillLearningBody")}
|
||||
/>
|
||||
<NextPeriod prediction={prediction} />
|
||||
<UpcomingCycles projection={projection} state={projectionState} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -119,6 +131,7 @@ export function PredictionsPanel({
|
||||
value={formatDate(prediction.predictedOvulation, fmt)}
|
||||
/>
|
||||
) : null}
|
||||
<UpcomingCycles projection={projection} state={projectionState} />
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -114,10 +114,28 @@ export interface CalendarResponse {
|
||||
* phase band — render the calm "learning your cycle" state over the grid.
|
||||
*/
|
||||
stillLearning: boolean;
|
||||
/**
|
||||
* #1350 — the next cycles with widening bands, resolved server-side (the
|
||||
* calendar feed serves the same list). Render as given; never extrapolate.
|
||||
*/
|
||||
projection: ProjectedCycle[];
|
||||
projectionState: ProjectionState;
|
||||
days: CalendarDay[];
|
||||
meta: { generatedAt: string };
|
||||
}
|
||||
|
||||
/** Mirrors `ProjectionState` in `src/lib/cycle/projection.ts`. */
|
||||
export type ProjectionState = "OK" | "LEARNING" | "LATE" | "OFF";
|
||||
|
||||
/** Mirrors `ProjectedCycle` in `src/lib/cycle/projection.ts`. */
|
||||
export interface ProjectedCycle {
|
||||
k: number;
|
||||
start: string;
|
||||
periodLength: number;
|
||||
bandStart: string;
|
||||
bandEnd: string;
|
||||
}
|
||||
|
||||
export interface MenstrualCycleDTO {
|
||||
id: string;
|
||||
startDate: string;
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import { FLOW_HUE } from "./phase-tokens";
|
||||
import type { ProjectedCycle, ProjectionState } from "./types";
|
||||
|
||||
/**
|
||||
* #1350 — the periods after the next one, each as the window its start may
|
||||
* fall in. The list and its bands are the server's (`/api/cycle/calendar`
|
||||
* `projection`, the same list the calendar feed serves); this only renders
|
||||
* them. The band widens from one row to the next, and the server stops the
|
||||
* list where a date would be a guess.
|
||||
*
|
||||
* The next period itself is the row above this one in the predictions card,
|
||||
* so cycle 1 is skipped here. A late period is stated, in the muted tone: a
|
||||
* late period is a fact about the calendar, not a warning.
|
||||
*/
|
||||
export function UpcomingCycles({
|
||||
projection,
|
||||
state,
|
||||
}: {
|
||||
projection: readonly ProjectedCycle[];
|
||||
state: ProjectionState | null;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
|
||||
if (state === "LATE") {
|
||||
return (
|
||||
<p
|
||||
className="text-muted-foreground text-sm"
|
||||
data-slot="cycle-projection-late"
|
||||
>
|
||||
{t("cycle.projection.state.LATE")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const later = projection.filter((c) => c.k >= 2);
|
||||
if (later.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-2" data-slot="cycle-projection">
|
||||
<p className="text-muted-foreground text-xs font-medium">
|
||||
{t("cycle.projection.title")}
|
||||
</p>
|
||||
<ul className="divide-border divide-y">
|
||||
{later.map((c) => (
|
||||
<li key={c.k} className="flex items-center gap-2 py-2 text-sm">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-full opacity-60"
|
||||
style={{ backgroundColor: FLOW_HUE }}
|
||||
/>
|
||||
<span className="text-muted-foreground">
|
||||
{t("cycle.projection.around", {
|
||||
date: fmt.dateShortSmartCalendar(c.start),
|
||||
})}
|
||||
</span>
|
||||
<span className="text-foreground ml-auto font-medium tabular-nums">
|
||||
{t("cycle.predictions.nextPeriodWindow", {
|
||||
low: fmt.dateShortSmartCalendar(c.bandStart),
|
||||
high: fmt.dateShortSmartCalendar(c.bandEnd),
|
||||
})}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* #1350 — the calendar subscription's read and write hooks.
|
||||
*
|
||||
* Every value the card shows is the server's: the status read carries the
|
||||
* options, the fetch statistics as a calendar-app family, and the projection
|
||||
* state that explains a shorter feed. The URL exists in the client only in
|
||||
* the one mint or rotate response that returns it, held in the card's state
|
||||
* and gone when the card unmounts.
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import type { ProjectionState } from "./types";
|
||||
|
||||
export type FeedTitleMode = "DISCREET" | "DESCRIPTIVE";
|
||||
export type FeedFetcher = "GOOGLE" | "APPLE" | "MICROSOFT" | "OTHER";
|
||||
|
||||
export interface CycleFeedSummary {
|
||||
tokenPrefix: string;
|
||||
createdAt: string;
|
||||
lastFetchedAt: string | null;
|
||||
fetchCount: number;
|
||||
lastFetcher: FeedFetcher | null;
|
||||
horizonCycles: number;
|
||||
historyCycles: number;
|
||||
includeFertile: boolean;
|
||||
titleMode: FeedTitleMode;
|
||||
}
|
||||
|
||||
export interface CycleFeedStatus {
|
||||
feed: CycleFeedSummary | null;
|
||||
projectionState: ProjectionState | null;
|
||||
fertileAvailable: boolean;
|
||||
}
|
||||
|
||||
export interface CycleFeedMinted {
|
||||
url: string;
|
||||
webcalUrl: string;
|
||||
feed: CycleFeedSummary;
|
||||
}
|
||||
|
||||
export type CycleFeedOptionsPatch = Partial<
|
||||
Pick<
|
||||
CycleFeedSummary,
|
||||
"horizonCycles" | "historyCycles" | "includeFertile" | "titleMode"
|
||||
>
|
||||
>;
|
||||
|
||||
const PATH = "/api/cycle/feed-subscription";
|
||||
|
||||
export function useCycleFeedStatus(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.cycleFeedSubscription(),
|
||||
queryFn: () => apiGet<CycleFeedStatus>(PATH),
|
||||
staleTime: 60_000,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
function useInvalidateStatus() {
|
||||
const qc = useQueryClient();
|
||||
return () =>
|
||||
qc.invalidateQueries({ queryKey: queryKeys.cycleFeedSubscription() });
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn the subscription on, or rotate its link. `proof` is the recent-proof
|
||||
* flow: both answer 401 `auth.reproof.required` without a fresh proof, and
|
||||
* `proof.run` opens the re-proof dialog and retries.
|
||||
*/
|
||||
export function useMintCycleFeed(proof: {
|
||||
run: <T>(action: () => Promise<T>) => Promise<T>;
|
||||
}) {
|
||||
const invalidate = useInvalidateStatus();
|
||||
return useMutation({
|
||||
mutationKey: queryKeys.cycleFeedSubscription(),
|
||||
mutationFn: (kind: "create" | "rotate") =>
|
||||
kind === "create"
|
||||
? proof.run(() =>
|
||||
apiPost<CycleFeedMinted>("/api/cycle/feed-subscription"),
|
||||
)
|
||||
: proof.run(() =>
|
||||
apiPost<CycleFeedMinted>("/api/cycle/feed-subscription/rotate"),
|
||||
),
|
||||
onSettled: () => invalidate(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateCycleFeed() {
|
||||
const invalidate = useInvalidateStatus();
|
||||
return useMutation({
|
||||
mutationKey: queryKeys.cycleFeedSubscription(),
|
||||
mutationFn: (patch: CycleFeedOptionsPatch) =>
|
||||
apiPatch<CycleFeedStatus>(PATH, patch),
|
||||
onSettled: () => invalidate(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRevokeCycleFeed() {
|
||||
const invalidate = useInvalidateStatus();
|
||||
return useMutation({
|
||||
mutationKey: queryKeys.cycleFeedSubscription(),
|
||||
mutationFn: () => apiDelete<{ revoked: true }>(PATH),
|
||||
onSettled: () => invalidate(),
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 } }),
|
||||
];
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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:") };
|
||||
}
|
||||
@@ -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"',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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[],
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user