mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
feat: lay down the shared v1.42 contract
Land every file the v1.42 work shares before any of it is built, so the parts built side by side never edit the same file. Schema and migrations 0379-0386: the HEALTH_CONNECT measurement source, ImportJob.kind, the air-quality columns and the per-user switch on the environment tables, sealed environment locations with nullable plaintext, managed-profile handover links, the per-type HealthKit arrival ledger, lab unit-conversion provenance, and user-defined vaccines. Every new column is annotated for the column-reader sweep. HEALTH_CONNECT reaches every exhaustive list: the source labels, the read enum (not the write allowlist), the source-priority ladder cap, now the number of sources rather than a fixed eight, and the write-path smoke test. RMSSD gets its own priority class and a 300 ms ceiling. The batch syncTrigger gains `manual`, with its label on the Apple Health card. Queues for the Health Connect import, the compaction-tombstone purge and the measurement-table maintenance are registered with stub handlers; host-metric sampling moves to every five minutes. Environment and work_mem variables reach the compose whitelist, the production example and the manifest. The proxy admits the handover link and redirects it at the edge, and the Health Connect upload bypasses the proxy body limit. The new routes answer 501 behind their final authentication and are published in the contract; backup, wipe and key rotation classify the new models and columns; the seven message bundles carry the base copy. Guards that list release-pending keys and columns fail once an entry is wired, so those lists empty themselves before the tag. Also re-anchor a login-throttle fixture to the wall clock, where its expiry is compared, and re-base the aggregate bundle cap on a measured 12 KB of new locale strings.
This commit is contained in:
1 parent
125b15bfb7
commit
e63be67b5a
82 files changed
+5099
-727
No files matched your search
@@ -450,6 +450,12 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# for the heaviest legitimate read (the live-aggregate analytics fallback on a
|
||||
# large account). Set "0" to disable it and restore unbounded behaviour.
|
||||
# DATABASE_STATEMENT_TIMEOUT_MS="60000"
|
||||
#
|
||||
# Postgres `work_mem` applied to every app session, as a Postgres size string.
|
||||
# Default 16MB. Large sorts and aggregates that exceed it spill to disk; each
|
||||
# sort step of each pooled connection may use this much memory, so raise it
|
||||
# with the pool size in mind.
|
||||
# DATABASE_WORK_MEM="16MB"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -592,6 +598,16 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# force the public-host guard, so an internal instance is not blocked).
|
||||
# OPENMETEO_BASE_URL="https://archive-api.open-meteo.com"
|
||||
# OPENMETEO_GEOCODING_URL="https://geocoding-api.open-meteo.com"
|
||||
#
|
||||
# Air quality, pollen and UV come from the Open-Meteo air-quality API, which
|
||||
# serves the Copernicus Atmosphere Monitoring Service (CAMS) models. CAMS data
|
||||
# is free to use with attribution, which the app shows next to the values.
|
||||
# Like the URLs above this one is optional and can point at a self-hosted
|
||||
# Open-Meteo instance. Set ENVIRONMENT_AIR_QUALITY_DISABLED to 1, true or yes
|
||||
# to stop the air-quality requests for the whole instance; weather and
|
||||
# daylight keep working, and no user can turn air quality back on.
|
||||
# OPENMETEO_AIR_QUALITY_URL="https://air-quality-api.open-meteo.com"
|
||||
# ENVIRONMENT_AIR_QUALITY_DISABLED="1"
|
||||
|
||||
# ── Native canvas renderer (document thumbnails, scanned-PDF rasterization) ──
|
||||
# The bundled renderer's x64 build uses AVX2 CPU instructions. On x86-64 hosts
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up. v1.38.14 re-measures the four eager routes and changes what the number means. The ceilings had been re-stated but never re-based since v1.37.4, so /inLine truncated
|
||||
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up. v1.38.14 re-measures the four eager routes and changes what the number means. The ceilings had been re-stated but never re-based since v1.37.4, so /inLine truncated
|
||||
"routeBaselineKbGz": {
|
||||
"/page": 429,
|
||||
"/insights/page": 368,
|
||||
@@ -7,6 +7,6 @@
|
||||
"/insights/mood/page": 379
|
||||
},
|
||||
"routeDriftAllowanceKbGz": 20,
|
||||
"totalClientKbGz": 4155,
|
||||
"totalClientKbGz": 4182,
|
||||
"maxRechartsChunks": 1
|
||||
}
|
||||
@@ -72,6 +72,8 @@ x-healthlog-env: &healthlog_env
|
||||
# (default 60000; 0 disables it and restores the unbounded behaviour).
|
||||
# Raise it if a very large account's analytics fallback is being cut off.
|
||||
DATABASE_STATEMENT_TIMEOUT_MS: "${DATABASE_STATEMENT_TIMEOUT_MS:-}"
|
||||
# Postgres work_mem for every app session (default 16MB).
|
||||
DATABASE_WORK_MEM: "${DATABASE_WORK_MEM:-}"
|
||||
WITHINGS_WEBHOOK_SECRET: "${WITHINGS_WEBHOOK_SECRET:-}"
|
||||
# Overrides the derived `${NEXT_PUBLIC_APP_URL}/api/withings/callback`;
|
||||
# set it when the URL registered with Withings differs from the app's own
|
||||
@@ -244,6 +246,11 @@ x-healthlog-env: &healthlog_env
|
||||
# the coordinate lookups off a third party.
|
||||
OPENMETEO_BASE_URL: "${OPENMETEO_BASE_URL:-}"
|
||||
OPENMETEO_GEOCODING_URL: "${OPENMETEO_GEOCODING_URL:-}"
|
||||
# Air quality, pollen and UV (CAMS via Open-Meteo). Same hosted default and
|
||||
# self-host option as above; ENVIRONMENT_AIR_QUALITY_DISABLED=1 stops the
|
||||
# air-quality requests for the whole instance.
|
||||
OPENMETEO_AIR_QUALITY_URL: "${OPENMETEO_AIR_QUALITY_URL:-}"
|
||||
ENVIRONMENT_AIR_QUALITY_DISABLED: "${ENVIRONMENT_AIR_QUALITY_DISABLED:-}"
|
||||
# OIDC SSO login (self-hosted IdP: Authentik/Keycloak/Authelia/etc).
|
||||
# All three of ISSUER_URL/CLIENT_ID/CLIENT_SECRET together enable the
|
||||
# "Sign in with SSO" button; set none to leave it disabled. OIDC_ONLY
|
||||
|
||||
+1956
-559
File diff suppressed because it is too large.
Load diff
@@ -121,6 +121,14 @@
|
||||
"**/__mocks__/**",
|
||||
"e2e/setup/test-helpers.ts",
|
||||
"src/lib/logging/index.ts",
|
||||
// v1.42 release-branch contract stubs: each is created ahead of its
|
||||
// consumers so the packages built in parallel share one signature. Drop
|
||||
// a line the moment its first consumer lands; the release integration
|
||||
// checklist removes whatever is left before the tag.
|
||||
"src/lib/environment/air-quality-contract.ts",
|
||||
"src/lib/integrations/healthkit-type-sync.ts",
|
||||
"src/lib/measurements/folded-window.ts",
|
||||
"src/lib/measurements/live-types.ts",
|
||||
],
|
||||
"ignoreDependencies": [
|
||||
"@types/node",
|
||||
|
||||
+121
-10
@@ -513,6 +513,19 @@
|
||||
"fixLan": "Server im Heimnetz ohne TLS: Setze SESSION_COOKIE_SECURE=false in seinen Einstellungen und starte ihn neu.",
|
||||
"fixHttps": "Server hinter einem HTTPS-Proxy: Öffne stattdessen die https://-Adresse und sorge dafür, dass der Proxy X-Forwarded-Proto: https weitergibt.",
|
||||
"localhostNote": "Diese Seite läuft auf http://localhost, der Server setzt sein Anmelde-Cookie aber nur für https://. Die meisten Browser akzeptieren das auf localhost; wenn du nach der Anmeldung wieder hier landest, setze SESSION_COOKIE_SECURE=false."
|
||||
},
|
||||
"claim": {
|
||||
"title": "Dein Profil übernehmen",
|
||||
"description": "{guardian} hat ein Gesundheitsprofil für dich geführt. Wähle deine Anmeldung, um es zu deinem eigenen Konto zu machen.",
|
||||
"invalid": "Dieser Link ist ungültig, abgelaufen oder schon benutzt.",
|
||||
"signOutFirst": "Melde dich zuerst ab, um dieses Profil zu übernehmen.",
|
||||
"emailRequired": "Eine E-Mail-Adresse ist erforderlich.",
|
||||
"guardiansTitle": "Wer Zugang behält",
|
||||
"guardianProposal": "{name} schlägt vor: {proposal}",
|
||||
"consentNote": "Den Hinweis und eine etwaige KI-Einwilligung bestätigst du nach der Übernahme selbst.",
|
||||
"submit": "Profil übernehmen",
|
||||
"submitting": "Wird übernommen …",
|
||||
"success": "Das Profil ist jetzt dein Konto."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -834,7 +847,8 @@
|
||||
"durationHoursOnly": "{hours} Std.",
|
||||
"durationMinutesOnly": "{minutes} Min.",
|
||||
"durationInvalid": "Gib eine Dauer ein, zum Beispiel 7,5, 7:30 oder 7h 30m.",
|
||||
"durationTooLong": "Ein Eintrag kann nicht länger als 24 Stunden sein."
|
||||
"durationTooLong": "Ein Eintrag kann nicht länger als 24 Stunden sein.",
|
||||
"sourceHealthConnect": "Health Connect"
|
||||
},
|
||||
"metricCatalog": {
|
||||
"title": "Was HealthLog erfassen kann",
|
||||
@@ -2550,7 +2564,10 @@
|
||||
"ENV_DAYLIGHT": "das Tageslicht",
|
||||
"ENV_PRECIP": "der Niederschlag",
|
||||
"ENV_PRESSURE_MEAN": "der Luftdruck",
|
||||
"ENV_PRESSURE_DELTA": "die Luftdruckschwankung am Tag"
|
||||
"ENV_PRESSURE_DELTA": "die Luftdruckschwankung am Tag",
|
||||
"ENV_PM25": "der Feinstaub (PM2,5)",
|
||||
"ENV_OZONE_8H": "das Ozon",
|
||||
"ENV_POLLEN_MAX": "der Pollenflug"
|
||||
},
|
||||
"daily": {
|
||||
"high": "Wenn {behaviour} höher liegt, fällt {outcome} am Folgetag in deinen Daten tendenziell {direction} aus — ein Muster, das Beachtung verdient, keine Ursache.",
|
||||
@@ -2573,6 +2590,11 @@
|
||||
"none": "Stimmung und Ruhepuls bewegen sich in diesem Zeitraum nicht deutlich gemeinsam."
|
||||
},
|
||||
"weightWeekday": "Das Gewicht am {weekday} liegt {delta} kg {direction} deinem Durchschnitt der übrigen Tage — ein Muster, das Beachtung verdient."
|
||||
},
|
||||
"dailySameDay": {
|
||||
"high": "Mehr {behaviour} am selben Tag und am Vortag geht in deinen Daten tendenziell mit {direction} {outcome} einher — ein Muster, das sich zu beobachten lohnt, keine Ursache.",
|
||||
"moderate": "Mehr {behaviour} am selben Tag und am Vortag scheint in deinen Daten nach bisheriger Lage mit {direction} {outcome} einherzugehen — ein Muster, das sich zu beobachten lohnt, keine Ursache.",
|
||||
"faint": "Mehr {behaviour} am selben Tag und am Vortag zeigt in deinen Daten allenfalls einen schwachen Hinweis auf {direction} {outcome} — zu klein, um sich darauf zu stützen, nie eine Ursache."
|
||||
}
|
||||
},
|
||||
"emptyTitle": "Noch nicht genug Daten",
|
||||
@@ -3678,7 +3700,10 @@
|
||||
"endInFuture": "Das Workout würde in der Zukunft enden. Stell den Beginn auf die Uhrzeit, zu der es angefangen hat.",
|
||||
"distanceInvalid": "Gib die Distanz als Zahl in einem realistischen Bereich ein.",
|
||||
"energyInvalid": "Gib die aktive Energie als Zahl bis 20.000 kcal ein."
|
||||
}
|
||||
},
|
||||
"editTitle": "Training bearbeiten",
|
||||
"editAction": "Bearbeiten",
|
||||
"updated": "Training aktualisiert"
|
||||
}
|
||||
},
|
||||
"bloodGlucose": {
|
||||
@@ -5364,7 +5389,8 @@
|
||||
"respiratoryRate": "Atemfrequenz",
|
||||
"recovery": "Erholung",
|
||||
"stress": "Stress",
|
||||
"sleepDebt": "Schlafdefizit"
|
||||
"sleepDebt": "Schlafdefizit",
|
||||
"hrvRmssd": "HRV (RMSSD)"
|
||||
},
|
||||
"sourceLabels": {
|
||||
"WITHINGS": "Withings",
|
||||
@@ -5381,7 +5407,8 @@
|
||||
"MCP": "MCP",
|
||||
"GOOGLE_HEALTH": "Google Health",
|
||||
"STRAVA": "Strava",
|
||||
"EXTERNAL": "Externes Gerät"
|
||||
"EXTERNAL": "Externes Gerät",
|
||||
"HEALTH_CONNECT": "Health Connect"
|
||||
},
|
||||
"deviceLabels": {
|
||||
"watch": "Uhr",
|
||||
@@ -5618,6 +5645,25 @@
|
||||
"tooManyRows": "Diese Datei enthält mehr Zeilen, als ein Import aufnehmen kann. Teilen und in Teilen importieren."
|
||||
},
|
||||
"detail": "Eine Datei deckt alle Medikamente darin ab."
|
||||
},
|
||||
"healthConnect": {
|
||||
"title": "Health-Connect-Export",
|
||||
"description": "Importiere die ZIP-Datei, die die Health-Connect-App auf Android beim Export deiner Daten schreibt.",
|
||||
"choose": "ZIP-Datei wählen",
|
||||
"uploading": "Wird hochgeladen …",
|
||||
"uploadFailed": "Das Hochladen ist fehlgeschlagen. Bitte versuch es noch einmal.",
|
||||
"tooLarge": "Die Datei ist größer, als dieser Server annimmt.",
|
||||
"unsupportedVersion": "Dieser Health-Connect-Export ist zu alt, um ihn zu lesen.",
|
||||
"doneSummary": "{count} Werte importiert.",
|
||||
"failed": "Der Import ist fehlgeschlagen: {reason}",
|
||||
"phase": {
|
||||
"queued": "Wartet auf den Start",
|
||||
"unpacking": "Datei wird entpackt",
|
||||
"parsing": "Einträge werden gelesen",
|
||||
"upserting": "Werte werden gespeichert",
|
||||
"done": "Fertig",
|
||||
"failed": "Fehlgeschlagen"
|
||||
}
|
||||
}
|
||||
},
|
||||
"subtitle": "Exportiere deine Daten oder importiere aus einer Sicherung."
|
||||
@@ -5732,7 +5778,17 @@
|
||||
"noResults": "Keine passenden Orte gefunden.",
|
||||
"fetchFailedOne": "Die letzte Wetteraktualisierung im Hintergrund ist fehlgeschlagen ({when}).",
|
||||
"fetchFailedFew": "Die letzten {count} Wetteraktualisierungen im Hintergrund sind fehlgeschlagen, zuletzt {when}.",
|
||||
"fetchFailedOther": "Die letzten {count} Wetteraktualisierungen im Hintergrund sind fehlgeschlagen, zuletzt {when}."
|
||||
"fetchFailedOther": "Die letzten {count} Wetteraktualisierungen im Hintergrund sind fehlgeschlagen, zuletzt {when}.",
|
||||
"airQuality": {
|
||||
"title": "Luftqualität und Pollen",
|
||||
"description": "Tägliche Luftqualität, Pollen und UV für deinen Ort, aus dem CAMS-Modell über Open-Meteo.",
|
||||
"toggle": "Luftqualität und Pollen einbeziehen",
|
||||
"operatorDisabled": "Luftqualität ist auf diesem Server abgeschaltet.",
|
||||
"coverage": "{count} Tage mit Luftqualitätsdaten",
|
||||
"pollenEuropeOnly": "Pollendaten gibt es nur für Europa.",
|
||||
"attribution": "Luftqualität: Copernicus-Atmosphärenüberwachungsdienst (CAMS), über Open-Meteo.",
|
||||
"saveError": "Die Einstellung konnte nicht gespeichert werden."
|
||||
}
|
||||
},
|
||||
"score": {
|
||||
"title": "Gesundheitsscore",
|
||||
@@ -6990,8 +7046,15 @@
|
||||
"foreground": "bei geöffneter App",
|
||||
"background": "im Hintergrund",
|
||||
"push": "durch einen Push-Weckruf",
|
||||
"unknown": "Auslöser von der App nicht gemeldet"
|
||||
"unknown": "Auslöser von der App nicht gemeldet",
|
||||
"manual": "über „Alles synchronisieren“"
|
||||
}
|
||||
},
|
||||
"freshness": {
|
||||
"title": "Nach Datentyp",
|
||||
"lastReceived": "Zuletzt empfangen {relative}",
|
||||
"lastNewValue": "Letzter neuer Wert {relative}",
|
||||
"never": "Noch nicht empfangen"
|
||||
}
|
||||
},
|
||||
"garminInfo": {
|
||||
@@ -9386,7 +9449,8 @@
|
||||
"sourceRange": {
|
||||
"fromReport": "Befundbereich {range}",
|
||||
"catalogDiffers": "dein Bereich {range}"
|
||||
}
|
||||
},
|
||||
"convertedFrom": "Umgerechnet aus {value} {unit}"
|
||||
},
|
||||
"modules": {
|
||||
"cycle": {
|
||||
@@ -9616,6 +9680,19 @@
|
||||
"overdue": "Auffrischung fällig seit {date}",
|
||||
"dueSoon": "Auffrischung fällig am {date}",
|
||||
"current": "Nächste Auffrischung {date}"
|
||||
},
|
||||
"custom": {
|
||||
"title": "Eigene Impfstoffe",
|
||||
"add": "Eigenen Impfstoff anlegen",
|
||||
"name": "Name",
|
||||
"components": "Schützt vor",
|
||||
"seriesDoses": "Dosen der Grundimmunisierung",
|
||||
"boosterMonths": "Auffrischung alle (Monate)",
|
||||
"saved": "Impfstoff gespeichert",
|
||||
"deleted": "Impfstoff entfernt",
|
||||
"deleteConfirm": "Diesen Impfstoff entfernen? Bereits eingetragene Impfungen behalten ihren Namen.",
|
||||
"saveFailed": "Der Impfstoff konnte nicht gespeichert werden.",
|
||||
"nameTaken": "Du hast schon einen Impfstoff mit diesem Namen."
|
||||
}
|
||||
},
|
||||
"illness": {
|
||||
@@ -10528,7 +10605,16 @@
|
||||
"daylight": "Tageslicht",
|
||||
"precip": "Niederschlag",
|
||||
"pressureMean": "Luftdruck",
|
||||
"pressureDelta": "Druckschwankung"
|
||||
"pressureDelta": "Druckschwankung",
|
||||
"apparentMax": "Gefühlte Höchsttemperatur",
|
||||
"pm25": "Feinstaub (PM2,5)",
|
||||
"pm10": "Feinstaub (PM10)",
|
||||
"no2": "Stickstoffdioxid",
|
||||
"ozone8h": "Ozon (höchster 8-Stunden-Wert)",
|
||||
"europeanAqi": "Europäischer Luftqualitätsindex",
|
||||
"uvIndexMax": "UV-Index (Höchstwert)",
|
||||
"dustMax": "Saharastaub",
|
||||
"pollenMax": "Pollen (höchster Wert)"
|
||||
}
|
||||
},
|
||||
"documents": {
|
||||
@@ -11475,7 +11561,32 @@
|
||||
"guardianErrorFailed": "Die Einladung konnte nicht gesendet werden.",
|
||||
"errorMfaRequired": "Dafür ist ein zweiter Faktor nötig, und dein Konto hat noch keinen. Richte unter Einstellungen → Sicherheit einen ein und versuche es dann erneut.",
|
||||
"rosterLoadError": "Es ließ sich nicht laden, wer sich um diese Akte kümmert.",
|
||||
"errorRateLimit": "Zu viele Profile in der letzten Stunde angelegt. Warte etwas und versuche es erneut."
|
||||
"errorRateLimit": "Zu viele Profile in der letzten Stunde angelegt. Warte etwas und versuche es erneut.",
|
||||
"handover": {
|
||||
"title": "Profil übergeben",
|
||||
"description": "Gib {name} eine eigene Anmeldung. Die Daten bleiben, wo sie sind; du schlägst vor, welchen Zugang jede betreuende Person behält, und {name} entscheidet.",
|
||||
"create": "Übergabelink erstellen",
|
||||
"expiryLabel": "Link gültig für",
|
||||
"expiry1": "1 Tag",
|
||||
"expiry7": "7 Tage",
|
||||
"expiry14": "14 Tage",
|
||||
"proposalLabel": "Vorgeschlagener Zugang danach",
|
||||
"proposal": {
|
||||
"end": "Zugang beenden",
|
||||
"read": "Nur ansehen",
|
||||
"manage": "Weiter verwalten"
|
||||
},
|
||||
"linkTitle": "Übergabelink",
|
||||
"linkOnce": "Dieser Link wird nur einmal angezeigt. Gib ihn {name} direkt oder lass den Code scannen.",
|
||||
"copy": "Link kopieren",
|
||||
"copied": "Link kopiert",
|
||||
"pending": "Übergabelink offen bis {date}",
|
||||
"revoke": "Link zurückziehen",
|
||||
"revoked": "Link zurückgezogen",
|
||||
"errorFailed": "Der Übergabelink konnte nicht erstellt werden.",
|
||||
"oidcOnly": "Die Profilübergabe ist nicht verfügbar, solange dieser Server nur Single Sign-on erlaubt.",
|
||||
"minorHint": "{name} ist jünger als 16. Den Lesezugang zu behalten, ist eine Überlegung wert."
|
||||
}
|
||||
},
|
||||
"toast": {
|
||||
"savedTo": "In der Akte von {name} gespeichert"
|
||||
|
||||
+121
-10
@@ -513,6 +513,19 @@
|
||||
"fixLan": "Server on a home network without TLS: set SESSION_COOKIE_SECURE=false in its settings and restart it.",
|
||||
"fixHttps": "Server behind an HTTPS proxy: open the https:// address instead, and make sure the proxy forwards X-Forwarded-Proto: https.",
|
||||
"localhostNote": "This page is on http://localhost while the server sets its sign-in cookie for https:// only. Most browsers accept that on localhost; if signing in keeps returning here, set SESSION_COOKIE_SECURE=false."
|
||||
},
|
||||
"claim": {
|
||||
"title": "Take over your profile",
|
||||
"description": "{guardian} has kept a health record for you. Choose your sign-in to make it your own account.",
|
||||
"invalid": "This link is invalid, expired or already used.",
|
||||
"signOutFirst": "Sign out first to take over this profile.",
|
||||
"emailRequired": "An email address is required.",
|
||||
"guardiansTitle": "Who keeps access",
|
||||
"guardianProposal": "{name} proposes: {proposal}",
|
||||
"consentNote": "You will confirm the disclaimer and any AI consent yourself after taking over.",
|
||||
"submit": "Take over profile",
|
||||
"submitting": "Taking over…",
|
||||
"success": "The profile is now your account."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -834,7 +847,8 @@
|
||||
"durationHoursOnly": "{hours} h",
|
||||
"durationMinutesOnly": "{minutes} min",
|
||||
"durationInvalid": "Enter a duration like 7.5, 7:30 or 7h 30m.",
|
||||
"durationTooLong": "One entry can't be longer than 24 hours."
|
||||
"durationTooLong": "One entry can't be longer than 24 hours.",
|
||||
"sourceHealthConnect": "Health Connect"
|
||||
},
|
||||
"metricCatalog": {
|
||||
"title": "What HealthLog can track",
|
||||
@@ -2550,7 +2564,10 @@
|
||||
"ENV_DAYLIGHT": "daylight",
|
||||
"ENV_PRECIP": "precipitation",
|
||||
"ENV_PRESSURE_MEAN": "barometric pressure",
|
||||
"ENV_PRESSURE_DELTA": "intraday pressure swing"
|
||||
"ENV_PRESSURE_DELTA": "intraday pressure swing",
|
||||
"ENV_PM25": "fine particles (PM2.5)",
|
||||
"ENV_OZONE_8H": "ozone",
|
||||
"ENV_POLLEN_MAX": "pollen"
|
||||
},
|
||||
"daily": {
|
||||
"high": "Higher {behaviour} tends to go with {direction} next-day {outcome} in your data — a pattern worth watching, not a cause.",
|
||||
@@ -2573,6 +2590,11 @@
|
||||
"none": "Mood and resting pulse do not move together strongly in this window."
|
||||
},
|
||||
"weightWeekday": "{weekday} weights run {delta} kg {direction} your other-day average — a pattern worth watching."
|
||||
},
|
||||
"dailySameDay": {
|
||||
"high": "Higher {behaviour} on the day and the day before tends to go with {direction} {outcome} in your data — a pattern worth watching, not a cause.",
|
||||
"moderate": "Higher {behaviour} on the day and the day before looks like it goes with {direction} {outcome} in your data, on the evidence so far — a pattern worth watching, not a cause.",
|
||||
"faint": "Higher {behaviour} on the day and the day before shows a faint hint, if anything, of {direction} {outcome} in your data — too small to lean on, never a cause."
|
||||
}
|
||||
},
|
||||
"noAnalysisYet": "No assessment yet.",
|
||||
@@ -3678,7 +3700,10 @@
|
||||
"endInFuture": "It would end in the future. Set the start to when the workout began.",
|
||||
"distanceInvalid": "Enter the distance as a number within a realistic range.",
|
||||
"energyInvalid": "Enter active energy as a number up to 20,000 kcal."
|
||||
}
|
||||
},
|
||||
"editTitle": "Edit workout",
|
||||
"editAction": "Edit",
|
||||
"updated": "Workout updated"
|
||||
}
|
||||
},
|
||||
"bloodGlucose": {
|
||||
@@ -5364,7 +5389,8 @@
|
||||
"respiratoryRate": "Respiratory rate",
|
||||
"recovery": "Recovery",
|
||||
"stress": "Stress",
|
||||
"sleepDebt": "Sleep debt"
|
||||
"sleepDebt": "Sleep debt",
|
||||
"hrvRmssd": "HRV (RMSSD)"
|
||||
},
|
||||
"sourceLabels": {
|
||||
"WITHINGS": "Withings",
|
||||
@@ -5381,7 +5407,8 @@
|
||||
"MCP": "MCP",
|
||||
"GOOGLE_HEALTH": "Google Health",
|
||||
"STRAVA": "Strava",
|
||||
"EXTERNAL": "External device"
|
||||
"EXTERNAL": "External device",
|
||||
"HEALTH_CONNECT": "Health Connect"
|
||||
},
|
||||
"deviceLabels": {
|
||||
"watch": "Watch",
|
||||
@@ -5618,6 +5645,25 @@
|
||||
"tooManyRows": "That file holds more rows than one import can take. Split it and import the parts."
|
||||
},
|
||||
"detail": "One file covers every medication in it."
|
||||
},
|
||||
"healthConnect": {
|
||||
"title": "Health Connect export",
|
||||
"description": "Import the ZIP file the Health Connect app on Android writes when you export your data.",
|
||||
"choose": "Choose ZIP file",
|
||||
"uploading": "Uploading…",
|
||||
"uploadFailed": "The upload failed. Please try again.",
|
||||
"tooLarge": "The file is larger than this server accepts.",
|
||||
"unsupportedVersion": "This Health Connect export is too old to read.",
|
||||
"doneSummary": "{count} values imported.",
|
||||
"failed": "The import failed: {reason}",
|
||||
"phase": {
|
||||
"queued": "Waiting to start",
|
||||
"unpacking": "Unpacking the file",
|
||||
"parsing": "Reading the records",
|
||||
"upserting": "Saving the values",
|
||||
"done": "Finished",
|
||||
"failed": "Failed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"subtitle": "Export your data or import from a backup."
|
||||
@@ -5732,7 +5778,17 @@
|
||||
"noResults": "No matching places found.",
|
||||
"fetchFailedOne": "The last background weather update failed ({when}).",
|
||||
"fetchFailedFew": "The last {count} background weather updates failed, most recently {when}.",
|
||||
"fetchFailedOther": "The last {count} background weather updates failed, most recently {when}."
|
||||
"fetchFailedOther": "The last {count} background weather updates failed, most recently {when}.",
|
||||
"airQuality": {
|
||||
"title": "Air quality and pollen",
|
||||
"description": "Daily air quality, pollen and UV for your location, from the CAMS model via Open-Meteo.",
|
||||
"toggle": "Include air quality and pollen",
|
||||
"operatorDisabled": "Air quality is turned off on this server.",
|
||||
"coverage": "{count} days with air-quality data",
|
||||
"pollenEuropeOnly": "Pollen data covers Europe only.",
|
||||
"attribution": "Air quality: Copernicus Atmosphere Monitoring Service (CAMS), via Open-Meteo.",
|
||||
"saveError": "The setting could not be saved."
|
||||
}
|
||||
},
|
||||
"score": {
|
||||
"title": "Health Score",
|
||||
@@ -6990,8 +7046,15 @@
|
||||
"foreground": "with the app open",
|
||||
"background": "in the background",
|
||||
"push": "from a push wake",
|
||||
"unknown": "trigger not reported by the app"
|
||||
"unknown": "trigger not reported by the app",
|
||||
"manual": "from Sync all"
|
||||
}
|
||||
},
|
||||
"freshness": {
|
||||
"title": "By data type",
|
||||
"lastReceived": "Last received {relative}",
|
||||
"lastNewValue": "Last new value {relative}",
|
||||
"never": "Not received yet"
|
||||
}
|
||||
},
|
||||
"garminInfo": {
|
||||
@@ -9386,7 +9449,8 @@
|
||||
"sourceRange": {
|
||||
"fromReport": "Report range {range}",
|
||||
"catalogDiffers": "your range {range}"
|
||||
}
|
||||
},
|
||||
"convertedFrom": "Converted from {value} {unit}"
|
||||
},
|
||||
"modules": {
|
||||
"cycle": {
|
||||
@@ -9616,6 +9680,19 @@
|
||||
"overdue": "Booster overdue since {date}",
|
||||
"dueSoon": "Booster due {date}",
|
||||
"current": "Next booster {date}"
|
||||
},
|
||||
"custom": {
|
||||
"title": "Own vaccines",
|
||||
"add": "Add own vaccine",
|
||||
"name": "Name",
|
||||
"components": "Protects against",
|
||||
"seriesDoses": "Doses in the primary series",
|
||||
"boosterMonths": "Booster every (months)",
|
||||
"saved": "Vaccine saved",
|
||||
"deleted": "Vaccine removed",
|
||||
"deleteConfirm": "Remove this vaccine? Doses already logged keep their name.",
|
||||
"saveFailed": "The vaccine could not be saved.",
|
||||
"nameTaken": "You already have a vaccine with this name."
|
||||
}
|
||||
},
|
||||
"illness": {
|
||||
@@ -10528,7 +10605,16 @@
|
||||
"daylight": "Daylight",
|
||||
"precip": "Precipitation",
|
||||
"pressureMean": "Barometric pressure",
|
||||
"pressureDelta": "Pressure swing"
|
||||
"pressureDelta": "Pressure swing",
|
||||
"apparentMax": "Feels-like high",
|
||||
"pm25": "Fine particles (PM2.5)",
|
||||
"pm10": "Particles (PM10)",
|
||||
"no2": "Nitrogen dioxide",
|
||||
"ozone8h": "Ozone (8-hour high)",
|
||||
"europeanAqi": "European air quality index",
|
||||
"uvIndexMax": "UV index (high)",
|
||||
"dustMax": "Saharan dust",
|
||||
"pollenMax": "Pollen (highest)"
|
||||
}
|
||||
},
|
||||
"documents": {
|
||||
@@ -11475,7 +11561,32 @@
|
||||
"guardianErrorFailed": "The invitation could not be sent.",
|
||||
"errorMfaRequired": "This needs a second factor, and your account does not have one yet. Set one up under Settings → Security, then try again.",
|
||||
"rosterLoadError": "Could not load who looks after this record.",
|
||||
"errorRateLimit": "Too many profiles created in the last hour. Wait a while and try again."
|
||||
"errorRateLimit": "Too many profiles created in the last hour. Wait a while and try again.",
|
||||
"handover": {
|
||||
"title": "Hand over this profile",
|
||||
"description": "Give {name} a sign-in of their own. The data stays where it is; you propose what access each guardian keeps, and {name} decides.",
|
||||
"create": "Create handover link",
|
||||
"expiryLabel": "Link valid for",
|
||||
"expiry1": "1 day",
|
||||
"expiry7": "7 days",
|
||||
"expiry14": "14 days",
|
||||
"proposalLabel": "Proposed access afterwards",
|
||||
"proposal": {
|
||||
"end": "End access",
|
||||
"read": "View only",
|
||||
"manage": "Keep managing"
|
||||
},
|
||||
"linkTitle": "Handover link",
|
||||
"linkOnce": "This link is shown only once. Hand it to {name} directly or let them scan the code.",
|
||||
"copy": "Copy link",
|
||||
"copied": "Link copied",
|
||||
"pending": "Handover link open until {date}",
|
||||
"revoke": "Withdraw link",
|
||||
"revoked": "Link withdrawn",
|
||||
"errorFailed": "The handover link could not be created.",
|
||||
"oidcOnly": "Handing over a profile is not available while this server allows single sign-on only.",
|
||||
"minorHint": "{name} is under 16. Keeping view access is worth considering."
|
||||
}
|
||||
},
|
||||
"toast": {
|
||||
"savedTo": "Saved to {name}'s record"
|
||||
|
||||
+121
-10
@@ -513,6 +513,19 @@
|
||||
"fixLan": "Servidor en una red doméstica sin TLS: pon SESSION_COOKIE_SECURE=false en su configuración y reinícialo.",
|
||||
"fixHttps": "Servidor detrás de un proxy HTTPS: abre la dirección https:// y asegúrate de que el proxy reenvía X-Forwarded-Proto: https.",
|
||||
"localhostNote": "Esta página está en http://localhost y el servidor solo establece su cookie de sesión para https://. La mayoría de los navegadores lo aceptan en localhost; si vuelves aquí tras iniciar sesión, pon SESSION_COOKIE_SECURE=false."
|
||||
},
|
||||
"claim": {
|
||||
"title": "Hazte cargo de tu perfil",
|
||||
"description": "{guardian} ha llevado un registro de salud para ti. Elige tu acceso para convertirlo en tu propia cuenta.",
|
||||
"invalid": "Este enlace no es válido, ha caducado o ya se ha usado.",
|
||||
"signOutFirst": "Cierra sesión primero para hacerte cargo de este perfil.",
|
||||
"emailRequired": "Se necesita una dirección de correo.",
|
||||
"guardiansTitle": "Quién conserva el acceso",
|
||||
"guardianProposal": "{name} propone: {proposal}",
|
||||
"consentNote": "Después de hacerte cargo, confirmarás tú el aviso y cualquier consentimiento de IA.",
|
||||
"submit": "Hacerme cargo del perfil",
|
||||
"submitting": "Procesando…",
|
||||
"success": "El perfil ya es tu cuenta."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -834,7 +847,8 @@
|
||||
"durationHoursOnly": "{hours} h",
|
||||
"durationMinutesOnly": "{minutes} min",
|
||||
"durationInvalid": "Escribe una duración como 7,5, 7:30 o 7h 30m.",
|
||||
"durationTooLong": "Una entrada no puede durar más de 24 horas."
|
||||
"durationTooLong": "Una entrada no puede durar más de 24 horas.",
|
||||
"sourceHealthConnect": "Health Connect"
|
||||
},
|
||||
"metricCatalog": {
|
||||
"title": "Lo que HealthLog puede registrar",
|
||||
@@ -2550,7 +2564,10 @@
|
||||
"ENV_DAYLIGHT": "la luz natural",
|
||||
"ENV_PRECIP": "las precipitaciones",
|
||||
"ENV_PRESSURE_MEAN": "la presión atmosférica",
|
||||
"ENV_PRESSURE_DELTA": "la variación de presión del día"
|
||||
"ENV_PRESSURE_DELTA": "la variación de presión del día",
|
||||
"ENV_PM25": "las partículas finas (PM2,5)",
|
||||
"ENV_OZONE_8H": "el ozono",
|
||||
"ENV_POLLEN_MAX": "el polen"
|
||||
},
|
||||
"daily": {
|
||||
"high": "Cuando {behaviour} es mayor, {outcome} del día siguiente tiende a {direction} en tus datos — un patrón que conviene observar, no una causa.",
|
||||
@@ -2573,6 +2590,11 @@
|
||||
"none": "El ánimo y el pulso en reposo no se mueven juntos de forma marcada en este periodo."
|
||||
},
|
||||
"weightWeekday": "El peso de los {weekday} está {delta} kg {direction} tu media de los demás días — un patrón que conviene observar."
|
||||
},
|
||||
"dailySameDay": {
|
||||
"high": "Más {behaviour} el mismo día y el día anterior tiende a ir con {direction} {outcome} en tus datos: un patrón que conviene observar, no una causa.",
|
||||
"moderate": "Más {behaviour} el mismo día y el día anterior parece ir con {direction} {outcome} en tus datos, según lo visto hasta ahora: un patrón que conviene observar, no una causa.",
|
||||
"faint": "Más {behaviour} el mismo día y el día anterior muestra, como mucho, un leve indicio de {direction} {outcome} en tus datos: demasiado pequeño para fiarse, nunca una causa."
|
||||
}
|
||||
},
|
||||
"noAnalysisYet": "Aún no hay valoración.",
|
||||
@@ -3678,7 +3700,10 @@
|
||||
"endInFuture": "Terminaría en el futuro. Pon como inicio la hora en que empezó el entrenamiento.",
|
||||
"distanceInvalid": "Introduce la distancia como un número dentro de un rango realista.",
|
||||
"energyInvalid": "Introduce la energía activa como un número de hasta 20.000 kcal."
|
||||
}
|
||||
},
|
||||
"editTitle": "Editar entrenamiento",
|
||||
"editAction": "Editar",
|
||||
"updated": "Entrenamiento actualizado"
|
||||
}
|
||||
},
|
||||
"bloodGlucose": {
|
||||
@@ -5364,7 +5389,8 @@
|
||||
"respiratoryRate": "Frecuencia respiratoria",
|
||||
"recovery": "Recuperación",
|
||||
"stress": "Estrés",
|
||||
"sleepDebt": "Deuda de sueño"
|
||||
"sleepDebt": "Deuda de sueño",
|
||||
"hrvRmssd": "VFC (RMSSD)"
|
||||
},
|
||||
"sourceLabels": {
|
||||
"WITHINGS": "Withings",
|
||||
@@ -5381,7 +5407,8 @@
|
||||
"MCP": "MCP",
|
||||
"GOOGLE_HEALTH": "Google Health",
|
||||
"STRAVA": "Strava",
|
||||
"EXTERNAL": "Dispositivo externo"
|
||||
"EXTERNAL": "Dispositivo externo",
|
||||
"HEALTH_CONNECT": "Health Connect"
|
||||
},
|
||||
"deviceLabels": {
|
||||
"watch": "Reloj",
|
||||
@@ -5618,6 +5645,25 @@
|
||||
"tooManyRows": "Ese archivo tiene más filas de las que admite una importación. Divídelo e importa las partes."
|
||||
},
|
||||
"detail": "Un archivo abarca todos los medicamentos que contiene."
|
||||
},
|
||||
"healthConnect": {
|
||||
"title": "Exportación de Health Connect",
|
||||
"description": "Importa el archivo ZIP que crea la app Health Connect en Android al exportar tus datos.",
|
||||
"choose": "Elegir archivo ZIP",
|
||||
"uploading": "Subiendo…",
|
||||
"uploadFailed": "La subida ha fallado. Inténtalo de nuevo.",
|
||||
"tooLarge": "El archivo supera el tamaño que acepta este servidor.",
|
||||
"unsupportedVersion": "Esta exportación de Health Connect es demasiado antigua para leerla.",
|
||||
"doneSummary": "{count} valores importados.",
|
||||
"failed": "La importación ha fallado: {reason}",
|
||||
"phase": {
|
||||
"queued": "Esperando para empezar",
|
||||
"unpacking": "Descomprimiendo el archivo",
|
||||
"parsing": "Leyendo los registros",
|
||||
"upserting": "Guardando los valores",
|
||||
"done": "Terminado",
|
||||
"failed": "Fallido"
|
||||
}
|
||||
}
|
||||
},
|
||||
"subtitle": "Exporta tus datos o importa desde una copia de seguridad."
|
||||
@@ -5732,7 +5778,17 @@
|
||||
"noResults": "No se encontraron lugares coincidentes.",
|
||||
"fetchFailedOne": "La última actualización meteorológica en segundo plano falló ({when}).",
|
||||
"fetchFailedFew": "Las últimas {count} actualizaciones meteorológicas en segundo plano fallaron, la más reciente {when}.",
|
||||
"fetchFailedOther": "Las últimas {count} actualizaciones meteorológicas en segundo plano fallaron, la más reciente {when}."
|
||||
"fetchFailedOther": "Las últimas {count} actualizaciones meteorológicas en segundo plano fallaron, la más reciente {when}.",
|
||||
"airQuality": {
|
||||
"title": "Calidad del aire y polen",
|
||||
"description": "Calidad del aire, polen y UV diarios para tu ubicación, del modelo CAMS a través de Open-Meteo.",
|
||||
"toggle": "Incluir calidad del aire y polen",
|
||||
"operatorDisabled": "La calidad del aire está desactivada en este servidor.",
|
||||
"coverage": "{count} días con datos de calidad del aire",
|
||||
"pollenEuropeOnly": "Los datos de polen solo cubren Europa.",
|
||||
"attribution": "Calidad del aire: Servicio de Vigilancia Atmosférica de Copernicus (CAMS), a través de Open-Meteo.",
|
||||
"saveError": "No se pudo guardar el ajuste."
|
||||
}
|
||||
},
|
||||
"score": {
|
||||
"title": "Puntuación de salud",
|
||||
@@ -6990,8 +7046,15 @@
|
||||
"foreground": "con la app abierta",
|
||||
"background": "en segundo plano",
|
||||
"push": "desde un aviso push",
|
||||
"unknown": "la app no informó del desencadenante"
|
||||
"unknown": "la app no informó del desencadenante",
|
||||
"manual": "desde «Sincronizar todo»"
|
||||
}
|
||||
},
|
||||
"freshness": {
|
||||
"title": "Por tipo de dato",
|
||||
"lastReceived": "Última recepción {relative}",
|
||||
"lastNewValue": "Último valor nuevo {relative}",
|
||||
"never": "Aún no recibido"
|
||||
}
|
||||
},
|
||||
"garminInfo": {
|
||||
@@ -9386,7 +9449,8 @@
|
||||
"sourceRange": {
|
||||
"fromReport": "Rango del informe {range}",
|
||||
"catalogDiffers": "tu rango {range}"
|
||||
}
|
||||
},
|
||||
"convertedFrom": "Convertido de {value} {unit}"
|
||||
},
|
||||
"modules": {
|
||||
"cycle": {
|
||||
@@ -9616,6 +9680,19 @@
|
||||
"overdue": "Refuerzo pendiente desde el {date}",
|
||||
"dueSoon": "Refuerzo previsto el {date}",
|
||||
"current": "Próximo refuerzo el {date}"
|
||||
},
|
||||
"custom": {
|
||||
"title": "Vacunas propias",
|
||||
"add": "Añadir vacuna propia",
|
||||
"name": "Nombre",
|
||||
"components": "Protege contra",
|
||||
"seriesDoses": "Dosis de la serie primaria",
|
||||
"boosterMonths": "Refuerzo cada (meses)",
|
||||
"saved": "Vacuna guardada",
|
||||
"deleted": "Vacuna eliminada",
|
||||
"deleteConfirm": "¿Eliminar esta vacuna? Las dosis ya registradas conservan su nombre.",
|
||||
"saveFailed": "No se pudo guardar la vacuna.",
|
||||
"nameTaken": "Ya tienes una vacuna con este nombre."
|
||||
}
|
||||
},
|
||||
"illness": {
|
||||
@@ -10528,7 +10605,16 @@
|
||||
"daylight": "Luz diurna",
|
||||
"precip": "Precipitación",
|
||||
"pressureMean": "Presión atmosférica",
|
||||
"pressureDelta": "Variación de presión"
|
||||
"pressureDelta": "Variación de presión",
|
||||
"apparentMax": "Máxima de sensación térmica",
|
||||
"pm25": "Partículas finas (PM2,5)",
|
||||
"pm10": "Partículas (PM10)",
|
||||
"no2": "Dióxido de nitrógeno",
|
||||
"ozone8h": "Ozono (máximo de 8 horas)",
|
||||
"europeanAqi": "Índice europeo de calidad del aire",
|
||||
"uvIndexMax": "Índice UV (máximo)",
|
||||
"dustMax": "Polvo del Sáhara",
|
||||
"pollenMax": "Polen (valor más alto)"
|
||||
}
|
||||
},
|
||||
"documents": {
|
||||
@@ -11475,7 +11561,32 @@
|
||||
"guardianErrorFailed": "No se pudo enviar la invitación.",
|
||||
"errorMfaRequired": "Esto necesita un segundo factor y tu cuenta aún no tiene ninguno. Configura uno en Ajustes → Seguridad y vuelve a intentarlo.",
|
||||
"rosterLoadError": "No se pudo cargar quién se ocupa de este historial.",
|
||||
"errorRateLimit": "Demasiados perfiles creados en la última hora. Espera un poco y vuelve a intentarlo."
|
||||
"errorRateLimit": "Demasiados perfiles creados en la última hora. Espera un poco y vuelve a intentarlo.",
|
||||
"handover": {
|
||||
"title": "Entregar este perfil",
|
||||
"description": "Dale a {name} su propio acceso. Los datos se quedan donde están; tú propones qué acceso conserva cada persona cuidadora y {name} decide.",
|
||||
"create": "Crear enlace de entrega",
|
||||
"expiryLabel": "Enlace válido durante",
|
||||
"expiry1": "1 día",
|
||||
"expiry7": "7 días",
|
||||
"expiry14": "14 días",
|
||||
"proposalLabel": "Acceso propuesto después",
|
||||
"proposal": {
|
||||
"end": "Terminar el acceso",
|
||||
"read": "Solo ver",
|
||||
"manage": "Seguir gestionando"
|
||||
},
|
||||
"linkTitle": "Enlace de entrega",
|
||||
"linkOnce": "Este enlace solo se muestra una vez. Dáselo a {name} directamente o deja que escanee el código.",
|
||||
"copy": "Copiar enlace",
|
||||
"copied": "Enlace copiado",
|
||||
"pending": "Enlace de entrega abierto hasta el {date}",
|
||||
"revoke": "Retirar enlace",
|
||||
"revoked": "Enlace retirado",
|
||||
"errorFailed": "No se pudo crear el enlace de entrega.",
|
||||
"oidcOnly": "La entrega de perfiles no está disponible mientras este servidor solo permita el inicio de sesión único.",
|
||||
"minorHint": "{name} tiene menos de 16 años. Conviene plantearse mantener el acceso de lectura."
|
||||
}
|
||||
},
|
||||
"toast": {
|
||||
"savedTo": "Guardado en el historial de {name}"
|
||||
|
||||
+121
-10
@@ -513,6 +513,19 @@
|
||||
"fixLan": "Serveur sur un réseau domestique sans TLS : mets SESSION_COOKIE_SECURE=false dans ses paramètres et redémarre-le.",
|
||||
"fixHttps": "Serveur derrière un proxy HTTPS : ouvre plutôt l'adresse https:// et vérifie que le proxy transmet X-Forwarded-Proto: https.",
|
||||
"localhostNote": "Cette page est sur http://localhost alors que le serveur ne pose son cookie de connexion qu'en https://. La plupart des navigateurs l'acceptent sur localhost ; si tu reviens ici après la connexion, mets SESSION_COOKIE_SECURE=false."
|
||||
},
|
||||
"claim": {
|
||||
"title": "Reprendre ton profil",
|
||||
"description": "{guardian} a tenu un dossier de santé pour toi. Choisis ton accès pour en faire ton propre compte.",
|
||||
"invalid": "Ce lien est invalide, expiré ou déjà utilisé.",
|
||||
"signOutFirst": "Déconnecte-toi d’abord pour reprendre ce profil.",
|
||||
"emailRequired": "Une adresse e-mail est requise.",
|
||||
"guardiansTitle": "Qui garde l’accès",
|
||||
"guardianProposal": "{name} propose : {proposal}",
|
||||
"consentNote": "Après la reprise, tu confirmeras toi-même l’avertissement et tout consentement IA.",
|
||||
"submit": "Reprendre le profil",
|
||||
"submitting": "Reprise en cours…",
|
||||
"success": "Le profil est maintenant ton compte."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -834,7 +847,8 @@
|
||||
"durationHoursOnly": "{hours} h",
|
||||
"durationMinutesOnly": "{minutes} min",
|
||||
"durationInvalid": "Saisis une durée comme 7,5, 7:30 ou 7h 30m.",
|
||||
"durationTooLong": "Une saisie ne peut pas dépasser 24 heures."
|
||||
"durationTooLong": "Une saisie ne peut pas dépasser 24 heures.",
|
||||
"sourceHealthConnect": "Health Connect"
|
||||
},
|
||||
"metricCatalog": {
|
||||
"title": "Ce que HealthLog peut suivre",
|
||||
@@ -2550,7 +2564,10 @@
|
||||
"ENV_DAYLIGHT": "la lumière du jour",
|
||||
"ENV_PRECIP": "les précipitations",
|
||||
"ENV_PRESSURE_MEAN": "la pression atmosphérique",
|
||||
"ENV_PRESSURE_DELTA": "la variation de pression sur la journée"
|
||||
"ENV_PRESSURE_DELTA": "la variation de pression sur la journée",
|
||||
"ENV_PM25": "les particules fines (PM2,5)",
|
||||
"ENV_OZONE_8H": "l’ozone",
|
||||
"ENV_POLLEN_MAX": "le pollen"
|
||||
},
|
||||
"daily": {
|
||||
"high": "Quand {behaviour} monte, {outcome} du lendemain tend à {direction} dans tes données — une tendance à surveiller, pas une cause.",
|
||||
@@ -2573,6 +2590,11 @@
|
||||
"none": "L'humeur et le pouls au repos n'évoluent pas nettement ensemble sur cette période."
|
||||
},
|
||||
"weightWeekday": "Le poids du {weekday} est {delta} kg {direction} ta moyenne des autres jours — une tendance à surveiller."
|
||||
},
|
||||
"dailySameDay": {
|
||||
"high": "Plus de {behaviour} le jour même et la veille va souvent de pair avec {direction} {outcome} dans tes données : un schéma à surveiller, pas une cause.",
|
||||
"moderate": "Plus de {behaviour} le jour même et la veille semble aller de pair avec {direction} {outcome} dans tes données, d’après ce qu’on voit jusqu’ici : un schéma à surveiller, pas une cause.",
|
||||
"faint": "Plus de {behaviour} le jour même et la veille ne montre, au mieux, qu’un faible indice de {direction} {outcome} dans tes données : trop peu pour s’y fier, jamais une cause."
|
||||
}
|
||||
},
|
||||
"noAnalysisYet": "Pas encore d’évaluation.",
|
||||
@@ -3678,7 +3700,10 @@
|
||||
"endInFuture": "Il se terminerait dans le futur. Indiquez comme début l’heure à laquelle l’entraînement a commencé.",
|
||||
"distanceInvalid": "Saisissez la distance sous forme de nombre, dans une plage réaliste.",
|
||||
"energyInvalid": "Saisissez l’énergie active sous forme de nombre, jusqu’à 20 000 kcal."
|
||||
}
|
||||
},
|
||||
"editTitle": "Modifier l’entraînement",
|
||||
"editAction": "Modifier",
|
||||
"updated": "Entraînement mis à jour"
|
||||
}
|
||||
},
|
||||
"bloodGlucose": {
|
||||
@@ -5364,7 +5389,8 @@
|
||||
"respiratoryRate": "Fréquence respiratoire",
|
||||
"recovery": "Récupération",
|
||||
"stress": "Stress",
|
||||
"sleepDebt": "Dette de sommeil"
|
||||
"sleepDebt": "Dette de sommeil",
|
||||
"hrvRmssd": "VFC (RMSSD)"
|
||||
},
|
||||
"sourceLabels": {
|
||||
"WITHINGS": "Withings",
|
||||
@@ -5381,7 +5407,8 @@
|
||||
"MCP": "MCP",
|
||||
"GOOGLE_HEALTH": "Google Health",
|
||||
"STRAVA": "Strava",
|
||||
"EXTERNAL": "Appareil externe"
|
||||
"EXTERNAL": "Appareil externe",
|
||||
"HEALTH_CONNECT": "Health Connect"
|
||||
},
|
||||
"deviceLabels": {
|
||||
"watch": "Montre",
|
||||
@@ -5618,6 +5645,25 @@
|
||||
"tooManyRows": "Ce fichier contient plus de lignes qu'un import peut en prendre. Découpez-le et importez les parties."
|
||||
},
|
||||
"detail": "Un fichier couvre tous les médicaments qu'il contient."
|
||||
},
|
||||
"healthConnect": {
|
||||
"title": "Export Health Connect",
|
||||
"description": "Importe le fichier ZIP que l’app Health Connect sur Android crée lors de l’export de tes données.",
|
||||
"choose": "Choisir le fichier ZIP",
|
||||
"uploading": "Envoi en cours…",
|
||||
"uploadFailed": "L’envoi a échoué. Réessaie.",
|
||||
"tooLarge": "Le fichier dépasse la taille acceptée par ce serveur.",
|
||||
"unsupportedVersion": "Cet export Health Connect est trop ancien pour être lu.",
|
||||
"doneSummary": "{count} valeurs importées.",
|
||||
"failed": "L’import a échoué : {reason}",
|
||||
"phase": {
|
||||
"queued": "En attente de démarrage",
|
||||
"unpacking": "Décompression du fichier",
|
||||
"parsing": "Lecture des enregistrements",
|
||||
"upserting": "Enregistrement des valeurs",
|
||||
"done": "Terminé",
|
||||
"failed": "Échec"
|
||||
}
|
||||
}
|
||||
},
|
||||
"subtitle": "Exportez vos données ou importez depuis une sauvegarde."
|
||||
@@ -5732,7 +5778,17 @@
|
||||
"noResults": "Aucun lieu correspondant trouvé.",
|
||||
"fetchFailedOne": "La dernière mise à jour météo en arrière-plan a échoué ({when}).",
|
||||
"fetchFailedFew": "Les {count} dernières mises à jour météo en arrière-plan ont échoué, la plus récente {when}.",
|
||||
"fetchFailedOther": "Les {count} dernières mises à jour météo en arrière-plan ont échoué, la plus récente {when}."
|
||||
"fetchFailedOther": "Les {count} dernières mises à jour météo en arrière-plan ont échoué, la plus récente {when}.",
|
||||
"airQuality": {
|
||||
"title": "Qualité de l’air et pollen",
|
||||
"description": "Qualité de l’air, pollen et UV quotidiens pour ton lieu, issus du modèle CAMS via Open-Meteo.",
|
||||
"toggle": "Inclure la qualité de l’air et le pollen",
|
||||
"operatorDisabled": "La qualité de l’air est désactivée sur ce serveur.",
|
||||
"coverage": "{count} jours avec des données de qualité de l’air",
|
||||
"pollenEuropeOnly": "Les données de pollen ne couvrent que l’Europe.",
|
||||
"attribution": "Qualité de l’air : service de surveillance de l’atmosphère de Copernicus (CAMS), via Open-Meteo.",
|
||||
"saveError": "Le réglage n’a pas pu être enregistré."
|
||||
}
|
||||
},
|
||||
"score": {
|
||||
"title": "Score de santé",
|
||||
@@ -6990,8 +7046,15 @@
|
||||
"foreground": "avec l'app ouverte",
|
||||
"background": "en arrière-plan",
|
||||
"push": "depuis un réveil push",
|
||||
"unknown": "déclencheur non signalé par l'app"
|
||||
"unknown": "déclencheur non signalé par l'app",
|
||||
"manual": "via « Tout synchroniser »"
|
||||
}
|
||||
},
|
||||
"freshness": {
|
||||
"title": "Par type de donnée",
|
||||
"lastReceived": "Dernière réception {relative}",
|
||||
"lastNewValue": "Dernière nouvelle valeur {relative}",
|
||||
"never": "Pas encore reçu"
|
||||
}
|
||||
},
|
||||
"garminInfo": {
|
||||
@@ -9386,7 +9449,8 @@
|
||||
"sourceRange": {
|
||||
"fromReport": "Plage du compte rendu {range}",
|
||||
"catalogDiffers": "votre plage {range}"
|
||||
}
|
||||
},
|
||||
"convertedFrom": "Converti depuis {value} {unit}"
|
||||
},
|
||||
"modules": {
|
||||
"cycle": {
|
||||
@@ -9616,6 +9680,19 @@
|
||||
"overdue": "Rappel en retard depuis le {date}",
|
||||
"dueSoon": "Rappel prévu le {date}",
|
||||
"current": "Prochain rappel le {date}"
|
||||
},
|
||||
"custom": {
|
||||
"title": "Vaccins personnels",
|
||||
"add": "Ajouter un vaccin personnel",
|
||||
"name": "Nom",
|
||||
"components": "Protège contre",
|
||||
"seriesDoses": "Doses de la primovaccination",
|
||||
"boosterMonths": "Rappel tous les (mois)",
|
||||
"saved": "Vaccin enregistré",
|
||||
"deleted": "Vaccin supprimé",
|
||||
"deleteConfirm": "Supprimer ce vaccin ? Les doses déjà saisies gardent leur nom.",
|
||||
"saveFailed": "Le vaccin n’a pas pu être enregistré.",
|
||||
"nameTaken": "Tu as déjà un vaccin portant ce nom."
|
||||
}
|
||||
},
|
||||
"illness": {
|
||||
@@ -10528,7 +10605,16 @@
|
||||
"daylight": "Durée du jour",
|
||||
"precip": "Précipitations",
|
||||
"pressureMean": "Pression atmosphérique",
|
||||
"pressureDelta": "Variation de pression"
|
||||
"pressureDelta": "Variation de pression",
|
||||
"apparentMax": "Maximum ressenti",
|
||||
"pm25": "Particules fines (PM2,5)",
|
||||
"pm10": "Particules (PM10)",
|
||||
"no2": "Dioxyde d’azote",
|
||||
"ozone8h": "Ozone (maximum sur 8 heures)",
|
||||
"europeanAqi": "Indice européen de qualité de l’air",
|
||||
"uvIndexMax": "Indice UV (maximum)",
|
||||
"dustMax": "Poussières du Sahara",
|
||||
"pollenMax": "Pollen (valeur la plus élevée)"
|
||||
}
|
||||
},
|
||||
"documents": {
|
||||
@@ -11475,7 +11561,32 @@
|
||||
"guardianErrorFailed": "L'invitation n'a pas pu être envoyée.",
|
||||
"errorMfaRequired": "Cela demande un deuxième facteur, et votre compte n'en a pas encore. Configurez-en un dans Réglages → Sécurité, puis réessayez.",
|
||||
"rosterLoadError": "Impossible de charger qui s'occupe de ce dossier.",
|
||||
"errorRateLimit": "Trop de profils créés dans la dernière heure. Patientez un peu et réessayez."
|
||||
"errorRateLimit": "Trop de profils créés dans la dernière heure. Patientez un peu et réessayez.",
|
||||
"handover": {
|
||||
"title": "Transmettre ce profil",
|
||||
"description": "Donne à {name} son propre accès. Les données restent où elles sont ; tu proposes l’accès que garde chaque personne aidante, et {name} décide.",
|
||||
"create": "Créer le lien de transmission",
|
||||
"expiryLabel": "Lien valable pendant",
|
||||
"expiry1": "1 jour",
|
||||
"expiry7": "7 jours",
|
||||
"expiry14": "14 jours",
|
||||
"proposalLabel": "Accès proposé ensuite",
|
||||
"proposal": {
|
||||
"end": "Mettre fin à l’accès",
|
||||
"read": "Consultation seule",
|
||||
"manage": "Continuer à gérer"
|
||||
},
|
||||
"linkTitle": "Lien de transmission",
|
||||
"linkOnce": "Ce lien n’est affiché qu’une seule fois. Remets-le directement à {name} ou laisse-le scanner le code.",
|
||||
"copy": "Copier le lien",
|
||||
"copied": "Lien copié",
|
||||
"pending": "Lien de transmission ouvert jusqu’au {date}",
|
||||
"revoke": "Retirer le lien",
|
||||
"revoked": "Lien retiré",
|
||||
"errorFailed": "Le lien de transmission n’a pas pu être créé.",
|
||||
"oidcOnly": "La transmission de profil n’est pas disponible tant que ce serveur n’autorise que l’authentification unique.",
|
||||
"minorHint": "{name} a moins de 16 ans. Garder l’accès en lecture mérite réflexion."
|
||||
}
|
||||
},
|
||||
"toast": {
|
||||
"savedTo": "Enregistré dans le dossier de {name}"
|
||||
|
||||
+121
-10
@@ -513,6 +513,19 @@
|
||||
"fixLan": "Server in una rete domestica senza TLS: imposta SESSION_COOKIE_SECURE=false nelle sue impostazioni e riavvialo.",
|
||||
"fixHttps": "Server dietro un proxy HTTPS: apri invece l'indirizzo https:// e assicurati che il proxy inoltri X-Forwarded-Proto: https.",
|
||||
"localhostNote": "Questa pagina è su http://localhost mentre il server imposta il cookie di accesso solo per https://. La maggior parte dei browser lo accetta su localhost; se dopo l'accesso torni qui, imposta SESSION_COOKIE_SECURE=false."
|
||||
},
|
||||
"claim": {
|
||||
"title": "Prendi in mano il tuo profilo",
|
||||
"description": "{guardian} ha tenuto un registro sanitario per te. Scegli il tuo accesso per renderlo il tuo account.",
|
||||
"invalid": "Questo link non è valido, è scaduto o è già stato usato.",
|
||||
"signOutFirst": "Esci prima per prendere in mano questo profilo.",
|
||||
"emailRequired": "È necessario un indirizzo email.",
|
||||
"guardiansTitle": "Chi mantiene l’accesso",
|
||||
"guardianProposal": "{name} propone: {proposal}",
|
||||
"consentNote": "Dopo la presa in carico confermerai tu l’avvertenza e l’eventuale consenso all’IA.",
|
||||
"submit": "Prendi il profilo",
|
||||
"submitting": "Presa in carico…",
|
||||
"success": "Il profilo ora è il tuo account."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -834,7 +847,8 @@
|
||||
"durationHoursOnly": "{hours} h",
|
||||
"durationMinutesOnly": "{minutes} min",
|
||||
"durationInvalid": "Inserisci una durata come 7,5, 7:30 o 7h 30m.",
|
||||
"durationTooLong": "Una voce non può superare le 24 ore."
|
||||
"durationTooLong": "Una voce non può superare le 24 ore.",
|
||||
"sourceHealthConnect": "Health Connect"
|
||||
},
|
||||
"metricCatalog": {
|
||||
"title": "Cosa può monitorare HealthLog",
|
||||
@@ -2550,7 +2564,10 @@
|
||||
"ENV_DAYLIGHT": "la luce del giorno",
|
||||
"ENV_PRECIP": "le precipitazioni",
|
||||
"ENV_PRESSURE_MEAN": "la pressione atmosferica",
|
||||
"ENV_PRESSURE_DELTA": "l'oscillazione di pressione nella giornata"
|
||||
"ENV_PRESSURE_DELTA": "l'oscillazione di pressione nella giornata",
|
||||
"ENV_PM25": "il particolato fine (PM2,5)",
|
||||
"ENV_OZONE_8H": "l’ozono",
|
||||
"ENV_POLLEN_MAX": "i pollini"
|
||||
},
|
||||
"daily": {
|
||||
"high": "Quando {behaviour} sale, {outcome} del giorno dopo tende a {direction} nei tuoi dati — un andamento da osservare, non una causa.",
|
||||
@@ -2573,6 +2590,11 @@
|
||||
"none": "L'umore e il polso a riposo non si muovono insieme in modo marcato in questo periodo."
|
||||
},
|
||||
"weightWeekday": "Il peso del {weekday} è {delta} kg {direction} la tua media degli altri giorni — un andamento da osservare."
|
||||
},
|
||||
"dailySameDay": {
|
||||
"high": "Più {behaviour} nello stesso giorno e in quello prima tende ad accompagnarsi a {direction} {outcome} nei tuoi dati: uno schema da osservare, non una causa.",
|
||||
"moderate": "Più {behaviour} nello stesso giorno e in quello prima sembra accompagnarsi a {direction} {outcome} nei tuoi dati, per quanto visto finora: uno schema da osservare, non una causa.",
|
||||
"faint": "Più {behaviour} nello stesso giorno e in quello prima mostra al massimo un debole indizio di {direction} {outcome} nei tuoi dati: troppo poco per contarci, mai una causa."
|
||||
}
|
||||
},
|
||||
"noAnalysisYet": "Ancora nessuna valutazione.",
|
||||
@@ -3678,7 +3700,10 @@
|
||||
"endInFuture": "Finirebbe nel futuro. Imposta come inizio l’ora in cui è cominciato l’allenamento.",
|
||||
"distanceInvalid": "Inserisci la distanza come numero in un intervallo realistico.",
|
||||
"energyInvalid": "Inserisci l’energia attiva come numero fino a 20.000 kcal."
|
||||
}
|
||||
},
|
||||
"editTitle": "Modifica allenamento",
|
||||
"editAction": "Modifica",
|
||||
"updated": "Allenamento aggiornato"
|
||||
}
|
||||
},
|
||||
"bloodGlucose": {
|
||||
@@ -5364,7 +5389,8 @@
|
||||
"respiratoryRate": "Frequenza respiratoria",
|
||||
"recovery": "Recupero",
|
||||
"stress": "Stress",
|
||||
"sleepDebt": "Debito di sonno"
|
||||
"sleepDebt": "Debito di sonno",
|
||||
"hrvRmssd": "HRV (RMSSD)"
|
||||
},
|
||||
"sourceLabels": {
|
||||
"WITHINGS": "Withings",
|
||||
@@ -5381,7 +5407,8 @@
|
||||
"MCP": "MCP",
|
||||
"GOOGLE_HEALTH": "Google Health",
|
||||
"STRAVA": "Strava",
|
||||
"EXTERNAL": "Dispositivo esterno"
|
||||
"EXTERNAL": "Dispositivo esterno",
|
||||
"HEALTH_CONNECT": "Health Connect"
|
||||
},
|
||||
"deviceLabels": {
|
||||
"watch": "Orologio",
|
||||
@@ -5618,6 +5645,25 @@
|
||||
"tooManyRows": "Quel file contiene più righe di quante un'importazione possa accoglierne. Dividilo e importa le parti."
|
||||
},
|
||||
"detail": "Un file copre tutti i farmaci che contiene."
|
||||
},
|
||||
"healthConnect": {
|
||||
"title": "Esportazione di Health Connect",
|
||||
"description": "Importa il file ZIP che l’app Health Connect su Android crea quando esporti i tuoi dati.",
|
||||
"choose": "Scegli file ZIP",
|
||||
"uploading": "Caricamento…",
|
||||
"uploadFailed": "Il caricamento non è riuscito. Riprova.",
|
||||
"tooLarge": "Il file supera la dimensione accettata da questo server.",
|
||||
"unsupportedVersion": "Questa esportazione di Health Connect è troppo vecchia per essere letta.",
|
||||
"doneSummary": "{count} valori importati.",
|
||||
"failed": "L’importazione non è riuscita: {reason}",
|
||||
"phase": {
|
||||
"queued": "In attesa di avvio",
|
||||
"unpacking": "Estrazione del file",
|
||||
"parsing": "Lettura dei record",
|
||||
"upserting": "Salvataggio dei valori",
|
||||
"done": "Completato",
|
||||
"failed": "Non riuscito"
|
||||
}
|
||||
}
|
||||
},
|
||||
"subtitle": "Esporta i tuoi dati o importa da un backup."
|
||||
@@ -5732,7 +5778,17 @@
|
||||
"noResults": "Nessun luogo corrispondente trovato.",
|
||||
"fetchFailedOne": "L’ultimo aggiornamento meteo in background non è riuscito ({when}).",
|
||||
"fetchFailedFew": "Gli ultimi {count} aggiornamenti meteo in background non sono riusciti, il più recente {when}.",
|
||||
"fetchFailedOther": "Gli ultimi {count} aggiornamenti meteo in background non sono riusciti, il più recente {when}."
|
||||
"fetchFailedOther": "Gli ultimi {count} aggiornamenti meteo in background non sono riusciti, il più recente {when}.",
|
||||
"airQuality": {
|
||||
"title": "Qualità dell’aria e pollini",
|
||||
"description": "Qualità dell’aria, pollini e UV giornalieri per la tua località, dal modello CAMS tramite Open-Meteo.",
|
||||
"toggle": "Includi qualità dell’aria e pollini",
|
||||
"operatorDisabled": "La qualità dell’aria è disattivata su questo server.",
|
||||
"coverage": "{count} giorni con dati sulla qualità dell’aria",
|
||||
"pollenEuropeOnly": "I dati sui pollini coprono solo l’Europa.",
|
||||
"attribution": "Qualità dell’aria: servizio di monitoraggio atmosferico di Copernicus (CAMS), tramite Open-Meteo.",
|
||||
"saveError": "Impossibile salvare l’impostazione."
|
||||
}
|
||||
},
|
||||
"score": {
|
||||
"title": "Punteggio di salute",
|
||||
@@ -6990,8 +7046,15 @@
|
||||
"foreground": "con l'app aperta",
|
||||
"background": "in background",
|
||||
"push": "da una sveglia push",
|
||||
"unknown": "trigger non segnalato dall'app"
|
||||
"unknown": "trigger non segnalato dall'app",
|
||||
"manual": "da «Sincronizza tutto»"
|
||||
}
|
||||
},
|
||||
"freshness": {
|
||||
"title": "Per tipo di dato",
|
||||
"lastReceived": "Ultima ricezione {relative}",
|
||||
"lastNewValue": "Ultimo nuovo valore {relative}",
|
||||
"never": "Non ancora ricevuto"
|
||||
}
|
||||
},
|
||||
"garminInfo": {
|
||||
@@ -9386,7 +9449,8 @@
|
||||
"sourceRange": {
|
||||
"fromReport": "Intervallo del referto {range}",
|
||||
"catalogDiffers": "il tuo intervallo {range}"
|
||||
}
|
||||
},
|
||||
"convertedFrom": "Convertito da {value} {unit}"
|
||||
},
|
||||
"modules": {
|
||||
"cycle": {
|
||||
@@ -9616,6 +9680,19 @@
|
||||
"overdue": "Richiamo scaduto dal {date}",
|
||||
"dueSoon": "Richiamo previsto il {date}",
|
||||
"current": "Prossimo richiamo il {date}"
|
||||
},
|
||||
"custom": {
|
||||
"title": "Vaccini personali",
|
||||
"add": "Aggiungi vaccino personale",
|
||||
"name": "Nome",
|
||||
"components": "Protegge da",
|
||||
"seriesDoses": "Dosi del ciclo primario",
|
||||
"boosterMonths": "Richiamo ogni (mesi)",
|
||||
"saved": "Vaccino salvato",
|
||||
"deleted": "Vaccino rimosso",
|
||||
"deleteConfirm": "Rimuovere questo vaccino? Le dosi già registrate mantengono il loro nome.",
|
||||
"saveFailed": "Impossibile salvare il vaccino.",
|
||||
"nameTaken": "Hai già un vaccino con questo nome."
|
||||
}
|
||||
},
|
||||
"illness": {
|
||||
@@ -10528,7 +10605,16 @@
|
||||
"daylight": "Ore di luce",
|
||||
"precip": "Precipitazioni",
|
||||
"pressureMean": "Pressione atmosferica",
|
||||
"pressureDelta": "Variazione di pressione"
|
||||
"pressureDelta": "Variazione di pressione",
|
||||
"apparentMax": "Massima percepita",
|
||||
"pm25": "Particolato fine (PM2,5)",
|
||||
"pm10": "Particolato (PM10)",
|
||||
"no2": "Biossido di azoto",
|
||||
"ozone8h": "Ozono (massimo su 8 ore)",
|
||||
"europeanAqi": "Indice europeo di qualità dell’aria",
|
||||
"uvIndexMax": "Indice UV (massimo)",
|
||||
"dustMax": "Polvere del Sahara",
|
||||
"pollenMax": "Pollini (valore più alto)"
|
||||
}
|
||||
},
|
||||
"documents": {
|
||||
@@ -11475,7 +11561,32 @@
|
||||
"guardianErrorFailed": "Non è stato possibile inviare l'invito.",
|
||||
"errorMfaRequired": "Serve un secondo fattore e il tuo account non ne ha ancora uno. Impostane uno in Impostazioni → Sicurezza e riprova.",
|
||||
"rosterLoadError": "Non è stato possibile caricare chi si occupa di questa cartella.",
|
||||
"errorRateLimit": "Troppi profili creati nell'ultima ora. Aspetta un po' e riprova."
|
||||
"errorRateLimit": "Troppi profili creati nell'ultima ora. Aspetta un po' e riprova.",
|
||||
"handover": {
|
||||
"title": "Consegna questo profilo",
|
||||
"description": "Dai a {name} un accesso tutto suo. I dati restano dove sono; tu proponi quale accesso mantiene ogni persona che se ne occupa e {name} decide.",
|
||||
"create": "Crea link di consegna",
|
||||
"expiryLabel": "Link valido per",
|
||||
"expiry1": "1 giorno",
|
||||
"expiry7": "7 giorni",
|
||||
"expiry14": "14 giorni",
|
||||
"proposalLabel": "Accesso proposto dopo",
|
||||
"proposal": {
|
||||
"end": "Termina l’accesso",
|
||||
"read": "Solo visualizzazione",
|
||||
"manage": "Continua a gestire"
|
||||
},
|
||||
"linkTitle": "Link di consegna",
|
||||
"linkOnce": "Questo link viene mostrato una sola volta. Consegnalo direttamente a {name} o fagli scansionare il codice.",
|
||||
"copy": "Copia link",
|
||||
"copied": "Link copiato",
|
||||
"pending": "Link di consegna aperto fino al {date}",
|
||||
"revoke": "Ritira link",
|
||||
"revoked": "Link ritirato",
|
||||
"errorFailed": "Impossibile creare il link di consegna.",
|
||||
"oidcOnly": "La consegna del profilo non è disponibile finché questo server consente solo l’accesso unico.",
|
||||
"minorHint": "{name} ha meno di 16 anni. Vale la pena considerare di mantenere l’accesso in lettura."
|
||||
}
|
||||
},
|
||||
"toast": {
|
||||
"savedTo": "Salvato nella cartella di {name}"
|
||||
|
||||
+121
-10
@@ -513,6 +513,19 @@
|
||||
"fixLan": "TLS 없이 홈 네트워크에서 쓰는 서버: 설정에서 SESSION_COOKIE_SECURE=false로 지정하고 다시 시작하세요.",
|
||||
"fixHttps": "HTTPS 프록시 뒤의 서버: 대신 https:// 주소를 열고, 프록시가 X-Forwarded-Proto: https를 전달하는지 확인하세요.",
|
||||
"localhostNote": "이 페이지는 http://localhost에 있지만 서버는 로그인 쿠키를 https://에만 설정합니다. 대부분의 브라우저는 localhost에서 이를 허용합니다. 로그인 후 계속 여기로 돌아온다면 SESSION_COOKIE_SECURE=false로 지정하세요."
|
||||
},
|
||||
"claim": {
|
||||
"title": "내 프로필 넘겨받기",
|
||||
"description": "{guardian}님이 당신의 건강 기록을 관리해 왔습니다. 로그인 방법을 정해 내 계정으로 만드세요.",
|
||||
"invalid": "이 링크는 유효하지 않거나 만료되었거나 이미 사용되었습니다.",
|
||||
"signOutFirst": "이 프로필을 넘겨받으려면 먼저 로그아웃하세요.",
|
||||
"emailRequired": "이메일 주소가 필요합니다.",
|
||||
"guardiansTitle": "접근 권한을 유지할 사람",
|
||||
"guardianProposal": "{name}님의 제안: {proposal}",
|
||||
"consentNote": "넘겨받은 뒤에는 고지 사항과 AI 동의를 직접 다시 확인하게 됩니다.",
|
||||
"submit": "프로필 넘겨받기",
|
||||
"submitting": "넘겨받는 중…",
|
||||
"success": "이제 이 프로필이 내 계정입니다."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -834,7 +847,8 @@
|
||||
"durationHoursOnly": "{hours}시간",
|
||||
"durationMinutesOnly": "{minutes}분",
|
||||
"durationInvalid": "7.5, 7:30, 7h 30m처럼 시간을 입력해 주세요.",
|
||||
"durationTooLong": "한 번에 24시간보다 길게 입력할 수 없어요."
|
||||
"durationTooLong": "한 번에 24시간보다 길게 입력할 수 없어요.",
|
||||
"sourceHealthConnect": "Health Connect"
|
||||
},
|
||||
"metricCatalog": {
|
||||
"title": "HealthLog가 기록할 수 있는 것",
|
||||
@@ -2550,7 +2564,10 @@
|
||||
"ENV_DAYLIGHT": "낮 길이",
|
||||
"ENV_PRECIP": "강수량",
|
||||
"ENV_PRESSURE_MEAN": "기압",
|
||||
"ENV_PRESSURE_DELTA": "하루 중 기압 변화"
|
||||
"ENV_PRESSURE_DELTA": "하루 중 기압 변화",
|
||||
"ENV_PM25": "초미세먼지(PM2.5)",
|
||||
"ENV_OZONE_8H": "오존",
|
||||
"ENV_POLLEN_MAX": "꽃가루"
|
||||
},
|
||||
"daily": {
|
||||
"high": "{behaviour} 수치가 높은 날에는 다음 날 {outcome} 수치가 {direction} 경향이 있어요 — 원인이 아니라 지켜볼 만한 패턴이에요.",
|
||||
@@ -2573,6 +2590,11 @@
|
||||
"none": "이 기간에는 기분과 안정 시 맥박이 뚜렷하게 함께 움직이지 않아요."
|
||||
},
|
||||
"weightWeekday": "{weekday} 체중이 다른 요일 평균보다 {delta}kg {direction} 편이에요 — 지켜볼 만한 패턴이에요."
|
||||
},
|
||||
"dailySameDay": {
|
||||
"high": "당일과 전날의 {behaviour}이(가) 높을수록 내 데이터에서는 {outcome}이(가) {direction} 경향이 있습니다. 지켜볼 만한 패턴이지 원인은 아닙니다.",
|
||||
"moderate": "지금까지의 데이터로 보면 당일과 전날의 {behaviour}이(가) 높을수록 {outcome}이(가) {direction} 것으로 보입니다. 지켜볼 만한 패턴이지 원인은 아닙니다.",
|
||||
"faint": "당일과 전날의 {behaviour}이(가) 높을 때 내 데이터에서 {outcome}이(가) {direction} 기미가 아주 약하게 보일 뿐입니다. 의지하기엔 너무 작고, 원인은 아닙니다."
|
||||
}
|
||||
},
|
||||
"noAnalysisYet": "아직 평가가 없어요.",
|
||||
@@ -3678,7 +3700,10 @@
|
||||
"endInFuture": "종료 시간이 미래가 됩니다. 시작 시간을 운동을 시작한 시각으로 설정하세요.",
|
||||
"distanceInvalid": "거리를 현실적인 범위의 숫자로 입력하세요.",
|
||||
"energyInvalid": "활동 에너지는 20,000kcal 이하의 숫자로 입력하세요."
|
||||
}
|
||||
},
|
||||
"editTitle": "운동 편집",
|
||||
"editAction": "편집",
|
||||
"updated": "운동을 업데이트했습니다"
|
||||
}
|
||||
},
|
||||
"bloodGlucose": {
|
||||
@@ -5364,7 +5389,8 @@
|
||||
"respiratoryRate": "호흡수",
|
||||
"recovery": "회복",
|
||||
"stress": "스트레스",
|
||||
"sleepDebt": "수면 부채"
|
||||
"sleepDebt": "수면 부채",
|
||||
"hrvRmssd": "심박변이도 (RMSSD)"
|
||||
},
|
||||
"sourceLabels": {
|
||||
"WITHINGS": "Withings",
|
||||
@@ -5381,7 +5407,8 @@
|
||||
"MCP": "MCP",
|
||||
"GOOGLE_HEALTH": "Google Health",
|
||||
"STRAVA": "Strava",
|
||||
"EXTERNAL": "외부 기기"
|
||||
"EXTERNAL": "외부 기기",
|
||||
"HEALTH_CONNECT": "Health Connect"
|
||||
},
|
||||
"deviceLabels": {
|
||||
"watch": "워치",
|
||||
@@ -5618,6 +5645,25 @@
|
||||
"tooManyRows": "한 번에 불러올 수 있는 행 수를 넘었어요. 파일을 나눠서 불러와 주세요."
|
||||
},
|
||||
"detail": "파일 하나에 담긴 모든 약을 함께 처리해요."
|
||||
},
|
||||
"healthConnect": {
|
||||
"title": "Health Connect 내보내기",
|
||||
"description": "Android의 Health Connect 앱에서 데이터를 내보낼 때 만들어지는 ZIP 파일을 가져옵니다.",
|
||||
"choose": "ZIP 파일 선택",
|
||||
"uploading": "업로드 중…",
|
||||
"uploadFailed": "업로드에 실패했습니다. 다시 시도해 주세요.",
|
||||
"tooLarge": "파일이 이 서버에서 허용하는 크기보다 큽니다.",
|
||||
"unsupportedVersion": "이 Health Connect 내보내기는 너무 오래되어 읽을 수 없습니다.",
|
||||
"doneSummary": "값 {count}개를 가져왔습니다.",
|
||||
"failed": "가져오기에 실패했습니다: {reason}",
|
||||
"phase": {
|
||||
"queued": "시작 대기 중",
|
||||
"unpacking": "파일 압축 해제 중",
|
||||
"parsing": "기록 읽는 중",
|
||||
"upserting": "값 저장 중",
|
||||
"done": "완료",
|
||||
"failed": "실패"
|
||||
}
|
||||
}
|
||||
},
|
||||
"subtitle": "데이터를 내보내거나 백업에서 불러와요."
|
||||
@@ -5732,7 +5778,17 @@
|
||||
"noResults": "일치하는 장소가 없어요.",
|
||||
"fetchFailedOne": "마지막 백그라운드 날씨 업데이트가 실패했어요 ({when}).",
|
||||
"fetchFailedFew": "최근 백그라운드 날씨 업데이트 {count}번이 실패했어요 — 마지막은 {when}.",
|
||||
"fetchFailedOther": "최근 백그라운드 날씨 업데이트 {count}번이 실패했어요 — 마지막은 {when}."
|
||||
"fetchFailedOther": "최근 백그라운드 날씨 업데이트 {count}번이 실패했어요 — 마지막은 {when}.",
|
||||
"airQuality": {
|
||||
"title": "대기질과 꽃가루",
|
||||
"description": "Open-Meteo를 통해 CAMS 모델에서 가져온 내 위치의 일별 대기질, 꽃가루, 자외선 정보입니다.",
|
||||
"toggle": "대기질과 꽃가루 포함",
|
||||
"operatorDisabled": "이 서버에서는 대기질 기능이 꺼져 있습니다.",
|
||||
"coverage": "대기질 데이터가 있는 날 {count}일",
|
||||
"pollenEuropeOnly": "꽃가루 데이터는 유럽만 제공됩니다.",
|
||||
"attribution": "대기질: 코페르니쿠스 대기 모니터링 서비스(CAMS), Open-Meteo 경유.",
|
||||
"saveError": "설정을 저장하지 못했습니다."
|
||||
}
|
||||
},
|
||||
"score": {
|
||||
"title": "건강 점수",
|
||||
@@ -6990,8 +7046,15 @@
|
||||
"foreground": "앱이 열린 상태에서",
|
||||
"background": "백그라운드에서",
|
||||
"push": "푸시 깨우기로",
|
||||
"unknown": "앱이 트리거를 알려주지 않았어요"
|
||||
"unknown": "앱이 트리거를 알려주지 않았어요",
|
||||
"manual": "'모두 동기화'로"
|
||||
}
|
||||
},
|
||||
"freshness": {
|
||||
"title": "데이터 유형별",
|
||||
"lastReceived": "마지막 수신 {relative}",
|
||||
"lastNewValue": "마지막 새 값 {relative}",
|
||||
"never": "아직 수신되지 않음"
|
||||
}
|
||||
},
|
||||
"garminInfo": {
|
||||
@@ -9386,7 +9449,8 @@
|
||||
"sourceRange": {
|
||||
"fromReport": "결과지 범위 {range}",
|
||||
"catalogDiffers": "내 범위 {range}"
|
||||
}
|
||||
},
|
||||
"convertedFrom": "{value} {unit}에서 변환됨"
|
||||
},
|
||||
"modules": {
|
||||
"cycle": {
|
||||
@@ -9616,6 +9680,19 @@
|
||||
"overdue": "{date}부터 추가접종 기한 지남",
|
||||
"dueSoon": "{date} 추가접종 예정",
|
||||
"current": "다음 추가접종 {date}"
|
||||
},
|
||||
"custom": {
|
||||
"title": "직접 추가한 백신",
|
||||
"add": "백신 직접 추가",
|
||||
"name": "이름",
|
||||
"components": "예방 대상",
|
||||
"seriesDoses": "기초 접종 횟수",
|
||||
"boosterMonths": "추가 접종 주기(개월)",
|
||||
"saved": "백신을 저장했습니다",
|
||||
"deleted": "백신을 삭제했습니다",
|
||||
"deleteConfirm": "이 백신을 삭제할까요? 이미 기록된 접종은 이름이 그대로 남습니다.",
|
||||
"saveFailed": "백신을 저장하지 못했습니다.",
|
||||
"nameTaken": "같은 이름의 백신이 이미 있습니다."
|
||||
}
|
||||
},
|
||||
"illness": {
|
||||
@@ -10528,7 +10605,16 @@
|
||||
"daylight": "낮 길이",
|
||||
"precip": "강수량",
|
||||
"pressureMean": "기압",
|
||||
"pressureDelta": "기압 변화"
|
||||
"pressureDelta": "기압 변화",
|
||||
"apparentMax": "체감 최고 기온",
|
||||
"pm25": "초미세먼지 (PM2.5)",
|
||||
"pm10": "미세먼지 (PM10)",
|
||||
"no2": "이산화질소",
|
||||
"ozone8h": "오존 (8시간 최고)",
|
||||
"europeanAqi": "유럽 대기질 지수",
|
||||
"uvIndexMax": "자외선 지수 (최고)",
|
||||
"dustMax": "사하라 먼지",
|
||||
"pollenMax": "꽃가루 (최고)"
|
||||
}
|
||||
},
|
||||
"documents": {
|
||||
@@ -11475,7 +11561,32 @@
|
||||
"guardianErrorFailed": "초대를 보내지 못했어요.",
|
||||
"errorMfaRequired": "여기에는 2단계 인증이 필요한데 계정에 아직 없어요. 설정 → 보안에서 등록한 뒤 다시 시도해 주세요.",
|
||||
"rosterLoadError": "이 기록을 돌보는 사람을 불러오지 못했어요.",
|
||||
"errorRateLimit": "최근 한 시간 동안 만든 프로필이 너무 많아요. 잠시 기다렸다 다시 시도해 주세요."
|
||||
"errorRateLimit": "최근 한 시간 동안 만든 프로필이 너무 많아요. 잠시 기다렸다 다시 시도해 주세요.",
|
||||
"handover": {
|
||||
"title": "이 프로필 넘겨주기",
|
||||
"description": "{name}에게 자신의 로그인을 만들어 주세요. 데이터는 그대로 있고, 각 보호자가 유지할 접근 권한은 당신이 제안하며 {name}이(가) 결정합니다.",
|
||||
"create": "넘겨주기 링크 만들기",
|
||||
"expiryLabel": "링크 유효 기간",
|
||||
"expiry1": "1일",
|
||||
"expiry7": "7일",
|
||||
"expiry14": "14일",
|
||||
"proposalLabel": "이후 제안하는 접근 권한",
|
||||
"proposal": {
|
||||
"end": "접근 종료",
|
||||
"read": "보기만",
|
||||
"manage": "계속 관리"
|
||||
},
|
||||
"linkTitle": "넘겨주기 링크",
|
||||
"linkOnce": "이 링크는 한 번만 표시됩니다. {name}에게 직접 전달하거나 코드를 스캔하게 하세요.",
|
||||
"copy": "링크 복사",
|
||||
"copied": "링크를 복사했습니다",
|
||||
"pending": "넘겨주기 링크 유효: {date}까지",
|
||||
"revoke": "링크 철회",
|
||||
"revoked": "링크를 철회했습니다",
|
||||
"errorFailed": "넘겨주기 링크를 만들지 못했습니다.",
|
||||
"oidcOnly": "이 서버가 통합 로그인만 허용하는 동안에는 프로필을 넘겨줄 수 없습니다.",
|
||||
"minorHint": "{name}은(는) 16세 미만입니다. 보기 권한을 유지하는 것을 고려해 보세요."
|
||||
}
|
||||
},
|
||||
"toast": {
|
||||
"savedTo": "{name} 님의 기록에 저장했어요"
|
||||
|
||||
+121
-10
@@ -513,6 +513,19 @@
|
||||
"fixLan": "Serwer w sieci domowej bez TLS: ustaw SESSION_COOKIE_SECURE=false w jego ustawieniach i uruchom go ponownie.",
|
||||
"fixHttps": "Serwer za proxy HTTPS: otwórz zamiast tego adres https:// i upewnij się, że proxy przekazuje X-Forwarded-Proto: https.",
|
||||
"localhostNote": "Ta strona działa na http://localhost, a serwer ustawia ciasteczko logowania tylko dla https://. Większość przeglądarek akceptuje to na localhost; jeśli po zalogowaniu wracasz tutaj, ustaw SESSION_COOKIE_SECURE=false."
|
||||
},
|
||||
"claim": {
|
||||
"title": "Przejmij swój profil",
|
||||
"description": "{guardian} prowadził(a) dla ciebie kartę zdrowia. Wybierz sposób logowania, aby stała się twoim własnym kontem.",
|
||||
"invalid": "Ten link jest nieprawidłowy, wygasł lub został już użyty.",
|
||||
"signOutFirst": "Najpierw się wyloguj, aby przejąć ten profil.",
|
||||
"emailRequired": "Adres e-mail jest wymagany.",
|
||||
"guardiansTitle": "Kto zachowa dostęp",
|
||||
"guardianProposal": "{name} proponuje: {proposal}",
|
||||
"consentNote": "Po przejęciu sam(a) potwierdzisz zastrzeżenie i ewentualną zgodę na AI.",
|
||||
"submit": "Przejmij profil",
|
||||
"submitting": "Przejmowanie…",
|
||||
"success": "Profil jest teraz twoim kontem."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -834,7 +847,8 @@
|
||||
"durationHoursOnly": "{hours} godz.",
|
||||
"durationMinutesOnly": "{minutes} min",
|
||||
"durationInvalid": "Wpisz czas trwania, np. 7,5, 7:30 lub 7h 30m.",
|
||||
"durationTooLong": "Jeden wpis nie może przekraczać 24 godzin."
|
||||
"durationTooLong": "Jeden wpis nie może przekraczać 24 godzin.",
|
||||
"sourceHealthConnect": "Health Connect"
|
||||
},
|
||||
"metricCatalog": {
|
||||
"title": "Co HealthLog potrafi śledzić",
|
||||
@@ -2550,7 +2564,10 @@
|
||||
"ENV_DAYLIGHT": "światło dzienne",
|
||||
"ENV_PRECIP": "opady",
|
||||
"ENV_PRESSURE_MEAN": "ciśnienie atmosferyczne",
|
||||
"ENV_PRESSURE_DELTA": "dobowa zmiana ciśnienia"
|
||||
"ENV_PRESSURE_DELTA": "dobowa zmiana ciśnienia",
|
||||
"ENV_PM25": "pył drobny (PM2,5)",
|
||||
"ENV_OZONE_8H": "ozon",
|
||||
"ENV_POLLEN_MAX": "pyłki"
|
||||
},
|
||||
"daily": {
|
||||
"high": "Gdy {behaviour} rośnie, {outcome} następnego dnia zwykle wykazuje tendencję do {direction} w Twoich danych — wzorzec wart obserwacji, nie przyczyna.",
|
||||
@@ -2573,6 +2590,11 @@
|
||||
"none": "Nastrój i tętno spoczynkowe nie zmieniają się wyraźnie razem w tym okresie."
|
||||
},
|
||||
"weightWeekday": "Waga w {weekday} jest {delta} kg {direction} Twojej średniej z pozostałych dni — wzorzec wart obserwacji."
|
||||
},
|
||||
"dailySameDay": {
|
||||
"high": "Więcej {behaviour} tego samego dnia i dzień wcześniej idzie w twoich danych zwykle w parze z {direction} {outcome} — to wzorzec wart obserwacji, nie przyczyna.",
|
||||
"moderate": "Więcej {behaviour} tego samego dnia i dzień wcześniej wydaje się, na podstawie dotychczasowych danych, iść w parze z {direction} {outcome} — to wzorzec wart obserwacji, nie przyczyna.",
|
||||
"faint": "Więcej {behaviour} tego samego dnia i dzień wcześniej pokazuje w twoich danych co najwyżej słaby ślad {direction} {outcome} — za mało, by na tym polegać, nigdy przyczyna."
|
||||
}
|
||||
},
|
||||
"noAnalysisYet": "Brak oceny.",
|
||||
@@ -3678,7 +3700,10 @@
|
||||
"endInFuture": "Trening kończyłby się w przyszłości. Ustaw początek na godzinę, o której się zaczął.",
|
||||
"distanceInvalid": "Podaj dystans jako liczbę w realistycznym zakresie.",
|
||||
"energyInvalid": "Podaj energię czynną jako liczbę do 20 000 kcal."
|
||||
}
|
||||
},
|
||||
"editTitle": "Edytuj trening",
|
||||
"editAction": "Edytuj",
|
||||
"updated": "Trening zaktualizowany"
|
||||
}
|
||||
},
|
||||
"bloodGlucose": {
|
||||
@@ -5364,7 +5389,8 @@
|
||||
"respiratoryRate": "Częstość oddechów",
|
||||
"recovery": "Regeneracja",
|
||||
"stress": "Stres",
|
||||
"sleepDebt": "Dług snu"
|
||||
"sleepDebt": "Dług snu",
|
||||
"hrvRmssd": "HRV (RMSSD)"
|
||||
},
|
||||
"sourceLabels": {
|
||||
"WITHINGS": "Withings",
|
||||
@@ -5381,7 +5407,8 @@
|
||||
"MCP": "MCP",
|
||||
"GOOGLE_HEALTH": "Google Health",
|
||||
"STRAVA": "Strava",
|
||||
"EXTERNAL": "Urządzenie zewnętrzne"
|
||||
"EXTERNAL": "Urządzenie zewnętrzne",
|
||||
"HEALTH_CONNECT": "Health Connect"
|
||||
},
|
||||
"deviceLabels": {
|
||||
"watch": "Zegarek",
|
||||
@@ -5618,6 +5645,25 @@
|
||||
"tooManyRows": "Ten plik ma więcej wierszy, niż może przyjąć jeden import. Podziel go i zaimportuj części."
|
||||
},
|
||||
"detail": "Jeden plik obejmuje wszystkie leki, które zawiera."
|
||||
},
|
||||
"healthConnect": {
|
||||
"title": "Eksport z Health Connect",
|
||||
"description": "Zaimportuj plik ZIP, który aplikacja Health Connect na Androidzie tworzy przy eksporcie danych.",
|
||||
"choose": "Wybierz plik ZIP",
|
||||
"uploading": "Przesyłanie…",
|
||||
"uploadFailed": "Przesyłanie nie powiodło się. Spróbuj ponownie.",
|
||||
"tooLarge": "Plik jest większy, niż przyjmuje ten serwer.",
|
||||
"unsupportedVersion": "Ten eksport z Health Connect jest zbyt stary, by go odczytać.",
|
||||
"doneSummary": "Zaimportowano wartości: {count}.",
|
||||
"failed": "Import nie powiódł się: {reason}",
|
||||
"phase": {
|
||||
"queued": "Czeka na start",
|
||||
"unpacking": "Rozpakowywanie pliku",
|
||||
"parsing": "Odczytywanie wpisów",
|
||||
"upserting": "Zapisywanie wartości",
|
||||
"done": "Gotowe",
|
||||
"failed": "Niepowodzenie"
|
||||
}
|
||||
}
|
||||
},
|
||||
"subtitle": "Wyeksportuj dane lub zaimportuj z kopii zapasowej."
|
||||
@@ -5732,7 +5778,17 @@
|
||||
"noResults": "Nie znaleziono pasujących miejsc.",
|
||||
"fetchFailedOne": "Ostatnia aktualizacja pogody w tle nie powiodła się ({when}).",
|
||||
"fetchFailedFew": "Ostatnie {count} aktualizacje pogody w tle nie powiodły się, najnowsza {when}.",
|
||||
"fetchFailedOther": "Ostatnich {count} aktualizacji pogody w tle nie powiodło się, najnowsza {when}."
|
||||
"fetchFailedOther": "Ostatnich {count} aktualizacji pogody w tle nie powiodło się, najnowsza {when}.",
|
||||
"airQuality": {
|
||||
"title": "Jakość powietrza i pyłki",
|
||||
"description": "Codzienna jakość powietrza, pyłki i UV dla twojej lokalizacji, z modelu CAMS przez Open-Meteo.",
|
||||
"toggle": "Uwzględniaj jakość powietrza i pyłki",
|
||||
"operatorDisabled": "Jakość powietrza jest wyłączona na tym serwerze.",
|
||||
"coverage": "Dni z danymi o jakości powietrza: {count}",
|
||||
"pollenEuropeOnly": "Dane o pyłkach obejmują tylko Europę.",
|
||||
"attribution": "Jakość powietrza: serwis monitorowania atmosfery Copernicus (CAMS), przez Open-Meteo.",
|
||||
"saveError": "Nie udało się zapisać ustawienia."
|
||||
}
|
||||
},
|
||||
"score": {
|
||||
"title": "Wynik zdrowia",
|
||||
@@ -6990,8 +7046,15 @@
|
||||
"foreground": "przy otwartej aplikacji",
|
||||
"background": "w tle",
|
||||
"push": "z wybudzenia push",
|
||||
"unknown": "aplikacja nie zgłosiła wyzwalacza"
|
||||
"unknown": "aplikacja nie zgłosiła wyzwalacza",
|
||||
"manual": "przez „Synchronizuj wszystko”"
|
||||
}
|
||||
},
|
||||
"freshness": {
|
||||
"title": "Według typu danych",
|
||||
"lastReceived": "Ostatnio odebrano {relative}",
|
||||
"lastNewValue": "Ostatnia nowa wartość {relative}",
|
||||
"never": "Jeszcze nie odebrano"
|
||||
}
|
||||
},
|
||||
"garminInfo": {
|
||||
@@ -9386,7 +9449,8 @@
|
||||
"sourceRange": {
|
||||
"fromReport": "Zakres z wyniku {range}",
|
||||
"catalogDiffers": "twój zakres {range}"
|
||||
}
|
||||
},
|
||||
"convertedFrom": "Przeliczono z {value} {unit}"
|
||||
},
|
||||
"modules": {
|
||||
"cycle": {
|
||||
@@ -9616,6 +9680,19 @@
|
||||
"overdue": "Dawka przypominająca zaległa od {date}",
|
||||
"dueSoon": "Dawka przypominająca {date}",
|
||||
"current": "Następna dawka przypominająca {date}"
|
||||
},
|
||||
"custom": {
|
||||
"title": "Własne szczepionki",
|
||||
"add": "Dodaj własną szczepionkę",
|
||||
"name": "Nazwa",
|
||||
"components": "Chroni przed",
|
||||
"seriesDoses": "Dawki w szczepieniu podstawowym",
|
||||
"boosterMonths": "Dawka przypominająca co (miesiące)",
|
||||
"saved": "Szczepionka zapisana",
|
||||
"deleted": "Szczepionka usunięta",
|
||||
"deleteConfirm": "Usunąć tę szczepionkę? Zapisane już dawki zachowają swoją nazwę.",
|
||||
"saveFailed": "Nie udało się zapisać szczepionki.",
|
||||
"nameTaken": "Masz już szczepionkę o tej nazwie."
|
||||
}
|
||||
},
|
||||
"illness": {
|
||||
@@ -10528,7 +10605,16 @@
|
||||
"daylight": "Światło dzienne",
|
||||
"precip": "Opady",
|
||||
"pressureMean": "Ciśnienie atmosferyczne",
|
||||
"pressureDelta": "Wahanie ciśnienia"
|
||||
"pressureDelta": "Wahanie ciśnienia",
|
||||
"apparentMax": "Odczuwalne maksimum",
|
||||
"pm25": "Pył drobny (PM2,5)",
|
||||
"pm10": "Pył (PM10)",
|
||||
"no2": "Dwutlenek azotu",
|
||||
"ozone8h": "Ozon (maksimum 8-godzinne)",
|
||||
"europeanAqi": "Europejski indeks jakości powietrza",
|
||||
"uvIndexMax": "Indeks UV (maksimum)",
|
||||
"dustMax": "Pył saharyjski",
|
||||
"pollenMax": "Pyłki (najwyższa wartość)"
|
||||
}
|
||||
},
|
||||
"documents": {
|
||||
@@ -11475,7 +11561,32 @@
|
||||
"guardianErrorFailed": "Nie udało się wysłać zaproszenia.",
|
||||
"errorMfaRequired": "To wymaga drugiego składnika logowania, a Twoje konto jeszcze go nie ma. Skonfiguruj go w Ustawieniach → Bezpieczeństwo i spróbuj ponownie.",
|
||||
"rosterLoadError": "Nie udało się wczytać, kto opiekuje się tą kartą.",
|
||||
"errorRateLimit": "Zbyt wiele profili utworzonych w ostatniej godzinie. Odczekaj chwilę i spróbuj ponownie."
|
||||
"errorRateLimit": "Zbyt wiele profili utworzonych w ostatniej godzinie. Odczekaj chwilę i spróbuj ponownie.",
|
||||
"handover": {
|
||||
"title": "Przekaż ten profil",
|
||||
"description": "Daj osobie {name} własne logowanie. Dane zostają tam, gdzie są; ty proponujesz, jaki dostęp zachowa każdy opiekun, a {name} decyduje.",
|
||||
"create": "Utwórz link przekazania",
|
||||
"expiryLabel": "Link ważny przez",
|
||||
"expiry1": "1 dzień",
|
||||
"expiry7": "7 dni",
|
||||
"expiry14": "14 dni",
|
||||
"proposalLabel": "Proponowany dostęp potem",
|
||||
"proposal": {
|
||||
"end": "Zakończ dostęp",
|
||||
"read": "Tylko podgląd",
|
||||
"manage": "Dalej zarządzaj"
|
||||
},
|
||||
"linkTitle": "Link przekazania",
|
||||
"linkOnce": "Ten link jest pokazywany tylko raz. Przekaż go osobie {name} bezpośrednio albo pozwól zeskanować kod.",
|
||||
"copy": "Kopiuj link",
|
||||
"copied": "Link skopiowany",
|
||||
"pending": "Link przekazania otwarty do {date}",
|
||||
"revoke": "Wycofaj link",
|
||||
"revoked": "Link wycofany",
|
||||
"errorFailed": "Nie udało się utworzyć linku przekazania.",
|
||||
"oidcOnly": "Przekazanie profilu jest niedostępne, dopóki ten serwer pozwala tylko na logowanie jednokrotne.",
|
||||
"minorHint": "{name} ma mniej niż 16 lat. Warto rozważyć zachowanie dostępu do podglądu."
|
||||
}
|
||||
},
|
||||
"toast": {
|
||||
"savedTo": "Zapisano w karcie użytkownika {name}"
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Android Health Connect import provenance (#972).
|
||||
--
|
||||
-- Rows written by the Health Connect import worker carry their own
|
||||
-- `MeasurementSource` value, so they stay distinguishable from the Apple
|
||||
-- Health and generic IMPORT rows and get an isolated idempotency namespace
|
||||
-- `(userId, type, HEALTH_CONNECT, externalId)`.
|
||||
--
|
||||
-- Purely additive enum extension, alone in its migration: Postgres forbids
|
||||
-- USING a freshly added value in the same transaction, and nothing here uses
|
||||
-- it. `ADD VALUE IF NOT EXISTS` makes a rerun safe. Mirrors 0346 (EXTERNAL).
|
||||
--
|
||||
-- Reversibility: Postgres cannot remove an enum value, so `HEALTH_CONNECT`
|
||||
-- stays (inert with no rows).
|
||||
ALTER TYPE "measurement_source" ADD VALUE IF NOT EXISTS 'HEALTH_CONNECT';
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Which importer owns an `import_jobs` row (#972).
|
||||
--
|
||||
-- Until now every row was an Apple Health export; the Health Connect importer
|
||||
-- shares the table. The reconcile cron and the staged-file sweep scope by
|
||||
-- `kind` so one importer never fails or deletes the other's running job.
|
||||
-- Every existing row is an Apple Health job, which the default records.
|
||||
ALTER TABLE "import_jobs" ADD COLUMN "kind" TEXT NOT NULL DEFAULT 'apple_health';
|
||||
|
||||
CREATE INDEX "import_jobs_user_id_kind_started_at_idx" ON "import_jobs"("user_id", "kind", "started_at");
|
||||
@@ -0,0 +1,40 @@
|
||||
-- Air quality, pollen and UV per environment day (#615).
|
||||
--
|
||||
-- Twenty-two nullable columns filled from the Open-Meteo air-quality feed
|
||||
-- (CAMS Europe / global). Nullable throughout: a partial feed stores what it
|
||||
-- has, and a day the feed never covered stays NULL rather than zero.
|
||||
-- `aq_fetched_at` NULL marks a day whose air-quality part has not been
|
||||
-- fetched yet; the partial gap index serves the nightly fill's lookup.
|
||||
--
|
||||
-- `users.environment_air_quality_enabled` is the per-user switch, on by
|
||||
-- default; the operator switch `ENVIRONMENT_AIR_QUALITY_DISABLED` wins.
|
||||
ALTER TABLE "environment_contexts"
|
||||
ADD COLUMN "apparent_max" DOUBLE PRECISION,
|
||||
ADD COLUMN "pm25_mean" DOUBLE PRECISION,
|
||||
ADD COLUMN "pm25_max" DOUBLE PRECISION,
|
||||
ADD COLUMN "pm10_mean" DOUBLE PRECISION,
|
||||
ADD COLUMN "no2_mean" DOUBLE PRECISION,
|
||||
ADD COLUMN "so2_mean" DOUBLE PRECISION,
|
||||
ADD COLUMN "co_mean" DOUBLE PRECISION,
|
||||
ADD COLUMN "o3_max_8h" DOUBLE PRECISION,
|
||||
ADD COLUMN "eaqi_max" DOUBLE PRECISION,
|
||||
ADD COLUMN "usaqi_max" DOUBLE PRECISION,
|
||||
ADD COLUMN "uv_index_max" DOUBLE PRECISION,
|
||||
ADD COLUMN "dust_max" DOUBLE PRECISION,
|
||||
ADD COLUMN "aod_max" DOUBLE PRECISION,
|
||||
ADD COLUMN "pollen_alder_max" DOUBLE PRECISION,
|
||||
ADD COLUMN "pollen_birch_max" DOUBLE PRECISION,
|
||||
ADD COLUMN "pollen_grass_max" DOUBLE PRECISION,
|
||||
ADD COLUMN "pollen_mugwort_max" DOUBLE PRECISION,
|
||||
ADD COLUMN "pollen_olive_max" DOUBLE PRECISION,
|
||||
ADD COLUMN "pollen_ragweed_max" DOUBLE PRECISION,
|
||||
ADD COLUMN "aq_domain" TEXT,
|
||||
ADD COLUMN "aq_hours" INTEGER,
|
||||
ADD COLUMN "aq_fetched_at" TIMESTAMP(3);
|
||||
|
||||
CREATE INDEX "environment_contexts_aq_gap_idx"
|
||||
ON "environment_contexts"("user_id", "date")
|
||||
WHERE "aq_fetched_at" IS NULL;
|
||||
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN "environment_air_quality_enabled" BOOLEAN NOT NULL DEFAULT true;
|
||||
@@ -0,0 +1,36 @@
|
||||
-- Environment locations sealed at rest (#615).
|
||||
--
|
||||
-- The home location, each dated location period and the per-day resolved
|
||||
-- location gain a sealed copy (lat, lon, label under AAD
|
||||
-- `healthlog/environment-location/v1`). The free-text encryption backfill
|
||||
-- fills it from the plaintext and then clears the plaintext; the plaintext
|
||||
-- columns therefore become nullable here and drop in a later release, once
|
||||
-- the backfill reports zero remaining rows everywhere.
|
||||
--
|
||||
-- The partial indexes serve the backfill's discovery ("rows still holding
|
||||
-- plaintext") and empty themselves as it runs.
|
||||
ALTER TABLE "users" ADD COLUMN "home_location_encrypted" BYTEA;
|
||||
|
||||
ALTER TABLE "environment_travel_locations"
|
||||
ADD COLUMN "location_encrypted" BYTEA,
|
||||
ALTER COLUMN "lat" DROP NOT NULL,
|
||||
ALTER COLUMN "lon" DROP NOT NULL,
|
||||
ALTER COLUMN "label" DROP NOT NULL;
|
||||
|
||||
ALTER TABLE "environment_contexts"
|
||||
ADD COLUMN "location_encrypted" BYTEA,
|
||||
ALTER COLUMN "lat" DROP NOT NULL,
|
||||
ALTER COLUMN "lon" DROP NOT NULL,
|
||||
ALTER COLUMN "location_label" DROP NOT NULL;
|
||||
|
||||
CREATE INDEX "users_home_location_plain_idx"
|
||||
ON "users"("id")
|
||||
WHERE "home_lat" IS NOT NULL;
|
||||
|
||||
CREATE INDEX "environment_travel_locations_plain_location_idx"
|
||||
ON "environment_travel_locations"("id")
|
||||
WHERE "lat" IS NOT NULL;
|
||||
|
||||
CREATE INDEX "environment_contexts_plain_location_idx"
|
||||
ON "environment_contexts"("id")
|
||||
WHERE "lat" IS NOT NULL;
|
||||
@@ -0,0 +1,32 @@
|
||||
-- Managed profile handover links (#959).
|
||||
--
|
||||
-- A guardian mints a one-time link that hands a managed profile over to the
|
||||
-- person it describes. Only the HMAC-SHA256 hash of the token is stored.
|
||||
-- `proposals_json` carries the guardian-side proposal per guardian (end,
|
||||
-- read, manage) that the new owner confirms when claiming. The partial
|
||||
-- unique index allows at most one open (unused, unrevoked) link per profile.
|
||||
CREATE TABLE "managed_profile_handovers" (
|
||||
"id" TEXT NOT NULL,
|
||||
"profile_id" TEXT NOT NULL,
|
||||
"created_by_id" TEXT NOT NULL,
|
||||
"token_hash" TEXT NOT NULL,
|
||||
"proposals_json" JSONB NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||
"used_at" TIMESTAMP(3),
|
||||
"revoked_at" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "managed_profile_handovers_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "managed_profile_handovers_token_hash_key" ON "managed_profile_handovers"("token_hash");
|
||||
|
||||
CREATE INDEX "managed_profile_handovers_profile_id_idx" ON "managed_profile_handovers"("profile_id");
|
||||
|
||||
CREATE UNIQUE INDEX "managed_profile_handovers_open_idx"
|
||||
ON "managed_profile_handovers"("profile_id")
|
||||
WHERE "used_at" IS NULL AND "revoked_at" IS NULL;
|
||||
|
||||
ALTER TABLE "managed_profile_handovers" ADD CONSTRAINT "managed_profile_handovers_profile_id_fkey" FOREIGN KEY ("profile_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "managed_profile_handovers" ADD CONSTRAINT "managed_profile_handovers_created_by_id_fkey" FOREIGN KEY ("created_by_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Per-HealthKit-type arrival ledger (#1173).
|
||||
--
|
||||
-- One row per (user, type), upserted by the measurement batch route: when the
|
||||
-- type last arrived, under which `syncTrigger`, and when it last brought a new
|
||||
-- or updated sample. Diagnostic state the next sync rebuilds; not backed up.
|
||||
CREATE TABLE "healthkit_type_syncs" (
|
||||
"user_id" TEXT NOT NULL,
|
||||
"type" "measurement_type" NOT NULL,
|
||||
"last_received_at" TIMESTAMP(3) NOT NULL,
|
||||
"last_trigger" TEXT,
|
||||
"last_new_sample_at" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "healthkit_type_syncs_pkey" PRIMARY KEY ("user_id","type")
|
||||
);
|
||||
|
||||
ALTER TABLE "healthkit_type_syncs" ADD CONSTRAINT "healthkit_type_syncs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Lab unit conversion provenance (#1095).
|
||||
--
|
||||
-- `biomarkers.analyte_key` names the catalogue analyte a marker resolves to,
|
||||
-- which selects its unit-conversion table. `lab_results.source_value` /
|
||||
-- `source_unit` keep the value and unit as printed on the report when a write
|
||||
-- converted the reading into the marker's canonical unit. All nullable; no
|
||||
-- existing row changes.
|
||||
ALTER TABLE "biomarkers" ADD COLUMN "analyte_key" TEXT;
|
||||
|
||||
ALTER TABLE "lab_results"
|
||||
ADD COLUMN "source_value" DOUBLE PRECISION,
|
||||
ADD COLUMN "source_unit" TEXT;
|
||||
@@ -0,0 +1,29 @@
|
||||
-- User-defined vaccines (#1005).
|
||||
--
|
||||
-- A vaccine the user defines for a product the shipped catalogue does not
|
||||
-- list, in the same shape a catalogue entry resolves to. A dose links to it
|
||||
-- through `vaccination_records.custom_vaccine_id`; deleting the definition
|
||||
-- sets the link to NULL and the dose stays on its `vaccine_name`.
|
||||
CREATE TABLE "custom_vaccines" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"components" TEXT[],
|
||||
"typical_series_doses" INTEGER,
|
||||
"booster_interval_months" INTEGER,
|
||||
"deleted_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "custom_vaccines_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "custom_vaccines_user_id_name_key" ON "custom_vaccines"("user_id", "name");
|
||||
|
||||
ALTER TABLE "custom_vaccines" ADD CONSTRAINT "custom_vaccines_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "vaccination_records" ADD COLUMN "custom_vaccine_id" TEXT;
|
||||
|
||||
CREATE INDEX "vaccination_records_custom_vaccine_id_idx" ON "vaccination_records"("custom_vaccine_id");
|
||||
|
||||
ALTER TABLE "vaccination_records" ADD CONSTRAINT "vaccination_records_custom_vaccine_id_fkey" FOREIGN KEY ("custom_vaccine_id") REFERENCES "custom_vaccines"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
+234
-34
@@ -64,9 +64,11 @@ model User {
|
||||
///
|
||||
/// A timestamp rather than a boolean: it says since when, and preserves a
|
||||
/// future-compatible null state without adding a transition surface here.
|
||||
/// This release does not expose a way to clear the marker or attach
|
||||
/// credentials; the grants and activity trail remain intact if a later,
|
||||
/// separately designed handover ever needs that state.
|
||||
/// Cleared by the handover flow (`ManagedProfileHandover`): when the
|
||||
/// person the profile describes claims it with credentials of their own,
|
||||
/// the marker goes back to null in the same transaction that settles the
|
||||
/// guardians' grants, so the account becomes an ordinary adult account.
|
||||
/// The grants and activity trail stay intact across that transition.
|
||||
///
|
||||
/// Consumers land in this release: the guardian resolver in
|
||||
/// `src/lib/api-handler.ts` reads it on every guardian request, and the
|
||||
@@ -91,16 +93,26 @@ model User {
|
||||
// without a home set) simply has no environment rows. `homeTimezone` anchors
|
||||
// the day-key an observation is stored under (it can differ from the display
|
||||
// `timezone` above for an account that tracks weather for another locale).
|
||||
homeLat Float? @map("home_lat")
|
||||
homeLon Float? @map("home_lon")
|
||||
homeLabel String? @map("home_label")
|
||||
homeTimezone String? @map("home_timezone")
|
||||
homeLat Float? @map("home_lat")
|
||||
homeLon Float? @map("home_lon")
|
||||
homeLabel String? @map("home_label")
|
||||
homeTimezone String? @map("home_timezone")
|
||||
// v1.25 — effective-from instant for the home location. Stamped the moment a
|
||||
// home is set/updated. Day resolution attributes a past day to home ONLY when
|
||||
// the day is on/after this instant; earlier days are skipped (never fabricated
|
||||
// from the current home). To fill the deep past the user adds an explicit
|
||||
// dated location period instead. Backfill defaults its start to this instant.
|
||||
homeSince DateTime? @map("home_since")
|
||||
homeSince DateTime? @map("home_since")
|
||||
/// @pending-consumer(#615) v1.42 — sealed home location (lat, lon, label)
|
||||
/// under AAD `healthlog/environment-location/v1`. Replaces the plaintext
|
||||
/// `homeLat` / `homeLon` / `homeLabel` once the free-text encryption backfill
|
||||
/// reports zero remaining rows; the plaintext columns drop in v1.43.
|
||||
homeLocationEncrypted Bytes? @map("home_location_encrypted")
|
||||
/// @pending-consumer(#615) v1.42 — per-user switch for the air-quality and
|
||||
/// pollen part of the environment module. On by default; off means the
|
||||
/// nightly fetch skips the air-quality feed and the surfaces leave the
|
||||
/// fields out. The operator switch `ENVIRONMENT_AIR_QUALITY_DISABLED` wins.
|
||||
environmentAirQualityEnabled Boolean @default(true) @map("environment_air_quality_enabled")
|
||||
|
||||
// Codex OAuth (ChatGPT subscription)
|
||||
codexAccessTokenEncrypted String? @map("codex_access_token_encrypted")
|
||||
@@ -971,6 +983,15 @@ model User {
|
||||
// `vaccinations` module key, which governs the surfaces and not the store.
|
||||
vaccinationRecords VaccinationRecord[]
|
||||
vaccinationDocumentLinks VaccinationDocumentLink[]
|
||||
// v1.42 — user-defined vaccines for products the shipped catalogue does
|
||||
// not list (#1005).
|
||||
customVaccines CustomVaccine[]
|
||||
// v1.42 — per-HealthKit-type arrival ledger (#1173).
|
||||
healthKitTypeSyncs HealthKitTypeSync[]
|
||||
// v1.42 — managed-profile handover links (#959): the profile being handed
|
||||
// over, and the guardian who minted the link.
|
||||
handoversOfProfile ManagedProfileHandover[] @relation("HandoverProfile")
|
||||
handoversCreated ManagedProfileHandover[] @relation("HandoverCreator")
|
||||
|
||||
// v1.7.0 profile — optional patient-identity fields surfaced on the
|
||||
// health-record export cover (PDF) and the FHIR `Patient` resource.
|
||||
@@ -1074,16 +1095,21 @@ model EnvironmentContext {
|
||||
date String
|
||||
|
||||
/// Coarse resolved location for the day (lat/lon rounded to 1 decimal, about
|
||||
/// 11 km, + label + the source that won precedence).
|
||||
lat Float
|
||||
lon Float
|
||||
/// 11 km, + label + the source that won precedence). Nullable since v1.42:
|
||||
/// the sealed copy in `locationEncrypted` replaces the plaintext once the
|
||||
/// encryption backfill has run; the plaintext columns drop in v1.43.
|
||||
lat Float?
|
||||
lon Float?
|
||||
/// @internal: human-readable label of the resolved location, stored as
|
||||
/// operator/debug provenance beside the coordinates; surfaces select the
|
||||
/// numeric and weather columns only, never this label. The backup selects it
|
||||
/// (`src/lib/export/environment-backup.ts`) with the rest of the row: a
|
||||
/// restored reading has to keep saying where it was read.
|
||||
locationLabel String @map("location_label")
|
||||
source EnvironmentLocationSource
|
||||
locationLabel String? @map("location_label")
|
||||
/// @pending-consumer(#615) v1.42 — sealed (lat, lon, label) for the day,
|
||||
/// AAD `healthlog/environment-location/v1`.
|
||||
locationEncrypted Bytes? @map("location_encrypted")
|
||||
source EnvironmentLocationSource
|
||||
|
||||
/// Daily temperature (°C). Min doubles as the overnight-low proxy.
|
||||
tempMin Float? @map("temp_min")
|
||||
@@ -1111,6 +1137,54 @@ model EnvironmentContext {
|
||||
/// WMO weather code for the day (descriptive condition lookup).
|
||||
weatherCode Int? @map("weather_code")
|
||||
|
||||
// v1.42 (#615) — air quality, pollen and UV from the Open-Meteo
|
||||
// air-quality feed (CAMS). All nullable: a partial or absent feed stores
|
||||
// what it has, and a day the feed never covered stays NULL, never zero.
|
||||
/// @pending-consumer(#615) Daily apparent ("feels-like") maximum (°C).
|
||||
apparentMax Float? @map("apparent_max")
|
||||
/// @pending-consumer(#615) PM2.5 daily mean (µg/m³).
|
||||
pm25Mean Float? @map("pm25_mean")
|
||||
/// @pending-consumer(#615) PM2.5 hourly maximum over the local day (µg/m³).
|
||||
pm25Max Float? @map("pm25_max")
|
||||
/// @pending-consumer(#615) PM10 daily mean (µg/m³).
|
||||
pm10Mean Float? @map("pm10_mean")
|
||||
/// @pending-consumer(#615) Nitrogen dioxide daily mean (µg/m³).
|
||||
no2Mean Float? @map("no2_mean")
|
||||
/// @pending-consumer(#615) Sulphur dioxide daily mean (µg/m³).
|
||||
so2Mean Float? @map("so2_mean")
|
||||
/// @pending-consumer(#615) Carbon monoxide daily mean (µg/m³).
|
||||
coMean Float? @map("co_mean")
|
||||
/// @pending-consumer(#615) Ozone, maximum 8-hour running mean of the day (µg/m³).
|
||||
o3Max8h Float? @map("o3_max_8h")
|
||||
/// @pending-consumer(#615) European AQI, daily maximum.
|
||||
eaqiMax Float? @map("eaqi_max")
|
||||
/// @pending-consumer(#615) US AQI, daily maximum.
|
||||
usaqiMax Float? @map("usaqi_max")
|
||||
/// @pending-consumer(#615) UV index, daily maximum.
|
||||
uvIndexMax Float? @map("uv_index_max")
|
||||
/// @pending-consumer(#615) Saharan dust, daily maximum (µg/m³).
|
||||
dustMax Float? @map("dust_max")
|
||||
/// @pending-consumer(#615) Aerosol optical depth, daily maximum.
|
||||
aodMax Float? @map("aod_max")
|
||||
/// @pending-consumer(#615) Alder pollen, daily maximum (grains/m³, Europe only).
|
||||
pollenAlderMax Float? @map("pollen_alder_max")
|
||||
/// @pending-consumer(#615) Birch pollen, daily maximum (grains/m³, Europe only).
|
||||
pollenBirchMax Float? @map("pollen_birch_max")
|
||||
/// @pending-consumer(#615) Grass pollen, daily maximum (grains/m³, Europe only).
|
||||
pollenGrassMax Float? @map("pollen_grass_max")
|
||||
/// @pending-consumer(#615) Mugwort pollen, daily maximum (grains/m³, Europe only).
|
||||
pollenMugwortMax Float? @map("pollen_mugwort_max")
|
||||
/// @pending-consumer(#615) Olive pollen, daily maximum (grains/m³, Europe only).
|
||||
pollenOliveMax Float? @map("pollen_olive_max")
|
||||
/// @pending-consumer(#615) Ragweed pollen, daily maximum (grains/m³, Europe only).
|
||||
pollenRagweedMax Float? @map("pollen_ragweed_max")
|
||||
/// @pending-consumer(#615) Which air-quality model domain served the day (`cams_europe` / `cams_global`).
|
||||
aqDomain String? @map("aq_domain")
|
||||
/// @pending-consumer(#615) Hourly air-quality samples the day was aggregated from (75 % rule).
|
||||
aqHours Int? @map("aq_hours")
|
||||
/// @pending-consumer(#615) When the air-quality part was last fetched; NULL = not yet fetched (gap index).
|
||||
aqFetchedAt DateTime? @map("aq_fetched_at")
|
||||
|
||||
/// When the row was last fetched/refreshed from the upstream feed.
|
||||
fetchedAt DateTime @default(now()) @map("fetched_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
@@ -1118,6 +1192,9 @@ model EnvironmentContext {
|
||||
|
||||
@@unique([userId, date])
|
||||
@@index([userId, date])
|
||||
// `environment_contexts_aq_gap_idx` (user_id, date) WHERE aq_fetched_at IS
|
||||
// NULL and `environment_contexts_plain_location_idx` (id) WHERE lat IS NOT
|
||||
// NULL are partial indexes from migrations 0381 / 0382 (raw SQL only).
|
||||
@@map("environment_contexts")
|
||||
}
|
||||
|
||||
@@ -1138,14 +1215,22 @@ model EnvironmentTravelLocation {
|
||||
startDate String @map("start_date")
|
||||
endDate String @map("end_date")
|
||||
|
||||
lat Float
|
||||
lon Float
|
||||
label String
|
||||
/// Plaintext coarse location. Nullable since v1.42: the sealed copy in
|
||||
/// `locationEncrypted` replaces it once the encryption backfill has run;
|
||||
/// the plaintext columns drop in v1.43.
|
||||
lat Float?
|
||||
lon Float?
|
||||
label String?
|
||||
/// @pending-consumer(#615) v1.42 — sealed (lat, lon, label) for the
|
||||
/// period, AAD `healthlog/environment-location/v1`.
|
||||
locationEncrypted Bytes? @map("location_encrypted")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@index([userId, startDate, endDate])
|
||||
// `environment_travel_locations_plain_location_idx` (id) WHERE lat IS NOT
|
||||
// NULL is a partial index from migration 0382 (raw SQL only).
|
||||
@@map("environment_travel_locations")
|
||||
}
|
||||
|
||||
@@ -1791,6 +1876,12 @@ enum MeasurementSource {
|
||||
// user's own. iOS DTO must mirror this exact spelling so the
|
||||
// /api/sync/changes delta feed decodes the rows.
|
||||
EXTERNAL
|
||||
// v1.42 — rows imported from an Android Health Connect export (#972).
|
||||
// Server-owned: written only by the Health Connect import worker, never
|
||||
// by a client, so the batch + single-POST surfaces reject it like IMPORT.
|
||||
// iOS DTO must mirror this spelling so the /api/sync/changes delta feed
|
||||
// decodes the rows.
|
||||
HEALTH_CONNECT
|
||||
|
||||
@@map("measurement_source")
|
||||
}
|
||||
@@ -3814,6 +3905,63 @@ model InviteToken {
|
||||
@@map("invite_tokens")
|
||||
}
|
||||
|
||||
/// v1.42 (#959) — a one-time link that hands a managed profile over to the
|
||||
/// person it describes. Minted by a guardian with MANAGE on the profile; the
|
||||
/// token (`hlp_` + 64 hex) is shown once and only its HMAC-SHA256 hash is
|
||||
/// stored, the `InviteToken` scheme. Claiming sets credentials on the
|
||||
/// profile's own `User` row (no data moves), clears `managedProfileAt` and
|
||||
/// settles each guardian's grant by the proposal recorded here. At most one
|
||||
/// open link per profile (partial unique index in migration 0383).
|
||||
model ManagedProfileHandover {
|
||||
id String @id @default(cuid())
|
||||
/// The managed profile (`User` row with `managedProfileAt` set).
|
||||
profileId String @map("profile_id")
|
||||
/// The guardian who minted the link.
|
||||
createdById String @map("created_by_id")
|
||||
/// @pending-consumer(#959) HMAC-SHA256 of the token, unique.
|
||||
tokenHash String @unique @map("token_hash")
|
||||
/// @pending-consumer(#959) Per-guardian proposal (end / read / manage) the
|
||||
/// new owner confirms or changes when claiming.
|
||||
proposalsJson Json @map("proposals_json")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
/// @pending-consumer(#959) Link expiry (1, 7 or 14 days after minting).
|
||||
expiresAt DateTime @map("expires_at")
|
||||
/// @pending-consumer(#959) Set when the profile was claimed through the link.
|
||||
usedAt DateTime? @map("used_at")
|
||||
/// @pending-consumer(#959) Set when a guardian withdrew the link.
|
||||
revokedAt DateTime? @map("revoked_at")
|
||||
|
||||
profile User @relation("HandoverProfile", fields: [profileId], references: [id], onDelete: Cascade)
|
||||
createdBy User @relation("HandoverCreator", fields: [createdById], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([profileId])
|
||||
// `managed_profile_handovers_open_idx` UNIQUE (profile_id) WHERE used_at IS
|
||||
// NULL AND revoked_at IS NULL is a partial index from migration 0383.
|
||||
@@map("managed_profile_handovers")
|
||||
}
|
||||
|
||||
/// v1.42 (#1173) — when each HealthKit type last arrived through the batch
|
||||
/// ingest, and by which trigger. One row per (user, type), upserted by the
|
||||
/// batch route; read by the Apple Health card's per-type freshness list.
|
||||
/// Diagnostic state: rebuilt by the next sync, so it is not backed up.
|
||||
model HealthKitTypeSync {
|
||||
userId String @map("user_id")
|
||||
type MeasurementType
|
||||
/// @pending-consumer(#1173) Last batch that carried this type.
|
||||
lastReceivedAt DateTime @map("last_received_at")
|
||||
/// @pending-consumer(#1173) `syncTrigger` of that batch
|
||||
/// (`foreground` / `background` / `push` / `manual`), NULL when absent.
|
||||
lastTrigger String? @map("last_trigger")
|
||||
/// @pending-consumer(#1173) Last batch that inserted or updated a sample of
|
||||
/// this type (as opposed to sending only duplicates).
|
||||
lastNewSampleAt DateTime? @map("last_new_sample_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([userId, type])
|
||||
@@map("healthkit_type_syncs")
|
||||
}
|
||||
|
||||
// v1.16.0 — one row per signup an invite admitted. `InviteToken.usedBy`
|
||||
// keeps only the LAST consumer (it overwrites on multi-use invites);
|
||||
// this ledger carries the full history so the admin table can show
|
||||
@@ -4958,8 +5106,8 @@ model AppSettings {
|
||||
// `src/lib/ai/reasoning/resolve.ts` and published per record on
|
||||
// `GET /api/auth/me` as `coachReasoning`. Defaults keep reasoning on with no
|
||||
// cap, so an upgrade needs no admin action.
|
||||
aiReasoningEnabled Boolean @default(true) @map("ai_reasoning_enabled")
|
||||
aiReasoningMaxEffort String @default("high") @map("ai_reasoning_max_effort")
|
||||
aiReasoningEnabled Boolean @default(true) @map("ai_reasoning_enabled")
|
||||
aiReasoningMaxEffort String @default("high") @map("ai_reasoning_max_effort")
|
||||
|
||||
// v1.18.0 — operator-level server-wide module availability. A DISABLED
|
||||
// allowlist mirroring the per-user `User.modulePreferencesJson`: absent /
|
||||
@@ -5439,6 +5587,11 @@ model ImportJob {
|
||||
pgBossJobId String? @unique @map("pg_boss_job_id")
|
||||
/// `queued | unpacking | parsing | upserting | done | failed`.
|
||||
status String @default("queued")
|
||||
/// @pending-consumer(#972) v1.42 — which importer owns the row:
|
||||
/// `apple_health` (every row before v1.42) or `health_connect`. The
|
||||
/// reconcile cron and the staged-file sweep scope by it so one importer
|
||||
/// never fails or deletes the other's running job.
|
||||
kind String @default("apple_health")
|
||||
/// Free-text reason on `failed`; null otherwise.
|
||||
failureReason String? @map("failure_reason")
|
||||
/// Bytes uploaded — surfaced to the operator audit trail.
|
||||
@@ -5486,6 +5639,7 @@ model ImportJob {
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId, startedAt])
|
||||
@@index([userId, kind, startedAt])
|
||||
@@index([status])
|
||||
@@map("import_jobs")
|
||||
}
|
||||
@@ -5929,6 +6083,13 @@ model LabResult {
|
||||
sourceReferenceLow Float? @map("source_reference_low")
|
||||
sourceReferenceHigh Float? @map("source_reference_high")
|
||||
sourceReferenceText String? @map("source_reference_text")
|
||||
/// @pending-consumer(#1095) v1.42 — the value as printed on the source
|
||||
/// report, kept when a write converted it into the biomarker's canonical
|
||||
/// unit. NULL when no conversion happened. A later edit of `value` clears it.
|
||||
sourceValue Float? @map("source_value")
|
||||
/// @pending-consumer(#1095) v1.42 — the unit the source report printed,
|
||||
/// paired with `sourceValue`.
|
||||
sourceUnit String? @map("source_unit")
|
||||
/// When the sample was taken (UTC instant; wall-clock anchoring is the
|
||||
/// reader's concern per the schema's UTC-only convention).
|
||||
takenAt DateTime @map("taken_at")
|
||||
@@ -5997,6 +6158,10 @@ model Biomarker {
|
||||
/// and from the lab-entry pickers, but never deleted — its readings and
|
||||
/// canonical unit/range definition survive.
|
||||
hidden Boolean @default(false) @map("hidden")
|
||||
/// @pending-consumer(#1095) v1.42 — the catalogue analyte this marker
|
||||
/// resolves to (alias list in `src/lib/labs/biomarker-catalog.ts`), which
|
||||
/// selects the unit-conversion table. NULL = not resolved; no conversion.
|
||||
analyteKey String? @map("analyte_key")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@ -9120,13 +9285,13 @@ model EncounterConditionLink {
|
||||
/// module is off; these data routes stay exempt so a restore or an import
|
||||
/// keeps working and re-enabling finds every row intact.
|
||||
model VaccinationRecord {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
/// The day the dose was given, at UTC midnight. An Impfpass carries dates,
|
||||
/// never times, so the time component is not meaningful and is not
|
||||
/// collected. The API refuses future instants: a vaccination is logged
|
||||
/// after the fact, and a planned booster is a reminder, not a record.
|
||||
occurredAt DateTime @map("occurred_at")
|
||||
occurredAt DateTime @map("occurred_at")
|
||||
/// Catalogue identity. NULL = a free-text-only row, which is the arm that
|
||||
/// makes a 1987 Pass line loggable at all.
|
||||
///
|
||||
@@ -9136,48 +9301,83 @@ model VaccinationRecord {
|
||||
/// resolve, but a restore writes whatever the backup held. A slug that
|
||||
/// stops resolving degrades to the free-text arm — a tested behaviour, not
|
||||
/// an accident (`src/lib/vaccinations/__tests__/catalog-integrity.test.ts`).
|
||||
antigenSlug String? @map("antigen_slug")
|
||||
antigenSlug String? @map("antigen_slug")
|
||||
/// The Pass's own wording, verbatim. May be a trade name; that is user
|
||||
/// data. Plaintext and bounded so the list can render and search it.
|
||||
vaccineName String? @map("vaccine_name")
|
||||
vaccineName String? @map("vaccine_name")
|
||||
/// The Pass's claim about which dose this is. Optional — the series
|
||||
/// derivation falls back to the chronological index per component antigen.
|
||||
doseNumber Int? @map("dose_number")
|
||||
doseNumber Int? @map("dose_number")
|
||||
/// Per-record override of the catalogue's typical series length, for the
|
||||
/// schedule a person was actually given.
|
||||
seriesDoses Int? @map("series_doses")
|
||||
seriesDoses Int? @map("series_doses")
|
||||
/// Chargenbezeichnung (IfSG §22). A batch code, not a secret: plaintext and
|
||||
/// bounded so the list and the report can render it without a decrypt.
|
||||
lotNumber String? @map("lot_number")
|
||||
site VaccinationSite?
|
||||
practitionerId String? @map("practitioner_id")
|
||||
encounterId String? @map("encounter_id")
|
||||
lotNumber String? @map("lot_number")
|
||||
site VaccinationSite?
|
||||
practitionerId String? @map("practitioner_id")
|
||||
encounterId String? @map("encounter_id")
|
||||
/// The booster reminder this dose satisfied, when one matched. The act runs
|
||||
/// through `satisfyReminder`; this column is the record of it, not the act.
|
||||
/// One id for possibly several satisfied rows — the engine keeps
|
||||
/// `lastSatisfiedAt` per reminder, this keeps the first match in catalogue
|
||||
/// component order.
|
||||
reminderId String? @map("reminder_id")
|
||||
reminderId String? @map("reminder_id")
|
||||
/// @pending-consumer(#1005) v1.42 — the user's own vaccine definition when
|
||||
/// the dose is for a product the catalogue does not list. Catalogue wins
|
||||
/// when both resolve. SetNull on delete: the dose keeps its `vaccineName`.
|
||||
customVaccineId String? @map("custom_vaccine_id")
|
||||
/// The one free-text field (AES-256-GCM at rest, the `*Encrypted` Bytes
|
||||
/// convention). Impfreaktionen live here in v1 rather than in a structured
|
||||
/// reaction model nobody has asked for yet.
|
||||
noteEncrypted Bytes? @map("note_encrypted")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
noteEncrypted Bytes? @map("note_encrypted")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
/// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant.
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
practitioner Practitioner? @relation(fields: [practitionerId], references: [id], onDelete: SetNull)
|
||||
encounter Encounter? @relation(fields: [encounterId], references: [id], onDelete: SetNull)
|
||||
reminder MeasurementReminder? @relation(fields: [reminderId], references: [id], onDelete: SetNull)
|
||||
customVaccine CustomVaccine? @relation(fields: [customVaccineId], references: [id], onDelete: SetNull)
|
||||
documentLinks VaccinationDocumentLink[]
|
||||
|
||||
@@index([userId, deletedAt, occurredAt])
|
||||
@@index([userId, antigenSlug, occurredAt])
|
||||
@@index([customVaccineId])
|
||||
@@map("vaccination_records")
|
||||
}
|
||||
|
||||
/// v1.42 (#1005) — a vaccine the user defines for a product the shipped
|
||||
/// catalogue does not list. Same shape the resolver hands out for a
|
||||
/// catalogue entry (`components` = antigen slugs, typical series length,
|
||||
/// booster interval), so series derivation and booster matching treat both
|
||||
/// alike. Soft-deleted; a deleted definition leaves its doses standing on
|
||||
/// their `vaccineName`.
|
||||
model CustomVaccine {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
/// @pending-consumer(#1005) Display name, unique per user.
|
||||
name String
|
||||
/// @pending-consumer(#1005) Antigen slugs the product covers.
|
||||
components String[]
|
||||
/// @pending-consumer(#1005) Typical primary-series length.
|
||||
typicalSeriesDoses Int? @map("typical_series_doses")
|
||||
/// @pending-consumer(#1005) Booster interval in months; NULL = no booster.
|
||||
boosterIntervalMonths Int? @map("booster_interval_months")
|
||||
/// Soft-delete tombstone. NULL = live.
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
records VaccinationRecord[]
|
||||
|
||||
@@unique([userId, name])
|
||||
@@map("custom_vaccines")
|
||||
}
|
||||
|
||||
/// Where the dose went. Small and closed, because the Pass's own vocabulary
|
||||
/// is small and closed; OTHER absorbs the rest rather than growing the enum.
|
||||
enum VaccinationSite {
|
||||
|
||||
@@ -186,6 +186,40 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Environmental context (Open-Meteo)",
|
||||
"description": "Endpoints and switches for the opt-in Environment module. Every variable is optional: unset uses the hosted, keyless Open-Meteo services. Point the URLs at a self-hosted Open-Meteo instance to keep coordinate lookups off a third party.",
|
||||
"required": false,
|
||||
"variables": [
|
||||
{
|
||||
"name": "OPENMETEO_BASE_URL",
|
||||
"purpose": "Historical weather (archive) API base URL. Default https://archive-api.open-meteo.com."
|
||||
},
|
||||
{
|
||||
"name": "OPENMETEO_GEOCODING_URL",
|
||||
"purpose": "Geocoding API base URL for the home and travel location search. Default https://geocoding-api.open-meteo.com."
|
||||
},
|
||||
{
|
||||
"name": "OPENMETEO_AIR_QUALITY_URL",
|
||||
"purpose": "Air-quality API base URL (CAMS air quality, pollen, UV). Default https://air-quality-api.open-meteo.com. CAMS data is licensed by the Copernicus Atmosphere Monitoring Service; the attribution is shown in the app."
|
||||
},
|
||||
{
|
||||
"name": "ENVIRONMENT_AIR_QUALITY_DISABLED",
|
||||
"purpose": "Set to 1, true or yes to stop the air-quality requests for the whole instance. Weather and daylight keep working; the per-user switch cannot turn air quality back on."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Database session tuning",
|
||||
"description": "Per-session Postgres settings the app applies to every pooled connection.",
|
||||
"required": false,
|
||||
"variables": [
|
||||
{
|
||||
"name": "DATABASE_WORK_MEM",
|
||||
"purpose": "Postgres work_mem for every app session, as a Postgres size string. Default 16MB. Raise it when large sorts and aggregates spill to disk; each sort step of each connection may use this much."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "APNs (iOS push)",
|
||||
"description": "All four core APNS_* vars together. Missing any disables APNs silently \u2014 the v1.4.40 AP-2 gap that motivated this manifest. allOrNone trips the synthetic <all-or-none> required row whenever 1-3 of the 4 are set.",
|
||||
|
||||
@@ -815,6 +815,30 @@ async function main() {
|
||||
} as unknown as CorpusClient),
|
||||
);
|
||||
|
||||
// ───── v1.42 environment locations (Bytes, binary2, batched) ─────
|
||||
// The sealed home, each dated location period and each day's resolved
|
||||
// location, all under the environment-location label. NULL on a row the
|
||||
// encryption backfill has not reached yet, which the walk skips.
|
||||
results.push(
|
||||
await rotateRegistryColumn("User", "homeLocationEncrypted", {
|
||||
user: prisma.user,
|
||||
} as unknown as CorpusClient),
|
||||
);
|
||||
results.push(
|
||||
await rotateRegistryColumn(
|
||||
"EnvironmentTravelLocation",
|
||||
"locationEncrypted",
|
||||
{
|
||||
environmentTravelLocation: prisma.environmentTravelLocation,
|
||||
} as unknown as CorpusClient,
|
||||
),
|
||||
);
|
||||
results.push(
|
||||
await rotateRegistryColumn("EnvironmentContext", "locationEncrypted", {
|
||||
environmentContext: prisma.environmentContext,
|
||||
} as unknown as CorpusClient),
|
||||
);
|
||||
|
||||
// ───── v1.39.3 custom-metric reading note (Bytes column) ─────
|
||||
// NULL on a reading without a note, and on a row the free-text backfill has
|
||||
// not reached yet; `rotateBytesColumn` skips both.
|
||||
|
||||
@@ -98,6 +98,7 @@ const LEGIT_IDENTICAL = new Set<string>([
|
||||
"insights.derived.composite.READINESS.component.hrv",
|
||||
"labs.catalog.hs-crp",
|
||||
"measurements.typeHrvRmssd",
|
||||
"settings.sections.sources.metrics.hrvRmssd",
|
||||
"measurements.typeVo2Max",
|
||||
// "{hours} h {minutes} min": the SI symbols for hour and minute.
|
||||
"measurements.durationHoursMinutes",
|
||||
@@ -112,6 +113,7 @@ const LEGIT_IDENTICAL = new Set<string>([
|
||||
"admin.webPushVapidTitle",
|
||||
"measurements.sourceAppleHealth",
|
||||
"measurements.sourceGoogleHealth",
|
||||
"measurements.sourceHealthConnect",
|
||||
"medications.sourceAppleHealth",
|
||||
"settings.ai.providerChain.types.anthropic",
|
||||
"settings.ai.providerChain.types.codex",
|
||||
@@ -119,6 +121,7 @@ const LEGIT_IDENTICAL = new Set<string>([
|
||||
"settings.sections.export.import.appleHealth.title",
|
||||
"settings.sections.sources.sourceLabels.APPLE_HEALTH",
|
||||
"settings.sections.sources.sourceLabels.GOOGLE_HEALTH",
|
||||
"settings.sections.sources.sourceLabels.HEALTH_CONNECT",
|
||||
"settings.appleHealth.title",
|
||||
"mood.tag.fastFood",
|
||||
// Technical / config labels conventionally kept verbatim.
|
||||
|
||||
@@ -66,6 +66,42 @@ const DYNAMIC_ALLOWLIST_PREFIXES = [
|
||||
"apiErrors",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Keys a release lands ahead of their call sites, so that the packages built
|
||||
* in parallel on the release branch share one bundle and never edit it
|
||||
* themselves. Each entry names the package that wires the call sites and the
|
||||
* issue it serves.
|
||||
*
|
||||
* This list expires by construction: the companion test fails as soon as an
|
||||
* entry no longer covers a single orphan, so a prefix whose call sites have
|
||||
* all landed must be dropped in the same change, and the list is empty again
|
||||
* before the release is tagged. It is NOT a place for keys nobody will wire.
|
||||
*/
|
||||
const RELEASE_PENDING_PREFIXES: Record<string, string> = {
|
||||
"auth.claim": "v1.42 managed-profile claim page (#959)",
|
||||
"recordSharing.managed.handover":
|
||||
"v1.42 managed-profile handover dialog (#959)",
|
||||
"settings.sections.export.import.healthConnect":
|
||||
"v1.42 Health Connect import card (#972)",
|
||||
"settings.sections.environment.airQuality":
|
||||
"v1.42 environment settings, air quality (#615)",
|
||||
"environment.fields.apparentMax": "v1.42 air-quality field label (#615)",
|
||||
"environment.fields.pm10": "v1.42 air-quality field label (#615)",
|
||||
"environment.fields.no2": "v1.42 air-quality field label (#615)",
|
||||
"environment.fields.europeanAqi": "v1.42 air-quality field label (#615)",
|
||||
"environment.fields.uvIndexMax": "v1.42 air-quality field label (#615)",
|
||||
"environment.fields.dustMax": "v1.42 air-quality field label (#615)",
|
||||
"insights.correlation.dailySameDay":
|
||||
"v1.42 same-day environment correlation templates (#615)",
|
||||
"settings.appleHealth.freshness":
|
||||
"v1.42 per-type HealthKit freshness list (#1173)",
|
||||
"insights.workouts.manual.editTitle": "v1.42 manual workout edit (#1162)",
|
||||
"insights.workouts.manual.editAction": "v1.42 manual workout edit (#1162)",
|
||||
"insights.workouts.manual.updated": "v1.42 manual workout edit (#1162)",
|
||||
"vaccinations.custom": "v1.42 user-defined vaccines (#1005)",
|
||||
"labs.convertedFrom": "v1.42 lab unit conversion note (#1095)",
|
||||
};
|
||||
|
||||
/**
|
||||
* Interpolating strings that LOOK like key templates and are not. Each entry
|
||||
* whitelists nothing — the template still contributes no reachable keys — it
|
||||
@@ -118,9 +154,14 @@ function nodePaths(obj: unknown, prefix: string, out: Set<string>): void {
|
||||
}
|
||||
}
|
||||
|
||||
function underPrefix(leaf: string, prefix: string): boolean {
|
||||
return leaf === prefix || leaf.startsWith(`${prefix}.`);
|
||||
}
|
||||
|
||||
function isAllowlisted(leaf: string): boolean {
|
||||
return DYNAMIC_ALLOWLIST_PREFIXES.some(
|
||||
(p) => leaf === p || leaf.startsWith(`${p}.`),
|
||||
return (
|
||||
DYNAMIC_ALLOWLIST_PREFIXES.some((p) => underPrefix(leaf, p)) ||
|
||||
Object.keys(RELEASE_PENDING_PREFIXES).some((p) => underPrefix(leaf, p))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -185,4 +226,18 @@ describe("i18n reverse coverage", () => {
|
||||
);
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
it("every release-pending prefix still covers an unwired key", () => {
|
||||
const { leaves, ref } = scan();
|
||||
const stale = Object.keys(RELEASE_PENDING_PREFIXES).filter(
|
||||
(prefix) =>
|
||||
!leaves.some(
|
||||
(leaf) => underPrefix(leaf, prefix) && !isReferenced(leaf, ref),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
stale,
|
||||
"These RELEASE_PENDING_PREFIXES entries have all their call sites now — drop them",
|
||||
).toEqual([]);
|
||||
}, 15_000);
|
||||
});
|
||||
@@ -79,6 +79,10 @@ const UNAUTHENTICATED: Readonly<Record<string, string>> = {
|
||||
"Says whether single sign-on is configured, so the sign-in screen can offer it.",
|
||||
"/api/notifications/vapid":
|
||||
"The instance's public Web Push key. Public by definition.",
|
||||
"/api/auth/claim/preview":
|
||||
"Previews a managed-profile handover link for a person who has no credential yet; the one-time token in the body is the credential.",
|
||||
"/api/auth/claim":
|
||||
"Turns a managed profile into the claimant's own account and creates their first credential; the one-time token in the body authorises it.",
|
||||
// The tombstones, sourced from the registry rather than restated one by one.
|
||||
// A retired path answers 410 from the proxy before any handler runs, and that
|
||||
// answer never reads the request's cookies or Authorization header — so it is
|
||||
|
||||
@@ -51,18 +51,21 @@ describe("User column backup classification", () => {
|
||||
);
|
||||
});
|
||||
|
||||
// The person's own data, sealed at rest for privacy rather than a secret
|
||||
// that grants anything: the insurance number, and since v1.42 the home
|
||||
// location the environment module resolves weather against.
|
||||
const SEALED_SETTINGS = ["insuranceNumberEncrypted", "homeLocationEncrypted"];
|
||||
|
||||
it("carries no credential-shaped column as a setting", () => {
|
||||
// The insurance number is the one sealed setting: it is the person's own
|
||||
// data, sealed at rest for privacy, not a secret that grants anything.
|
||||
const sealedSettings = ACCOUNT_SETTING_COLUMNS.filter((column) =>
|
||||
/Encrypted$|Token|Secret|password|totp|oidc/i.test(column),
|
||||
);
|
||||
expect(sealedSettings).toEqual(["insuranceNumberEncrypted"]);
|
||||
expect(sealedSettings.sort()).toEqual([...SEALED_SETTINGS].sort());
|
||||
});
|
||||
|
||||
it("never classifies a sealed column other than the insurance number as anything but a credential", () => {
|
||||
it("never classifies a sealed column other than the sealed settings as anything but a credential", () => {
|
||||
const sealed = columns.filter(
|
||||
(c) => /Encrypted$/.test(c) && c !== "insuranceNumberEncrypted",
|
||||
(c) => /Encrypted$/.test(c) && !SEALED_SETTINGS.includes(c),
|
||||
);
|
||||
expect(sealed.length).toBeGreaterThan(20);
|
||||
expect(sealed.filter((c) => classified[c] !== "CREDENTIAL")).toEqual([]);
|
||||
|
||||
@@ -402,6 +402,11 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
|
||||
"src/app/api/vaccinations/[id]/route.ts",
|
||||
"src/app/api/vaccinations/[id]/restore/route.ts",
|
||||
"src/app/api/vaccinations/[id]/links/route.ts",
|
||||
// v1.42 (#1005) — the record's own vaccine definitions. Same posture: a
|
||||
// restore must bring back the definitions its doses point at with the
|
||||
// surface hidden.
|
||||
"src/app/api/vaccinations/custom/route.ts",
|
||||
"src/app/api/vaccinations/custom/[id]/route.ts",
|
||||
// The booster mint and the upload suggestion are the same posture: a restore
|
||||
// or an import that arms a booster, or a document review that files a scan,
|
||||
// must keep working with the surface hidden, so the data routes stay exempt
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* `POST /api/admin/maintenance/measurements` — queue the operator-triggered
|
||||
* `VACUUM (ANALYZE)` / `REINDEX INDEX CONCURRENTLY` pass over `measurements`
|
||||
* (v1.42). Cookie-only admin: `requireAdmin()` refuses every Bearer.
|
||||
*
|
||||
* Contract stub: authenticates as the finished route will, then answers 501.
|
||||
*/
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
await requireAdmin();
|
||||
return notImplemented();
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* `POST /api/auth/claim/preview` — what a handover link would hand over: the
|
||||
* profile's display name and each guardian's proposal (v1.42, #959).
|
||||
* Anonymous, token in the body, the same uniform 404 as the claim itself.
|
||||
*
|
||||
* Contract stub: answers 501.
|
||||
*/
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
|
||||
export const POST = apiHandler(async () => notImplemented());
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* `POST /api/auth/claim` — claim a managed profile through its handover link
|
||||
* (v1.42, #959). Anonymous by design: the person claiming has no credentials
|
||||
* yet. The one-time `hlp_` token travels in the body, never in the URL of
|
||||
* this request, and every failure answers the same 404.
|
||||
*
|
||||
* Contract stub: answers 501.
|
||||
*/
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
|
||||
export const POST = apiHandler(async () => notImplemented());
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* `PATCH /api/environment/preferences` — the caller's environment switches,
|
||||
* today `airQualityEnabled` (v1.42, #615). Module-gated like the rest of the
|
||||
* environment surface.
|
||||
*
|
||||
* Contract stub: authenticates as the finished route will, then answers 501.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
export const PATCH = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
const gate = await requireModuleEnabled(user.id, "environment");
|
||||
if (!gate.enabled) return gate.response;
|
||||
return notImplemented();
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* `POST /api/import/health-connect-export` — upload an Android Health Connect
|
||||
* export ZIP for background import (v1.42, #972). Outside the proxy matcher
|
||||
* like the Apple Health upload (`src/lib/http/proxy-bypass-routes.ts`), so the
|
||||
* body is streamed to disk after authentication instead of being buffered.
|
||||
*
|
||||
* Contract stub: authenticates as the finished route will, then answers 501.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
await requireAuth();
|
||||
return notImplemented();
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* `GET /api/import/health-connect-export/status` — the caller's most recent
|
||||
* Health Connect import job (v1.42, #972).
|
||||
*
|
||||
* Contract stub: authenticates as the finished route will, then answers 501.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAuth();
|
||||
return notImplemented();
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* `POST /api/managed-profiles/[id]/handover` — mint the one-time link that
|
||||
* hands a managed profile over to the person it describes, with a proposal
|
||||
* per guardian; `DELETE` withdraws the open link (v1.42, #959). Cookie-only
|
||||
* like every act on a managed profile; minting is step-up gated, withdrawing
|
||||
* is not.
|
||||
*
|
||||
* Contract stub: authenticates as the finished route will, then answers 501.
|
||||
*/
|
||||
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
await requireCookieAuth();
|
||||
return notImplemented();
|
||||
});
|
||||
|
||||
export const DELETE = apiHandler(async () => {
|
||||
await requireCookieAuth();
|
||||
return notImplemented();
|
||||
});
|
||||
@@ -181,7 +181,9 @@ const batchEntrySchema = z.object({
|
||||
// client knows its own wake reason, so it is client-asserted by necessity;
|
||||
// being diagnostic-only, that carries no trust surface. Optional so every
|
||||
// pre-#66 caller stays byte-for-byte unchanged.
|
||||
const syncTriggerEnum = z.enum(["foreground", "background", "push"]);
|
||||
// v1.42 (#1173) — `manual` names a user-initiated "Sync all" run, as opposed to
|
||||
// the app simply being open (`foreground`).
|
||||
const syncTriggerEnum = z.enum(["foreground", "background", "push", "manual"]);
|
||||
|
||||
const batchPayloadSchema = z.object({
|
||||
entries: z.array(batchEntrySchema).min(1),
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* `PATCH /api/vaccinations/custom/[id]` edits one of the record's own vaccine
|
||||
* definitions; `DELETE` soft-deletes it, leaving every dose logged against it
|
||||
* on its `vaccineName` (v1.42, #1005). Record-scoped like the dose log.
|
||||
*
|
||||
* Contract stub: owner-only until implemented, then answers 501. The
|
||||
* finished routes resolve the record through `requireRecordAuth` like the
|
||||
* dose log and join the sharing guard's delegable lists with their audit
|
||||
* rows; a stub cannot carry those, so it does not claim the admission.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
|
||||
export const PATCH = apiHandler(async () => {
|
||||
await requireAuth();
|
||||
return notImplemented();
|
||||
});
|
||||
|
||||
export const DELETE = apiHandler(async () => {
|
||||
await requireAuth();
|
||||
return notImplemented();
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* `GET /api/vaccinations/custom` lists the record's own vaccine definitions;
|
||||
* `POST` adds one (v1.42, #1005). Record-scoped like the dose log.
|
||||
*
|
||||
* Contract stub: owner-only until implemented, then answers 501. The
|
||||
* finished routes resolve the record through `requireRecordAuth` like the
|
||||
* dose log and join the sharing guard's delegable lists with their audit
|
||||
* rows; a stub cannot carry those, so it does not claim the admission.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAuth();
|
||||
return notImplemented();
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
await requireAuth();
|
||||
return notImplemented();
|
||||
});
|
||||
@@ -65,6 +65,10 @@ const PUBLIC_PATHS = [
|
||||
// so without the entry the shell classifies the route as protected and
|
||||
// `router.replace("/auth/login")` wins the race against the redirect.
|
||||
"/invite/",
|
||||
// v1.42 (#959) — the managed-profile handover link is an edge redirect onto
|
||||
// `/auth/claim?token=…`, public in `proxy.ts` for the same reason as
|
||||
// `/invite/`.
|
||||
"/claim/",
|
||||
];
|
||||
|
||||
export function AuthShell({
|
||||
|
||||
@@ -34,7 +34,7 @@ import { queryKeys } from "@/lib/query-keys";
|
||||
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
|
||||
import { IntegrationErrorMessage, pillStateForVerdict } from "./shared";
|
||||
|
||||
type SyncTrigger = "foreground" | "background" | "push";
|
||||
type SyncTrigger = "foreground" | "background" | "push" | "manual";
|
||||
|
||||
interface HealthKitStatus {
|
||||
lastSyncedAt: string | null;
|
||||
@@ -65,6 +65,7 @@ const TRIGGER_KEYS: Record<SyncTrigger, string> = {
|
||||
foreground: "settings.appleHealth.delivery.trigger.foreground",
|
||||
background: "settings.appleHealth.delivery.trigger.background",
|
||||
push: "settings.appleHealth.delivery.trigger.push",
|
||||
manual: "settings.appleHealth.delivery.trigger.manual",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -85,6 +85,7 @@ const METRIC_LABEL_KEYS: Record<SourcePriorityMetricKey, string> = {
|
||||
bodyTemperature: "settings.sections.sources.metrics.bodyTemperature",
|
||||
spo2: "settings.sections.sources.metrics.spo2",
|
||||
hrv: "settings.sections.sources.metrics.hrv",
|
||||
hrvRmssd: "settings.sections.sources.metrics.hrvRmssd",
|
||||
restingHeartRate: "settings.sections.sources.metrics.restingHeartRate",
|
||||
vo2Max: "settings.sections.sources.metrics.vo2Max",
|
||||
// v1.11.0 — WHOOP-overlapping metric classes + native-vs-derived recovery.
|
||||
|
||||
@@ -161,9 +161,13 @@ describe("somebody guessing from two addresses", () => {
|
||||
it("the owner's remembered browser still gets through", async () => {
|
||||
await tenGuesses();
|
||||
deviceCookie = "device-token";
|
||||
// The device's expiry is compared against the wall clock, not the mocked
|
||||
// rate-limit clock `now`, so it is anchored to the wall clock too: an
|
||||
// expiry fixed relative to `now` expired for real once the calendar
|
||||
// passed it.
|
||||
vi.mocked(prisma.trustedDevice.findUnique).mockResolvedValue({
|
||||
userId: "owner-1",
|
||||
expiresAt: new Date(now + 1e9),
|
||||
expiresAt: new Date(Date.now() + 30 * 86_400_000),
|
||||
} as never);
|
||||
expect(await guess("203.0.113.51", { right: true })).toBe("checked");
|
||||
});
|
||||
|
||||
@@ -60,6 +60,14 @@ export const WORKOUT_ROUTE_GEOMETRY_AAD = "healthlog/workout-route-geometry/v1";
|
||||
export const DOCUMENT_AI_RUN_INPUT_AAD = "healthlog/document-ai-run-input/v1";
|
||||
export const DOCUMENT_AI_RUN_RESULT_AAD = "healthlog/document-ai-run-result/v1";
|
||||
|
||||
/**
|
||||
* The associated-data label the environment module's coarse locations are
|
||||
* sealed with (v1.42, #615): the home, each dated location period, and each
|
||||
* day's resolved location. One label for all three, because they hold the
|
||||
* same kind of value; it keeps a location from opening as anything else.
|
||||
*/
|
||||
export const ENVIRONMENT_LOCATION_AAD = "healthlog/environment-location/v1";
|
||||
|
||||
export interface EncryptedColumn {
|
||||
/** Prisma model name (PascalCase, as declared in schema.prisma). */
|
||||
readonly model: string;
|
||||
@@ -395,6 +403,32 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
|
||||
aad: WORKOUT_ROUTE_GEOMETRY_AAD,
|
||||
},
|
||||
|
||||
// ───── v1.42 environment locations (Bytes, binary2) ─────
|
||||
// The coarse home, each dated location period, and each day's resolved
|
||||
// location, sealed as (lat, lon, label) under one label. The plaintext
|
||||
// columns beside them empty as the encryption backfill runs.
|
||||
{
|
||||
model: "User",
|
||||
field: "homeLocationEncrypted",
|
||||
kind: "bytes",
|
||||
codec: "binary2",
|
||||
aad: ENVIRONMENT_LOCATION_AAD,
|
||||
},
|
||||
{
|
||||
model: "EnvironmentTravelLocation",
|
||||
field: "locationEncrypted",
|
||||
kind: "bytes",
|
||||
codec: "binary2",
|
||||
aad: ENVIRONMENT_LOCATION_AAD,
|
||||
},
|
||||
{
|
||||
model: "EnvironmentContext",
|
||||
field: "locationEncrypted",
|
||||
kind: "bytes",
|
||||
codec: "binary2",
|
||||
aad: ENVIRONMENT_LOCATION_AAD,
|
||||
},
|
||||
|
||||
// ───── v1.38.0 dose free text (Bytes column) ─────
|
||||
// What the person wrote about the dose — a reaction, a sore arm, why the
|
||||
// schedule went the way it did. The Pass's own structured fields stay
|
||||
@@ -551,6 +585,8 @@ export const UPDATED_AT_MODELS: ReadonlySet<string> = new Set([
|
||||
"DocumentThumbnail",
|
||||
"EcgRecording",
|
||||
"Encounter",
|
||||
"EnvironmentContext",
|
||||
"EnvironmentTravelLocation",
|
||||
"ExtractedFact",
|
||||
"FamilyHistoryEntry",
|
||||
"FitbitConnection",
|
||||
|
||||
@@ -114,6 +114,9 @@ export const WIPE_MODELS = [
|
||||
// ahead of both.
|
||||
"VaccinationDocumentLink",
|
||||
"VaccinationRecord",
|
||||
// v1.42 (#1005) — the person's own vaccine definitions. After the doses,
|
||||
// which point at them with SetNull, so neither count reads short.
|
||||
"CustomVaccine",
|
||||
|
||||
// ── Clinical record ─────────────────────────────────────────────────────
|
||||
"MentalHealthAssessment",
|
||||
@@ -173,6 +176,14 @@ export const WIPE_MODELS = [
|
||||
// was read off it; a child of the document, so it goes before it.
|
||||
"DocumentAiRun",
|
||||
"ImportJob",
|
||||
// v1.42 (#1173) — when each HealthKit type last arrived. Diagnostic, but a
|
||||
// record of what the account's phone sent and when.
|
||||
"HealthKitTypeSync",
|
||||
// v1.42 (#959) — handover links, on both sides: the ones minted for this
|
||||
// profile, and the ones this account minted for a profile it looks after.
|
||||
// The second is the AccountGrant argument: an account that asked for
|
||||
// everything of its own to be deleted must not leave a live link behind.
|
||||
"ManagedProfileHandover",
|
||||
|
||||
// ── AI ──────────────────────────────────────────────────────────────────
|
||||
"CoachConversation",
|
||||
@@ -360,6 +371,8 @@ export const USER_RESET = {
|
||||
homeLabel: null,
|
||||
homeTimezone: null,
|
||||
homeSince: null,
|
||||
homeLocationEncrypted: null,
|
||||
environmentAirQualityEnabled: true,
|
||||
|
||||
// Derived AI output cached on the row
|
||||
insightsPrivacyMode: "aggregated",
|
||||
@@ -512,6 +525,7 @@ export const WIPE_OWNER_FIELDS: Readonly<Record<string, readonly string[]>> = {
|
||||
MedicationIntakeImportJob: ["recordUserId"],
|
||||
NotificationEvent: ["recordUserId"],
|
||||
NotificationEgressAuthorization: ["recordUserId", "recipientUserId"],
|
||||
ManagedProfileHandover: ["profileId", "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.
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* The air-quality day and its attributions (v1.42, #615).
|
||||
*
|
||||
* One `EnvironmentContext` row carries, beside the weather, the day's air
|
||||
* quality, pollen and UV from the Open-Meteo air-quality API (CAMS models).
|
||||
* This is the shape every reader agrees on: the environment overview, the
|
||||
* Coach and MCP read, and the day view. Every value is nullable, because a
|
||||
* partial feed stores what it has and a day the feed never covered is
|
||||
* absent, not zero.
|
||||
*/
|
||||
|
||||
/** One local day of air quality, as stored and as served. */
|
||||
export interface AirQualityDay {
|
||||
/** Daily apparent ("feels-like") maximum, °C. */
|
||||
apparentMax: number | null;
|
||||
/** PM2.5 daily mean and hourly maximum, µg/m³. */
|
||||
pm25Mean: number | null;
|
||||
pm25Max: number | null;
|
||||
/** PM10 daily mean, µg/m³. */
|
||||
pm10Mean: number | null;
|
||||
/** NO2, SO2 and CO daily means, µg/m³. */
|
||||
no2Mean: number | null;
|
||||
so2Mean: number | null;
|
||||
coMean: number | null;
|
||||
/** Ozone, highest 8-hour running mean of the day, µg/m³. */
|
||||
o3Max8h: number | null;
|
||||
/** European and US air-quality index, daily maximum. */
|
||||
eaqiMax: number | null;
|
||||
usaqiMax: number | null;
|
||||
/** UV index, daily maximum. */
|
||||
uvIndexMax: number | null;
|
||||
/** Saharan dust (µg/m³) and aerosol optical depth, daily maximum. */
|
||||
dustMax: number | null;
|
||||
aodMax: number | null;
|
||||
/** Pollen daily maxima, grains/m³; Europe only. */
|
||||
pollenAlderMax: number | null;
|
||||
pollenBirchMax: number | null;
|
||||
pollenGrassMax: number | null;
|
||||
pollenMugwortMax: number | null;
|
||||
pollenOliveMax: number | null;
|
||||
pollenRagweedMax: number | null;
|
||||
/** Which model domain served the day, e.g. `cams_europe`. */
|
||||
aqDomain: string | null;
|
||||
/** Hourly samples the day was aggregated from. */
|
||||
aqHours: number | null;
|
||||
/** When the air-quality part was fetched, ISO-8601; null = not yet. */
|
||||
aqFetchedAt: string | null;
|
||||
}
|
||||
|
||||
/** Who the values come from, shown wherever they are shown. */
|
||||
export const AIR_QUALITY_ATTRIBUTIONS = [
|
||||
{
|
||||
key: "cams",
|
||||
name: "Copernicus Atmosphere Monitoring Service (CAMS)",
|
||||
url: "https://atmosphere.copernicus.eu/",
|
||||
},
|
||||
{
|
||||
key: "open-meteo",
|
||||
name: "Open-Meteo",
|
||||
url: "https://open-meteo.com/",
|
||||
},
|
||||
] as const;
|
||||
@@ -174,7 +174,7 @@ export async function fetchAndStoreEnvironment(args: {
|
||||
}
|
||||
: null;
|
||||
|
||||
const travelRows = await prisma.environmentTravelLocation.findMany({
|
||||
const travelRecords = await prisma.environmentTravelLocation.findMany({
|
||||
where: { userId },
|
||||
select: {
|
||||
startDate: true,
|
||||
@@ -185,6 +185,15 @@ export async function fetchAndStoreEnvironment(args: {
|
||||
},
|
||||
});
|
||||
|
||||
// Since v1.42 the plaintext location is nullable (migration 0382): a period
|
||||
// whose plaintext the encryption backfill has already cleared resolves only
|
||||
// through its sealed copy, which this reader does not open yet.
|
||||
const travelRows: TravelOverride[] = travelRecords.flatMap((row) =>
|
||||
row.lat != null && row.lon != null && row.label != null
|
||||
? [{ ...row, lat: row.lat, lon: row.lon, label: row.label }]
|
||||
: [],
|
||||
);
|
||||
|
||||
// The timezone used to enumerate AND fetch days — one tz keeps the stored
|
||||
// `date` consistent with the user's day-keys elsewhere.
|
||||
const timezone = home?.timezone ?? user.timezone;
|
||||
|
||||
@@ -381,6 +381,37 @@ const insuranceNumber: SettingCodec = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* v1.42 (#615) — the sealed home location. A disaster-recovery file carries
|
||||
* the ciphertext verbatim, like the insurance number, because it is restored
|
||||
* onto a host holding the same keys. A portable file leaves it out: the
|
||||
* readable home rides `homeLat` / `homeLon` / `homeLabel` until those columns
|
||||
* drop, and the receiving host's encryption backfill seals it again.
|
||||
*/
|
||||
const homeLocationSealed: SettingCodec = {
|
||||
write: (row, disasterRecovery) => {
|
||||
if (!disasterRecovery) return {};
|
||||
const sealed = row.homeLocationEncrypted;
|
||||
return {
|
||||
homeLocationEncrypted:
|
||||
sealed instanceof Uint8Array
|
||||
? Buffer.from(sealed).toString("base64")
|
||||
: null,
|
||||
};
|
||||
},
|
||||
read: (entry) => {
|
||||
if (!has(entry, "homeLocationEncrypted")) return undefined;
|
||||
const value = entry.homeLocationEncrypted;
|
||||
if (value === null) return null;
|
||||
if (typeof value !== "string") return new Refused("homeLocationEncrypted");
|
||||
const buffer = Buffer.from(value, "base64");
|
||||
if (buffer.byteLength === 0) return new Refused("homeLocationEncrypted");
|
||||
const bytes = new Uint8Array(new ArrayBuffer(buffer.byteLength));
|
||||
bytes.set(buffer);
|
||||
return bytes;
|
||||
},
|
||||
};
|
||||
|
||||
const ACCOUNT_SETTING_CODECS: Readonly<
|
||||
Record<AccountSettingColumn, SettingCodec>
|
||||
> = {
|
||||
@@ -402,6 +433,8 @@ const ACCOUNT_SETTING_CODECS: Readonly<
|
||||
homeLabel: plain("homeLabel"),
|
||||
homeTimezone: timeZone("homeTimezone", true),
|
||||
homeSince: instant("homeSince"),
|
||||
homeLocationEncrypted: homeLocationSealed,
|
||||
environmentAirQualityEnabled: plain("environmentAirQualityEnabled"),
|
||||
timezone: timeZone("timezone", false),
|
||||
locale: oneOf("locale", locales, true),
|
||||
// The vocabularies the two preference routes accept, read from their own
|
||||
|
||||
@@ -171,6 +171,10 @@ export const BACKED_UP_MODELS = [
|
||||
// re-syncs it from a provider.
|
||||
"VaccinationRecord",
|
||||
"VaccinationDocumentLink",
|
||||
// v1.42 (#1005) — the person's own vaccine definitions. A dose logged
|
||||
// against one resolves its antigens and booster rule through it, so a
|
||||
// restore without them leaves those doses as bare names.
|
||||
"CustomVaccine",
|
||||
|
||||
// ── Coach ─────────────────────────────────────────────────────────────────
|
||||
// The transcript — conversation, message, attachment — travels; see
|
||||
@@ -585,6 +589,8 @@ export const STRUCTURALLY_UNATTRIBUTABLE: Readonly<Record<string, string>> = {
|
||||
* rather than by what is cheapest to write.
|
||||
*/
|
||||
export const COVERAGE_PENDING: Readonly<Record<string, string>> = {
|
||||
CustomVaccine:
|
||||
"The person's own vaccine definitions (v1.42, #1005). Until both ends land, a restored dose that pointed at one comes back as its bare `vaccineName`: no antigens, no series position, no booster rule.",
|
||||
WorkoutRoute:
|
||||
"GPS traces. Deliberately absent from the payload today and DISCLOSED as absent in the file's own manifest, which is why this is a documented exclusion rather than a silent one — but it is still a loss for a self-hoster with no other copy.",
|
||||
WorkoutSamples:
|
||||
@@ -718,6 +724,10 @@ export const NOT_IN_BACKUP_MODELS: Readonly<Record<string, string>> = {
|
||||
"Further import source keys that were answered with an already stored document because the bytes matched. The next import run writes the same alias again the moment it meets the same bytes, so a restore loses nothing a re-run does not rebuild; and for a deleted document it restores no less than the tombstones and ledger beside it do, which do not travel either.",
|
||||
ImportJob:
|
||||
"A job record pointing at an uploaded file that the backup does not carry, so restoring it would resurrect a task with nothing to work on.",
|
||||
HealthKitTypeSync:
|
||||
"When each HealthKit type last arrived on this host and how. Diagnostic state the next sync rebuilds; restored elsewhere it would claim deliveries that host never received.",
|
||||
ManagedProfileHandover:
|
||||
"A one-time handover link: a credential, minted by a guardian and shown once. Restoring it would bring back a link that was used, withdrawn or expired, and only its hash exists to carry anyway. Same reasoning as `InviteToken`.",
|
||||
InviteToken:
|
||||
"The instance's registration ledger, minted by the operator rather than by the person, and already declared instance-scoped by the wipe plan. Restoring one account's backup must not re-open a registration code the operator retired, and the creator relation is the only thing that makes this look account-scoped at all. It surfaced here when the classification check learned to read a relation by type instead of by field name.",
|
||||
MedicationIntakeImportJob:
|
||||
@@ -914,6 +924,10 @@ export const USER_COLUMN_BACKUP_CLASS = {
|
||||
homeLabel: "SETTING",
|
||||
homeTimezone: "SETTING",
|
||||
homeSince: "SETTING",
|
||||
// v1.42 (#615) — the sealed copy of the same home location, and the
|
||||
// account's switch for the air-quality part of the module.
|
||||
homeLocationEncrypted: "SETTING",
|
||||
environmentAirQualityEnabled: "SETTING",
|
||||
// How the interface reads.
|
||||
timezone: "SETTING",
|
||||
locale: "SETTING",
|
||||
|
||||
@@ -66,9 +66,13 @@ import { roundCoarse } from "@/lib/environment/open-meteo";
|
||||
export interface EnvironmentContextBackupEntry {
|
||||
/** `YYYY-MM-DD`, anchored to the resolved location's timezone. */
|
||||
date: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
locationLabel: string;
|
||||
/**
|
||||
* Plaintext coarse location. Nullable since v1.42 (migration 0382): the
|
||||
* sealed copy replaces it once the encryption backfill has run.
|
||||
*/
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
locationLabel: string | null;
|
||||
/**
|
||||
* Which precedence rule chose the location. Carried because it is the only
|
||||
* record that this day was NOT the home city, and because a re-resolve
|
||||
@@ -98,9 +102,10 @@ export interface EnvironmentTravelLocationBackupEntry {
|
||||
/** Inclusive `YYYY-MM-DD` bounds. Strings end to end; see the file header. */
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
label: string;
|
||||
/** Plaintext coarse location; nullable since v1.42 (migration 0382). */
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
label: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -315,8 +320,8 @@ export async function restoreEnvironmentData(
|
||||
endDate: entry.endDate,
|
||||
// A file written before v1.39.4 carries 2-decimal coordinates; they
|
||||
// come back at today's privacy floor.
|
||||
lat: roundCoarse(entry.lat),
|
||||
lon: roundCoarse(entry.lon),
|
||||
lat: entry.lat == null ? null : roundCoarse(entry.lat),
|
||||
lon: entry.lon == null ? null : roundCoarse(entry.lon),
|
||||
label: entry.label,
|
||||
...(entry.createdAt ? { createdAt: new Date(entry.createdAt) } : {}),
|
||||
...(entry.updatedAt ? { updatedAt: new Date(entry.updatedAt) } : {}),
|
||||
@@ -329,8 +334,8 @@ export async function restoreEnvironmentData(
|
||||
data: payload.environmentContexts.map((entry) => ({
|
||||
userId: ownerId,
|
||||
date: entry.date,
|
||||
lat: roundCoarse(entry.lat),
|
||||
lon: roundCoarse(entry.lon),
|
||||
lat: entry.lat == null ? null : roundCoarse(entry.lat),
|
||||
lon: entry.lon == null ? null : roundCoarse(entry.lon),
|
||||
locationLabel: entry.locationLabel,
|
||||
source: entry.source,
|
||||
tempMin: entry.tempMin ?? null,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* The answer a route gives while its contract exists and its body does not.
|
||||
*
|
||||
* A release built in parallel packages lands every new route as a stub first,
|
||||
* registered in the OpenAPI table with its final shape, so the route-coverage
|
||||
* and contract checks hold in every package's worktree from the start. The
|
||||
* stub authenticates exactly as the finished route will and then answers 501.
|
||||
*
|
||||
* Nothing may ship with a stub in it: the error-code catalogue check fails on
|
||||
* a listed code no route emits, so the code leaves the catalogue, and this
|
||||
* file with it, once the last stub is implemented.
|
||||
*/
|
||||
import { apiError } from "@/lib/api-response";
|
||||
|
||||
export const NOT_IMPLEMENTED_ERROR_CODE = "not_implemented";
|
||||
|
||||
export function notImplemented() {
|
||||
return apiError("Not implemented", 501, {
|
||||
errorCode: NOT_IMPLEMENTED_ERROR_CODE,
|
||||
});
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import { DEMO_REFUSAL_BODY } from "@/lib/demo-refusal";
|
||||
export const PROXY_BYPASS_ROUTES: readonly string[] = [
|
||||
// Streamed archive uploads, up to 1.5 GB.
|
||||
"/api/import/apple-health-export",
|
||||
"/api/import/health-connect-export",
|
||||
"/api/admin/import-apple-health-export",
|
||||
// Backup file upload, up to 512 MB raw.
|
||||
"/api/admin/backups/upload",
|
||||
|
||||
@@ -45,6 +45,7 @@ export const MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS: Record<
|
||||
// Never offered in the ladder (it is on no default priority list), but
|
||||
// the map is keyed by the enum, so the entry is not optional.
|
||||
EXTERNAL: "settings.sections.sources.sourceLabels.EXTERNAL",
|
||||
HEALTH_CONNECT: "settings.sections.sources.sourceLabels.HEALTH_CONNECT",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -71,6 +72,7 @@ export const MEASUREMENT_SOURCE_LIST_LABEL_KEYS: Record<
|
||||
GOOGLE_HEALTH: "measurements.sourceGoogleHealth",
|
||||
STRAVA: "measurements.sourceStrava",
|
||||
EXTERNAL: "measurements.sourceExternal",
|
||||
HEALTH_CONNECT: "measurements.sourceHealthConnect",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Per-type HealthKit arrival ledger (v1.42, #1173).
|
||||
*
|
||||
* The batch route records, per measurement type it received, when the type
|
||||
* last arrived, under which `syncTrigger`, and when it last brought a new or
|
||||
* updated sample (as opposed to duplicates only). The Apple Health card reads
|
||||
* it to show which types are actually flowing. A scoped ingest credential
|
||||
* does not write it, the same rule as `lastSyncedAt`.
|
||||
*
|
||||
* Contract stub: the signature is final; the body records nothing yet.
|
||||
*/
|
||||
import type { MeasurementType, Prisma } from "@/generated/prisma/client";
|
||||
|
||||
export type HealthKitSyncTrigger =
|
||||
"foreground" | "background" | "push" | "manual";
|
||||
|
||||
/** What one batch did for one type. */
|
||||
export interface HealthKitTypeArrival {
|
||||
/** Entries of this type the batch accepted (inserted, updated or duplicate). */
|
||||
accepted: number;
|
||||
/** Entries of this type that were inserted or updated. */
|
||||
newSamples: number;
|
||||
}
|
||||
|
||||
export async function recordHealthKitTypeArrivals(
|
||||
tx: Prisma.TransactionClient,
|
||||
input: {
|
||||
userId: string;
|
||||
counts: ReadonlyMap<MeasurementType, HealthKitTypeArrival>;
|
||||
trigger: HealthKitSyncTrigger | null;
|
||||
at?: Date;
|
||||
},
|
||||
): Promise<void> {
|
||||
void tx;
|
||||
void input;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Backlog purge of compaction tombstones (v1.42).
|
||||
*
|
||||
* The dense-intraday consolidation used to soft-delete the raw rows it folded
|
||||
* into an hourly or daily `stats:` row. Those tombstones ("class A") never
|
||||
* reach a client as a meaningful deletion and only weigh on the
|
||||
* `measurements` table and its indexes. This queue removes the backlog in
|
||||
* batches of 5 000 rows, each batch under `holdAccountAgainstRestore`; the
|
||||
* delete predicate is idempotent, so a run that stops early simply resumes on
|
||||
* the next one. User deletions ("class B") are never touched here and keep
|
||||
* their 75-day retention.
|
||||
*
|
||||
* Contract stub: the queue is registered and bound so the registrar, the
|
||||
* runtime table and the subsystem surface are settled. The handler refuses to
|
||||
* claim success until the purge is implemented, and the boot enqueue sends
|
||||
* nothing yet.
|
||||
*/
|
||||
import { jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome";
|
||||
|
||||
export const COMPACTION_TOMBSTONE_PURGE_QUEUE = "compaction-tombstone-purge";
|
||||
|
||||
/** Empty payload: one run walks every account. */
|
||||
export type CompactionTombstonePurgePayload = Record<string, never>;
|
||||
|
||||
export async function handleCompactionTombstonePurge(): Promise<JobOutcome> {
|
||||
return jobFailed("compaction tombstone purge not implemented");
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue one purge run when the worker boots. The run is self-limiting, so a
|
||||
* boot that finds no backlog costs one cheap query. Sends nothing until the
|
||||
* purge is implemented.
|
||||
*/
|
||||
export async function enqueueBootTimeCompactionTombstonePurge(): Promise<{
|
||||
enqueued: boolean;
|
||||
}> {
|
||||
return { enqueued: false };
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Android Health Connect export import worker (v1.42, #972).
|
||||
*
|
||||
* Reads the `health_connect_export.db` SQLite file out of an uploaded export
|
||||
* ZIP (read-only, `immutable=1`) and writes its records as `HEALTH_CONNECT`
|
||||
* measurements through the same reconciliation path the other external
|
||||
* sources use. Mirrors the Apple Health import: the upload is staged to disk
|
||||
* by the route, an `ImportJob` row (`kind = "health_connect"`) carries the
|
||||
* progress, and the run consumes its staged file, so it is a one-shot.
|
||||
*
|
||||
* Contract stub: the queue is registered and bound; the handler refuses to
|
||||
* claim success until the importer is implemented.
|
||||
*/
|
||||
import { jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome";
|
||||
|
||||
export const HEALTH_CONNECT_IMPORT_QUEUE = "health-connect-import";
|
||||
|
||||
/** One import at a time per worker: the SQLite read and the writes are heavy. */
|
||||
export const HEALTH_CONNECT_IMPORT_CONCURRENCY = 1;
|
||||
|
||||
/** Same shape and reasoning as `APPLE_HEALTH_IMPORT_SEND_OPTIONS`. */
|
||||
export const HEALTH_CONNECT_IMPORT_SEND_OPTIONS = {
|
||||
retryLimit: 0,
|
||||
expireInSeconds: 6 * 60 * 60,
|
||||
} as const;
|
||||
|
||||
/** Payload `boss.send` carries onto the queue. */
|
||||
export interface HealthConnectImportPayload {
|
||||
/** Owner of the imported rows. */
|
||||
userId: string;
|
||||
/** The `ImportJob` row that mirrors this run. */
|
||||
importJobId: string;
|
||||
/** Absolute path on the worker filesystem where the upload landed. */
|
||||
uploadPath: string;
|
||||
}
|
||||
|
||||
export async function handleHealthConnectImport(): Promise<JobOutcome> {
|
||||
return jobFailed("health connect import not implemented");
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* One report per job run with one outcome per candidate (v1.42).
|
||||
*
|
||||
* A pass over many accounts used to annotate its wide event once per
|
||||
* candidate, and the last write won: a run where six accounts worked and one
|
||||
* failed read as whatever the last account did. `reportJobRun` takes the
|
||||
* whole candidate list instead, so the run's line carries every outcome and
|
||||
* the level follows the worst of them.
|
||||
*
|
||||
* Contract stub: the signature is fixed for its callers (the insight
|
||||
* pre-generation, the tombstone purge, the measurement maintenance). Until
|
||||
* the implementation lands it annotates the run's counts the way the callers
|
||||
* do today.
|
||||
*/
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
/** How one candidate of a run ended. */
|
||||
export type JobRunOutcome =
|
||||
"ok" | "skipped" | "deferred" | "auth_failed" | "screened" | "failed";
|
||||
|
||||
export interface JobRunCandidate {
|
||||
/** Stable, non-identifying key (a scope or a hashed id, never a user id). */
|
||||
key: string;
|
||||
outcome: JobRunOutcome;
|
||||
/** Short, stable cause for a non-`ok` outcome. */
|
||||
cause?: string;
|
||||
}
|
||||
|
||||
export function reportJobRun(input: {
|
||||
queue: string;
|
||||
runId: string;
|
||||
candidates: readonly JobRunCandidate[];
|
||||
}): void {
|
||||
const failed = input.candidates.filter(
|
||||
(c) => c.outcome === "failed" || c.outcome === "auth_failed",
|
||||
).length;
|
||||
annotate({
|
||||
meta: {
|
||||
queue: input.queue,
|
||||
runId: input.runId,
|
||||
candidates: input.candidates.length,
|
||||
failed,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Operator-triggered maintenance of the `measurements` table (v1.42).
|
||||
*
|
||||
* `VACUUM (ANALYZE)` and a `REINDEX INDEX CONCURRENTLY` per index, largest
|
||||
* first, after the compaction-tombstone purge has shrunk the table. Neither
|
||||
* statement can run inside a transaction, so this is a job rather than a
|
||||
* Prisma migration. Started only from `POST /api/admin/maintenance/measurements`
|
||||
* (cookie-only admin), never on a schedule: the operator picks a quiet window.
|
||||
* Runbook: `docs/ops/measurement-maintenance.md`.
|
||||
*
|
||||
* Contract stub: the queue is registered and bound; the handler refuses to
|
||||
* claim success until the maintenance pass is implemented.
|
||||
*/
|
||||
import { jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome";
|
||||
|
||||
export const MEASUREMENT_MAINTENANCE_QUEUE = "measurement-maintenance";
|
||||
|
||||
/**
|
||||
* A REINDEX of the largest index on a big instance takes well past pg-boss's
|
||||
* default fifteen minutes. One-shot: a retry would start the pass over beside
|
||||
* a half-finished one, so the operator re-triggers instead.
|
||||
*/
|
||||
export const MEASUREMENT_MAINTENANCE_SEND_OPTIONS = {
|
||||
retryLimit: 0,
|
||||
expireInSeconds: 6 * 60 * 60,
|
||||
singletonKey: "measurement-maintenance",
|
||||
} as const;
|
||||
|
||||
/** Which steps the operator asked for. */
|
||||
export interface MeasurementMaintenancePayload {
|
||||
vacuum: boolean;
|
||||
reindex: boolean;
|
||||
}
|
||||
|
||||
export async function handleMeasurementMaintenance(): Promise<JobOutcome> {
|
||||
return jobFailed("measurement maintenance not implemented");
|
||||
}
|
||||
@@ -39,6 +39,8 @@ import { BACKUP_RESTORE_EXPIRE_SECONDS } from "@/lib/jobs/backup-restore";
|
||||
import { RESTORE_DRILL_EXPIRE_SECONDS } from "@/lib/jobs/restore-drill";
|
||||
import { APPLE_HEALTH_IMPORT_SEND_OPTIONS } from "@/lib/jobs/apple-health-import-worker";
|
||||
import { MEDICATION_INTAKE_IMPORT_SEND_OPTIONS } from "@/lib/jobs/medication-intake-import";
|
||||
import { HEALTH_CONNECT_IMPORT_SEND_OPTIONS } from "@/lib/jobs/health-connect-import-worker";
|
||||
import { MEASUREMENT_MAINTENANCE_SEND_OPTIONS } from "@/lib/jobs/measurement-maintenance";
|
||||
|
||||
/** pg-boss's default job expiry, in seconds. */
|
||||
export const DEFAULT_JOB_EXPIRE_SECONDS = 15 * 60;
|
||||
@@ -351,6 +353,43 @@ export const QUEUE_RUNTIME: Readonly<Record<string, QueueRuntime>> = {
|
||||
"The legacy queue: moves each job onto apple-health-import-v2.",
|
||||
),
|
||||
"apple-health-import-reconcile": short("One updateMany."),
|
||||
"health-connect-import": {
|
||||
runtime: "long",
|
||||
expireInSeconds: HEALTH_CONNECT_IMPORT_SEND_OPTIONS.expireInSeconds,
|
||||
expiryVia: ["HEALTH_CONNECT_IMPORT_SEND_OPTIONS"],
|
||||
stop: {
|
||||
oneShot:
|
||||
"The run consumes and unlinks its staged upload, so it can neither stop half-way nor be retried; the send sets retryLimit 0.",
|
||||
},
|
||||
exclusive: {
|
||||
marker: "retryLimit: 0",
|
||||
at: {
|
||||
file: "lib/jobs/health-connect-import-worker.ts",
|
||||
fn: "HEALTH_CONNECT_IMPORT_SEND_OPTIONS",
|
||||
},
|
||||
},
|
||||
why: "Reads a Health Connect export database that can hold years of minute-level samples.",
|
||||
},
|
||||
"compaction-tombstone-purge": short(
|
||||
"At most 40 batches of 5 000 rows per run; the predicate is idempotent, so the next run resumes.",
|
||||
),
|
||||
"measurement-maintenance": {
|
||||
runtime: "long",
|
||||
expireInSeconds: MEASUREMENT_MAINTENANCE_SEND_OPTIONS.expireInSeconds,
|
||||
expiryVia: ["MEASUREMENT_MAINTENANCE_SEND_OPTIONS"],
|
||||
stop: {
|
||||
oneShot:
|
||||
"VACUUM and REINDEX CONCURRENTLY cannot be resumed mid-statement; the send sets retryLimit 0 and the operator re-triggers.",
|
||||
},
|
||||
exclusive: {
|
||||
marker: "retryLimit: 0",
|
||||
at: {
|
||||
file: "lib/jobs/measurement-maintenance.ts",
|
||||
fn: "MEASUREMENT_MAINTENANCE_SEND_OPTIONS",
|
||||
},
|
||||
},
|
||||
why: "Rebuilds every index of the largest table, one at a time.",
|
||||
},
|
||||
"medication-intake-import": {
|
||||
runtime: "long",
|
||||
expireInSeconds: MEDICATION_INTAKE_IMPORT_SEND_OPTIONS.expireInSeconds,
|
||||
|
||||
@@ -61,6 +61,23 @@ import {
|
||||
migrateLegacyAppleHealthImport,
|
||||
type AppleHealthImportPayload,
|
||||
} from "@/lib/jobs/apple-health-import-worker";
|
||||
import {
|
||||
COMPACTION_TOMBSTONE_PURGE_QUEUE,
|
||||
enqueueBootTimeCompactionTombstonePurge,
|
||||
handleCompactionTombstonePurge,
|
||||
type CompactionTombstonePurgePayload,
|
||||
} from "@/lib/jobs/compaction-tombstone-purge";
|
||||
import {
|
||||
MEASUREMENT_MAINTENANCE_QUEUE,
|
||||
handleMeasurementMaintenance,
|
||||
type MeasurementMaintenancePayload,
|
||||
} from "@/lib/jobs/measurement-maintenance";
|
||||
import {
|
||||
HEALTH_CONNECT_IMPORT_QUEUE,
|
||||
HEALTH_CONNECT_IMPORT_CONCURRENCY,
|
||||
handleHealthConnectImport,
|
||||
type HealthConnectImportPayload,
|
||||
} from "@/lib/jobs/health-connect-import-worker";
|
||||
import {
|
||||
MEDICATION_INTAKE_IMPORT_QUEUE,
|
||||
MEDICATION_INTAKE_IMPORT_CONCURRENCY,
|
||||
@@ -303,9 +320,11 @@ const INTAKE_SLOT_DEDUP_CRON = "28 3 * * *";
|
||||
const OFFHOST_BACKUP_CRON = "30 2 * * *";
|
||||
|
||||
const HOST_METRIC_QUEUE = "host-metric-sample";
|
||||
// Per-minute cadence — matches the chart's 60s polling refetchInterval.
|
||||
// v1.42 — every five minutes. A per-minute sample filled pg-boss's job
|
||||
// table with one row a minute for a chart nobody reads at that resolution.
|
||||
// The admin chart's polling interval (`host-metrics-chart.tsx`) has to match.
|
||||
|
||||
const HOST_METRIC_CRON = "* * * * *";
|
||||
const HOST_METRIC_CRON = "*/5 * * * *";
|
||||
// v1.4.16 phase B5e — daily rec-feedback aggregator. 04:00 Europe/Berlin
|
||||
// runs the slot AFTER all the cleanup jobs (rate-limit, idempotency,
|
||||
// audit-log) so the previous-day's noise is gone before we aggregate.
|
||||
@@ -436,6 +455,17 @@ const allQueues = [
|
||||
// that survives past the next worker boot is never revisited.
|
||||
IMPORT_JOB_RECONCILE_QUEUE,
|
||||
MEDICATION_INTAKE_IMPORT_QUEUE,
|
||||
// v1.42 (#972) — Android Health Connect export import, the Apple Health
|
||||
// import's sibling. Without this entry every upload would queue a job
|
||||
// nobody works.
|
||||
HEALTH_CONNECT_IMPORT_QUEUE,
|
||||
// v1.42 — backlog purge of compaction tombstones, queued once at boot.
|
||||
// Without this entry the boot enqueue silently no-ops.
|
||||
COMPACTION_TOMBSTONE_PURGE_QUEUE,
|
||||
// v1.42 — operator-triggered VACUUM / REINDEX of `measurements`. No
|
||||
// cron: the admin route enqueues it. Without this entry the trigger
|
||||
// silently no-ops.
|
||||
MEASUREMENT_MAINTENANCE_QUEUE,
|
||||
// v1.8.2 — one-time duplicate dose-slot cleanup. Boot discovery enqueues
|
||||
// one job per user holding two live intake rows that snap to the same
|
||||
// canonical slot (the pre-fix REMINDER-pending + API-taken pair). Also
|
||||
@@ -1084,6 +1114,30 @@ export async function registerMaintenanceQueues(
|
||||
return jobDone({ jobs: jobs.length });
|
||||
},
|
||||
);
|
||||
// v1.42 (#972) — Health Connect import. One at a time per worker, like the
|
||||
// Apple Health import: each run reads a whole export and consumes its
|
||||
// staged upload.
|
||||
await createAndWork<HealthConnectImportPayload>(
|
||||
boss,
|
||||
HEALTH_CONNECT_IMPORT_QUEUE,
|
||||
{ localConcurrency: HEALTH_CONNECT_IMPORT_CONCURRENCY },
|
||||
handleHealthConnectImport,
|
||||
);
|
||||
// v1.42 — compaction-tombstone backlog purge. Serial: each batch holds
|
||||
// an account against a concurrent restore.
|
||||
await createAndWork<CompactionTombstonePurgePayload>(
|
||||
boss,
|
||||
COMPACTION_TOMBSTONE_PURGE_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleCompactionTombstonePurge,
|
||||
);
|
||||
// v1.42 — measurement table maintenance, operator-triggered only.
|
||||
await createAndWork<MeasurementMaintenancePayload>(
|
||||
boss,
|
||||
MEASUREMENT_MAINTENANCE_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleMeasurementMaintenance,
|
||||
);
|
||||
// v1.32.1 (issue #588) — periodic orphan-ImportJob sweep. Single-flight:
|
||||
// the underlying `updateMany` is idempotent and two ticks racing the same
|
||||
// read-then-write pass would just be wasted work.
|
||||
@@ -1825,6 +1879,22 @@ export async function enqueueMaintenanceBootDiscovery(): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
// v1.42 — one compaction-tombstone purge run per boot. The run is
|
||||
// self-limiting and idempotent, so a boot with no backlog costs one query.
|
||||
try {
|
||||
const { enqueued } = await enqueueBootTimeCompactionTombstonePurge();
|
||||
workerLog(
|
||||
"info",
|
||||
`[compaction-tombstone-purge] boot discovery: enqueued=${enqueued}`,
|
||||
);
|
||||
} catch (err) {
|
||||
workerLog(
|
||||
"error",
|
||||
"[compaction-tombstone-purge] boot discovery threw an unexpected error",
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
||||
// v1.37.7 (issue #659) — runtime GeoLite2 fetch. Only enqueues when a
|
||||
// MAXMIND_LICENSE_KEY is set, egress is not disabled, and the offline tier
|
||||
// is not already present — so a self-hoster who sets the key on the
|
||||
|
||||
@@ -114,6 +114,9 @@ const subsystemSurface = {
|
||||
"apple-health-import-v2": { audience: "account" },
|
||||
"apple-health-import": { audience: "account" },
|
||||
"apple-health-import-reconcile": { audience: "account" },
|
||||
"health-connect-import": { audience: "account" },
|
||||
"compaction-tombstone-purge": { audience: "system" },
|
||||
"measurement-maintenance": { audience: "system" },
|
||||
"medication-intake-import": { audience: "system" },
|
||||
"intake-slot-dedup": { audience: "system" },
|
||||
"mood-reminder-cleanup": { audience: "system" },
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* The `folded_window` ingest guard (v1.42).
|
||||
*
|
||||
* The dense-intraday consolidation folds the raw Apple Health samples of a
|
||||
* DENSE type into one `stats:` row per local hour once they are older than
|
||||
* the retention window, and a MEAN type into one `stats:` row per local day
|
||||
* once the consolidation grace has passed. Since v1.42 the folded raw rows are
|
||||
* deleted outright rather than left as tombstones, so a client that re-uploads
|
||||
* an old sample after losing its anchor would put it back beside the hour or
|
||||
* day that already accounts for it. A raw (non-`stats:`) Apple Health value of
|
||||
* such a type, old enough to have been folded, is therefore a duplicate when a
|
||||
* live `stats:` row covers its hour or day. The batch route and the ZIP import
|
||||
* apply the same rule.
|
||||
*
|
||||
* Health Connect does not use this: its importer writes the folded shape
|
||||
* itself.
|
||||
*
|
||||
* Contract stub: the rules and the pure candidate check are final; the lookup
|
||||
* that finds the covering `stats:` rows returns nothing until it is built, so
|
||||
* nothing is refused yet.
|
||||
*/
|
||||
import type {
|
||||
MeasurementSource,
|
||||
MeasurementType,
|
||||
Prisma,
|
||||
} from "@/generated/prisma/client";
|
||||
import { HIGH_FREQUENCY_MEAN_TYPES } from "@/lib/measurements/apple-health-mapping";
|
||||
import { CONSOLIDATION_GRACE_CUTOFF_HOURS } from "@/lib/measurements/consolidation-tz";
|
||||
import {
|
||||
DENSE_INTRADAY_RETENTION_DAYS,
|
||||
DENSE_INTRADAY_RETENTION_TYPES,
|
||||
} from "@/lib/measurements/dense-intraday-retention";
|
||||
|
||||
/** The per-entry status reason the batch result carries for such a row. */
|
||||
export type FoldedWindowReason = "folded_window";
|
||||
|
||||
const HOUR_MS = 3_600_000;
|
||||
|
||||
/** Which types fold into which window, and from what age. */
|
||||
export const FOLDED_WINDOW_RULES = [
|
||||
{
|
||||
window: "hour",
|
||||
types: DENSE_INTRADAY_RETENTION_TYPES,
|
||||
foldedAfterMs: DENSE_INTRADAY_RETENTION_DAYS * 24 * HOUR_MS,
|
||||
},
|
||||
{
|
||||
window: "day",
|
||||
types: HIGH_FREQUENCY_MEAN_TYPES,
|
||||
foldedAfterMs: CONSOLIDATION_GRACE_CUTOFF_HOURS * HOUR_MS,
|
||||
},
|
||||
] as const;
|
||||
|
||||
/** The fields of an incoming row the guard reads. */
|
||||
export interface FoldedWindowCandidateRow {
|
||||
type: MeasurementType;
|
||||
source: MeasurementSource;
|
||||
externalId: string | null;
|
||||
measuredAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a row is old enough, and of the right kind, for its hour or day to
|
||||
* have been folded already. Pure: whether a live `stats:` row actually covers
|
||||
* it is the lookup's question.
|
||||
*/
|
||||
export function isFoldedWindowCandidate(
|
||||
row: FoldedWindowCandidateRow,
|
||||
now: Date = new Date(),
|
||||
): boolean {
|
||||
if (row.source !== "APPLE_HEALTH") return false;
|
||||
if (row.externalId?.startsWith("stats:")) return false;
|
||||
const age = now.getTime() - row.measuredAt.getTime();
|
||||
return FOLDED_WINDOW_RULES.some(
|
||||
(rule) => rule.types.has(row.type) && age > rule.foldedAfterMs,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Indexes (into `rows`) of the candidates whose hour or day a live `stats:`
|
||||
* row already covers. Stub: returns none.
|
||||
*/
|
||||
export async function findFoldedWindowDuplicates(
|
||||
tx: Prisma.TransactionClient,
|
||||
userId: string,
|
||||
rows: readonly FoldedWindowCandidateRow[],
|
||||
): Promise<Set<number>> {
|
||||
void tx;
|
||||
void userId;
|
||||
void rows;
|
||||
return new Set();
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Which measurement types an account has live rows of (v1.42).
|
||||
*
|
||||
* Several readers ask this ("which types exist?") on the request path and in
|
||||
* nightly passes. Since v1.42 they ask through this one helper, so the query
|
||||
* can move to a loose index scan over `measurements_live_covering_idx`
|
||||
* without each caller re-learning the plan.
|
||||
*
|
||||
* Contract stub: the signature is final; the body is the `groupBy` the callers
|
||||
* run today.
|
||||
*/
|
||||
import type {
|
||||
MeasurementSource,
|
||||
MeasurementType,
|
||||
} from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export async function listLiveMeasurementTypes(
|
||||
userId: string,
|
||||
options: {
|
||||
source?: MeasurementSource;
|
||||
types?: readonly MeasurementType[];
|
||||
} = {},
|
||||
): Promise<MeasurementType[]> {
|
||||
const rows = await prisma.measurement.groupBy({
|
||||
by: ["type"],
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
...(options.source ? { source: options.source } : {}),
|
||||
...(options.types ? { type: { in: [...options.types] } } : {}),
|
||||
},
|
||||
orderBy: { type: "asc" },
|
||||
});
|
||||
return rows.map((row) => row.type);
|
||||
}
|
||||
@@ -403,6 +403,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
"modules_conflict",
|
||||
"mood_tag_layout_conflict",
|
||||
"not_configured",
|
||||
"not_implemented",
|
||||
"notification_prefs_conflict",
|
||||
"oidc_only",
|
||||
"private_origin_not_approved",
|
||||
|
||||
@@ -38,7 +38,12 @@ import {
|
||||
} from "@/lib/validations/managed-profiles";
|
||||
import { onboardingStateResource } from "./onboarding";
|
||||
import { aiAccountBlock, moduleAccessMap } from "./profile";
|
||||
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
|
||||
import {
|
||||
dataEnvelope,
|
||||
errorEnvelope,
|
||||
notImplementedResponse,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
|
||||
/**
|
||||
* The four request bodies this table publishes, and the one thing to know about
|
||||
@@ -545,7 +550,113 @@ const managedProfileEnvelope = dataEnvelope(
|
||||
"ManagedProfileEnvelope",
|
||||
);
|
||||
|
||||
// v1.42 (#959) — handing a managed profile over to the person it describes.
|
||||
const handoverProposal = z
|
||||
.enum(["end", "read", "manage"])
|
||||
.describe(
|
||||
"What a guardian's access becomes after the handover: ended, view only, or still managing. The new owner confirms or changes it when claiming.",
|
||||
)
|
||||
.meta({ id: "ManagedProfileHandoverProposal" });
|
||||
|
||||
const createHandoverRequest = z
|
||||
.object({
|
||||
expiresInDays: z
|
||||
.union([z.literal(1), z.literal(7), z.literal(14)])
|
||||
.default(7)
|
||||
.describe("How long the link stays valid."),
|
||||
proposals: z
|
||||
.array(
|
||||
z.object({
|
||||
grantId: z.string().describe("The guardian's MANAGE grant."),
|
||||
proposal: handoverProposal,
|
||||
}),
|
||||
)
|
||||
.describe(
|
||||
"One proposal per active guardian. A guardian left out is proposed `read`.",
|
||||
),
|
||||
})
|
||||
.strict()
|
||||
.meta({ id: "CreateManagedProfileHandoverRequest" });
|
||||
|
||||
const managedProfileHandover = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
token: z
|
||||
.string()
|
||||
.describe(
|
||||
"The one-time `hlp_` token. Returned only in this response and never again; only its hash is stored.",
|
||||
),
|
||||
url: z.string().describe("The link to hand over: `<app>/claim/<token>`."),
|
||||
expiresAt: z.iso.datetime({ offset: true }),
|
||||
})
|
||||
.meta({ id: "ManagedProfileHandover" });
|
||||
|
||||
export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/managed-profiles/{id}/handover": {
|
||||
post: {
|
||||
tags: ["Account sharing"],
|
||||
summary: "Create a handover link for a managed profile",
|
||||
description:
|
||||
"Mints the one-time link that lets the person a managed profile describes take it over as their own account. No data moves: claiming sets credentials on the profile's own record. Each guardian's access afterwards follows the proposal recorded here unless the new owner changes it. Creating a new link withdraws an open one. Pending MANAGE invitations on the profile are withdrawn when it is claimed. Cookie-only and step-up gated, like every act on a managed profile. Refused on an instance that allows single sign-on only.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: createHandoverRequest } },
|
||||
},
|
||||
responses: {
|
||||
...stdResponses,
|
||||
"201": {
|
||||
description: "The link, shown once.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
managedProfileHandover,
|
||||
"ManagedProfileHandoverEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"403": {
|
||||
description:
|
||||
"The instance allows single sign-on only (`meta.errorCode: profile_claim.oidc_only_unsupported`).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"404": {
|
||||
description:
|
||||
"No such managed profile, or the caller is not one of its Guardians (`meta.errorCode: managed_profile.not_found`).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...notImplementedResponse,
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["Account sharing"],
|
||||
summary: "Withdraw the open handover link",
|
||||
description:
|
||||
"Withdraws the profile's open handover link, if there is one; the link then answers like any unknown one. Cookie-only; no step-up, because withdrawing only takes something away.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
responses: {
|
||||
...stdResponses,
|
||||
"200": {
|
||||
description: "Withdrawn, or there was no open link.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ revoked: z.boolean() }),
|
||||
"RevokeManagedProfileHandoverEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description:
|
||||
"No such managed profile, or the caller is not one of its Guardians (`meta.errorCode: managed_profile.not_found`).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...notImplementedResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/auth/me": {
|
||||
get: {
|
||||
tags: ["Auth"],
|
||||
|
||||
@@ -9,7 +9,12 @@ import { z } from "zod/v4";
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import { inviteCreateSchema } from "@/lib/validations/invite";
|
||||
import { adminReminderCheckSchema } from "@/lib/validations/notifications";
|
||||
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
|
||||
import {
|
||||
dataEnvelope,
|
||||
errorEnvelope,
|
||||
notImplementedResponse,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
|
||||
// v1.4.48 H-APNs-1 — admin diagnostic endpoint for the notification
|
||||
// subsystem. Mirrors the runtime types in
|
||||
@@ -243,7 +248,72 @@ const adminReminderCheckResult = z
|
||||
"What the sweep found and what it sent. `notificationsSent` counts DISPATCHES, not deliveries — each one enters the channel cascade and the per-channel verdict lands in the `push_attempts` ledger the notification diagnostic surfaces.",
|
||||
});
|
||||
|
||||
// v1.42 — operator-triggered maintenance of the measurements table.
|
||||
const adminMeasurementMaintenanceRequest = z
|
||||
.object({
|
||||
vacuum: z
|
||||
.boolean()
|
||||
.default(true)
|
||||
.describe("Run `VACUUM (ANALYZE)` on `measurements`."),
|
||||
reindex: z
|
||||
.boolean()
|
||||
.default(true)
|
||||
.describe(
|
||||
"Rebuild every index of `measurements` with `REINDEX INDEX CONCURRENTLY`, largest first, one at a time.",
|
||||
),
|
||||
})
|
||||
.strict()
|
||||
.meta({ id: "AdminMeasurementMaintenanceRequest" });
|
||||
|
||||
const adminMeasurementMaintenanceResult = z
|
||||
.object({
|
||||
enqueued: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"False when a maintenance run is already queued or running; nothing new was queued.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "AdminMeasurementMaintenanceResult" });
|
||||
|
||||
export const adminDiagnosticPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/admin/maintenance/measurements": {
|
||||
post: {
|
||||
tags: ["Admin"],
|
||||
summary: "Queue measurement table maintenance",
|
||||
description:
|
||||
"Queues one `VACUUM (ANALYZE)` and `REINDEX INDEX CONCURRENTLY` pass over the `measurements` table, for after a large purge. Runs in the background worker, one statement at a time; pick a quiet window, because a rebuild needs free disk the size of the index it rebuilds. At most one run at a time. Cookie auth only: `requireAdmin()` refuses every Bearer caller. Runbook: `docs/ops/measurement-maintenance.md`.",
|
||||
requestBody: {
|
||||
required: false,
|
||||
content: {
|
||||
"application/json": { schema: adminMeasurementMaintenanceRequest },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"202": {
|
||||
description: "Queued, or already queued.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
adminMeasurementMaintenanceResult,
|
||||
"AdminMeasurementMaintenanceResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"403": {
|
||||
description: "Caller is not an admin.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"503": {
|
||||
description:
|
||||
"No background worker is bound, so nothing would run it.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
...notImplementedResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/admin/notifications/reminder-check": {
|
||||
post: {
|
||||
tags: ["Admin"],
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
stdResponses,
|
||||
errorEnvelope,
|
||||
loginPasswordSchema,
|
||||
notImplementedResponse,
|
||||
} from "./shared";
|
||||
import { passkeyLoginOptionsSchema } from "@/lib/validations/auth";
|
||||
|
||||
@@ -697,7 +698,137 @@ const codexDevicePollResponse = z
|
||||
})
|
||||
.meta({ id: "CodexDevicePollResponse" });
|
||||
|
||||
// v1.42 (#959) — claiming a managed profile through its handover link.
|
||||
const claimTokenField = z
|
||||
.string()
|
||||
.describe(
|
||||
"The `hlp_` token from the handover link. Sent in the body, never in a URL.",
|
||||
);
|
||||
|
||||
const claimPreviewRequest = z
|
||||
.object({ token: claimTokenField })
|
||||
.strict()
|
||||
.meta({ id: "ProfileClaimPreviewRequest" });
|
||||
|
||||
const claimPreview = z
|
||||
.object({
|
||||
displayName: z.string(),
|
||||
expiresAt: z.iso.datetime({ offset: true }),
|
||||
guardians: z.array(
|
||||
z.object({
|
||||
grantId: z.string(),
|
||||
displayName: z.string(),
|
||||
proposal: z.enum(["end", "read", "manage"]),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.meta({ id: "ProfileClaimPreview" });
|
||||
|
||||
const claimRequest = z
|
||||
.object({
|
||||
token: claimTokenField,
|
||||
username: z.string(),
|
||||
email: z.string().describe("Required: the account needs a way back in."),
|
||||
password: z.string(),
|
||||
guardians: z
|
||||
.array(
|
||||
z.object({
|
||||
grantId: z.string(),
|
||||
decision: z.enum(["end", "read", "manage"]),
|
||||
}),
|
||||
)
|
||||
.describe("The new owner's decision per guardian."),
|
||||
})
|
||||
.strict()
|
||||
.meta({ id: "ProfileClaimRequest" });
|
||||
|
||||
export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/auth/claim/preview": {
|
||||
post: {
|
||||
// No credential: the person claiming the profile has none yet. The
|
||||
// one-time token in the body is the credential.
|
||||
security: [],
|
||||
tags: ["Auth"],
|
||||
summary: "Preview a managed-profile handover link",
|
||||
description:
|
||||
"What a handover link would hand over: the profile's display name, the link's expiry, and each guardian's proposal. Anonymous; the token travels in the body. An unknown, expired, used or withdrawn token, and a link whose creator lost access, all answer the same 404. Rate-limited per source address.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: claimPreviewRequest } },
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The preview.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(claimPreview, "ProfileClaimPreviewEnvelope"),
|
||||
},
|
||||
},
|
||||
},
|
||||
"403": {
|
||||
description:
|
||||
"The instance allows single sign-on only (`meta.errorCode: profile_claim.oidc_only_unsupported`).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"404": {
|
||||
description:
|
||||
"No usable link behind this token (`meta.errorCode: profile_claim.invalid`). Every failure class answers identically.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"409": {
|
||||
description:
|
||||
"The request carries a signed-in session (`meta.errorCode: auth.already_authenticated`); sign out first.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
...notImplementedResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/auth/claim": {
|
||||
post: {
|
||||
// No credential: see the preview above.
|
||||
security: [],
|
||||
tags: ["Auth"],
|
||||
summary: "Claim a managed profile",
|
||||
description:
|
||||
"Turns the managed profile behind a handover link into the claimant's own account: sets the username, email and password on the profile's record, clears the managed marker, settles each guardian's access by the claimant's decision, withdraws pending MANAGE invitations, and resets the disclaimer, onboarding and AI consent so the new owner gives them personally. No data moves. Signs the new owner in. Anonymous; the token travels in the body, and every token failure answers the same 404.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: claimRequest } },
|
||||
},
|
||||
responses: {
|
||||
"201": {
|
||||
description: "Claimed; the response sets the session cookie.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ userId: z.string(), username: z.string() }),
|
||||
"ProfileClaimEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"403": {
|
||||
description:
|
||||
"The instance allows single sign-on only (`meta.errorCode: profile_claim.oidc_only_unsupported`).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"404": {
|
||||
description:
|
||||
"No usable link behind this token (`meta.errorCode: profile_claim.invalid`).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"409": {
|
||||
description:
|
||||
"The request carries a signed-in session (`meta.errorCode: auth.already_authenticated`), or the username or email is taken.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
...notImplementedResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/auth/login": {
|
||||
post: {
|
||||
// No credential: this operation is reachable before one exists.
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
dataEnvelope,
|
||||
errorEnvelope,
|
||||
moduleDisabledResponse,
|
||||
notImplementedResponse,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
|
||||
@@ -159,7 +160,58 @@ const geocodeResult = z
|
||||
})
|
||||
.meta({ id: "EnvironmentGeocodeResult" });
|
||||
|
||||
// v1.42 (#615) — the per-user environment switches.
|
||||
const environmentPreferencesRequest = z
|
||||
.object({
|
||||
airQualityEnabled: z
|
||||
.boolean()
|
||||
.describe("Fetch and show air quality, pollen and UV for this account."),
|
||||
})
|
||||
.strict()
|
||||
.meta({ id: "EnvironmentPreferencesRequest" });
|
||||
|
||||
const environmentPreferences = z
|
||||
.object({
|
||||
airQualityEnabled: z.boolean(),
|
||||
operatorDisabled: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"True when the operator turned air quality off for the whole instance; the account switch then has no effect.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "EnvironmentPreferences" });
|
||||
|
||||
export const environmentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/environment/preferences": {
|
||||
patch: {
|
||||
tags: ["Environment"],
|
||||
summary: "Change the environment switches",
|
||||
description:
|
||||
"Turns the air-quality part of the module on or off for this account (on by default). Off means the nightly fetch skips the air-quality feed and the overview leaves the fields out; days already stored are kept. Module-gated. Strict body.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: environmentPreferencesRequest },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The stored switches.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
environmentPreferences,
|
||||
"EnvironmentPreferencesEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...moduleDisabledResponse,
|
||||
...stdResponses,
|
||||
...notImplementedResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/environment": {
|
||||
get: {
|
||||
tags: ["Environment"],
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
dataEnvelope,
|
||||
errorEnvelope,
|
||||
measurementTypeEnum,
|
||||
notImplementedResponse,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
|
||||
@@ -177,7 +178,124 @@ const jsonImportStats = z
|
||||
description: "Per-section outcome counts of a JSON import.",
|
||||
});
|
||||
|
||||
// v1.42 (#972) — Android Health Connect export import.
|
||||
const healthConnectImportJob = z
|
||||
.object({
|
||||
jobId: z.string(),
|
||||
status: z
|
||||
.string()
|
||||
.describe(
|
||||
"`queued`, `unpacking`, `parsing`, `upserting`, `done` or `failed`. A plain string, like the Apple Health status.",
|
||||
),
|
||||
startedAt: z.iso.datetime({ offset: true }),
|
||||
completedAt: z.iso.datetime({ offset: true }).nullable(),
|
||||
uploadBytes: z.number().int(),
|
||||
progress: z
|
||||
.record(z.string(), z.unknown())
|
||||
.describe("Free-form phase progress. Shape is not part of the contract."),
|
||||
result: z
|
||||
.record(z.string(), z.unknown())
|
||||
.nullable()
|
||||
.describe("The worker's outcome summary, null until it finishes."),
|
||||
failureReason: z.string().nullable(),
|
||||
})
|
||||
.meta({ id: "HealthConnectImportJob" });
|
||||
|
||||
export const importPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/import/health-connect-export": {
|
||||
post: {
|
||||
tags: ["Import"],
|
||||
summary: "Upload a Health Connect export for background import",
|
||||
description:
|
||||
"Takes the ZIP the Android Health Connect app writes (it holds `health_connect_export.db`) and hands it to a background worker. The response is **202 with a `jobId`**; poll `GET /api/import/health-connect-export/status`. Body is `multipart/form-data` with the file under `file`, streamed to disk after authentication like the Apple Health upload. Re-uploading the same bytes resolves to the still-viable job and returns `idempotent: true`. Imported rows carry `source = HEALTH_CONNECT`. Cookie or wildcard Bearer.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"multipart/form-data": {
|
||||
schema: z.object({
|
||||
file: z
|
||||
.string()
|
||||
.describe(
|
||||
"The Health Connect export ZIP, under the field name `file`.",
|
||||
),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"202": {
|
||||
description:
|
||||
"Accepted and queued, or matched to a job already carrying these bytes.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({
|
||||
jobId: z.string(),
|
||||
status: z.enum([
|
||||
"queued",
|
||||
"unpacking",
|
||||
"parsing",
|
||||
"upserting",
|
||||
"done",
|
||||
]),
|
||||
idempotent: z.boolean().optional(),
|
||||
}),
|
||||
"HealthConnectImportKickoffEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"400": {
|
||||
description: "The request carried no body at all.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"409": {
|
||||
description:
|
||||
"An import for this account is already queued or running with different bytes. The upload was discarded.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"413": {
|
||||
description: "The declared `Content-Length` exceeds the upload cap.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
"422": {
|
||||
description:
|
||||
"The upload is not a Health Connect export the importer can read: no `file` part, no export database in the ZIP, or a database version older than the importer supports.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"503": {
|
||||
description:
|
||||
"No background worker is bound, so nothing would run the import.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...notImplementedResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/import/health-connect-export/status": {
|
||||
get: {
|
||||
tags: ["Import"],
|
||||
summary: "The caller's latest Health Connect import",
|
||||
description:
|
||||
"The most recent Health Connect import job of the caller, or `null` when there has never been one.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The latest job, or null.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ job: healthConnectImportJob.nullable() }),
|
||||
"HealthConnectImportStatusEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
...notImplementedResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/import/csv": {
|
||||
post: {
|
||||
tags: ["Import"],
|
||||
|
||||
@@ -49,7 +49,7 @@ const healthKitDirectionEnum = z
|
||||
|
||||
// Mirror the batch route's `syncTriggerEnum` — what woke the client for a sync.
|
||||
const healthKitSyncTriggerEnum = z
|
||||
.enum(["foreground", "background", "push"])
|
||||
.enum(["foreground", "background", "push", "manual"])
|
||||
.describe("What triggered a HealthKit batch.");
|
||||
|
||||
// Resolved entry (defaults merged): `kind` + `enabled` are always present.
|
||||
|
||||
@@ -127,10 +127,10 @@ const batchPayloadSchema = z
|
||||
// used in attribution — sending it or omitting it produces byte-identical
|
||||
// row outcomes. Omit on any client that does not track its wake reason.
|
||||
syncTrigger: z
|
||||
.enum(["foreground", "background", "push"])
|
||||
.enum(["foreground", "background", "push", "manual"])
|
||||
.optional()
|
||||
.describe(
|
||||
"Diagnostic-only. Names what triggered this sync (foreground app open, background refresh, or a push wake). Recorded on the ingest wide event and kept on the account as `lastSyncTrigger` — plus `lastBackgroundSyncAt` for a `background` or `push` trigger — both readable from `GET /api/integrations/healthkit`. It does not affect dedup, attribution, or how any sample is stored. Optional and backward-compatible: pre-#66 clients omit it.",
|
||||
'Diagnostic-only. Names what triggered this sync (foreground app open, background refresh, a push wake, or a user-initiated `manual` "Sync all" run, since v1.42). Recorded on the ingest wide event and kept on the account as `lastSyncTrigger` — plus `lastBackgroundSyncAt` for a `background` or `push` trigger — both readable from `GET /api/integrations/healthkit`. It does not affect dedup, attribution, or how any sample is stored. Optional and backward-compatible: pre-#66 clients omit it.',
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
|
||||
@@ -269,6 +269,21 @@ export const malformedJsonResponse = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The 501 a route answers while its contract is published ahead of its
|
||||
* implementation on a release branch. Every stub carries it; the owner of the
|
||||
* route drops it with the stub. No tagged release answers it: the error-code
|
||||
* catalogue check fails once `not_implemented` is listed and nothing emits
|
||||
* it, and fails while something still does and it is not listed.
|
||||
*/
|
||||
export const notImplementedResponse = {
|
||||
"501": {
|
||||
description:
|
||||
"Not implemented yet: the contract is published ahead of the implementation on a release branch. `meta.errorCode: not_implemented`.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
} as const;
|
||||
|
||||
// ── Standard 401 / 422 / 429 responses ───────────────────────────────
|
||||
|
||||
export const stdResponses = {
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
errorEnvelope,
|
||||
idempotencyKeyParameter,
|
||||
idempotentWrite,
|
||||
notImplementedResponse,
|
||||
recordRefusal,
|
||||
recordWriteRateLimitResponse,
|
||||
stdResponses,
|
||||
@@ -243,7 +244,150 @@ const vaccinationNotFound = {
|
||||
|
||||
const idPath = { path: z.object({ id: z.string() }) };
|
||||
|
||||
// v1.42 (#1005) — the record's own vaccine definitions.
|
||||
const customVaccine = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
components: z
|
||||
.array(z.string())
|
||||
.describe("Catalogue antigen slugs the product covers."),
|
||||
typicalSeriesDoses: z.number().int().nullable(),
|
||||
boosterIntervalMonths: z.number().int().nullable(),
|
||||
createdAt: z.iso.datetime({ offset: true }),
|
||||
updatedAt: z.iso.datetime({ offset: true }),
|
||||
})
|
||||
.meta({ id: "CustomVaccine" });
|
||||
|
||||
const customVaccineCreateRequest = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
components: z.array(z.string()),
|
||||
typicalSeriesDoses: z.number().int().nullable().optional(),
|
||||
boosterIntervalMonths: z.number().int().nullable().optional(),
|
||||
})
|
||||
.strict()
|
||||
.meta({ id: "CreateCustomVaccineRequest" });
|
||||
|
||||
const customVaccineUpdateRequest = customVaccineCreateRequest
|
||||
.partial()
|
||||
.meta({ id: "UpdateCustomVaccineRequest" });
|
||||
|
||||
export const vaccinationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/vaccinations/custom": {
|
||||
get: {
|
||||
tags: ["Records"],
|
||||
summary: "List the record's own vaccines",
|
||||
description:
|
||||
"Vaccine definitions the record's owner added for products the shipped catalogue does not list. Soft-deleted definitions are excluded.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The definitions, by name.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.array(customVaccine),
|
||||
"ListCustomVaccinesEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
...notImplementedResponse,
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ["Records"],
|
||||
summary: "Add an own vaccine",
|
||||
description:
|
||||
"Defines a vaccine in the same shape a catalogue entry resolves to, so series and booster logic treat both alike. Names are unique per record.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: customVaccineCreateRequest } },
|
||||
},
|
||||
responses: {
|
||||
"201": {
|
||||
description: "Created.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
customVaccine,
|
||||
"CreateCustomVaccineEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"409": {
|
||||
description: "A live definition with this name already exists.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
...recordWriteRateLimitResponse,
|
||||
...notImplementedResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/vaccinations/custom/{id}": {
|
||||
patch: {
|
||||
tags: ["Records"],
|
||||
summary: "Edit an own vaccine",
|
||||
description: "Changes one definition. Owner-scoped; an unknown id 404s.",
|
||||
requestParams: idPath,
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: customVaccineUpdateRequest } },
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Updated.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
customVaccine,
|
||||
"UpdateCustomVaccineEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description: "No such definition on this record.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"409": {
|
||||
description: "A live definition with this name already exists.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
...notImplementedResponse,
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["Records"],
|
||||
summary: "Remove an own vaccine",
|
||||
description:
|
||||
"Soft-deletes one definition. Doses logged against it stay, on their `vaccineName`.",
|
||||
requestParams: idPath,
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Removed.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ deleted: z.boolean() }),
|
||||
"DeleteCustomVaccineEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description: "No such definition on this record.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
...notImplementedResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/vaccinations": {
|
||||
get: {
|
||||
tags: ["Records"],
|
||||
|
||||
@@ -1725,6 +1725,58 @@ export const SIGNALS: Record<string, SignalDefinition> = {
|
||||
coachSnapshot: false,
|
||||
mcp: false,
|
||||
},
|
||||
}, // ── v1.42 (#615) air-quality exposure channels ───────────────────────────
|
||||
// Three correlation channels out of the air-quality feed: fine particles,
|
||||
// the ozone 8-hour high, and the day's highest pollen count. UV, NO2 and
|
||||
// dust are display and Coach context only. Same surface flags as the weather
|
||||
// channels above; the channel series builder carries the wiring.
|
||||
ENV_PM25: {
|
||||
key: "ENV_PM25",
|
||||
kind: "environment",
|
||||
source: { environmentChannelKey: "ENV_PM25" },
|
||||
i18nPrefix: "environment.fields.pm25",
|
||||
displayName: "Fine particles (PM2.5)",
|
||||
unit: "µg/m³",
|
||||
direction: "target-band",
|
||||
archetype: "environmental-exposure",
|
||||
surfaces: {
|
||||
detailPage: false,
|
||||
correlationEligible: true,
|
||||
coachSnapshot: false,
|
||||
mcp: false,
|
||||
},
|
||||
},
|
||||
ENV_OZONE_8H: {
|
||||
key: "ENV_OZONE_8H",
|
||||
kind: "environment",
|
||||
source: { environmentChannelKey: "ENV_OZONE_8H" },
|
||||
i18nPrefix: "environment.fields.ozone8h",
|
||||
displayName: "Ozone (8-hour high)",
|
||||
unit: "µg/m³",
|
||||
direction: "target-band",
|
||||
archetype: "environmental-exposure",
|
||||
surfaces: {
|
||||
detailPage: false,
|
||||
correlationEligible: true,
|
||||
coachSnapshot: false,
|
||||
mcp: false,
|
||||
},
|
||||
},
|
||||
ENV_POLLEN_MAX: {
|
||||
key: "ENV_POLLEN_MAX",
|
||||
kind: "environment",
|
||||
source: { environmentChannelKey: "ENV_POLLEN_MAX" },
|
||||
i18nPrefix: "environment.fields.pollenMax",
|
||||
displayName: "Pollen (highest)",
|
||||
unit: "grains/m³",
|
||||
direction: "target-band",
|
||||
archetype: "environmental-exposure",
|
||||
surfaces: {
|
||||
detailPage: false,
|
||||
correlationEligible: true,
|
||||
coachSnapshot: false,
|
||||
mcp: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
parseSourcePriority,
|
||||
sourcePrioritySchema,
|
||||
} from "../source-priority";
|
||||
import { measurementSourceEnum } from "../measurement";
|
||||
|
||||
/**
|
||||
* v1.4.25 W5e — per-user, per-metric-class source-priority foundation.
|
||||
@@ -116,21 +117,17 @@ describe("sourcePrioritySchema", () => {
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects more than 8 sources in a single metric class", () => {
|
||||
const result = sourcePrioritySchema.safeParse({
|
||||
weight: [
|
||||
"WITHINGS",
|
||||
"APPLE_HEALTH",
|
||||
"MANUAL",
|
||||
"IMPORT",
|
||||
"WITHINGS",
|
||||
"APPLE_HEALTH",
|
||||
"MANUAL",
|
||||
"IMPORT",
|
||||
"WITHINGS",
|
||||
],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
// The cap is the number of sources: every source may be ranked once (a
|
||||
// ladder that ranks all sixteen is valid), and one entry more is refused.
|
||||
it("accepts a ladder that ranks every source, and rejects one entry more", () => {
|
||||
const every = [...measurementSourceEnum.options];
|
||||
expect(every.length).toBeGreaterThan(8);
|
||||
expect(sourcePrioritySchema.safeParse({ weight: every }).success).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
sourcePrioritySchema.safeParse({ weight: [...every, "MANUAL"] }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a non-array value for a metric class", () => {
|
||||
|
||||
@@ -1091,6 +1091,10 @@ const labResultBackupSchema = z
|
||||
sourceReferenceLow: z.number().nullable().optional(),
|
||||
sourceReferenceHigh: z.number().nullable().optional(),
|
||||
sourceReferenceText: z.string().nullable().optional(),
|
||||
// v1.42 (#1095) — the value and unit as printed, kept beside a converted
|
||||
// reading. Optional: absent on every file written before the columns.
|
||||
sourceValue: z.number().nullable().optional(),
|
||||
sourceUnit: z.string().nullable().optional(),
|
||||
takenAt: isoDateTime,
|
||||
source: z.string().min(1),
|
||||
biomarkerName: z.string().nullable().optional(),
|
||||
@@ -1112,6 +1116,8 @@ const biomarkerBackupSchema = z
|
||||
upperBound: z.number().nullable().optional(),
|
||||
panel: z.string().nullable().optional(),
|
||||
hidden: z.boolean().optional(),
|
||||
// v1.42 (#1095) — the catalogue analyte the marker resolves to.
|
||||
analyteKey: z.string().nullable().optional(),
|
||||
context: z.string().nullable().optional(),
|
||||
createdAt: isoDateTime.optional(),
|
||||
updatedAt: isoDateTime.optional(),
|
||||
@@ -1293,6 +1299,9 @@ const vaccinationBackupSchema = z
|
||||
// Remapped against the restored reminders (they travel since v1.37.20);
|
||||
// dropped to NULL, with the drop named, only when the file lacks the row.
|
||||
reminderId: z.string().nullable().optional(),
|
||||
// v1.42 (#1005) — the person's own vaccine definition the dose names;
|
||||
// remapped against the restored definitions like `reminderId`.
|
||||
customVaccineId: z.string().nullable().optional(),
|
||||
// Ciphertext on a disaster-recovery file, readable on a portable one.
|
||||
noteEncrypted: base64BytesSchema.nullable().optional(),
|
||||
note: z.string().nullable().optional(),
|
||||
@@ -1421,9 +1430,12 @@ const environmentDayKey = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, {
|
||||
const environmentContextBackupSchema = z
|
||||
.object({
|
||||
date: environmentDayKey,
|
||||
lat: z.number(),
|
||||
lon: z.number(),
|
||||
locationLabel: z.string(),
|
||||
// Nullable since v1.42 (migration 0382): once the encryption backfill has
|
||||
// run, the location travels sealed in `locationEncrypted` instead.
|
||||
lat: z.number().nullable(),
|
||||
lon: z.number().nullable(),
|
||||
locationLabel: z.string().nullable(),
|
||||
locationEncrypted: base64BytesSchema.nullable().optional(),
|
||||
source: z.enum(EnvironmentLocationSource),
|
||||
tempMin: z.number().nullable().optional(),
|
||||
tempMax: z.number().nullable().optional(),
|
||||
@@ -1437,6 +1449,29 @@ const environmentContextBackupSchema = z
|
||||
humidityMean: z.number().nullable().optional(),
|
||||
cloudMean: z.number().nullable().optional(),
|
||||
weatherCode: z.number().int().nullable().optional(),
|
||||
// v1.42 (#615) — air quality, pollen and UV. Optional throughout.
|
||||
apparentMax: z.number().nullable().optional(),
|
||||
pm25Mean: z.number().nullable().optional(),
|
||||
pm25Max: z.number().nullable().optional(),
|
||||
pm10Mean: z.number().nullable().optional(),
|
||||
no2Mean: z.number().nullable().optional(),
|
||||
so2Mean: z.number().nullable().optional(),
|
||||
coMean: z.number().nullable().optional(),
|
||||
o3Max8h: z.number().nullable().optional(),
|
||||
eaqiMax: z.number().nullable().optional(),
|
||||
usaqiMax: z.number().nullable().optional(),
|
||||
uvIndexMax: z.number().nullable().optional(),
|
||||
dustMax: z.number().nullable().optional(),
|
||||
aodMax: z.number().nullable().optional(),
|
||||
pollenAlderMax: z.number().nullable().optional(),
|
||||
pollenBirchMax: z.number().nullable().optional(),
|
||||
pollenGrassMax: z.number().nullable().optional(),
|
||||
pollenMugwortMax: z.number().nullable().optional(),
|
||||
pollenOliveMax: z.number().nullable().optional(),
|
||||
pollenRagweedMax: z.number().nullable().optional(),
|
||||
aqDomain: z.string().nullable().optional(),
|
||||
aqHours: z.number().int().nullable().optional(),
|
||||
aqFetchedAt: isoDateTime.nullable().optional(),
|
||||
fetchedAt: isoDateTime.optional(),
|
||||
createdAt: isoDateTime.optional(),
|
||||
updatedAt: isoDateTime.optional(),
|
||||
@@ -1452,9 +1487,11 @@ const environmentTravelLocationBackupSchema = z
|
||||
.object({
|
||||
startDate: environmentDayKey,
|
||||
endDate: environmentDayKey,
|
||||
lat: z.number(),
|
||||
lon: z.number(),
|
||||
label: z.string(),
|
||||
// Nullable since v1.42 (migration 0382), as above.
|
||||
lat: z.number().nullable(),
|
||||
lon: z.number().nullable(),
|
||||
label: z.string().nullable(),
|
||||
locationEncrypted: base64BytesSchema.nullable().optional(),
|
||||
createdAt: isoDateTime.optional(),
|
||||
updatedAt: isoDateTime.optional(),
|
||||
})
|
||||
@@ -1903,6 +1940,10 @@ const accountSettingsBackupSchema = z
|
||||
homeLabel: z.string().nullable().optional(),
|
||||
homeTimezone: z.string().nullable().optional(),
|
||||
homeSince: isoDateTime.nullable().optional(),
|
||||
// v1.42 (#615) — the sealed home location rides a disaster-recovery file
|
||||
// as ciphertext; a portable file carries the readable home above.
|
||||
homeLocationEncrypted: base64BytesSchema.nullable().optional(),
|
||||
environmentAirQualityEnabled: z.boolean().optional(),
|
||||
timezone: z.string().optional(),
|
||||
locale: z.string().nullable().optional(),
|
||||
unitPreference: z.string().nullable().optional(),
|
||||
|
||||
@@ -234,6 +234,12 @@ export const measurementSourceEnum = z.enum([
|
||||
// above these rows stay value-editable; see
|
||||
// `USER_CORRECTABLE_MEASUREMENT_SOURCES`.
|
||||
"EXTERNAL",
|
||||
// v1.42 — rows imported from an Android Health Connect export (#972).
|
||||
// Server-owned: only the Health Connect import worker writes them, so the
|
||||
// value is deliberately absent from `WRITABLE_MEASUREMENT_SOURCES` and the
|
||||
// batch allowlist, like IMPORT. Part of this enum so the read/response
|
||||
// shapes (and the iOS decoder) can decode the rows it surfaces.
|
||||
"HEALTH_CONNECT",
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -602,10 +608,10 @@ export const VALUE_RANGES: Record<string, { min: number; max: number }> = {
|
||||
STRESS_SCORE: { min: 0, max: 100 },
|
||||
STRAIN_SCORE: { min: 0, max: 100 },
|
||||
// ── v1.11.0 — WHOOP-native score classes ──
|
||||
// RMSSD HRV (ms). Same plausibility band as the SDNN variant: lows reach
|
||||
// single digits in stressed samples, 200 ms is a generous upper bound for
|
||||
// relaxed athletic windows.
|
||||
HRV_RMSSD: { min: 1, max: 200 },
|
||||
// RMSSD HRV (ms). Lows reach single digits in stressed samples. RMSSD runs
|
||||
// higher than SDNN in relaxed, well-trained sleepers, and nightly readings
|
||||
// above 200 ms are real (#1110), so the ceiling sits at 300 ms.
|
||||
HRV_RMSSD: { min: 1, max: 300 },
|
||||
// Day / workout strain on WHOOP's bounded 0–21 scale.
|
||||
DAY_STRAIN: { min: 0, max: 21 },
|
||||
WORKOUT_STRAIN: { min: 0, max: 21 },
|
||||
|
||||
@@ -94,10 +94,14 @@ export type DeviceType = z.infer<typeof deviceTypeEnum>;
|
||||
* keys; lifted into a nested object so the schema has room for the new
|
||||
* `deviceTypePriority` sibling without clobbering the metric keys.
|
||||
*
|
||||
* The 8-entry cap is a sanity bound (we have 4 sources today, the cap
|
||||
* leaves headroom without inviting a megabyte-blob payload).
|
||||
* The cap is the number of `MeasurementSource` values: a ladder may rank
|
||||
* every source once, and no more. A fixed 8 stopped fitting once the
|
||||
* longest default ladders reached eight entries and a ninth source
|
||||
* (Health Connect, v1.42) had to be sortable into them.
|
||||
*/
|
||||
const metricSourceLadder = z.array(measurementSourceEnum).max(8);
|
||||
const metricSourceLadder = z
|
||||
.array(measurementSourceEnum)
|
||||
.max(measurementSourceEnum.options.length);
|
||||
|
||||
/**
|
||||
* Metric-class keys carried by `SourcePriority`. Listed once here so
|
||||
@@ -144,6 +148,10 @@ export const SOURCE_PRIORITY_METRIC_KEYS = [
|
||||
// Laddering it now keeps the picker deterministic — and the choice user-
|
||||
// configurable, not WHOOP-hardcoded — the instant a native producer lands.
|
||||
"sleepDebt",
|
||||
// v1.42 (#1110) — RMSSD HRV as its own class. WHOOP, Oura and Polar report
|
||||
// RMSSD natively, Apple Health relays it from third-party apps; SDNN keeps
|
||||
// the `hrv` ladder, since the two measures are not interchangeable.
|
||||
"hrvRmssd",
|
||||
] as const;
|
||||
|
||||
export type SourcePriorityMetricKey =
|
||||
@@ -395,6 +403,9 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
|
||||
// rolling-balance debt; the COMPUTED engine is the fallback every user lands
|
||||
// on now. Mirrors the recovery ladder's native-above-computed ordering.
|
||||
sleepDebt: ["WHOOP", "OURA", "POLAR", "COMPUTED"],
|
||||
// v1.42 (#1110) — the straps and rings that measure RMSSD overnight lead;
|
||||
// the HealthKit relay of a third-party reading is the fallback.
|
||||
hrvRmssd: ["WHOOP", "OURA", "POLAR", "APPLE_HEALTH"],
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
+32
-1
@@ -75,6 +75,14 @@ export const PUBLIC_PATHS = [
|
||||
// and is not an enumeration oracle — so it must reach the page without
|
||||
// an auth gate, like the `/auth/` register surface it forwards to.
|
||||
"/invite/",
|
||||
// v1.42 (#959) — `/claim/<hlp_token>` is the managed-profile handover link.
|
||||
// Like `/invite/` it is a shape-validated edge redirect onto
|
||||
// `/auth/claim?token=…` (handled below), carries no session and touches no
|
||||
// database. `/api/auth/claim` (preview + claim) authenticates by the
|
||||
// one-time token in the request body, never by a session: the person
|
||||
// claiming the profile has no credentials yet.
|
||||
"/claim/",
|
||||
"/api/auth/claim",
|
||||
// Locale-catalog boot script (`/i18n/<locale>?v=…`). The login page loads
|
||||
// it pre-auth, and the body is the same public catalog JSON that ships in
|
||||
// the repository — no tenant data, no secrets. Immutable-cacheable.
|
||||
@@ -406,6 +414,29 @@ export function proxy(request: NextRequest) {
|
||||
return redirected;
|
||||
}
|
||||
|
||||
// v1.42 (#959) — the managed-profile handover link answers at the edge for
|
||||
// the same reasons as the invite link above: no HTML, no hydration race, the
|
||||
// shape gate drops anything that is not a handover token rather than
|
||||
// reflecting it, and the hop is uncacheable, unindexable and never leaked as
|
||||
// a `Referer`. The shape is restated from `src/lib/auth/handover-token.ts`.
|
||||
if (pathname.startsWith("/claim/")) {
|
||||
const token = pathname.slice("/claim/".length).split("/")[0] ?? "";
|
||||
const target = new URL("/auth/claim", request.url);
|
||||
if (/^hlp_[0-9a-f]{64}$/.test(token)) {
|
||||
target.searchParams.set("token", token);
|
||||
}
|
||||
const redirected = applyBaselineSecurityHeaders(
|
||||
NextResponse.redirect(target, 307),
|
||||
);
|
||||
redirected.headers.set(
|
||||
"Cache-Control",
|
||||
"no-store, no-cache, must-revalidate",
|
||||
);
|
||||
redirected.headers.set("X-Robots-Tag", "noindex, nofollow");
|
||||
redirected.headers.set("Referrer-Policy", "no-referrer");
|
||||
return redirected;
|
||||
}
|
||||
|
||||
// Server-side route protection for pages (not API routes — those have their own getSession checks)
|
||||
const isApiRoute = pathname.startsWith("/api/");
|
||||
const isStaticFile = /\.\w+$/.test(pathname);
|
||||
@@ -718,6 +749,6 @@ export const config = {
|
||||
// the matcher and `apiHandler` performs the proxy's duties for it. The
|
||||
// alternation must stay equal to that list; `proxy-body-limit.test.ts`
|
||||
// compiles this exactly as Next does and checks.
|
||||
"/((?!_next/static|_next/image|favicon.ico|sw\\.js|manifest\\.json|robots\\.txt|sitemap\\.xml|api/(?:import(?:/csv|/apple-health-export)?|admin/import-apple-health-export|admin/backups/upload|documents/inbound|medications/intake/(?:dose-history-import|bulk)|labs/ocr/extract|user/avatar|measurements/batch|workouts/batch|insights/ecg|mood-entries/bulk|cycle/day-logs/bulk)/?$|.*\\.(?:svg|png|jpg|jpeg|gif|webp|txt|xml)$).*)",
|
||||
"/((?!_next/static|_next/image|favicon.ico|sw\\.js|manifest\\.json|robots\\.txt|sitemap\\.xml|api/(?:import(?:/csv|/apple-health-export|/health-connect-export)?|admin/import-apple-health-export|admin/backups/upload|documents/inbound|medications/intake/(?:dose-history-import|bulk)|labs/ocr/extract|user/avatar|measurements/batch|workouts/batch|insights/ecg|mood-entries/bulk|cycle/day-logs/bulk)/?$|.*\\.(?:svg|png|jpg|jpeg|gif|webp|txt|xml)$).*)",
|
||||
],
|
||||
};
|
||||
@@ -104,6 +104,8 @@ describe("DELETE /api/admin/data leaves nothing it promised to delete", () => {
|
||||
dateOfBirth: new Date("1990-01-01T00:00:00Z"),
|
||||
telegramBotToken: "secret-token",
|
||||
telegramEnabled: true,
|
||||
// Off, so the reset back to the default (on) is visible below.
|
||||
environmentAirQualityEnabled: false,
|
||||
locale: "en",
|
||||
unitPreference: "metric",
|
||||
},
|
||||
@@ -269,7 +271,12 @@ describe("DELETE /api/admin/data leaves nothing it promised to delete", () => {
|
||||
value === 0 ||
|
||||
(Array.isArray(value) && value.length === 0) ||
|
||||
value === "aggregated" ||
|
||||
value === "disconnected";
|
||||
value === "disconnected" ||
|
||||
// A column whose reset is a non-empty default (v1.42: the
|
||||
// air-quality switch resets to on) is cleared when it holds
|
||||
// exactly that default.
|
||||
(typeof value === "boolean" &&
|
||||
(USER_RESET as Record<string, unknown>)[column] === value);
|
||||
if (!cleared) stillSet.push(`${column} = ${JSON.stringify(value)}`);
|
||||
}
|
||||
expect(
|
||||
|
||||
@@ -3502,6 +3502,48 @@ const COLUMN_EXCLUSIONS: Readonly<Record<string, string>> = {
|
||||
"insert stamp of a symptom link; no reader, the illness day log's own timestamps travel",
|
||||
};
|
||||
|
||||
/**
|
||||
* Columns a release adds ahead of the backup ends that carry them, so the
|
||||
* packages built in parallel on its branch each own their own section of the
|
||||
* export and the restore. NOT an exclusion: each entry names the package that
|
||||
* carries it, and the companion assertion fails as soon as an entry's column
|
||||
* starts coming back, so the entry has to go in the change that carries it.
|
||||
* The list is empty again before the release is tagged.
|
||||
*/
|
||||
const ENVIRONMENT_V142 =
|
||||
"v1.42 (#615): the environment section carries the air-quality columns and the sealed location";
|
||||
const LABS_V142 =
|
||||
"v1.42 (#1095): the lab section carries the conversion provenance";
|
||||
const RELEASE_PENDING_COLUMNS: Readonly<Record<string, string>> = {
|
||||
"EnvironmentContext.aodMax": ENVIRONMENT_V142,
|
||||
"EnvironmentContext.apparentMax": ENVIRONMENT_V142,
|
||||
"EnvironmentContext.aqDomain": ENVIRONMENT_V142,
|
||||
"EnvironmentContext.aqFetchedAt": ENVIRONMENT_V142,
|
||||
"EnvironmentContext.aqHours": ENVIRONMENT_V142,
|
||||
"EnvironmentContext.coMean": ENVIRONMENT_V142,
|
||||
"EnvironmentContext.dustMax": ENVIRONMENT_V142,
|
||||
"EnvironmentContext.eaqiMax": ENVIRONMENT_V142,
|
||||
"EnvironmentContext.no2Mean": ENVIRONMENT_V142,
|
||||
"EnvironmentContext.o3Max8h": ENVIRONMENT_V142,
|
||||
"EnvironmentContext.pm10Mean": ENVIRONMENT_V142,
|
||||
"EnvironmentContext.pm25Max": ENVIRONMENT_V142,
|
||||
"EnvironmentContext.pm25Mean": ENVIRONMENT_V142,
|
||||
"EnvironmentContext.pollenAlderMax": ENVIRONMENT_V142,
|
||||
"EnvironmentContext.pollenBirchMax": ENVIRONMENT_V142,
|
||||
"EnvironmentContext.pollenGrassMax": ENVIRONMENT_V142,
|
||||
"EnvironmentContext.pollenMugwortMax": ENVIRONMENT_V142,
|
||||
"EnvironmentContext.pollenOliveMax": ENVIRONMENT_V142,
|
||||
"EnvironmentContext.pollenRagweedMax": ENVIRONMENT_V142,
|
||||
"EnvironmentContext.so2Mean": ENVIRONMENT_V142,
|
||||
"EnvironmentContext.usaqiMax": ENVIRONMENT_V142,
|
||||
"EnvironmentContext.uvIndexMax": ENVIRONMENT_V142,
|
||||
"EnvironmentContext.locationEncrypted": ENVIRONMENT_V142,
|
||||
"EnvironmentTravelLocation.locationEncrypted": ENVIRONMENT_V142,
|
||||
"Biomarker.analyteKey": LABS_V142,
|
||||
"LabResult.sourceValue": LABS_V142,
|
||||
"LabResult.sourceUnit": LABS_V142,
|
||||
};
|
||||
|
||||
/**
|
||||
* Columns every reader parses through one function before using, compared
|
||||
* through that same function: the restore writes the parsed form.
|
||||
@@ -3915,11 +3957,15 @@ describe("every column of every two-ended model survives a real restore", () =>
|
||||
// data model that stopped being read.
|
||||
expect(compared).toBeGreaterThanOrEqual(620);
|
||||
expect(
|
||||
lost,
|
||||
lost.filter((key) => !(key in RELEASE_PENDING_COLUMNS)),
|
||||
"these columns left with a value and came back without it. Carry each " +
|
||||
"through the export and the restore, or name it in COLUMN_EXCLUSIONS " +
|
||||
"with the reason the restore is right to drop it",
|
||||
).toEqual([]);
|
||||
expect(
|
||||
Object.keys(RELEASE_PENDING_COLUMNS).filter((key) => !lost.includes(key)),
|
||||
"these RELEASE_PENDING_COLUMNS entries come back now — drop them",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("notices a column nobody told it about", () => {
|
||||
@@ -3958,6 +4004,7 @@ describe("every column of every two-ended model survives a real restore", () =>
|
||||
);
|
||||
const stale = [
|
||||
...Object.keys(COLUMN_EXCLUSIONS),
|
||||
...Object.keys(RELEASE_PENDING_COLUMNS),
|
||||
...Object.keys(READ_THROUGH),
|
||||
...Object.keys(FILL_OVERRIDES),
|
||||
].filter((key) => !known.has(key));
|
||||
|
||||
@@ -311,6 +311,28 @@ const SOURCE_CASES = {
|
||||
expect(verdict.status).toBe("inserted");
|
||||
},
|
||||
},
|
||||
// v1.42 — Health Connect rows enter through the import worker, which writes
|
||||
// each sample through the shared external-measurement reconciliation
|
||||
// service. Until the worker lands, the case drives that service directly,
|
||||
// the same narrowest production path the STRAVA case uses.
|
||||
HEALTH_CONNECT: {
|
||||
expectedType: "PULSE",
|
||||
expectedExternalId: "hc:source-smoke-health-connect",
|
||||
write: async () => {
|
||||
const verdict = await getPrismaClient().$transaction((tx) =>
|
||||
reconcileExternalMeasurement(tx, {
|
||||
userId: USER_ID,
|
||||
type: "PULSE",
|
||||
source: "HEALTH_CONNECT",
|
||||
value: 64,
|
||||
unit: "bpm",
|
||||
measuredAt: new Date("2026-07-20T08:13:00.000Z"),
|
||||
externalId: "hc:source-smoke-health-connect",
|
||||
}),
|
||||
);
|
||||
expect(verdict.status).toBe("inserted");
|
||||
},
|
||||
},
|
||||
// v1.38.x — the third-party ingest token. Written through the ordinary
|
||||
// single-entry POST, because that IS the production path: the route reads
|
||||
// the credential and resolves the source itself. The body names none — one
|
||||
@@ -405,9 +427,9 @@ describe("MeasurementSource write paths against real Postgres", () => {
|
||||
});
|
||||
}
|
||||
|
||||
expect(sourceCases).toHaveLength(15);
|
||||
expect(sourceCases).toHaveLength(16);
|
||||
expect(await prisma.measurement.count({ where: { userId: USER_ID } })).toBe(
|
||||
15,
|
||||
16,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -102,6 +102,8 @@ describe("DELETE /api/settings/data leaves nothing it promised to delete", () =>
|
||||
fullName: "Seeded Name",
|
||||
insurerName: "Seeded Insurer",
|
||||
stravaAthleteId: "12345",
|
||||
// Off, so the reset back to the default (on) is visible below.
|
||||
environmentAirQualityEnabled: false,
|
||||
thresholdsJson: { sys: 130 },
|
||||
onboardingCompletedAt: new Date(),
|
||||
passwordHash: "argon2-placeholder",
|
||||
@@ -229,7 +231,11 @@ describe("DELETE /api/settings/data leaves nothing it promised to delete", () =>
|
||||
value === 0 ||
|
||||
(Array.isArray(value) && value.length === 0) ||
|
||||
value === "aggregated" ||
|
||||
value === "disconnected";
|
||||
value === "disconnected" ||
|
||||
// A column whose reset is a non-empty default (v1.42: the air-quality
|
||||
// switch resets to on) is cleared when it holds exactly that default.
|
||||
(typeof value === "boolean" &&
|
||||
(USER_RESET as Record<string, unknown>)[column] === value);
|
||||
if (!cleared) stillSet.push(`${column} = ${JSON.stringify(value)}`);
|
||||
}
|
||||
expect(
|
||||
|
||||
Reference in new issue
Block a user