mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Merge release/v1.18.7 into main
v1.18.7 — dashboard, Coach and insights polish; AI request batching; mobile consistency; restored share-link section with passphrase gate; medication supply freshness; security hardening. Three additive migrations.
This commit is contained in:
1148 files changed
+22210
-10578
No files matched your search
@@ -33,6 +33,12 @@ jobs:
|
||||
- name: ESLint
|
||||
run: pnpm lint
|
||||
|
||||
# Prettier formatting gate. The format pass excludes generator-owned
|
||||
# artifacts via .prettierignore (notably docs/api/openapi.yaml, which
|
||||
# the OpenAPI drift check below enforces byte-for-byte).
|
||||
- name: Prettier format check
|
||||
run: pnpm format:check
|
||||
|
||||
- name: TypeScript strict check
|
||||
run: pnpm typecheck
|
||||
|
||||
|
||||
+3
-2
@@ -46,8 +46,9 @@ yarn-error.log*
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# prisma generated
|
||||
/src/generated/prisma
|
||||
# prisma generated — ignore the whole path so a stray symlink at
|
||||
# `src/generated` (e.g. from a worktree) can never be committed
|
||||
/src/generated
|
||||
|
||||
# v1.4.43 QoL (L3) — generated at build time by
|
||||
# scripts/generate-sw-version.mjs; loaded by public/sw.js to key
|
||||
|
||||
@@ -2,5 +2,17 @@ node_modules
|
||||
.next
|
||||
out
|
||||
build
|
||||
coverage
|
||||
|
||||
# Prisma-generated client.
|
||||
src/generated
|
||||
|
||||
# Lockfile — owned by pnpm.
|
||||
pnpm-lock.yaml
|
||||
|
||||
# Generator-owned API contract. The openapi:check job enforces its exact
|
||||
# bytes against the Zod registry, so prettier must never rewrite it.
|
||||
docs/api/openapi.yaml
|
||||
|
||||
# Build-generated service worker (the PWA build emits it).
|
||||
public/sw.js
|
||||
+86
-39
@@ -2,6 +2,52 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.18.7] — 2026-06-19 — dashboard, Coach and insights polish
|
||||
|
||||
A broad UI, insights, and AI-efficiency release. It ships database migrations (see the migration note below) and two client-facing contract changes: Coach budget responses now arrive as a stream error rather than a 429, and the measurement-reminder push carries the reminder id.
|
||||
|
||||
### Added
|
||||
|
||||
- The daily briefing surfaces "signals of the day" — today's readings against your 7- and 30-day trend, emerging slopes and recent outliers — and leads with one concrete, present-focused nudge.
|
||||
- Preventive-care cards show a quiet seven-day strip of the metric, and their menu jumps straight to the measurement history filtered to that type.
|
||||
- The Coach reads your active illness state and Rest Mode, can point you to the relevant in-depth guide, and draws on a tiered view of your history (recent days in full, older periods progressively summarised with peaks preserved).
|
||||
- Dictate to the Coach by voice.
|
||||
- The share-link section is back as its own Settings entry, next to the health-record export: mint a time-boxed, revocable, read-only link to share your record with a clinician.
|
||||
- Share links now carry a passphrase second factor. A new link mints a one-time passphrase embedded in a QR code (in the URL fragment, never sent to the server); the public view stays locked until the passphrase is verified. Links created before this release keep working without one.
|
||||
- Labs, preventive care, and the condition journal each have their settings as a proper section of the Settings shell, in the same layout as every other section, instead of a separate page.
|
||||
|
||||
### Changed
|
||||
|
||||
- The daily briefing regenerates every day and reads warmer and more motivating.
|
||||
- The Coach interface is rebuilt around a calm, centred reading column with a collapsible conversation list, a thinking indicator that hands off to streaming, and a quieter scrollbar.
|
||||
- The health-score rings are flattened to match the chart style — the glow, sheen and pulse are gone.
|
||||
- Sleep drops the redundant "last night" card; the chronotype reads as a prominent summary with your natural sleep midpoint and an expandable detail.
|
||||
- Every integration card follows one layout — a short description ending in an inline setup-guide link, with the credential hint above the save button.
|
||||
- A warm insight cycle now sends far fewer AI requests: the per-metric status assessments are batched into a single call, and the comprehensive path recovers from a malformed reply with one corrective retry instead of failing outright.
|
||||
- The daily briefing now varies its wording day to day while the underlying findings stay stable, so a quiet day no longer reads as a frozen cache.
|
||||
- Prompt construction is consolidated: grounding, tone, and the medication-safety rules come from one shared source across the briefing, status cards, narratives, and the Coach.
|
||||
- Settings cards share one card primitive and a consistent padding step, and touch controls across the app meet a 44-pixel target on phones while staying compact on wider screens.
|
||||
- The local-AI private-host switch accepts an explicit host allowlist instead of an all-or-nothing flag (the previous `true` still means "any private host").
|
||||
- Coach conversations are pruned on a schedule, alongside the existing notification-attempt retention.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The daily briefing no longer goes stale: a fresh daily signal forces regeneration instead of re-stamping an unchanged narrative.
|
||||
- The sleep assessment no longer shows the "connect an AI provider" prompt when a provider is already connected.
|
||||
- A medication's supply now updates on the next read: adding a container or registering a new medication evicts the cached list instead of serving the pre-change stock for the cache window, and the card and table refresh together.
|
||||
- The seed for the public demo no longer produces an impossible blood-pressure reading, and reads as a current, healthy, fully-used account.
|
||||
|
||||
### Security
|
||||
|
||||
- The Coach budget gate reserves spend atomically before each request, so concurrent requests can no longer slip past the cap together, and tokens burned on an empty or refused reply are still counted.
|
||||
- New share links require a passphrase to open the record; the passphrase is stored only as an HMAC hash, verified in constant time behind a rate limit, and unlocks a short-lived view cookie scoped to that one link.
|
||||
- The session and authentication-challenge tables are indexed on their expiry column for the reaper.
|
||||
- The development key-padding fallback fails closed unless the environment is explicitly development or test, so a missing `NODE_ENV` can no longer silently derive a weak key.
|
||||
|
||||
### Migration
|
||||
|
||||
- This release adds three migrations: expiry indexes on the session and challenge tables, a daily-briefing reroll marker, and the nullable share-link passphrase hash. Run `prisma migrate deploy` (the entrypoint does this automatically). All three are additive — no backfill, no downtime.
|
||||
|
||||
## [1.18.6.1] — 2026-06-18 — settings and Coach UI follow-ups
|
||||
|
||||
A small UI and settings patch on top of v1.18.6. No migration, no API contract change.
|
||||
@@ -2287,6 +2333,7 @@ iOS-operator-blocking hotfix for REG-11: the Home dashboard tile rendered neithe
|
||||
### Tests
|
||||
|
||||
Four new cases in `src/app/api/dashboard/summary/__tests__/route.test.ts` pin REG-11:
|
||||
|
||||
- BP with a 60-day-old reading → tile emits with historical `latestValue` + `sparkline`
|
||||
- BP with null readings ever → tile NOT emitted
|
||||
- Weight within 7 days → behaviour unchanged (regression guard)
|
||||
@@ -2652,7 +2699,7 @@ self-heal via boot-time backfill on first reach.
|
||||
card; not exposed via any route yet.
|
||||
- **`computeLongWindowSummary(userId, type, windowDays)`** in
|
||||
`summaries-slice.ts` — granularity-routed `count / min / max /
|
||||
mean / sum` aggregate for long-window consumers.
|
||||
mean / sum` aggregate for long-window consumers.
|
||||
- **`rollup-read-cumulative.ts`** — `readCumulativeDaySums` /
|
||||
`readCumulativeDaySumsBatch` / `resolveBucketSum` helpers with
|
||||
legacy-NULL fallback.
|
||||
@@ -2682,7 +2729,7 @@ rides the post-deploy window.
|
||||
- **`/api/measurements?groupBy=day` cumulative path:** consumes
|
||||
`sum_value` directly; eliminates per-type JS aggregation on
|
||||
`ACTIVITY_STEPS / FLIGHTS_CLIMBED / WALKING_RUNNING_DISTANCE /
|
||||
TIME_IN_DAYLIGHT / ACTIVE_ENERGY`.
|
||||
TIME_IN_DAYLIGHT / ACTIVE_ENERGY`.
|
||||
- **`/api/analytics` live-fallback row cap: 347 k → ~5 k.** Trailing
|
||||
425-day `since` cap on the `fetchMeasurementSeriesChunked` per-type
|
||||
loop. Defense-in-depth — the v1.4.38.8 per-type fast-path gate makes
|
||||
@@ -2739,10 +2786,10 @@ false and stranded the whole helper on live SQL across the full
|
||||
### Fixed
|
||||
|
||||
- **`correlations-fast-path` gates only on `BLOOD_PRESSURE_SYS +
|
||||
PULSE + WEIGHT`** — the three types the helper actually reads.
|
||||
PULSE + WEIGHT`** — the three types the helper actually reads.
|
||||
`isFullyCovered(coverage)` AND-term dropped.
|
||||
- **`bp-in-target-fast-path` gates only on `BLOOD_PRESSURE_SYS +
|
||||
BLOOD_PRESSURE_DIA`**. Same fix.
|
||||
BLOOD_PRESSURE_DIA`**. Same fix.
|
||||
- **`health-score-fast-path` gates only on `WEIGHT`**. Same fix.
|
||||
|
||||
Coverage-probe semantics unchanged. Each helper now consults the
|
||||
@@ -3049,7 +3096,7 @@ without changing existing behaviour.
|
||||
`aps.category = "MEDICATION_REMINDER"` on every medication-reminder
|
||||
payload so iOS renders the three action-buttons (Genommen / Snooze
|
||||
15 min / Übersprungen) wired up in iOS v0.5.3. `aps.mutable-content
|
||||
= 1` is set by default so a future Notification Service Extension
|
||||
= 1` is set by default so a future Notification Service Extension
|
||||
can hook the payload without a server-side change. The med-reminder
|
||||
metadata gains `scheduledAt` (ISO 8601) so the iOS "snooze 15 min"
|
||||
action pins to the schedule slot rather than wall-clock delivery
|
||||
@@ -3248,7 +3295,7 @@ dashboard SQL surfaced.
|
||||
list through a Zod `refine` chained on the existing
|
||||
`isValidTimezone` helper. Returns 422 "Invalid IANA timezone" at
|
||||
the write boundary so the new `to_char(measured_at AT TIME ZONE
|
||||
$tz)` SQL in the dashboard summary route cannot be self-DoS'd by
|
||||
$tz)` SQL in the dashboard summary route cannot be self-DoS'd by
|
||||
a corrupted stored value.
|
||||
|
||||
### Tests
|
||||
@@ -3576,7 +3623,7 @@ surfaces city + country when the carrier lookup misses.
|
||||
buckets + a freshly logged WEIGHT type) falls back to the live
|
||||
aggregate so no type silently underreports.
|
||||
- **Daily chart fetches opt into rollup buckets.** `GET
|
||||
/api/measurements?aggregate=daily&source=rollup` reads DAY buckets
|
||||
/api/measurements?aggregate=daily&source=rollup` reads DAY buckets
|
||||
from `measurement_rollups` instead of scanning the measurements
|
||||
table when the window exceeds seven days. The Insights chart
|
||||
fetchers pass `source=rollup`; iOS and other clients are
|
||||
@@ -3643,7 +3690,7 @@ surfaces city + country when the carrier lookup misses.
|
||||
`pickCumulativeDaySum` helper, source-priority-aware in the same
|
||||
shape SLEEP_DURATION already used.
|
||||
- **Insights nav strict-gate** flipped (`if (!availability) return
|
||||
true` → `return false`). Types without measurements no longer
|
||||
true` → `return false`). Types without measurements no longer
|
||||
appear in the strip.
|
||||
- **About → Admin Console.** "Über HealthLog" moved out of the
|
||||
personal dropdown into a new `/admin/about` slug. The public
|
||||
@@ -3669,7 +3716,7 @@ surfaces city + country when the carrier lookup misses.
|
||||
### Schema
|
||||
|
||||
- **Additive `users.insights_exclude_metrics`** (`String[] NOT NULL
|
||||
DEFAULT '{}'`). Migration
|
||||
DEFAULT '{}'`). Migration
|
||||
`0068_v1436_insights_exclude_metrics` is idempotent under re-run.
|
||||
|
||||
### Refactor
|
||||
@@ -3679,7 +3726,7 @@ surfaces city + country when the carrier lookup misses.
|
||||
`applyInsightsExcludeFilter`, `pickCumulativeDaySum`,
|
||||
`probeRollupCoverage`, `isFullyCovered`. Tested standalone.
|
||||
- `ensureUserRollupsFresh` now annotates `{ rollup_refresh_failed:
|
||||
true, rollup_refresh_error: ... }` on failure instead of swallowing
|
||||
true, rollup_refresh_error: ... }` on failure instead of swallowing
|
||||
silently. The read path still returns `{ recomputed: false }` so
|
||||
the response never fails because of a populator hiccup.
|
||||
- Cumulative metric-key lookup hoisted out of a four-level nested
|
||||
@@ -3741,7 +3788,7 @@ zero reader-path impact (serial concurrency, runs in the background).
|
||||
in place. The discovery query finds users with at least one row in
|
||||
`measurements` and zero rows in `measurement_rollups`, then sends
|
||||
one job per uncovered account with `singletonKey:
|
||||
boot-backfill|{userId}` so a fast restart while a fold is queued
|
||||
boot-backfill|{userId}` so a fast restart while a fold is queued
|
||||
doesn't pile up duplicates.
|
||||
- **Best-effort:** any error during discovery is captured in the
|
||||
return value and logged through `workerLog`; the worker boot never
|
||||
@@ -3756,7 +3803,7 @@ zero reader-path impact (serial concurrency, runs in the background).
|
||||
user lands on the queue when a third already-folded user co-exists;
|
||||
zero jobs enqueued when every account is already covered.
|
||||
- Unit suite 4280 → 4285. Integration suite 228 → 230. `pnpm
|
||||
typecheck` + `pnpm lint` clean.
|
||||
typecheck` + `pnpm lint` clean.
|
||||
|
||||
## [1.4.35] — 2026-05-17 — Persistent measurement rollups + partial read-swap
|
||||
|
||||
@@ -3774,7 +3821,7 @@ across every analytics surface is planned for a follow-up.
|
||||
- **New table `measurement_rollups`** keyed on
|
||||
`(user_id, type, granularity, bucket_start)` with
|
||||
`count / mean / min_value / max_value / sd / slope / r2 /
|
||||
computed_at`. Backed by a composite-descending index on
|
||||
computed_at`. Backed by a composite-descending index on
|
||||
`(user_id, type, granularity, bucket_start DESC)`. Additive only —
|
||||
no `ALTER` on existing tables. Migration
|
||||
`0067_v1434_measurement_rollups` is idempotent under re-run.
|
||||
@@ -3850,8 +3897,8 @@ viewport-zoom fix surfaced during the mobile-deep pass.
|
||||
- `tests/integration/withings-oauth.test.ts` covers connect /
|
||||
callback / disconnect plus state-token replay rejection.
|
||||
- `tests/integration/passkey-register.test.ts` covers register-options
|
||||
+ register-verify + tampered-attestation rejection + challenge
|
||||
single-use.
|
||||
- register-verify + tampered-attestation rejection + challenge
|
||||
single-use.
|
||||
- `tests/integration/cache-invalidation-coverage.test.ts` grids the
|
||||
v1.4.34 IW-G invalidation matrix across 5 write surfaces.
|
||||
- Integration suite goes from 197/50 to 222/55. tsc + lint clean.
|
||||
@@ -3923,7 +3970,7 @@ freeze.
|
||||
- **Focus rings on mobile + tablet navigation.** The bottom-nav primary
|
||||
links, the More overflow trigger, and the mobile top-bar user menu
|
||||
trigger gained `focus-visible:ring-ring/50 focus-visible:ring-2
|
||||
focus-visible:outline-none focus-visible:ring-offset-2`. Keyboard
|
||||
focus-visible:outline-none focus-visible:ring-offset-2`. Keyboard
|
||||
and switch-control users can now see focus on every nav surface.
|
||||
- **Tap-target floor (WCAG 2.5.5).** Input, NativeSelect, Select, and
|
||||
DateTimeInput now ship `h-11 sm:h-10` so mobile clears the 44 px
|
||||
@@ -4157,7 +4204,7 @@ without leaking userIds.
|
||||
gate; Bearer tokens never elevate).
|
||||
- **`ImportJob` schema model + migration.** New Prisma model captures
|
||||
the per-upload state machine (`queued | unpacking | parsing |
|
||||
upserting | done | failed`), the content-hash for idempotency
|
||||
upserting | done | failed`), the content-hash for idempotency
|
||||
short-circuits, and the per-`MeasurementType` ingestion counters
|
||||
the status route surfaces back to the client.
|
||||
- **`lastSeenByType` on `/api/analytics`.** Both the slim
|
||||
@@ -4408,8 +4455,7 @@ the affected surfaces.
|
||||
`very_late`.** Defensive fallback when the dose-window
|
||||
estimator can't resolve a window.
|
||||
- **`/api/insights/generate` POST gating.** Endpoint now refuses
|
||||
on a disabled assistant master flag instead of returning a
|
||||
500.
|
||||
on a disabled assistant master flag instead of returning a 500.
|
||||
- **Mood Log overflow on narrow viewports.** Cards no longer
|
||||
shred their internal grid below 360 CSS px.
|
||||
- **F13 username readability.** Header username now respects
|
||||
@@ -4756,7 +4802,7 @@ permission picker and the future Insights nav.
|
||||
### Added
|
||||
|
||||
- **Daily-stats `externalId` helper.** `dailyStatsExternalId(hkId,
|
||||
date)` mints `"stats:<HKQuantityTypeIdentifier>:<YYYY-MM-DD>"`
|
||||
date)` mints `"stats:<HKQuantityTypeIdentifier>:<YYYY-MM-DD>"`
|
||||
alongside the v1.4.29 `CUMULATIVE_HK_TYPES` set. The shape is
|
||||
locked in the iOS coordination notes (§12). iOS
|
||||
emits one row per day per cumulative type via
|
||||
@@ -4839,11 +4885,12 @@ encounter them in read paths because no source writes them yet, and
|
||||
the codegen path will pick them up on the next iOS regeneration.
|
||||
|
||||
**Cutover sequence.** v1.4.30 ships with the helper + drain script
|
||||
+ server tolerance for both shapes. The next iOS TestFlight build
|
||||
adopts `HealthKitStatisticsService.swift` and starts posting daily-
|
||||
aggregated rows for the five cumulative types. Operator runs the
|
||||
drain script once after the new TestFlight cuts over; per-sample
|
||||
row pressure on `Measurement` drops 50-200× for cumulative types.
|
||||
|
||||
- server tolerance for both shapes. The next iOS TestFlight build
|
||||
adopts `HealthKitStatisticsService.swift` and starts posting daily-
|
||||
aggregated rows for the five cumulative types. Operator runs the
|
||||
drain script once after the new TestFlight cuts over; per-sample
|
||||
row pressure on `Measurement` drops 50-200× for cumulative types.
|
||||
|
||||
## [1.4.29.1] — 2026-05-16 — Daily-step aggregation hotfix
|
||||
|
||||
@@ -5100,7 +5147,7 @@ internal-only `/api/internal/web-vitals` beacon route).
|
||||
GLP-1 row painted with a brand icon and a middle-dot separator;
|
||||
every other row painted two-line without an icon. Both routes
|
||||
through a shared `<MedicationCardHeader>` now — line 1 is `{name}
|
||||
{dose}`, line 2 is the class label plus state badges. The GLP-1
|
||||
{dose}`, line 2 is the class label plus state badges. The GLP-1
|
||||
outlier shape is gone.
|
||||
- **HealthScore card came up short below the action+prompts column.**
|
||||
The card now opts into `flex h-full flex-col` with the disclaimer
|
||||
@@ -5138,7 +5185,7 @@ internal-only `/api/internal/web-vitals` beacon route).
|
||||
- **Medications detail-page chrome collapsed to one heading scale.**
|
||||
Every section on `/medications/[id]` mounts through
|
||||
`<MedicationDetailSection>` (`text-base font-semibold leading-6
|
||||
tracking-tight`). DrugLevelChart's standalone header migrates to
|
||||
tracking-tight`). DrugLevelChart's standalone header migrates to
|
||||
`<h2>` with the same classes. Micro labels lift from
|
||||
`text-[10px]` / `text-[11px]` to `text-xs` across Scheduling,
|
||||
Titration, SideEffects. Three scales survive the page: heading,
|
||||
@@ -5663,7 +5710,7 @@ is additive, `IF NOT EXISTS`-guarded, and forward-only.
|
||||
`router.replace`s to a clean URL.
|
||||
- **`/api/version` register button below the tap floor.** The
|
||||
`/auth/register` submit button promotes to `size="lg" min-h-11
|
||||
w-full` so the primary action stays finger-tap reachable on
|
||||
w-full` so the primary action stays finger-tap reachable on
|
||||
narrow viewports.
|
||||
- **Workouts pagination broken under canonical dedup.** Pulling the
|
||||
full filtered set and slicing post-dedup yields the correct
|
||||
@@ -5759,8 +5806,8 @@ is additive, `IF NOT EXISTS`-guarded, and forward-only.
|
||||
contract and the SSR `useIsMobile` first-paint.
|
||||
- **16 new `metric-availability.test.ts` cases** cover each metric ×
|
||||
`{has data, no data, undefined summaries, missing summary entry,
|
||||
BMI-from-WEIGHT derivation, sys-vs-dia independence,
|
||||
mood/medication overrides}`.
|
||||
BMI-from-WEIGHT derivation, sys-vs-dia independence,
|
||||
mood/medication overrides}`.
|
||||
- **6 new `insights-tab-strip.test.tsx` cases** assert
|
||||
backward-compat without `availability`, pill-drop when data is
|
||||
missing, overview pill always renders, mood + medication
|
||||
@@ -5928,7 +5975,7 @@ refuses drug-level estimates with MDR + MDCG 2021-24 cites).
|
||||
browser-detect maps `fr|es|it|pl` to the corresponding bundle.
|
||||
- **Coach — native first-party system prompts across six locales.**
|
||||
Coach + insights system prompts move from EN body + `REPLY
|
||||
LANGUAGE` footer to native per-locale bodies; the safety contract
|
||||
LANGUAGE` footer to native per-locale bodies; the safety contract
|
||||
matrix lives as YAML per locale (single source of truth across
|
||||
drafting + tests). The refusal-probe matrix in CI catches
|
||||
cross-locale prompt regressions before they reach the dispatch
|
||||
@@ -5939,7 +5986,7 @@ refuses drug-level estimates with MDR + MDCG 2021-24 cites).
|
||||
- **Personal Records end-to-end — schema, detection worker, badge,
|
||||
push opt-in.** Migration 0054 introduces the `PersonalRecord`
|
||||
table + `PersonalRecordDirection` helper; `GET
|
||||
/api/personal-records` is paginated (default twenty-five, max two
|
||||
/api/personal-records` is paginated (default twenty-five, max two
|
||||
hundred). A pg-boss worker sweeps MAX / MIN per metric and the
|
||||
workout slots on every batch-ingest + a thirty-minute fallback
|
||||
cron (concurrency five, warmup gate so the very first datapoint
|
||||
@@ -6010,7 +6057,7 @@ refuses drug-level estimates with MDR + MDCG 2021-24 cites).
|
||||
GLP-1 detail page renders the user's current step + remaining
|
||||
ladder as observational reference, framed as reference-not-advice
|
||||
and bound by GROUND RULE 15. `GET
|
||||
/api/medications/[id]/titration-ladder` returns the structured
|
||||
/api/medications/[id]/titration-ladder` returns the structured
|
||||
ladder + current step.
|
||||
- **GLP-1 — pen-and-vial inventory with a 30-day in-use clock.**
|
||||
Migration 0056 introduces the `MedicationInventoryItem` table +
|
||||
@@ -6064,7 +6111,7 @@ refuses drug-level estimates with MDR + MDCG 2021-24 cites).
|
||||
and the per-row collapse affordance comes off.
|
||||
- **DELETE `/api/measurements/by-external-ids` for iOS deletion
|
||||
sync.** Idempotent batch delete keyed on `(user, source,
|
||||
externalId)` tuples; rate-limited the same way as the batch
|
||||
externalId)` tuples; rate-limited the same way as the batch
|
||||
ingest endpoint.
|
||||
- **Multi-arch Docker image.** GHCR publish workflow builds
|
||||
`linux/amd64` on `ubuntu-latest` plus `linux/arm64` on
|
||||
@@ -6072,7 +6119,7 @@ refuses drug-level estimates with MDR + MDCG 2021-24 cites).
|
||||
Macs and arm64 clouds now pull native; the previously-stale
|
||||
README claim is accurate again.
|
||||
- **GLP-1 endpoint hardening.** `POST
|
||||
/api/medications/[id]/glp1` now parses through bounded Zod
|
||||
/api/medications/[id]/glp1` now parses through bounded Zod
|
||||
schemas (`glp1DoseChangePostSchema`, `glp1InventoryPostSchema`)
|
||||
with length-capped notes, finite-number guards on dose value,
|
||||
and bounded `effectiveFrom`. Every write produces an audit-log
|
||||
@@ -6157,7 +6204,7 @@ refuses drug-level estimates with MDR + MDCG 2021-24 cites).
|
||||
`src/lib/medications/route-guards.ts` rather than open-coding
|
||||
the lookup; rate-limit headers across the same surface align
|
||||
on the option-bag form `apiError(..., { headers:
|
||||
rateLimitHeaders(rl) })`.
|
||||
rateLimitHeaders(rl) })`.
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -6237,7 +6284,7 @@ refuses drug-level estimates with MDR + MDCG 2021-24 cites).
|
||||
single-entry POST stripped it on the Zod boundary. Mirror parity
|
||||
restored.
|
||||
- **Personal-records endpoint unpaginated.** `GET
|
||||
/api/personal-records` now clamps `?limit` (default twenty-five,
|
||||
/api/personal-records` now clamps `?limit` (default twenty-five,
|
||||
maximum two hundred); the unbounded read is gone.
|
||||
- **Withings activity sync bucketed positive-offset users into the
|
||||
wrong day.** The per-day row was anchored at 23:59:59 UTC, so a
|
||||
@@ -6356,7 +6403,7 @@ refuses drug-level estimates with MDR + MDCG 2021-24 cites).
|
||||
2024 pharmacokinetic reference. The acknowledgment version
|
||||
re-prompts the user on bump.
|
||||
- **GLP-1 convenience endpoint hardened.** `POST
|
||||
/api/medications/[id]/glp1` parses through bounded Zod schemas
|
||||
/api/medications/[id]/glp1` parses through bounded Zod schemas
|
||||
(`glp1DoseChangePostSchema`, `glp1InventoryPostSchema`), enforces
|
||||
length caps on notes, finite-number guards on dose value, and
|
||||
bounded `effectiveFrom`; every write produces an audit-log row
|
||||
@@ -6562,7 +6609,7 @@ estimates and source citations, is tracked internally. Headline items:
|
||||
- **iOS Swift app — P1 through P5.** Login + dashboard + widget
|
||||
(P1); Apple Health sync (P2); Coach extended for HRV / sleep /
|
||||
resting HR / steps (P3); per-metric APNs alerts (P4); workouts
|
||||
+ GeoJSON routes (P5). All server contracts locked in v1.4.25.
|
||||
- GeoJSON routes (P5). All server contracts locked in v1.4.25.
|
||||
- **Workout ingest API matching the v1.5 iOS contract.**
|
||||
Schema shipped this release; endpoint signature finalised
|
||||
during the iOS sprint.
|
||||
|
||||
@@ -32,19 +32,19 @@ Prefer patch bumps. A release dominated by bug fixes plus additive features with
|
||||
|
||||
## Tech stack at a glance
|
||||
|
||||
| Layer | Tech | Notes |
|
||||
|---|---|---|
|
||||
| Runtime | Node 22 (Alpine) + pnpm 10.31 | Node version fixed only by the Dockerfile base image; no `.nvmrc` or `engines`. |
|
||||
| Framework | Next.js 16.2.6 — App Router, `output: "standalone"` | SWC strips `console.*` in prod except `error/warn`. |
|
||||
| Language | TypeScript 6, `strict: true`, `moduleResolution: "bundler"` | Path alias `@/*` → `./src/*`. `scripts/` excluded from typecheck. |
|
||||
| UI | React 19 (exact pin), Tailwind 4, shadcn/ui (new-york style, zinc base), Radix, Lucide | Recharts 3 for charts — stays; replacement requires explicit approval. |
|
||||
| Persistence | PostgreSQL 16 + Prisma 7.8 — 80 models | Generated client at `src/generated/prisma`. Production image ships a separate `/opt/prisma-cli` install + `/opt/pg-boss` runtime install (see `Dockerfile:93-100`). |
|
||||
| Queue | pg-boss 12.18 | Cron + retry semantics live in Postgres tables; workers under `src/lib/jobs/`. |
|
||||
| Auth | `@simplewebauthn/server` 13, `@node-rs/argon2` 2 | Passkey + Argon2id password, server-side sessions in Postgres. |
|
||||
| Notifications | `@parse/node-apn` 8 (APNs), `web-push` 3 (VAPID), raw fetch for Telegram + ntfy | Per-channel `recordPushAttempt` row, hard-reject classification. |
|
||||
| Forms / validation | `react-hook-form` 7 + `zod` 4 + `@hookform/resolvers` + `zod-openapi` 5 | The Zod registry is the source of truth for `docs/api/openapi.yaml`. |
|
||||
| Testing | Vitest 4 (unit + integration), Playwright 1.60 (e2e), `@axe-core/playwright` | Integration runs against a `testcontainers/postgresql` Postgres 16. |
|
||||
| Container | Multi-stage Alpine Dockerfile, GHCR multi-arch (`amd64` + `arm64`) | `pull_policy: always` in `docker-compose.yml` is load-bearing — without it Docker re-uses the stale `:latest` digest. |
|
||||
| Layer | Tech | Notes |
|
||||
| ------------------ | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Runtime | Node 22 (Alpine) + pnpm 10.31 | Node version fixed only by the Dockerfile base image; no `.nvmrc` or `engines`. |
|
||||
| Framework | Next.js 16.2.6 — App Router, `output: "standalone"` | SWC strips `console.*` in prod except `error/warn`. |
|
||||
| Language | TypeScript 6, `strict: true`, `moduleResolution: "bundler"` | Path alias `@/*` → `./src/*`. `scripts/` excluded from typecheck. |
|
||||
| UI | React 19 (exact pin), Tailwind 4, shadcn/ui (new-york style, zinc base), Radix, Lucide | Recharts 3 for charts — stays; replacement requires explicit approval. |
|
||||
| Persistence | PostgreSQL 16 + Prisma 7.8 — 80 models | Generated client at `src/generated/prisma`. Production image ships a separate `/opt/prisma-cli` install + `/opt/pg-boss` runtime install (see `Dockerfile:93-100`). |
|
||||
| Queue | pg-boss 12.18 | Cron + retry semantics live in Postgres tables; workers under `src/lib/jobs/`. |
|
||||
| Auth | `@simplewebauthn/server` 13, `@node-rs/argon2` 2 | Passkey + Argon2id password, server-side sessions in Postgres. |
|
||||
| Notifications | `@parse/node-apn` 8 (APNs), `web-push` 3 (VAPID), raw fetch for Telegram + ntfy | Per-channel `recordPushAttempt` row, hard-reject classification. |
|
||||
| Forms / validation | `react-hook-form` 7 + `zod` 4 + `@hookform/resolvers` + `zod-openapi` 5 | The Zod registry is the source of truth for `docs/api/openapi.yaml`. |
|
||||
| Testing | Vitest 4 (unit + integration), Playwright 1.60 (e2e), `@axe-core/playwright` | Integration runs against a `testcontainers/postgresql` Postgres 16. |
|
||||
| Container | Multi-stage Alpine Dockerfile, GHCR multi-arch (`amd64` + `arm64`) | `pull_policy: always` in `docker-compose.yml` is load-bearing — without it Docker re-uses the stale `:latest` digest. |
|
||||
|
||||
More detail in `.planning/codebase/tech.md`, generated as part of the v1.5.2 codebase audit.
|
||||
|
||||
@@ -110,23 +110,23 @@ The architecture map in `.planning/codebase/arch.md` walks each layer with file:
|
||||
|
||||
## Critical-files map
|
||||
|
||||
| Concern | File | What it owns |
|
||||
|---|---|---|
|
||||
| HTTP edge | `src/proxy.ts` | Public-path allowlist, demo-mode block, onboarding redirect, request-id + CSP nonce, every security header, worker-only refusal. |
|
||||
| API kit | `src/lib/api-handler.ts` | `apiHandler` wrapper, `requireAuth` / `requireAdmin`, idempotency plumbing, GlitchTip forwarder. |
|
||||
| Response envelope | `src/lib/api-response.ts` | `apiSuccess` / `apiError`, `safeJson` (now with opt-in `maxBytes`), trusted-proxy IP resolver, `returnAllZodIssues` + `sanitiseZodIssues`. |
|
||||
| Crypto at rest | `src/lib/crypto.ts` | AES-256-GCM with versioned key ids, `extractKeyId`, fail-closed loader, rotation primitives. Rotation CLI: `scripts/rotate-encryption-key.ts`. |
|
||||
| Session | `src/lib/auth/session.ts` + `src/lib/auth/secure-cookie.ts` | Postgres-backed sessions, sliding 30-day expiry, `shouldEmitSecureCookie()` the one source of truth for the `Secure` flag (every cookie setter routes through it). |
|
||||
| Refresh tokens | `src/lib/auth/refresh-token.ts` | Per-device one-time-use rotation with reuse-detection that revokes the device's token family. |
|
||||
| Bearer tokens | `src/lib/auth/hmac.ts` + `src/lib/auth/issue-token.ts` | HMAC-SHA256 hashing under `API_TOKEN_HMAC_KEY`; no plaintext path; `lastUsedAt` updated fire-and-forget. |
|
||||
| Rollup tier | `src/lib/rollups/` | DAY / WEEK / MONTH / YEAR pre-aggregations. Read-swap pattern: try the rollup, fall back to live SQL only on coverage miss. Boot-time `rollup-full-backfill` queue handles new accounts. |
|
||||
| Compliance | `src/lib/analytics/compliance.ts` | Cadence-aware medication compliance — daysOfWeek + intervalWeeks honoured across the eight call sites that surface a rate (Coach prompt, BP-status gate, dashboard pillar, …). |
|
||||
| AI providers | `src/lib/ai/provider.ts` (`resolveProvider`) + `src/lib/ai/provider-chain.ts` (`PROVIDER_CHAIN_TYPES`) + `src/lib/ai/{openai,anthropic,local,codex,mock}-client.ts` | Five providers, hand-rolled fetch over the documented wire (no vendor SDKs). Mock is excluded structurally by the `PROVIDER_CHAIN_TYPES` allowlist — production cannot reach it. |
|
||||
| Notification dispatcher | `src/lib/notifications/dispatcher.ts` + `src/lib/notifications/senders/` | APNs → Telegram → ntfy → Web Push cascade, hard-reject classification, `push_attempts` ledger with 90-day retention. |
|
||||
| Coach | `src/app/api/insights/chat/route.ts` + `src/lib/ai/coach/` | SSE stream, budget gate + per-user rate gate, refusal detector, snapshot builder, message persistence with `encryptedContent` Bytes column. |
|
||||
| OpenAPI | `src/lib/openapi/registry.ts` + `src/lib/openapi/routes/` (23 modules, `index.ts` barrel) | Source of truth for `docs/api/openapi.yaml`. |
|
||||
| Schema | `prisma/schema.prisma` (80 models) | `cuid()` PKs, `snake_case` columns via `@map`, encrypted columns mostly under `*Encrypted` (search the file). |
|
||||
| Compose | `docker-compose.yml` | `app` + `db`. Env-var whitelist under `environment:` — vars not listed never reach the container. `pull_policy: always` is load-bearing. |
|
||||
| Concern | File | What it owns |
|
||||
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| HTTP edge | `src/proxy.ts` | Public-path allowlist, demo-mode block, onboarding redirect, request-id + CSP nonce, every security header, worker-only refusal. |
|
||||
| API kit | `src/lib/api-handler.ts` | `apiHandler` wrapper, `requireAuth` / `requireAdmin`, idempotency plumbing, GlitchTip forwarder. |
|
||||
| Response envelope | `src/lib/api-response.ts` | `apiSuccess` / `apiError`, `safeJson` (now with opt-in `maxBytes`), trusted-proxy IP resolver, `returnAllZodIssues` + `sanitiseZodIssues`. |
|
||||
| Crypto at rest | `src/lib/crypto.ts` | AES-256-GCM with versioned key ids, `extractKeyId`, fail-closed loader, rotation primitives. Rotation CLI: `scripts/rotate-encryption-key.ts`. |
|
||||
| Session | `src/lib/auth/session.ts` + `src/lib/auth/secure-cookie.ts` | Postgres-backed sessions, sliding 30-day expiry, `shouldEmitSecureCookie()` the one source of truth for the `Secure` flag (every cookie setter routes through it). |
|
||||
| Refresh tokens | `src/lib/auth/refresh-token.ts` | Per-device one-time-use rotation with reuse-detection that revokes the device's token family. |
|
||||
| Bearer tokens | `src/lib/auth/hmac.ts` + `src/lib/auth/issue-token.ts` | HMAC-SHA256 hashing under `API_TOKEN_HMAC_KEY`; no plaintext path; `lastUsedAt` updated fire-and-forget. |
|
||||
| Rollup tier | `src/lib/rollups/` | DAY / WEEK / MONTH / YEAR pre-aggregations. Read-swap pattern: try the rollup, fall back to live SQL only on coverage miss. Boot-time `rollup-full-backfill` queue handles new accounts. |
|
||||
| Compliance | `src/lib/analytics/compliance.ts` | Cadence-aware medication compliance — daysOfWeek + intervalWeeks honoured across the eight call sites that surface a rate (Coach prompt, BP-status gate, dashboard pillar, …). |
|
||||
| AI providers | `src/lib/ai/provider.ts` (`resolveProvider`) + `src/lib/ai/provider-chain.ts` (`PROVIDER_CHAIN_TYPES`) + `src/lib/ai/{openai,anthropic,local,codex,mock}-client.ts` | Five providers, hand-rolled fetch over the documented wire (no vendor SDKs). Mock is excluded structurally by the `PROVIDER_CHAIN_TYPES` allowlist — production cannot reach it. |
|
||||
| Notification dispatcher | `src/lib/notifications/dispatcher.ts` + `src/lib/notifications/senders/` | APNs → Telegram → ntfy → Web Push cascade, hard-reject classification, `push_attempts` ledger with 90-day retention. |
|
||||
| Coach | `src/app/api/insights/chat/route.ts` + `src/lib/ai/coach/` | SSE stream, budget gate + per-user rate gate, refusal detector, snapshot builder, message persistence with `encryptedContent` Bytes column. |
|
||||
| OpenAPI | `src/lib/openapi/registry.ts` + `src/lib/openapi/routes/` (23 modules, `index.ts` barrel) | Source of truth for `docs/api/openapi.yaml`. |
|
||||
| Schema | `prisma/schema.prisma` (80 models) | `cuid()` PKs, `snake_case` columns via `@map`, encrypted columns mostly under `*Encrypted` (search the file). |
|
||||
| Compose | `docker-compose.yml` | `app` + `db`. Env-var whitelist under `environment:` — vars not listed never reach the container. `pull_policy: always` is load-bearing. |
|
||||
|
||||
`.planning/codebase/arch.md` carries the full annotated walk for every section.
|
||||
|
||||
@@ -181,21 +181,22 @@ The native SwiftUI client lives in a separate repository and rides public beta v
|
||||
|
||||
## Pointers
|
||||
|
||||
| What | Where |
|
||||
|---|---|
|
||||
| Public face | `README.md` |
|
||||
| Release history | `CHANGELOG.md` |
|
||||
| Codebase audit (v1.5.2) | `.planning/codebase/{tech,arch,quality,concerns}.md` |
|
||||
| Security audit (v1.5.2) | `.planning/security-audit-v1.5.2.md` + `.planning/security-audit-findings/*` |
|
||||
| Operator runbooks | `docs/ops/` (deploy, encryption-key-rotation, env-check, backup-restore, migrations) |
|
||||
| Self-hosting guide | `docs/self-hosting/` |
|
||||
| Public API contract | `docs/api/openapi.yaml` |
|
||||
| Architecture diagrams | `docs/diagrams/` (rendered through `docs.healthlog.dev`) |
|
||||
| iOS coordination notes | `.planning/ios-coord/` |
|
||||
| Maintainer working notes | `.planning/PROJECT.md`, `.planning/ROADMAP.md`, `.planning/STATE.md` (gitignored release scratch lives alongside; the patterns are in `.gitignore`) |
|
||||
| Personal preferences + collaboration memory | `~/.claude/projects/-Users-marc-Projects-HealthLog/memory/MEMORY.md` (local to the maintainer's working copy; not in the repo) |
|
||||
| What | Where |
|
||||
| ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Public face | `README.md` |
|
||||
| Release history | `CHANGELOG.md` |
|
||||
| Codebase audit (v1.5.2) | `.planning/codebase/{tech,arch,quality,concerns}.md` |
|
||||
| Security audit (v1.5.2) | `.planning/security-audit-v1.5.2.md` + `.planning/security-audit-findings/*` |
|
||||
| Operator runbooks | `docs/ops/` (deploy, encryption-key-rotation, env-check, backup-restore, migrations) |
|
||||
| Self-hosting guide | `docs/self-hosting/` |
|
||||
| Public API contract | `docs/api/openapi.yaml` |
|
||||
| Architecture diagrams | `docs/diagrams/` (rendered through `docs.healthlog.dev`) |
|
||||
| iOS coordination notes | `.planning/ios-coord/` |
|
||||
| Maintainer working notes | `.planning/PROJECT.md`, `.planning/ROADMAP.md`, `.planning/STATE.md` (gitignored release scratch lives alongside; the patterns are in `.gitignore`) |
|
||||
| Personal preferences + collaboration memory | `~/.claude/projects/-Users-marc-Projects-HealthLog/memory/MEMORY.md` (local to the maintainer's working copy; not in the repo) |
|
||||
|
||||
## Agent context hygiene (ECONNRESET / oversized-request prevention)
|
||||
- **NEVER read `src/generated/**`** — Prisma-generated client (~9 MB, 83 files incl. a 224 KB+ inline schema). Reading/grepping it explodes the request context → 10-min "cogitations" + `ECONNRESET` on large turns. Regenerate via `pnpm prisma generate` if needed; never open it. (Enforced via `.claude/settings.local.json` Read-deny.)
|
||||
|
||||
- **NEVER read `src/generated/**`** — Prisma-generated client (~9 MB, 83 files incl. a 224 KB+ inline schema). Reading/grepping it explodes the request context → 10-min "cogitations" + `ECONNRESET`on large turns. Regenerate via`pnpm prisma generate`if needed; never open it. (Enforced via`.claude/settings.local.json` Read-deny.)
|
||||
- Avoid bulk-reading other large artifacts: `pnpm-lock.yaml`, `CHANGELOG.md`, `messages/*.json`, `docs/api/openapi.yaml` — grep narrowly instead of full reads.
|
||||
- For very large multi-agent actions: cap concurrent sub-agents (many simultaneous streaming requests + big context is the worst combo for connection resets). When on a VPN (Tailscale MTU 1280 / iCloud Private Relay), large requests can hit MTU black-holes — disable the tunnel for heavy sessions.
|
||||
@@ -32,7 +32,7 @@ Separate the two naming axes cleanly:
|
||||
|
||||
- **UI copy is always localised.** Every visible string resolves through an
|
||||
i18n `t()` key and is translated across all six locales (`de / en / es / fr /
|
||||
it / pl`). The user never sees a raw identifier.
|
||||
it / pl`). The user never sees a raw identifier.
|
||||
- **Internal identifiers are English.** Route slugs (`/insights/<slug>`), the
|
||||
tile-layout enum (`INSIGHTS_TILE_IDS`), query keys, and the internal
|
||||
slug→metric / slug→target maps all use established English terms. English is
|
||||
@@ -54,7 +54,7 @@ The migration must not break either client-visible boundary:
|
||||
navigation keep resolving.
|
||||
- **Tile-layout contract.** The layout endpoint keeps **accepting** the legacy
|
||||
German tile ids on input. The Zod enum is built from
|
||||
`ACCEPTED_INSIGHTS_TILE_IDS` (canonical English ids *plus* the legacy German
|
||||
`ACCEPTED_INSIGHTS_TILE_IDS` (canonical English ids _plus_ the legacy German
|
||||
aliases), so a `PUT` carrying old ids validates rather than tripping a 422.
|
||||
`normalizeInsightsTileId` collapses each legacy id onto its canonical English
|
||||
replacement; this normalisation runs on **both** the read path
|
||||
|
||||
@@ -13,16 +13,16 @@ row. The fold only runs when cycle tracking is enabled for the account.
|
||||
|
||||
## Day-log fields
|
||||
|
||||
| HealthKit category type | CycleDayLog field | Values |
|
||||
|---|---|---|
|
||||
| `MenstrualFlow` | `flow` | unspecified→LIGHT, light→LIGHT, medium→MEDIUM, heavy→HEAVY, none→NONE |
|
||||
| `IntermenstrualBleeding` | `intermenstrualBleeding` | presence → `true` |
|
||||
| `CervicalMucusQuality` | `cervicalMucus` | dry→DRY, sticky→STICKY, creamy→CREAMY, watery→WATERY, eggWhite→EGG_WHITE |
|
||||
| `OvulationTestResult` | `ovulationTest` | negative→NEGATIVE, lhSurge→POSITIVE_LH_SURGE, indeterminate→INDETERMINATE, estrogenSurge→ESTROGEN_SURGE |
|
||||
| `SexualActivity` | `sexualActivity` + `protectedSex` | presence → `true`; `HKMetadataKeySexualActivityProtectionUsed` → `protectedSex` |
|
||||
| `PregnancyTestResult` | `pregnancyTest` | negative→NEGATIVE, positive→POSITIVE, indeterminate→INDETERMINATE |
|
||||
| `ProgesteroneTestResult` | `progesteroneTest` | negative→NEGATIVE, positive→POSITIVE, indeterminate→INDETERMINATE |
|
||||
| `Contraceptive` | `contraceptive` (+ profile nudge) | unspecified→UNSPECIFIED, implant→IMPLANT, injection→INJECTION, iud→IUD, ring→INTRAVAGINAL_RING, oral→ORAL, patch→PATCH, emergency→EMERGENCY |
|
||||
| HealthKit category type | CycleDayLog field | Values |
|
||||
| ------------------------ | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `MenstrualFlow` | `flow` | unspecified→LIGHT, light→LIGHT, medium→MEDIUM, heavy→HEAVY, none→NONE |
|
||||
| `IntermenstrualBleeding` | `intermenstrualBleeding` | presence → `true` |
|
||||
| `CervicalMucusQuality` | `cervicalMucus` | dry→DRY, sticky→STICKY, creamy→CREAMY, watery→WATERY, eggWhite→EGG_WHITE |
|
||||
| `OvulationTestResult` | `ovulationTest` | negative→NEGATIVE, lhSurge→POSITIVE_LH_SURGE, indeterminate→INDETERMINATE, estrogenSurge→ESTROGEN_SURGE |
|
||||
| `SexualActivity` | `sexualActivity` + `protectedSex` | presence → `true`; `HKMetadataKeySexualActivityProtectionUsed` → `protectedSex` |
|
||||
| `PregnancyTestResult` | `pregnancyTest` | negative→NEGATIVE, positive→POSITIVE, indeterminate→INDETERMINATE |
|
||||
| `ProgesteroneTestResult` | `progesteroneTest` | negative→NEGATIVE, positive→POSITIVE, indeterminate→INDETERMINATE |
|
||||
| `Contraceptive` | `contraceptive` (+ profile nudge) | unspecified→UNSPECIFIED, implant→IMPLANT, injection→INJECTION, iud→IUD, ring→INTRAVAGINAL_RING, oral→ORAL, patch→PATCH, emergency→EMERGENCY |
|
||||
|
||||
HealthLog's `SPOTTING` flow level has no HealthKit counterpart; it is only ever
|
||||
produced by a manual entry. HealthKit `unspecified` flow maps to `LIGHT` (a
|
||||
@@ -31,21 +31,21 @@ logged-but-unspecified bleeding day is at least light flow).
|
||||
## Symptom category types → seeded symptom keys
|
||||
|
||||
Symptom category samples link to the seeded `CycleSymptom` catalogue by key. A
|
||||
sample whose severity is *not present* (codepoint 1) does NOT create a link.
|
||||
sample whose severity is _not present_ (codepoint 1) does NOT create a link.
|
||||
|
||||
| HealthKit category type | CycleSymptom key |
|
||||
|---|---|
|
||||
| `AbdominalCramps` | `cramps` |
|
||||
| `Headache` | `headache` |
|
||||
| `Bloating` | `bloating` |
|
||||
| `Acne` | `acne` |
|
||||
| `BreastPain` | `breast_tenderness` |
|
||||
| `Fatigue` | `fatigue` |
|
||||
| `LowerBackPain` | `back_pain` |
|
||||
| `SleepChanges` | `insomnia` |
|
||||
| `MoodChanges` | `mood_swings` |
|
||||
| `AppetiteChanges` | `food_cravings` |
|
||||
| `Nausea` | `nausea` |
|
||||
| HealthKit category type | CycleSymptom key |
|
||||
| ----------------------- | ------------------- |
|
||||
| `AbdominalCramps` | `cramps` |
|
||||
| `Headache` | `headache` |
|
||||
| `Bloating` | `bloating` |
|
||||
| `Acne` | `acne` |
|
||||
| `BreastPain` | `breast_tenderness` |
|
||||
| `Fatigue` | `fatigue` |
|
||||
| `LowerBackPain` | `back_pain` |
|
||||
| `SleepChanges` | `insomnia` |
|
||||
| `MoodChanges` | `mood_swings` |
|
||||
| `AppetiteChanges` | `food_cravings` |
|
||||
| `Nausea` | `nausea` |
|
||||
|
||||
## Profile-level signals
|
||||
|
||||
@@ -64,8 +64,8 @@ the temperature-trend ovulation method directly from `Measurement`.
|
||||
|
||||
## Deferred
|
||||
|
||||
| HealthKit category type | Status |
|
||||
|---|---|
|
||||
| `Pregnancy`, `Lactation` | Deferred — no `CycleProfile` column yet (pregnancy-mode is a later release). |
|
||||
| `BleedingDuringPregnancy`, `BleedingAfterPregnancy` | Deferred — pregnancy-mode. |
|
||||
| `IrregularMenstrualCycles`, `InfrequentMenstrualCycles`, `ProlongedMenstrualPeriods`, `PersistentIntermenstrualBleeding` | Server-derived from the cycle engine, never ingested. |
|
||||
| HealthKit category type | Status |
|
||||
| ------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- |
|
||||
| `Pregnancy`, `Lactation` | Deferred — no `CycleProfile` column yet (pregnancy-mode is a later release). |
|
||||
| `BleedingDuringPregnancy`, `BleedingAfterPregnancy` | Deferred — pregnancy-mode. |
|
||||
| `IrregularMenstrualCycles`, `InfrequentMenstrualCycles`, `ProlongedMenstrualPeriods`, `PersistentIntermenstrualBleeding` | Server-derived from the cycle engine, never ingested. |
|
||||
+91
-2
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.18.6.1
|
||||
version: 1.18.7
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
@@ -989,7 +989,9 @@ paths:
|
||||
- Export
|
||||
summary: Create a clinician share link (v1.11.0)
|
||||
description: "Owner-only. Mints an `hls_` token (192-bit), stores only its HMAC hash, and returns the raw token EXACTLY
|
||||
ONCE in the response. Every scope column (window, sections, FHIR resource types, API toggle) is frozen
|
||||
ONCE in the response. v1.18.7 also mints a passphrase second factor (returned once as `passphrase`, with
|
||||
`shareUrl` and a `qrUrl` carrying the passphrase in the URL fragment); a leaked URL without the passphrase
|
||||
cannot open the record. Every scope column (window, sections, FHIR resource types, API toggle) is frozen
|
||||
write-once. `expiresAt` is required and capped at 90 days. Auth via cookie or Bearer; rate-limited
|
||||
(`share-link:<userId>`, 20/h). Strict: unknown keys 422."
|
||||
requestBody:
|
||||
@@ -1148,6 +1150,50 @@ paths:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/c/{token}/unlock:
|
||||
post:
|
||||
tags:
|
||||
- Export
|
||||
summary: Verify a share-link passphrase (v1.18.7, public)
|
||||
description: Anonymous, no-session passphrase gate for a protected share link. The raw path token plus the submitted
|
||||
passphrase are the only credentials. On success the server sets a short-lived (30 min), token-scoped, httpOnly
|
||||
cookie so `/c/<token>` renders the record. Rate-limited (`share-unlock:<tokenHash>:<ip>`); every failure class
|
||||
(bad token, no passphrase set, wrong passphrase) answers one blunt 401.
|
||||
parameters:
|
||||
- in: path
|
||||
name: token
|
||||
schema:
|
||||
type: string
|
||||
required: true
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
passphrase:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 64
|
||||
required:
|
||||
- passphrase
|
||||
additionalProperties: false
|
||||
responses:
|
||||
"200":
|
||||
description: Passphrase accepted; short-lived token-scoped unlock cookie set.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ShareLinkUnlock"
|
||||
"401":
|
||||
description: Invalid passphrase (blunt — no failure detail leaked).
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/fhir/metadata:
|
||||
get:
|
||||
tags:
|
||||
@@ -9701,6 +9747,8 @@ components:
|
||||
type: string
|
||||
allowFhirApi:
|
||||
type: boolean
|
||||
protected:
|
||||
type: boolean
|
||||
expiresAt:
|
||||
type: string
|
||||
createdAt:
|
||||
@@ -9720,6 +9768,16 @@ components:
|
||||
token:
|
||||
type: string
|
||||
description: Raw `hls_` token — returned ONCE and unrecoverable thereafter.
|
||||
passphrase:
|
||||
type: string
|
||||
description: Raw passphrase second factor — returned ONCE; stored only as an HMAC hash, unrecoverable.
|
||||
shareUrl:
|
||||
type: string
|
||||
description: Absolute `/c/<token>` URL (no passphrase).
|
||||
qrUrl:
|
||||
type: string
|
||||
description: Absolute share URL with the passphrase in the URL FRAGMENT (`#k=<passphrase>`). Encode this as the QR
|
||||
payload; the fragment is never sent to the server.
|
||||
required:
|
||||
- id
|
||||
- label
|
||||
@@ -9727,6 +9785,7 @@ components:
|
||||
- rangeEnd
|
||||
- resourceTypes
|
||||
- allowFhirApi
|
||||
- protected
|
||||
- expiresAt
|
||||
- createdAt
|
||||
- revokedAt
|
||||
@@ -9734,6 +9793,9 @@ components:
|
||||
- accessCount
|
||||
- active
|
||||
- token
|
||||
- passphrase
|
||||
- shareUrl
|
||||
- qrUrl
|
||||
additionalProperties: false
|
||||
error:
|
||||
type: "null"
|
||||
@@ -9774,6 +9836,8 @@ components:
|
||||
type: string
|
||||
allowFhirApi:
|
||||
type: boolean
|
||||
protected:
|
||||
type: boolean
|
||||
expiresAt:
|
||||
type: string
|
||||
createdAt:
|
||||
@@ -9797,6 +9861,7 @@ components:
|
||||
- rangeEnd
|
||||
- resourceTypes
|
||||
- allowFhirApi
|
||||
- protected
|
||||
- expiresAt
|
||||
- createdAt
|
||||
- revokedAt
|
||||
@@ -9845,6 +9910,30 @@ components:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
ShareLinkUnlock:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
unlocked:
|
||||
type: boolean
|
||||
const: true
|
||||
required:
|
||||
- unlocked
|
||||
additionalProperties: false
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
LoginResponse:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -11,6 +11,7 @@ last_updated: 2026-05-15
|
||||
## TL;DR
|
||||
|
||||
Three submission gates that need the maintainer's hand at ASC:
|
||||
|
||||
1. Regulated-Medical-Device declaration (Guideline 1.4 + 5.1.1(ix))
|
||||
2. Privacy Nutrition Labels matching the iOS Privacy Manifest (Guideline 5.1.2)
|
||||
3. Standard metadata (name, description, screenshots, categories, age rating)
|
||||
@@ -24,9 +25,11 @@ Plus pre-submit final checks.
|
||||
**Decision**: HealthLog is **NOT a medical device** under EU MDR 2017/745 / FDA 21 CFR 880 / Apple's interpretation.
|
||||
|
||||
**Recommended wording in the declaration form**:
|
||||
|
||||
> HealthLog is a personal health-data logging and visualization app. It displays user-entered observations + integrates with Apple HealthKit, Withings, and manual entry. It does not diagnose, treat, prescribe, or recommend dosing. The AI Coach surfaces are explicit-refusal-gated (GROUND RULES 9 + 15) against any medical-device-class output. The drug-level estimation chart is display-only behind a versioned MDR-acknowledgment dialog citing EU 2017/745 + MDCG 2021-24. For medical advice, users must consult licensed clinicians.
|
||||
|
||||
**What to check**:
|
||||
|
||||
- [ ] Check "No" on the medical-device toggle
|
||||
- [ ] Add the description above to the medical-device-justification field if Apple shows one
|
||||
- [ ] Cross-reference the in-app `/privacy` page (https://<your-instance>/privacy) §7 MDR-boundary
|
||||
@@ -44,22 +47,26 @@ Plus pre-submit final checks.
|
||||
For each category below: **Linked to user, used for App Functionality + Account Management**. Optional purposes: never enable Analytics, Tracking, Advertising, or Third-Party Tracking.
|
||||
|
||||
**Health & Fitness**
|
||||
|
||||
- Health: yes (all HKQuantityType + HKCategoryType the iOS app reads — see `/privacy` page §2)
|
||||
- Fitness: yes (steps, distance, active energy, workouts)
|
||||
- Purpose: App Functionality
|
||||
- Linked to user: yes
|
||||
|
||||
**Contact Info**
|
||||
|
||||
- Email Address: yes (for account login + GDPR data-subject correspondence)
|
||||
- Purpose: App Functionality + Account Management
|
||||
- Linked to user: yes
|
||||
|
||||
**Sensitive Info**
|
||||
|
||||
- Blood pressure, glucose, medications, drug-related observations: yes
|
||||
- Purpose: App Functionality
|
||||
- Linked to user: yes
|
||||
|
||||
**User Content**
|
||||
|
||||
- Emails or Text Messages: NO (the AI Coach is in-app conversation, but iOS does not send emails)
|
||||
- Photos or Videos: NO
|
||||
- Audio Data: NO (HealthKit audio-exposure is environmental dB; not the user's voice)
|
||||
@@ -69,12 +76,14 @@ For each category below: **Linked to user, used for App Functionality + Account
|
||||
- Linked to user: yes
|
||||
|
||||
**Identifiers**
|
||||
|
||||
- User ID: yes (server-side user UUID)
|
||||
- Device ID: yes (X-Device-Id Keychain-stored UUID, APNs push token)
|
||||
- Purpose: App Functionality + Account Management
|
||||
- Linked to user: yes
|
||||
|
||||
**Other Data Types**
|
||||
|
||||
- Other Data Types: yes (general medical observations not fitting above)
|
||||
- Purpose: App Functionality
|
||||
- Linked to user: yes
|
||||
@@ -97,6 +106,7 @@ For each category below: **Linked to user, used for App Functionality + Account
|
||||
## Section 3 — Standard Metadata Checklist
|
||||
|
||||
### App Information
|
||||
|
||||
- [ ] Name: "HealthLog"
|
||||
- [ ] Subtitle: 30-char tagline (suggestion: "Personal health-data log + Coach")
|
||||
- [ ] Primary category: Health & Fitness
|
||||
@@ -105,25 +115,30 @@ For each category below: **Linked to user, used for App Functionality + Account
|
||||
- [ ] Age rating: 17+ (because Sensitive Health Info content)
|
||||
|
||||
### Description
|
||||
|
||||
- [ ] Long-form description (4000 char max): cover Insights + Coach + GLP-1 + Withings + Apple HealthKit + open-source nature + EU GDPR compliance
|
||||
- [ ] What's New (version-by-version notes): v0.3.0 = "Server-sync, comprehensive Apple HealthKit support across 19 sample types, smart Insights, AI Coach, Health Score, GLP-1 tracking with Research-Mode-gated drug-level chart, interactive charts with drill-down, push notifications, deep links, and account deletion."
|
||||
|
||||
### Screenshots
|
||||
|
||||
- [ ] 6.5" iPhone screenshots: cover Onboarding (welcome carousel + permissions), Dashboard (Health Score tile + tile-strip), Insights (sub-page with severity sort), Coach (refusal example to show MDR boundary), Medications (GLP-1 detail stack), Settings (Research Mode toggle + account deletion).
|
||||
- [ ] 5.5" iPhone screenshots: same set (Apple's legacy size).
|
||||
- [ ] iPad screenshots if supporting iPad.
|
||||
|
||||
### URLs
|
||||
|
||||
- [ ] Marketing URL: https://<your-instance> (or the marketing site once the landing repo's v1.4.25 is deployed)
|
||||
- [ ] Privacy Policy URL: **https://<your-instance>/privacy** (ships with v1.4.25.1 hotfix)
|
||||
- [ ] Support URL: https://github.com/MBombeck/HealthLog/issues
|
||||
|
||||
### App Review Information
|
||||
|
||||
- [ ] Demo account credentials: the maintainer creates a read-only demo account on `demo.healthlog.dev` and provides credentials in the demo account fields
|
||||
- [ ] Review notes: explain MDR boundary + AI Coach safety contract + Research Mode gating; reference `/privacy` §7 MDR-boundary. Apple reviewers DO read this and DO check the in-app behavior matches the claim.
|
||||
- [ ] Sign-in required: yes (provide demo credentials)
|
||||
|
||||
### Pricing + Availability
|
||||
|
||||
- [ ] Free
|
||||
- [ ] Availability: select countries (maintainer decision — recommend EU + US + Canada + Australia + Japan for first launch)
|
||||
|
||||
@@ -154,6 +169,7 @@ Run through this list:
|
||||
---
|
||||
|
||||
**Source links**:
|
||||
|
||||
- Apple Health App Guidelines: https://developer.apple.com/app-store/review/guidelines/#health-and-health-research
|
||||
- Apple App Privacy Details: https://developer.apple.com/app-store/app-privacy-details/
|
||||
- EU MDR 2017/745: https://eur-lex.europa.eu/eli/reg/2017/745/oj
|
||||
|
||||
@@ -6,13 +6,13 @@ pages as `<img>` tags. Mirrored into `healthlog-docs/src/assets/diagrams/`
|
||||
and `healthlog-landing/public/diagrams/` so each surface ships its own
|
||||
copy.
|
||||
|
||||
| File | Story it tells |
|
||||
| ---- | -------------- |
|
||||
| `01-data-flow.svg` | Sources (Withings, Apple Health export.zip, iPhone HealthKit, manual, moodLog) → ingest endpoints → Measurement + MeasurementRollup → reads (rollup probe / live SQL fallback / cache) → surfaces (dashboard, insights, Coach, doctor PDF). |
|
||||
| `02-coach-pipeline.svg` | User question → snapshot builder → prompt assembler → provider chain (ChatGPT OAuth / OpenAI / Anthropic / local / admin-shared) → response parser → cited reply with mini-charts. |
|
||||
| `03-self-hosting-topology.svg` | Internet → reverse proxy (TLS) → Next.js app (port 3000) ↔ pg-boss worker ↔ Postgres 16, plus GHCR image pull, optional Coolify autodeploy, optional S3 off-host backup. |
|
||||
| `04-source-priority.svg` | Three sources for the same metric on the same day → resolver picks one canonical row per (cumulative vs point) ladder. Losing rows stay in the audit trail. |
|
||||
| `05-security-model.svg` | Three concentric layers — auth perimeter (passkey + password fallback), session perimeter (HttpOnly cookie + Postgres sessions), encrypted core (AES-256-GCM versioned keys). Side rails: rate limiter, audit log, HMAC tokens, CSP/HSTS, SSRF guard. |
|
||||
| File | Story it tells |
|
||||
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `01-data-flow.svg` | Sources (Withings, Apple Health export.zip, iPhone HealthKit, manual, moodLog) → ingest endpoints → Measurement + MeasurementRollup → reads (rollup probe / live SQL fallback / cache) → surfaces (dashboard, insights, Coach, doctor PDF). |
|
||||
| `02-coach-pipeline.svg` | User question → snapshot builder → prompt assembler → provider chain (ChatGPT OAuth / OpenAI / Anthropic / local / admin-shared) → response parser → cited reply with mini-charts. |
|
||||
| `03-self-hosting-topology.svg` | Internet → reverse proxy (TLS) → Next.js app (port 3000) ↔ pg-boss worker ↔ Postgres 16, plus GHCR image pull, optional Coolify autodeploy, optional S3 off-host backup. |
|
||||
| `04-source-priority.svg` | Three sources for the same metric on the same day → resolver picks one canonical row per (cumulative vs point) ladder. Losing rows stay in the audit trail. |
|
||||
| `05-security-model.svg` | Three concentric layers — auth perimeter (passkey + password fallback), session perimeter (HttpOnly cookie + Postgres sessions), encrypted core (AES-256-GCM versioned keys). Side rails: rate limiter, audit log, HMAC tokens, CSP/HSTS, SSRF guard. |
|
||||
|
||||
## Editing
|
||||
|
||||
|
||||
@@ -9,12 +9,12 @@ Ollama endpoint can fall back from one to the other automatically.
|
||||
|
||||
## Provider matrix
|
||||
|
||||
| Provider | Key acquisition | Default model | Endpoint | Privacy stance |
|
||||
| -------- | ---------------- | ------------- | -------- | -------------- |
|
||||
| `OPENAI` | <https://platform.openai.com/api-keys> | `gpt-4o` | `https://api.openai.com/v1` | Measurement context sent to OpenAI |
|
||||
| `ANTHROPIC` | <https://console.anthropic.com/settings/keys> | `claude-sonnet-4-6` | Anthropic SDK default | Measurement context sent to Anthropic |
|
||||
| `LOCAL` | None (your endpoint) | `local-model` | OpenAI-compatible URL you control | Stays on your network |
|
||||
| `CHATGPT_OAUTH` | Sign in with your ChatGPT account | Codex-routed | `chatgpt.com/backend-api/codex/responses` | Routed via your ChatGPT subscription |
|
||||
| Provider | Key acquisition | Default model | Endpoint | Privacy stance |
|
||||
| --------------- | --------------------------------------------- | ------------------- | ----------------------------------------- | ------------------------------------- |
|
||||
| `OPENAI` | <https://platform.openai.com/api-keys> | `gpt-4o` | `https://api.openai.com/v1` | Measurement context sent to OpenAI |
|
||||
| `ANTHROPIC` | <https://console.anthropic.com/settings/keys> | `claude-sonnet-4-6` | Anthropic SDK default | Measurement context sent to Anthropic |
|
||||
| `LOCAL` | None (your endpoint) | `local-model` | OpenAI-compatible URL you control | Stays on your network |
|
||||
| `CHATGPT_OAUTH` | Sign in with your ChatGPT account | Codex-routed | `chatgpt.com/backend-api/codex/responses` | Routed via your ChatGPT subscription |
|
||||
|
||||
The four spellings above are the canonical `User.aiProvider` enum
|
||||
values. Anything else falls through to the admin-shared OpenAI key
|
||||
@@ -134,11 +134,14 @@ API key at rest like every other provider credential.
|
||||
|
||||
**SSRF guard.** Local endpoints route through the same validation
|
||||
as every other outbound URL HealthLog hits. The guard rejects
|
||||
private-IP-range targets by default — set
|
||||
`ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` in the container environment to
|
||||
allow `localhost`, RFC1918, and link-local destinations
|
||||
(`src/lib/ai/provider.ts:456-462`). Leave it unset on public-facing
|
||||
instances unless you specifically run an internal endpoint.
|
||||
private-IP-range targets by default. `ALLOW_LOCAL_AI_PRIVATE_HOSTS`
|
||||
opens the escape hatch and accepts two forms: a comma-separated host
|
||||
allowlist (e.g. `ALLOW_LOCAL_AI_PRIVATE_HOSTS=ollama.lan,10.0.0.5`),
|
||||
which permits only those exact hostnames, or the legacy
|
||||
`ALLOW_LOCAL_AI_PRIVATE_HOSTS=true`, which permits any private host —
|
||||
including the cloud-metadata endpoint, so prefer the host list. Leave
|
||||
it unset on public-facing instances unless you specifically run an
|
||||
internal endpoint.
|
||||
|
||||
**Model sizing.** Roughly: 7-8B for 8 GB GPU / Apple Silicon base
|
||||
(briefings fine, deeper Coach reasoning gets terse), 14-24B for
|
||||
@@ -232,9 +235,11 @@ error.
|
||||
provider in `/settings/ai`, or configure an admin-shared OpenAI
|
||||
key in the admin panel.
|
||||
- **Local endpoint rejected as "internal/private host".** Set
|
||||
`ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` in the container environment.
|
||||
Leave it unset on internet-facing instances unless you specifically
|
||||
want to allow private-range targets.
|
||||
`ALLOW_LOCAL_AI_PRIVATE_HOSTS` to the endpoint's exact hostname
|
||||
(e.g. `ollama.lan`; comma-separate several) in the container
|
||||
environment, or to `true` to allow any private host. Prefer the host
|
||||
list; leave it unset on internet-facing instances unless you
|
||||
specifically want to allow private-range targets.
|
||||
- **Codex disconnects after a few weeks of inactivity.** ChatGPT
|
||||
refresh tokens lapse after extended idle periods. Reconnect via
|
||||
the **Connect ChatGPT** button — HealthLog re-runs the device-code
|
||||
|
||||
@@ -97,12 +97,12 @@ they triggered.
|
||||
The parser folds four XML element types into the existing HealthLog
|
||||
schema (CHANGELOG v1.4.34):
|
||||
|
||||
| XML element | Maps to | Notes |
|
||||
| ----------- | ------- | ----- |
|
||||
| `<Record>` | `Measurement` rows | One row per spot sample. Keyed by `HKMetadataKeyExternalUUID` when present, otherwise a deterministic `sample:<sha256-of-attributes>` fallback so re-imports stay idempotent. |
|
||||
| `<Workout>` | `Workout` rows | Activity type + duration + energy + distance. |
|
||||
| `<Correlation>` | `Measurement` rows | Compound samples like blood pressure (systolic + diastolic) explode into their per-metric children. |
|
||||
| `<ClinicalRecord>` | `Measurement` rows where the type maps | Health-record FHIR snapshots; only the metric-typed fields are ingested. |
|
||||
| XML element | Maps to | Notes |
|
||||
| ------------------ | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `<Record>` | `Measurement` rows | One row per spot sample. Keyed by `HKMetadataKeyExternalUUID` when present, otherwise a deterministic `sample:<sha256-of-attributes>` fallback so re-imports stay idempotent. |
|
||||
| `<Workout>` | `Workout` rows | Activity type + duration + energy + distance. |
|
||||
| `<Correlation>` | `Measurement` rows | Compound samples like blood pressure (systolic + diastolic) explode into their per-metric children. |
|
||||
| `<ClinicalRecord>` | `Measurement` rows where the type maps | Health-record FHIR snapshots; only the metric-typed fields are ingested. |
|
||||
|
||||
The HK quantity types that map to HealthLog metric types live in
|
||||
`src/lib/measurements/apple-health-mapping.ts`. Anything outside the
|
||||
@@ -187,13 +187,13 @@ Override per-user via the Sources section of `/settings/thresholds`.
|
||||
|
||||
## Failure modes
|
||||
|
||||
| Failure | What you see | Recovery |
|
||||
| ------- | ------------ | -------- |
|
||||
| Archive is missing `apple_health_export/export.xml` | `failureReason: "Archive is missing the apple_health_export/export.xml member"` | Re-export from the iPhone; a partial AirDrop or a renamed archive can lose the canonical entry. |
|
||||
| Archive is encrypted | `failureReason: "Encrypted ZIP entries are not supported"` | Apple does not encrypt the export — this means the file was repacked by a third-party tool. Re-export from iOS directly. |
|
||||
| Unsupported compression method | `failureReason: "Unsupported ZIP compression method <n> for export.xml"` | Same recovery — re-export from iOS, which always emits method 0 (stored) or method 8 (deflate). |
|
||||
| Worker is not running | `503 Background worker is not running` from the kick-off endpoint | The worker process is down. Check `docker compose ps` for the `app-worker` container (or `HEALTHLOG_PROCESS_TYPE=all` mode on the main `app` container) and restart. |
|
||||
| Rate-limited | `429 Too many import uploads, try again later` | Wait sixty seconds; the limit is three uploads per minute per user. |
|
||||
| Failure | What you see | Recovery |
|
||||
| --------------------------------------------------- | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Archive is missing `apple_health_export/export.xml` | `failureReason: "Archive is missing the apple_health_export/export.xml member"` | Re-export from the iPhone; a partial AirDrop or a renamed archive can lose the canonical entry. |
|
||||
| Archive is encrypted | `failureReason: "Encrypted ZIP entries are not supported"` | Apple does not encrypt the export — this means the file was repacked by a third-party tool. Re-export from iOS directly. |
|
||||
| Unsupported compression method | `failureReason: "Unsupported ZIP compression method <n> for export.xml"` | Same recovery — re-export from iOS, which always emits method 0 (stored) or method 8 (deflate). |
|
||||
| Worker is not running | `503 Background worker is not running` from the kick-off endpoint | The worker process is down. Check `docker compose ps` for the `app-worker` container (or `HEALTHLOG_PROCESS_TYPE=all` mode on the main `app` container) and restart. |
|
||||
| Rate-limited | `429 Too many import uploads, try again later` | Wait sixty seconds; the limit is three uploads per minute per user. |
|
||||
|
||||
The wide-event log line `import.apple-health.kickoff` (or
|
||||
`import.apple-health.kickoff.denied` on failure) captures every
|
||||
|
||||
@@ -36,8 +36,12 @@ arrays:
|
||||
|
||||
```json
|
||||
{
|
||||
"measurements": [ /* … */ ],
|
||||
"moodEntries": [ /* … */ ]
|
||||
"measurements": [
|
||||
/* … */
|
||||
],
|
||||
"moodEntries": [
|
||||
/* … */
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -48,26 +52,26 @@ skipped and counted under `skipped` in the response.
|
||||
|
||||
### Measurement entry
|
||||
|
||||
| Field | Required | Type | Notes |
|
||||
|---|---|---|---|
|
||||
| `type` | yes | enum | One of the `MeasurementType` values in the table below. |
|
||||
| `value` | yes | number | Plausibility-checked per type; out-of-range values are rejected. |
|
||||
| `unit` | yes | string | The canonical unit for the type (see the table). |
|
||||
| `measuredAt` | yes | string | ISO-8601 datetime, e.g. `2026-05-01T08:00:00.000Z`. |
|
||||
| `source` | no | string | Free-text origin label. Imported rows are tagged `IMPORT` server-side regardless. |
|
||||
| `notes` | no | string | Optional free text. |
|
||||
| `glucoseContext` | no | enum | Only for `BLOOD_GLUCOSE` (e.g. fasting / post-meal). |
|
||||
| Field | Required | Type | Notes |
|
||||
| ---------------- | -------- | ------ | --------------------------------------------------------------------------------- |
|
||||
| `type` | yes | enum | One of the `MeasurementType` values in the table below. |
|
||||
| `value` | yes | number | Plausibility-checked per type; out-of-range values are rejected. |
|
||||
| `unit` | yes | string | The canonical unit for the type (see the table). |
|
||||
| `measuredAt` | yes | string | ISO-8601 datetime, e.g. `2026-05-01T08:00:00.000Z`. |
|
||||
| `source` | no | string | Free-text origin label. Imported rows are tagged `IMPORT` server-side regardless. |
|
||||
| `notes` | no | string | Optional free text. |
|
||||
| `glucoseContext` | no | enum | Only for `BLOOD_GLUCOSE` (e.g. fasting / post-meal). |
|
||||
|
||||
### Mood entry
|
||||
|
||||
| Field | Required | Type | Notes |
|
||||
|---|---|---|---|
|
||||
| `date` | yes | string | `YYYY-MM-DD`. |
|
||||
| `mood` | yes | enum | One of `SUPER_GUT`, `GUT`, `OKAY`, `SCHLECHT`, `LAUSIG`. |
|
||||
| `score` | yes | integer | `1`–`5`. |
|
||||
| `tags` | no | string | Comma-separated tags. |
|
||||
| `loggedAt` | no | string | ISO-8601 datetime; defaults to now. |
|
||||
| `externalId` | no | string | A source-stable id (1–120 chars). When present, a re-import upserts on `(user, source, externalId)` so an edit upstream is reflected rather than skipped. |
|
||||
| Field | Required | Type | Notes |
|
||||
| ------------ | -------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `date` | yes | string | `YYYY-MM-DD`. |
|
||||
| `mood` | yes | enum | One of `SUPER_GUT`, `GUT`, `OKAY`, `SCHLECHT`, `LAUSIG`. |
|
||||
| `score` | yes | integer | `1`–`5`. |
|
||||
| `tags` | no | string | Comma-separated tags. |
|
||||
| `loggedAt` | no | string | ISO-8601 datetime; defaults to now. |
|
||||
| `externalId` | no | string | A source-stable id (1–120 chars). When present, a re-import upserts on `(user, source, externalId)` so an edit upstream is reflected rather than skipped. |
|
||||
|
||||
### `MeasurementType` values and canonical units
|
||||
|
||||
@@ -76,72 +80,72 @@ display per the user's preference. Event-class types record a single
|
||||
fired event (`value` is `1`) and are normally produced by a device, not
|
||||
hand-authored.
|
||||
|
||||
| Type | Canonical unit |
|
||||
|---|---|
|
||||
| `WEIGHT` | kg |
|
||||
| `BLOOD_PRESSURE_SYS` | mmHg |
|
||||
| `BLOOD_PRESSURE_DIA` | mmHg |
|
||||
| `PULSE` | bpm |
|
||||
| `RESTING_HEART_RATE` | bpm |
|
||||
| `WALKING_HEART_RATE_AVERAGE` | bpm |
|
||||
| `AVERAGE_HEART_RATE` | bpm |
|
||||
| `MAX_HEART_RATE` | bpm |
|
||||
| `CARDIO_RECOVERY` | bpm |
|
||||
| `HEART_RATE_VARIABILITY` | ms (SDNN) |
|
||||
| `HRV_RMSSD` | ms (RMSSD) |
|
||||
| `RESPIRATORY_RATE` | count/min |
|
||||
| `OXYGEN_SATURATION` | percent (0–100) |
|
||||
| `BLOOD_GLUCOSE` | mg/dL |
|
||||
| `BODY_FAT` | percent (0–100) |
|
||||
| `FAT_MASS` | kg |
|
||||
| `FAT_FREE_MASS` | kg |
|
||||
| `LEAN_BODY_MASS` | kg |
|
||||
| `MUSCLE_MASS` | kg |
|
||||
| `BONE_MASS` | kg |
|
||||
| `TOTAL_BODY_WATER` | kg |
|
||||
| `VISCERAL_FAT` | rating |
|
||||
| `BODY_MASS_INDEX` | kg/m² |
|
||||
| `BODY_TEMPERATURE` | °C (core) |
|
||||
| `SKIN_TEMPERATURE` | °C (dermal spot) |
|
||||
| `WRIST_TEMPERATURE` | °C (overnight wrist) |
|
||||
| `SLEEP_DURATION` | minutes |
|
||||
| `SLEEP_NEED` | minutes |
|
||||
| `SLEEP_PERFORMANCE` | percent (0–100) |
|
||||
| `SLEEP_EFFICIENCY` | percent (0–100) |
|
||||
| `SLEEP_CONSISTENCY` | percent (0–100) |
|
||||
| `SLEEP_DISTURBANCE_COUNT` | count |
|
||||
| `BREATHING_DISTURBANCES` | count |
|
||||
| `ACTIVITY_STEPS` | count |
|
||||
| `FLIGHTS_CLIMBED` | count |
|
||||
| `WALKING_RUNNING_DISTANCE` | metres |
|
||||
| `SIX_MINUTE_WALK_DISTANCE` | metres |
|
||||
| `WALKING_STEP_LENGTH` | metres |
|
||||
| `WALKING_SPEED` | m/s |
|
||||
| `STAIR_ASCENT_SPEED` | m/s |
|
||||
| `STAIR_DESCENT_SPEED` | m/s |
|
||||
| `WALKING_STEADINESS` | percent (0–100) |
|
||||
| `WALKING_ASYMMETRY` | percent (0–100) |
|
||||
| `WALKING_DOUBLE_SUPPORT` | percent (0–100) |
|
||||
| `ACTIVE_ENERGY_BURNED` | kcal |
|
||||
| `ENERGY_EXPENDITURE_KJ` | kJ |
|
||||
| `VO2_MAX` | mL/(kg·min) |
|
||||
| `PULSE_WAVE_VELOCITY` | m/s |
|
||||
| `VASCULAR_AGE` | years |
|
||||
| `FALL_COUNT` | count |
|
||||
| `AUDIO_EXPOSURE_ENV` | dBA |
|
||||
| `AUDIO_EXPOSURE_HEADPHONE` | dBA |
|
||||
| `TIME_IN_DAYLIGHT` | minutes |
|
||||
| `RECOVERY_SCORE` | score (0–100) |
|
||||
| `STRESS_SCORE` | score (0–100) |
|
||||
| `STRAIN_SCORE` | score (0–100) |
|
||||
| `DAY_STRAIN` | score (0–21) |
|
||||
| `WORKOUT_STRAIN` | score (0–21) |
|
||||
| `AUDIO_EXPOSURE_EVENT` | count (event) |
|
||||
| `IRREGULAR_RHYTHM_NOTIFICATION` | event |
|
||||
| `HIGH_HEART_RATE_EVENT` | event |
|
||||
| `LOW_HEART_RATE_EVENT` | event |
|
||||
| `WALKING_STEADINESS_EVENT` | event |
|
||||
| `BREATHING_DISTURBANCE_EVENT` | event |
|
||||
| Type | Canonical unit |
|
||||
| ------------------------------- | -------------------- |
|
||||
| `WEIGHT` | kg |
|
||||
| `BLOOD_PRESSURE_SYS` | mmHg |
|
||||
| `BLOOD_PRESSURE_DIA` | mmHg |
|
||||
| `PULSE` | bpm |
|
||||
| `RESTING_HEART_RATE` | bpm |
|
||||
| `WALKING_HEART_RATE_AVERAGE` | bpm |
|
||||
| `AVERAGE_HEART_RATE` | bpm |
|
||||
| `MAX_HEART_RATE` | bpm |
|
||||
| `CARDIO_RECOVERY` | bpm |
|
||||
| `HEART_RATE_VARIABILITY` | ms (SDNN) |
|
||||
| `HRV_RMSSD` | ms (RMSSD) |
|
||||
| `RESPIRATORY_RATE` | count/min |
|
||||
| `OXYGEN_SATURATION` | percent (0–100) |
|
||||
| `BLOOD_GLUCOSE` | mg/dL |
|
||||
| `BODY_FAT` | percent (0–100) |
|
||||
| `FAT_MASS` | kg |
|
||||
| `FAT_FREE_MASS` | kg |
|
||||
| `LEAN_BODY_MASS` | kg |
|
||||
| `MUSCLE_MASS` | kg |
|
||||
| `BONE_MASS` | kg |
|
||||
| `TOTAL_BODY_WATER` | kg |
|
||||
| `VISCERAL_FAT` | rating |
|
||||
| `BODY_MASS_INDEX` | kg/m² |
|
||||
| `BODY_TEMPERATURE` | °C (core) |
|
||||
| `SKIN_TEMPERATURE` | °C (dermal spot) |
|
||||
| `WRIST_TEMPERATURE` | °C (overnight wrist) |
|
||||
| `SLEEP_DURATION` | minutes |
|
||||
| `SLEEP_NEED` | minutes |
|
||||
| `SLEEP_PERFORMANCE` | percent (0–100) |
|
||||
| `SLEEP_EFFICIENCY` | percent (0–100) |
|
||||
| `SLEEP_CONSISTENCY` | percent (0–100) |
|
||||
| `SLEEP_DISTURBANCE_COUNT` | count |
|
||||
| `BREATHING_DISTURBANCES` | count |
|
||||
| `ACTIVITY_STEPS` | count |
|
||||
| `FLIGHTS_CLIMBED` | count |
|
||||
| `WALKING_RUNNING_DISTANCE` | metres |
|
||||
| `SIX_MINUTE_WALK_DISTANCE` | metres |
|
||||
| `WALKING_STEP_LENGTH` | metres |
|
||||
| `WALKING_SPEED` | m/s |
|
||||
| `STAIR_ASCENT_SPEED` | m/s |
|
||||
| `STAIR_DESCENT_SPEED` | m/s |
|
||||
| `WALKING_STEADINESS` | percent (0–100) |
|
||||
| `WALKING_ASYMMETRY` | percent (0–100) |
|
||||
| `WALKING_DOUBLE_SUPPORT` | percent (0–100) |
|
||||
| `ACTIVE_ENERGY_BURNED` | kcal |
|
||||
| `ENERGY_EXPENDITURE_KJ` | kJ |
|
||||
| `VO2_MAX` | mL/(kg·min) |
|
||||
| `PULSE_WAVE_VELOCITY` | m/s |
|
||||
| `VASCULAR_AGE` | years |
|
||||
| `FALL_COUNT` | count |
|
||||
| `AUDIO_EXPOSURE_ENV` | dBA |
|
||||
| `AUDIO_EXPOSURE_HEADPHONE` | dBA |
|
||||
| `TIME_IN_DAYLIGHT` | minutes |
|
||||
| `RECOVERY_SCORE` | score (0–100) |
|
||||
| `STRESS_SCORE` | score (0–100) |
|
||||
| `STRAIN_SCORE` | score (0–100) |
|
||||
| `DAY_STRAIN` | score (0–21) |
|
||||
| `WORKOUT_STRAIN` | score (0–21) |
|
||||
| `AUDIO_EXPOSURE_EVENT` | count (event) |
|
||||
| `IRREGULAR_RHYTHM_NOTIFICATION` | event |
|
||||
| `HIGH_HEART_RATE_EVENT` | event |
|
||||
| `LOW_HEART_RATE_EVENT` | event |
|
||||
| `WALKING_STEADINESS_EVENT` | event |
|
||||
| `BREATHING_DISTURBANCE_EVENT` | event |
|
||||
|
||||
The live, machine-readable list of writable types and their units is
|
||||
also served by `GET /api/meta/capabilities` under `ingest.quantityTypes`
|
||||
|
||||
@@ -31,6 +31,7 @@ grant scoped to credentials they control.
|
||||
and the connection dies when the first access token expires (~1 hour).
|
||||
The remaining `read:*` scopes are read-only; request all of them so the
|
||||
user grants once and every sync resource is covered.
|
||||
|
||||
4. Save the app. WHOOP issues a **Client ID** and **Client Secret** —
|
||||
keep the secret out of any chat log; it grants read access to the
|
||||
linked account's full WHOOP history.
|
||||
|
||||
@@ -11,14 +11,14 @@ relay.
|
||||
The Withings client (`src/lib/withings/client.ts`) maps the following
|
||||
metric types into `Measurement` rows tagged `source = WITHINGS`:
|
||||
|
||||
| Withings meastype | HealthLog `MeasurementType` |
|
||||
| ----------------- | --------------------------- |
|
||||
| 1 | `WEIGHT` |
|
||||
| 5 | `FAT_FREE_MASS` |
|
||||
| 9 | `BLOOD_PRESSURE_DIA` |
|
||||
| 10 | `BLOOD_PRESSURE_SYS` |
|
||||
| 11 | `PULSE` |
|
||||
| 54, 35 | `OXYGEN_SATURATION` (ScanWatch / pulse-ox products) |
|
||||
| Withings meastype | HealthLog `MeasurementType` |
|
||||
| ---------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- |
|
||||
| 1 | `WEIGHT` |
|
||||
| 5 | `FAT_FREE_MASS` |
|
||||
| 9 | `BLOOD_PRESSURE_DIA` |
|
||||
| 10 | `BLOOD_PRESSURE_SYS` |
|
||||
| 11 | `PULSE` |
|
||||
| 54, 35 | `OXYGEN_SATURATION` (ScanWatch / pulse-ox products) |
|
||||
| Plus the body-composition family (fat ratio, muscle mass, bone mass, hydration) and sleep / heart-rate variability via the heart-list endpoint |
|
||||
|
||||
Activity data (steps, active energy, distance, floors climbed) lands
|
||||
@@ -136,7 +136,7 @@ Override per-user via the Sources section of `/settings/thresholds`.
|
||||
- **`Withings credentials not configured`** — `WITHINGS_CLIENT_ID`
|
||||
or `WITHINGS_CLIENT_SECRET` is missing from `.env`. Confirm both
|
||||
are set in the container's runtime environment (`docker compose
|
||||
exec app env | grep WITHINGS`).
|
||||
exec app env | grep WITHINGS`).
|
||||
- **`Callback URI mismatch`** — the URL HealthLog sends to Withings
|
||||
must match an entry in the developer-portal app's callback list.
|
||||
Add the public URL exactly, including the `https://` scheme and
|
||||
@@ -183,7 +183,7 @@ The Settings page **Disconnect** button:
|
||||
the database — disconnect does not delete data. Re-linking the
|
||||
same Withings account merges back onto the same metric
|
||||
timeline; the upsert path matches on `(userId, type, measuredAt,
|
||||
source)` so previously-synced rows update in place.
|
||||
source)` so previously-synced rows update in place.
|
||||
|
||||
## Admin probe
|
||||
|
||||
|
||||
@@ -39,11 +39,11 @@ export without polluting the local shell.
|
||||
Output is one row per declared variable, grouped by section. Three
|
||||
states:
|
||||
|
||||
| Marker | Meaning |
|
||||
| --------------------- | ---------------------------------------------------------------------------------- |
|
||||
| `[OK]` | Variable is set and non-empty. |
|
||||
| `[MISSING-REQUIRED]` | Variable is required for boot. The script exits 1. |
|
||||
| `[missing-optional]` | Variable is optional. The script reports it but does not fail. |
|
||||
| Marker | Meaning |
|
||||
| -------------------- | -------------------------------------------------------------- |
|
||||
| `[OK]` | Variable is set and non-empty. |
|
||||
| `[MISSING-REQUIRED]` | Variable is required for boot. The script exits 1. |
|
||||
| `[missing-optional]` | Variable is optional. The script reports it but does not fail. |
|
||||
|
||||
The grep-friendly format lets a CI pipeline do `grep -q
|
||||
'\[MISSING-REQUIRED\]'` to gate a deploy. The CI integration itself
|
||||
@@ -93,7 +93,7 @@ so changes go through a PR. To add a new variable:
|
||||
Deferred to v1.4.43:
|
||||
|
||||
- GitHub Actions workflow that runs `pnpm check-env --file
|
||||
.env.production.example` on every PR, blocking changes that
|
||||
.env.production.example` on every PR, blocking changes that
|
||||
introduce new required vars without updating the example file.
|
||||
- Coolify pre-deploy hook that runs `pnpm check-env` inside the
|
||||
target container and aborts the deploy on exit code 1.
|
||||
@@ -96,6 +96,7 @@ pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix
|
||||
```
|
||||
|
||||
`--tombstone-implausible` is refused without `--fix`.
|
||||
|
||||
- **Drifted schedule windows are reconciled** to
|
||||
`min/max(times_of_day)` so the legacy pair stops contradicting the
|
||||
canonical dose times. Overnight windows (`window_end < window_start`)
|
||||
|
||||
@@ -9,8 +9,8 @@ already installed, longer if a TLS-fronted public hostname is in scope
|
||||
|
||||
- **Docker 24+ with Compose v2.** The bundled `docker-compose.yml`
|
||||
pulls a multi-arch image from GitHub Container Registry (linux/amd64
|
||||
+ linux/arm64), so a Raspberry Pi 5 or an x86 VPS both work
|
||||
unchanged.
|
||||
- linux/arm64), so a Raspberry Pi 5 or an x86 VPS both work
|
||||
unchanged.
|
||||
- **2 GB RAM, 10 GB free disk.** The Postgres data volume grows with
|
||||
measurement history; the Apple Health import worker can briefly
|
||||
hold a 1.5 GB upload in `/tmp` while it parses.
|
||||
@@ -43,11 +43,11 @@ echo "ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env
|
||||
echo "API_TOKEN_HMAC_KEY=$(openssl rand -hex 32)" >> .env
|
||||
```
|
||||
|
||||
| Variable | Purpose | Rotation cost |
|
||||
| -------- | ------- | ------------- |
|
||||
| `POSTGRES_PASSWORD` | Bundled Postgres password — used by both the `db` service and `DATABASE_URL` | Database restart + connection-string update |
|
||||
| `ENCRYPTION_KEY` | AES-256-GCM key for at-rest secrets (Withings tokens, AI provider keys, VAPID secrets) | Coordinated; see `docs/ops/encryption-key-rotation.md` |
|
||||
| `API_TOKEN_HMAC_KEY` | HMAC-SHA256 key for hashing Bearer API tokens before storage | Invalidates every issued `hlk_*` token |
|
||||
| Variable | Purpose | Rotation cost |
|
||||
| -------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------ |
|
||||
| `POSTGRES_PASSWORD` | Bundled Postgres password — used by both the `db` service and `DATABASE_URL` | Database restart + connection-string update |
|
||||
| `ENCRYPTION_KEY` | AES-256-GCM key for at-rest secrets (Withings tokens, AI provider keys, VAPID secrets) | Coordinated; see `docs/ops/encryption-key-rotation.md` |
|
||||
| `API_TOKEN_HMAC_KEY` | HMAC-SHA256 key for hashing Bearer API tokens before storage | Invalidates every issued `hlk_*` token |
|
||||
|
||||
Then open `.env` and confirm `DATABASE_URL` includes the password you
|
||||
just generated. The default template (`postgresql://healthlog:CHANGE-ME@db:5432/healthlog`)
|
||||
@@ -131,9 +131,9 @@ compose `environment:` block has no effect — they only take hold when
|
||||
you build your own image. They are intentionally absent from the
|
||||
compose runtime whitelist for that reason.
|
||||
|
||||
| Variable | Default | Effect when set |
|
||||
| --- | --- | --- |
|
||||
| `NEXT_PUBLIC_DASHBOARD_SNAPSHOT` | on | The dashboard hydrates every above-the-fold tile from the single `GET /api/dashboard/snapshot` cell so the whole strip shares one completion moment. Build with `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false` to fall back to the legacy four-cell path. |
|
||||
| Variable | Default | Effect when set |
|
||||
| -------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `NEXT_PUBLIC_DASHBOARD_SNAPSHOT` | on | The dashboard hydrates every above-the-fold tile from the single `GET /api/dashboard/snapshot` cell so the whole strip shares one completion moment. Build with `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false` to fall back to the legacy four-cell path. |
|
||||
|
||||
To flip one, rebuild the image with the variable exported:
|
||||
|
||||
@@ -144,18 +144,18 @@ NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false pnpm build
|
||||
|
||||
## 7. Where to go next
|
||||
|
||||
| Next step | File |
|
||||
| --------- | ---- |
|
||||
| TLS + reverse-proxy configuration | `docs/self-hosting/reverse-proxy.md` |
|
||||
| Push notifications — Web Push, Telegram, ntfy, webhook, email, APNs | `docs/self-hosting/notifications.md` |
|
||||
| Web/worker process split for horizontal scale | `docs/self-hosting/scaling.md` |
|
||||
| Off-host encrypted backups to S3/R2/B2 | `docs/ops/backup-restore.md` |
|
||||
| Encryption-key rotation procedure | `docs/ops/encryption-key-rotation.md` |
|
||||
| Withings device sync | `docs/integrations/withings.md` |
|
||||
| WHOOP device sync (per-user BYO-keys) | `docs/integrations/whoop.md` |
|
||||
| Google Health sync — Fitbit & Pixel (per-user BYO-keys) | `docs/integrations/google-health.md` |
|
||||
| Apple Health `export.zip` import | `docs/integrations/apple-health.md` |
|
||||
| AI provider setup (OpenAI / Anthropic / local / ChatGPT OAuth) | `docs/integrations/ai-providers.md` |
|
||||
| Next step | File |
|
||||
| ------------------------------------------------------------------- | ------------------------------------- |
|
||||
| TLS + reverse-proxy configuration | `docs/self-hosting/reverse-proxy.md` |
|
||||
| Push notifications — Web Push, Telegram, ntfy, webhook, email, APNs | `docs/self-hosting/notifications.md` |
|
||||
| Web/worker process split for horizontal scale | `docs/self-hosting/scaling.md` |
|
||||
| Off-host encrypted backups to S3/R2/B2 | `docs/ops/backup-restore.md` |
|
||||
| Encryption-key rotation procedure | `docs/ops/encryption-key-rotation.md` |
|
||||
| Withings device sync | `docs/integrations/withings.md` |
|
||||
| WHOOP device sync (per-user BYO-keys) | `docs/integrations/whoop.md` |
|
||||
| Google Health sync — Fitbit & Pixel (per-user BYO-keys) | `docs/integrations/google-health.md` |
|
||||
| Apple Health `export.zip` import | `docs/integrations/apple-health.md` |
|
||||
| AI provider setup (OpenAI / Anthropic / local / ChatGPT OAuth) | `docs/integrations/ai-providers.md` |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -168,7 +168,7 @@ NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false pnpm build
|
||||
`app_settings.singleton.registrationEnabled`. The admin panel can
|
||||
flip it back if a previous owner closed it.
|
||||
- **Service unreachable on `localhost:3000`.** Check `docker compose
|
||||
ps` — the `app` healthcheck polls `/api/version` every 30 seconds
|
||||
ps` — the `app` healthcheck polls `/api/version` every 30 seconds
|
||||
and will mark the container unhealthy if Postgres is still
|
||||
starting. Tail `docker compose logs db` to confirm Postgres came up
|
||||
cleanly.
|
||||
|
||||
@@ -24,14 +24,14 @@ Apple-platform exclusives the web cannot offer.
|
||||
|
||||
## TL;DR — which channel needs what
|
||||
|
||||
| Channel | What it needs | Cost | Where it works |
|
||||
| --- | --- | --- | --- |
|
||||
| **Web Push** | A VAPID keypair (one command, or paste into the admin panel) | Free | Any modern desktop/Android browser; the installed PWA on iPhone (iOS 16.4+, after *Add to Home Screen*) |
|
||||
| **Telegram** | A bot token from @BotFather + each user's chat ID | Free | Anywhere the Telegram app runs |
|
||||
| **ntfy** | A topic on `ntfy.sh` or your own ntfy server | Free | The ntfy app (iOS/Android) or any browser |
|
||||
| **Webhook** | A URL you own (and optionally one custom header) | Free | Anywhere — you route the POST yourself |
|
||||
| **Email** | An SMTP transport you operate or rent | Free–cheap | Any mailbox |
|
||||
| **APNs** | An Apple Developer account + a `.p8` push key + your own signed iOS build | Apple Developer Program (paid) | A native iOS app you build and sign |
|
||||
| Channel | What it needs | Cost | Where it works |
|
||||
| ------------ | ------------------------------------------------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------- |
|
||||
| **Web Push** | A VAPID keypair (one command, or paste into the admin panel) | Free | Any modern desktop/Android browser; the installed PWA on iPhone (iOS 16.4+, after _Add to Home Screen_) |
|
||||
| **Telegram** | A bot token from @BotFather + each user's chat ID | Free | Anywhere the Telegram app runs |
|
||||
| **ntfy** | A topic on `ntfy.sh` or your own ntfy server | Free | The ntfy app (iOS/Android) or any browser |
|
||||
| **Webhook** | A URL you own (and optionally one custom header) | Free | Anywhere — you route the POST yourself |
|
||||
| **Email** | An SMTP transport you operate or rent | Free–cheap | Any mailbox |
|
||||
| **APNs** | An Apple Developer account + a `.p8` push key + your own signed iOS build | Apple Developer Program (paid) | A native iOS app you build and sign |
|
||||
|
||||
Web Push is the recommended default — it needs no third-party account,
|
||||
just a keypair you generate yourself once.
|
||||
@@ -173,7 +173,7 @@ pub/sub push relay you can use hosted (`ntfy.sh`) or run yourself. You
|
||||
subscribe to a **topic** in the ntfy app (iOS/Android) or a browser, and
|
||||
HealthLog publishes to that topic.
|
||||
|
||||
1. Pick a hard-to-guess topic name (the topic *is* the access control on
|
||||
1. Pick a hard-to-guess topic name (the topic _is_ the access control on
|
||||
public `ntfy.sh` — anyone who knows the string can read it, so treat
|
||||
it like a secret).
|
||||
2. Subscribe to that topic in the ntfy app or at `https://ntfy.sh/<topic>`.
|
||||
@@ -268,14 +268,14 @@ under your own Apple identity. Everything below is that path.
|
||||
|
||||
The APNs env vars (`src/lib/notifications/senders/apns.ts`):
|
||||
|
||||
| Variable | Purpose |
|
||||
| --- | --- |
|
||||
| `APNS_KEY_ID` | The `.p8` key's Key ID |
|
||||
| `APNS_TEAM_ID` | Your Apple Developer Team ID |
|
||||
| `APNS_BUNDLE_ID` | Your app's bundle identifier |
|
||||
| `APNS_KEY_B64` | The `.p8` PEM, base64-encoded (recommended key source) |
|
||||
| `APNS_KEY` | OR the raw PEM body inline |
|
||||
| `APNS_KEY_FILE` | OR a path to the `.p8` file |
|
||||
| Variable | Purpose |
|
||||
| --------------------------- | ----------------------------------------------------------------------------------- |
|
||||
| `APNS_KEY_ID` | The `.p8` key's Key ID |
|
||||
| `APNS_TEAM_ID` | Your Apple Developer Team ID |
|
||||
| `APNS_BUNDLE_ID` | Your app's bundle identifier |
|
||||
| `APNS_KEY_B64` | The `.p8` PEM, base64-encoded (recommended key source) |
|
||||
| `APNS_KEY` | OR the raw PEM body inline |
|
||||
| `APNS_KEY_FILE` | OR a path to the `.p8` file |
|
||||
| `APNS_CRITICAL_ENTITLEMENT` | Optional — set `true` only if Apple grants your app the Critical Alerts entitlement |
|
||||
|
||||
APNs is **all-or-none**: either set the three IDs plus exactly one key
|
||||
@@ -311,14 +311,14 @@ Two gotchas worth knowing before you blame a stale token:
|
||||
|
||||
## Which channel should I pick?
|
||||
|
||||
| Your setup | Recommended channel |
|
||||
| --- | --- |
|
||||
| PWA on Android or desktop | **Web Push** — generate a VAPID keypair, subscribe, done. |
|
||||
| PWA on an iPhone | **Web Push**, but install the PWA via *Add to Home Screen* first (iOS 16.4+). Reminders, taken-clears, and the app badge all work; the lock-screen Live Activity needs a native build. |
|
||||
| You want the native iOS lock-screen Live Activity | Build and sign **your own** iOS app under your own Apple account and wire up **APNs** (paid), with Web Push as a fallback for any non-iOS device. |
|
||||
| Headless / no browser, or you live in chat | **Telegram** or **ntfy** — neither needs a browser, both are free. ntfy is the most self-host-native; Telegram adds inline reminder action buttons. |
|
||||
| You already run an automation hub | **Webhook** — POST every event to your own URL and route it however you like. |
|
||||
| You want reminders in your inbox | **Email** — set the SMTP transport once as the operator; users add their address. |
|
||||
| Your setup | Recommended channel |
|
||||
| ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| PWA on Android or desktop | **Web Push** — generate a VAPID keypair, subscribe, done. |
|
||||
| PWA on an iPhone | **Web Push**, but install the PWA via _Add to Home Screen_ first (iOS 16.4+). Reminders, taken-clears, and the app badge all work; the lock-screen Live Activity needs a native build. |
|
||||
| You want the native iOS lock-screen Live Activity | Build and sign **your own** iOS app under your own Apple account and wire up **APNs** (paid), with Web Push as a fallback for any non-iOS device. |
|
||||
| Headless / no browser, or you live in chat | **Telegram** or **ntfy** — neither needs a browser, both are free. ntfy is the most self-host-native; Telegram adds inline reminder action buttons. |
|
||||
| You already run an automation hub | **Webhook** — POST every event to your own URL and route it however you like. |
|
||||
| You want reminders in your inbox | **Email** — set the SMTP transport once as the operator; users add their address. |
|
||||
|
||||
Mixing channels is fine and common — e.g. Web Push on the laptop and
|
||||
ntfy on the phone. Each user configures their own set from
|
||||
|
||||
@@ -45,7 +45,7 @@ reached:
|
||||
- **Physical or legal seizure of your own host.** If the machine
|
||||
running PostgreSQL — your VPS, your home server, your NAS — is seized,
|
||||
imaged, or compelled, the data is on it. Encryption at rest protects
|
||||
against a stolen disk or a leaked backup only while the key is *not*
|
||||
against a stolen disk or a leaked backup only while the key is _not_
|
||||
also available; if the host is running, or the key is stored next to
|
||||
the data, that protection is gone.
|
||||
- **Your hosting provider.** A VPS provider can snapshot your disk and
|
||||
@@ -77,7 +77,7 @@ record, not speculation:
|
||||
California privacy statute over the sharing of menstrual-tracking
|
||||
data; the verdict and the underlying class action are public record.
|
||||
|
||||
These are cited as factual public record to explain *why* the
|
||||
These are cited as factual public record to explain _why_ the
|
||||
"who holds the data" question is the one that matters for this category.
|
||||
HealthLog makes no claim about any other product's current practices.
|
||||
|
||||
|
||||
@@ -11,11 +11,11 @@ the env-var pairing is identical across them.
|
||||
|
||||
## Pre-flight — env vars the proxy interacts with
|
||||
|
||||
| Variable | Required for | Notes |
|
||||
| -------- | ------------ | ----- |
|
||||
| `NEXT_PUBLIC_APP_URL` | Every public-facing absolute URL the UI emits | Must match the hostname the proxy serves |
|
||||
| `APP_URL` | Server-side absolute URLs from background jobs | Mirror of the above; both must match |
|
||||
| `TRUST_PROXY_HOPS` | `X-Forwarded-For` chain trust | Defaults to `1` — one trusted hop |
|
||||
| Variable | Required for | Notes |
|
||||
| --------------------- | ---------------------------------------------- | ---------------------------------------- |
|
||||
| `NEXT_PUBLIC_APP_URL` | Every public-facing absolute URL the UI emits | Must match the hostname the proxy serves |
|
||||
| `APP_URL` | Server-side absolute URLs from background jobs | Mirror of the above; both must match |
|
||||
| `TRUST_PROXY_HOPS` | `X-Forwarded-For` chain trust | Defaults to `1` — one trusted hop |
|
||||
|
||||
Set the URL pair to the public origin (`https://your-instance.example.com`)
|
||||
and restart the `app` container. The image reads both at startup; no
|
||||
@@ -28,12 +28,12 @@ chain from the **right**, counting back `TRUST_PROXY_HOPS` entries.
|
||||
This guards against a client rotating `X-Forwarded-For` per request
|
||||
to defeat IP-based rate limits.
|
||||
|
||||
| Topology | `TRUST_PROXY_HOPS` |
|
||||
| -------- | ------------------ |
|
||||
| App is internet-facing with no proxy | `0` (XFF ignored entirely) |
|
||||
| Single proxy in front (Caddy / Traefik / Nginx / Coolify) | `1` (default) |
|
||||
| Cloudflare → your proxy → app | `2` |
|
||||
| Cloudflare → Coolify-Tunnel → Coolify → app | `3` |
|
||||
| Topology | `TRUST_PROXY_HOPS` |
|
||||
| --------------------------------------------------------- | -------------------------- |
|
||||
| App is internet-facing with no proxy | `0` (XFF ignored entirely) |
|
||||
| Single proxy in front (Caddy / Traefik / Nginx / Coolify) | `1` (default) |
|
||||
| Cloudflare → your proxy → app | `2` |
|
||||
| Cloudflare → Coolify-Tunnel → Coolify → app | `3` |
|
||||
|
||||
A misconfigured count logs a one-shot warning to stderr and collapses
|
||||
every anonymous caller into one shared rate-limit bucket. Match the
|
||||
@@ -132,7 +132,7 @@ operator attention:
|
||||
|
||||
The following variables are **build-time only** — they are injected
|
||||
via `--build-arg` by the `docker-publish` workflow and baked into the
|
||||
shipped image. Setting them under Coolify's *Environment Variables*
|
||||
shipped image. Setting them under Coolify's _Environment Variables_
|
||||
panel has zero effect on the running container, and the stale value
|
||||
becomes a misleading artefact next time someone audits the deploy
|
||||
config:
|
||||
|
||||
@@ -90,7 +90,7 @@ services:
|
||||
app:
|
||||
image: ghcr.io/mbombeck/healthlog:latest
|
||||
environment:
|
||||
DATABASE_POOL_MAX: "12" # 6 containers × 12 = 72 < 100
|
||||
DATABASE_POOL_MAX: "12" # 6 containers × 12 = 72 < 100
|
||||
HEALTHLOG_PROCESS_TYPE: web
|
||||
```
|
||||
|
||||
|
||||
@@ -235,7 +235,7 @@ lists) uses the unified `<FilterBar>` rail from
|
||||
labelled `<Select>`s and date inputs.
|
||||
|
||||
- **Grammar**: `[icon] [Zeitraum] [Typ] [Quelle] [extras…]
|
||||
[Zurücksetzen] … [count]`. Canonical dimension order is date range,
|
||||
[Zurücksetzen] … [count]`. Canonical dimension order is date range,
|
||||
type, source; surface-specific dimensions append after.
|
||||
- **Pill = trigger + chip**: inactive pills show only the dimension
|
||||
name; active pills show `Name: Wert`, pick up the primary accent and
|
||||
|
||||
@@ -243,7 +243,9 @@ test.describe("Coach guided clarifying questions", () => {
|
||||
await expect(current).toHaveCount(0);
|
||||
|
||||
// Settling the offer (decline via ✕) releases the next question.
|
||||
await adopt.locator('[data-slot="coach-self-context-adopt-dismiss"]').click();
|
||||
await adopt
|
||||
.locator('[data-slot="coach-self-context-adopt-dismiss"]')
|
||||
.click();
|
||||
await expect(current).toContainText(QUESTIONS[1], { timeout: 10_000 });
|
||||
await expect(
|
||||
current.locator('[data-slot="coach-guided-progress"]'),
|
||||
|
||||
@@ -114,7 +114,12 @@ test.describe("authenticated dashboard render", () => {
|
||||
const types = (url.searchParams.get("types") ?? "WEIGHT").split(",");
|
||||
const series: Record<
|
||||
string,
|
||||
Array<{ type: string; value: number; measuredAt: string; count: number }>
|
||||
Array<{
|
||||
type: string;
|
||||
value: number;
|
||||
measuredAt: string;
|
||||
count: number;
|
||||
}>
|
||||
> = {};
|
||||
for (const type of types) {
|
||||
const baseValue =
|
||||
|
||||
@@ -24,9 +24,9 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
test.describe("v1.4.28 — insights scroll restoration", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
test.fixme(
|
||||
"returning to /insights from a sub-page lands at the top",
|
||||
async ({ page }) => {
|
||||
test.fixme("returning to /insights from a sub-page lands at the top", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/insights", { waitUntil: "domcontentloaded" });
|
||||
// Force the mother page tall enough that scrolling has somewhere
|
||||
// to go even on a sparse seed; the rendered Insights overview
|
||||
@@ -42,10 +42,7 @@ test.describe("v1.4.28 — insights scroll restoration", () => {
|
||||
await page.waitForURL(/\/insights\//);
|
||||
|
||||
// Back to the mother page via the overview pill (pill index 0).
|
||||
await page
|
||||
.locator("[data-slot='insights-tab-strip-pill']")
|
||||
.first()
|
||||
.click();
|
||||
await page.locator("[data-slot='insights-tab-strip-pill']").first().click();
|
||||
await page.waitForURL(/\/insights\/?$/);
|
||||
|
||||
// The mother page's mount-effect deferred a `scrollTo(0)` to the
|
||||
@@ -53,8 +50,7 @@ test.describe("v1.4.28 — insights scroll restoration", () => {
|
||||
await page.waitForTimeout(50);
|
||||
const y = await page.evaluate(() => window.scrollY);
|
||||
expect(y).toBeLessThan(50);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("the sticky tab strip declares touch-action pan-y so vertical swipes pass through", async ({
|
||||
page,
|
||||
@@ -63,7 +59,9 @@ test.describe("v1.4.28 — insights scroll restoration", () => {
|
||||
const nav = page.locator("[data-slot='insights-tab-strip']");
|
||||
await expect(nav).toBeVisible();
|
||||
const touchAction = await nav.evaluate(
|
||||
(el) => (el as HTMLElement).style.touchAction || getComputedStyle(el).touchAction,
|
||||
(el) =>
|
||||
(el as HTMLElement).style.touchAction ||
|
||||
getComputedStyle(el).touchAction,
|
||||
);
|
||||
expect(touchAction).toContain("pan-y");
|
||||
});
|
||||
|
||||
@@ -165,10 +165,7 @@ test.describe("medication wizard — compose-mode", () => {
|
||||
expect(capture.postBody).toMatchObject({
|
||||
name: "Insulin",
|
||||
oneShot: false,
|
||||
schedules: [
|
||||
{ rrule: "FREQ=DAILY" },
|
||||
{ rrule: "FREQ=WEEKLY;BYDAY=WE" },
|
||||
],
|
||||
schedules: [{ rrule: "FREQ=DAILY" }, { rrule: "FREQ=WEEKLY;BYDAY=WE" }],
|
||||
});
|
||||
|
||||
await expectMedicationOnList(page, {
|
||||
|
||||
@@ -221,9 +221,13 @@ export async function expectStep(
|
||||
// stepper landed, so it is hidden on the desktop e2e viewport. Assert the
|
||||
// viewport-independent root data-attributes the dialog always carries.
|
||||
const root = page.locator('[data-slot="medication-wizard-dialog"]');
|
||||
await expect(root).toHaveAttribute("data-display-step", String(displayIndex), {
|
||||
timeout: 5_000,
|
||||
});
|
||||
await expect(root).toHaveAttribute(
|
||||
"data-display-step",
|
||||
String(displayIndex),
|
||||
{
|
||||
timeout: 5_000,
|
||||
},
|
||||
);
|
||||
if (totalSteps != null) {
|
||||
await expect(root).toHaveAttribute("data-total-steps", String(totalSteps));
|
||||
}
|
||||
|
||||
@@ -159,7 +159,9 @@ test.describe("mobile-viewport smoke", () => {
|
||||
// trimmed text. A role-based label is more stable across
|
||||
// re-skins than the raw `innerText` that brittle selectors
|
||||
// surfaced previously.
|
||||
const ariaLabel = await btn.getAttribute("aria-label").catch(() => null);
|
||||
const ariaLabel = await btn
|
||||
.getAttribute("aria-label")
|
||||
.catch(() => null);
|
||||
const innerText = await btn.innerText().catch(() => "");
|
||||
const label = ariaLabel || innerText || "(no text)";
|
||||
failures.push(
|
||||
|
||||
@@ -138,17 +138,17 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
|
||||
|
||||
// CCH-04 — the per-metric inline launch icon is retired; no header
|
||||
// launcher renders on the sub-page.
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-launch-icon"]'),
|
||||
).toHaveCount(0);
|
||||
await expect(page.locator('[data-slot="coach-launch-icon"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
|
||||
// The FAB is the permanent launcher; with no unread nudge it
|
||||
// renders without the unread dot.
|
||||
const fab = page.locator('[data-slot="coach-fab"]');
|
||||
await expect(fab).toBeVisible({ timeout: 10_000 });
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-fab-unread"]'),
|
||||
).toHaveCount(0);
|
||||
await expect(page.locator('[data-slot="coach-fab-unread"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test(`/insights/${slug} mounts the dot-free Coach FAB on Desktop Chrome`, async ({
|
||||
@@ -161,15 +161,15 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
|
||||
|
||||
await page.goto(`/insights/${slug}`, { waitUntil: "domcontentloaded" });
|
||||
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-launch-icon"]'),
|
||||
).toHaveCount(0);
|
||||
await expect(page.locator('[data-slot="coach-launch-icon"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
|
||||
const fab = page.locator('[data-slot="coach-fab"]');
|
||||
await expect(fab).toBeVisible({ timeout: 10_000 });
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-fab-unread"]'),
|
||||
).toHaveCount(0);
|
||||
await expect(page.locator('[data-slot="coach-fab-unread"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -255,9 +255,7 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
|
||||
await expect(fab).toHaveAttribute("data-unread", "true", {
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-fab-unread"]'),
|
||||
).toBeVisible();
|
||||
await expect(page.locator('[data-slot="coach-fab-unread"]')).toBeVisible();
|
||||
await fab.click();
|
||||
|
||||
// v1.16.11 — the FAB opens the side drawer in place instead of
|
||||
@@ -274,9 +272,7 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
|
||||
|
||||
// Opening the Coach counts as reading the nudge on this device —
|
||||
// the dot clears and the unread flag drops off the FAB.
|
||||
await expect(page.locator('[data-slot="coach-fab-unread"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect(page.locator('[data-slot="coach-fab-unread"]')).toHaveCount(0);
|
||||
await expect(fab).not.toHaveAttribute("data-unread", "true");
|
||||
});
|
||||
|
||||
@@ -318,9 +314,9 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
|
||||
|
||||
// No in-panel tray opens — the click navigates to the full view.
|
||||
await page.waitForURL("**/coach", { timeout: 10_000 });
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-page"]'),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.locator('[data-slot="coach-page"]')).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-drawer-history-tray"]'),
|
||||
).toHaveCount(0);
|
||||
|
||||
@@ -148,9 +148,7 @@ test.describe("v1.4.27 — insights empty-state with metric gating", () => {
|
||||
// title). Pin on the sub-page slot's h1 so we don't accidentally
|
||||
// match a sidebar / nav element.
|
||||
await expect(
|
||||
page.locator(
|
||||
'[data-slot="insights-subpage"] #insights-subpage-title',
|
||||
),
|
||||
page.locator('[data-slot="insights-subpage"] #insights-subpage-title'),
|
||||
).toContainText(headingMatch);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -38,14 +38,14 @@ test.describe("v1.4.27 — public pages", () => {
|
||||
// No bounce to the login page.
|
||||
expect(page.url()).toMatch(/\/about(\?|$)/);
|
||||
|
||||
await expect(page.getByRole("heading", { name: /about/i }).first()).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: /about/i }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
// GeoLite2 attribution is the load-bearing reason this page exists.
|
||||
await expect(page.getByText(/GeoLite2/)).toBeVisible();
|
||||
await expect(page.getByText(/MaxMind/)).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(/Attribution-ShareAlike 4\.0/i),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText(/Attribution-ShareAlike 4\.0/i)).toBeVisible();
|
||||
});
|
||||
|
||||
// v1.4.28 backlog: Next.js's `not-found.tsx` may return 200 or 404
|
||||
|
||||
@@ -58,9 +58,7 @@ test.describe("v1.4.27 — ResponsiveSheet branch", () => {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
|
||||
const content = page.locator(
|
||||
'[data-slot="responsive-sheet-content"]',
|
||||
);
|
||||
const content = page.locator('[data-slot="responsive-sheet-content"]');
|
||||
await expect(content).toBeVisible({ timeout: 10_000 });
|
||||
await expect(content).toHaveAttribute("data-variant", "sheet");
|
||||
|
||||
@@ -85,9 +83,7 @@ test.describe("v1.4.27 — ResponsiveSheet branch", () => {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
|
||||
const content = page.locator(
|
||||
'[data-slot="responsive-sheet-content"]',
|
||||
);
|
||||
const content = page.locator('[data-slot="responsive-sheet-content"]');
|
||||
await expect(content).toBeVisible({ timeout: 10_000 });
|
||||
await expect(content).toHaveAttribute("data-variant", "dialog");
|
||||
});
|
||||
|
||||
@@ -125,8 +125,8 @@ const queryKeyFactoryRule = {
|
||||
node.key.type === "Identifier"
|
||||
? node.key.name
|
||||
: node.key.type === "Literal"
|
||||
? node.key.value
|
||||
: null;
|
||||
? node.key.value
|
||||
: null;
|
||||
if (keyName !== "queryKey" && keyName !== "mutationKey") return;
|
||||
|
||||
context.report({
|
||||
|
||||
@@ -81,7 +81,7 @@ const safeFetchRequiredRule = {
|
||||
schema: [],
|
||||
messages: {
|
||||
rawFetch:
|
||||
"Raw fetch() bypasses the safeFetch wrapper's manual-redirect + timeout (and requirePublicHost) defences. Import { safeFetch } from \"@/lib/safe-fetch\" and call it instead.",
|
||||
'Raw fetch() bypasses the safeFetch wrapper\'s manual-redirect + timeout (and requirePublicHost) defences. Import { safeFetch } from "@/lib/safe-fetch" and call it instead.',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
|
||||
@@ -63,21 +63,10 @@
|
||||
"testcontainers",
|
||||
"tw-animate-css"
|
||||
],
|
||||
"ignoreBinaries": [
|
||||
"docker",
|
||||
"docker-compose",
|
||||
"pg_isready",
|
||||
"tsx",
|
||||
"cosign"
|
||||
],
|
||||
"ignoreBinaries": ["docker", "docker-compose", "pg_isready", "tsx", "cosign"],
|
||||
"ignoreExportsUsedInFile": true,
|
||||
"ignoreIssues": {
|
||||
"src/components/ui/**": [
|
||||
"exports",
|
||||
"types"
|
||||
],
|
||||
"src/lib/validations/**": [
|
||||
"types"
|
||||
]
|
||||
"src/components/ui/**": ["exports", "types"],
|
||||
"src/lib/validations/**": ["types"]
|
||||
}
|
||||
}
|
||||
}
|
||||
+62
-35
@@ -2003,6 +2003,7 @@
|
||||
"dailyBriefing": {
|
||||
"title": "Tagesbriefing",
|
||||
"keyFindingsTitle": "Wichtige Befunde",
|
||||
"signalsTitle": "Signale des Tages",
|
||||
"emptyTitle": "Noch kein Briefing",
|
||||
"emptyDescription": "Erstelle das heutige Briefing, um eine Zusammenfassung deiner Trends zu bekommen.",
|
||||
"emptyAction": "Briefing erstellen",
|
||||
@@ -2263,7 +2264,11 @@
|
||||
"settingsClustersLabel": "Daten, die der Coach sieht",
|
||||
"settingsClustersHint": "Wähle, welche Datencluster der Coach lesen darf. Mehr Daten bedeuten reichhaltigere Antworten und einen größeren Prompt.",
|
||||
"settingsClusterNoData": "Noch keine Daten",
|
||||
"sourcesMemberCount": "{count} Werte"
|
||||
"sourcesMemberCount": "{count} Werte",
|
||||
"dictate": "Diktieren",
|
||||
"dictateStop": "Diktat beenden",
|
||||
"showConversations": "Unterhaltungen anzeigen",
|
||||
"hideConversations": "Unterhaltungen ausblenden"
|
||||
},
|
||||
"relativeJustNow": "gerade eben",
|
||||
"relativeMinutesAgoOne": "vor {count} Minute",
|
||||
@@ -2568,7 +2573,6 @@
|
||||
"chronotype": {
|
||||
"title": "Chronotyp",
|
||||
"learning": "Dein Rhythmus wird noch erfasst — {counted} von {total} freien Nächten. Ein Chronotyp braucht ein paar weckerfreie Morgen, um aussagekräftig zu sein.",
|
||||
"midSleepCaption": "Deine natürliche Schlafmitte an freien Tagen liegt etwa um {clock}.",
|
||||
"advancedToggle": "Mehr Details",
|
||||
"socialJetlagValue": "{hours} Std. {minutes} Min. sozialer Jetlag",
|
||||
"socialJetlagCaption": "Der Unterschied zwischen deinem Schlaf an Arbeits- und freien Tagen — wie eine kleine wöchentliche Zeitzonenverschiebung.",
|
||||
@@ -2582,7 +2586,7 @@
|
||||
"late": "Abendtyp",
|
||||
"extreme_late": "Ausgeprägter Abendtyp"
|
||||
},
|
||||
"cornerLabel": "Chronotyp"
|
||||
"midpointHeadline": "Deine natürliche Schlafmitte liegt etwa um {clock}."
|
||||
},
|
||||
"rhythm": {
|
||||
"loadError": "Dein Schlafrhythmus konnte gerade nicht geladen werden. Er erscheint wieder, sobald die Verbindung steht."
|
||||
@@ -4021,6 +4025,21 @@
|
||||
"description": "Gruppen, Tags und archivierte Tags der Stimmungs-Auswahl.",
|
||||
"subtitle": "Organisiere deine Stimmungs-Tags, Gruppen und Auswahlreihenfolge."
|
||||
},
|
||||
"labs": {
|
||||
"title": "Laborwerte",
|
||||
"description": "Ansicht, Sortierung und Biomarker deiner Laborwerte.",
|
||||
"subtitle": "Ansicht, Sortierung und Biomarker verwalten."
|
||||
},
|
||||
"illness": {
|
||||
"title": "Krankheitstagebuch",
|
||||
"description": "Ansicht und Reihenfolge deiner Krankheitsepisoden.",
|
||||
"subtitle": "Ansicht und Reihenfolge der Episoden verwalten."
|
||||
},
|
||||
"vorsorge": {
|
||||
"title": "Vorsorge",
|
||||
"description": "Ansicht, Reihenfolge und einzelne Vorsorge-Erinnerungen.",
|
||||
"subtitle": "Ansicht, Reihenfolge und einzelne Erinnerungen verwalten."
|
||||
},
|
||||
"modules": {
|
||||
"title": "Module",
|
||||
"saved": "Gespeichert.",
|
||||
@@ -4324,7 +4343,7 @@
|
||||
}
|
||||
},
|
||||
"withings": "Withings",
|
||||
"withingsDescription": "Verbinde deine Withings-Waage und Blutdruckmessgeräte.",
|
||||
"withingsDescription": "Verbinde deine Withings-Waage und dein Blutdruckmessgerät, um Gewicht, Körperzusammensetzung und Blutdruck automatisch zu synchronisieren.",
|
||||
"withingsCredentials": "API-Zugangsdaten",
|
||||
"withingsClientId": "Client-ID",
|
||||
"withingsClientSecret": "Client-Secret",
|
||||
@@ -4362,10 +4381,8 @@
|
||||
"withingsConnect": "Mit Withings verbinden",
|
||||
"withingsNoCredentials": "Bitte zuerst die API-Zugangsdaten oben eingeben, um Withings zu verbinden.",
|
||||
"whoop": "WHOOP",
|
||||
"whoopDescription": "Verbinde dein WHOOP-Band, um Erholung, Schlaf, Belastung und Workouts zu synchronisieren.",
|
||||
"whoopOverlapNote": "Wenn WHOOP und eine weitere Quelle denselben Vitalwert liefern – Ruhepuls, Blutsauerstoff, Körpertemperatur, Atemfrequenz oder Schlafphasen –, können beide Werte angezeigt werden, bis sich ein künftiges Update auf eine bevorzugte Quelle festlegt.",
|
||||
"whoopDescription": "Verbinde dein WHOOP-Band, um Erholung, Schlaf, Belastung und Workouts direkt in HealthLog zu synchronisieren.",
|
||||
"whoopCredentials": "API-Zugangsdaten",
|
||||
"whoopCredentialsHelp": "Registriere deine eigene WHOOP-Entwickler-App und füge hier Client ID und Client Secret ein.",
|
||||
"whoopClientId": "Client ID",
|
||||
"whoopClientSecret": "Client Secret",
|
||||
"whoopCredentialsSaved": "Zugangsdaten gespeichert",
|
||||
@@ -4399,12 +4416,9 @@
|
||||
},
|
||||
"fitbit": "Google Health",
|
||||
"fitbitTag": "Fitbit & Pixel",
|
||||
"fitbitDescription": "Verbinde Google Health, um deine Fitbit- und Pixel-Watch-Werte zu synchronisieren — Gewicht, Körperfett, Ruhepuls, Sauerstoffsättigung, Herzfrequenzvariabilität und mehr.",
|
||||
"fitbitOverlapNote": "Wenn Google Health und eine andere Quelle denselben Vitalwert liefern — Ruhepuls, Sauerstoffsättigung, Körpertemperatur oder Atemfrequenz —, siehst du möglicherweise beide Werte, bis sich ein künftiges Update auf eine bevorzugte Quelle festlegt.",
|
||||
"fitbitDescription": "Verbinde Google Health, um deine Fitbit- und Pixel-Watch-Werte zu synchronisieren — Gewicht, Herzfrequenz und mehr.",
|
||||
"fitbitExperimentalBadge": "Experimentell",
|
||||
"fitbitExperimentalNote": "Diese Integration befindet sich in der Beta — die Datenabdeckung und die Feldzuordnung werden noch gegen echte Google-Health-Konten geprüft, daher können einzelne Werte fehlen oder sich in einem künftigen Update ändern.",
|
||||
"fitbitCredentials": "API-Zugangsdaten",
|
||||
"fitbitCredentialsHelp": "Registriere deinen eigenen Google-Cloud-OAuth-Client für die Google-Health-API und füge hier dessen Client-ID und Client-Secret ein.",
|
||||
"fitbitClientId": "Client-ID",
|
||||
"fitbitClientSecret": "Client-Secret",
|
||||
"fitbitCredentialsSaved": "Zugangsdaten gespeichert",
|
||||
@@ -4662,7 +4676,16 @@
|
||||
"statusActive": "Aktiv",
|
||||
"statusRevoked": "Widerrufen",
|
||||
"statusExpired": "Abgelaufen",
|
||||
"inactiveTitle": "Widerrufen und abgelaufen ({count})"
|
||||
"inactiveTitle": "Widerrufen und abgelaufen ({count})",
|
||||
"shownOnce": "Nur einmal sichtbar. Erfassen Sie QR-Code und Passphrase jetzt — beide lassen sich später nicht wiederherstellen.",
|
||||
"qrLabel": "QR-Code",
|
||||
"qrAlt": "QR-Code mit Freigabelink und Passphrase",
|
||||
"linkLabel": "Link",
|
||||
"passphraseLabel": "Passphrase",
|
||||
"passphraseHint": "Die Empfängerin oder der Empfänger benötigt Link und Passphrase. Der QR-Code enthält beides.",
|
||||
"copyLink": "Link kopieren",
|
||||
"copyPassphrase": "Passphrase kopieren",
|
||||
"protected": "Passphrase"
|
||||
},
|
||||
"genderOther": "Divers",
|
||||
"cycleTracking": {
|
||||
@@ -4674,9 +4697,8 @@
|
||||
},
|
||||
"nightscout": "Nightscout",
|
||||
"nightscoutTag": "CGM",
|
||||
"nightscoutDescription": "Verbinde deine eigene Nightscout-Seite, um kontinuierliche Glukosewerte für das Glukose-Panel zu synchronisieren.",
|
||||
"nightscoutDescription": "Verbinde deine eigene Nightscout-Seite, um kontinuierliche Glukosewerte ins Glukose-Panel zu synchronisieren.",
|
||||
"nightscoutViewData": "Glukose ansehen",
|
||||
"nightscoutHelp": "Richte HealthLog auf deine Nightscout-Instanz aus. Werte werden stündlich im Hintergrund abgerufen.",
|
||||
"nightscoutUrl": "Nightscout-URL",
|
||||
"nightscoutUrlSavedPlaceholder": "Gespeichert — neue URL eingeben zum Ersetzen",
|
||||
"nightscoutToken": "API-Token",
|
||||
@@ -4693,11 +4715,10 @@
|
||||
"nightscoutDisconnectTitle": "Nightscout trennen?",
|
||||
"nightscoutDisconnectDescription": "Künftige Synchronisierungen stoppen. Bereits importierte Glukosewerte bleiben in deinem Verlauf.",
|
||||
"polar": "Polar",
|
||||
"polarDescription": "Verbinde dein Polar-Konto, um Nightly-Recharge-Erholung, Schlafphasen und Tagesaktivität zu synchronisieren.",
|
||||
"polarDescription": "Verbinde dein Polar-Konto, um Nightly-Recharge-Erholung, Schlafphasen und tägliche Aktivität zu synchronisieren.",
|
||||
"polarViewData": "Schlaf & Erholung ansehen",
|
||||
"polarHelp": "Melde dich bei Polar an, um den Zugriff zu erlauben. Daten werden stündlich im Hintergrund abgerufen.",
|
||||
"polarCredentials": "API-Zugangsdaten",
|
||||
"polarCredentialsHelp": "Optional: Registriere deine eigene Polar-AccessLink-App und füge hier Client ID und Client Secret ein. Leer lassen, um die gemeinsame Polar-App des Servers zu nutzen.",
|
||||
"polarClientId": "Client ID",
|
||||
"polarClientSecret": "Client Secret",
|
||||
"polarCredentialsSaved": "Zugangsdaten gespeichert",
|
||||
@@ -4721,11 +4742,10 @@
|
||||
"generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut."
|
||||
},
|
||||
"oura": "Oura",
|
||||
"ouraDescription": "Verbinde dein Oura-Konto, um Readiness, Schlafphasen, HRV und Tagesaktivität zu synchronisieren.",
|
||||
"ouraDescription": "Verbinde dein Oura-Konto, um Readiness, Schlafphasen, Herzfrequenzvariabilität und tägliche Aktivität zu synchronisieren.",
|
||||
"ouraViewData": "Schlaf & Erholung ansehen",
|
||||
"ouraHelp": "Melde dich bei Oura an, um den Zugriff zu erlauben. Daten werden stündlich im Hintergrund abgerufen.",
|
||||
"ouraCredentials": "API-Zugangsdaten",
|
||||
"ouraCredentialsHelp": "Optional: Registriere deine eigene Oura-App und füge hier Client ID und Client Secret ein. Leer lassen, um die gemeinsame Oura-App des Servers zu nutzen.",
|
||||
"ouraClientId": "Client ID",
|
||||
"ouraClientSecret": "Client Secret",
|
||||
"ouraCredentialsSaved": "Zugangsdaten gespeichert",
|
||||
@@ -4758,7 +4778,14 @@
|
||||
"savedOff": "Coach ist ausgeblendet.",
|
||||
"saveError": "Speichern fehlgeschlagen. Bitte erneut versuchen."
|
||||
}
|
||||
}
|
||||
},
|
||||
"withingsDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der",
|
||||
"whoopDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der",
|
||||
"fitbitDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der",
|
||||
"polarDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der",
|
||||
"ouraDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der",
|
||||
"nightscoutDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der",
|
||||
"integrationCredentialsHint": "Wo du diese Zugangsdaten findest und wie du deine eigene App registrierst, steht in der Einrichtungsanleitung oben."
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
@@ -5945,7 +5972,14 @@
|
||||
"strain": "Belastungswert",
|
||||
"score": "Wellness-Wert"
|
||||
},
|
||||
"footer": "Geteilt aus HealthLog — einem selbst gehosteten Gesundheits-Tracker."
|
||||
"footer": "Geteilt aus HealthLog — einem selbst gehosteten Gesundheits-Tracker.",
|
||||
"unlock": {
|
||||
"title": "Passphrase eingeben",
|
||||
"description": "Diese geteilte Gesundheitsakte ist geschützt. Geben Sie die erhaltene Passphrase ein, um sie anzuzeigen.",
|
||||
"label": "Passphrase",
|
||||
"error": "Diese Passphrase ist nicht korrekt.",
|
||||
"submit": "Entsperren"
|
||||
}
|
||||
},
|
||||
"cycle": {
|
||||
"symptomCategory": {
|
||||
@@ -6404,11 +6438,6 @@
|
||||
"onTrack": "Im Plan",
|
||||
"due": "Jetzt fällig"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Vorsorge anpassen",
|
||||
"description": "Ansicht, Reihenfolge und einzelne Erinnerungen verwalten.",
|
||||
"back": "Zurück zur Vorsorge"
|
||||
},
|
||||
"capture": {
|
||||
"title": "Messwert erfassen",
|
||||
"description": "Wert eintragen; die Erinnerung wird als erledigt markiert."
|
||||
@@ -6421,6 +6450,14 @@
|
||||
"dashboard": {
|
||||
"empty": "Keine anstehenden Vorsorge-Erinnerungen.",
|
||||
"openLink": "Vorsorge öffnen"
|
||||
},
|
||||
"showMeasurements": "Messergebnisse anzeigen",
|
||||
"trendStrip": {
|
||||
"label": "Verlauf der letzten 7 Tage",
|
||||
"summary": "Letzter Wert {value}, {direction} über 7 Tage.",
|
||||
"rising": "steigend",
|
||||
"falling": "fallend",
|
||||
"steady": "stabil"
|
||||
}
|
||||
},
|
||||
"moduleList": {
|
||||
@@ -6450,11 +6487,6 @@
|
||||
"deleteError": "Wert konnte nicht gelöscht werden",
|
||||
"loadError": "Laborwerte konnten nicht geladen werden",
|
||||
"customize": "Laborwerte anpassen",
|
||||
"settings": {
|
||||
"title": "Laborwerte anpassen",
|
||||
"description": "Ansicht, Sortierung und Biomarker verwalten.",
|
||||
"back": "Zurück zu den Laborwerten"
|
||||
},
|
||||
"sort": {
|
||||
"heading": "Sortierung",
|
||||
"label": "Reihenfolge der Werte",
|
||||
@@ -6653,11 +6685,6 @@
|
||||
"title": "Krankheitstagebuch",
|
||||
"subtitle": "Ein rückblickendes Protokoll deiner Krankheits- und Beschwerde-Episoden.",
|
||||
"customize": "Krankheitstagebuch anpassen",
|
||||
"settings": {
|
||||
"title": "Krankheitstagebuch anpassen",
|
||||
"description": "Ansicht und Reihenfolge der Episoden verwalten.",
|
||||
"back": "Zurück zum Krankheitstagebuch"
|
||||
},
|
||||
"newEpisode": "Neue Episode",
|
||||
"logDay": "Tag erfassen",
|
||||
"markRecovered": "Als genesen markieren",
|
||||
|
||||
+61
-34
@@ -2003,6 +2003,7 @@
|
||||
"dailyBriefing": {
|
||||
"title": "Daily Briefing",
|
||||
"keyFindingsTitle": "Key findings",
|
||||
"signalsTitle": "Signals of the day",
|
||||
"emptyTitle": "No briefing yet",
|
||||
"emptyDescription": "Generate today's briefing to get a narrative read of your trends.",
|
||||
"emptyAction": "Generate briefing",
|
||||
@@ -2263,7 +2264,11 @@
|
||||
"settingsClustersLabel": "Data the Coach sees",
|
||||
"settingsClustersHint": "Pick which clusters of your data the Coach can read. More data means richer answers and a larger prompt.",
|
||||
"settingsClusterNoData": "No data yet",
|
||||
"sourcesMemberCount": "{count} metrics"
|
||||
"sourcesMemberCount": "{count} metrics",
|
||||
"dictate": "Dictate",
|
||||
"dictateStop": "Stop dictation",
|
||||
"showConversations": "Show conversations",
|
||||
"hideConversations": "Hide conversations"
|
||||
},
|
||||
"relativeJustNow": "just now",
|
||||
"relativeMinutesAgoOne": "{count} minute ago",
|
||||
@@ -2568,7 +2573,6 @@
|
||||
"chronotype": {
|
||||
"title": "Chronotype",
|
||||
"learning": "Still learning your rhythm — {counted} of {total} free nights. A chronotype needs a few alarm-free mornings before it means anything.",
|
||||
"midSleepCaption": "Your natural sleep midpoint on free days lands around {clock}.",
|
||||
"advancedToggle": "Advanced",
|
||||
"socialJetlagValue": "{hours}h {minutes}m social jetlag",
|
||||
"socialJetlagCaption": "The gap between your work-day and free-day sleep timing — like a small weekly time-zone shift.",
|
||||
@@ -2582,7 +2586,7 @@
|
||||
"late": "Evening type",
|
||||
"extreme_late": "Strong evening type"
|
||||
},
|
||||
"cornerLabel": "Chronotype"
|
||||
"midpointHeadline": "Your natural sleep midpoint is around {clock}."
|
||||
},
|
||||
"rhythm": {
|
||||
"loadError": "Your sleep rhythm couldn't load just now. It'll reappear once the connection settles."
|
||||
@@ -4021,6 +4025,21 @@
|
||||
"description": "Groups, tags, and archived tags for the mood picker.",
|
||||
"subtitle": "Organize your mood tags, groups, and picker order."
|
||||
},
|
||||
"labs": {
|
||||
"title": "Labs",
|
||||
"description": "View, sort order, and biomarkers for your labs.",
|
||||
"subtitle": "Manage the view, sort order, and biomarkers."
|
||||
},
|
||||
"illness": {
|
||||
"title": "Illness journal",
|
||||
"description": "View and order of your illness episodes.",
|
||||
"subtitle": "Manage the view and order of episodes."
|
||||
},
|
||||
"vorsorge": {
|
||||
"title": "Preventive care",
|
||||
"description": "View, order, and individual preventive-care reminders.",
|
||||
"subtitle": "Manage the view, order, and individual reminders."
|
||||
},
|
||||
"modules": {
|
||||
"title": "Modules",
|
||||
"saved": "Saved.",
|
||||
@@ -4324,7 +4343,7 @@
|
||||
}
|
||||
},
|
||||
"withings": "Withings",
|
||||
"withingsDescription": "Connect your Withings scale and blood pressure monitors.",
|
||||
"withingsDescription": "Connect your Withings scale and blood-pressure monitor to sync weight, body composition, and blood pressure automatically.",
|
||||
"withingsCredentials": "API Credentials",
|
||||
"withingsClientId": "Client ID",
|
||||
"withingsClientSecret": "Client Secret",
|
||||
@@ -4362,10 +4381,8 @@
|
||||
"withingsConnect": "Connect with Withings",
|
||||
"withingsNoCredentials": "Please enter your API credentials above to connect Withings.",
|
||||
"whoop": "WHOOP",
|
||||
"whoopDescription": "Connect your WHOOP strap to sync recovery, sleep, strain and workouts.",
|
||||
"whoopOverlapNote": "If WHOOP and another source both supply a vital — resting heart rate, blood oxygen, body temperature, respiratory rate or sleep stages — you may see both values until a future update settles on a single preferred source.",
|
||||
"whoopDescription": "Connect your WHOOP strap to sync recovery, sleep, strain, and workouts straight into HealthLog.",
|
||||
"whoopCredentials": "API Credentials",
|
||||
"whoopCredentialsHelp": "Register your own WHOOP developer app and paste its Client ID and Client Secret here.",
|
||||
"whoopClientId": "Client ID",
|
||||
"whoopClientSecret": "Client Secret",
|
||||
"whoopCredentialsSaved": "Credentials saved",
|
||||
@@ -4399,12 +4416,9 @@
|
||||
},
|
||||
"fitbit": "Google Health",
|
||||
"fitbitTag": "Fitbit & Pixel",
|
||||
"fitbitDescription": "Connect Google Health to sync your Fitbit and Pixel Watch metrics — weight, body fat, resting heart rate, blood oxygen, heart-rate variability and more.",
|
||||
"fitbitOverlapNote": "If Google Health and another source both supply a vital — resting heart rate, blood oxygen, body temperature or respiratory rate — you may see both values until a future update settles on a single preferred source.",
|
||||
"fitbitDescription": "Connect Google Health to sync your Fitbit and Pixel Watch vitals — weight, heart rate, and more.",
|
||||
"fitbitExperimentalBadge": "Experimental",
|
||||
"fitbitExperimentalNote": "This integration is in beta — data coverage and field mapping are still being verified against live Google Health accounts, so some values may be missing or change in a future update.",
|
||||
"fitbitCredentials": "API Credentials",
|
||||
"fitbitCredentialsHelp": "Register your own Google Cloud OAuth client for the Google Health API and paste its Client ID and Client Secret here.",
|
||||
"fitbitClientId": "Client ID",
|
||||
"fitbitClientSecret": "Client Secret",
|
||||
"fitbitCredentialsSaved": "Credentials saved",
|
||||
@@ -4662,7 +4676,16 @@
|
||||
"statusActive": "Active",
|
||||
"statusRevoked": "Revoked",
|
||||
"statusExpired": "Expired",
|
||||
"inactiveTitle": "Revoked and expired ({count})"
|
||||
"inactiveTitle": "Revoked and expired ({count})",
|
||||
"shownOnce": "Shown once only. Capture the QR code and passphrase now — neither can be recovered later.",
|
||||
"qrLabel": "QR code",
|
||||
"qrAlt": "QR code containing the share link and passphrase",
|
||||
"linkLabel": "Link",
|
||||
"passphraseLabel": "Passphrase",
|
||||
"passphraseHint": "The recipient needs both the link and this passphrase. The QR code carries both.",
|
||||
"copyLink": "Copy link",
|
||||
"copyPassphrase": "Copy passphrase",
|
||||
"protected": "Passphrase"
|
||||
},
|
||||
"genderOther": "Other",
|
||||
"cycleTracking": {
|
||||
@@ -4674,9 +4697,8 @@
|
||||
},
|
||||
"nightscout": "Nightscout",
|
||||
"nightscoutTag": "CGM",
|
||||
"nightscoutDescription": "Connect your own Nightscout site to sync continuous glucose readings for the glucose panel.",
|
||||
"nightscoutDescription": "Connect your own Nightscout site to sync continuous glucose readings into the glucose panel.",
|
||||
"nightscoutViewData": "View your glucose",
|
||||
"nightscoutHelp": "Point HealthLog at your Nightscout instance. Readings are pulled in the background once an hour.",
|
||||
"nightscoutUrl": "Nightscout URL",
|
||||
"nightscoutUrlSavedPlaceholder": "Saved — enter a new URL to replace it",
|
||||
"nightscoutToken": "API token",
|
||||
@@ -4697,7 +4719,6 @@
|
||||
"polarViewData": "View your sleep & recovery",
|
||||
"polarHelp": "Sign in with Polar to grant access. Data is pulled in the background once an hour.",
|
||||
"polarCredentials": "API Credentials",
|
||||
"polarCredentialsHelp": "Optional: register your own Polar AccessLink app and paste its Client ID and Client Secret here. Leave blank to use the server's shared Polar app.",
|
||||
"polarClientId": "Client ID",
|
||||
"polarClientSecret": "Client Secret",
|
||||
"polarCredentialsSaved": "Credentials saved",
|
||||
@@ -4721,11 +4742,10 @@
|
||||
"generic": "Unknown error while connecting. Please start the connection again."
|
||||
},
|
||||
"oura": "Oura",
|
||||
"ouraDescription": "Connect your Oura account to sync readiness, sleep stages, HRV, and daily activity.",
|
||||
"ouraDescription": "Connect your Oura account to sync readiness, sleep stages, heart-rate variability, and daily activity.",
|
||||
"ouraViewData": "View your sleep & recovery",
|
||||
"ouraHelp": "Sign in with Oura to grant access. Data is pulled in the background once an hour.",
|
||||
"ouraCredentials": "API Credentials",
|
||||
"ouraCredentialsHelp": "Optional: register your own Oura app and paste its Client ID and Client Secret here. Leave blank to use the server's shared Oura app.",
|
||||
"ouraClientId": "Client ID",
|
||||
"ouraClientSecret": "Client Secret",
|
||||
"ouraCredentialsSaved": "Credentials saved",
|
||||
@@ -4758,7 +4778,14 @@
|
||||
"savedOff": "Coach hidden.",
|
||||
"saveError": "Could not save the change. Please try again."
|
||||
}
|
||||
}
|
||||
},
|
||||
"withingsDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the",
|
||||
"whoopDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the",
|
||||
"fitbitDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the",
|
||||
"polarDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the",
|
||||
"ouraDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the",
|
||||
"nightscoutDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the",
|
||||
"integrationCredentialsHint": "Where to find these credentials and how to register your own app is covered in the setup guide above."
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
@@ -5945,7 +5972,14 @@
|
||||
"strain": "Strain score",
|
||||
"score": "Wellness score"
|
||||
},
|
||||
"footer": "Shared from HealthLog — a self-hosted personal health tracker."
|
||||
"footer": "Shared from HealthLog — a self-hosted personal health tracker.",
|
||||
"unlock": {
|
||||
"title": "Enter passphrase",
|
||||
"description": "This shared health record is protected. Enter the passphrase you were given to view it.",
|
||||
"label": "Passphrase",
|
||||
"error": "That passphrase is not correct.",
|
||||
"submit": "Unlock"
|
||||
}
|
||||
},
|
||||
"cycle": {
|
||||
"symptomCategory": {
|
||||
@@ -6404,11 +6438,6 @@
|
||||
"onTrack": "On track",
|
||||
"due": "Due now"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Customize preventive care",
|
||||
"description": "Manage the view, order, and individual reminders.",
|
||||
"back": "Back to preventive care"
|
||||
},
|
||||
"capture": {
|
||||
"title": "Log measurement",
|
||||
"description": "Enter the reading; the reminder is marked done."
|
||||
@@ -6421,6 +6450,14 @@
|
||||
"dashboard": {
|
||||
"empty": "No upcoming preventive-care reminders.",
|
||||
"openLink": "Open preventive care"
|
||||
},
|
||||
"showMeasurements": "Show measurements",
|
||||
"trendStrip": {
|
||||
"label": "Last 7 days",
|
||||
"summary": "Latest value {value}, {direction} over 7 days.",
|
||||
"rising": "rising",
|
||||
"falling": "falling",
|
||||
"steady": "steady"
|
||||
}
|
||||
},
|
||||
"moduleList": {
|
||||
@@ -6450,11 +6487,6 @@
|
||||
"deleteError": "Could not delete the result",
|
||||
"loadError": "Could not load lab results",
|
||||
"customize": "Customize labs",
|
||||
"settings": {
|
||||
"title": "Customize labs",
|
||||
"description": "Manage the view, sort order, and biomarkers.",
|
||||
"back": "Back to labs"
|
||||
},
|
||||
"sort": {
|
||||
"heading": "Sort order",
|
||||
"label": "Order of values",
|
||||
@@ -6653,11 +6685,6 @@
|
||||
"title": "Illness journal",
|
||||
"subtitle": "A retrospective log of your illness and condition episodes.",
|
||||
"customize": "Customize illness journal",
|
||||
"settings": {
|
||||
"title": "Customize illness journal",
|
||||
"description": "Manage the view and order of episodes.",
|
||||
"back": "Back to the illness journal"
|
||||
},
|
||||
"newEpisode": "New episode",
|
||||
"logDay": "Log a day",
|
||||
"markRecovered": "Mark recovered",
|
||||
|
||||
+61
-34
@@ -2003,6 +2003,7 @@
|
||||
"dailyBriefing": {
|
||||
"title": "Resumen del día",
|
||||
"keyFindingsTitle": "Hallazgos clave",
|
||||
"signalsTitle": "Señales del día",
|
||||
"emptyTitle": "Aún no hay resumen",
|
||||
"emptyDescription": "Genera el resumen de hoy para tener una vista rápida de tus tendencias.",
|
||||
"emptyAction": "Generar resumen",
|
||||
@@ -2263,7 +2264,11 @@
|
||||
"settingsClustersLabel": "Datos que ve el Coach",
|
||||
"settingsClustersHint": "Elige qué grupos de datos puede leer el Coach. Más datos significan respuestas más ricas y un prompt mayor.",
|
||||
"settingsClusterNoData": "Aún no hay datos",
|
||||
"sourcesMemberCount": "{count} métricas"
|
||||
"sourcesMemberCount": "{count} métricas",
|
||||
"dictate": "Dictar",
|
||||
"dictateStop": "Detener el dictado",
|
||||
"showConversations": "Mostrar conversaciones",
|
||||
"hideConversations": "Ocultar conversaciones"
|
||||
},
|
||||
"relativeJustNow": "ahora mismo",
|
||||
"relativeMinutesAgoOne": "hace {count} minuto",
|
||||
@@ -2568,7 +2573,6 @@
|
||||
"chronotype": {
|
||||
"title": "Cronotipo",
|
||||
"learning": "Aún estamos aprendiendo tu ritmo — {counted} de {total} noches libres. Un cronotipo necesita varias mañanas sin despertador para tener sentido.",
|
||||
"midSleepCaption": "El punto medio natural de tu sueño en días libres ronda las {clock}.",
|
||||
"advancedToggle": "Avanzado",
|
||||
"socialJetlagValue": "{hours} h {minutes} min de jet lag social",
|
||||
"socialJetlagCaption": "La diferencia entre el horario de sueño laboral y el de los días libres — como un pequeño cambio de huso horario semanal.",
|
||||
@@ -2582,7 +2586,7 @@
|
||||
"late": "Vespertino",
|
||||
"extreme_late": "Vespertino marcado"
|
||||
},
|
||||
"cornerLabel": "Cronotipo"
|
||||
"midpointHeadline": "El punto medio natural de tu sueño ronda las {clock}."
|
||||
},
|
||||
"rhythm": {
|
||||
"loadError": "Tu ritmo de sueño no pudo cargarse ahora mismo. Reaparecerá en cuanto se estabilice la conexión."
|
||||
@@ -4021,6 +4025,21 @@
|
||||
"description": "Grupos, etiquetas y etiquetas archivadas del selector de ánimo.",
|
||||
"subtitle": "Organiza tus etiquetas de ánimo, grupos y orden del selector."
|
||||
},
|
||||
"labs": {
|
||||
"title": "Laboratorio",
|
||||
"description": "Vista, orden y biomarcadores de tu laboratorio.",
|
||||
"subtitle": "Gestiona la vista, el orden y los biomarcadores."
|
||||
},
|
||||
"illness": {
|
||||
"title": "Diario de enfermedad",
|
||||
"description": "Vista y orden de tus episodios de enfermedad.",
|
||||
"subtitle": "Gestiona la vista y el orden de los episodios."
|
||||
},
|
||||
"vorsorge": {
|
||||
"title": "Prevención",
|
||||
"description": "Vista, orden y recordatorios de prevención individuales.",
|
||||
"subtitle": "Gestiona la vista, el orden y los recordatorios individuales."
|
||||
},
|
||||
"modules": {
|
||||
"title": "Módulos",
|
||||
"saved": "Guardado.",
|
||||
@@ -4324,7 +4343,7 @@
|
||||
}
|
||||
},
|
||||
"withings": "Withings",
|
||||
"withingsDescription": "Conecta tu báscula y tensiómetros Withings.",
|
||||
"withingsDescription": "Conecta tu báscula y tensiómetro Withings para sincronizar peso, composición corporal y tensión arterial automáticamente.",
|
||||
"withingsCredentials": "Credenciales de API",
|
||||
"withingsClientId": "ID de cliente",
|
||||
"withingsClientSecret": "Secreto de cliente",
|
||||
@@ -4362,10 +4381,8 @@
|
||||
"withingsConnect": "Conectar con Withings",
|
||||
"withingsNoCredentials": "Introduce primero tus credenciales de API arriba para conectar Withings.",
|
||||
"whoop": "WHOOP",
|
||||
"whoopDescription": "Conecta tu banda WHOOP para sincronizar recuperación, sueño, esfuerzo y entrenamientos.",
|
||||
"whoopOverlapNote": "Si WHOOP y otra fuente proporcionan el mismo valor vital —frecuencia cardíaca en reposo, oxígeno en sangre, temperatura corporal, frecuencia respiratoria o fases del sueño—, es posible que veas ambos valores hasta que una futura actualización elija una única fuente preferida.",
|
||||
"whoopDescription": "Conecta tu banda WHOOP para sincronizar recuperación, sueño, esfuerzo y entrenamientos directamente en HealthLog.",
|
||||
"whoopCredentials": "Credenciales de API",
|
||||
"whoopCredentialsHelp": "Registra tu propia app de desarrollador de WHOOP y pega aquí su Client ID y Client Secret.",
|
||||
"whoopClientId": "Client ID",
|
||||
"whoopClientSecret": "Client Secret",
|
||||
"whoopCredentialsSaved": "Credenciales guardadas",
|
||||
@@ -4399,12 +4416,9 @@
|
||||
},
|
||||
"fitbit": "Google Health",
|
||||
"fitbitTag": "Fitbit y Pixel",
|
||||
"fitbitDescription": "Conecta Google Health para sincronizar tus métricas de Fitbit y Pixel Watch: peso, grasa corporal, frecuencia cardíaca en reposo, saturación de oxígeno, variabilidad de la frecuencia cardíaca y más.",
|
||||
"fitbitOverlapNote": "Si Google Health y otra fuente proporcionan la misma constante vital —frecuencia cardíaca en reposo, saturación de oxígeno, temperatura corporal o frecuencia respiratoria—, es posible que veas ambos valores hasta que una actualización futura defina una única fuente preferida.",
|
||||
"fitbitDescription": "Conecta Google Health para sincronizar tus datos de Fitbit y Pixel Watch — peso, frecuencia cardíaca y más.",
|
||||
"fitbitExperimentalBadge": "Experimental",
|
||||
"fitbitExperimentalNote": "Esta integración está en fase beta: la cobertura de datos y la asignación de campos aún se están verificando con cuentas reales de Google Health, por lo que algunos valores pueden faltar o cambiar en una actualización futura.",
|
||||
"fitbitCredentials": "Credenciales de API",
|
||||
"fitbitCredentialsHelp": "Registra tu propio cliente OAuth de Google Cloud para la API de Google Health y pega aquí su Client ID y Client Secret.",
|
||||
"fitbitClientId": "Client ID",
|
||||
"fitbitClientSecret": "Client Secret",
|
||||
"fitbitCredentialsSaved": "Credenciales guardadas",
|
||||
@@ -4662,7 +4676,16 @@
|
||||
"statusActive": "Activo",
|
||||
"statusRevoked": "Revocado",
|
||||
"statusExpired": "Caducado",
|
||||
"inactiveTitle": "Revocados y caducados ({count})"
|
||||
"inactiveTitle": "Revocados y caducados ({count})",
|
||||
"shownOnce": "Se muestra una sola vez. Guarde ahora el código QR y la frase de acceso — no se pueden recuperar después.",
|
||||
"qrLabel": "Código QR",
|
||||
"qrAlt": "Código QR con el enlace para compartir y la frase de acceso",
|
||||
"linkLabel": "Enlace",
|
||||
"passphraseLabel": "Frase de acceso",
|
||||
"passphraseHint": "El destinatario necesita el enlace y esta frase de acceso. El código QR contiene ambos.",
|
||||
"copyLink": "Copiar enlace",
|
||||
"copyPassphrase": "Copiar frase de acceso",
|
||||
"protected": "Frase de acceso"
|
||||
},
|
||||
"genderOther": "Otro",
|
||||
"cycleTracking": {
|
||||
@@ -4676,7 +4699,6 @@
|
||||
"nightscoutTag": "CGM",
|
||||
"nightscoutDescription": "Conecta tu propio sitio Nightscout para sincronizar lecturas continuas de glucosa en el panel de glucosa.",
|
||||
"nightscoutViewData": "Ver tu glucosa",
|
||||
"nightscoutHelp": "Apunta HealthLog a tu instancia de Nightscout. Las lecturas se obtienen en segundo plano una vez por hora.",
|
||||
"nightscoutUrl": "URL de Nightscout",
|
||||
"nightscoutUrlSavedPlaceholder": "Guardado: introduce una URL nueva para reemplazarla",
|
||||
"nightscoutToken": "Token de API",
|
||||
@@ -4693,11 +4715,10 @@
|
||||
"nightscoutDisconnectTitle": "¿Desconectar Nightscout?",
|
||||
"nightscoutDisconnectDescription": "Las sincronizaciones futuras se detienen. Las lecturas de glucosa ya importadas permanecen en tu historial.",
|
||||
"polar": "Polar",
|
||||
"polarDescription": "Conecta tu cuenta de Polar para sincronizar la recuperación Nightly Recharge, las fases de sueño y la actividad diaria.",
|
||||
"polarDescription": "Conecta tu cuenta de Polar para sincronizar la recuperación Nightly Recharge, fases de sueño y actividad diaria.",
|
||||
"polarViewData": "Ver tu sueño y recuperación",
|
||||
"polarHelp": "Inicia sesión en Polar para conceder acceso. Los datos se obtienen en segundo plano una vez por hora.",
|
||||
"polarCredentials": "Credenciales de API",
|
||||
"polarCredentialsHelp": "Opcional: registra tu propia app de Polar AccessLink y pega aquí su Client ID y Client Secret. Déjalo en blanco para usar la app compartida del servidor.",
|
||||
"polarClientId": "Client ID",
|
||||
"polarClientSecret": "Client Secret",
|
||||
"polarCredentialsSaved": "Credenciales guardadas",
|
||||
@@ -4721,11 +4742,10 @@
|
||||
"generic": "Error desconocido al conectar. Inicia la conexión de nuevo."
|
||||
},
|
||||
"oura": "Oura",
|
||||
"ouraDescription": "Conecta tu cuenta de Oura para sincronizar readiness, fases de sueño, VFC y actividad diaria.",
|
||||
"ouraDescription": "Conecta tu cuenta de Oura para sincronizar disposición, fases de sueño, variabilidad cardíaca y actividad diaria.",
|
||||
"ouraViewData": "Ver tu sueño y recuperación",
|
||||
"ouraHelp": "Inicia sesión en Oura para conceder acceso. Los datos se obtienen en segundo plano una vez por hora.",
|
||||
"ouraCredentials": "Credenciales de API",
|
||||
"ouraCredentialsHelp": "Opcional: registra tu propia app de Oura y pega aquí su Client ID y Client Secret. Déjalo en blanco para usar la app compartida del servidor.",
|
||||
"ouraClientId": "Client ID",
|
||||
"ouraClientSecret": "Client Secret",
|
||||
"ouraCredentialsSaved": "Credenciales guardadas",
|
||||
@@ -4758,7 +4778,14 @@
|
||||
"savedOff": "Coach oculto.",
|
||||
"saveError": "No se pudo guardar el cambio. Inténtalo de nuevo."
|
||||
}
|
||||
}
|
||||
},
|
||||
"withingsDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la",
|
||||
"whoopDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la",
|
||||
"fitbitDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la",
|
||||
"polarDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la",
|
||||
"ouraDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la",
|
||||
"nightscoutDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la",
|
||||
"integrationCredentialsHint": "Dónde encontrar estas credenciales y cómo registrar tu propia aplicación se explica en la guía de configuración de arriba."
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
@@ -5945,7 +5972,14 @@
|
||||
"strain": "Puntuación de esfuerzo",
|
||||
"score": "Puntuación de bienestar"
|
||||
},
|
||||
"footer": "Compartido desde HealthLog — un registro de salud autoalojado."
|
||||
"footer": "Compartido desde HealthLog — un registro de salud autoalojado.",
|
||||
"unlock": {
|
||||
"title": "Introduzca la frase de acceso",
|
||||
"description": "Este registro de salud compartido está protegido. Introduzca la frase de acceso que recibió para verlo.",
|
||||
"label": "Frase de acceso",
|
||||
"error": "Esa frase de acceso no es correcta.",
|
||||
"submit": "Desbloquear"
|
||||
}
|
||||
},
|
||||
"cycle": {
|
||||
"symptomCategory": {
|
||||
@@ -6404,11 +6438,6 @@
|
||||
"onTrack": "Al día",
|
||||
"due": "Toca ahora"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Personalizar prevención",
|
||||
"description": "Gestiona la vista, el orden y los recordatorios individuales.",
|
||||
"back": "Volver a prevención"
|
||||
},
|
||||
"capture": {
|
||||
"title": "Registrar medición",
|
||||
"description": "Introduce el valor; el recordatorio se marca como hecho."
|
||||
@@ -6421,6 +6450,14 @@
|
||||
"dashboard": {
|
||||
"empty": "No hay recordatorios de prevención próximos.",
|
||||
"openLink": "Abrir prevención"
|
||||
},
|
||||
"showMeasurements": "Ver mediciones",
|
||||
"trendStrip": {
|
||||
"label": "Últimos 7 días",
|
||||
"summary": "Último valor {value}, {direction} en 7 días.",
|
||||
"rising": "en aumento",
|
||||
"falling": "en descenso",
|
||||
"steady": "estable"
|
||||
}
|
||||
},
|
||||
"moduleList": {
|
||||
@@ -6450,11 +6487,6 @@
|
||||
"deleteError": "No se pudo eliminar el resultado",
|
||||
"loadError": "No se pudieron cargar los resultados",
|
||||
"customize": "Personalizar laboratorio",
|
||||
"settings": {
|
||||
"title": "Personalizar laboratorio",
|
||||
"description": "Gestiona la vista, el orden y los biomarcadores.",
|
||||
"back": "Volver al laboratorio"
|
||||
},
|
||||
"sort": {
|
||||
"heading": "Orden",
|
||||
"label": "Orden de los valores",
|
||||
@@ -6653,11 +6685,6 @@
|
||||
"title": "Diario de enfermedad",
|
||||
"subtitle": "Un registro retrospectivo de tus episodios de enfermedad y dolencias.",
|
||||
"customize": "Personalizar diario de enfermedad",
|
||||
"settings": {
|
||||
"title": "Personalizar diario de enfermedad",
|
||||
"description": "Gestiona la vista y el orden de los episodios.",
|
||||
"back": "Volver al diario de enfermedad"
|
||||
},
|
||||
"newEpisode": "Nuevo episodio",
|
||||
"logDay": "Registrar un día",
|
||||
"markRecovered": "Marcar como recuperado",
|
||||
|
||||
+62
-35
@@ -2003,6 +2003,7 @@
|
||||
"dailyBriefing": {
|
||||
"title": "Briefing du jour",
|
||||
"keyFindingsTitle": "Constats clés",
|
||||
"signalsTitle": "Signaux du jour",
|
||||
"emptyTitle": "Pas encore de briefing",
|
||||
"emptyDescription": "Génère le briefing du jour pour obtenir un récap de tes tendances.",
|
||||
"emptyAction": "Générer le briefing",
|
||||
@@ -2263,7 +2264,11 @@
|
||||
"settingsClustersLabel": "Données vues par le Coach",
|
||||
"settingsClustersHint": "Choisissez les groupes de données que le Coach peut lire. Plus de données, des réponses plus riches et un prompt plus grand.",
|
||||
"settingsClusterNoData": "Pas encore de données",
|
||||
"sourcesMemberCount": "{count} mesures"
|
||||
"sourcesMemberCount": "{count} mesures",
|
||||
"dictate": "Dicter",
|
||||
"dictateStop": "Arrêter la dictée",
|
||||
"showConversations": "Afficher les conversations",
|
||||
"hideConversations": "Masquer les conversations"
|
||||
},
|
||||
"relativeJustNow": "à l’instant",
|
||||
"relativeMinutesAgoOne": "il y a {count} minute",
|
||||
@@ -2568,7 +2573,6 @@
|
||||
"chronotype": {
|
||||
"title": "Chronotype",
|
||||
"learning": "Votre rythme est encore en cours d'analyse — {counted} sur {total} nuits libres. Un chronotype a besoin de quelques matins sans réveil pour avoir du sens.",
|
||||
"midSleepCaption": "Le milieu naturel de votre sommeil les jours de repos se situe vers {clock}.",
|
||||
"advancedToggle": "Avancé",
|
||||
"socialJetlagValue": "{hours} h {minutes} min de jet lag social",
|
||||
"socialJetlagCaption": "L'écart entre vos horaires de sommeil les jours travaillés et les jours libres — comme un petit décalage horaire hebdomadaire.",
|
||||
@@ -2582,7 +2586,7 @@
|
||||
"late": "Type du soir",
|
||||
"extreme_late": "Type du soir marqué"
|
||||
},
|
||||
"cornerLabel": "Chronotype"
|
||||
"midpointHeadline": "Le milieu naturel de votre sommeil se situe vers {clock}."
|
||||
},
|
||||
"rhythm": {
|
||||
"loadError": "Votre rythme de sommeil n'a pas pu se charger pour l'instant. Il réapparaîtra dès que la connexion sera rétablie."
|
||||
@@ -4021,6 +4025,21 @@
|
||||
"description": "Groupes, étiquettes et étiquettes archivées du sélecteur d’humeur.",
|
||||
"subtitle": "Organisez vos tags d'humeur, groupes et ordre du sélecteur."
|
||||
},
|
||||
"labs": {
|
||||
"title": "Analyses",
|
||||
"description": "Affichage, tri et biomarqueurs de vos analyses.",
|
||||
"subtitle": "Gérez l’affichage, le tri et les biomarqueurs."
|
||||
},
|
||||
"illness": {
|
||||
"title": "Journal de maladie",
|
||||
"description": "Affichage et ordre de vos épisodes de maladie.",
|
||||
"subtitle": "Gérez l’affichage et l’ordre des épisodes."
|
||||
},
|
||||
"vorsorge": {
|
||||
"title": "Prévention",
|
||||
"description": "Affichage, ordre et rappels de prévention individuels.",
|
||||
"subtitle": "Gérez l’affichage, l’ordre et les rappels individuels."
|
||||
},
|
||||
"modules": {
|
||||
"title": "Modules",
|
||||
"saved": "Enregistré.",
|
||||
@@ -4324,7 +4343,7 @@
|
||||
}
|
||||
},
|
||||
"withings": "Withings",
|
||||
"withingsDescription": "Connectez votre balance et vos tensiomètres Withings.",
|
||||
"withingsDescription": "Connecte ta balance et ton tensiomètre Withings pour synchroniser automatiquement poids, composition corporelle et tension.",
|
||||
"withingsCredentials": "Identifiants API",
|
||||
"withingsClientId": "ID client",
|
||||
"withingsClientSecret": "Secret client",
|
||||
@@ -4362,10 +4381,8 @@
|
||||
"withingsConnect": "Se connecter avec Withings",
|
||||
"withingsNoCredentials": "Saisissez d’abord vos identifiants API ci-dessus pour connecter Withings.",
|
||||
"whoop": "WHOOP",
|
||||
"whoopDescription": "Connectez votre bracelet WHOOP pour synchroniser récupération, sommeil, effort et entraînements.",
|
||||
"whoopOverlapNote": "Si WHOOP et une autre source fournissent la même donnée vitale — fréquence cardiaque au repos, oxygène sanguin, température corporelle, fréquence respiratoire ou phases de sommeil —, les deux valeurs peuvent apparaître jusqu'à ce qu'une future mise à jour retienne une seule source préférée.",
|
||||
"whoopDescription": "Connecte ton bracelet WHOOP pour synchroniser récupération, sommeil, charge et entraînements directement dans HealthLog.",
|
||||
"whoopCredentials": "Identifiants API",
|
||||
"whoopCredentialsHelp": "Enregistrez votre propre application développeur WHOOP et collez ici son Client ID et son Client Secret.",
|
||||
"whoopClientId": "Client ID",
|
||||
"whoopClientSecret": "Client Secret",
|
||||
"whoopCredentialsSaved": "Identifiants enregistrés",
|
||||
@@ -4399,12 +4416,9 @@
|
||||
},
|
||||
"fitbit": "Google Health",
|
||||
"fitbitTag": "Fitbit et Pixel",
|
||||
"fitbitDescription": "Connectez Google Health pour synchroniser vos données Fitbit et Pixel Watch : poids, masse grasse, fréquence cardiaque au repos, saturation en oxygène, variabilité de la fréquence cardiaque et plus encore.",
|
||||
"fitbitOverlapNote": "Si Google Health et une autre source fournissent la même constante vitale — fréquence cardiaque au repos, saturation en oxygène, température corporelle ou fréquence respiratoire —, vous pourriez voir les deux valeurs jusqu'à ce qu'une future mise à jour retienne une seule source préférée.",
|
||||
"fitbitDescription": "Connecte Google Health pour synchroniser tes données Fitbit et Pixel Watch — poids, fréquence cardiaque et plus.",
|
||||
"fitbitExperimentalBadge": "Expérimental",
|
||||
"fitbitExperimentalNote": "Cette intégration est en bêta : la couverture des données et la correspondance des champs sont encore en cours de vérification avec de vrais comptes Google Health, donc certaines valeurs peuvent manquer ou changer lors d'une future mise à jour.",
|
||||
"fitbitCredentials": "Identifiants d'API",
|
||||
"fitbitCredentialsHelp": "Enregistrez votre propre client OAuth Google Cloud pour l'API Google Health et collez ici son Client ID et son Client Secret.",
|
||||
"fitbitClientId": "Client ID",
|
||||
"fitbitClientSecret": "Client Secret",
|
||||
"fitbitCredentialsSaved": "Identifiants enregistrés",
|
||||
@@ -4662,7 +4676,16 @@
|
||||
"statusActive": "Actif",
|
||||
"statusRevoked": "Révoqué",
|
||||
"statusExpired": "Expiré",
|
||||
"inactiveTitle": "Révoqués et expirés ({count})"
|
||||
"inactiveTitle": "Révoqués et expirés ({count})",
|
||||
"shownOnce": "Affiché une seule fois. Enregistrez le code QR et la phrase secrète maintenant — ils ne pourront pas être récupérés ensuite.",
|
||||
"qrLabel": "Code QR",
|
||||
"qrAlt": "Code QR contenant le lien de partage et la phrase secrète",
|
||||
"linkLabel": "Lien",
|
||||
"passphraseLabel": "Phrase secrète",
|
||||
"passphraseHint": "Le destinataire a besoin du lien et de cette phrase secrète. Le code QR contient les deux.",
|
||||
"copyLink": "Copier le lien",
|
||||
"copyPassphrase": "Copier la phrase secrète",
|
||||
"protected": "Phrase secrète"
|
||||
},
|
||||
"genderOther": "Autre",
|
||||
"cycleTracking": {
|
||||
@@ -4674,9 +4697,8 @@
|
||||
},
|
||||
"nightscout": "Nightscout",
|
||||
"nightscoutTag": "CGM",
|
||||
"nightscoutDescription": "Connectez votre propre site Nightscout pour synchroniser les mesures de glycémie en continu dans le panneau glucose.",
|
||||
"nightscoutDescription": "Connecte ton propre site Nightscout pour synchroniser les mesures de glucose en continu dans le panneau glucose.",
|
||||
"nightscoutViewData": "Voir votre glycémie",
|
||||
"nightscoutHelp": "Pointez HealthLog vers votre instance Nightscout. Les mesures sont récupérées en arrière-plan une fois par heure.",
|
||||
"nightscoutUrl": "URL Nightscout",
|
||||
"nightscoutUrlSavedPlaceholder": "Enregistré — saisissez une nouvelle URL pour la remplacer",
|
||||
"nightscoutToken": "Jeton d'API",
|
||||
@@ -4693,11 +4715,10 @@
|
||||
"nightscoutDisconnectTitle": "Déconnecter Nightscout ?",
|
||||
"nightscoutDisconnectDescription": "Les synchronisations futures s'arrêtent. Les mesures de glycémie déjà importées restent dans votre historique.",
|
||||
"polar": "Polar",
|
||||
"polarDescription": "Connectez votre compte Polar pour synchroniser la récupération Nightly Recharge, les phases de sommeil et l'activité quotidienne.",
|
||||
"polarDescription": "Connecte ton compte Polar pour synchroniser la récupération Nightly Recharge, les phases de sommeil et l'activité.",
|
||||
"polarViewData": "Voir votre sommeil et récupération",
|
||||
"polarHelp": "Connectez-vous à Polar pour accorder l'accès. Les données sont récupérées en arrière-plan une fois par heure.",
|
||||
"polarCredentials": "Identifiants API",
|
||||
"polarCredentialsHelp": "Facultatif : enregistrez votre propre application Polar AccessLink et collez ici son Client ID et son Client Secret. Laissez vide pour utiliser l'application Polar partagée du serveur.",
|
||||
"polarClientId": "Client ID",
|
||||
"polarClientSecret": "Client Secret",
|
||||
"polarCredentialsSaved": "Identifiants enregistrés",
|
||||
@@ -4721,11 +4742,10 @@
|
||||
"generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion."
|
||||
},
|
||||
"oura": "Oura",
|
||||
"ouraDescription": "Connectez votre compte Oura pour synchroniser le readiness, les phases de sommeil, la VFC et l'activité quotidienne.",
|
||||
"ouraDescription": "Connecte ton compte Oura pour synchroniser readiness, phases de sommeil, variabilité cardiaque et activité quotidienne.",
|
||||
"ouraViewData": "Voir votre sommeil et récupération",
|
||||
"ouraHelp": "Connectez-vous à Oura pour accorder l'accès. Les données sont récupérées en arrière-plan une fois par heure.",
|
||||
"ouraCredentials": "Identifiants API",
|
||||
"ouraCredentialsHelp": "Facultatif : enregistrez votre propre application Oura et collez ici son Client ID et son Client Secret. Laissez vide pour utiliser l'application Oura partagée du serveur.",
|
||||
"ouraClientId": "Client ID",
|
||||
"ouraClientSecret": "Client Secret",
|
||||
"ouraCredentialsSaved": "Identifiants enregistrés",
|
||||
@@ -4758,7 +4778,14 @@
|
||||
"savedOff": "Coach masqué.",
|
||||
"saveError": "Impossible d enregistrer la modification. Veuillez réessayer."
|
||||
}
|
||||
}
|
||||
},
|
||||
"withingsDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le",
|
||||
"whoopDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le",
|
||||
"fitbitDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le",
|
||||
"polarDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le",
|
||||
"ouraDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le",
|
||||
"nightscoutDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le",
|
||||
"integrationCredentialsHint": "Où trouver ces identifiants et comment enregistrer ta propre application est expliqué dans le guide de configuration ci-dessus."
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
@@ -5945,7 +5972,14 @@
|
||||
"strain": "Score de charge",
|
||||
"score": "Score de bien-être"
|
||||
},
|
||||
"footer": "Partagé depuis HealthLog — un suivi de santé auto-hébergé."
|
||||
"footer": "Partagé depuis HealthLog — un suivi de santé auto-hébergé.",
|
||||
"unlock": {
|
||||
"title": "Saisir la phrase secrète",
|
||||
"description": "Ce dossier de santé partagé est protégé. Saisissez la phrase secrète qui vous a été communiquée pour le consulter.",
|
||||
"label": "Phrase secrète",
|
||||
"error": "Cette phrase secrète est incorrecte.",
|
||||
"submit": "Déverrouiller"
|
||||
}
|
||||
},
|
||||
"cycle": {
|
||||
"symptomCategory": {
|
||||
@@ -6404,11 +6438,6 @@
|
||||
"onTrack": "Dans les temps",
|
||||
"due": "À faire maintenant"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Personnaliser la prévention",
|
||||
"description": "Gérer l'affichage, l'ordre et les rappels individuels.",
|
||||
"back": "Retour à la prévention"
|
||||
},
|
||||
"capture": {
|
||||
"title": "Enregistrer une mesure",
|
||||
"description": "Saisissez la valeur ; le rappel est marqué comme fait."
|
||||
@@ -6421,6 +6450,14 @@
|
||||
"dashboard": {
|
||||
"empty": "Aucun rappel de prévention à venir.",
|
||||
"openLink": "Ouvrir la prévention"
|
||||
},
|
||||
"showMeasurements": "Afficher les mesures",
|
||||
"trendStrip": {
|
||||
"label": "7 derniers jours",
|
||||
"summary": "Dernière valeur {value}, {direction} sur 7 jours.",
|
||||
"rising": "en hausse",
|
||||
"falling": "en baisse",
|
||||
"steady": "stable"
|
||||
}
|
||||
},
|
||||
"moduleList": {
|
||||
@@ -6450,11 +6487,6 @@
|
||||
"deleteError": "Impossible de supprimer le résultat",
|
||||
"loadError": "Impossible de charger les résultats",
|
||||
"customize": "Personnaliser les analyses",
|
||||
"settings": {
|
||||
"title": "Personnaliser les analyses",
|
||||
"description": "Gérer l'affichage, le tri et les biomarqueurs.",
|
||||
"back": "Retour aux analyses"
|
||||
},
|
||||
"sort": {
|
||||
"heading": "Tri",
|
||||
"label": "Ordre des valeurs",
|
||||
@@ -6653,11 +6685,6 @@
|
||||
"title": "Journal de maladie",
|
||||
"subtitle": "Un suivi rétrospectif de vos épisodes de maladie et d'affections.",
|
||||
"customize": "Personnaliser le journal de maladie",
|
||||
"settings": {
|
||||
"title": "Personnaliser le journal de maladie",
|
||||
"description": "Gérer l'affichage et l'ordre des épisodes.",
|
||||
"back": "Retour au journal de maladie"
|
||||
},
|
||||
"newEpisode": "Nouvel épisode",
|
||||
"logDay": "Enregistrer un jour",
|
||||
"markRecovered": "Marquer comme rétabli",
|
||||
|
||||
+61
-34
@@ -2003,6 +2003,7 @@
|
||||
"dailyBriefing": {
|
||||
"title": "Briefing del giorno",
|
||||
"keyFindingsTitle": "Riscontri principali",
|
||||
"signalsTitle": "Segnali del giorno",
|
||||
"emptyTitle": "Ancora nessun briefing",
|
||||
"emptyDescription": "Genera il briefing di oggi per avere un riepilogo dei tuoi trend.",
|
||||
"emptyAction": "Genera briefing",
|
||||
@@ -2263,7 +2264,11 @@
|
||||
"settingsClustersLabel": "Dati che il Coach vede",
|
||||
"settingsClustersHint": "Scegli quali gruppi di dati il Coach può leggere. Più dati significano risposte più ricche e un prompt più grande.",
|
||||
"settingsClusterNoData": "Ancora nessun dato",
|
||||
"sourcesMemberCount": "{count} metriche"
|
||||
"sourcesMemberCount": "{count} metriche",
|
||||
"dictate": "Detta",
|
||||
"dictateStop": "Interrompi dettatura",
|
||||
"showConversations": "Mostra conversazioni",
|
||||
"hideConversations": "Nascondi conversazioni"
|
||||
},
|
||||
"relativeJustNow": "proprio ora",
|
||||
"relativeMinutesAgoOne": "{count} minuto fa",
|
||||
@@ -2568,7 +2573,6 @@
|
||||
"chronotype": {
|
||||
"title": "Cronotipo",
|
||||
"learning": "Stiamo ancora imparando il tuo ritmo — {counted} di {total} notti libere. Un cronotipo richiede alcune mattine senza sveglia per avere significato.",
|
||||
"midSleepCaption": "Il punto medio naturale del tuo sonno nei giorni liberi è intorno alle {clock}.",
|
||||
"advancedToggle": "Avanzate",
|
||||
"socialJetlagValue": "{hours} h {minutes} min di jet lag sociale",
|
||||
"socialJetlagCaption": "La differenza tra gli orari del sonno nei giorni lavorativi e in quelli liberi — come un piccolo cambio di fuso orario settimanale.",
|
||||
@@ -2582,7 +2586,7 @@
|
||||
"late": "Tipo serale",
|
||||
"extreme_late": "Tipo serale marcato"
|
||||
},
|
||||
"cornerLabel": "Cronotipo"
|
||||
"midpointHeadline": "Il punto medio naturale del tuo sonno è intorno alle {clock}."
|
||||
},
|
||||
"rhythm": {
|
||||
"loadError": "Non è stato possibile caricare il tuo ritmo del sonno in questo momento. Riapparirà non appena la connessione sarà stabile."
|
||||
@@ -4021,6 +4025,21 @@
|
||||
"description": "Gruppi, tag e tag archiviati del selettore dell’umore.",
|
||||
"subtitle": "Organizza i tag dell'umore, i gruppi e l'ordine del selettore."
|
||||
},
|
||||
"labs": {
|
||||
"title": "Esami",
|
||||
"description": "Vista, ordinamento e biomarcatori dei tuoi esami.",
|
||||
"subtitle": "Gestisci la vista, l’ordinamento e i biomarcatori."
|
||||
},
|
||||
"illness": {
|
||||
"title": "Diario delle malattie",
|
||||
"description": "Vista e ordine dei tuoi episodi di malattia.",
|
||||
"subtitle": "Gestisci la vista e l’ordine degli episodi."
|
||||
},
|
||||
"vorsorge": {
|
||||
"title": "Prevenzione",
|
||||
"description": "Vista, ordine e singoli promemoria di prevenzione.",
|
||||
"subtitle": "Gestisci la vista, l’ordine e i singoli promemoria."
|
||||
},
|
||||
"modules": {
|
||||
"title": "Moduli",
|
||||
"saved": "Salvato.",
|
||||
@@ -4324,7 +4343,7 @@
|
||||
}
|
||||
},
|
||||
"withings": "Withings",
|
||||
"withingsDescription": "Collega la tua bilancia e i misuratori di pressione Withings.",
|
||||
"withingsDescription": "Collega la bilancia e il misuratore di pressione Withings per sincronizzare peso, composizione corporea e pressione automaticamente.",
|
||||
"withingsCredentials": "Credenziali API",
|
||||
"withingsClientId": "ID client",
|
||||
"withingsClientSecret": "Secret client",
|
||||
@@ -4362,10 +4381,8 @@
|
||||
"withingsConnect": "Connetti con Withings",
|
||||
"withingsNoCredentials": "Inserisci prima le tue credenziali API qui sopra per collegare Withings.",
|
||||
"whoop": "WHOOP",
|
||||
"whoopDescription": "Collega la tua fascia WHOOP per sincronizzare recupero, sonno, sforzo e allenamenti.",
|
||||
"whoopOverlapNote": "Se WHOOP e un'altra fonte forniscono lo stesso valore vitale — frequenza cardiaca a riposo, ossigeno nel sangue, temperatura corporea, frequenza respiratoria o fasi del sonno —, potresti vedere entrambi i valori finché un futuro aggiornamento non sceglierà un'unica fonte preferita.",
|
||||
"whoopDescription": "Collega la fascia WHOOP per sincronizzare recupero, sonno, sforzo e allenamenti direttamente in HealthLog.",
|
||||
"whoopCredentials": "Credenziali API",
|
||||
"whoopCredentialsHelp": "Registra la tua app sviluppatore WHOOP e incolla qui il suo Client ID e Client Secret.",
|
||||
"whoopClientId": "Client ID",
|
||||
"whoopClientSecret": "Client Secret",
|
||||
"whoopCredentialsSaved": "Credenziali salvate",
|
||||
@@ -4399,12 +4416,9 @@
|
||||
},
|
||||
"fitbit": "Google Health",
|
||||
"fitbitTag": "Fitbit e Pixel",
|
||||
"fitbitDescription": "Collega Google Health per sincronizzare i dati di Fitbit e Pixel Watch: peso, massa grassa, frequenza cardiaca a riposo, saturazione di ossigeno, variabilità della frequenza cardiaca e altro.",
|
||||
"fitbitOverlapNote": "Se Google Health e un'altra fonte forniscono lo stesso parametro vitale — frequenza cardiaca a riposo, saturazione di ossigeno, temperatura corporea o frequenza respiratoria —, potresti vedere entrambi i valori finché un futuro aggiornamento non sceglierà un'unica fonte preferita.",
|
||||
"fitbitDescription": "Collega Google Health per sincronizzare i dati di Fitbit e Pixel Watch — peso, frequenza cardiaca e altro.",
|
||||
"fitbitExperimentalBadge": "Sperimentale",
|
||||
"fitbitExperimentalNote": "Questa integrazione è in versione beta: la copertura dei dati e la mappatura dei campi sono ancora in fase di verifica con account Google Health reali, quindi alcuni valori potrebbero mancare o cambiare in un futuro aggiornamento.",
|
||||
"fitbitCredentials": "Credenziali API",
|
||||
"fitbitCredentialsHelp": "Registra il tuo client OAuth di Google Cloud per l'API Google Health e incolla qui il suo Client ID e Client Secret.",
|
||||
"fitbitClientId": "Client ID",
|
||||
"fitbitClientSecret": "Client Secret",
|
||||
"fitbitCredentialsSaved": "Credenziali salvate",
|
||||
@@ -4662,7 +4676,16 @@
|
||||
"statusActive": "Attivo",
|
||||
"statusRevoked": "Revocato",
|
||||
"statusExpired": "Scaduto",
|
||||
"inactiveTitle": "Revocati e scaduti ({count})"
|
||||
"inactiveTitle": "Revocati e scaduti ({count})",
|
||||
"shownOnce": "Mostrato una sola volta. Salva ora il codice QR e la passphrase — non potranno essere recuperati in seguito.",
|
||||
"qrLabel": "Codice QR",
|
||||
"qrAlt": "Codice QR contenente il link di condivisione e la passphrase",
|
||||
"linkLabel": "Link",
|
||||
"passphraseLabel": "Passphrase",
|
||||
"passphraseHint": "Il destinatario ha bisogno del link e di questa passphrase. Il codice QR contiene entrambi.",
|
||||
"copyLink": "Copia link",
|
||||
"copyPassphrase": "Copia passphrase",
|
||||
"protected": "Passphrase"
|
||||
},
|
||||
"genderOther": "Altro",
|
||||
"cycleTracking": {
|
||||
@@ -4674,9 +4697,8 @@
|
||||
},
|
||||
"nightscout": "Nightscout",
|
||||
"nightscoutTag": "CGM",
|
||||
"nightscoutDescription": "Collega il tuo sito Nightscout per sincronizzare le letture continue della glicemia nel pannello glicemia.",
|
||||
"nightscoutDescription": "Collega il tuo sito Nightscout per sincronizzare le letture continue del glucosio nel pannello glucosio.",
|
||||
"nightscoutViewData": "Vedi la tua glicemia",
|
||||
"nightscoutHelp": "Punta HealthLog alla tua istanza Nightscout. Le letture vengono recuperate in background una volta all'ora.",
|
||||
"nightscoutUrl": "URL Nightscout",
|
||||
"nightscoutUrlSavedPlaceholder": "Salvato — inserisci un nuovo URL per sostituirlo",
|
||||
"nightscoutToken": "Token API",
|
||||
@@ -4697,7 +4719,6 @@
|
||||
"polarViewData": "Vedi sonno e recupero",
|
||||
"polarHelp": "Accedi a Polar per concedere l'accesso. I dati vengono recuperati in background una volta all'ora.",
|
||||
"polarCredentials": "Credenziali API",
|
||||
"polarCredentialsHelp": "Facoltativo: registra la tua app Polar AccessLink e incolla qui il suo Client ID e Client Secret. Lascia vuoto per usare l'app Polar condivisa del server.",
|
||||
"polarClientId": "Client ID",
|
||||
"polarClientSecret": "Client Secret",
|
||||
"polarCredentialsSaved": "Credenziali salvate",
|
||||
@@ -4721,11 +4742,10 @@
|
||||
"generic": "Errore sconosciuto durante la connessione. Riavvia la connessione."
|
||||
},
|
||||
"oura": "Oura",
|
||||
"ouraDescription": "Collega il tuo account Oura per sincronizzare readiness, fasi del sonno, HRV e attività giornaliera.",
|
||||
"ouraDescription": "Collega il tuo account Oura per sincronizzare prontezza, fasi del sonno, variabilità cardiaca e attività giornaliera.",
|
||||
"ouraViewData": "Vedi sonno e recupero",
|
||||
"ouraHelp": "Accedi a Oura per concedere l'accesso. I dati vengono recuperati in background una volta all'ora.",
|
||||
"ouraCredentials": "Credenziali API",
|
||||
"ouraCredentialsHelp": "Facoltativo: registra la tua app Oura e incolla qui il suo Client ID e Client Secret. Lascia vuoto per usare l'app Oura condivisa del server.",
|
||||
"ouraClientId": "Client ID",
|
||||
"ouraClientSecret": "Client Secret",
|
||||
"ouraCredentialsSaved": "Credenziali salvate",
|
||||
@@ -4758,7 +4778,14 @@
|
||||
"savedOff": "Coach nascosto.",
|
||||
"saveError": "Impossibile salvare la modifica. Riprova."
|
||||
}
|
||||
}
|
||||
},
|
||||
"withingsDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la",
|
||||
"whoopDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la",
|
||||
"fitbitDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la",
|
||||
"polarDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la",
|
||||
"ouraDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la",
|
||||
"nightscoutDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la",
|
||||
"integrationCredentialsHint": "Dove trovare queste credenziali e come registrare la tua app è spiegato nella guida alla configurazione qui sopra."
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
@@ -5945,7 +5972,14 @@
|
||||
"strain": "Punteggio di sforzo",
|
||||
"score": "Punteggio di benessere"
|
||||
},
|
||||
"footer": "Condiviso da HealthLog — un tracker della salute self-hosted."
|
||||
"footer": "Condiviso da HealthLog — un tracker della salute self-hosted.",
|
||||
"unlock": {
|
||||
"title": "Inserisci la passphrase",
|
||||
"description": "Questo referto sanitario condiviso è protetto. Inserisci la passphrase che hai ricevuto per visualizzarlo.",
|
||||
"label": "Passphrase",
|
||||
"error": "La passphrase non è corretta.",
|
||||
"submit": "Sblocca"
|
||||
}
|
||||
},
|
||||
"cycle": {
|
||||
"symptomCategory": {
|
||||
@@ -6404,11 +6438,6 @@
|
||||
"onTrack": "In linea",
|
||||
"due": "Da fare ora"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Personalizza prevenzione",
|
||||
"description": "Gestisci la vista, l'ordine e i singoli promemoria.",
|
||||
"back": "Torna alla prevenzione"
|
||||
},
|
||||
"capture": {
|
||||
"title": "Registra misurazione",
|
||||
"description": "Inserisci il valore; il promemoria viene segnato come fatto."
|
||||
@@ -6421,6 +6450,14 @@
|
||||
"dashboard": {
|
||||
"empty": "Nessun promemoria di prevenzione in arrivo.",
|
||||
"openLink": "Apri prevenzione"
|
||||
},
|
||||
"showMeasurements": "Mostra le misurazioni",
|
||||
"trendStrip": {
|
||||
"label": "Ultimi 7 giorni",
|
||||
"summary": "Ultimo valore {value}, {direction} su 7 giorni.",
|
||||
"rising": "in aumento",
|
||||
"falling": "in calo",
|
||||
"steady": "stabile"
|
||||
}
|
||||
},
|
||||
"moduleList": {
|
||||
@@ -6450,11 +6487,6 @@
|
||||
"deleteError": "Impossibile eliminare il risultato",
|
||||
"loadError": "Impossibile caricare i risultati",
|
||||
"customize": "Personalizza esami",
|
||||
"settings": {
|
||||
"title": "Personalizza esami",
|
||||
"description": "Gestisci la vista, l'ordinamento e i biomarcatori.",
|
||||
"back": "Torna agli esami"
|
||||
},
|
||||
"sort": {
|
||||
"heading": "Ordinamento",
|
||||
"label": "Ordine dei valori",
|
||||
@@ -6653,11 +6685,6 @@
|
||||
"title": "Diario delle malattie",
|
||||
"subtitle": "Un registro retrospettivo dei tuoi episodi di malattia e disturbi.",
|
||||
"customize": "Personalizza diario delle malattie",
|
||||
"settings": {
|
||||
"title": "Personalizza diario delle malattie",
|
||||
"description": "Gestisci la vista e l'ordine degli episodi.",
|
||||
"back": "Torna al diario delle malattie"
|
||||
},
|
||||
"newEpisode": "Nuovo episodio",
|
||||
"logDay": "Registra un giorno",
|
||||
"markRecovered": "Segna come guarito",
|
||||
|
||||
+62
-35
@@ -2003,6 +2003,7 @@
|
||||
"dailyBriefing": {
|
||||
"title": "Briefing dnia",
|
||||
"keyFindingsTitle": "Kluczowe wnioski",
|
||||
"signalsTitle": "Sygnały dnia",
|
||||
"emptyTitle": "Jeszcze brak briefingu",
|
||||
"emptyDescription": "Wygeneruj dzisiejszy briefing, aby zobaczyć podsumowanie trendów.",
|
||||
"emptyAction": "Wygeneruj briefing",
|
||||
@@ -2263,7 +2264,11 @@
|
||||
"settingsClustersLabel": "Dane widziane przez Coacha",
|
||||
"settingsClustersHint": "Wybierz, które grupy danych Coach może czytać. Więcej danych to bogatsze odpowiedzi i większy prompt.",
|
||||
"settingsClusterNoData": "Brak danych",
|
||||
"sourcesMemberCount": "{count} wskaźników"
|
||||
"sourcesMemberCount": "{count} wskaźników",
|
||||
"dictate": "Dyktuj",
|
||||
"dictateStop": "Zatrzymaj dyktowanie",
|
||||
"showConversations": "Pokaż rozmowy",
|
||||
"hideConversations": "Ukryj rozmowy"
|
||||
},
|
||||
"relativeJustNow": "przed chwilą",
|
||||
"relativeMinutesAgoOne": "{count} minutę temu",
|
||||
@@ -2568,7 +2573,6 @@
|
||||
"chronotype": {
|
||||
"title": "Chronotyp",
|
||||
"learning": "Wciąż poznajemy Twój rytm — {counted} z {total} wolnych nocy. Chronotyp wymaga kilku poranków bez budzika, aby coś znaczył.",
|
||||
"midSleepCaption": "Naturalny środek Twojego snu w dni wolne przypada około {clock}.",
|
||||
"advancedToggle": "Zaawansowane",
|
||||
"socialJetlagValue": "{hours} godz. {minutes} min jet lagu społecznego",
|
||||
"socialJetlagCaption": "Różnica między porą snu w dni robocze i wolne — jak niewielka cotygodniowa zmiana strefy czasowej.",
|
||||
@@ -2582,7 +2586,7 @@
|
||||
"late": "Typ wieczorny",
|
||||
"extreme_late": "Wyraźny typ wieczorny"
|
||||
},
|
||||
"cornerLabel": "Chronotyp"
|
||||
"midpointHeadline": "Naturalny środek Twojego snu przypada około {clock}."
|
||||
},
|
||||
"rhythm": {
|
||||
"loadError": "Nie udało się teraz wczytać Twojego rytmu snu. Pojawi się ponownie, gdy połączenie się ustabilizuje."
|
||||
@@ -4021,6 +4025,21 @@
|
||||
"description": "Grupy, tagi i zarchiwizowane tagi w wyborze nastroju.",
|
||||
"subtitle": "Uporządkuj tagi nastroju, grupy i kolejność wyboru."
|
||||
},
|
||||
"labs": {
|
||||
"title": "Wyniki badań",
|
||||
"description": "Widok, sortowanie i biomarkery Twoich wyników badań.",
|
||||
"subtitle": "Zarządzaj widokiem, sortowaniem i biomarkerami."
|
||||
},
|
||||
"illness": {
|
||||
"title": "Dziennik chorób",
|
||||
"description": "Widok i kolejność Twoich epizodów chorobowych.",
|
||||
"subtitle": "Zarządzaj widokiem i kolejnością epizodów."
|
||||
},
|
||||
"vorsorge": {
|
||||
"title": "Profilaktyka",
|
||||
"description": "Widok, kolejność i poszczególne przypomnienia profilaktyczne.",
|
||||
"subtitle": "Zarządzaj widokiem, kolejnością i poszczególnymi przypomnieniami."
|
||||
},
|
||||
"modules": {
|
||||
"title": "Moduły",
|
||||
"saved": "Zapisano.",
|
||||
@@ -4324,7 +4343,7 @@
|
||||
}
|
||||
},
|
||||
"withings": "Withings",
|
||||
"withingsDescription": "Połącz swoją wagę i ciśnieniomierze Withings.",
|
||||
"withingsDescription": "Połącz wagę i ciśnieniomierz Withings, aby automatycznie synchronizować wagę, skład ciała i ciśnienie krwi.",
|
||||
"withingsCredentials": "Dane logowania API",
|
||||
"withingsClientId": "ID klienta",
|
||||
"withingsClientSecret": "Sekret klienta",
|
||||
@@ -4362,10 +4381,8 @@
|
||||
"withingsConnect": "Połącz z Withings",
|
||||
"withingsNoCredentials": "Najpierw wprowadź powyżej dane logowania API, aby połączyć Withings.",
|
||||
"whoop": "WHOOP",
|
||||
"whoopDescription": "Połącz opaskę WHOOP, aby synchronizować regenerację, sen, obciążenie i treningi.",
|
||||
"whoopOverlapNote": "Jeśli WHOOP i inne źródło dostarczają tę samą wartość życiową — tętno spoczynkowe, saturację, temperaturę ciała, częstość oddechów lub fazy snu — możesz widzieć obie wartości, dopóki przyszła aktualizacja nie wybierze jednego preferowanego źródła.",
|
||||
"whoopDescription": "Połącz opaskę WHOOP, aby synchronizować regenerację, sen, obciążenie i treningi prosto do HealthLog.",
|
||||
"whoopCredentials": "Dane API",
|
||||
"whoopCredentialsHelp": "Zarejestruj własną aplikację deweloperską WHOOP i wklej tutaj jej Client ID oraz Client Secret.",
|
||||
"whoopClientId": "Client ID",
|
||||
"whoopClientSecret": "Client Secret",
|
||||
"whoopCredentialsSaved": "Dane zapisane",
|
||||
@@ -4399,12 +4416,9 @@
|
||||
},
|
||||
"fitbit": "Google Health",
|
||||
"fitbitTag": "Fitbit i Pixel",
|
||||
"fitbitDescription": "Połącz Google Health, aby synchronizować dane z Fitbita i Pixel Watch — wagę, tkankę tłuszczową, tętno spoczynkowe, saturację, zmienność rytmu serca i więcej.",
|
||||
"fitbitOverlapNote": "Jeśli Google Health i inne źródło dostarczają ten sam parametr życiowy — tętno spoczynkowe, saturację, temperaturę ciała lub częstość oddechów — możesz widzieć obie wartości, dopóki przyszła aktualizacja nie wybierze jednego preferowanego źródła.",
|
||||
"fitbitDescription": "Połącz Google Health, aby synchronizować dane z Fitbit i Pixel Watch — wagę, tętno i więcej.",
|
||||
"fitbitExperimentalBadge": "Eksperymentalne",
|
||||
"fitbitExperimentalNote": "Ta integracja jest w wersji beta — zakres danych i mapowanie pól są wciąż weryfikowane na rzeczywistych kontach Google Health, więc niektóre wartości mogą być niedostępne lub zmienić się w przyszłej aktualizacji.",
|
||||
"fitbitCredentials": "Dane uwierzytelniające API",
|
||||
"fitbitCredentialsHelp": "Zarejestruj własny klient OAuth Google Cloud dla interfejsu Google Health API i wklej tutaj jego Client ID oraz Client Secret.",
|
||||
"fitbitClientId": "Client ID",
|
||||
"fitbitClientSecret": "Client Secret",
|
||||
"fitbitCredentialsSaved": "Dane uwierzytelniające zapisane",
|
||||
@@ -4662,7 +4676,16 @@
|
||||
"statusActive": "Aktywny",
|
||||
"statusRevoked": "Unieważniony",
|
||||
"statusExpired": "Wygasły",
|
||||
"inactiveTitle": "Unieważnione i wygasłe ({count})"
|
||||
"inactiveTitle": "Unieważnione i wygasłe ({count})",
|
||||
"shownOnce": "Wyświetlane tylko raz. Zapisz teraz kod QR i hasło — nie można ich później odzyskać.",
|
||||
"qrLabel": "Kod QR",
|
||||
"qrAlt": "Kod QR zawierający link do udostępniania i hasło",
|
||||
"linkLabel": "Link",
|
||||
"passphraseLabel": "Hasło",
|
||||
"passphraseHint": "Odbiorca potrzebuje linku i tego hasła. Kod QR zawiera oba.",
|
||||
"copyLink": "Kopiuj link",
|
||||
"copyPassphrase": "Kopiuj hasło",
|
||||
"protected": "Hasło"
|
||||
},
|
||||
"genderOther": "Inna",
|
||||
"cycleTracking": {
|
||||
@@ -4674,9 +4697,8 @@
|
||||
},
|
||||
"nightscout": "Nightscout",
|
||||
"nightscoutTag": "CGM",
|
||||
"nightscoutDescription": "Połącz własną witrynę Nightscout, aby synchronizować ciągłe odczyty glukozy w panelu glukozy.",
|
||||
"nightscoutDescription": "Połącz własną stronę Nightscout, aby synchronizować ciągłe odczyty glukozy w panelu glukozy.",
|
||||
"nightscoutViewData": "Zobacz swoją glukozę",
|
||||
"nightscoutHelp": "Wskaż HealthLog na swoją instancję Nightscout. Odczyty są pobierane w tle raz na godzinę.",
|
||||
"nightscoutUrl": "Adres URL Nightscout",
|
||||
"nightscoutUrlSavedPlaceholder": "Zapisano — wpisz nowy adres URL, aby go zastąpić",
|
||||
"nightscoutToken": "Token API",
|
||||
@@ -4693,11 +4715,10 @@
|
||||
"nightscoutDisconnectTitle": "Rozłączyć Nightscout?",
|
||||
"nightscoutDisconnectDescription": "Przyszłe synchronizacje zostaną zatrzymane. Już zaimportowane odczyty glukozy pozostaną w historii.",
|
||||
"polar": "Polar",
|
||||
"polarDescription": "Połącz konto Polar, aby synchronizować regenerację Nightly Recharge, fazy snu i aktywność dzienną.",
|
||||
"polarDescription": "Połącz konto Polar, aby synchronizować regenerację Nightly Recharge, fazy snu i dzienną aktywność.",
|
||||
"polarViewData": "Zobacz sen i regenerację",
|
||||
"polarHelp": "Zaloguj się w Polar, aby przyznać dostęp. Dane są pobierane w tle raz na godzinę.",
|
||||
"polarCredentials": "Dane API",
|
||||
"polarCredentialsHelp": "Opcjonalnie: zarejestruj własną aplikację Polar AccessLink i wklej tutaj jej Client ID oraz Client Secret. Pozostaw puste, aby użyć współdzielonej aplikacji Polar serwera.",
|
||||
"polarClientId": "Client ID",
|
||||
"polarClientSecret": "Client Secret",
|
||||
"polarCredentialsSaved": "Dane zapisane",
|
||||
@@ -4721,11 +4742,10 @@
|
||||
"generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie."
|
||||
},
|
||||
"oura": "Oura",
|
||||
"ouraDescription": "Połącz konto Oura, aby synchronizować readiness, fazy snu, HRV i aktywność dzienną.",
|
||||
"ouraDescription": "Połącz konto Oura, aby synchronizować gotowość, fazy snu, zmienność rytmu serca i dzienną aktywność.",
|
||||
"ouraViewData": "Zobacz sen i regenerację",
|
||||
"ouraHelp": "Zaloguj się w Oura, aby przyznać dostęp. Dane są pobierane w tle raz na godzinę.",
|
||||
"ouraCredentials": "Dane API",
|
||||
"ouraCredentialsHelp": "Opcjonalnie: zarejestruj własną aplikację Oura i wklej tutaj jej Client ID oraz Client Secret. Pozostaw puste, aby użyć współdzielonej aplikacji Oura serwera.",
|
||||
"ouraClientId": "Client ID",
|
||||
"ouraClientSecret": "Client Secret",
|
||||
"ouraCredentialsSaved": "Dane zapisane",
|
||||
@@ -4758,7 +4778,14 @@
|
||||
"savedOff": "Coach ukryty.",
|
||||
"saveError": "Nie udało się zapisać zmiany. Spróbuj ponownie."
|
||||
}
|
||||
}
|
||||
},
|
||||
"withingsDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z",
|
||||
"whoopDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z",
|
||||
"fitbitDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z",
|
||||
"polarDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z",
|
||||
"ouraDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z",
|
||||
"nightscoutDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z",
|
||||
"integrationCredentialsHint": "Gdzie znaleźć te dane logowania i jak zarejestrować własną aplikację, opisano w przewodniku konfiguracji powyżej."
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administracja",
|
||||
@@ -5945,7 +5972,14 @@
|
||||
"strain": "Wynik obciążenia",
|
||||
"score": "Wynik dobrostanu"
|
||||
},
|
||||
"footer": "Udostępnione z HealthLog — samodzielnie hostowanego trackera zdrowia."
|
||||
"footer": "Udostępnione z HealthLog — samodzielnie hostowanego trackera zdrowia.",
|
||||
"unlock": {
|
||||
"title": "Wprowadź hasło",
|
||||
"description": "Ta udostępniona dokumentacja zdrowotna jest chroniona. Wprowadź otrzymane hasło, aby ją wyświetlić.",
|
||||
"label": "Hasło",
|
||||
"error": "To hasło jest nieprawidłowe.",
|
||||
"submit": "Odblokuj"
|
||||
}
|
||||
},
|
||||
"cycle": {
|
||||
"symptomCategory": {
|
||||
@@ -6404,11 +6438,6 @@
|
||||
"onTrack": "Zgodnie z planem",
|
||||
"due": "Termin teraz"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Dostosuj profilaktykę",
|
||||
"description": "Zarządzaj widokiem, kolejnością i poszczególnymi przypomnieniami.",
|
||||
"back": "Powrót do profilaktyki"
|
||||
},
|
||||
"capture": {
|
||||
"title": "Zapisz pomiar",
|
||||
"description": "Wpisz wartość; przypomnienie zostanie oznaczone jako wykonane."
|
||||
@@ -6421,6 +6450,14 @@
|
||||
"dashboard": {
|
||||
"empty": "Brak nadchodzących przypomnień profilaktycznych.",
|
||||
"openLink": "Otwórz profilaktykę"
|
||||
},
|
||||
"showMeasurements": "Pokaż pomiary",
|
||||
"trendStrip": {
|
||||
"label": "Ostatnie 7 dni",
|
||||
"summary": "Ostatnia wartość {value}, {direction} w ciągu 7 dni.",
|
||||
"rising": "rosnący",
|
||||
"falling": "malejący",
|
||||
"steady": "stabilny"
|
||||
}
|
||||
},
|
||||
"moduleList": {
|
||||
@@ -6450,11 +6487,6 @@
|
||||
"deleteError": "Nie udało się usunąć wyniku",
|
||||
"loadError": "Nie udało się wczytać wyników",
|
||||
"customize": "Dostosuj wyniki badań",
|
||||
"settings": {
|
||||
"title": "Dostosuj wyniki badań",
|
||||
"description": "Zarządzaj widokiem, sortowaniem i biomarkerami.",
|
||||
"back": "Powrót do wyników badań"
|
||||
},
|
||||
"sort": {
|
||||
"heading": "Sortowanie",
|
||||
"label": "Kolejność wartości",
|
||||
@@ -6653,11 +6685,6 @@
|
||||
"title": "Dziennik chorób",
|
||||
"subtitle": "Retrospektywny zapis Twoich epizodów chorób i dolegliwości.",
|
||||
"customize": "Dostosuj dziennik chorób",
|
||||
"settings": {
|
||||
"title": "Dostosuj dziennik chorób",
|
||||
"description": "Zarządzaj widokiem i kolejnością epizodów.",
|
||||
"back": "Powrót do dziennika chorób"
|
||||
},
|
||||
"newEpisode": "Nowy epizod",
|
||||
"logDay": "Zapisz dzień",
|
||||
"markRecovered": "Oznacz jako wyleczone",
|
||||
|
||||
+1
-3
@@ -140,9 +140,7 @@ const nextConfig: NextConfig = {
|
||||
return [
|
||||
{
|
||||
source: "/:path*",
|
||||
headers: [
|
||||
{ key: "Permissions-Policy", value: "unload=()" },
|
||||
],
|
||||
headers: [{ key: "Permissions-Policy", value: "unload=()" }],
|
||||
},
|
||||
{
|
||||
source: "/((?!api|_next).*)",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.18.6.1",
|
||||
"version": "1.18.7",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
-- v1.18.7 — expiry indexes for the two tables swept by the session reaper.
|
||||
--
|
||||
-- `sessions` (sliding-30-day session expiry) and `auth_challenges` (passkey
|
||||
-- registration / authentication challenges) are both pruned on `expires_at`,
|
||||
-- but neither carried an index on that column. On a busy multi-user instance
|
||||
-- the cleanup degraded to a sequential scan. Every sibling expiry table
|
||||
-- (`refresh_tokens`, `whoop_oauth_states`, `idempotency_keys`,
|
||||
-- `withings_oauth_states`, …) already indexes its expiry column; this brings
|
||||
-- the two stragglers in line. Additive; no data touched.
|
||||
--
|
||||
-- Reversibility (down):
|
||||
-- DROP INDEX IF EXISTS "sessions_expires_at_idx";
|
||||
-- DROP INDEX IF EXISTS "auth_challenges_expires_at_idx";
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX IF NOT EXISTS "sessions_expires_at_idx" ON "sessions"("expires_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX IF NOT EXISTS "auth_challenges_expires_at_idx" ON "auth_challenges"("expires_at");
|
||||
@@ -0,0 +1,5 @@
|
||||
-- v1.18.7 — track the daily-briefing phrasing re-roll date.
|
||||
-- The content-hash gate keeps the briefing findings byte-stable on
|
||||
-- unchanged data; this column lets the paragraph be re-rolled once per
|
||||
-- calendar day (UTC) for phrasing variety without re-paying for findings.
|
||||
ALTER TABLE "users" ADD COLUMN "insights_briefing_reroll_date" TEXT;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- v1.18.7 — passphrase second factor for clinician share links.
|
||||
-- A leaked share URL on its own no longer opens the record: every new
|
||||
-- link carries a high-entropy passphrase, stored only as its HMAC-SHA256
|
||||
-- hash (same key as token_hash). The column is NULLABLE so every link
|
||||
-- minted before this change keeps the possession-only contract and the
|
||||
-- public view renders for it without a gate.
|
||||
ALTER TABLE "clinician_share_links" ADD COLUMN "passphrase_hash" TEXT;
|
||||
@@ -71,6 +71,13 @@ model User {
|
||||
// so a persistently broken provider chain cannot be re-driven into a
|
||||
// full generation attempt by every page-open.
|
||||
insightsWarmFailedAt DateTime? @map("insights_warm_failed_at")
|
||||
// v1.18.7 — UTC YYYY-MM-DD of the last daily-briefing phrasing re-roll.
|
||||
// The content-hash gate keeps the FINDINGS byte-stable on unchanged data,
|
||||
// but the daily-briefing PARAGRAPH is re-rolled once per calendar day at a
|
||||
// higher, seedless temperature so the prose reads fresh day-over-day
|
||||
// without re-paying for the findings. This stamps the day a re-roll ran so
|
||||
// the gate stays at most one extra call per day.
|
||||
insightsBriefingRerollDate String? @map("insights_briefing_reroll_date")
|
||||
// v1.4.36 W3 T3 — per-user AI Insights exclude-metrics list, mirrors
|
||||
// `coachPrefsJson.excludeMetrics`. The /api/insights/generate route
|
||||
// filters matching keys off `features` before JSON.stringify so the
|
||||
@@ -721,6 +728,9 @@ model Session {
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
// v1.18.7 — the sliding-30-day session reaper sweeps on `expiresAt`; without
|
||||
// this index the cleanup degrades to a sequential scan on a busy instance.
|
||||
@@index([expiresAt])
|
||||
@@map("sessions")
|
||||
}
|
||||
|
||||
@@ -735,6 +745,9 @@ model AuthChallenge {
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
// v1.18.7 — challenges are swept by expiry; index the column the reaper
|
||||
// filters on so the cleanup stays index-backed.
|
||||
@@index([expiresAt])
|
||||
@@map("auth_challenges")
|
||||
}
|
||||
|
||||
@@ -3843,6 +3856,13 @@ model ClinicianShareLink {
|
||||
userId String @map("user_id")
|
||||
/// HMAC-SHA256 of the raw `hls_` token (same scheme as `ApiToken.tokenHash`).
|
||||
tokenHash String @unique @map("token_hash")
|
||||
/// v1.18.7 — HMAC-SHA256 of the passphrase second factor (same key as
|
||||
/// `tokenHash`). NULL for legacy links minted before the passphrase gate;
|
||||
/// those keep the possession-only contract (the public view renders without
|
||||
/// a gate). A non-null value forces the unlock gate before the record paints.
|
||||
/// The raw passphrase is returned exactly once at create time and stored only
|
||||
/// as this hash — there is no recovery path.
|
||||
passphraseHash String? @map("passphrase_hash")
|
||||
/// Human label the owner sets (e.g. a clinic/specialty note). Plaintext, bounded.
|
||||
label String
|
||||
|
||||
|
||||
@@ -41,19 +41,25 @@
|
||||
"name": "Add measurement",
|
||||
"short_name": "Add",
|
||||
"url": "/measurements?add=WEIGHT&source=shortcut",
|
||||
"icons": [{ "src": "/logo-192.png", "sizes": "192x192", "type": "image/png" }]
|
||||
"icons": [
|
||||
{ "src": "/logo-192.png", "sizes": "192x192", "type": "image/png" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Today",
|
||||
"short_name": "Today",
|
||||
"url": "/?source=shortcut",
|
||||
"icons": [{ "src": "/logo-192.png", "sizes": "192x192", "type": "image/png" }]
|
||||
"icons": [
|
||||
{ "src": "/logo-192.png", "sizes": "192x192", "type": "image/png" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Medications",
|
||||
"short_name": "Meds",
|
||||
"url": "/medications?source=shortcut",
|
||||
"icons": [{ "src": "/logo-192.png", "sizes": "192x192", "type": "image/png" }]
|
||||
"icons": [
|
||||
{ "src": "/logo-192.png", "sizes": "192x192", "type": "image/png" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+1
-1
@@ -36,7 +36,7 @@ try {
|
||||
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.18.6";
|
||||
/* @sw-version-fallback */ "v1.18.6.1";
|
||||
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
|
||||
|
||||
@@ -9,11 +9,7 @@
|
||||
* `self.__APP_VERSION__` (falling back to a literal).
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
existsSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
} from "node:fs";
|
||||
import { existsSync, readFileSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, it, expect, afterAll } from "vitest";
|
||||
|
||||
|
||||
@@ -59,7 +59,9 @@ async function main(): Promise<void> {
|
||||
const dryRun = !args.confirm;
|
||||
|
||||
if (dryRun) {
|
||||
console.log("[backfill-note] DRY RUN — no rows will be written. Pass --confirm to commit.");
|
||||
console.log(
|
||||
"[backfill-note] DRY RUN — no rows will be written. Pass --confirm to commit.",
|
||||
);
|
||||
} else {
|
||||
console.log("[backfill-note] COMMIT MODE — updating rows in place.");
|
||||
}
|
||||
@@ -92,7 +94,9 @@ async function main(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[backfill-note] ${updated} row${updated === 1 ? "" : "s"} ${dryRun ? "would be" : "were"} updated`);
|
||||
console.log(
|
||||
`[backfill-note] ${updated} row${updated === 1 ? "" : "s"} ${dryRun ? "would be" : "were"} updated`,
|
||||
);
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
|
||||
@@ -66,12 +66,9 @@ async function main(): Promise<void> {
|
||||
for (const [index, user] of users.entries()) {
|
||||
const userStarted = Date.now();
|
||||
try {
|
||||
const { rowsUpserted, durationMs } = await recomputeUserRollups(
|
||||
user.id,
|
||||
{
|
||||
granularities: ALL_GRANULARITIES,
|
||||
},
|
||||
);
|
||||
const { rowsUpserted, durationMs } = await recomputeUserRollups(user.id, {
|
||||
granularities: ALL_GRANULARITIES,
|
||||
});
|
||||
totalRowsUpserted += rowsUpserted;
|
||||
console.log(
|
||||
`[${index + 1}/${users.length}] user=${user.username} ` +
|
||||
|
||||
@@ -158,11 +158,7 @@ export function checkEnv(
|
||||
// All-or-none groups: surface a synthetic row when the group is
|
||||
// partially populated. This catches the v1.4.40 AP-2 pattern even
|
||||
// when the group itself is `required: false`.
|
||||
if (
|
||||
group.allOrNone &&
|
||||
presentInGroup > 0 &&
|
||||
absentInGroup > 0
|
||||
) {
|
||||
if (group.allOrNone && presentInGroup > 0 && absentInGroup > 0) {
|
||||
results.push({
|
||||
group: group.name,
|
||||
variable: "<all-or-none>",
|
||||
@@ -205,8 +201,7 @@ function renderResults(results: CheckResult[]): {
|
||||
);
|
||||
} else if (r.required) {
|
||||
console.log(
|
||||
` [MISSING-REQUIRED] ${r.variable}` +
|
||||
(r.note ? ` — ${r.note}` : ""),
|
||||
` [MISSING-REQUIRED] ${r.variable}` + (r.note ? ` — ${r.note}` : ""),
|
||||
);
|
||||
missingRequired++;
|
||||
} else {
|
||||
|
||||
@@ -163,11 +163,17 @@
|
||||
"required": false,
|
||||
"allOrNone": true,
|
||||
"variables": [
|
||||
{ "name": "BACKUP_S3_ENDPOINT", "purpose": "S3-compatible endpoint URL." },
|
||||
{
|
||||
"name": "BACKUP_S3_ENDPOINT",
|
||||
"purpose": "S3-compatible endpoint URL."
|
||||
},
|
||||
{ "name": "BACKUP_S3_BUCKET", "purpose": "Bucket name." },
|
||||
{ "name": "BACKUP_S3_ACCESS_KEY", "purpose": "S3 access key." },
|
||||
{ "name": "BACKUP_S3_SECRET_KEY", "purpose": "S3 secret key." },
|
||||
{ "name": "BACKUP_ENCRYPTION_KEY", "purpose": "Separate AES key for the backup body — MUST NOT match ENCRYPTION_KEY." }
|
||||
{
|
||||
"name": "BACKUP_ENCRYPTION_KEY",
|
||||
"purpose": "Separate AES key for the backup body — MUST NOT match ENCRYPTION_KEY."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -53,4 +53,6 @@ const rewritten = sw.replace(fallbackPattern, `$1${JSON.stringify(version)}`);
|
||||
if (rewritten !== sw) writeFileSync(swPath, rewritten, "utf8");
|
||||
|
||||
// One-line log so the build output shows the version we baked in.
|
||||
process.stdout.write(`sw-version.js → ${version} (sw.js fallback re-anchored)\n`);
|
||||
process.stdout.write(
|
||||
`sw-version.js → ${version} (sw.js fallback re-anchored)\n`,
|
||||
);
|
||||
@@ -30,28 +30,28 @@ on both ends** so secret columns can be NULLed and column order is pinned.
|
||||
|
||||
## What moves (FK-parent-first, 20 tables)
|
||||
|
||||
| # | Table | Scope predicate |
|
||||
|---|---|---|
|
||||
| 1 | `users` | `id = demo` (the one row; secrets NULLed, `role='ADMIN'`) |
|
||||
| 2 | `measurements` | `user_id = demo AND deleted_at IS NULL` (incl. RECOVERY/STRAIN/STRESS_SCORE rows) |
|
||||
| 3 | `cycle_profiles` | `user_id = demo` |
|
||||
| 4 | `menstrual_cycles` | `user_id = demo` |
|
||||
| 5 | `cycle_day_logs` | `user_id = demo` (encrypted cols NULLed) |
|
||||
| 6 | `cycle_symptom_links` | `day_log_id IN (demo's day logs)` |
|
||||
| 7 | `mood_entries` | `user_id = demo AND deleted_at IS NULL` |
|
||||
| 8 | `mood_entry_tag_links` | `mood_entry_id IN (demo's entries)` — global tags only |
|
||||
| 9 | `medications` | `user_id = demo` |
|
||||
| 10 | `medication_schedules` | `medication_id IN (demo's meds)` |
|
||||
| 11 | `medication_intake_events` | `user_id = demo AND deleted_at IS NULL` |
|
||||
| 12 | `medication_dose_changes` | `medication_id IN (demo's meds)` |
|
||||
| 13 | `medication_inventory_items` | `user_id = demo` |
|
||||
| 14 | `medication_inventory_events` | `medication_id IN (demo's meds)` |
|
||||
| 15 | `medication_side_effects` | `user_id = demo` |
|
||||
| 16 | `reminder_phase_configs` | `medication_id IN (demo's meds)` |
|
||||
| 17 | `personal_records` | `user_id = demo` |
|
||||
| 18 | `user_achievements` | `user_id = demo` |
|
||||
| 19 | `consent_receipts` | `user_id = demo` (carries the `ai_full` consent) |
|
||||
| 20 | `audit_logs` | `user_id = demo AND action LIKE 'insights.%'` (plaintext AI assessments) |
|
||||
| # | Table | Scope predicate |
|
||||
| --- | ----------------------------- | --------------------------------------------------------------------------------- |
|
||||
| 1 | `users` | `id = demo` (the one row; secrets NULLed, `role='ADMIN'`) |
|
||||
| 2 | `measurements` | `user_id = demo AND deleted_at IS NULL` (incl. RECOVERY/STRAIN/STRESS_SCORE rows) |
|
||||
| 3 | `cycle_profiles` | `user_id = demo` |
|
||||
| 4 | `menstrual_cycles` | `user_id = demo` |
|
||||
| 5 | `cycle_day_logs` | `user_id = demo` (encrypted cols NULLed) |
|
||||
| 6 | `cycle_symptom_links` | `day_log_id IN (demo's day logs)` |
|
||||
| 7 | `mood_entries` | `user_id = demo AND deleted_at IS NULL` |
|
||||
| 8 | `mood_entry_tag_links` | `mood_entry_id IN (demo's entries)` — global tags only |
|
||||
| 9 | `medications` | `user_id = demo` |
|
||||
| 10 | `medication_schedules` | `medication_id IN (demo's meds)` |
|
||||
| 11 | `medication_intake_events` | `user_id = demo AND deleted_at IS NULL` |
|
||||
| 12 | `medication_dose_changes` | `medication_id IN (demo's meds)` |
|
||||
| 13 | `medication_inventory_items` | `user_id = demo` |
|
||||
| 14 | `medication_inventory_events` | `medication_id IN (demo's meds)` |
|
||||
| 15 | `medication_side_effects` | `user_id = demo` |
|
||||
| 16 | `reminder_phase_configs` | `medication_id IN (demo's meds)` |
|
||||
| 17 | `personal_records` | `user_id = demo` |
|
||||
| 18 | `user_achievements` | `user_id = demo` |
|
||||
| 19 | `consent_receipts` | `user_id = demo` (carries the `ai_full` consent) |
|
||||
| 20 | `audit_logs` | `user_id = demo AND action LIKE 'insights.%'` (plaintext AI assessments) |
|
||||
|
||||
## Secret / credential columns NULLed on the `users` row
|
||||
|
||||
@@ -142,7 +142,7 @@ be a write-capable admin. Keep DEMO_MODE enabled.
|
||||
|
||||
## Caveats / things to confirm before running
|
||||
|
||||
- **`FOREIGN_HITS` assertion** counts *all* non-demo rows in the carried
|
||||
- **`FOREIGN_HITS` assertion** counts _all_ non-demo rows in the carried
|
||||
tables on edge01, not just what this run inserted. On a clean
|
||||
single-demo-user DB it is `0`. If edge01 legitimately hosts other demo-only
|
||||
accounts, treat a non-zero value as a warning to inspect, not an automatic
|
||||
|
||||
@@ -151,7 +151,11 @@ async function updateCoolifyEnv(
|
||||
appUuid: string,
|
||||
value: string,
|
||||
): Promise<void> {
|
||||
const list = await coolifyRequest(base, token, `/applications/${appUuid}/envs`);
|
||||
const list = await coolifyRequest(
|
||||
base,
|
||||
token,
|
||||
`/applications/${appUuid}/envs`,
|
||||
);
|
||||
if (!list.ok) {
|
||||
fail(`Coolify env list failed: HTTP ${list.status} — check token/UUID`);
|
||||
}
|
||||
|
||||
@@ -100,7 +100,10 @@ const TAG = "[repair-intake-anomalies]";
|
||||
// LOCAL time by default; this parser pins them back to UTC so every
|
||||
// Date this script handles is the true instant. (OID 1114 = timestamp
|
||||
// without time zone.)
|
||||
types.setTypeParser(1114, (value: string) => new Date(`${value.replace(" ", "T")}Z`));
|
||||
types.setTypeParser(
|
||||
1114,
|
||||
(value: string) => new Date(`${value.replace(" ", "T")}Z`),
|
||||
);
|
||||
|
||||
/**
|
||||
* Serialise a Date for a `timestamp without time zone` comparison /
|
||||
@@ -1056,11 +1059,7 @@ async function main(): Promise<void> {
|
||||
SET "starts_on" = $2::timestamp, "updated_at" = NOW()
|
||||
WHERE "id" = $1 AND "starts_on" = $3::timestamp
|
||||
`,
|
||||
[
|
||||
p.medicationId,
|
||||
utcParam(p.firstRow),
|
||||
utcParam(p.startsOn),
|
||||
],
|
||||
[p.medicationId, utcParam(p.firstRow), utcParam(p.startsOn)],
|
||||
);
|
||||
summary.startsOnAdjusted += res.rowCount ?? 0;
|
||||
}
|
||||
@@ -1105,11 +1104,17 @@ async function main(): Promise<void> {
|
||||
console.log(` duplicate slot groups: ${summary.duplicateGroups}`);
|
||||
console.log(` duplicate rows tombstoned: ${summary.rowsTombstoned}`);
|
||||
console.log(` implausible taken_at rows: ${summary.implausibleRows}`);
|
||||
console.log(` implausible rows tombstoned: ${summary.implausibleTombstoned}`);
|
||||
console.log(` window/times drift schedules: ${summary.windowDriftSchedules}`);
|
||||
console.log(
|
||||
` implausible rows tombstoned: ${summary.implausibleTombstoned}`,
|
||||
);
|
||||
console.log(
|
||||
` window/times drift schedules: ${summary.windowDriftSchedules}`,
|
||||
);
|
||||
console.log(` windows reconciled: ${summary.windowsReconciled}`);
|
||||
console.log(` stale-anchor pending rows: ${summary.staleAnchorRows}`);
|
||||
console.log(` stale-anchor rows tombstoned: ${summary.staleAnchorTombstoned}`);
|
||||
console.log(
|
||||
` stale-anchor rows tombstoned: ${summary.staleAnchorTombstoned}`,
|
||||
);
|
||||
console.log(` era-inference candidates: ${summary.eraCandidates}`);
|
||||
console.log(` era revisions created: ${summary.eraRevisionsCreated}`);
|
||||
console.log(` startsOn candidates: ${summary.startsOnCandidates}`);
|
||||
|
||||
@@ -207,7 +207,11 @@ async function main() {
|
||||
),
|
||||
);
|
||||
results.push(
|
||||
await rotateStringColumn("WhoopConnection", field, prisma.whoopConnection),
|
||||
await rotateStringColumn(
|
||||
"WhoopConnection",
|
||||
field,
|
||||
prisma.whoopConnection,
|
||||
),
|
||||
);
|
||||
results.push(
|
||||
await rotateStringColumn(
|
||||
@@ -349,11 +353,7 @@ async function main() {
|
||||
await rotateBytesColumn("LabResult", "noteEncrypted", prisma.labResult),
|
||||
);
|
||||
results.push(
|
||||
await rotateBytesColumn(
|
||||
"Biomarker",
|
||||
"contextEncrypted",
|
||||
prisma.biomarker,
|
||||
),
|
||||
await rotateBytesColumn("Biomarker", "contextEncrypted", prisma.biomarker),
|
||||
);
|
||||
results.push(
|
||||
await rotateBytesColumn(
|
||||
|
||||
+750
-54
@@ -1,18 +1,48 @@
|
||||
/**
|
||||
* Demo Data Seed Script for HealthLog
|
||||
*
|
||||
* Creates realistic demo data:
|
||||
* Creates a realistic, aspirational demo tenant for the public `demo`
|
||||
* account (demo / demo123demo123 — credentials are intentionally public).
|
||||
* The shape is tuned so a fresh seed reads healthy, engaged, and current
|
||||
* for App-Store / marketing screenshots:
|
||||
*
|
||||
* - 1 admin user (demo/demo123demo123)
|
||||
* - 90 days of measurements (weight, BP, pulse, body fat, sleep, steps)
|
||||
* - 3 medications with schedules and intake history
|
||||
* - 90 days of measurements (weight, BP, pulse, resting HR, body fat,
|
||||
* sleep, steps) — flat-or-improving trends, most-recent point from today
|
||||
* - A full week of per-stage sleep nights (awake/REM/light(core)/deep),
|
||||
* ~7.5 h each, including last night
|
||||
* - 90 days of body composition (fat/lean/muscle mass, total body water,
|
||||
* bone mass, visceral fat, BMI) derived from the same-day weight + body fat
|
||||
* - 90 days of blood glucose in a healthy non-diabetic band (fasting +
|
||||
* post-meal + bedtime, contexts tagged)
|
||||
* - 90 days of cardio fitness + vitals (HRV SDNN + RMSSD, SpO2, respiratory
|
||||
* rate, VO2 max, active energy, walking/running distance, flights climbed)
|
||||
* - 90 days of WHOOP-style scores (recovery, day strain, sleep
|
||||
* performance/efficiency/consistency) coherent with the sleep nights
|
||||
* - ~3-4 workouts/week (running, strength, cycling) with per-workout HR
|
||||
* samples and per-workout strain
|
||||
* - 3 medications with schedules and ~90 days of intake history at high
|
||||
* compliance, with today scheduled on-track (taken or not-yet-due)
|
||||
* - 90 days of mood entries
|
||||
* - Vorsorge (preventive-care) reminders — upcoming dental + annual physical
|
||||
* - One lab panel of biomarkers in healthy ranges
|
||||
* - One resolved illness episode in the past
|
||||
* - An AI-configured Coach with one short sample conversation
|
||||
* - App settings (registration disabled, English locale)
|
||||
*
|
||||
* Every date is relative to "now" so the demo stays fresh on every re-seed.
|
||||
*
|
||||
* Usage: npx tsx scripts/seed-demo.ts
|
||||
* Requires DATABASE_URL env var.
|
||||
* Requires DATABASE_URL env var. The Coach sample conversation additionally
|
||||
* needs ENCRYPTION_KEYS / ENCRYPTION_KEY (the same key the app runs with).
|
||||
*/
|
||||
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
import pg from "pg";
|
||||
import { encryptToBytes } from "../src/lib/ai/coach/bytes-codec";
|
||||
import { encrypt } from "../src/lib/crypto";
|
||||
import { VALUE_RANGES } from "../src/lib/validations/measurement";
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
@@ -45,6 +75,16 @@ function daysAgo(n: number): Date {
|
||||
return d;
|
||||
}
|
||||
|
||||
// A specific local instant N days ago, at a fixed wall-clock time. Used for
|
||||
// the per-stage sleep segments and the on-track intake slots where the hour
|
||||
// matters (a random morning hour would scatter the sleep stages).
|
||||
function daysAgoAt(n: number, hour: number, minute = 0): Date {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - n);
|
||||
d.setHours(hour, minute, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
function formatDate(d: Date): string {
|
||||
return d.toISOString().split("T")[0];
|
||||
}
|
||||
@@ -55,13 +95,20 @@ function randomWalk(
|
||||
target: number,
|
||||
days: number,
|
||||
volatility: number,
|
||||
// Optional physiological band. A free walk can drift below the floor and
|
||||
// mint an out-of-range row (a systolic-0 BP reading was observed in a
|
||||
// fresh seed — iOS #33); clamping every step keeps the series inside the
|
||||
// same band the input validator enforces.
|
||||
clamp?: { min: number; max: number },
|
||||
): number[] {
|
||||
const values: number[] = [start];
|
||||
for (let i = 1; i < days; i++) {
|
||||
const prev = values[i - 1];
|
||||
const drift = (target - prev) * 0.03; // mean reversion
|
||||
const noise = (Math.random() - 0.5) * volatility;
|
||||
values.push(Math.round((prev + drift + noise) * 10) / 10);
|
||||
let next = Math.round((prev + drift + noise) * 10) / 10;
|
||||
if (clamp) next = Math.min(clamp.max, Math.max(clamp.min, next));
|
||||
values.push(next);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
@@ -75,7 +122,14 @@ async function seed() {
|
||||
await client.query("BEGIN");
|
||||
|
||||
console.log("Cleaning existing data...");
|
||||
await client.query("DELETE FROM coach_messages");
|
||||
await client.query("DELETE FROM coach_conversations");
|
||||
await client.query("DELETE FROM illness_episodes");
|
||||
await client.query("DELETE FROM lab_results");
|
||||
await client.query("DELETE FROM measurement_reminders");
|
||||
await client.query("DELETE FROM mood_entries");
|
||||
await client.query("DELETE FROM workout_samples");
|
||||
await client.query("DELETE FROM workouts");
|
||||
await client.query("DELETE FROM medication_intake_events");
|
||||
await client.query("DELETE FROM medication_schedules");
|
||||
await client.query("DELETE FROM reminder_phase_configs");
|
||||
@@ -101,9 +155,17 @@ async function seed() {
|
||||
const passwordHash =
|
||||
"$argon2id$v=19$m=65536,t=3,p=4$Kips6OxPAl0vmspO9SoKZQ$oX9gLgwHVnnENCqBloyM13ewuqmhPnw8EpLoemS3MNI";
|
||||
|
||||
// AI Coach is configured to the Anthropic provider so the Coach surface
|
||||
// reads as connected. The stored key is a placeholder ciphertext: the
|
||||
// public demo never makes a live provider call (the sample conversation
|
||||
// below supplies the visible content), so no real credential is seeded.
|
||||
const aiProvider = "ANTHROPIC";
|
||||
const aiModel = "claude-3-5-sonnet";
|
||||
const aiKeyEncrypted = encrypt("sk-ant-demo-placeholder-not-a-live-key");
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO users (id, username, email, password_hash, role, height_cm, date_of_birth, gender, timezone, locale, onboarding_completed_at, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW(), NOW())`,
|
||||
`INSERT INTO users (id, username, email, password_hash, role, height_cm, date_of_birth, gender, timezone, locale, ai_provider, ai_model, ai_anthropic_key_encrypted, onboarding_completed_at, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW(), NOW(), NOW())`,
|
||||
[
|
||||
userId,
|
||||
"demo",
|
||||
@@ -115,29 +177,62 @@ async function seed() {
|
||||
"MALE",
|
||||
"Europe/Berlin",
|
||||
"en",
|
||||
aiProvider,
|
||||
aiModel,
|
||||
aiKeyEncrypted,
|
||||
],
|
||||
);
|
||||
|
||||
// ── Measurements (90 days) ────────────────
|
||||
// The loop runs i = 0..days (inclusive) so the final iteration lands a
|
||||
// point for TODAY (daysAgo(0)) — every headline metric has a current
|
||||
// reading for the dashboard, not one from yesterday.
|
||||
console.log("Creating 90 days of measurements...");
|
||||
const days = 90;
|
||||
const span = days + 1; // include today
|
||||
|
||||
// Weight: 86.5 → trending down to ~82.5
|
||||
const weights = randomWalk(86.5, 82.0, days, 0.6);
|
||||
// Systolic BP: 132 → improving to ~124
|
||||
const sysBP = randomWalk(132, 124, days, 4);
|
||||
// Diastolic BP: 85 → improving to ~78
|
||||
const diaBP = randomWalk(85, 78, days, 3);
|
||||
// Pulse: 72 → slight improvement to ~68
|
||||
const pulse = randomWalk(72, 68, days, 3);
|
||||
// Body fat: 24.5% → trending to ~22%
|
||||
const bodyFat = randomWalk(24.5, 22.0, days, 0.4);
|
||||
// Sleep: 6.5h → improving to ~7.5h
|
||||
const sleep = randomWalk(6.5, 7.5, days, 0.8);
|
||||
// Steps: 5000 → improving to ~8000
|
||||
const steps = randomWalk(5000, 8500, days, 1500);
|
||||
// Weight: 86.5 → trending down to a stable ~82 (healthy BMI ~24.7 at 182cm)
|
||||
const weights = randomWalk(86.5, 82.0, span, 0.6);
|
||||
// Systolic BP: 128 → settled in the optimal band ~118 (clamped)
|
||||
const sysBP = randomWalk(
|
||||
128,
|
||||
118,
|
||||
span,
|
||||
3,
|
||||
VALUE_RANGES.BLOOD_PRESSURE_SYS,
|
||||
);
|
||||
// Diastolic BP: 82 → settled ~76 (clamped)
|
||||
const diaBP = randomWalk(
|
||||
82,
|
||||
76,
|
||||
span,
|
||||
2.5,
|
||||
VALUE_RANGES.BLOOD_PRESSURE_DIA,
|
||||
);
|
||||
// Spot pulse (daytime heart rate): ~72, gently lower
|
||||
const pulse = randomWalk(74, 70, span, 3, VALUE_RANGES.PULSE);
|
||||
// Resting heart rate: clean ~60, the metric the resting-pulse tile scores
|
||||
const restingHr = randomWalk(
|
||||
64,
|
||||
58,
|
||||
span,
|
||||
2,
|
||||
VALUE_RANGES.RESTING_HEART_RATE,
|
||||
);
|
||||
// Body fat: 24% → trending to a healthy ~19%
|
||||
const bodyFat = randomWalk(24.0, 19.0, span, 0.4, VALUE_RANGES.BODY_FAT);
|
||||
// Sleep duration in MINUTES (the SLEEP_DURATION unit): ~7h → ~7h45m
|
||||
const sleepMin = randomWalk(
|
||||
420,
|
||||
465,
|
||||
span,
|
||||
25,
|
||||
VALUE_RANGES.SLEEP_DURATION,
|
||||
);
|
||||
// Steps: 6500 → a solid ~9000
|
||||
const steps = randomWalk(6500, 9000, span, 1500);
|
||||
|
||||
for (let i = 0; i < days; i++) {
|
||||
for (let i = 0; i < span; i++) {
|
||||
const date = daysAgo(days - i);
|
||||
|
||||
// Weight (daily)
|
||||
@@ -159,13 +254,20 @@ async function seed() {
|
||||
[cuid(), userId, Math.round(diaBP[i]), date],
|
||||
);
|
||||
|
||||
// Pulse (daily)
|
||||
// Spot pulse (daily)
|
||||
await client.query(
|
||||
`INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at)
|
||||
VALUES ($1, $2, 'PULSE', $3, 'bpm', 'MANUAL', $4, $4, $4)`,
|
||||
[cuid(), userId, Math.round(pulse[i]), date],
|
||||
);
|
||||
|
||||
// Resting heart rate (daily) — scored by the resting-pulse tile
|
||||
await client.query(
|
||||
`INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at)
|
||||
VALUES ($1, $2, 'RESTING_HEART_RATE', $3, 'bpm', 'MANUAL', $4, $4, $4)`,
|
||||
[cuid(), userId, Math.round(restingHr[i]), date],
|
||||
);
|
||||
|
||||
// Body fat (every 2-3 days)
|
||||
if (i % 2 === 0 || Math.random() > 0.5) {
|
||||
await client.query(
|
||||
@@ -175,12 +277,19 @@ async function seed() {
|
||||
);
|
||||
}
|
||||
|
||||
// Sleep (daily)
|
||||
await client.query(
|
||||
`INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at)
|
||||
VALUES ($1, $2, 'SLEEP_DURATION', $3, 'hours', 'MANUAL', $4, $4, $4)`,
|
||||
[cuid(), userId, Math.round(sleep[i] * 10) / 10, date],
|
||||
);
|
||||
// Sleep duration (daily, in minutes). This single aggregate row keeps
|
||||
// the 90-day sleep series continuous for the trend chart. The most
|
||||
// recent seven nights are instead written as per-stage breakdowns below
|
||||
// (one row per stage); skip the aggregate for those so a night is not
|
||||
// double-counted (aggregate + summed stages).
|
||||
const nightsAgo = days - i;
|
||||
if (nightsAgo >= 7) {
|
||||
await client.query(
|
||||
`INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at)
|
||||
VALUES ($1, $2, 'SLEEP_DURATION', $3, 'minutes', 'MANUAL', $4, $4, $4)`,
|
||||
[cuid(), userId, Math.round(sleepMin[i]), date],
|
||||
);
|
||||
}
|
||||
|
||||
// Steps (daily)
|
||||
await client.query(
|
||||
@@ -190,6 +299,382 @@ async function seed() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Per-stage sleep nights (last 7 nights, incl. last night) ──
|
||||
// One SLEEP_DURATION row per stage, all stamped at the same wake instant,
|
||||
// mirroring how a HealthKit night lands (AWAKE / REM / CORE / DEEP, with
|
||||
// the legacy aggregate kept above). The reader sums the stages for the
|
||||
// night's total. Durations target a healthy ~7.5 h with a normal stage
|
||||
// mix (~55% core, ~22% REM, ~18% deep, ~5% awake). Source APPLE_HEALTH
|
||||
// with device_type 'watch' so the night reads as device-tracked rather
|
||||
// than a manual estimate.
|
||||
console.log("Creating per-stage sleep nights (7 nights)...");
|
||||
const sleepStages: Array<{ stage: string; fraction: number }> = [
|
||||
{ stage: "AWAKE", fraction: 0.05 },
|
||||
{ stage: "REM", fraction: 0.22 },
|
||||
{ stage: "CORE", fraction: 0.55 },
|
||||
{ stage: "DEEP", fraction: 0.18 },
|
||||
];
|
||||
for (let n = 1; n <= 7; n++) {
|
||||
// Wake at ~06:50 with small per-night jitter; total ~7.5 h.
|
||||
const wake = daysAgoAt(n - 1, 6, 40 + Math.floor(Math.random() * 25));
|
||||
const totalMin = 440 + Math.floor(Math.random() * 40); // 7h20m – 8h
|
||||
for (const { stage, fraction } of sleepStages) {
|
||||
const stageMin = Math.round(totalMin * fraction);
|
||||
await client.query(
|
||||
`INSERT INTO measurements (id, user_id, type, value, unit, source, sleep_stage, device_type, measured_at, created_at, updated_at)
|
||||
VALUES ($1, $2, 'SLEEP_DURATION', $3, 'minutes', 'APPLE_HEALTH', $4, 'watch', $5, $5, $5)`,
|
||||
[cuid(), userId, stageMin, stage, wake],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Body composition (smart-scale series) ──
|
||||
// A full scale-style breakdown, written every 2–3 days like a real
|
||||
// body-composition scale would record. Every component is DERIVED from
|
||||
// the same-day weight + body-fat series above so the numbers reconcile:
|
||||
// fat mass = weight × bodyFat%, fat-free mass = weight − fat mass, and
|
||||
// the muscle / water / bone / BMI components hang off those so the demo
|
||||
// never contradicts its own headline weight + body-fat tiles. Source
|
||||
// APPLE_HEALTH with device_type 'scale' so they read as device-tracked.
|
||||
// Canonical units (see prisma/schema.prisma + VALUE_RANGES): mass
|
||||
// components in kg, VISCERAL_FAT a 1–12 rating, BODY_MASS_INDEX kg/m².
|
||||
console.log("Creating body composition series...");
|
||||
const HEIGHT_M = 1.82;
|
||||
for (let i = 0; i < span; i++) {
|
||||
// Roughly every other day, jittered, so the cadence reads organic.
|
||||
if (i % 2 !== 0 && Math.random() > 0.4) continue;
|
||||
const date = daysAgo(days - i);
|
||||
const weightKg = weights[i];
|
||||
const fatPct = bodyFat[i];
|
||||
const fatMass = Math.round(weightKg * (fatPct / 100) * 10) / 10;
|
||||
const fatFree = Math.round((weightKg - fatMass) * 10) / 10;
|
||||
// Skeletal muscle is a subset of fat-free mass (~75% of it for a lean
|
||||
// adult male); lean body mass ≈ fat-free mass.
|
||||
const muscleMass = Math.round(fatFree * 0.75 * 10) / 10;
|
||||
const leanMass = fatFree;
|
||||
// Total body water ≈ 73% of fat-free mass (kg of water).
|
||||
const bodyWater = Math.round(fatFree * 0.73 * 10) / 10;
|
||||
const boneMass =
|
||||
Math.round((3.1 + (Math.random() - 0.5) * 0.2) * 10) / 10;
|
||||
// Visceral-fat rating on Withings' 1–12 scale; a healthy ~6, easing
|
||||
// down slightly as the body fat trends down.
|
||||
const visceralFat = Math.round(7 - (i / span) * 1.5);
|
||||
const bmi = Math.round((weightKg / (HEIGHT_M * HEIGHT_M)) * 10) / 10;
|
||||
|
||||
const composition: Array<{ type: string; value: number; unit: string }> =
|
||||
[
|
||||
{ type: "FAT_MASS", value: fatMass, unit: "kg" },
|
||||
{ type: "FAT_FREE_MASS", value: fatFree, unit: "kg" },
|
||||
{ type: "LEAN_BODY_MASS", value: leanMass, unit: "kg" },
|
||||
{ type: "MUSCLE_MASS", value: muscleMass, unit: "kg" },
|
||||
{ type: "TOTAL_BODY_WATER", value: bodyWater, unit: "kg" },
|
||||
{ type: "BONE_MASS", value: boneMass, unit: "kg" },
|
||||
{ type: "VISCERAL_FAT", value: visceralFat, unit: "rating" },
|
||||
{ type: "BODY_MASS_INDEX", value: bmi, unit: "kg/m²" },
|
||||
];
|
||||
for (const c of composition) {
|
||||
await client.query(
|
||||
`INSERT INTO measurements (id, user_id, type, value, unit, source, device_type, measured_at, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, 'APPLE_HEALTH', 'scale', $6, $6, $6)`,
|
||||
[cuid(), userId, c.type, c.value, c.unit, date],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Blood glucose (healthy non-diabetic series) ──
|
||||
// A few readings per day over the full window, every value in a healthy
|
||||
// non-diabetic band: fasting ~85–95 mg/dL, a well-controlled post-meal
|
||||
// peak ~110–135, and a calm bedtime ~95–105. Canonical storage is mg/dL
|
||||
// (schema note on BLOOD_GLUCOSE); the per-reading context lands in the
|
||||
// glucose_context column (FASTING / POSTPRANDIAL / BEDTIME) so the
|
||||
// glucose panel can apply the right target band per reading.
|
||||
console.log("Creating blood glucose series...");
|
||||
const glucoseReadings: Array<{
|
||||
context: string;
|
||||
hour: number;
|
||||
base: number;
|
||||
jitter: number;
|
||||
}> = [
|
||||
{ context: "FASTING", hour: 7, base: 89, jitter: 6 },
|
||||
{ context: "POSTPRANDIAL", hour: 13, base: 122, jitter: 12 },
|
||||
{ context: "POSTPRANDIAL", hour: 19, base: 118, jitter: 12 },
|
||||
{ context: "BEDTIME", hour: 22, base: 99, jitter: 6 },
|
||||
];
|
||||
for (let i = 0; i < span; i++) {
|
||||
for (const r of glucoseReadings) {
|
||||
const at = daysAgoAt(days - i, r.hour, Math.floor(Math.random() * 30));
|
||||
const value = Math.round(r.base + (Math.random() - 0.5) * r.jitter);
|
||||
await client.query(
|
||||
`INSERT INTO measurements (id, user_id, type, value, unit, source, glucose_context, device_type, measured_at, created_at, updated_at)
|
||||
VALUES ($1, $2, 'BLOOD_GLUCOSE', $3, 'mg/dL', 'APPLE_HEALTH', $4, 'phone', $5, $5, $5)`,
|
||||
[cuid(), userId, value, r.context, at],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cardio fitness + vitals ───────────────
|
||||
// The dashboard + insights surface these device-tracked vitals. All
|
||||
// recent (full window, latest point today), all in healthy/aspirational
|
||||
// bands. Canonical units per schema + VALUE_RANGES: HRV ms,
|
||||
// OXYGEN_SATURATION percent, RESPIRATORY_RATE breaths/min, VO2_MAX
|
||||
// mL/(kg·min), ACTIVE_ENERGY_BURNED kcal, WALKING_RUNNING_DISTANCE metres,
|
||||
// FLIGHTS_CLIMBED count. Source APPLE_HEALTH device_type 'watch'.
|
||||
console.log("Creating cardio fitness + vitals series...");
|
||||
// HRV SDNN: a healthy ~55 ms, gently rising; also write the RMSSD variant.
|
||||
const hrvSdnn = randomWalk(
|
||||
48,
|
||||
58,
|
||||
span,
|
||||
5,
|
||||
VALUE_RANGES.HEART_RATE_VARIABILITY,
|
||||
);
|
||||
const hrvRmssd = randomWalk(42, 52, span, 5, VALUE_RANGES.HRV_RMSSD);
|
||||
// SpO2: 97–99%.
|
||||
const spo2 = randomWalk(98, 98, span, 0.8, VALUE_RANGES.OXYGEN_SATURATION);
|
||||
// Respiratory rate: 13–16 breaths/min.
|
||||
const respRate = randomWalk(
|
||||
15,
|
||||
14,
|
||||
span,
|
||||
0.8,
|
||||
VALUE_RANGES.RESPIRATORY_RATE,
|
||||
);
|
||||
// Active energy: ~550 kcal/day.
|
||||
const activeKcal = randomWalk(
|
||||
480,
|
||||
600,
|
||||
span,
|
||||
80,
|
||||
VALUE_RANGES.ACTIVE_ENERGY_BURNED,
|
||||
);
|
||||
// Walking + running distance (metres): ~6–8 km/day, tracks steps.
|
||||
const distM = randomWalk(
|
||||
6200,
|
||||
7800,
|
||||
span,
|
||||
900,
|
||||
VALUE_RANGES.WALKING_RUNNING_DISTANCE,
|
||||
);
|
||||
// Flights climbed: ~8–14/day.
|
||||
const flights = randomWalk(9, 13, span, 4, VALUE_RANGES.FLIGHTS_CLIMBED);
|
||||
|
||||
for (let i = 0; i < span; i++) {
|
||||
const date = daysAgo(days - i);
|
||||
const vitals: Array<{ type: string; value: number; unit: string }> = [
|
||||
{
|
||||
type: "HEART_RATE_VARIABILITY",
|
||||
value: Math.round(hrvSdnn[i]),
|
||||
unit: "ms",
|
||||
},
|
||||
{ type: "HRV_RMSSD", value: Math.round(hrvRmssd[i]), unit: "ms" },
|
||||
{
|
||||
type: "OXYGEN_SATURATION",
|
||||
value: Math.round(spo2[i] * 10) / 10,
|
||||
unit: "%",
|
||||
},
|
||||
{
|
||||
type: "RESPIRATORY_RATE",
|
||||
value: Math.round(respRate[i] * 10) / 10,
|
||||
unit: "count/min",
|
||||
},
|
||||
{
|
||||
type: "ACTIVE_ENERGY_BURNED",
|
||||
value: Math.round(activeKcal[i]),
|
||||
unit: "kcal",
|
||||
},
|
||||
{
|
||||
type: "WALKING_RUNNING_DISTANCE",
|
||||
value: Math.round(distM[i]),
|
||||
unit: "m",
|
||||
},
|
||||
{
|
||||
type: "FLIGHTS_CLIMBED",
|
||||
value: Math.round(flights[i]),
|
||||
unit: "count",
|
||||
},
|
||||
];
|
||||
for (const v of vitals) {
|
||||
await client.query(
|
||||
`INSERT INTO measurements (id, user_id, type, value, unit, source, device_type, measured_at, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, 'APPLE_HEALTH', 'watch', $6, $6, $6)`,
|
||||
[cuid(), userId, v.type, v.value, v.unit, date],
|
||||
);
|
||||
}
|
||||
// VO2 max: only every ~10 days (Apple samples it rarely), ~46–49.
|
||||
if (i % 10 === 0) {
|
||||
const vo2 = Math.round((46 + (i / span) * 3) * 10) / 10;
|
||||
await client.query(
|
||||
`INSERT INTO measurements (id, user_id, type, value, unit, source, device_type, measured_at, created_at, updated_at)
|
||||
VALUES ($1, $2, 'VO2_MAX', $3, 'mL/(kg·min)', 'APPLE_HEALTH', 'watch', $4, $4, $4)`,
|
||||
[cuid(), userId, vo2, date],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Recovery / strain / sleep scores (WHOOP-style) ──
|
||||
// The WHOOP-style readiness surface. These are stored as Measurement rows
|
||||
// (type RECOVERY_SCORE / DAY_STRAIN / SLEEP_PERFORMANCE / SLEEP_EFFICIENCY
|
||||
// / SLEEP_CONSISTENCY, unit 'score' or '%') with source WHOOP, distinct
|
||||
// from the COMPUTED engine's own rows. Coherent with the seeded sleep:
|
||||
// well-slept nights pair with high recovery + efficiency, and day-strain
|
||||
// sits in a sustainable band. RECOVERY_SCORE / sleep percentages are
|
||||
// 0–100; DAY_STRAIN is on WHOOP's 0–21 scale.
|
||||
console.log("Creating recovery / strain / sleep scores...");
|
||||
const recovery = randomWalk(68, 78, span, 8, VALUE_RANGES.RECOVERY_SCORE);
|
||||
const dayStrain = randomWalk(11, 13, span, 2.5, VALUE_RANGES.DAY_STRAIN);
|
||||
const sleepPerf = randomWalk(
|
||||
82,
|
||||
90,
|
||||
span,
|
||||
6,
|
||||
VALUE_RANGES.SLEEP_PERFORMANCE,
|
||||
);
|
||||
const sleepEff = randomWalk(88, 93, span, 4, VALUE_RANGES.SLEEP_EFFICIENCY);
|
||||
const sleepConsistency = randomWalk(
|
||||
74,
|
||||
84,
|
||||
span,
|
||||
6,
|
||||
VALUE_RANGES.SLEEP_CONSISTENCY,
|
||||
);
|
||||
for (let i = 0; i < span; i++) {
|
||||
// Anchor scores to the morning wake instant so a night's recovery sits
|
||||
// on the day it belongs to.
|
||||
const at = daysAgoAt(days - i, 7, 0);
|
||||
const scores: Array<{ type: string; value: number; unit: string }> = [
|
||||
{
|
||||
type: "RECOVERY_SCORE",
|
||||
value: Math.round(recovery[i]),
|
||||
unit: "score",
|
||||
},
|
||||
{
|
||||
type: "DAY_STRAIN",
|
||||
value: Math.round(dayStrain[i] * 10) / 10,
|
||||
unit: "score",
|
||||
},
|
||||
{
|
||||
type: "SLEEP_PERFORMANCE",
|
||||
value: Math.round(sleepPerf[i]),
|
||||
unit: "%",
|
||||
},
|
||||
{ type: "SLEEP_EFFICIENCY", value: Math.round(sleepEff[i]), unit: "%" },
|
||||
{
|
||||
type: "SLEEP_CONSISTENCY",
|
||||
value: Math.round(sleepConsistency[i]),
|
||||
unit: "%",
|
||||
},
|
||||
];
|
||||
for (const s of scores) {
|
||||
await client.query(
|
||||
`INSERT INTO measurements (id, user_id, type, value, unit, source, device_type, measured_at, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, 'WHOOP', 'band', $6, $6, $6)`,
|
||||
[cuid(), userId, s.type, s.value, s.unit, at],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Workouts ──────────────────────────────
|
||||
// ~3–4 sessions/week across the window: a mix of running, strength, and
|
||||
// cycling, with the fields the workout tiles render — duration, energy,
|
||||
// avg/max HR, distance (running/cycling). Each workout carries a sparse
|
||||
// HR sample series in WorkoutSamples (the per-workout HR chart) and its
|
||||
// per-workout strain in metadata (WORKOUT_STRAIN lives on the workout row
|
||||
// per the schema note, not as a free-floating Measurement). Distances in
|
||||
// metres, energy in kcal, durations in seconds — matching the model.
|
||||
console.log("Creating workouts...");
|
||||
const workoutPlan: Array<{
|
||||
sport: string;
|
||||
durationMin: number;
|
||||
kcal: number;
|
||||
avgHr: number;
|
||||
maxHr: number;
|
||||
distanceM: number | null;
|
||||
strain: number;
|
||||
}> = [
|
||||
{
|
||||
sport: "running",
|
||||
durationMin: 38,
|
||||
kcal: 420,
|
||||
avgHr: 148,
|
||||
maxHr: 171,
|
||||
distanceM: 6500,
|
||||
strain: 13.4,
|
||||
},
|
||||
{
|
||||
sport: "strength",
|
||||
durationMin: 52,
|
||||
kcal: 360,
|
||||
avgHr: 118,
|
||||
maxHr: 152,
|
||||
distanceM: null,
|
||||
strain: 10.2,
|
||||
},
|
||||
{
|
||||
sport: "cycling",
|
||||
durationMin: 65,
|
||||
kcal: 520,
|
||||
avgHr: 134,
|
||||
maxHr: 158,
|
||||
distanceM: 24000,
|
||||
strain: 12.1,
|
||||
},
|
||||
];
|
||||
// Step backwards through the window placing ~2 sessions a week per slot
|
||||
// so the cadence lands around 3–4/week without overlapping.
|
||||
let planIdx = 0;
|
||||
for (let dayOffset = days - 1; dayOffset >= 1; dayOffset -= 2) {
|
||||
// Skip ~25% of slots so the week isn't perfectly regular.
|
||||
if (Math.random() < 0.25) continue;
|
||||
const plan = workoutPlan[planIdx % workoutPlan.length];
|
||||
planIdx += 1;
|
||||
const startHour = 17 + Math.floor(Math.random() * 3);
|
||||
const startedAt = daysAgoAt(
|
||||
dayOffset,
|
||||
startHour,
|
||||
Math.floor(Math.random() * 50),
|
||||
);
|
||||
const durationSec = plan.durationMin * 60;
|
||||
const endedAt = new Date(startedAt.getTime() + durationSec * 1000);
|
||||
const workoutId = cuid();
|
||||
await client.query(
|
||||
`INSERT INTO workouts (id, user_id, sport_type, started_at, ended_at, duration_sec, total_energy_kcal, total_distance_m, avg_heart_rate, max_heart_rate, source, metadata, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'APPLE_HEALTH', $11, $4, $4)`,
|
||||
[
|
||||
workoutId,
|
||||
userId,
|
||||
plan.sport,
|
||||
startedAt,
|
||||
endedAt,
|
||||
durationSec,
|
||||
plan.kcal,
|
||||
plan.distanceM,
|
||||
plan.avgHr,
|
||||
plan.maxHr,
|
||||
JSON.stringify({ workoutStrain: plan.strain }),
|
||||
],
|
||||
);
|
||||
// A sparse HR series — one sample every ~5 minutes across the session,
|
||||
// rising into the working band and easing at the end. The per-workout
|
||||
// HR chart renders straight from this WorkoutSamples blob.
|
||||
const sampleCount = Math.max(2, Math.round(plan.durationMin / 5));
|
||||
const samples: Array<{ t: string; hr: number }> = [];
|
||||
for (let s = 0; s < sampleCount; s++) {
|
||||
const frac = s / (sampleCount - 1);
|
||||
// Warm up from ~60% to the avg, peak near the max around 70% through.
|
||||
const peakBias = 1 - Math.abs(frac - 0.7) * 0.6;
|
||||
const hr = Math.round(
|
||||
plan.avgHr + (plan.maxHr - plan.avgHr) * peakBias * 0.7,
|
||||
);
|
||||
const t = new Date(startedAt.getTime() + frac * durationSec * 1000);
|
||||
samples.push({ t: t.toISOString(), hr });
|
||||
}
|
||||
await client.query(
|
||||
`INSERT INTO workout_samples (id, workout_id, samples, sample_count, created_at)
|
||||
VALUES ($1, $2, $3, $4, NOW())`,
|
||||
[cuid(), workoutId, JSON.stringify(samples), samples.length],
|
||||
);
|
||||
}
|
||||
|
||||
// ── Medications ───────────────────────────
|
||||
console.log("Creating medications...");
|
||||
|
||||
@@ -211,7 +696,7 @@ async function seed() {
|
||||
const med2Id = cuid();
|
||||
await client.query(
|
||||
`INSERT INTO medications (id, user_id, name, dose, active, notifications_enabled, created_at, updated_at)
|
||||
VALUES ($1, $2, 'Vitamin D3', '2000 IE', true, true, $3, $3)`,
|
||||
VALUES ($1, $2, 'Vitamin D3', '2000 IU', true, true, $3, $3)`,
|
||||
[med2Id, userId, daysAgo(90)],
|
||||
);
|
||||
const sched2Id = cuid();
|
||||
@@ -236,13 +721,17 @@ async function seed() {
|
||||
);
|
||||
|
||||
// ── Medication Intake Events ──────────────
|
||||
// High compliance with no overdue doses. Historical days (i < days) carry
|
||||
// a taken/skip row at ~95%+ adherence. TODAY is handled separately below
|
||||
// so the dashboard always reads on-track (morning doses taken, the
|
||||
// evening dose left as a not-yet-due pending row — never a miss).
|
||||
console.log("Creating intake events (90 days)...");
|
||||
|
||||
for (let i = 0; i < days; i++) {
|
||||
const date = daysAgo(days - i);
|
||||
|
||||
// Ramipril: 96% compliance (miss ~1 every 25 days)
|
||||
const takeMed1 = Math.random() > 0.04;
|
||||
// Ramipril: 98% compliance (miss ~1 every 50 days)
|
||||
const takeMed1 = Math.random() > 0.02;
|
||||
if (takeMed1) {
|
||||
const takenTime = new Date(date);
|
||||
takenTime.setHours(8, Math.floor(Math.random() * 45), 0);
|
||||
@@ -259,8 +748,8 @@ async function seed() {
|
||||
);
|
||||
}
|
||||
|
||||
// Vitamin D3: 92% compliance
|
||||
const takeMed2 = Math.random() > 0.08;
|
||||
// Vitamin D3: 95% compliance
|
||||
const takeMed2 = Math.random() > 0.05;
|
||||
if (takeMed2) {
|
||||
const takenTime = new Date(date);
|
||||
takenTime.setHours(8, Math.floor(5 + Math.random() * 50), 0);
|
||||
@@ -277,9 +766,9 @@ async function seed() {
|
||||
);
|
||||
}
|
||||
|
||||
// Magnesium: only last 60 days, 88% compliance
|
||||
// Magnesium: only last 60 days, 94% compliance
|
||||
if (i >= 30) {
|
||||
const takeMed3 = Math.random() > 0.12;
|
||||
const takeMed3 = Math.random() > 0.06;
|
||||
if (takeMed3) {
|
||||
const takenTime = new Date(date);
|
||||
takenTime.setHours(20, Math.floor(Math.random() * 60), 0);
|
||||
@@ -298,6 +787,31 @@ async function seed() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Today's doses (on-track, no overdue) ──
|
||||
// Morning meds: taken inside their 08:00–10:00 window. Evening Magnesium:
|
||||
// a pending row anchored on tonight's 20:00 slot, never auto-missed — it
|
||||
// reads as "not yet due" regardless of the seed wall-clock, and the
|
||||
// compliance engine excludes a plain pending row from the denominator, so
|
||||
// "today" never shows 0% or an overdue dose.
|
||||
console.log("Creating today's on-track doses...");
|
||||
const todayMorning = daysAgoAt(0, 8, 30);
|
||||
await client.query(
|
||||
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $4, false, 'WEB', $4, $4)`,
|
||||
[cuid(), userId, med1Id, todayMorning],
|
||||
);
|
||||
await client.query(
|
||||
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $4, false, 'WEB', $4, $4)`,
|
||||
[cuid(), userId, med2Id, todayMorning],
|
||||
);
|
||||
const todayEvening = daysAgoAt(0, 20, 0);
|
||||
await client.query(
|
||||
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, auto_missed, source, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, NULL, false, false, 'WEB', $4, $4)`,
|
||||
[cuid(), userId, med3Id, todayEvening],
|
||||
);
|
||||
|
||||
// ── Mood Entries ─────────────────────────
|
||||
console.log("Creating mood entries (90 days)...");
|
||||
|
||||
@@ -310,10 +824,11 @@ async function seed() {
|
||||
["exhausted", "pain"],
|
||||
];
|
||||
|
||||
// Mood trend: starts around 3, improves to ~4 over time
|
||||
const moodTrend = randomWalk(3.0, 4.2, days, 0.8);
|
||||
// Mood trend: starts solid (~3.5) and improves to ~4.4 — flat-or-up, so
|
||||
// the score never dips into an alarming band.
|
||||
const moodTrend = randomWalk(3.5, 4.4, span, 0.7);
|
||||
|
||||
for (let i = 0; i < days; i++) {
|
||||
for (let i = 0; i < span; i++) {
|
||||
// Skip ~5% of days (forgot to log)
|
||||
if (Math.random() < 0.05) continue;
|
||||
|
||||
@@ -339,22 +854,121 @@ async function seed() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Vorsorge reminders (preventive care) ──
|
||||
// User-created (origin VORSORGE) checklist reminders with no auto-resolve
|
||||
// measurement target. Both anchored to fire comfortably in the future so
|
||||
// the Vorsorge surface shows sensible upcoming items, not overdue ones.
|
||||
console.log("Creating Vorsorge reminders...");
|
||||
const dentalNextDue = daysAgo(-45); // ~6 weeks out
|
||||
await client.query(
|
||||
`INSERT INTO measurement_reminders (id, user_id, label, interval_days, anchor_date, origin, notify_hour, location, next_due_at, created_at, updated_at)
|
||||
VALUES ($1, $2, 'Dental check-up', 365, $3, 'VORSORGE', 9, 'Dentist', $4, NOW(), NOW())`,
|
||||
[cuid(), userId, daysAgo(320), dentalNextDue],
|
||||
);
|
||||
const physicalNextDue = daysAgo(-90); // ~3 months out
|
||||
await client.query(
|
||||
`INSERT INTO measurement_reminders (id, user_id, label, interval_days, anchor_date, origin, notify_hour, location, next_due_at, created_at, updated_at)
|
||||
VALUES ($1, $2, 'Annual physical', 365, $3, 'VORSORGE', 9, 'Family doctor', $4, NOW(), NOW())`,
|
||||
[cuid(), userId, daysAgo(275), physicalNextDue],
|
||||
);
|
||||
|
||||
// ── Lab panel (biomarkers in healthy ranges) ──
|
||||
// One grouped panel taken ~10 days ago, every analyte comfortably inside
|
||||
// its reference range, so the labs surface reads clean.
|
||||
console.log("Creating lab panel...");
|
||||
const panelTakenAt = daysAgo(10);
|
||||
const labResults: Array<{
|
||||
analyte: string;
|
||||
value: number;
|
||||
unit: string;
|
||||
low: number;
|
||||
high: number;
|
||||
}> = [
|
||||
{
|
||||
analyte: "Total Cholesterol",
|
||||
value: 178,
|
||||
unit: "mg/dL",
|
||||
low: 0,
|
||||
high: 200,
|
||||
},
|
||||
{ analyte: "LDL", value: 98, unit: "mg/dL", low: 0, high: 130 },
|
||||
{ analyte: "HDL", value: 58, unit: "mg/dL", low: 40, high: 100 },
|
||||
{ analyte: "Triglycerides", value: 92, unit: "mg/dL", low: 0, high: 150 },
|
||||
{ analyte: "HbA1c", value: 5.2, unit: "%", low: 4.0, high: 5.6 },
|
||||
{
|
||||
analyte: "Fasting Glucose",
|
||||
value: 89,
|
||||
unit: "mg/dL",
|
||||
low: 70,
|
||||
high: 99,
|
||||
},
|
||||
{ analyte: "Vitamin D", value: 42, unit: "ng/mL", low: 30, high: 100 },
|
||||
{ analyte: "TSH", value: 1.8, unit: "mIU/L", low: 0.4, high: 4.0 },
|
||||
{ analyte: "Ferritin", value: 120, unit: "ng/mL", low: 30, high: 400 },
|
||||
];
|
||||
for (const lab of labResults) {
|
||||
await client.query(
|
||||
`INSERT INTO lab_results (id, user_id, panel, analyte, value, unit, reference_low, reference_high, taken_at, source, created_at, updated_at)
|
||||
VALUES ($1, $2, 'Annual blood panel', $3, $4, $5, $6, $7, $8, 'MANUAL', NOW(), NOW())`,
|
||||
[
|
||||
cuid(),
|
||||
userId,
|
||||
lab.analyte,
|
||||
lab.value,
|
||||
lab.unit,
|
||||
lab.low,
|
||||
lab.high,
|
||||
panelTakenAt,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// ── Resolved illness episode ──────────────
|
||||
// A short cold ~7 weeks ago, fully recovered, so the illness journal shows
|
||||
// a clean "resolved" entry rather than an active sickness.
|
||||
console.log("Creating resolved illness episode...");
|
||||
await client.query(
|
||||
`INSERT INTO illness_episodes (id, user_id, label, type, lifecycle, onset_at, resolved_at, created_at, updated_at)
|
||||
VALUES ($1, $2, 'Common cold', 'INFECTION', 'ACUTE', $3, $4, NOW(), NOW())`,
|
||||
[cuid(), userId, daysAgo(49), daysAgo(43)],
|
||||
);
|
||||
|
||||
// ── Achievements ─────────────────────────
|
||||
console.log("Creating achievements...");
|
||||
|
||||
// Every id below is a real key from the achievement registry
|
||||
// (src/lib/gamification/achievements.ts) so the badge resolves to a real
|
||||
// title/icon rather than an "unknown" placeholder. Only milestones the
|
||||
// seeded history genuinely supports are unlocked (first-entry badges,
|
||||
// logging streaks, in-range BP/pulse/BMI streaks, compliance + miss-free
|
||||
// streaks, the self-context + 7-night sleep badges). The dynamic streak
|
||||
// families use the `<prefix>-<target>` naming the generator emits.
|
||||
const achievements = [
|
||||
{ id: "first-measurement", daysAgo: 89 },
|
||||
{ id: "first-medication", daysAgo: 89 },
|
||||
{ id: "first-mood", daysAgo: 89 },
|
||||
{ id: "week-streak-measurements", daysAgo: 82 },
|
||||
{ id: "week-streak-mood", daysAgo: 82 },
|
||||
{ id: "month-streak-measurements", daysAgo: 59 },
|
||||
{ id: "compliance-90", daysAgo: 45 },
|
||||
{ id: "weight-loss-5", daysAgo: 30 },
|
||||
{ id: "bp-normal", daysAgo: 20 },
|
||||
{ id: "steps-10k", daysAgo: 15 },
|
||||
{ id: "month-streak-mood", daysAgo: 10 },
|
||||
{ id: "three-month-streak", daysAgo: 2 },
|
||||
{ id: "weight-first", daysAgo: 89 },
|
||||
{ id: "bp-first", daysAgo: 89 },
|
||||
{ id: "pulse-first", daysAgo: 89 },
|
||||
{ id: "mood-first", daysAgo: 89 },
|
||||
{ id: "self-context-complete", daysAgo: 88 },
|
||||
{ id: "entry-streak-7", daysAgo: 82 },
|
||||
{ id: "mood-streak-7", daysAgo: 82 },
|
||||
{ id: "sleep-log-7", daysAgo: 70 },
|
||||
{ id: "mood-up-7", daysAgo: 68 },
|
||||
{ id: "weight-50", daysAgo: 60 },
|
||||
{ id: "bp-50", daysAgo: 55 },
|
||||
{ id: "entry-streak-30", daysAgo: 50 },
|
||||
{ id: "mood-streak-30", daysAgo: 50 },
|
||||
{ id: "miss-free-7", daysAgo: 48 },
|
||||
{ id: "consistent-month", daysAgo: 45 },
|
||||
{ id: "bp-green-7", daysAgo: 42 },
|
||||
{ id: "pulse-green-7", daysAgo: 40 },
|
||||
{ id: "bmi-green-7", daysAgo: 38 },
|
||||
{ id: "compliance-80-30", daysAgo: 30 },
|
||||
{ id: "miss-free-30", daysAgo: 30 },
|
||||
{ id: "on-time-perfect-7", daysAgo: 28 },
|
||||
{ id: "measurement-weeks-4", daysAgo: 25 },
|
||||
{ id: "bp-green-30", daysAgo: 12 },
|
||||
{ id: "pulse-green-30", daysAgo: 10 },
|
||||
{ id: "miss-free-90", daysAgo: 2 },
|
||||
];
|
||||
|
||||
for (const ach of achievements) {
|
||||
@@ -366,6 +980,64 @@ async function seed() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Coach sample conversation ─────────────
|
||||
// A short, encrypted two-turn exchange so the Coach surface shows real
|
||||
// content. Messages use the same AES-256-GCM Bytes codec the app writes
|
||||
// with (encryptToBytes → encrypt() under ENCRYPTION_KEYS), so they decrypt
|
||||
// exactly like a live conversation — no faked / plaintext rows.
|
||||
console.log("Creating Coach sample conversation...");
|
||||
const convoId = cuid();
|
||||
const convoStart = daysAgo(3);
|
||||
await client.query(
|
||||
`INSERT INTO coach_conversations (id, user_id, title, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $4)`,
|
||||
[convoId, userId, "Blood pressure trend", convoStart],
|
||||
);
|
||||
|
||||
const coachTurns: Array<{
|
||||
role: string;
|
||||
content: string;
|
||||
metricSourceJson?: string;
|
||||
}> = [
|
||||
{
|
||||
role: "user",
|
||||
content: "How has my blood pressure been trending lately?",
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content:
|
||||
"Your blood pressure has been settling nicely. Over the last few " +
|
||||
"weeks the morning readings have drifted into the optimal band, and " +
|
||||
"your resting heart rate is sitting in a comfortable range too. " +
|
||||
"Keeping up the steady sleep and daily movement looks like it is " +
|
||||
"paying off — worth staying consistent with the morning measurements " +
|
||||
"so the trend stays easy to read.",
|
||||
metricSourceJson: JSON.stringify({
|
||||
window: "last30days",
|
||||
metrics: ["bloodPressure", "restingHeartRate", "sleep"],
|
||||
}),
|
||||
},
|
||||
];
|
||||
for (let t = 0; t < coachTurns.length; t++) {
|
||||
const turn = coachTurns[t];
|
||||
const ts = new Date(convoStart.getTime() + t * 60_000);
|
||||
const encrypted = Buffer.from(encryptToBytes(turn.content));
|
||||
await client.query(
|
||||
`INSERT INTO coach_messages (id, conversation_id, role, encrypted_content, metric_source_json, provider_type, prompt_version, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
[
|
||||
cuid(),
|
||||
convoId,
|
||||
turn.role,
|
||||
encrypted,
|
||||
turn.metricSourceJson ?? null,
|
||||
turn.role === "assistant" ? aiProvider : null,
|
||||
turn.role === "assistant" ? "demo" : null,
|
||||
ts,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// ── App Settings ─────────────────────────
|
||||
console.log("Creating app settings...");
|
||||
await client.query(
|
||||
@@ -388,9 +1060,33 @@ async function seed() {
|
||||
await client.query("COMMIT");
|
||||
console.log("\nDemo data seeded successfully!");
|
||||
console.log(` User: demo / demo123demo123`);
|
||||
console.log(` Measurements: ${days * 6} entries (7 types, 90 days)`);
|
||||
console.log(` Medications: 3 (with ${days * 2 + 60} intake events)`);
|
||||
console.log(` Mood: ~${Math.round(days * 0.95)} entries`);
|
||||
console.log(
|
||||
` Measurements: weight, BP, pulse, resting HR, body fat, steps, sleep`,
|
||||
);
|
||||
console.log(
|
||||
` Body composition: fat/lean/muscle mass, water, bone, visceral fat, BMI`,
|
||||
);
|
||||
console.log(
|
||||
` Blood glucose: ${glucoseReadings.length} readings/day (fasting + post-meal + bedtime)`,
|
||||
);
|
||||
console.log(
|
||||
` Cardio + vitals: HRV (SDNN + RMSSD), SpO2, resp. rate, VO2max, active energy, distance, flights`,
|
||||
);
|
||||
console.log(
|
||||
` Scores: recovery, day strain, sleep performance/efficiency/consistency`,
|
||||
);
|
||||
console.log(
|
||||
` Sleep: 7 per-stage nights (4 stages each) + daily aggregate`,
|
||||
);
|
||||
console.log(` Medications: 3 (high compliance, today on-track)`);
|
||||
console.log(
|
||||
` Workouts: ~3-4/week (running, strength, cycling) with HR samples`,
|
||||
);
|
||||
console.log(` Mood: ~${Math.round(span * 0.95)} entries`);
|
||||
console.log(` Vorsorge reminders: 2 (dental, annual physical)`);
|
||||
console.log(` Lab panel: ${labResults.length} biomarkers`);
|
||||
console.log(` Illness: 1 resolved episode`);
|
||||
console.log(` Coach: 1 conversation (${coachTurns.length} messages)`);
|
||||
console.log(` Achievements: ${achievements.length}`);
|
||||
} catch (err) {
|
||||
await client.query("ROLLBACK");
|
||||
|
||||
@@ -64,9 +64,9 @@ vi.mock("next/headers", () => ({
|
||||
// AsyncLocalStorage `eventStorage.run` wiring intact (the
|
||||
// `apiHandler` opens an event scope inside `run()`).
|
||||
vi.mock("@/lib/logging/context", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@/lib/logging/context")
|
||||
>("@/lib/logging/context");
|
||||
const actual = await vi.importActual<typeof import("@/lib/logging/context")>(
|
||||
"@/lib/logging/context",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
annotate: vi.fn(actual.annotate),
|
||||
@@ -153,24 +153,18 @@ beforeEach(async () => {
|
||||
// `clearAllMocks()` to wipe call history between tests; `vi.fn()` at
|
||||
// module scope only sets the initial implementation, and the slim
|
||||
// slice test must observe a resolved value not `undefined`.
|
||||
const { ensureUserRollupsFresh } = await import(
|
||||
"@/lib/rollups/measurement-rollups"
|
||||
);
|
||||
const { probeRollupCoverage } = await import(
|
||||
"@/lib/rollups/measurement-coverage"
|
||||
);
|
||||
const { computeBpInTargetFastPath } = await import(
|
||||
"@/lib/analytics/bp-in-target-fast-path"
|
||||
);
|
||||
const { computeUserHealthScoreFastPath } = await import(
|
||||
"@/lib/analytics/health-score-fast-path"
|
||||
);
|
||||
const { computeCorrelationHypothesesFastPath } = await import(
|
||||
"@/lib/analytics/correlations-fast-path"
|
||||
);
|
||||
const { computeSummariesSlice } = await import(
|
||||
"@/lib/analytics/summaries-slice"
|
||||
);
|
||||
const { ensureUserRollupsFresh } =
|
||||
await import("@/lib/rollups/measurement-rollups");
|
||||
const { probeRollupCoverage } =
|
||||
await import("@/lib/rollups/measurement-coverage");
|
||||
const { computeBpInTargetFastPath } =
|
||||
await import("@/lib/analytics/bp-in-target-fast-path");
|
||||
const { computeUserHealthScoreFastPath } =
|
||||
await import("@/lib/analytics/health-score-fast-path");
|
||||
const { computeCorrelationHypothesesFastPath } =
|
||||
await import("@/lib/analytics/correlations-fast-path");
|
||||
const { computeSummariesSlice } =
|
||||
await import("@/lib/analytics/summaries-slice");
|
||||
|
||||
(ensureUserRollupsFresh as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
undefined,
|
||||
@@ -185,9 +179,9 @@ beforeEach(async () => {
|
||||
priorMonth: null,
|
||||
priorYear: null,
|
||||
});
|
||||
(computeUserHealthScoreFastPath as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
null,
|
||||
);
|
||||
(
|
||||
computeUserHealthScoreFastPath as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue(null);
|
||||
(
|
||||
computeCorrelationHypothesesFastPath as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue({ hypotheses: [] });
|
||||
|
||||
@@ -62,10 +62,7 @@ vi.mock("next/headers", () => ({
|
||||
|
||||
import { POST } from "@/app/api/auth/check-user/route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
checkRateLimit,
|
||||
checkAuthSurfaceRateLimit,
|
||||
} from "@/lib/rate-limit";
|
||||
import { checkRateLimit, checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
interface CheckUserBody {
|
||||
data: {
|
||||
|
||||
@@ -27,12 +27,7 @@ const ROOT = join(__dirname, "../..");
|
||||
const SRC = join(ROOT, "src");
|
||||
const EN_BUNDLE_PATH = join(ROOT, "messages/en.json");
|
||||
|
||||
const SKIP_DIRS = new Set([
|
||||
"__tests__",
|
||||
"node_modules",
|
||||
".next",
|
||||
"generated",
|
||||
]);
|
||||
const SKIP_DIRS = new Set(["__tests__", "node_modules", ".next", "generated"]);
|
||||
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
for (const name of readdirSync(dir)) {
|
||||
@@ -48,8 +43,7 @@ function walk(dir: string, out: string[] = []): string[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
const KEY_PATTERN =
|
||||
/(?<![a-zA-Z0-9_])t\(\s*["']([a-zA-Z][a-zA-Z0-9_.]+)["']/g;
|
||||
const KEY_PATTERN = /(?<![a-zA-Z0-9_])t\(\s*["']([a-zA-Z][a-zA-Z0-9_.]+)["']/g;
|
||||
|
||||
interface CallSite {
|
||||
key: string;
|
||||
@@ -98,7 +92,10 @@ function extractKeys(): CallSite[] {
|
||||
inBlockComment = true;
|
||||
break;
|
||||
}
|
||||
line = line.slice(0, start) + " ".repeat(end - start + 2) + line.slice(end + 2);
|
||||
line =
|
||||
line.slice(0, start) +
|
||||
" ".repeat(end - start + 2) +
|
||||
line.slice(end + 2);
|
||||
}
|
||||
line = stripCommentLine(line);
|
||||
let m: RegExpExecArray | null;
|
||||
@@ -119,7 +116,11 @@ function resolveLeaf(obj: unknown, dotted: string): string | null {
|
||||
const parts = dotted.split(".");
|
||||
let cur: unknown = obj;
|
||||
for (const p of parts) {
|
||||
if (cur && typeof cur === "object" && p in (cur as Record<string, unknown>)) {
|
||||
if (
|
||||
cur &&
|
||||
typeof cur === "object" &&
|
||||
p in (cur as Record<string, unknown>)
|
||||
) {
|
||||
cur = (cur as Record<string, unknown>)[p];
|
||||
} else {
|
||||
return null;
|
||||
|
||||
@@ -30,11 +30,7 @@ function loadBundle(locale: string): Record<string, unknown> {
|
||||
return JSON.parse(readFileSync(join(MESSAGES, `${locale}.json`), "utf8"));
|
||||
}
|
||||
|
||||
function flatten(
|
||||
obj: unknown,
|
||||
prefix: string,
|
||||
out: Map<string, string>,
|
||||
): void {
|
||||
function flatten(obj: unknown, prefix: string, out: Map<string, string>): void {
|
||||
if (obj == null || typeof obj !== "object") return;
|
||||
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
|
||||
const key = prefix ? `${prefix}.${k}` : k;
|
||||
|
||||
@@ -186,7 +186,11 @@ function isReferenced(leaf: string, ref: Reference): boolean {
|
||||
}
|
||||
}
|
||||
for (const ck of ref.literalKeys) {
|
||||
if (base === ck || base.startsWith(`${ck}.`) || ck.startsWith(`${base}.`)) {
|
||||
if (
|
||||
base === ck ||
|
||||
base.startsWith(`${ck}.`) ||
|
||||
ck.startsWith(`${base}.`)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,9 +42,7 @@ describe("proxy.ts DEMO_MODE mutation allowlist", () => {
|
||||
});
|
||||
|
||||
it("permits the chart-overlay-prefs PUT (above-chart display toggles)", () => {
|
||||
const res = proxy(
|
||||
makeRequest("/api/dashboard/chart-overlay-prefs", "PUT"),
|
||||
);
|
||||
const res = proxy(makeRequest("/api/dashboard/chart-overlay-prefs", "PUT"));
|
||||
expect(res.status).not.toBe(403);
|
||||
});
|
||||
|
||||
|
||||
@@ -324,7 +324,9 @@ describe("sw.js — trimCache", () => {
|
||||
store.keysCalls = 0;
|
||||
|
||||
const trimCache = (
|
||||
harness.context as { trimCache?: (name: string, max: number) => Promise<void> }
|
||||
harness.context as {
|
||||
trimCache?: (name: string, max: number) => Promise<void>;
|
||||
}
|
||||
).trimCache;
|
||||
expect(typeof trimCache).toBe("function");
|
||||
await trimCache!(CURRENT_PAGE_CACHE, 5);
|
||||
@@ -340,10 +342,7 @@ describe("sw.js — trimCache", () => {
|
||||
});
|
||||
|
||||
// ── push handler (v1.18.4: clear-on-taken tag close + app badge) ─────────────
|
||||
async function dispatchPush(
|
||||
harness: SwHarness,
|
||||
data: unknown,
|
||||
): Promise<void> {
|
||||
async function dispatchPush(harness: SwHarness, data: unknown): Promise<void> {
|
||||
let settled: Promise<unknown> = Promise.resolve();
|
||||
harness.listeners.get("push")!({
|
||||
data: { json: () => data, text: () => JSON.stringify(data) },
|
||||
|
||||
@@ -147,12 +147,8 @@ describe("dashboard chart-reveal wiring (page.tsx)", () => {
|
||||
const src = readFileSync(PAGE_PATH, "utf8");
|
||||
|
||||
it("derives the gated id list from the chart visibility gates", () => {
|
||||
expect(src).toMatch(
|
||||
/const\s+revealChartIds:\s*string\[\]\s*=\s*\[\];/,
|
||||
);
|
||||
expect(src).toMatch(
|
||||
/useDashboardChartReveal\(revealChartIds\)/,
|
||||
);
|
||||
expect(src).toMatch(/const\s+revealChartIds:\s*string\[\]\s*=\s*\[\];/);
|
||||
expect(src).toMatch(/useDashboardChartReveal\(revealChartIds\)/);
|
||||
});
|
||||
|
||||
it("every data-backed chart reports through onDataReady", () => {
|
||||
|
||||
@@ -243,9 +243,7 @@ describe("v1.4.43 W11 — dashboard tile-strip skeleton (wiring)", () => {
|
||||
|
||||
it("uses the structured silhouette as the per-tile Suspense fallback", () => {
|
||||
const src = readFileSync(PAGE_PATH, "utf8");
|
||||
expect(src).toMatch(
|
||||
/<Suspense fallback=\{<TrendCardSkeleton \/>\}>/,
|
||||
);
|
||||
expect(src).toMatch(/<Suspense fallback=\{<TrendCardSkeleton \/>\}>/);
|
||||
});
|
||||
|
||||
it("gates the EmptyState on the resolved `showEmptyState` flag", () => {
|
||||
|
||||
@@ -68,33 +68,29 @@ describe("v1.4.27 R3d MB4 — the layout mounts the Coach drawer", () => {
|
||||
const shellSource = load(
|
||||
`${process.cwd()}/src/components/layout/auth-shell.tsx`,
|
||||
);
|
||||
expect(shellSource).toContain(
|
||||
'from "@/lib/insights/coach-launch-context"',
|
||||
);
|
||||
expect(shellSource).toContain('from "@/lib/insights/coach-launch-context"');
|
||||
expect(shellSource).toMatch(/<CoachLaunchProvider>/);
|
||||
expect(shellSource).toMatch(/<LayoutCoachMount \/>/);
|
||||
});
|
||||
|
||||
it("the insights layout no longer double-mounts the provider or drawer", () => {
|
||||
const layout = load(INSIGHTS_LAYOUT_PATH);
|
||||
expect(layout).not.toContain(
|
||||
'from "@/lib/insights/coach-launch-context"',
|
||||
);
|
||||
expect(layout).not.toContain('from "@/lib/insights/coach-launch-context"');
|
||||
expect(layout).not.toContain("import { LayoutCoachMount }");
|
||||
// FAB stays scoped to `/insights/**` because the floating button
|
||||
// would distract on surfaces with a contextual inline CTA.
|
||||
expect(layout).toContain("LayoutCoachFab");
|
||||
});
|
||||
|
||||
it("hero strip's onAskCoach handler calls the launch context", () => {
|
||||
it("the overview no longer wires the inline ask-the-Coach affordance", () => {
|
||||
const src = load(INSIGHTS_PATH);
|
||||
// The hero supplies onAskCoach which now delegates to
|
||||
// `coachLaunch.askCoach(prefill)` from the layout-level provider
|
||||
// instead of toggling local state.
|
||||
expect(src).toMatch(/<HeroStrip[\s\S]*?onAskCoach=\{/);
|
||||
expect(src).toMatch(/<HeroStrip[\s\S]*?onPickPrompt=\{/);
|
||||
expect(src).toContain("useCoachLaunch");
|
||||
expect(src).toMatch(/coachLaunch\.askCoach/);
|
||||
// v1.18.7 — the inline "ask the Coach / suggested questions" block was
|
||||
// removed from the overview hero. The Coach is reached via the FAB +
|
||||
// bottom-right drawer, so the hero no longer carries onAskCoach/onPickPrompt
|
||||
// and the page no longer pulls the launch context.
|
||||
expect(src).not.toMatch(/<HeroStrip[\s\S]*?onAskCoach=\{/);
|
||||
expect(src).not.toMatch(/<HeroStrip[\s\S]*?onPickPrompt=\{/);
|
||||
expect(src).not.toContain("useCoachLaunch");
|
||||
});
|
||||
|
||||
it("the page no longer owns coachOpen + coachPrefill local state", () => {
|
||||
|
||||
@@ -50,7 +50,7 @@ function Section({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section id={id} className="space-y-3 scroll-mt-28">
|
||||
<section id={id} className="scroll-mt-28 space-y-3">
|
||||
<h2 className="text-xl font-semibold tracking-tight md:text-2xl">
|
||||
{title}
|
||||
</h2>
|
||||
@@ -64,7 +64,7 @@ function Section({
|
||||
export default function AboutPage() {
|
||||
return (
|
||||
<div className="bg-background text-foreground min-h-dvh">
|
||||
<header className="border-border/60 bg-background/80 sticky top-0 z-10 border-b backdrop-blur pt-[env(safe-area-inset-top)]">
|
||||
<header className="border-border/60 bg-background/80 sticky top-0 z-10 border-b pt-[env(safe-area-inset-top)] backdrop-blur">
|
||||
<div className="mx-auto flex max-w-3xl items-center justify-between px-4 py-3 md:px-6">
|
||||
<Link
|
||||
href="/"
|
||||
@@ -122,8 +122,8 @@ export default function AboutPage() {
|
||||
<Section id="credits" title="Credits">
|
||||
<p>
|
||||
HealthLog stands on a number of open-source libraries and public
|
||||
data sources. The list below covers the third-party assets that
|
||||
ship with the runtime image and whose licences require an explicit
|
||||
data sources. The list below covers the third-party assets that ship
|
||||
with the runtime image and whose licences require an explicit
|
||||
attribution.
|
||||
</p>
|
||||
|
||||
@@ -150,23 +150,22 @@ export default function AboutPage() {
|
||||
GeoLite2-ASN
|
||||
</code>
|
||||
) power the offline IP-to-location and IP-to-carrier lookups that
|
||||
decorate the admin login-overview audit table. They are
|
||||
distributed under the{" "}
|
||||
decorate the admin login-overview audit table. They are distributed
|
||||
under the{" "}
|
||||
<a
|
||||
href="https://creativecommons.org/licenses/by-sa/4.0/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Creative Commons Attribution-ShareAlike 4.0 International
|
||||
License
|
||||
Creative Commons Attribution-ShareAlike 4.0 International License
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<footer
|
||||
className="border-border/60 mt-12 border-t pt-6 text-xs text-muted-foreground"
|
||||
className="border-border/60 text-muted-foreground mt-12 border-t pt-6 text-xs"
|
||||
data-slot="about-footer"
|
||||
>
|
||||
<p>
|
||||
|
||||
@@ -222,11 +222,7 @@ function AchievementCard({ achievement, t }: AchievementCardProps) {
|
||||
value={achievement.progressPercent}
|
||||
className="h-1.5"
|
||||
aria-label={t("achievements.criterionHint", {
|
||||
current: formatMetric(
|
||||
achievement.format,
|
||||
achievement.current,
|
||||
t,
|
||||
),
|
||||
current: formatMetric(achievement.format, achievement.current, t),
|
||||
target: formatMetric(achievement.format, achievement.target, t),
|
||||
})}
|
||||
/>
|
||||
@@ -329,8 +325,7 @@ export default function AchievementsPage() {
|
||||
{t("achievements.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2"></div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
|
||||
@@ -268,7 +268,10 @@ function fileHasCall(text: string, needle: string): boolean {
|
||||
* parameterised insights routes resolve it from a metric map — those gate
|
||||
* dynamically and are accepted as gated without a literal-key assertion).
|
||||
*/
|
||||
function extractModuleKeys(text: string): { hasLiteral: boolean; keys: Set<string> } {
|
||||
function extractModuleKeys(text: string): {
|
||||
hasLiteral: boolean;
|
||||
keys: Set<string>;
|
||||
} {
|
||||
const keys = new Set<string>();
|
||||
let hasLiteral = false;
|
||||
const re = /requireModuleEnabled\([^,]+,\s*"([a-zA-Z]+)"\s*\)/g;
|
||||
|
||||
@@ -120,14 +120,14 @@ describe("GET /api/admin/ai-settings", () => {
|
||||
|
||||
describe("PUT /api/admin/ai-settings", () => {
|
||||
function upsertEcho() {
|
||||
vi.mocked(prisma.appSettings.upsert).mockImplementation(
|
||||
(async (args: { update: Record<string, unknown> }) => ({
|
||||
adminAiKeyEncrypted: null,
|
||||
adminAiModel: "gpt-4o",
|
||||
adminAiBaseUrl: "https://api.openai.com/v1",
|
||||
...args.update,
|
||||
})) as never,
|
||||
);
|
||||
vi.mocked(prisma.appSettings.upsert).mockImplementation((async (args: {
|
||||
update: Record<string, unknown>;
|
||||
}) => ({
|
||||
adminAiKeyEncrypted: null,
|
||||
adminAiModel: "gpt-4o",
|
||||
adminAiBaseUrl: "https://api.openai.com/v1",
|
||||
...args.update,
|
||||
})) as never);
|
||||
}
|
||||
|
||||
it("propagates the admin gate (non-admin cannot write)", async () => {
|
||||
|
||||
@@ -81,22 +81,28 @@ beforeEach(() => {
|
||||
describe("POST /api/admin/import-apple-health-export", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const req = new NextRequest("http://localhost/api/admin/import-apple-health-export", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "multipart/form-data; boundary=---x" },
|
||||
body: "stub",
|
||||
});
|
||||
const req = new NextRequest(
|
||||
"http://localhost/api/admin/import-apple-health-export",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "multipart/form-data; boundary=---x" },
|
||||
body: "stub",
|
||||
},
|
||||
);
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 403 when a non-admin cookie session calls the route", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(USER_SESSION as never);
|
||||
const req = new NextRequest("http://localhost/api/admin/import-apple-health-export", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "multipart/form-data; boundary=---x" },
|
||||
body: "stub",
|
||||
});
|
||||
const req = new NextRequest(
|
||||
"http://localhost/api/admin/import-apple-health-export",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "multipart/form-data; boundary=---x" },
|
||||
body: "stub",
|
||||
},
|
||||
);
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
@@ -107,11 +113,14 @@ describe("POST /api/admin/import-apple-health-export", () => {
|
||||
"content-type": "multipart/form-data; boundary=---x",
|
||||
"content-length": String(2 * 1024 * 1024 * 1024),
|
||||
});
|
||||
const req = new NextRequest("http://localhost/api/admin/import-apple-health-export", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: "stub",
|
||||
});
|
||||
const req = new NextRequest(
|
||||
"http://localhost/api/admin/import-apple-health-export",
|
||||
{
|
||||
method: "POST",
|
||||
headers,
|
||||
body: "stub",
|
||||
},
|
||||
);
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(413);
|
||||
});
|
||||
@@ -119,11 +128,14 @@ describe("POST /api/admin/import-apple-health-export", () => {
|
||||
it("returns 429 on rate-limit exhaustion", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(ADMIN_SESSION as never);
|
||||
mocks.checkRateLimit.mockResolvedValueOnce({ allowed: false });
|
||||
const req = new NextRequest("http://localhost/api/admin/import-apple-health-export", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "multipart/form-data; boundary=---x" },
|
||||
body: "stub",
|
||||
});
|
||||
const req = new NextRequest(
|
||||
"http://localhost/api/admin/import-apple-health-export",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "multipart/form-data; boundary=---x" },
|
||||
body: "stub",
|
||||
},
|
||||
);
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(429);
|
||||
});
|
||||
|
||||
@@ -106,10 +106,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
ipAddress: getClientIp(request),
|
||||
details: { reason: "target_user_missing", targetUserId },
|
||||
});
|
||||
return apiError(
|
||||
`Target user '${targetUserId}' does not exist`,
|
||||
422,
|
||||
);
|
||||
return apiError(`Target user '${targetUserId}' does not exist`, 422);
|
||||
}
|
||||
|
||||
// Same content-hash idempotency as the user-facing route, but scoped
|
||||
|
||||
@@ -22,10 +22,7 @@ import {
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { hashToken } from "@/lib/auth/hmac";
|
||||
import {
|
||||
buildInviteUrl,
|
||||
generateInviteToken,
|
||||
} from "@/lib/auth/invite-token";
|
||||
import { buildInviteUrl, generateInviteToken } from "@/lib/auth/invite-token";
|
||||
import { inviteCreateSchema } from "@/lib/validations/invite";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -77,9 +74,7 @@ export const POST = apiHandler(async (req: Request) => {
|
||||
|
||||
const { expiresInDays, maxUses } = parsed.data;
|
||||
const rawToken = generateInviteToken();
|
||||
const expiresAt = new Date(
|
||||
Date.now() + expiresInDays * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
const expiresAt = new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000);
|
||||
|
||||
const invite = await prisma.inviteToken.create({
|
||||
data: {
|
||||
|
||||
@@ -109,9 +109,7 @@ interface DiagnosticEnvelope {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(prisma.device.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.pushAttempt.findMany).mockResolvedValue([] as never);
|
||||
});
|
||||
|
||||
|
||||
@@ -80,9 +80,10 @@ interface DiagnosticPayload {
|
||||
* (prefix / suffix both null) — that shape never appears in
|
||||
* production, but defensive against partial inserts during testing.
|
||||
*/
|
||||
function maskApnsToken(
|
||||
token: string | null,
|
||||
): { prefix: string | null; suffix: string | null } {
|
||||
function maskApnsToken(token: string | null): {
|
||||
prefix: string | null;
|
||||
suffix: string | null;
|
||||
} {
|
||||
if (!token || token.length < 16) return { prefix: null, suffix: null };
|
||||
return {
|
||||
prefix: token.slice(0, 8),
|
||||
|
||||
@@ -75,7 +75,13 @@ export const GET = apiHandler(async (request) => {
|
||||
for (const row of grouped) {
|
||||
const existing =
|
||||
byChannel.get(row.channel) ??
|
||||
({ channel: row.channel, ok: 0, error: 0, skipped: 0, total: 0 } satisfies ChannelHealthRow);
|
||||
({
|
||||
channel: row.channel,
|
||||
ok: 0,
|
||||
error: 0,
|
||||
skipped: 0,
|
||||
total: 0,
|
||||
} satisfies ChannelHealthRow);
|
||||
const count = row._count._all;
|
||||
if (row.result === "ok") existing.ok += count;
|
||||
else if (row.result === "error") existing.error += count;
|
||||
|
||||
@@ -36,8 +36,9 @@ vi.mock("@/lib/api-handler", async () => {
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(h: T): T =>
|
||||
h,
|
||||
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
|
||||
h: T,
|
||||
): T => h,
|
||||
requireAdmin: vi.fn(),
|
||||
};
|
||||
});
|
||||
@@ -149,9 +150,7 @@ describe("POST /api/admin/notifications/test — APNS branch (v1.4.46)", () => {
|
||||
eventType: "SYSTEM_ALERT",
|
||||
}),
|
||||
);
|
||||
expect(body.data.results).toEqual([
|
||||
{ channel: "APNS", success: true },
|
||||
]);
|
||||
expect(body.data.results).toEqual([{ channel: "APNS", success: true }]);
|
||||
expect(body.data.sent).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -63,8 +63,14 @@ async function ensureTelegramChannel(userId: string): Promise<boolean> {
|
||||
}
|
||||
|
||||
function isLocale(value: string | null | undefined): value is Locale {
|
||||
return value === "de" || value === "en" || value === "fr" ||
|
||||
value === "es" || value === "it" || value === "pl";
|
||||
return (
|
||||
value === "de" ||
|
||||
value === "en" ||
|
||||
value === "fr" ||
|
||||
value === "es" ||
|
||||
value === "it" ||
|
||||
value === "pl"
|
||||
);
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
|
||||
@@ -48,7 +48,9 @@ describe("GET /api/admin/settings/module-availability", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await (GET as unknown as (r: NextRequest) => Promise<Response>)(
|
||||
new NextRequest("http://localhost/api/admin/settings/module-availability"),
|
||||
new NextRequest(
|
||||
"http://localhost/api/admin/settings/module-availability",
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
@@ -56,7 +58,9 @@ describe("GET /api/admin/settings/module-availability", () => {
|
||||
it("returns 403 when the caller is not an admin (cookie-only boundary)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(USER_OK as never);
|
||||
const res = await (GET as unknown as (r: NextRequest) => Promise<Response>)(
|
||||
new NextRequest("http://localhost/api/admin/settings/module-availability"),
|
||||
new NextRequest(
|
||||
"http://localhost/api/admin/settings/module-availability",
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
@@ -65,7 +69,9 @@ describe("GET /api/admin/settings/module-availability", () => {
|
||||
vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
|
||||
const res = await (GET as unknown as (r: NextRequest) => Promise<Response>)(
|
||||
new NextRequest("http://localhost/api/admin/settings/module-availability"),
|
||||
new NextRequest(
|
||||
"http://localhost/api/admin/settings/module-availability",
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
@@ -82,7 +88,9 @@ describe("GET /api/admin/settings/module-availability", () => {
|
||||
moduleAvailabilityJson: { mood: false },
|
||||
} as never);
|
||||
const res = await (GET as unknown as (r: NextRequest) => Promise<Response>)(
|
||||
new NextRequest("http://localhost/api/admin/settings/module-availability"),
|
||||
new NextRequest(
|
||||
"http://localhost/api/admin/settings/module-availability",
|
||||
),
|
||||
);
|
||||
const body = (await res.json()) as {
|
||||
data: { availability: Record<string, boolean> };
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { invalidateAppSettings } from "@/lib/cache/invalidate";
|
||||
@@ -68,7 +73,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const alreadyConfigured = Boolean(
|
||||
existing?.webPushVapidPublicKey &&
|
||||
existing?.webPushVapidPrivateKeyEncrypted,
|
||||
existing?.webPushVapidPrivateKeyEncrypted,
|
||||
);
|
||||
|
||||
if (alreadyConfigured && parsed.data.force !== true) {
|
||||
|
||||
@@ -170,5 +170,4 @@ describe("GET /api/admin/status (integration health summary)", () => {
|
||||
const tokenCallArgs = vi.mocked(prisma.apiToken.count).mock.calls[0]?.[0];
|
||||
expect(tokenCallArgs).toEqual({ where: { revoked: false } });
|
||||
});
|
||||
|
||||
});
|
||||
@@ -97,7 +97,10 @@ describe("PUT /api/admin/users/[id]", () => {
|
||||
|
||||
it("returns 404 when target user does not exist", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
|
||||
const res = await PUT(jsonReq({ username: "testuser" }), params("u-missing"));
|
||||
const res = await PUT(
|
||||
jsonReq({ username: "testuser" }),
|
||||
params("u-missing"),
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
expect(vi.mocked(prisma.user.update)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -138,18 +138,14 @@ beforeEach(() => {
|
||||
// findMany + null findFirst means the slim slice's parity check
|
||||
// diverges and falls back to live SQL (preserves the pre-v1.4.35
|
||||
// assertions in this file).
|
||||
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.measurementRollup.findFirst).mockResolvedValue(
|
||||
null as never,
|
||||
);
|
||||
vi.mocked(prisma.measurementRollup.deleteMany).mockResolvedValue(
|
||||
{ count: 0 } as never,
|
||||
);
|
||||
vi.mocked(prisma.measurementRollup.upsert).mockResolvedValue(
|
||||
{} as never,
|
||||
);
|
||||
vi.mocked(prisma.measurementRollup.deleteMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
vi.mocked(prisma.measurementRollup.upsert).mockResolvedValue({} as never);
|
||||
// v1.4.37 W2 — `ensureUserRollupsFresh` reads `measurement.findFirst`;
|
||||
// the per-type coverage probe + the rollup-recompute aggregator ride
|
||||
// `$queryRaw` / `$queryRawUnsafe`. Default to empty so the route
|
||||
@@ -318,12 +314,10 @@ describe("GET /api/analytics", () => {
|
||||
{ type: "WEIGHT", value: 81.8, measured_at: fiveDaysAgo },
|
||||
] as never);
|
||||
|
||||
const req = new Request(
|
||||
"http://localhost/api/analytics?slice=summaries",
|
||||
const req = new Request("http://localhost/api/analytics?slice=summaries");
|
||||
const res = await (GET as unknown as (req: Request) => Promise<Response>)(
|
||||
req,
|
||||
);
|
||||
const res = await (
|
||||
GET as unknown as (req: Request) => Promise<Response>
|
||||
)(req);
|
||||
expect(res.status).toBe(200);
|
||||
// v1.4.34 IW-B — bfcache-friendly directive rides on the slim
|
||||
// slice too.
|
||||
@@ -369,17 +363,17 @@ describe("GET /api/analytics", () => {
|
||||
// both fields rather than 403-ing the whole core-metrics payload.
|
||||
function seedGlucoseRows() {
|
||||
const measuredAt = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
|
||||
vi.mocked(prisma.measurement.findMany).mockImplementation(
|
||||
(async (args: { where?: { type?: string } }) => {
|
||||
if (args?.where?.type === "BLOOD_GLUCOSE") {
|
||||
return [
|
||||
{ value: 95, measuredAt, glucoseContext: "FASTING" },
|
||||
{ value: 140, measuredAt, glucoseContext: "POSTPRANDIAL" },
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}) as never,
|
||||
);
|
||||
vi.mocked(prisma.measurement.findMany).mockImplementation((async (args: {
|
||||
where?: { type?: string };
|
||||
}) => {
|
||||
if (args?.where?.type === "BLOOD_GLUCOSE") {
|
||||
return [
|
||||
{ value: 95, measuredAt, glucoseContext: "FASTING" },
|
||||
{ value: 140, measuredAt, glucoseContext: "POSTPRANDIAL" },
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}) as never);
|
||||
}
|
||||
|
||||
it("serves glucose clinical + per-context when glucose enabled", async () => {
|
||||
|
||||
@@ -72,7 +72,9 @@ export const GET = apiHandler(async (request?: Request) => {
|
||||
// instantly while one background recompute refreshes them, instead
|
||||
// of paying the 2-SQL-pass rebuild inline.
|
||||
const slim = await cachedSwr(
|
||||
caches.analytics as ServerCache<Awaited<ReturnType<typeof computeSummariesSlice>>>,
|
||||
caches.analytics as ServerCache<
|
||||
Awaited<ReturnType<typeof computeSummariesSlice>>
|
||||
>,
|
||||
`${user.id}|summaries`,
|
||||
() => computeSummariesSlice(user.id),
|
||||
annotate,
|
||||
@@ -95,7 +97,9 @@ export const GET = apiHandler(async (request?: Request) => {
|
||||
// hero score; after a measurement sync the next mount serves the
|
||||
// prior body instantly while one background recompute refreshes it.
|
||||
const cachedBody = await cachedSwr(
|
||||
caches.analytics as ServerCache<Awaited<ReturnType<typeof buildAnalyticsResponse>>>,
|
||||
caches.analytics as ServerCache<
|
||||
Awaited<ReturnType<typeof buildAnalyticsResponse>>
|
||||
>,
|
||||
`${user.id}|default`,
|
||||
() => buildAnalyticsResponse(user),
|
||||
annotate,
|
||||
@@ -596,7 +600,6 @@ async function computeSleepStageBreakdown(
|
||||
// used by the weight-weekday + day-key helpers inside the old inline
|
||||
// correlation builder.
|
||||
|
||||
|
||||
// v1.4.37 W2 — `mapMeasurementSourceToLabel`, `uniqueComponentSources`,
|
||||
// and the inline `computeUserHealthScore` body relocated to
|
||||
// `src/lib/analytics/health-score-fast-path.ts` so the probe-gated
|
||||
|
||||
@@ -52,8 +52,7 @@ vi.mock("@/lib/auth/hmac", () => ({
|
||||
// privacy contract is "the typed identifier never serialises into
|
||||
// the row's body", so the mock must not embed the raw input.
|
||||
hashToken: vi.fn(
|
||||
(raw: string) =>
|
||||
"aa".repeat(32) + (raw.length % 8).toString(16),
|
||||
(raw: string) => "aa".repeat(32) + (raw.length % 8).toString(16),
|
||||
),
|
||||
}));
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ vi.mock("@/lib/db", () => ({
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
toJson: <T,>(v: T) => v,
|
||||
toJson: <T>(v: T) => v,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
@@ -87,12 +87,14 @@ function mkPatch(body: unknown): Request {
|
||||
* (which writes `modulePreferencesJson`) is reflected by the post-update
|
||||
* `resolveModuleMap` re-read — mirroring real DB round-trip semantics.
|
||||
*/
|
||||
function primeUser(over: {
|
||||
gender?: string | null;
|
||||
disableCoach?: boolean;
|
||||
modulePreferencesJson?: unknown;
|
||||
cycleTrackingEnabled?: boolean | null;
|
||||
} = {}) {
|
||||
function primeUser(
|
||||
over: {
|
||||
gender?: string | null;
|
||||
disableCoach?: boolean;
|
||||
modulePreferencesJson?: unknown;
|
||||
cycleTrackingEnabled?: boolean | null;
|
||||
} = {},
|
||||
) {
|
||||
const row: {
|
||||
gender: string | null;
|
||||
disableCoach: boolean;
|
||||
@@ -102,9 +104,8 @@ function primeUser(over: {
|
||||
disableCoach: over.disableCoach ?? false,
|
||||
modulePreferencesJson: over.modulePreferencesJson ?? null,
|
||||
};
|
||||
vi.mocked(prisma.user.findUnique).mockImplementation(
|
||||
(() => Promise.resolve({ ...row })) as never,
|
||||
);
|
||||
vi.mocked(prisma.user.findUnique).mockImplementation((() =>
|
||||
Promise.resolve({ ...row })) as never);
|
||||
vi.mocked(prisma.user.update).mockImplementation(((args: {
|
||||
data?: { modulePreferencesJson?: unknown };
|
||||
}) => {
|
||||
@@ -213,7 +214,9 @@ describe("PATCH /api/auth/me/modules", () => {
|
||||
// user previously disabled glucose; now disabling sleep too.
|
||||
primeUser({ modulePreferencesJson: { glucose: false } });
|
||||
|
||||
await (PATCH as (r: Request) => Promise<Response>)(mkPatch({ sleep: false }));
|
||||
await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ sleep: false }),
|
||||
);
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
@@ -225,7 +228,9 @@ describe("PATCH /api/auth/me/modules", () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser({ modulePreferencesJson: { glucose: false } });
|
||||
|
||||
await (PATCH as (r: Request) => Promise<Response>)(mkPatch({ glucose: true }));
|
||||
await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ glucose: true }),
|
||||
);
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
@@ -299,7 +304,9 @@ describe("PATCH /api/auth/me/modules", () => {
|
||||
// junk in the stored row must not leak into the persisted merge.
|
||||
primeUser({ modulePreferencesJson: { mood: "yes", sleep: false } });
|
||||
|
||||
await (PATCH as (r: Request) => Promise<Response>)(mkPatch({ labs: false }));
|
||||
await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ labs: false }),
|
||||
);
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
|
||||
@@ -131,9 +131,7 @@ describe("GET /api/auth/me/research-mode", () => {
|
||||
};
|
||||
expect(env.data.enabled).toBe(true);
|
||||
expect(env.data.acknowledgedAt).toBe(ackAt.toISOString());
|
||||
expect(env.data.acknowledgedVersion).toBe(
|
||||
RESEARCH_MODE_DISCLAIMER_VERSION,
|
||||
);
|
||||
expect(env.data.acknowledgedVersion).toBe(RESEARCH_MODE_DISCLAIMER_VERSION);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -165,9 +163,7 @@ describe("POST /api/auth/me/research-mode", () => {
|
||||
data: { enabled: boolean; acknowledgedVersion: string };
|
||||
};
|
||||
expect(env.data.enabled).toBe(true);
|
||||
expect(env.data.acknowledgedVersion).toBe(
|
||||
RESEARCH_MODE_DISCLAIMER_VERSION,
|
||||
);
|
||||
expect(env.data.acknowledgedVersion).toBe(RESEARCH_MODE_DISCLAIMER_VERSION);
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -171,7 +171,8 @@ export const POST = apiHandler(async (req: Request) => {
|
||||
previous: previous ?? null,
|
||||
next: {
|
||||
...next,
|
||||
researchModeAcknowledgedAt: next.researchModeAcknowledgedAt.toISOString(),
|
||||
researchModeAcknowledgedAt:
|
||||
next.researchModeAcknowledgedAt.toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -131,7 +131,10 @@ describe("PATCH /api/auth/me/unit-preference", () => {
|
||||
"user.unit-preference.update",
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
details: expect.objectContaining({ previous: "metric", next: "imperial" }),
|
||||
details: expect.objectContaining({
|
||||
previous: "metric",
|
||||
next: "imperial",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { createAuthenticationOptions } from "@/lib/auth/passkey";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import {
|
||||
checkAuthSurfaceRateLimit,
|
||||
rateLimitHeaders,
|
||||
} from "@/lib/rate-limit";
|
||||
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { NextResponse } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
@@ -53,10 +53,7 @@ vi.mock("next/headers", () => ({
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { verifyAuthentication } from "@/lib/auth/passkey";
|
||||
import {
|
||||
checkRateLimit,
|
||||
checkAuthSurfaceRateLimit,
|
||||
} from "@/lib/rate-limit";
|
||||
import { checkRateLimit, checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const FAKE_USER = {
|
||||
id: "user-1",
|
||||
|
||||
@@ -23,7 +23,9 @@ vi.mock("@/lib/auth/audit", () => ({
|
||||
vi.mock("@/lib/auth/password", () => ({
|
||||
verifyPassword: vi.fn().mockResolvedValue(true),
|
||||
hashPassword: vi.fn().mockResolvedValue("hashed"),
|
||||
checkPasswordStrength: vi.fn().mockReturnValue({ isAcceptable: true, feedback: [] }),
|
||||
checkPasswordStrength: vi
|
||||
.fn()
|
||||
.mockReturnValue({ isAcceptable: true, feedback: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
@@ -81,9 +83,7 @@ beforeEach(() => {
|
||||
describe("POST /api/auth/password — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
it("surfaces TWO simultaneous validation errors", async () => {
|
||||
// Empty currentPassword + tooShort newPassword.
|
||||
const res = await POST(
|
||||
postReq({ currentPassword: "", newPassword: "x" }),
|
||||
);
|
||||
const res = await POST(postReq({ currentPassword: "", newPassword: "x" }));
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
@@ -102,9 +102,7 @@ describe("POST /api/auth/password — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
|
||||
it("surfaces THREE simultaneous validation errors", async () => {
|
||||
// Both empty + extra invalid type.
|
||||
const res = await POST(
|
||||
postReq({ currentPassword: "", newPassword: 123 }),
|
||||
);
|
||||
const res = await POST(postReq({ currentPassword: "", newPassword: 123 }));
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<unknown> };
|
||||
|
||||
@@ -23,7 +23,9 @@ vi.mock("@/lib/auth/session", () => ({
|
||||
|
||||
vi.mock("@/lib/auth/password", () => ({
|
||||
hashPassword: vi.fn().mockResolvedValue("hashed"),
|
||||
checkPasswordStrength: vi.fn().mockReturnValue({ isAcceptable: true, feedback: [] }),
|
||||
checkPasswordStrength: vi
|
||||
.fn()
|
||||
.mockReturnValue({ isAcceptable: true, feedback: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
|
||||
@@ -144,7 +144,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
} else {
|
||||
annotate({
|
||||
action: { name: "auth.register.invite_rejected" },
|
||||
meta: { reason: consumed.reason, registration_open: registrationEnabled },
|
||||
meta: {
|
||||
reason: consumed.reason,
|
||||
registration_open: registrationEnabled,
|
||||
},
|
||||
});
|
||||
if (!registrationEnabled) {
|
||||
return apiError("Invalid or expired invite", 403);
|
||||
|
||||
Loaded 100 of 1148 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user