Merge pull request #1373 from MBombeck/release/v1.47.3

This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-11-12 14:11:26 +01:00
commit 5d1c3ffbaf
381 files changed
+7736 -5665

No files matched your search

-6
View File
@@ -383,12 +383,6 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# CODEX_MODEL=""
# CODEX_MODEL_FALLBACK_CHAIN=""
# Surfaces reviewed n-of-1 experiment outcomes in the Coach snapshot. Off by
# default: the read-back is the most overclaim-prone Coach surface, so it is a
# deliberate operator opt-in. Set to "1" to enable; anything else keeps it
# dormant while the review worker still records outcomes.
# COACH_EXPERIMENT_VERDICT="1"
# -----------------------------------------------------------------------------
# Off-host backups -- all-or-none
+128
View File
@@ -2,6 +2,134 @@
## [Unreleased]
## [1.47.3] — 2026-11-12
### Changed
- **AI uses only the providers you left switched on.** When the provider
order saved under Settings > AI could not answer (say only a local
model was on and it was unreachable), several features quietly fell
back to the ChatGPT sign-in or to the operator's server key: the
briefing, the status notes, document reading and the Coach among
them. They now report that no provider is available, like the rest of
the app already did, and the cost lands where you expect it. An
account that never saved a provider order works as before, and the
OpenAI-compatible gateway you picked still answers unless you switched
it off in the order. If you run an instance and your people rely on
the server key, have them keep it switched on in their provider order.
The connection test now says "No AI provider configured" in that case
instead of a confusing validation message.
- **Turning AI off also ends the invitations to set it up.** With both
the Coach and AI analysis switched off, the "set up AI" hint, the
checklist row and the mention on the last setup screen no longer
appear. That setup screen now carries one line pointing to
Settings > AI instead of a large panel.
- **Blood pressure and resting pulse are judged against your own
target.** The charts have followed a target you set since #1261, but
the status notes, the overview, the morning briefing and the Coach
still compared your readings with the age standard. They now use your
target when you set one, and the age standard otherwise.
- **Coach plans show their metric by name.** A plan card read a raw key
such as `WEIGHT`; it now names the measurement in your language. The
nightly review still moves a finished plan to "Reviewed", but no
longer works out a verdict that no screen showed. The
`COACH_EXPERIMENT_VERDICT` setting is gone; remove it from your
environment if you set it.
- **The notification settings list only what HealthLog sends.**
"Unusual measurement", "Low adherence", "Withings sync failed" and
"Personal record" had switches, three of them on by default, but
nothing ever sent them. They are gone from the list. A failed Withings
sync still reaches admins as a system alert.
### Fixed
- **New entries show up in the day view and on the timeline right
away.** Symptoms, workouts, lab results, allergies, documents and
questionnaires appeared there only after a reload, also when added
through "Capture for this day". Confirming facts from a document and
turning free-text conditions or allergies into entries now update the
dashboard, Today, the day view and the timeline at once, and a
check-up marked done leaves the Today rail immediately.
- **Forms close as soon as the entry is stored.** Saving a measurement,
a mood, a workout, a dose, a vaccination, a visit, a symptom, an
illness episode or a life event kept the sheet open with emptied
fields and a spinner until every chart had reloaded. It now closes
right away and the charts catch up behind it. Bulk deletes and their
Undo do the same.
- **Tapping Save again after a timeout no longer stores the entry
twice.** Each opened form now sends the same request key until it is
saved, so the server recognises a repeat.
- **Errors say what went wrong, in your language.** An expired session,
a rate limit, a missing permission or a rejected value now say so
instead of "could not be saved", and many places that showed
the server's English text, among them the settings, mood tags,
medications, the Coach model picker and the admin pages, now show a
translated sentence. The measurement edit sheet marks an implausible
value on its field and names the accepted range in your unit. The
visit, vaccination, allergy, address-book and workout forms name the
actual problem, such as a removed doctor or a dose without a name. A
mistyped password while adding a passkey says so, instead of claiming
the session had ended.
- **Undo after deleting a vaccination, a visit or an address-book
entry.** The toast now offers it, like it does for illness episodes,
also when you delete two entries in a row.
- **A check-up marked done cannot be marked twice.** A quick second tap
on "Done", "Skip" or "Snooze" stored a second entry.
- **A managed person's health record carries their name.** The PDF,
its properties, the FHIR export and the share link showed the
technical login `managed-…` when no full name was stored. They now use
the display name, and so does an own account's report when no full
name is set. Initials in settings and in the Coach chat follow the
display name too.
- **Dates without a time land on their own day west of UTC.** For
people in the Americas the date of birth on the health record and the
share page showed the day before, and vaccinations sat on the previous
day in the day view, the calendar dots, the timeline, the visit
preparation, the health record and the share page; the first booster
reminder came a day early, also when a logged dose moved an existing
booster reminder. All of them now read the date as a date, and a date
of birth entered in the iPhone app is stored as a plain date too, so
it no longer depends on the hour it was typed.
The day view also stops inventing a clock time (01:00 or 13:00) for
vaccinations and allergy onsets.
- **Weight and BMI in someone else's record use that record.** In a
shared or managed record, the weight chart shaded the viewer's target
or a band from the viewer's height, the BMI page and chart checked the
viewer's height, and the target gear opened an editor that could not
save there. They now use the record on screen, and the gear is hidden
where it cannot work. Someone with read access to a record now sees
its weight band as well. The adult weight and BMI bands are no longer
drawn on a child's record, since they do not apply to children; the
values and charts stay. A weight target below 30 kg is accepted, so a
guardian can set one for a child.
- **"What changed since your last panel" sees small changes.** A move
from 0.004 to 0.009 read "no change", and some values printed with
fifteen decimals. Changes are now compared at the precision the lab
printed and shown in your language's notation.
- **The Medications module switched off means quiet.** Dose reminders,
missed-dose entries, low-stock alerts and the Telegram medication
commands kept running with the module off. They now wait until it is
switched back on, and nothing is lost meanwhile. A medication whose
course has ended no longer triggers low-stock alerts, and the Telegram
bot no longer offers it or records a dose for it. When two medications
share a name, the bot now picks the running one.
- **Medication reminders show the time in your 12- or 24-hour
setting.**
- **Computed scores no longer offer edit and delete.** Recovery, strain
and other computed rows in the measurement list showed a pencil that
led to an empty form and a bin, and "select all" swept them into a bulk
delete. They are now left out of both.
- **The cycle page takes "today" from your profile's time zone**, like
the rest of the app, instead of from the device.
- **The document pickers say when they show only the newest 50**, and
that search finds older ones.
### Removed
- The disabled "Try a 7-day experiment" button on correlation cards,
which never did anything.
- Three components nothing rendered, and their strings.
## [1.47.2] — 2026-11-11
### Added
-4
View File
@@ -360,10 +360,6 @@ x-healthlog-env: &healthlog_env
# outright; the account's list is then not read.
CODEX_MODEL: "${CODEX_MODEL:-}"
CODEX_MODEL_FALLBACK_CHAIN: "${CODEX_MODEL_FALLBACK_CHAIN:-}"
# Surfaces reviewed n-of-1 experiment outcomes in the Coach snapshot.
# Off by default — the read-back is the most overclaim-prone Coach
# surface, so it is an explicit operator opt-in. Set to "1" to enable.
COACH_EXPERIMENT_VERDICT: "${COACH_EXPERIMENT_VERDICT:-}"
# Daily-briefing output-token ceiling (optional; default 2500, clamped
# 500-8000). Raise when a verbose model's briefing JSON is cut off.
# Listed here so an operator value in .env reaches the container (the
+41 -39
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.47.2
version: 1.47.3
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -13677,9 +13677,9 @@ paths:
and the profile facts every band was resolved from. Pure compute over the record's own data — no provider
anywhere on the path. Stale-while-revalidate: past the fresh window the prior body serves immediately while one
background rebuild warms the cell, and a write hard-evicts the bucket so the account's own action lands on the
next read. Delegable at MANAGE level over the whole record: the walk spans vitals, sleep, medications, mood and
glucose, so a section-scoped grant is refused rather than served a filtered page. Not module-gated as a whole; a
tile whose data does not exist is simply absent. Cookie or Bearer auth."
next read. Delegable at READ level over the whole record, like the dashboard snapshot: the walk spans vitals,
sleep, medications, mood and glucose, so a section-scoped grant is refused rather than served a filtered page.
Not module-gated as a whole; a tile whose data does not exist is simply absent. Cookie or Bearer auth."
responses:
"200":
description: The target tiles and their page summary.
@@ -32053,11 +32053,11 @@ components:
properties:
min:
type: number
minimum: 30
minimum: 1
maximum: 300
max:
type: number
minimum: 30
minimum: 1
maximum: 300
required:
- min
@@ -38564,11 +38564,11 @@ components:
properties:
min:
type: number
minimum: 30
minimum: 1
maximum: 300
max:
type: number
minimum: 30
minimum: 1
maximum: 300
required:
- min
@@ -39694,35 +39694,36 @@ components:
activity.calendar.future_date, activity.calendar.too_old; admin — admin.users.delete.managed_profile,
admin.users.delete.self; ai — ai.budget.exceeded, ai.provider.none, ai.record.notPermitted,
ai.unavailable, ai.usage.aboveCeiling, ai.usage.defaultAboveCeiling, ai.usage.invalid,
ai.usage.levelTooLow, ai.usage.rateLimited; ai_provider — ai_provider.invalid, ai_provider.no_fields;
aiRuns — aiRuns.failed, aiRuns.notFound, aiRuns.readerBusy, aiRuns.timedOut, aiRuns.workerUnavailable;
allergy — allergy.invalid; analytics — analytics.invalid_query; anamnesis — anamnesis.auditC.invalid,
anamnesis.auditC.notFound, anamnesis.auditC.rateLimited, anamnesis.fact.conflict,
anamnesis.fact.currentExists, anamnesis.fact.invalidValue, anamnesis.lifeConfirm.invalid,
anamnesis.lifeSections.invalid; auth — auth.account.deletion_pending, auth.admin.required,
auth.already_authenticated, auth.mfa.code_invalid, auth.missing, auth.refresh.invalid,
auth.refresh.reuse, auth.refresh.revoked, auth.reproof.failed, auth.reproof.required,
auth.reproof.too_weak, auth.scope.insufficient, auth.session.insecure_transport,
auth.session.unavailable, auth.stepup.required, auth.token.expired, auth.token.invalid; backup —
backup.foreign_reference, backup.key.missing, backup.owner.exists, backup.owner.missing,
backup.payload.undecryptable, backup.restore.active, backup.restore.rows_exist_elsewhere,
backup.section.missing, backup.target.admin_account, backup.target.holds_records,
backup.target.managed_profile, backup.target.not_found, backup.target.pending_deletion,
backup.target.unconfirmed, backup.target.username_taken, backup.upload.too_large; biomarkers —
biomarkers.unit.locked; body-site — body-site.invalid; coach — coach.budget.exceeded,
coach.conversation.invalidTitle, coach.conversation.notFound, coach.fenced.attachmentConflict,
coach.fenced.attachmentLimit, coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed,
coach.fenced.attachmentRequired, coach.fenced.attachmentUnavailable, coach.fenced.invalid,
coach.request.invalid; coach-prefs — coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape;
consent — consent.ai.confirmRequired, consent.ai.required; cycle — cycle.bulk.invalid,
cycle.bulk.too_large, cycle.calendar.invalid, cycle.calendar.range, cycle.cycles.invalid,
cycle.day-log.conflict, cycle.day-log.invalid, cycle.disabled, cycle.feed.api_disabled,
cycle.feed.cookie_only, cycle.feed.exists, cycle.feed.fertile_goal, cycle.feed.none,
cycle.feed.notFound, cycle.feed.rateLimited, cycle.period.invalid, cycle.symptom.custom.invalid,
cycle.symptom.custom.limit; cycle-prefs — cycle-prefs.body.invalid_json, cycle-prefs.invalid;
disable-coach — disable-coach.body.invalid_json; data_wipe — data_wipe.copy_failed; dawarich —
dawarich.credentials_rejected, dawarich.home_place_unknown, dawarich.invalid, dawarich.not_connected,
dawarich.rate_limited, dawarich.unreachable; day — day.invalid; demo — demo.readOnly; documents —
ai.usage.levelTooLow, ai.usage.rateLimited; ai_provider — ai_provider.invalid, ai_provider.no_fields,
ai_provider.private_host; aiRuns — aiRuns.failed, aiRuns.notFound, aiRuns.readerBusy, aiRuns.timedOut,
aiRuns.workerUnavailable; allergy — allergy.invalid; analytics — analytics.invalid_query; anamnesis —
anamnesis.auditC.invalid, anamnesis.auditC.notFound, anamnesis.auditC.rateLimited,
anamnesis.fact.conflict, anamnesis.fact.currentExists, anamnesis.fact.invalidValue,
anamnesis.lifeConfirm.invalid, anamnesis.lifeSections.invalid; auth — auth.account.deletion_pending,
auth.admin.required, auth.already_authenticated, auth.mfa.already_active, auth.mfa.code_invalid,
auth.missing, auth.passkey.last_method, auth.refresh.invalid, auth.refresh.reuse, auth.refresh.revoked,
auth.reproof.failed, auth.reproof.required, auth.reproof.too_weak, auth.scope.insufficient,
auth.session.insecure_transport, auth.session.unavailable, auth.stepup.required, auth.token.expired,
auth.token.invalid; backup — backup.foreign_reference, backup.key.missing, backup.owner.exists,
backup.owner.missing, backup.payload.undecryptable, backup.restore.active,
backup.restore.rows_exist_elsewhere, backup.section.missing, backup.target.admin_account,
backup.target.holds_records, backup.target.managed_profile, backup.target.not_found,
backup.target.pending_deletion, backup.target.unconfirmed, backup.target.username_taken,
backup.upload.too_large; biomarkers — biomarkers.unit.locked; body-site — body-site.invalid; coach —
coach.budget.exceeded, coach.conversation.invalidTitle, coach.conversation.notFound,
coach.fenced.attachmentConflict, coach.fenced.attachmentLimit, coach.fenced.attachmentNotFound,
coach.fenced.attachmentNotIndexed, coach.fenced.attachmentRequired, coach.fenced.attachmentUnavailable,
coach.fenced.invalid, coach.request.invalid; coach-prefs — coach-prefs.body.invalid_json,
coach-prefs.body.invalid_shape; consent — consent.ai.confirmRequired, consent.ai.required; customMetric
— customMetric.name_taken; cycle — cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid,
cycle.calendar.range, cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid,
cycle.disabled, cycle.feed.api_disabled, cycle.feed.cookie_only, cycle.feed.exists,
cycle.feed.fertile_goal, cycle.feed.none, cycle.feed.notFound, cycle.feed.rateLimited,
cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs —
cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json;
data_wipe — data_wipe.copy_failed; dawarich — dawarich.credentials_rejected,
dawarich.home_place_unknown, dawarich.invalid, dawarich.not_connected, dawarich.rate_limited,
dawarich.unreachable; day — day.invalid; demo — demo.readOnly; documents —
documents.chat.conversationNotFound, documents.chat.invalid, documents.inbound.alreadyConfirmed,
documents.inbound.alreadyPartlyConfirmed, documents.inbound.budgetExceeded,
documents.inbound.decryptFailed, documents.inbound.encounterNotFound, documents.inbound.episodeNotFound,
@@ -90607,7 +90608,8 @@ components:
type: array
items:
type: string
description: Every event category the server knows, in declaration order — the list a client renders rows for.
description: "The event categories this account is shown a switch for, in declaration order: the list a client renders
rows for. A category nothing sends is not listed, and `preferences` carries no stored row for it."
eventDefaults:
type: object
propertyNames:
@@ -90663,7 +90665,7 @@ components:
- eventTypes
- eventDefaults
additionalProperties: false
description: The caller's notification channels, their stored per-event overrides, and the full event vocabulary.
description: The caller's notification channels, their stored per-event overrides, and the event categories they can switch.
NotificationChannelSummary:
type: object
properties:
-220
View File
@@ -1,220 +0,0 @@
/**
* v1.38.19 — the AI offer at the end of the setup flow.
*
* The screen used to promise that insights "work for you right now" the
* moment the operator had configured a key. It never knew whether the key
* answered, and it never mentioned the consent receipt the egress actually
* needs — so the person who believed it met a `consent.ai.required` refusal
* on their first briefing. On 2026-09-11 the operator's own instance was
* answering HTTP 500 from its OAuth proxy while the screen kept promising.
*
* Three runs, one account, and the only difference between them is a row in
* `provider_health`:
*
* a success inside the freshness window → the offer, and one tap grants
* a dead credential inside its cooldown → "not answering", and no button
* no row at all → neither: the panel says nothing
*
* What is deliberately absent: any call to a provider. The flow's rule holds
* — nothing in it talks to one — and a probe here would bill the operator for
* every registration on the instance.
*
* Serial, and it restores the instance state it seeded: the tri-state folds
* across accounts by design, so its inputs are instance-wide.
*/
import {
E2E_SETUP_AI_OFFER,
SETUP_AI_OFFER_STORAGE_STATE_PATH,
} from "./setup/global-setup";
import {
clearAiConsent,
clearOperatorProvider,
clearSharedProviderResult,
seedOperatorProvider,
seedSharedProviderResult,
} from "./setup/ai-offer-fixture";
import { resetSetupFlow } from "./setup/setup-flow-fixture";
import { expect, test } from "./setup/test";
import {
answer,
answerEverything,
complete,
expectScreen,
} from "./setup-flow-helpers";
const OFFER = '[data-slot="onboarding-ai-offer"]';
const GRANT = '[data-slot="onboarding-ai-offer-grant"]';
const UNAVAILABLE = '[data-slot="onboarding-ai-unavailable"]';
const SHARED_KEY = '[data-slot="onboarding-ai-shared-key"]';
/** The panel once it knows which variant it is; absent while the status is in
* flight, so an assertion of absence cannot pass on an unanswered question. */
const SETTLED = '[data-slot="onboarding-ai-panel"][data-ai-state]';
/** Walk the account to the done screen through the API, then open it. */
async function openDoneScreen(page: import("@playwright/test").Page) {
await page.goto("/onboarding");
await answerEverything(page);
// Q6 applies because the answers name glucose and weight; the flow does not
// complete while a screen it owes is unanswered.
await answer(page, {
step: "units",
units: { glucoseUnit: "mmol/L", unitPreference: "metric" },
});
await complete(page);
await answer(page, { step: "first-result", status: "skipped" });
await page.goto("/onboarding/done");
await expectScreen(page, "done");
// The panel itself is unconditional; the variant inside it is not.
await expect(page.locator('[data-slot="onboarding-ai-panel"]')).toBeVisible();
}
// One account, one instance-wide ledger. Two workers driving this at once
// would each read the other's seed.
test.describe.configure({ mode: "serial" });
test.describe("the setup flow's AI offer follows the shared provider's health", () => {
test.use({ storageState: SETUP_AI_OFFER_STORAGE_STATE_PATH });
test.beforeEach(async () => {
await resetSetupFlow(E2E_SETUP_AI_OFFER.username);
await clearAiConsent(E2E_SETUP_AI_OFFER.username);
await seedOperatorProvider();
});
test.afterAll(async () => {
await clearOperatorProvider();
await clearAiConsent(E2E_SETUP_AI_OFFER.username);
});
test("offers one tap when the shared provider worked recently, and the tap leaves a receipt", async ({
page,
}) => {
test.setTimeout(90_000);
await seedSharedProviderResult(E2E_SETUP_AI_OFFER.username, {
result: "ok",
okMinutesAgo: 30,
});
// No receipt before the tap — otherwise the assertion after it proves
// nothing about the button.
const before = await page.request.get(
"/api/consent/ai/latest?kind=ai_full",
);
expect(before.status()).toBe(200);
expect(
((await before.json()) as { data: { receipt: unknown } }).data.receipt,
).toBeNull();
await openDoneScreen(page);
await expect(page.locator(SETTLED)).toHaveAttribute(
"data-ai-state",
"offer",
{ timeout: 15_000 },
);
await expect(page.locator(OFFER)).toBeVisible();
await expect(page.locator(UNAVAILABLE)).toHaveCount(0);
// No provider was called to decide this. The flow reads a projection.
const probes: string[] = [];
page.on("request", (req) => {
const path = new URL(req.url()).pathname;
if (path === "/api/ai/test" || path.startsWith("/api/insights/"))
probes.push(path);
});
await page.locator(GRANT).click();
// The line the person reads swaps to the granted one.
await expect(page.locator(SHARED_KEY)).toBeVisible({ timeout: 15_000 });
await expect(page.locator(GRANT)).toHaveCount(0);
expect(probes, "the offer must not trigger a provider call").toEqual([]);
const after = await page.request.get("/api/consent/ai/latest?kind=ai_full");
const receipt = (
(await after.json()) as {
data: { receipt: { kind: string; revokedAt: string | null } | null };
}
).data.receipt;
expect(receipt).not.toBeNull();
expect(receipt!.kind).toBe("ai_full");
expect(receipt!.revokedAt).toBeNull();
});
test("says the provider is not answering while its credential is benched, and offers no button", async ({
page,
}) => {
test.setTimeout(90_000);
await seedSharedProviderResult(E2E_SETUP_AI_OFFER.username, {
result: "auth_failed",
});
await openDoneScreen(page);
await expect(page.locator(SETTLED)).toHaveAttribute(
"data-ai-state",
"unavailable",
{ timeout: 15_000 },
);
await expect(page.locator(UNAVAILABLE)).toBeVisible();
await expect(page.locator(GRANT)).toHaveCount(0);
await expect(page.locator(OFFER)).toHaveCount(0);
// The unproven promise is not shown to somebody who has not consented.
await expect(page.locator(SHARED_KEY)).toHaveCount(0);
// The rest of the panel is untouched: the flow stays complete without it.
await expect(
page.locator('[data-slot="onboarding-ai-keyless"]'),
).toBeVisible();
await expect(
page.locator('[data-slot="onboarding-open-dashboard"]'),
).toBeVisible();
// The grant route itself stays open — it is the user's own consent act
// and is reachable from Settings at any time. What this case pins is the
// decision the screen was handed, so read it back from the source.
const status = await page.request.get("/api/user/ai-provider");
const data = (
(await status.json()) as {
data: { serverProviderHealth: string; serverProviderOffer: boolean };
}
).data;
expect(data.serverProviderHealth).toBe("unhealthy");
expect(data.serverProviderOffer).toBe(false);
});
test("claims nothing on an instance whose provider nobody has used yet", async ({
page,
}) => {
test.setTimeout(90_000);
// The operator has configured a key and nothing has been asked of it —
// the state of every deployment until its first AI call, and so the most
// common way through this screen. The panel may not offer (nothing is
// known to work) and may not report an outage (nothing failed).
await clearSharedProviderResult();
await openDoneScreen(page);
await expect(page.locator(SETTLED)).toHaveAttribute(
"data-ai-state",
"neutral",
{ timeout: 15_000 },
);
await expect(
page.locator('[data-slot="onboarding-ai-keyless"]'),
).toBeVisible();
await expect(page.locator(UNAVAILABLE)).toHaveCount(0);
await expect(page.locator(OFFER)).toHaveCount(0);
await expect(page.locator(GRANT)).toHaveCount(0);
await expect(page.locator(SHARED_KEY)).toHaveCount(0);
const status = await page.request.get("/api/user/ai-provider");
const data = (
(await status.json()) as {
data: { serverProviderHealth: string; serverProviderOffer: boolean };
}
).data;
expect(data.serverProviderHealth).toBe("unknown");
expect(data.serverProviderOffer).toBe(false);
});
});
-149
View File
@@ -1,149 +0,0 @@
/**
* v1.38.19 — the instance state the shared-provider offer reads.
*
* Two rows decide what the setup flow's last screen may say, and neither has
* a product endpoint that writes them the way this journey needs:
*
* `app_settings.admin_ai_key_encrypted` — presence alone makes the operator
* the origin that would serve a user with no provider of their own
* (`managedBy: "server"`). Nothing decrypts it on this path, so the value
* below is a placeholder ciphertext and not a credential of any kind.
*
* `provider_health` — the ledger the tri-state folds. It is written only as
* a side effect of a real completion, which this suite must never make, so
* the journey states the outcome it wants to reason about instead.
*
* Both are INSTANCE-wide by nature: the projection deliberately folds across
* accounts, because a fresh account's own ledger is empty by construction.
* That is why the spec runs serially and puts both back afterwards.
*
* Seeded through `pg`, the same transport `global-setup.ts` and the other
* fixtures use.
*/
import pg from "pg";
/** Not a key. Presence is the only thing read on this path. */
const PLACEHOLDER_ADMIN_KEY = "e2e-admin-key-presence-only";
function connect(): pg.Pool {
const url = process.env.DATABASE_URL;
if (!url) {
throw new Error("[ai-offer-fixture] DATABASE_URL is not set");
}
return new pg.Pool({ connectionString: url });
}
async function userId(pool: pg.Pool, username: string): Promise<string> {
const { rows } = await pool.query<{ id: string }>(
`SELECT id FROM users WHERE username = $1`,
[username],
);
const id = rows[0]?.id;
if (!id) throw new Error(`[ai-offer-fixture] no account named ${username}`);
return id;
}
/** The operator has configured a shared provider on this instance. */
export async function seedOperatorProvider(): Promise<void> {
const pool = connect();
try {
await pool.query(
`INSERT INTO app_settings (id, admin_ai_key_encrypted)
VALUES ('singleton', $1)
ON CONFLICT (id) DO UPDATE SET admin_ai_key_encrypted = EXCLUDED.admin_ai_key_encrypted`,
[PLACEHOLDER_ADMIN_KEY],
);
} finally {
await pool.end();
}
}
/** Put the instance back: no operator key, no ledger rows for either tag. */
export async function clearOperatorProvider(): Promise<void> {
const pool = connect();
try {
await pool.query(
`UPDATE app_settings SET admin_ai_key_encrypted = NULL WHERE id = 'singleton'`,
);
await pool.query(
`DELETE FROM provider_health
WHERE provider_type IN ('admin-openai', 'admin-codex')`,
);
} finally {
await pool.end();
}
}
/**
* State one outcome for the operator's OpenAI provider.
*
* `ok` — it served somebody `okMinutesAgo` minutes ago.
* `auth_failed` — its credential is dead and benched for another hour,
* which is the shape the operator's own instance was in on
* 2026-09-11 (a 500 from the OAuth proxy, then a 401).
*/
export async function seedSharedProviderResult(
username: string,
outcome: { result: "ok"; okMinutesAgo: number } | { result: "auth_failed" },
): Promise<void> {
const pool = connect();
try {
const id = await userId(pool, username);
await pool.query(
`DELETE FROM provider_health
WHERE provider_type IN ('admin-openai', 'admin-codex')`,
);
if (outcome.result === "ok") {
await pool.query(
`INSERT INTO provider_health
(id, user_id, provider_type, last_result, consecutive_failures,
last_ok_at, updated_at)
VALUES ($1, $2, 'admin-openai', 'ok', 0,
NOW() - ($3 || ' minutes')::interval, NOW())`,
[`e2e-ph-${id}`, id, String(outcome.okMinutesAgo)],
);
} else {
await pool.query(
`INSERT INTO provider_health
(id, user_id, provider_type, last_result, last_status,
consecutive_failures, last_failure_at, next_retry_at, updated_at)
VALUES ($1, $2, 'admin-openai', 'auth_failed', 401, 1,
NOW(), NOW() + interval '1 hour', NOW())`,
[`e2e-ph-${id}`, id],
);
}
} finally {
await pool.end();
}
}
/**
* The ledger holds nothing for either shared tag, with the operator's key
* still in place: a brand-new instance before its first AI call, which is
* what every deployment looks like until somebody makes one.
*/
export async function clearSharedProviderResult(): Promise<void> {
const pool = connect();
try {
await pool.query(
`DELETE FROM provider_health
WHERE provider_type IN ('admin-openai', 'admin-codex')`,
);
} finally {
await pool.end();
}
}
/** No receipt on file — the state a fresh account reaches the screen in. */
export async function clearAiConsent(username: string): Promise<void> {
const pool = connect();
try {
const id = await userId(pool, username);
await pool.query(`DELETE FROM consent_receipts WHERE user_id = $1`, [id]);
await pool.query(`DELETE FROM rate_limits WHERE key = 'consent:' || $1`, [
id,
]);
} finally {
await pool.end();
}
}
-21
View File
@@ -717,21 +717,6 @@ export const E2E_SETUP_LOCALE = {
role: "USER",
} as const;
/**
* v1.38.19 — the shared-provider offer's own account.
*
* Its journey seeds INSTANCE-wide state (the operator key's presence and the
* provider-health ledger the tri-state folds) and mints a consent receipt on
* the account, so it cannot share one with a journey that asserts a module
* map or a tile order.
*/
export const E2E_SETUP_AI_OFFER = {
email: "e2e-setup-ai-offer@healthlog.test",
username: "e2e-setup-ai-offer",
password: "Ao6!Zd3fB9qMv2Rk",
role: "USER",
} as const;
export const SETUP_BP_STORAGE_STATE_PATH = resolve(
process.cwd(),
"e2e/setup/storageStateSetupBp.json",
@@ -760,10 +745,6 @@ export const SETUP_LOCALE_STORAGE_STATE_PATH = resolve(
process.cwd(),
"e2e/setup/storageStateSetupLocale.json",
);
export const SETUP_AI_OFFER_STORAGE_STATE_PATH = resolve(
process.cwd(),
"e2e/setup/storageStateSetupAiOffer.json",
);
const SETUP_FLOW_ACCOUNTS = [
E2E_SETUP_BP,
@@ -772,7 +753,6 @@ const SETUP_FLOW_ACCOUNTS = [
E2E_SETUP_CHILD,
E2E_SETUP_A11Y,
E2E_SETUP_LOCALE,
E2E_SETUP_AI_OFFER,
E2E_SETUP_CONNECT,
] as const;
@@ -1882,7 +1862,6 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
await capture(E2E_SETUP_A11Y, SETUP_A11Y_STORAGE_STATE_PATH);
await capture(E2E_SETUP_LOCALE, SETUP_LOCALE_STORAGE_STATE_PATH);
await capture(E2E_SETUP_CONNECT, SETUP_CONNECT_STORAGE_STATE_PATH);
await capture(E2E_SETUP_AI_OFFER, SETUP_AI_OFFER_STORAGE_STATE_PATH);
await capture(E2E_SETUP_CHILD, SETUP_CHILD_STORAGE_STATE_PATH);
await pool.query(
`UPDATE users SET totp_confirmed_at = $2 WHERE username = $1`,
+4 -3
View File
@@ -13,10 +13,11 @@ import type {
DashboardTargetBands,
} from "@/lib/dashboard/snapshot";
import { getBpTargets } from "@/lib/analytics/bp-targets";
import { METRIC_BOUNDS } from "@/lib/analytics/effective-range";
import {
buildTrafficLightBands,
buildTrafficRange,
buildWeightBandsFromHeight,
buildBandsFromTrafficRange,
buildWeightRangeFromHeight,
getBodyFatTargetRange,
} from "@/lib/analytics/value-bands";
@@ -77,8 +78,8 @@ function mockTargetBands(): DashboardTargetBands {
upperBound: 55,
}),
weightRange: buildWeightRangeFromHeight(180),
weightBands: buildWeightBandsFromHeight(180, {
lowerBound: 30,
weightBands: buildBandsFromTrafficRange(buildWeightRangeFromHeight(180), {
lowerBound: METRIC_BOUNDS.WEIGHT.min,
upperBound: 250,
}),
};
+85 -65
View File
@@ -142,7 +142,15 @@
"auth": {
"reproof": {
"required": "Bestätige dein aktuelles Passwort, um das zu ändern.",
"failed": "Die Bestätigung ist fehlgeschlagen. Es wurde nichts geändert."
"failed": "Die Bestätigung ist fehlgeschlagen. Es wurde nichts geändert.",
"too_weak": "Bestätige mit deinem zweiten Faktor oder einem Passkey. Bei diesem Konto reicht das Passwort allein nicht."
},
"missing": "Deine Sitzung ist abgelaufen, daher wurde nichts geändert. Melde dich erneut an, um weiterzumachen.",
"passkey": {
"last_method": "Dieser Passkey ist deine einzige Anmeldemöglichkeit. Lege zuerst ein Passwort fest oder füge einen weiteren Passkey hinzu."
},
"mfa": {
"already_active": "Für dieses Konto ist bereits eine Authenticator-App eingerichtet. Deaktiviere sie zuerst, um eine neue einzurichten."
}
},
"biomarkers": {
@@ -178,6 +186,54 @@
},
"aiRuns": {
"readerBusy": "Der Dokumentleser ist gerade mit anderen Aufgaben beschäftigt. Versuch es in einer Minute noch einmal."
},
"http": {
"unauthenticated": "Deine Sitzung ist abgelaufen, daher wurde nichts geändert. Melde dich erneut an, um weiterzumachen.",
"rate_limited": "Gerade gibt es zu viele Anfragen, daher wurde nichts geändert. Warte kurz und versuche es erneut.",
"forbidden": "Dafür fehlt dir die Berechtigung, daher wurde nichts geändert.",
"invalid": "Einige Angaben wurden nicht angenommen, daher wurde nichts geändert. Prüfe sie und versuche es erneut."
},
"ai_provider": {
"private_host": "Diese Adresse liegt in einem privaten Netz. Der Betreiber muss ihren genauen Ursprung erst in AI_PRIVATE_ORIGINS freigeben."
},
"encounter": {
"invalid": "Prüfe die Angaben zum Termin und versuche es erneut.",
"practitioner-not-found": "Diese Praxis bzw. dieser Arzt existiert nicht mehr. Wähle einen anderen Eintrag oder keinen.",
"reminder-not-found": "Diese Vorsorge existiert nicht mehr. Wähle eine andere oder keine.",
"reminder-conflict": "Ein geplanter Termin kann keine Vorsorge abschließen, denn er hat noch nicht stattgefunden."
},
"vaccination": {
"invalid": "Prüfe die Angaben zur Impfung und versuche es erneut.",
"practitioner-not-found": "Diese Praxis bzw. dieser Arzt existiert nicht mehr. Wähle einen anderen Eintrag oder keinen.",
"encounter-not-found": "Dieser Termin existiert nicht mehr. Wähle einen anderen oder keinen.",
"custom-vaccine-not-found": "Dieser Impfstoff existiert nicht mehr. Wähle ihn erneut aus der Liste.",
"not-found": "Diese Impfung ist nicht mehr vorhanden.",
"identity-required": "Eine Impfung braucht einen Namen: Wähle einen Impfstoff aus der Liste oder gib ein, was im Impfpass steht.",
"booster-invalid": "Prüfe die Angaben zur Auffrischung und versuche es erneut.",
"booster-no-antigen": "Diese Impfung nennt keinen Impfstoff aus dem Katalog, daher lässt sich keine Auffrischung planen.",
"booster-out-of-scope": "Deine Freigabe für diese Akte reicht nicht, um eine Auffrischung zu planen."
},
"allergy": {
"invalid": "Prüfe die Angaben zur Allergie und versuche es erneut."
},
"practitioner": {
"invalid": "Prüfe die Angaben zur Praxis bzw. zum Arzt und versuche es erneut."
},
"workout": {
"batch": {
"invalid": "Prüfe die Angaben zum Training und versuche es erneut.",
"too_large": "Zu viele Trainings auf einmal. Schicke sie in kleineren Paketen.",
"payload_too_large": "Die Trainingsdaten sind zu groß, um sie auf einmal zu speichern.",
"source_not_permitted": "Diese Verbindung darf keine Trainings unter dieser Quelle speichern."
}
},
"ai": {
"provider": {
"none": "Es ist kein KI-Anbieter eingerichtet, daher wurde nichts gesendet. Richte einen in den KI-Einstellungen ein."
},
"budget": {
"exceeded": "Das KI-Budget für heute ist aufgebraucht, daher wurde nichts gesendet. Morgen steht es wieder bereit."
}
}
},
"doctorReport": {
@@ -979,7 +1035,9 @@
"typeStressLevel": "Stresslevel",
"typeTrainingReadiness": "Trainingsbereitschaft",
"typeAcuteTrainingLoad": "Akute Trainingsbelastung",
"sourceGarmin": "Garmin"
"sourceGarmin": "Garmin",
"valueOutOfRange": "Gib einen Wert zwischen {min} und {max} {unit} ein.",
"valueOutOfRangeNoUnit": "Gib einen Wert zwischen {min} und {max} ein."
},
"metricCatalog": {
"title": "Was HealthLog erfassen kann",
@@ -1531,7 +1589,8 @@
"gallbladderDiscomfort": "Gallenblasen-Beschwerden"
},
"deleteConfirmTitle": "Eintrag löschen?",
"deleteConfirmBody": "Dieser Nebenwirkungs-Eintrag wird dauerhaft entfernt."
"deleteConfirmBody": "Dieser Nebenwirkungs-Eintrag wird dauerhaft entfernt.",
"saveError": "Eintrag konnte nicht gespeichert werden"
},
"scheduling": {
"naturalLanguage": {
@@ -1955,7 +2014,6 @@
},
"intake": {
"title": "Einnahmeverlauf",
"importButton": "Importieren",
"rowActions": {
"openMenu": "Zeilen-Aktionen öffnen",
"edit": "Bearbeiten",
@@ -1965,14 +2023,7 @@
"rowToggleLabel": "Zeile auswählen"
},
"bulkDelete": {
"selectionCount": "{count} Einträge ausgewählt",
"deleteButton": "Auswahl löschen",
"cancelButton": "Abbrechen",
"confirmTitle": "Ausgewählte Einträge löschen?",
"confirmBody": "{count} Einträge werden dauerhaft entfernt. Das Medikament bleibt.",
"confirmAction": "Löschen",
"toast": "Auswahl gelöscht",
"failed": "Auswahl konnte nicht gelöscht werden."
"cancelButton": "Abbrechen"
},
"edit": {
"dialogTitle": "Einnahme bearbeiten",
@@ -2846,11 +2897,6 @@
"bmiEmptyAction": "Account-Einstellungen öffnen",
"noAnalysisYet": "Noch keine Einschätzung vorhanden.",
"assessmentPreparing": "Einschätzung wird vorbereitet…",
"personalRecord": {
"badge": "PR",
"tooltip": "Persönlicher Rekord: {value} {unit} am {date}",
"benchmark": "Typisch in dem Alter: {low} bis {high} {unit} ({source})"
},
"loadError": "Insights konnten nicht geladen werden",
"heroPersonalBaseline": "Basierend auf deinen letzten 90 Tagen",
"heroRegenerating": "Wird generiert",
@@ -3017,8 +3063,6 @@
},
"correlationRow": {
"title": "Zusammenhänge",
"experimentCta": "7-Tage-Experiment starten",
"experimentTooltip": "Bald verfügbar",
"emptyTitle": "Mehr Daten nötig, um diesen Zusammenhang zu zeigen",
"sourceChip": "basierend auf {n} gepaarten Messungen ({window})",
"sourceWindowLast30": "letzte 30 Tage",
@@ -5179,7 +5223,9 @@
"connectCta": "Gerät verbinden",
"logCta": "Ersten Wert eintragen",
"importCta": "Bestehende Daten importieren",
"modulesCta": "Module auswählen"
"modulesCta": "Module auswählen",
"aiOptional": "KI ist optional. Alles hier funktioniert auch ohne.",
"aiSetupCta": "KI einrichten"
},
"welcomeBack": {
"title": "Willkommen zurück",
@@ -5200,37 +5246,6 @@
"allergiesPlaceholder": "z. B. Penicillin, Laktose",
"privacyNote": "Verschlüsselt gespeichert und nur für besseren Kontext genutzt. Medikamente fragen wir hier nicht ab, die kannst du jederzeit auf der Medikamenten-Seite ergänzen."
},
"ai": {
"panelTitle": "So sehen Insights aus",
"panelIntro": "Coach, das tägliche Briefing und die Einschätzungen je Messwert übersetzen deine eigenen Zahlen in Klartext. So sieht das aus, bevor du irgendetwas verbindest.",
"showSample": "Beispiel-Briefing ansehen",
"sampleTitle": "Tägliches Briefing",
"sampleTag": "Beispiel",
"sampleAriaLabel": "Beispielhaftes tägliches Briefing",
"sampleBody": "Gewicht in zwei Wochen um 1,2 kg gesunken. Ruhepuls stabil bei 58 bpm. Blutdruck an 6 von 7 Tagen im Zielbereich; der einzige hohe Wert folgte auf ein spätes, salziges Abendessen.",
"sampleCaption": "Nur ein Beispiel, nicht deine eigenen Daten. So liest sich ein Briefing, sobald Insights aktiv sind.",
"ladderTitle": "Drei Wege, es zu aktivieren",
"ladderLocalTitle": "Lokales Modell: KI-Anfragen bleiben in deinem Netzwerk",
"ladderLocalBody": "Richte HealthLog auf Ollama oder LM Studio auf deinem eigenen Rechner aus. Kostenlos und vollständig privat, der ruhige Standard.",
"ladderByokTitle": "Eigenen Schlüssel verwenden",
"ladderByokBody": "Nutze deinen eigenen OpenAI- oder Anthropic-Schlüssel. Er läuft über dein Konto und deine Abrechnung, und die API-Stufe hält deine Daten standardmäßig aus dem Training heraus.",
"ladderOauthTitle": "Mit einem Abo anmelden",
"ladderOauthBody": "Verbinde ein ChatGPT-Konto: keine Kosten pro Token, aber die Bedingungen der Verbraucherstufe können Inhalte fürs Training nutzen. Deshalb eine bewusste Wahl, nie der Standard für deine Gesundheitsdaten.",
"keylessLine": "HealthLog ist auch ohne KI vollständig nutzbar. Deine Kacheln, Diagramme, Trends und Exporte funktionieren alle. KI ergänzt nur den Klartext obendrauf.",
"setupCta": "Einrichten (etwa eine Minute)",
"sharedKeyNote": "Diese Instanz stellt bereits einen gemeinsamen KI-Anbieter bereit, deshalb funktionieren Insights für dich sofort, keine Einrichtung nötig.",
"offer": {
"title": "Die Zusammenfassung dieser Instanz ist bereit.",
"body": "Der Betreiber stellt einen Anbieter bereit, der zuletzt innerhalb der letzten 24 Stunden funktioniert hat. Mit einem Tipp schaltest du den Klartext ein; deine Daten gehen dann an diesen einen Anbieter, an niemanden sonst.",
"cta": "Klartext einschalten",
"granted": "Eingeschaltet. Die erste Zusammenfassung erscheint auf dem Dashboard.",
"error": "Das Einschalten hat nicht geklappt. Versuch es gleich noch einmal."
},
"unavailable": {
"title": "Der Anbieter dieser Instanz antwortet gerade nicht.",
"body": "HealthLog funktioniert ohne Klartext vollständig. Du kannst später in den Einstellungen einen eigenen Anbieter hinterlegen."
}
},
"steps": {
"who": "Für wen diese Akte ist",
"areas": "Worauf du achten willst",
@@ -5944,6 +5959,7 @@
"inProgress": "Ein Import des Einnahmeverlaufs läuft bereits. Auf sein Ende warten.",
"workerUnavailable": "Der Hintergrunddienst ist nicht verfügbar, es wurde nichts importiert.",
"failed": "Der Import ist fehlgeschlagen. Es wurde nichts geschrieben.",
"stillRunning": "Der Import dauert länger als üblich und kann noch fertig werden. Sieh dir deine Einnahmen in ein paar Minuten noch einmal an.",
"columnsHeading": "Was mit jeder Spalte passiert",
"jsonOnly": "nur JSON",
"previewLine": "{rows} Zeilen gelesen, {queued} würden importiert, {refused} abgewiesen",
@@ -7792,7 +7808,9 @@
"hrv": "HRV",
"weight": "Wiegungen",
"cycle": "Menstruationszyklus"
}
},
"passkeyRenameFailed": "Der Passkey konnte nicht umbenannt werden.",
"passkeyRemoveFailed": "Der Passkey konnte nicht entfernt werden."
},
"admin": {
"title": "Administration",
@@ -9314,16 +9332,8 @@
},
"eventMedicationReminder": "Medikamenten-Erinnerung",
"eventMedicationReminderDesc": "Wird zu jeder geplanten Einnahmezeit gesendet.",
"eventMeasurementAnomaly": "Auffälliger Messwert",
"eventMeasurementAnomalyDesc": "Wird gesendet, wenn ein Wert deutlich außerhalb deines üblichen Bereichs liegt.",
"eventComplianceLow": "Niedrige Therapietreue",
"eventComplianceLowDesc": "Wird gesendet, wenn deine Therapietreue in den letzten Tagen unter 80 % fällt.",
"eventWithingsSyncFailed": "Withings-Sync fehlgeschlagen",
"eventWithingsSyncFailedDesc": "Wird gesendet, wenn die Withings-Synchronisierung mehrere Versuche in Folge scheitert.",
"eventSystemAlert": "Systemmeldung",
"eventSystemAlertDesc": "Allgemeine Mitteilungen vom HealthLog-Server, etwa Wartungsfenster oder Konto-Hinweise.",
"eventPersonalRecord": "Persönlicher Rekord",
"eventPersonalRecordDesc": "Wird gesendet, wenn ein Messwert deine bisherige Bestmarke übertrifft.",
"eventMoodReminder": "Stimmungs-Erinnerung",
"eventMoodReminderDesc": "Tägliche Erinnerung, deine aktuelle Stimmung zu protokollieren.",
"eventCyclePeriodSoon": "Periode steht bevor",
@@ -10972,7 +10982,9 @@
"deleteTitle": "Eigenen Impfstoff entfernen?",
"sectionDescription": "Selbst angelegte Impfstoffe, die der Katalog nicht kennt.",
"catalogGroup": "Katalog"
}
},
"deleted": "Impfung gelöscht",
"restoreFailed": "Impfung konnte nicht wiederhergestellt werden."
},
"illness": {
"title": "Krankheit",
@@ -11186,7 +11198,6 @@
"title": "Pläne",
"pageDescription": "Wenn-dann-Pläne aus deinen Coach-Gesprächen. Bestätige einen Vorschlag, damit der Coach ihn sich merkt.",
"readOnlyNote": "Der Coach ist gerade nicht verfügbar, deine Pläne kannst du aber weiter lesen und löschen.",
"proposalTitle": "Plan-Vorschlag",
"ifThen": "Wenn {cue}, dann {action}.",
"targetPrefix": "Ziel: {target}",
"reviewPrefix": "Check-in: {when}",
@@ -12481,6 +12492,10 @@
"purgeFailed": "Das Dokument konnte nicht gelöscht werden.",
"purgeConfirmTitle": "Dieses Dokument endgültig löschen?",
"purgeConfirmBody": "Es wird jetzt entfernt statt am Ende der Wartezeit und lässt sich nicht wiederherstellen."
},
"picker": {
"capped": "Hier stehen die neuesten {count}. Ältere Dokumente findest du über die Suche.",
"cappedSearching": "Hier stehen die neuesten {count} Treffer. Ältere findest du mit einer genaueren Suche."
}
},
"selfDescription": {
@@ -12528,7 +12543,8 @@
"save": "Speichern",
"savedToast": "Eigene Metrik gespeichert",
"updatedToast": "Eigene Metrik aktualisiert",
"saveError": "Eigene Metrik konnte nicht gespeichert werden"
"saveError": "Eigene Metrik konnte nicht gespeichert werden",
"nameTaken": "Es gibt bereits eine eigene Metrik mit diesem Namen."
},
"entry": {
"value": "Wert",
@@ -13483,7 +13499,9 @@
"defaultReason": "Kein früherer Termin bei derselben Praxis, in derselben Fachrichtung oder derselben Art.",
"capped": "Die letzten zwei Jahre",
"cappedReason": "Der letzte passende Termin liegt mehr als zwei Jahre zurück, und ein Bericht umfasst höchstens zwei Jahre."
}
},
"deleted": "Termin gelöscht",
"restoreFailed": "Termin konnte nicht wiederhergestellt werden."
},
"checkups": {
"viewSwitchLabel": "Vorsorge, Termine, Eingriffe oder Körperstellen anzeigen",
@@ -13516,7 +13534,9 @@
"deleteTitle": "Eintrag löschen?",
"deleteDescription": "Deine bisherigen Termine bleiben erhalten und behalten diesen Namen.",
"deleteFailed": "Eintrag konnte nicht gelöscht werden.",
"noSpecialtyGroup": "Ohne Fachrichtung"
"noSpecialtyGroup": "Ohne Fachrichtung",
"deleted": "Eintrag gelöscht",
"restoreFailed": "Eintrag konnte nicht wiederhergestellt werden."
},
"links": {
"picker": {
+85 -65
View File
@@ -142,7 +142,15 @@
"auth": {
"reproof": {
"required": "Confirm your current password to change this.",
"failed": "That did not verify. Nothing was changed."
"failed": "That did not verify. Nothing was changed.",
"too_weak": "Confirm with your second factor or a passkey. On this account a password alone is not enough."
},
"missing": "Your session has ended, so nothing was changed. Sign in again to continue.",
"passkey": {
"last_method": "This passkey is your only way to sign in. Set a password or add another passkey first."
},
"mfa": {
"already_active": "An authenticator app is already set up for this account. Disable it first to set up a new one."
}
},
"biomarkers": {
@@ -178,6 +186,54 @@
},
"aiRuns": {
"readerBusy": "The document reader is busy with other work. Try again in a minute."
},
"http": {
"unauthenticated": "Your session has ended, so nothing was changed. Sign in again to continue.",
"rate_limited": "There are too many requests just now, so nothing was changed. Wait a moment and try again.",
"forbidden": "You don't have permission for this, so nothing was changed.",
"invalid": "Some of the entries were not accepted, so nothing was changed. Check them and try again."
},
"ai_provider": {
"private_host": "This address is on a private network. The operator has to allow its exact origin in AI_PRIVATE_ORIGINS before it can be used."
},
"encounter": {
"invalid": "Check the visit details and try again.",
"practitioner-not-found": "This practice or doctor no longer exists. Choose another or none.",
"reminder-not-found": "This checkup no longer exists. Choose another or none.",
"reminder-conflict": "A planned visit cannot close a checkup, because it has not happened yet."
},
"vaccination": {
"invalid": "Check the vaccination details and try again.",
"practitioner-not-found": "This practice or doctor no longer exists. Choose another or none.",
"encounter-not-found": "This visit no longer exists. Choose another or none.",
"custom-vaccine-not-found": "This vaccine no longer exists. Choose it again from the list.",
"not-found": "This vaccination is no longer available.",
"identity-required": "A dose needs a name: pick a vaccine from the list or type the name on your vaccination card.",
"booster-invalid": "Check the booster details and try again.",
"booster-no-antigen": "This dose names no vaccine from the catalogue, so no booster can be planned for it.",
"booster-out-of-scope": "Your access to this record does not cover planning a booster."
},
"allergy": {
"invalid": "Check the allergy details and try again."
},
"practitioner": {
"invalid": "Check the practice or doctor details and try again."
},
"workout": {
"batch": {
"invalid": "Check the workout details and try again.",
"too_large": "Too many workouts at once. Send them in smaller batches.",
"payload_too_large": "The workout data is too large to save in one go.",
"source_not_permitted": "This connection may not save workouts under that source."
}
},
"ai": {
"provider": {
"none": "No AI provider is set up, so nothing was sent to one. Set one up in the AI settings."
},
"budget": {
"exceeded": "Today's AI budget is used up, so nothing was sent. It renews tomorrow."
}
}
},
"doctorReport": {
@@ -979,7 +1035,9 @@
"typeStressLevel": "Stress level",
"typeTrainingReadiness": "Training readiness",
"typeAcuteTrainingLoad": "Acute training load",
"sourceGarmin": "Garmin"
"sourceGarmin": "Garmin",
"valueOutOfRange": "Enter a value between {min} and {max} {unit}.",
"valueOutOfRangeNoUnit": "Enter a value between {min} and {max}."
},
"metricCatalog": {
"title": "What HealthLog can track",
@@ -1531,7 +1589,8 @@
"gallbladderDiscomfort": "Gallbladder discomfort"
},
"deleteConfirmTitle": "Delete this entry?",
"deleteConfirmBody": "This side-effect entry will be permanently removed."
"deleteConfirmBody": "This side-effect entry will be permanently removed.",
"saveError": "Could not save the entry"
},
"scheduling": {
"naturalLanguage": {
@@ -1955,7 +2014,6 @@
},
"intake": {
"title": "Intake history",
"importButton": "Import",
"rowActions": {
"openMenu": "Open row actions",
"edit": "Edit",
@@ -1965,14 +2023,7 @@
"rowToggleLabel": "Select row"
},
"bulkDelete": {
"selectionCount": "{count} entries selected",
"deleteButton": "Delete selection",
"cancelButton": "Cancel",
"confirmTitle": "Delete the selected entries?",
"confirmBody": "{count} entries will be removed permanently. The medication itself stays.",
"confirmAction": "Delete",
"toast": "Selection deleted",
"failed": "Could not delete the selection."
"cancelButton": "Cancel"
},
"edit": {
"dialogTitle": "Edit intake",
@@ -2846,11 +2897,6 @@
"bmiEmptyTitle": "Set your height to see BMI",
"bmiEmptyDescription": "BMI needs your height. Add it under Settings → Account.",
"bmiEmptyAction": "Open account settings",
"personalRecord": {
"badge": "PR",
"tooltip": "Personal record: {value} {unit} on {date}",
"benchmark": "Typical at that age: {low} to {high} {unit} ({source})"
},
"loadError": "Failed to load insights",
"heroPersonalBaseline": "Based on your last 90 days",
"heroRegenerating": "Regenerating",
@@ -3017,8 +3063,6 @@
},
"correlationRow": {
"title": "Relationships",
"experimentCta": "Try a 7-day experiment",
"experimentTooltip": "Coming soon",
"emptyTitle": "Need more data to see this relationship",
"sourceChip": "based on {n} paired readings ({window})",
"sourceWindowLast30": "last 30 days",
@@ -5179,7 +5223,9 @@
"connectCta": "Connect a device",
"logCta": "Log your first measurement",
"importCta": "Import existing history",
"modulesCta": "Choose your modules"
"modulesCta": "Choose your modules",
"aiOptional": "AI is optional. Everything here works without it.",
"aiSetupCta": "Set up AI"
},
"welcomeBack": {
"title": "Welcome back",
@@ -5200,37 +5246,6 @@
"allergiesPlaceholder": "e.g. penicillin, lactose",
"privacyNote": "Stored encrypted and used only to give you better context. We don't ask for your medications here. Add those anytime from the Medications page."
},
"ai": {
"panelTitle": "See what insights look like",
"panelIntro": "Coach, the daily briefing and the per-metric reads turn your own numbers into plain language. Here's the shape of it, before you connect anything.",
"showSample": "See a sample briefing",
"sampleTitle": "Daily Briefing",
"sampleTag": "Example",
"sampleAriaLabel": "Example daily briefing",
"sampleBody": "Weight down 1.2 kg over two weeks. Resting pulse steady at 58 bpm. Blood pressure in range on 6 of 7 days; the one high reading followed a late, salty dinner.",
"sampleCaption": "Example only, not your own data. This is what a briefing reads like once insights are on.",
"ladderTitle": "Three ways to turn it on",
"ladderLocalTitle": "Local model: AI requests stay on your network",
"ladderLocalBody": "Point HealthLog at Ollama or LM Studio on your own machine. Free and fully private, the calm default.",
"ladderByokTitle": "Bring your own key",
"ladderByokBody": "Use your own OpenAI or Anthropic key. It runs under your account and billing, and the API tier keeps your data out of training by default.",
"ladderOauthTitle": "Sign in with a subscription",
"ladderOauthBody": "Connect a ChatGPT account. No per-token cost, but the consumer tier's terms may use content for training, so it's a deliberate choice, never the default for your health data.",
"keylessLine": "HealthLog is fully useful without AI. Your tiles, charts, trends and exports all work. AI only adds the plain-language read on top.",
"setupCta": "Set it up (about a minute)",
"sharedKeyNote": "This instance already provides a shared AI provider, so insights work for you right now, no setup needed.",
"offer": {
"title": "This instance can write your summary in plain language.",
"body": "The operator provides a provider that last worked within the past 24 hours. One tap turns the plain-language read on; your data then goes to that one provider and to nobody else.",
"cta": "Turn on plain language",
"granted": "Turned on. The first summary appears on your dashboard.",
"error": "Turning it on did not work. Give it another try in a moment."
},
"unavailable": {
"title": "This instance’s provider is not answering right now.",
"body": "HealthLog is fully useful without the plain-language read. You can add a provider of your own later under Settings."
}
},
"steps": {
"who": "Who this record is for",
"areas": "What to keep an eye on",
@@ -5944,6 +5959,7 @@
"inProgress": "A dose-history import is already running. Wait for it to finish.",
"workerUnavailable": "The background worker is not available, so nothing was imported.",
"failed": "The import failed. Nothing was written.",
"stillRunning": "The import is taking longer than usual and may still finish. Look at your doses again in a few minutes.",
"columnsHeading": "What each column does",
"jsonOnly": "JSON only",
"previewLine": "{rows} rows read, {queued} would be imported, {refused} refused",
@@ -7792,7 +7808,9 @@
"hrv": "HRV",
"weight": "weigh-ins",
"cycle": "menstrual cycle"
}
},
"passkeyRenameFailed": "Could not rename the passkey.",
"passkeyRemoveFailed": "Could not remove the passkey."
},
"admin": {
"title": "Administration",
@@ -9314,16 +9332,8 @@
},
"eventMedicationReminder": "Medication reminder",
"eventMedicationReminderDesc": "Sent at each scheduled dose time.",
"eventMeasurementAnomaly": "Unusual measurement",
"eventMeasurementAnomalyDesc": "Sent when a reading is well outside your usual range.",
"eventComplianceLow": "Low adherence",
"eventComplianceLowDesc": "Sent when your adherence over the last few days drops below 80 %.",
"eventWithingsSyncFailed": "Withings sync failed",
"eventWithingsSyncFailedDesc": "Sent when Withings synchronisation fails multiple attempts in a row.",
"eventSystemAlert": "System notice",
"eventSystemAlertDesc": "General messages from the HealthLog server, e.g. maintenance windows or account notes.",
"eventPersonalRecord": "Personal record",
"eventPersonalRecordDesc": "Sent when a reading beats your previous best.",
"eventMoodReminder": "Mood reminder",
"eventMoodReminderDesc": "Daily nudge to log your current mood.",
"eventCyclePeriodSoon": "Period coming up",
@@ -10972,7 +10982,9 @@
"deleteTitle": "Remove own vaccine?",
"sectionDescription": "Vaccines you added yourself because the catalogue does not list them.",
"catalogGroup": "Catalogue"
}
},
"deleted": "Vaccination deleted",
"restoreFailed": "The vaccination could not be restored."
},
"illness": {
"title": "Illness",
@@ -11186,7 +11198,6 @@
"title": "Plans",
"pageDescription": "If-then plans agreed in your Coach conversations. Confirm a proposal to make it part of the Coach's memory.",
"readOnlyNote": "The Coach is not available right now, but you can still read and delete your plans.",
"proposalTitle": "Plan proposal",
"ifThen": "If {cue}, then {action}.",
"targetPrefix": "Target: {target}",
"reviewPrefix": "Check-in: {when}",
@@ -12481,6 +12492,10 @@
"purgeFailed": "Couldn't delete the document.",
"purgeConfirmTitle": "Delete this document for good?",
"purgeConfirmBody": "It is removed now instead of at the end of the waiting period, and cannot be restored."
},
"picker": {
"capped": "Showing the newest {count}. Search to find an older document.",
"cappedSearching": "Showing the newest {count} matches. Narrow the search to find an older one."
}
},
"selfDescription": {
@@ -12528,7 +12543,8 @@
"save": "Save",
"savedToast": "Custom metric saved",
"updatedToast": "Custom metric updated",
"saveError": "Could not save the custom metric"
"saveError": "Could not save the custom metric",
"nameTaken": "A custom metric with this name already exists."
},
"entry": {
"value": "Value",
@@ -13483,7 +13499,9 @@
"defaultReason": "No earlier visit with the same practice, specialty or kind.",
"capped": "The last two years",
"cappedReason": "The last matching visit is more than two years ago, and a report covers two years at most."
}
},
"deleted": "Visit deleted",
"restoreFailed": "The visit could not be restored."
},
"checkups": {
"viewSwitchLabel": "Show checkups, visits, procedures or body sites",
@@ -13516,7 +13534,9 @@
"deleteTitle": "Delete this entry?",
"deleteDescription": "Your existing visits stay, and they keep this name.",
"deleteFailed": "The entry could not be deleted.",
"noSpecialtyGroup": "No specialty"
"noSpecialtyGroup": "No specialty",
"deleted": "Entry deleted",
"restoreFailed": "The entry could not be restored."
},
"links": {
"picker": {
+85 -65
View File
@@ -142,7 +142,15 @@
"auth": {
"reproof": {
"required": "Confirma tu contraseña actual para cambiar esto.",
"failed": "La verificación ha fallado. No se ha cambiado nada."
"failed": "La verificación ha fallado. No se ha cambiado nada.",
"too_weak": "Confírmalo con tu segundo factor o una clave de acceso. En esta cuenta no basta con la contraseña."
},
"missing": "Tu sesión ha caducado, así que no se ha cambiado nada. Inicia sesión de nuevo para continuar.",
"passkey": {
"last_method": "Esta clave de acceso es tu única forma de iniciar sesión. Primero establece una contraseña o añade otra clave de acceso."
},
"mfa": {
"already_active": "Esta cuenta ya tiene una aplicación de autenticación configurada. Desactívala primero para configurar una nueva."
}
},
"biomarkers": {
@@ -178,6 +186,54 @@
},
"aiRuns": {
"readerBusy": "El lector de documentos está ocupado con otras tareas. Inténtalo de nuevo en un minuto."
},
"http": {
"unauthenticated": "Tu sesión ha caducado, así que no se ha cambiado nada. Inicia sesión de nuevo para continuar.",
"rate_limited": "Hay demasiadas solicitudes ahora mismo, así que no se ha cambiado nada. Espera un momento y vuelve a intentarlo.",
"forbidden": "No tienes permiso para esto, así que no se ha cambiado nada.",
"invalid": "Algunos datos no se aceptaron, así que no se ha cambiado nada. Revísalos y vuelve a intentarlo."
},
"ai_provider": {
"private_host": "Esta dirección está en una red privada. El operador tiene que permitir antes su origen exacto en AI_PRIVATE_ORIGINS."
},
"encounter": {
"invalid": "Comprueba los datos de la cita e inténtalo de nuevo.",
"practitioner-not-found": "Esta consulta o este médico ya no existe. Elige otro o ninguno.",
"reminder-not-found": "Esta revisión ya no existe. Elige otra o ninguna.",
"reminder-conflict": "Una cita prevista no puede cerrar una revisión, porque todavía no ha tenido lugar."
},
"vaccination": {
"invalid": "Comprueba los datos de la vacuna e inténtalo de nuevo.",
"practitioner-not-found": "Esta consulta o este médico ya no existe. Elige otro o ninguno.",
"encounter-not-found": "Esta cita ya no existe. Elige otra o ninguna.",
"custom-vaccine-not-found": "Esta vacuna ya no existe. Elígela de nuevo en la lista.",
"not-found": "Esta vacuna ya no está disponible.",
"identity-required": "Una dosis necesita un nombre: elige una vacuna de la lista o escribe lo que figura en la cartilla.",
"booster-invalid": "Comprueba los datos del refuerzo e inténtalo de nuevo.",
"booster-no-antigen": "Esta dosis no indica ninguna vacuna del catálogo, así que no se puede planificar un refuerzo.",
"booster-out-of-scope": "Tu acceso a este registro no permite planificar un refuerzo."
},
"allergy": {
"invalid": "Comprueba los datos de la alergia e inténtalo de nuevo."
},
"practitioner": {
"invalid": "Comprueba los datos de la consulta o del médico e inténtalo de nuevo."
},
"workout": {
"batch": {
"invalid": "Comprueba los datos del entrenamiento e inténtalo de nuevo.",
"too_large": "Demasiados entrenamientos a la vez. Envía menos cada vez.",
"payload_too_large": "Los datos del entrenamiento son demasiado grandes para guardarlos de una vez.",
"source_not_permitted": "Esta conexión no puede guardar entrenamientos con esa fuente."
}
},
"ai": {
"provider": {
"none": "No hay ningún proveedor de IA configurado, así que no se ha enviado nada. Configura uno en los ajustes de IA."
},
"budget": {
"exceeded": "El presupuesto de IA de hoy se ha agotado, así que no se ha enviado nada. Se renueva mañana."
}
}
},
"doctorReport": {
@@ -979,7 +1035,9 @@
"typeStressLevel": "Nivel de estrés",
"typeTrainingReadiness": "Preparación para entrenar",
"typeAcuteTrainingLoad": "Carga de entrenamiento aguda",
"sourceGarmin": "Garmin"
"sourceGarmin": "Garmin",
"valueOutOfRange": "Introduce un valor entre {min} y {max} {unit}.",
"valueOutOfRangeNoUnit": "Introduce un valor entre {min} y {max}."
},
"metricCatalog": {
"title": "Lo que HealthLog puede registrar",
@@ -1531,7 +1589,8 @@
"gallbladderDiscomfort": "Molestia en la vesícula biliar"
},
"deleteConfirmTitle": "¿Eliminar esta entrada?",
"deleteConfirmBody": "Esta entrada de efecto secundario se eliminará de forma permanente."
"deleteConfirmBody": "Esta entrada de efecto secundario se eliminará de forma permanente.",
"saveError": "No se pudo guardar la entrada"
},
"scheduling": {
"naturalLanguage": {
@@ -1955,7 +2014,6 @@
},
"intake": {
"title": "Historial de tomas",
"importButton": "Importar",
"rowActions": {
"openMenu": "Abrir acciones de la fila",
"edit": "Editar",
@@ -1965,14 +2023,7 @@
"rowToggleLabel": "Seleccionar fila"
},
"bulkDelete": {
"selectionCount": "{count} entradas seleccionadas",
"deleteButton": "Eliminar selección",
"cancelButton": "Cancelar",
"confirmTitle": "¿Eliminar las entradas seleccionadas?",
"confirmBody": "Se eliminarán {count} entradas de forma permanente. El medicamento se conserva.",
"confirmAction": "Eliminar",
"toast": "Selección eliminada",
"failed": "No se pudo eliminar la selección."
"cancelButton": "Cancelar"
},
"edit": {
"dialogTitle": "Editar toma",
@@ -2846,11 +2897,6 @@
"bmiEmptyTitle": "Añade tu altura para calcular el IMC",
"bmiEmptyDescription": "El IMC necesita tu altura. Guárdala en Ajustes → Cuenta.",
"bmiEmptyAction": "Abrir ajustes de cuenta",
"personalRecord": {
"badge": "PR",
"tooltip": "Récord personal: {value} {unit} el {date}",
"benchmark": "Lo típico a esa edad: de {low} a {high} {unit} ({source})"
},
"loadError": "No se han podido cargar los análisis",
"heroPersonalBaseline": "Basado en tus últimos 90 días",
"heroRegenerating": "Generando",
@@ -3017,8 +3063,6 @@
},
"correlationRow": {
"title": "Correlaciones",
"experimentCta": "Iniciar experimento de 7 días",
"experimentTooltip": "Próximamente",
"emptyTitle": "Necesitamos más datos para mostrar esta correlación",
"sourceChip": "basado en {n} mediciones pareadas ({window})",
"sourceWindowLast30": "Últimos 30 días",
@@ -5179,7 +5223,9 @@
"connectCta": "Conectar un dispositivo",
"logCta": "Registra tu primera medición",
"importCta": "Importar historial existente",
"modulesCta": "Elige tus módulos"
"modulesCta": "Elige tus módulos",
"aiOptional": "La IA es opcional. Todo funciona también sin ella.",
"aiSetupCta": "Configurar IA"
},
"welcomeBack": {
"title": "Bienvenido de nuevo",
@@ -5200,37 +5246,6 @@
"allergiesPlaceholder": "p. ej. penicilina, lactosa",
"privacyNote": "Se almacena cifrado y solo se usa para darte mejor contexto. Aquí no te pedimos tu medicación: puedes añadirla cuando quieras desde la página de Medicación."
},
"ai": {
"panelTitle": "Mira cómo son los insights",
"panelIntro": "Coach, el resumen diario y las lecturas por métrica convierten tus propios números en lenguaje claro. Así se ve, antes de conectar nada.",
"showSample": "Ver un resumen de ejemplo",
"sampleTitle": "Resumen diario",
"sampleTag": "Ejemplo",
"sampleAriaLabel": "Resumen diario de ejemplo",
"sampleBody": "Peso 1,2 kg menos en dos semanas. Pulso en reposo estable en 58 lpm. Tensión dentro del rango 6 de 7 días; la única lectura alta llegó tras una cena tardía y salada.",
"sampleCaption": "Solo un ejemplo, no son tus datos. Así se lee un resumen cuando los insights están activos.",
"ladderTitle": "Tres formas de activarlo",
"ladderLocalTitle": "Modelo local: las solicitudes de IA no salen de tu red",
"ladderLocalBody": "Apunta HealthLog a Ollama o LM Studio en tu propia máquina. Gratis y totalmente privado: la opción tranquila por defecto.",
"ladderByokTitle": "Usa tu propia clave",
"ladderByokBody": "Usa tu propia clave de OpenAI o Anthropic. Funciona con tu cuenta y facturación, y el nivel de API mantiene tus datos fuera del entrenamiento por defecto.",
"ladderOauthTitle": "Inicia sesión con una suscripción",
"ladderOauthBody": "Conecta una cuenta de ChatGPT. Sin coste por token, pero los términos del nivel de consumo pueden usar el contenido para entrenamiento, así que es una elección deliberada, nunca la opción por defecto para tus datos de salud.",
"keylessLine": "HealthLog es plenamente útil sin IA. Tus paneles, gráficos, tendencias y exportaciones funcionan; la IA solo añade la lectura en lenguaje claro por encima.",
"setupCta": "Configúralo (un minuto aprox.)",
"sharedKeyNote": "Esta instancia ya ofrece un proveedor de IA compartido, así que los insights funcionan para ti ahora mismo, sin configuración.",
"offer": {
"title": "Esta instancia ya puede escribir tu resumen en lenguaje claro.",
"body": "El operador ofrece un proveedor que funcionó por última vez en las últimas 24 horas. Con un toque activas el lenguaje claro; tus datos irán entonces a ese único proveedor y a nadie más.",
"cta": "Activar el lenguaje claro",
"granted": "Activado. El primer resumen aparecerá en tu panel.",
"error": "No se pudo activar. Vuelve a intentarlo en un momento."
},
"unavailable": {
"title": "El proveedor de esta instancia no responde en este momento.",
"body": "HealthLog es plenamente útil sin el lenguaje claro. Más adelante puedes añadir tu propio proveedor en los ajustes."
}
},
"steps": {
"who": "Para quién es este registro",
"areas": "Qué quieres vigilar",
@@ -5944,6 +5959,7 @@
"inProgress": "Ya hay una importación de historial en curso. Espera a que termine.",
"workerUnavailable": "El proceso en segundo plano no está disponible, así que no se importó nada.",
"failed": "La importación falló. No se escribió nada.",
"stillRunning": "La importación está tardando más de lo habitual y aún puede terminar. Vuelve a mirar tus dosis dentro de unos minutos.",
"columnsHeading": "Qué hace cada columna",
"jsonOnly": "solo JSON",
"previewLine": "{rows} filas leídas, {queued} se importarían, {refused} rechazadas",
@@ -7792,7 +7808,9 @@
"hrv": "VFC",
"weight": "pesajes",
"cycle": "ciclo menstrual"
}
},
"passkeyRenameFailed": "No se pudo cambiar el nombre de la clave de acceso.",
"passkeyRemoveFailed": "No se pudo quitar la clave de acceso."
},
"admin": {
"title": "Administration",
@@ -9314,16 +9332,8 @@
},
"eventMedicationReminder": "Recordatorio de medicación",
"eventMedicationReminderDesc": "Se envía a cada hora de dosis programada.",
"eventMeasurementAnomaly": "Medición inusual",
"eventMeasurementAnomalyDesc": "Se envía cuando una lectura está claramente fuera de tu rango habitual.",
"eventComplianceLow": "Cumplimiento bajo",
"eventComplianceLowDesc": "Se envía cuando tu tasa de toma de los últimos días baja del 80 %.",
"eventWithingsSyncFailed": "Error de sincronización con Withings",
"eventWithingsSyncFailedDesc": "Se envía cuando la sincronización con Withings falla varios intentos seguidos.",
"eventSystemAlert": "Aviso del sistema",
"eventSystemAlertDesc": "Mensajes generales del servidor HealthLog, por ejemplo ventanas de mantenimiento o avisos de cuenta.",
"eventPersonalRecord": "Récord personal",
"eventPersonalRecordDesc": "Se envía cuando una lectura supera tu mejor marca anterior.",
"eventMoodReminder": "Recordatorio de ánimo",
"eventMoodReminderDesc": "Recordatorio diario para registrar tu ánimo actual.",
"eventCyclePeriodSoon": "Período próximo",
@@ -10972,7 +10982,9 @@
"deleteTitle": "¿Quitar la vacuna propia?",
"sectionDescription": "Vacunas que añadiste tú porque el catálogo no las recoge.",
"catalogGroup": "Catálogo"
}
},
"deleted": "Vacuna eliminada",
"restoreFailed": "No se pudo restaurar la vacuna."
},
"illness": {
"title": "Enfermedad",
@@ -11186,7 +11198,6 @@
"title": "Planes",
"pageDescription": "Planes de tipo «si… entonces» acordados en tus conversaciones con el Coach. Confirma una propuesta para que el Coach la recuerde.",
"readOnlyNote": "El Coach no está disponible ahora mismo, pero aún puedes leer y eliminar tus planes.",
"proposalTitle": "Propuesta de plan",
"ifThen": "Si {cue}, entonces {action}.",
"targetPrefix": "Objetivo: {target}",
"reviewPrefix": "Revisión: {when}",
@@ -12481,6 +12492,10 @@
"purgeFailed": "No se pudo eliminar el documento.",
"purgeConfirmTitle": "¿Eliminar este documento definitivamente?",
"purgeConfirmBody": "Se elimina ahora en lugar de al final del periodo de espera y no se puede restaurar."
},
"picker": {
"capped": "Se muestran los {count} más recientes. Usa la búsqueda para encontrar un documento más antiguo.",
"cappedSearching": "Se muestran las {count} coincidencias más recientes. Afina la búsqueda para encontrar una más antigua."
}
},
"selfDescription": {
@@ -12528,7 +12543,8 @@
"save": "Guardar",
"savedToast": "Métrica personalizada guardada",
"updatedToast": "Métrica personalizada actualizada",
"saveError": "No se pudo guardar la métrica personalizada"
"saveError": "No se pudo guardar la métrica personalizada",
"nameTaken": "Ya existe una métrica personalizada con este nombre."
},
"entry": {
"value": "Valor",
@@ -13483,7 +13499,9 @@
"defaultReason": "No hay ninguna visita anterior con la misma consulta, especialidad o tipo.",
"capped": "Los últimos dos años",
"cappedReason": "La última visita coincidente fue hace más de dos años, y un informe abarca como máximo dos años."
}
},
"deleted": "Cita eliminada",
"restoreFailed": "No se pudo restaurar la cita."
},
"checkups": {
"viewSwitchLabel": "Mostrar revisiones, citas, procedimientos o zonas del cuerpo",
@@ -13516,7 +13534,9 @@
"deleteTitle": "¿Eliminar esta entrada?",
"deleteDescription": "Tus citas anteriores se mantienen y conservan este nombre.",
"deleteFailed": "No se pudo eliminar la entrada.",
"noSpecialtyGroup": "Sin especialidad"
"noSpecialtyGroup": "Sin especialidad",
"deleted": "Entrada eliminada",
"restoreFailed": "No se pudo restaurar la entrada."
},
"links": {
"picker": {
+85 -65
View File
@@ -142,7 +142,15 @@
"auth": {
"reproof": {
"required": "Confirmez votre mot de passe actuel pour modifier ceci.",
"failed": "La vérification a échoué. Rien n'a été modifié."
"failed": "La vérification a échoué. Rien n'a été modifié.",
"too_weak": "Confirmez avec votre second facteur ou une clé d’accès. Sur ce compte, le mot de passe seul ne suffit pas."
},
"missing": "Votre session a expiré, rien n'a donc été modifié. Reconnectez-vous pour continuer.",
"passkey": {
"last_method": "Cette clé d’accès est votre seul moyen de connexion. Définissez d’abord un mot de passe ou ajoutez une autre clé d’accès."
},
"mfa": {
"already_active": "Une application d'authentification est déjà configurée pour ce compte. Désactivez-la d'abord pour en configurer une nouvelle."
}
},
"biomarkers": {
@@ -178,6 +186,54 @@
},
"aiRuns": {
"readerBusy": "Le lecteur de documents est occupé par d'autres tâches. Réessayez dans une minute."
},
"http": {
"unauthenticated": "Votre session a expiré, rien n'a donc été modifié. Reconnectez-vous pour continuer.",
"rate_limited": "Il y a trop de requêtes pour le moment, rien n'a donc été modifié. Patientez un instant puis réessayez.",
"forbidden": "Vous n'avez pas l'autorisation pour cette action, rien n'a donc été modifié.",
"invalid": "Certaines saisies n'ont pas été acceptées, rien n'a donc été modifié. Vérifiez-les puis réessayez."
},
"ai_provider": {
"private_host": "Cette adresse se trouve sur un réseau privé. L’opérateur doit d’abord autoriser son origine exacte dans AI_PRIVATE_ORIGINS."
},
"encounter": {
"invalid": "Vérifiez les informations du rendez-vous et réessayez.",
"practitioner-not-found": "Ce cabinet ou ce médecin n'existe plus. Choisissez-en un autre ou aucun.",
"reminder-not-found": "Ce dépistage n'existe plus. Choisissez-en un autre ou aucun.",
"reminder-conflict": "Un rendez-vous prévu ne peut pas clore un dépistage, car il n'a pas encore eu lieu."
},
"vaccination": {
"invalid": "Vérifiez les informations de la vaccination et réessayez.",
"practitioner-not-found": "Ce cabinet ou ce médecin n'existe plus. Choisissez-en un autre ou aucun.",
"encounter-not-found": "Ce rendez-vous n'existe plus. Choisissez-en un autre ou aucun.",
"custom-vaccine-not-found": "Ce vaccin n'existe plus. Choisissez-le à nouveau dans la liste.",
"not-found": "Cette vaccination n'est plus disponible.",
"identity-required": "Une dose a besoin d'un nom : choisissez un vaccin dans la liste ou saisissez ce qu'indique le carnet.",
"booster-invalid": "Vérifiez les informations du rappel et réessayez.",
"booster-no-antigen": "Cette dose ne cite aucun vaccin du catalogue, aucun rappel ne peut donc être prévu.",
"booster-out-of-scope": "Votre accès à ce dossier ne permet pas de prévoir un rappel."
},
"allergy": {
"invalid": "Vérifiez les informations de l'allergie et réessayez."
},
"practitioner": {
"invalid": "Vérifiez les informations du cabinet ou du médecin et réessayez."
},
"workout": {
"batch": {
"invalid": "Vérifiez les informations de l'entraînement et réessayez.",
"too_large": "Trop d'entraînements à la fois. Envoyez-les par lots plus petits.",
"payload_too_large": "Les données d'entraînement sont trop volumineuses pour être enregistrées en une fois.",
"source_not_permitted": "Cette connexion n'a pas le droit d'enregistrer des entraînements sous cette source."
}
},
"ai": {
"provider": {
"none": "Aucun fournisseur d’IA n’est configuré, rien n’a donc été envoyé. Configurez-en un dans les réglages IA."
},
"budget": {
"exceeded": "Le budget IA du jour est épuisé, rien n’a donc été envoyé. Il se renouvelle demain."
}
}
},
"doctorReport": {
@@ -979,7 +1035,9 @@
"typeStressLevel": "Niveau de stress",
"typeTrainingReadiness": "Disponibilité à l'entraînement",
"typeAcuteTrainingLoad": "Charge d'entraînement aiguë",
"sourceGarmin": "Garmin"
"sourceGarmin": "Garmin",
"valueOutOfRange": "Saisissez une valeur entre {min} et {max} {unit}.",
"valueOutOfRangeNoUnit": "Saisissez une valeur entre {min} et {max}."
},
"metricCatalog": {
"title": "Ce que HealthLog peut suivre",
@@ -1531,7 +1589,8 @@
"gallbladderDiscomfort": "Inconfort de la vésicule biliaire"
},
"deleteConfirmTitle": "Supprimer cette entrée ?",
"deleteConfirmBody": "Cette entrée d’effet secondaire sera définitivement supprimée."
"deleteConfirmBody": "Cette entrée d’effet secondaire sera définitivement supprimée.",
"saveError": "Impossible d'enregistrer l'entrée"
},
"scheduling": {
"naturalLanguage": {
@@ -1955,7 +2014,6 @@
},
"intake": {
"title": "Historique des prises",
"importButton": "Importer",
"rowActions": {
"openMenu": "Ouvrir les actions de la ligne",
"edit": "Modifier",
@@ -1965,14 +2023,7 @@
"rowToggleLabel": "Sélectionner la ligne"
},
"bulkDelete": {
"selectionCount": "{count} entrées sélectionnées",
"deleteButton": "Supprimer la sélection",
"cancelButton": "Annuler",
"confirmTitle": "Supprimer les entrées sélectionnées ?",
"confirmBody": "{count} entrées seront supprimées définitivement. Le médicament est conservé.",
"confirmAction": "Supprimer",
"toast": "Sélection supprimée",
"failed": "Impossible de supprimer la sélection."
"cancelButton": "Annuler"
},
"edit": {
"dialogTitle": "Modifier la prise",
@@ -2846,11 +2897,6 @@
"bmiEmptyTitle": "Ajoutez votre taille pour l’IMC",
"bmiEmptyDescription": "L’IMC nécessite votre taille. Renseignez-la dans Paramètres → Compte.",
"bmiEmptyAction": "Ouvrir les paramètres du compte",
"personalRecord": {
"badge": "PR",
"tooltip": "Record personnel : {value} {unit} le {date}",
"benchmark": "Valeur typique à cet âge : de {low} à {high} {unit} ({source})"
},
"loadError": "Impossible de charger les analyses",
"heroPersonalBaseline": "Basé sur vos 90 derniers jours",
"heroRegenerating": "Génération en cours",
@@ -3017,8 +3063,6 @@
},
"correlationRow": {
"title": "Corrélations",
"experimentCta": "Lancer une expérience de 7 jours",
"experimentTooltip": "Bientôt disponible",
"emptyTitle": "Il faut plus de données pour afficher cette corrélation",
"sourceChip": "sur la base de {n} mesures appariées ({window})",
"sourceWindowLast30": "30 derniers jours",
@@ -5179,7 +5223,9 @@
"connectCta": "Connecter un appareil",
"logCta": "Enregistrez votre première mesure",
"importCta": "Importer un historique existant",
"modulesCta": "Choisir vos modules"
"modulesCta": "Choisir vos modules",
"aiOptional": "L'IA est facultative. Tout fonctionne aussi sans elle.",
"aiSetupCta": "Configurer l'IA"
},
"welcomeBack": {
"title": "Bon retour",
@@ -5200,37 +5246,6 @@
"allergiesPlaceholder": "p. ex. pénicilline, lactose",
"privacyNote": "Stocké chiffré et utilisé uniquement pour un meilleur contexte. Nous ne demandons pas vos médicaments ici : ajoutez-les quand vous voulez depuis la page Médicaments."
},
"ai": {
"panelTitle": "Voyez à quoi ressemblent les insights",
"panelIntro": "Coach, le briefing quotidien et les lectures par mesure traduisent vos propres chiffres en langage clair. Voici l'idée, avant de connecter quoi que ce soit.",
"showSample": "Voir un exemple de briefing",
"sampleTitle": "Briefing quotidien",
"sampleTag": "Exemple",
"sampleAriaLabel": "Exemple de briefing quotidien",
"sampleBody": "Poids en baisse de 1,2 kg en deux semaines. Pouls au repos stable à 58 bpm. Tension dans la plage 6 jours sur 7 ; la seule valeur élevée a suivi un dîner tardif et salé.",
"sampleCaption": "Exemple uniquement, pas vos données. Voilà à quoi ressemble un briefing une fois les insights activés.",
"ladderTitle": "Trois façons de l'activer",
"ladderLocalTitle": "Modèle local : les requêtes d’IA restent sur votre réseau",
"ladderLocalBody": "Pointez HealthLog vers Ollama ou LM Studio sur votre propre machine. Gratuit et totalement privé, le choix calme par défaut.",
"ladderByokTitle": "Apportez votre clé",
"ladderByokBody": "Utilisez votre propre clé OpenAI ou Anthropic. Elle fonctionne avec votre compte et votre facturation, et le niveau API exclut vos données de l'entraînement par défaut.",
"ladderOauthTitle": "Connectez-vous avec un abonnement",
"ladderOauthBody": "Reliez un compte ChatGPT, sans coût par jeton, mais les conditions du niveau grand public peuvent utiliser le contenu pour l'entraînement : c'est donc un choix délibéré, jamais l'option par défaut pour vos données de santé.",
"keylessLine": "HealthLog est pleinement utile sans IA. Vos tuiles, graphiques, tendances et exports fonctionnent tous ; l'IA n'ajoute que la lecture en langage clair par-dessus.",
"setupCta": "Configurez-le (environ une minute)",
"sharedKeyNote": "Cette instance fournit déjà un fournisseur d'IA partagé : les insights fonctionnent pour vous dès maintenant, aucune configuration nécessaire.",
"offer": {
"title": "Cette instance peut rédiger votre résumé en langage clair.",
"body": "L’exploitant met à disposition un fournisseur qui a fonctionné au cours des dernières 24 heures. Une seule touche active le langage clair ; vos données partent alors vers ce seul fournisseur et vers personne d’autre.",
"cta": "Activer le langage clair",
"granted": "Activé. Le premier résumé apparaîtra sur votre tableau de bord.",
"error": "L’activation n’a pas abouti. Réessayez dans un instant."
},
"unavailable": {
"title": "Le fournisseur de cette instance ne répond pas pour le moment.",
"body": "HealthLog reste pleinement utile sans le langage clair. Vous pourrez ajouter votre propre fournisseur plus tard dans les paramètres."
}
},
"steps": {
"who": "À qui appartient ce dossier",
"areas": "Ce que vous voulez suivre",
@@ -5944,6 +5959,7 @@
"inProgress": "Un import d'historique est déjà en cours. Attendez qu'il se termine.",
"workerUnavailable": "Le service d'arrière-plan est indisponible, rien n'a été importé.",
"failed": "L'import a échoué. Rien n'a été écrit.",
"stillRunning": "L'import prend plus de temps que d'habitude et peut encore aboutir. Revenez voir vos prises dans quelques minutes.",
"columnsHeading": "Ce que devient chaque colonne",
"jsonOnly": "JSON uniquement",
"previewLine": "{rows} lignes lues, {queued} seraient importées, {refused} refusées",
@@ -7792,7 +7808,9 @@
"hrv": "VFC",
"weight": "pesées",
"cycle": "cycle menstruel"
}
},
"passkeyRenameFailed": "Impossible de renommer la clé d’accès.",
"passkeyRemoveFailed": "Impossible de supprimer la clé d’accès."
},
"admin": {
"title": "Administration",
@@ -9314,16 +9332,8 @@
},
"eventMedicationReminder": "Rappel de médicament",
"eventMedicationReminderDesc": "Envoyé à chaque heure de prise prévue.",
"eventMeasurementAnomaly": "Mesure inhabituelle",
"eventMeasurementAnomalyDesc": "Envoyé quand une valeur sort nettement de votre plage habituelle.",
"eventComplianceLow": "Observance faible",
"eventComplianceLowDesc": "Envoyé quand votre taux de prise des derniers jours passe sous les 80 %.",
"eventWithingsSyncFailed": "Échec de la synchronisation Withings",
"eventWithingsSyncFailedDesc": "Envoyé quand la synchronisation Withings échoue plusieurs fois d'affilée.",
"eventSystemAlert": "Notification système",
"eventSystemAlertDesc": "Messages généraux du serveur HealthLog, par exemple maintenance ou avis de compte.",
"eventPersonalRecord": "Record personnel",
"eventPersonalRecordDesc": "Envoyé quand une valeur dépasse votre meilleur précédent.",
"eventMoodReminder": "Rappel d'humeur",
"eventMoodReminderDesc": "Rappel quotidien pour noter votre humeur du moment.",
"eventCyclePeriodSoon": "Règles à venir",
@@ -10972,7 +10982,9 @@
"deleteTitle": "Retirer ce vaccin personnel ?",
"sectionDescription": "Les vaccins que vous avez ajoutés parce que le catalogue ne les connaît pas.",
"catalogGroup": "Catalogue"
}
},
"deleted": "Vaccination supprimée",
"restoreFailed": "La vaccination n'a pas pu être restaurée."
},
"illness": {
"title": "Maladie",
@@ -11186,7 +11198,6 @@
"title": "Plans",
"pageDescription": "Plans « si… alors » convenus dans vos conversations avec le Coach. Confirmez une proposition pour que le Coach s'en souvienne.",
"readOnlyNote": "Le Coach n'est pas disponible pour le moment, mais vous pouvez toujours lire et supprimer vos plans.",
"proposalTitle": "Proposition de plan",
"ifThen": "Si {cue}, alors {action}.",
"targetPrefix": "Objectif : {target}",
"reviewPrefix": "Point d'étape : {when}",
@@ -12481,6 +12492,10 @@
"purgeFailed": "Impossible de supprimer le document.",
"purgeConfirmTitle": "Supprimer ce document définitivement ?",
"purgeConfirmBody": "Il est supprimé maintenant plutôt qu'à la fin du délai d'attente et ne peut pas être restauré."
},
"picker": {
"capped": "Les {count} plus récents sont affichés. Utilisez la recherche pour trouver un document plus ancien.",
"cappedSearching": "Les {count} résultats les plus récents sont affichés. Affinez la recherche pour en trouver un plus ancien."
}
},
"selfDescription": {
@@ -12528,7 +12543,8 @@
"save": "Enregistrer",
"savedToast": "Mesure personnalisée enregistrée",
"updatedToast": "Mesure personnalisée mise à jour",
"saveError": "Impossible d'enregistrer la mesure personnalisée"
"saveError": "Impossible d'enregistrer la mesure personnalisée",
"nameTaken": "Une mesure personnalisée portant ce nom existe déjà."
},
"entry": {
"value": "Valeur",
@@ -13483,7 +13499,9 @@
"defaultReason": "Aucune visite antérieure au même cabinet, dans la même spécialité ou du même type.",
"capped": "Les deux dernières années",
"cappedReason": "La dernière consultation correspondante date de plus de deux ans, et un rapport couvre au plus deux ans."
}
},
"deleted": "Rendez-vous supprimé",
"restoreFailed": "Le rendez-vous n'a pas pu être restauré."
},
"checkups": {
"viewSwitchLabel": "Afficher les dépistages, les rendez-vous, les interventions ou les parties du corps",
@@ -13516,7 +13534,9 @@
"deleteTitle": "Supprimer cette entrée ?",
"deleteDescription": "Vos rendez-vous existants restent et conservent ce nom.",
"deleteFailed": "L'entrée n'a pas pu être supprimée.",
"noSpecialtyGroup": "Sans spécialité"
"noSpecialtyGroup": "Sans spécialité",
"deleted": "Entrée supprimée",
"restoreFailed": "L'entrée n'a pas pu être restaurée."
},
"links": {
"picker": {
+85 -65
View File
@@ -142,7 +142,15 @@
"auth": {
"reproof": {
"required": "Conferma la password attuale per modificarlo.",
"failed": "La verifica non è riuscita. Non è stato modificato nulla."
"failed": "La verifica non è riuscita. Non è stato modificato nulla.",
"too_weak": "Conferma con il secondo fattore o una passkey. Per questo account la sola password non basta."
},
"missing": "La tua sessione è scaduta, quindi non è stato modificato nulla. Accedi di nuovo per continuare.",
"passkey": {
"last_method": "Questa passkey è il tuo unico modo per accedere. Imposta prima una password o aggiungi un’altra passkey."
},
"mfa": {
"already_active": "Per questo account è già configurata un'app di autenticazione. Disattivala prima di configurarne una nuova."
}
},
"biomarkers": {
@@ -178,6 +186,54 @@
},
"aiRuns": {
"readerBusy": "Il lettore di documenti è occupato con altre attività. Riprova tra un minuto."
},
"http": {
"unauthenticated": "La tua sessione è scaduta, quindi non è stato modificato nulla. Accedi di nuovo per continuare.",
"rate_limited": "Ci sono troppe richieste in questo momento, quindi non è stato modificato nulla. Attendi un attimo e riprova.",
"forbidden": "Non hai l'autorizzazione per farlo, quindi non è stato modificato nulla.",
"invalid": "Alcuni dati non sono stati accettati, quindi non è stato modificato nulla. Controllali e riprova."
},
"ai_provider": {
"private_host": "Questo indirizzo si trova in una rete privata. L’operatore deve prima consentirne l’origine esatta in AI_PRIVATE_ORIGINS."
},
"encounter": {
"invalid": "Controlla i dati dell'appuntamento e riprova.",
"practitioner-not-found": "Questo studio o medico non esiste più. Scegline un altro o nessuno.",
"reminder-not-found": "Questo controllo non esiste più. Scegline un altro o nessuno.",
"reminder-conflict": "Un appuntamento previsto non può chiudere un controllo, perché non è ancora avvenuto."
},
"vaccination": {
"invalid": "Controlla i dati della vaccinazione e riprova.",
"practitioner-not-found": "Questo studio o medico non esiste più. Scegline un altro o nessuno.",
"encounter-not-found": "Questo appuntamento non esiste più. Scegline un altro o nessuno.",
"custom-vaccine-not-found": "Questo vaccino non esiste più. Sceglilo di nuovo dall'elenco.",
"not-found": "Questa vaccinazione non è più disponibile.",
"identity-required": "Una dose ha bisogno di un nome: scegli un vaccino dall'elenco o scrivi quello che riporta il libretto.",
"booster-invalid": "Controlla i dati del richiamo e riprova.",
"booster-no-antigen": "Questa dose non indica alcun vaccino del catalogo, quindi non si può pianificare un richiamo.",
"booster-out-of-scope": "Il tuo accesso a questa cartella non consente di pianificare un richiamo."
},
"allergy": {
"invalid": "Controlla i dati dell'allergia e riprova."
},
"practitioner": {
"invalid": "Controlla i dati dello studio o del medico e riprova."
},
"workout": {
"batch": {
"invalid": "Controlla i dati dell'allenamento e riprova.",
"too_large": "Troppi allenamenti in una volta. Inviali in gruppi più piccoli.",
"payload_too_large": "I dati dell'allenamento sono troppo grandi per salvarli in una volta.",
"source_not_permitted": "Questa connessione non può salvare allenamenti con questa fonte."
}
},
"ai": {
"provider": {
"none": "Nessun provider di IA è configurato, quindi non è stato inviato nulla. Configurane uno nelle impostazioni IA."
},
"budget": {
"exceeded": "Il budget IA di oggi è esaurito, quindi non è stato inviato nulla. Si rinnova domani."
}
}
},
"doctorReport": {
@@ -979,7 +1035,9 @@
"typeStressLevel": "Livello di stress",
"typeTrainingReadiness": "Prontezza all'allenamento",
"typeAcuteTrainingLoad": "Carico di allenamento acuto",
"sourceGarmin": "Garmin"
"sourceGarmin": "Garmin",
"valueOutOfRange": "Inserisci un valore tra {min} e {max} {unit}.",
"valueOutOfRangeNoUnit": "Inserisci un valore tra {min} e {max}."
},
"metricCatalog": {
"title": "Cosa può monitorare HealthLog",
@@ -1531,7 +1589,8 @@
"gallbladderDiscomfort": "Fastidio alla cistifellea"
},
"deleteConfirmTitle": "Eliminare questa voce?",
"deleteConfirmBody": "Questa voce di effetto collaterale verrà rimossa definitivamente."
"deleteConfirmBody": "Questa voce di effetto collaterale verrà rimossa definitivamente.",
"saveError": "Impossibile salvare la voce"
},
"scheduling": {
"naturalLanguage": {
@@ -1955,7 +2014,6 @@
},
"intake": {
"title": "Storico delle assunzioni",
"importButton": "Importa",
"rowActions": {
"openMenu": "Apri azioni della riga",
"edit": "Modifica",
@@ -1965,14 +2023,7 @@
"rowToggleLabel": "Seleziona riga"
},
"bulkDelete": {
"selectionCount": "{count} voci selezionate",
"deleteButton": "Elimina selezione",
"cancelButton": "Annulla",
"confirmTitle": "Eliminare le voci selezionate?",
"confirmBody": "{count} voci verranno rimosse definitivamente. Il farmaco resta.",
"confirmAction": "Elimina",
"toast": "Selezione eliminata",
"failed": "Impossibile eliminare la selezione."
"cancelButton": "Annulla"
},
"edit": {
"dialogTitle": "Modifica assunzione",
@@ -2846,11 +2897,6 @@
"bmiEmptyTitle": "Aggiungi l’altezza per il BMI",
"bmiEmptyDescription": "Per il BMI serve la tua altezza. Salvala in Impostazioni → Account.",
"bmiEmptyAction": "Apri impostazioni account",
"personalRecord": {
"badge": "PR",
"tooltip": "Record personale: {value} {unit} il {date}",
"benchmark": "Tipico a quell’età: da {low} a {high} {unit} ({source})"
},
"loadError": "Impossibile caricare le analisi",
"heroPersonalBaseline": "Basato sugli ultimi 90 giorni",
"heroRegenerating": "Generazione in corso",
@@ -3017,8 +3063,6 @@
},
"correlationRow": {
"title": "Correlazioni",
"experimentCta": "Avvia esperimento di 7 giorni",
"experimentTooltip": "In arrivo",
"emptyTitle": "Servono più dati per mostrare questa correlazione",
"sourceChip": "su {n} misurazioni appaiate ({window})",
"sourceWindowLast30": "Ultimi 30 giorni",
@@ -5179,7 +5223,9 @@
"connectCta": "Collega un dispositivo",
"logCta": "Registra la tua prima misurazione",
"importCta": "Importa la cronologia esistente",
"modulesCta": "Scegli i tuoi moduli"
"modulesCta": "Scegli i tuoi moduli",
"aiOptional": "L'IA è facoltativa. Tutto funziona anche senza.",
"aiSetupCta": "Configura IA"
},
"welcomeBack": {
"title": "Bentornato",
@@ -5200,37 +5246,6 @@
"allergiesPlaceholder": "es. penicillina, lattosio",
"privacyNote": "Memorizzato cifrato e usato solo per darti un contesto migliore. Qui non chiediamo i tuoi farmaci: aggiungili quando vuoi dalla pagina Farmaci."
},
"ai": {
"panelTitle": "Guarda come sono gli insight",
"panelIntro": "Coach, il briefing quotidiano e le letture per metrica traducono i tuoi numeri in linguaggio chiaro. Ecco com'è, prima di collegare qualsiasi cosa.",
"showSample": "Vedi un briefing di esempio",
"sampleTitle": "Briefing quotidiano",
"sampleTag": "Esempio",
"sampleAriaLabel": "Briefing quotidiano di esempio",
"sampleBody": "Peso in calo di 1,2 kg in due settimane. Battito a riposo stabile a 58 bpm. Pressione nel range in 6 giorni su 7: l'unico valore alto è arrivato dopo una cena tardiva e salata.",
"sampleCaption": "Solo un esempio, non i tuoi dati. Ecco come si legge un briefing quando gli insight sono attivi.",
"ladderTitle": "Tre modi per attivarlo",
"ladderLocalTitle": "Modello locale: le richieste all’IA restano nella tua rete",
"ladderLocalBody": "Punta HealthLog su Ollama o LM Studio sulla tua macchina. Gratis e del tutto privato: la scelta tranquilla di default.",
"ladderByokTitle": "Usa la tua chiave",
"ladderByokBody": "Usa la tua chiave OpenAI o Anthropic. Gira sul tuo account e sulla tua fatturazione, e il piano API tiene i tuoi dati fuori dall'addestramento in modo predefinito.",
"ladderOauthTitle": "Accedi con un abbonamento",
"ladderOauthBody": "Collega un account ChatGPT. Nessun costo per token, ma i termini del piano consumer possono usare i contenuti per l'addestramento, quindi è una scelta deliberata, mai l'impostazione predefinita per i tuoi dati sanitari.",
"keylessLine": "HealthLog è pienamente utile anche senza IA. Le tue schede, i grafici, i trend e le esportazioni funzionano tutti; l'IA aggiunge solo la lettura in linguaggio chiaro.",
"setupCta": "Configuralo (circa un minuto)",
"sharedKeyNote": "Questa istanza fornisce già un provider IA condiviso, quindi gli insight funzionano per te fin da subito, senza alcuna configurazione.",
"offer": {
"title": "Questa istanza può già scrivere il tuo riepilogo in parole semplici.",
"body": "Il gestore mette a disposizione un fornitore che ha funzionato nelle ultime 24 ore. Con un tocco attivi le parole semplici; i tuoi dati andranno allora a quell’unico fornitore e a nessun altro.",
"cta": "Attiva le parole semplici",
"granted": "Attivato. Il primo riepilogo comparirà sulla tua dashboard.",
"error": "L’attivazione non è riuscita. Riprova tra un momento."
},
"unavailable": {
"title": "Il fornitore di questa istanza al momento non risponde.",
"body": "HealthLog resta pienamente utile senza le parole semplici. Potrai aggiungere un fornitore tuo più avanti nelle impostazioni."
}
},
"steps": {
"who": "A chi appartiene questa cartella",
"areas": "Cosa vuoi tenere d'occhio",
@@ -5944,6 +5959,7 @@
"inProgress": "Un'importazione dello storico è già in corso. Attendi che finisca.",
"workerUnavailable": "Il processo in background non è disponibile, quindi non è stato importato nulla.",
"failed": "L'importazione è fallita. Non è stato scritto nulla.",
"stillRunning": "L'importazione richiede più tempo del solito e potrebbe ancora completarsi. Ricontrolla le tue assunzioni tra qualche minuto.",
"columnsHeading": "Cosa fa ogni colonna",
"jsonOnly": "solo JSON",
"previewLine": "{rows} righe lette, {queued} da importare, {refused} rifiutate",
@@ -7792,7 +7808,9 @@
"hrv": "HRV",
"weight": "pesate",
"cycle": "ciclo mestruale"
}
},
"passkeyRenameFailed": "Impossibile rinominare la passkey.",
"passkeyRemoveFailed": "Impossibile rimuovere la passkey."
},
"admin": {
"title": "Administration",
@@ -9314,16 +9332,8 @@
},
"eventMedicationReminder": "Promemoria farmaco",
"eventMedicationReminderDesc": "Inviato a ogni orario di dose programmato.",
"eventMeasurementAnomaly": "Misurazione insolita",
"eventMeasurementAnomalyDesc": "Inviato quando un valore esce nettamente dal tuo intervallo abituale.",
"eventComplianceLow": "Aderenza bassa",
"eventComplianceLowDesc": "Inviato quando il tuo tasso di assunzione degli ultimi giorni scende sotto l'80 %.",
"eventWithingsSyncFailed": "Sincronizzazione Withings non riuscita",
"eventWithingsSyncFailedDesc": "Inviato quando la sincronizzazione Withings fallisce per più tentativi consecutivi.",
"eventSystemAlert": "Avviso di sistema",
"eventSystemAlertDesc": "Messaggi generali dal server HealthLog, ad esempio finestre di manutenzione o note sull'account.",
"eventPersonalRecord": "Record personale",
"eventPersonalRecordDesc": "Inviato quando una misurazione supera il tuo precedente record.",
"eventMoodReminder": "Promemoria umore",
"eventMoodReminderDesc": "Promemoria quotidiano per registrare il tuo umore attuale.",
"eventCyclePeriodSoon": "Ciclo in arrivo",
@@ -10972,7 +10982,9 @@
"deleteTitle": "Rimuovere il vaccino personale?",
"sectionDescription": "Vaccini aggiunti da te perché il catalogo non li elenca.",
"catalogGroup": "Catalogo"
}
},
"deleted": "Vaccinazione eliminata",
"restoreFailed": "Impossibile ripristinare la vaccinazione."
},
"illness": {
"title": "Malattia",
@@ -11186,7 +11198,6 @@
"title": "Piani",
"pageDescription": "Piani «se… allora» concordati nelle conversazioni con il Coach. Conferma una proposta perché il Coach la tenga a mente.",
"readOnlyNote": "Il Coach non è disponibile al momento, ma puoi ancora leggere ed eliminare i tuoi piani.",
"proposalTitle": "Proposta di piano",
"ifThen": "Se {cue}, allora {action}.",
"targetPrefix": "Obiettivo: {target}",
"reviewPrefix": "Verifica: {when}",
@@ -12481,6 +12492,10 @@
"purgeFailed": "Impossibile eliminare il documento.",
"purgeConfirmTitle": "Eliminare definitivamente questo documento?",
"purgeConfirmBody": "Viene rimosso ora anziché alla fine del periodo di attesa e non può essere ripristinato."
},
"picker": {
"capped": "Sono mostrati i {count} più recenti. Usa la ricerca per trovare un documento più vecchio.",
"cappedSearching": "Sono mostrati i {count} risultati più recenti. Restringi la ricerca per trovarne uno più vecchio."
}
},
"selfDescription": {
@@ -12528,7 +12543,8 @@
"save": "Salva",
"savedToast": "Metrica personalizzata salvata",
"updatedToast": "Metrica personalizzata aggiornata",
"saveError": "Impossibile salvare la metrica personalizzata"
"saveError": "Impossibile salvare la metrica personalizzata",
"nameTaken": "Esiste già una metrica personalizzata con questo nome."
},
"entry": {
"value": "Valore",
@@ -13483,7 +13499,9 @@
"defaultReason": "Nessuna visita precedente presso lo stesso studio, nella stessa specialità o dello stesso tipo.",
"capped": "Gli ultimi due anni",
"cappedReason": "L'ultima visita corrispondente risale a più di due anni fa, e un report copre al massimo due anni."
}
},
"deleted": "Appuntamento eliminato",
"restoreFailed": "Non è stato possibile ripristinare l'appuntamento."
},
"checkups": {
"viewSwitchLabel": "Mostra controlli, appuntamenti, interventi o parti del corpo",
@@ -13516,7 +13534,9 @@
"deleteTitle": "Eliminare questa voce?",
"deleteDescription": "I tuoi appuntamenti esistenti restano e conservano questo nome.",
"deleteFailed": "Non è stato possibile eliminare la voce.",
"noSpecialtyGroup": "Senza specializzazione"
"noSpecialtyGroup": "Senza specializzazione",
"deleted": "Voce eliminata",
"restoreFailed": "Non è stato possibile ripristinare la voce."
},
"links": {
"picker": {
+85 -65
View File
@@ -142,7 +142,15 @@
"auth": {
"reproof": {
"required": "변경하려면 현재 비밀번호를 확인하세요.",
"failed": "확인에 실패했습니다. 아무것도 변경되지 않았습니다."
"failed": "확인에 실패했습니다. 아무것도 변경되지 않았습니다.",
"too_weak": "2단계 인증 수단이나 패스키로 확인하세요. 이 계정에서는 비밀번호만으로는 충분하지 않아요."
},
"missing": "세션이 만료되어 아무것도 변경되지 않았어요. 계속하려면 다시 로그인해 주세요.",
"passkey": {
"last_method": "이 패스키가 유일한 로그인 수단이에요. 먼저 비밀번호를 설정하거나 패스키를 하나 더 추가하세요."
},
"mfa": {
"already_active": "이 계정에는 이미 인증 앱이 설정되어 있어요. 새로 설정하려면 먼저 해제하세요."
}
},
"biomarkers": {
@@ -178,6 +186,54 @@
},
"aiRuns": {
"readerBusy": "문서 판독기가 다른 작업으로 바빠요. 1분 후에 다시 시도하세요."
},
"http": {
"unauthenticated": "세션이 만료되어 아무것도 변경되지 않았어요. 계속하려면 다시 로그인해 주세요.",
"rate_limited": "지금은 요청이 너무 많아 아무것도 변경되지 않았어요. 잠시 후 다시 시도해 주세요.",
"forbidden": "이 작업을 할 권한이 없어 아무것도 변경되지 않았어요.",
"invalid": "일부 입력값이 받아들여지지 않아 아무것도 변경되지 않았어요. 확인한 후 다시 시도해 주세요."
},
"ai_provider": {
"private_host": "이 주소는 사설 네트워크에 있어요. 운영자가 먼저 AI_PRIVATE_ORIGINS에 정확한 출처를 허용해야 해요."
},
"encounter": {
"invalid": "진료 정보를 확인하고 다시 시도해 주세요.",
"practitioner-not-found": "이 병원 또는 의사가 더 이상 없어요. 다른 항목을 고르거나 '지정 안 함'을 선택해 주세요.",
"reminder-not-found": "이 건강검진이 더 이상 없어요. 다른 검진을 고르거나 선택을 비워 두세요.",
"reminder-conflict": "예정된 진료는 아직 이루어지지 않았기 때문에 건강검진을 완료할 수 없어요."
},
"vaccination": {
"invalid": "예방접종 정보를 확인하고 다시 시도해 주세요.",
"practitioner-not-found": "이 병원 또는 의사가 더 이상 없어요. 다른 항목을 고르거나 '지정 안 함'을 선택해 주세요.",
"encounter-not-found": "이 진료가 더 이상 없어요. 다른 진료를 고르거나 선택을 비워 두세요.",
"custom-vaccine-not-found": "이 백신이 더 이상 없어요. 목록에서 다시 골라 주세요.",
"not-found": "이 예방접종 기록은 더 이상 없어요.",
"identity-required": "접종에는 이름이 필요해요. 목록에서 백신을 고르거나 기록에 적힌 이름을 입력하세요.",
"booster-invalid": "추가 접종 정보를 확인하고 다시 시도해 주세요.",
"booster-no-antigen": "이 접종에는 목록의 백신이 지정되지 않아 추가 접종을 계획할 수 없어요.",
"booster-out-of-scope": "이 기록에 대한 권한으로는 추가 접종을 계획할 수 없어요."
},
"allergy": {
"invalid": "알레르기 정보를 확인하고 다시 시도해 주세요."
},
"practitioner": {
"invalid": "병원 또는 의사 정보를 확인하고 다시 시도해 주세요."
},
"workout": {
"batch": {
"invalid": "운동 정보를 확인하고 다시 시도해 주세요.",
"too_large": "한 번에 운동이 너무 많아요. 조금씩 나눠서 보내 주세요.",
"payload_too_large": "운동 데이터가 너무 커서 한 번에 저장할 수 없어요.",
"source_not_permitted": "이 연결로는 해당 출처의 운동을 저장할 수 없어요."
}
},
"ai": {
"provider": {
"none": "설정된 AI 제공자가 없어 아무것도 보내지 않았어요. AI 설정에서 하나를 설정하세요."
},
"budget": {
"exceeded": "오늘의 AI 예산을 모두 사용해서 아무것도 보내지 않았어요. 내일 다시 채워져요."
}
}
},
"doctorReport": {
@@ -979,7 +1035,9 @@
"typeStressLevel": "스트레스 수준",
"typeTrainingReadiness": "트레이닝 준비도",
"typeAcuteTrainingLoad": "급성 훈련 부하",
"sourceGarmin": "Garmin"
"sourceGarmin": "Garmin",
"valueOutOfRange": "{min}에서 {max} {unit} 사이의 값을 입력해 주세요.",
"valueOutOfRangeNoUnit": "{min}에서 {max} 사이의 값을 입력해 주세요."
},
"metricCatalog": {
"title": "HealthLog가 기록할 수 있는 것",
@@ -1531,7 +1589,8 @@
"gallbladderDiscomfort": "담낭 불편감"
},
"deleteConfirmTitle": "이 기록을 삭제할까요?",
"deleteConfirmBody": "이 부작용 기록이 영구히 삭제돼요."
"deleteConfirmBody": "이 부작용 기록이 영구히 삭제돼요.",
"saveError": "기록을 저장하지 못했어요"
},
"scheduling": {
"naturalLanguage": {
@@ -1955,7 +2014,6 @@
},
"intake": {
"title": "복용 기록",
"importButton": "불러오기",
"rowActions": {
"openMenu": "행 작업 열기",
"edit": "편집",
@@ -1965,14 +2023,7 @@
"rowToggleLabel": "행 선택"
},
"bulkDelete": {
"selectionCount": "{count}개 항목 선택됨",
"deleteButton": "선택 항목 삭제",
"cancelButton": "취소",
"confirmTitle": "선택한 항목을 삭제할까요?",
"confirmBody": "{count}개 항목이 영구히 삭제돼요. 약 자체는 그대로 남아요.",
"confirmAction": "삭제",
"toast": "선택 항목을 삭제했어요",
"failed": "선택 항목을 삭제하지 못했어요."
"cancelButton": "취소"
},
"edit": {
"dialogTitle": "복용 기록 편집",
@@ -2846,11 +2897,6 @@
"bmiEmptyTitle": "BMI를 보려면 키를 입력해 주세요",
"bmiEmptyDescription": "BMI에는 키가 필요해요. 설정 → 계정에서 추가해 주세요.",
"bmiEmptyAction": "계정 설정 열기",
"personalRecord": {
"badge": "신기록",
"tooltip": "개인 최고 기록: {date} {value}{unit}",
"benchmark": "그 나이의 일반적인 범위: {low}~{high} {unit} ({source})"
},
"loadError": "인사이트를 불러오지 못했어요",
"heroPersonalBaseline": "최근 90일 기준",
"heroRegenerating": "다시 생성 중",
@@ -3017,8 +3063,6 @@
},
"correlationRow": {
"title": "관계",
"experimentCta": "7일 실험 해보기",
"experimentTooltip": "곧 제공돼요",
"emptyTitle": "이 관계를 보려면 데이터가 더 필요해요",
"sourceChip": "짝지은 측정값 {n}개 기준 ({window})",
"sourceWindowLast30": "최근 30일",
@@ -5179,7 +5223,9 @@
"connectCta": "기기 연결",
"logCta": "첫 측정값 기록하기",
"importCta": "기존 기록 불러오기",
"modulesCta": "모듈 고르기"
"modulesCta": "모듈 고르기",
"aiOptional": "AI는 선택 사항이에요. AI 없이도 모든 기능이 작동해요.",
"aiSetupCta": "AI 설정"
},
"welcomeBack": {
"title": "다시 오셨네요",
@@ -5200,37 +5246,6 @@
"allergiesPlaceholder": "예: 페니실린, 유당",
"privacyNote": "암호화해 저장하고, 더 나은 맥락을 주는 데만 써요. 여기서 약은 묻지 않아요. 복약 페이지에서 언제든 추가하면 돼요."
},
"ai": {
"panelTitle": "인사이트가 어떤 모습인지 보기",
"panelIntro": "코치, 데일리 브리핑, 지표별 해설이 내 숫자를 쉬운 말로 풀어 줘요. 무언가 연결하기 전에, 대략 이런 모습이에요.",
"showSample": "샘플 브리핑 보기",
"sampleTitle": "데일리 브리핑",
"sampleTag": "예시",
"sampleAriaLabel": "데일리 브리핑 예시",
"sampleBody": "2주 동안 체중 1.2kg 감소. 안정 시 맥박은 58bpm으로 꾸준해요. 혈압은 7일 중 6일 범위 안, 높았던 하루는 늦은 시간의 짠 저녁 식사 다음이었어요.",
"sampleCaption": "예시일 뿐 내 데이터가 아니에요. 인사이트를 켜면 브리핑이 이런 식으로 읽혀요.",
"ladderTitle": "켜는 방법 세 가지",
"ladderLocalTitle": "로컬 모델: AI 요청이 내 네트워크 안에 머물러요",
"ladderLocalBody": "내 컴퓨터의 Ollama나 LM Studio를 HealthLog에 연결해요. 무료이고 완전히 비공개라서 가장 마음 편한 기본값이에요.",
"ladderByokTitle": "내 API 키 사용",
"ladderByokBody": "본인의 OpenAI나 Anthropic 키를 쓰면 돼요. 내 계정과 결제로 돌아가고, API 등급은 기본적으로 데이터를 학습에 쓰지 않아요.",
"ladderOauthTitle": "구독 계정으로 로그인",
"ladderOauthBody": "ChatGPT 계정을 연결해요. 토큰당 비용은 없지만 소비자 등급 약관은 내용을 학습에 쓸 수 있어서, 건강 데이터에는 기본값이 아니라 신중히 고를 선택이에요.",
"keylessLine": "AI 없이도 HealthLog는 충분히 쓸모 있어요. 타일, 차트, 추이, 내보내기 모두 그대로 작동하고, AI는 그 위에 쉬운 말 해설을 더할 뿐이에요.",
"setupCta": "설정하기 (1분 정도)",
"sharedKeyNote": "이 인스턴스에는 이미 공용 AI 제공자가 있어서 지금 바로 인사이트를 쓸 수 있어요. 따로 설정할 게 없어요.",
"offer": {
"title": "이 인스턴스가 요약을 쉬운 말로 써 줄 수 있습니다.",
"body": "운영자가 제공하는 공급자가 지난 24시간 안에 마지막으로 정상 동작했습니다. 한 번만 누르면 쉬운 말 요약이 켜지며, 그러면 데이터는 그 하나의 공급자에게만 전달되고 다른 어느 곳에도 가지 않습니다.",
"cta": "쉬운 말 요약 켜기",
"granted": "켜졌습니다. 첫 요약은 대시보드에 표시됩니다.",
"error": "켜지지 않았습니다. 잠시 후에 다시 시도해 주세요."
},
"unavailable": {
"title": "이 인스턴스의 공급자가 지금은 응답하지 않습니다.",
"body": "쉬운 말 요약 없이도 HealthLog는 완전히 쓸모가 있습니다. 나중에 설정에서 직접 공급자를 등록할 수 있습니다."
}
},
"steps": {
"who": "이 기록의 대상",
"areas": "관심 있게 볼 항목",
@@ -5944,6 +5959,7 @@
"inProgress": "복용 이력 불러오기가 이미 진행 중이에요. 끝날 때까지 기다려 주세요.",
"workerUnavailable": "백그라운드 작업자를 쓸 수 없어서 아무것도 불러오지 못했어요.",
"failed": "불러오지 못했어요. 아무것도 저장되지 않았어요.",
"stillRunning": "불러오기가 평소보다 오래 걸리고 있어요. 아직 끝날 수 있으니 몇 분 뒤에 복용 기록을 다시 확인해 주세요.",
"columnsHeading": "각 열의 역할",
"jsonOnly": "JSON 전용",
"previewLine": "읽은 행 {rows}개, 불러올 행 {queued}개, 거부된 행 {refused}개",
@@ -7792,7 +7808,9 @@
"hrv": "HRV",
"weight": "체중 측정",
"cycle": "생리 주기"
}
},
"passkeyRenameFailed": "패스키 이름을 변경하지 못했어요.",
"passkeyRemoveFailed": "패스키를 삭제하지 못했어요."
},
"admin": {
"title": "관리자",
@@ -9314,16 +9332,8 @@
},
"eventMedicationReminder": "복약 알림",
"eventMedicationReminderDesc": "예정된 복용 시각마다 보내요.",
"eventMeasurementAnomaly": "이상 측정값",
"eventMeasurementAnomalyDesc": "측정값이 평소 범위를 크게 벗어나면 보내요.",
"eventComplianceLow": "낮은 복약 순응도",
"eventComplianceLowDesc": "최근 며칠 복약 순응도가 80% 아래로 떨어지면 보내요.",
"eventWithingsSyncFailed": "Withings 동기화 실패",
"eventWithingsSyncFailedDesc": "Withings 동기화가 여러 번 연달아 실패하면 보내요.",
"eventSystemAlert": "시스템 공지",
"eventSystemAlertDesc": "HealthLog 서버에서 오는 일반 메시지예요. 예를 들어 점검 시간이나 계정 안내.",
"eventPersonalRecord": "개인 기록",
"eventPersonalRecordDesc": "측정값이 이전 최고 기록을 넘으면 보내요.",
"eventMoodReminder": "기분 알림",
"eventMoodReminderDesc": "지금 기분을 기록하도록 매일 가볍게 알려줘요.",
"eventCyclePeriodSoon": "생리 예정",
@@ -10972,7 +10982,9 @@
"deleteTitle": "직접 추가한 백신을 삭제할까요?",
"sectionDescription": "목록에 없어서 직접 추가한 백신이에요.",
"catalogGroup": "목록"
}
},
"deleted": "예방접종 기록을 삭제했어요",
"restoreFailed": "예방접종 기록을 복원하지 못했어요."
},
"illness": {
"title": "아픈 날",
@@ -11186,7 +11198,6 @@
"title": "계획",
"pageDescription": "코치와의 대화에서 합의한 조건-실행 계획이에요. 제안을 확정하면 코치가 기억해요.",
"readOnlyNote": "지금은 코치를 사용할 수 없지만 계획은 계속 읽고 삭제할 수 있습니다.",
"proposalTitle": "계획 제안",
"ifThen": "만약 {cue}, 그러면 {action}.",
"targetPrefix": "목표: {target}",
"reviewPrefix": "점검: {when}",
@@ -12481,6 +12492,10 @@
"purgeFailed": "문서를 삭제하지 못했어요.",
"purgeConfirmTitle": "이 문서를 완전히 삭제할까요?",
"purgeConfirmBody": "대기 기간이 끝나기 전에 지금 삭제되며 복구할 수 없습니다."
},
"picker": {
"capped": "최신 {count}개를 보여 줘요. 이전 문서는 검색으로 찾아 주세요.",
"cappedSearching": "최근 검색 결과 {count}개를 보여 줘요. 이전 문서는 검색어를 좁혀서 찾아 주세요."
}
},
"selfDescription": {
@@ -12528,7 +12543,8 @@
"save": "저장",
"savedToast": "사용자 지표를 저장했어요",
"updatedToast": "사용자 지표를 수정했어요",
"saveError": "사용자 지표를 저장하지 못했어요"
"saveError": "사용자 지표를 저장하지 못했어요",
"nameTaken": "이 이름의 사용자 지표가 이미 있어요."
},
"entry": {
"value": "값",
@@ -13483,7 +13499,9 @@
"defaultReason": "같은 병원, 진료과 또는 유형의 이전 방문이 없어요.",
"capped": "최근 2년",
"cappedReason": "마지막으로 일치하는 진료가 2년 이상 지났으며, 보고서는 최대 2년까지 다룹니다."
}
},
"deleted": "진료를 삭제했어요",
"restoreFailed": "진료를 복원하지 못했어요."
},
"checkups": {
"viewSwitchLabel": "건강검진, 진료, 시술 또는 신체 부위 보기",
@@ -13516,7 +13534,9 @@
"deleteTitle": "이 항목을 삭제할까요?",
"deleteDescription": "기존 진료 기록은 그대로 남고, 이 이름도 유지돼요.",
"deleteFailed": "항목을 삭제하지 못했어요.",
"noSpecialtyGroup": "진료과 없음"
"noSpecialtyGroup": "진료과 없음",
"deleted": "항목을 삭제했어요",
"restoreFailed": "항목을 복원하지 못했어요."
},
"links": {
"picker": {
+85 -65
View File
@@ -142,7 +142,15 @@
"auth": {
"reproof": {
"required": "Bevestig je huidige wachtwoord om dit te wijzigen.",
"failed": "De verificatie is mislukt. Er is niets gewijzigd."
"failed": "De verificatie is mislukt. Er is niets gewijzigd.",
"too_weak": "Bevestig met je tweede factor of een passkey. Voor dit account is alleen je wachtwoord niet genoeg."
},
"missing": "Je sessie is verlopen, dus er is niets gewijzigd. Meld je opnieuw aan om verder te gaan.",
"passkey": {
"last_method": "Deze passkey is je enige manier om in te loggen. Stel eerst een wachtwoord in of voeg nog een passkey toe."
},
"mfa": {
"already_active": "Voor dit account is al een authenticator-app ingesteld. Schakel die eerst uit om een nieuwe in te stellen."
}
},
"biomarkers": {
@@ -178,6 +186,54 @@
},
"aiRuns": {
"readerBusy": "De documentlezer is met ander werk bezig. Probeer het over een minuut opnieuw."
},
"http": {
"unauthenticated": "Je sessie is verlopen, dus er is niets gewijzigd. Meld je opnieuw aan om verder te gaan.",
"rate_limited": "Er zijn net te veel verzoeken, dus er is niets gewijzigd. Wacht even en probeer het opnieuw.",
"forbidden": "Je hebt hier geen toestemming voor, dus er is niets gewijzigd.",
"invalid": "Sommige gegevens zijn niet geaccepteerd, dus er is niets gewijzigd. Controleer ze en probeer het opnieuw."
},
"ai_provider": {
"private_host": "Dit adres ligt in een privénetwerk. De beheerder moet de exacte origin eerst toestaan in AI_PRIVATE_ORIGINS."
},
"encounter": {
"invalid": "Controleer de gegevens van het bezoek en probeer het opnieuw.",
"practitioner-not-found": "Deze praktijk of arts bestaat niet meer. Kies een andere of geen.",
"reminder-not-found": "Deze controle bestaat niet meer. Kies een andere of geen.",
"reminder-conflict": "Een gepland bezoek kan geen controle afsluiten, want het heeft nog niet plaatsgevonden."
},
"vaccination": {
"invalid": "Controleer de gegevens van de vaccinatie en probeer het opnieuw.",
"practitioner-not-found": "Deze praktijk of arts bestaat niet meer. Kies een andere of geen.",
"encounter-not-found": "Dit bezoek bestaat niet meer. Kies een ander of geen.",
"custom-vaccine-not-found": "Dit vaccin bestaat niet meer. Kies het opnieuw uit de lijst.",
"not-found": "Deze vaccinatie is niet meer beschikbaar.",
"identity-required": "Een dosis heeft een naam nodig: kies een vaccin uit de lijst of typ wat er op je vaccinatiebewijs staat.",
"booster-invalid": "Controleer de gegevens van de booster en probeer het opnieuw.",
"booster-no-antigen": "Deze dosis noemt geen vaccin uit de catalogus, dus er kan geen booster worden gepland.",
"booster-out-of-scope": "Je toegang tot dit dossier dekt het plannen van een booster niet."
},
"allergy": {
"invalid": "Controleer de gegevens van de allergie en probeer het opnieuw."
},
"practitioner": {
"invalid": "Controleer de gegevens van de praktijk of arts en probeer het opnieuw."
},
"workout": {
"batch": {
"invalid": "Controleer de gegevens van de training en probeer het opnieuw.",
"too_large": "Te veel trainingen tegelijk. Stuur er minder per keer.",
"payload_too_large": "De trainingsgegevens zijn te groot om in één keer op te slaan.",
"source_not_permitted": "Deze koppeling mag geen trainingen onder die bron opslaan."
}
},
"ai": {
"provider": {
"none": "Er is geen AI-provider ingesteld, dus er is niets verzonden. Stel er een in bij de AI-instellingen."
},
"budget": {
"exceeded": "Het AI-budget voor vandaag is op, dus er is niets verzonden. Morgen is het er weer."
}
}
},
"doctorReport": {
@@ -979,7 +1035,9 @@
"typeStressLevel": "Stressniveau",
"typeTrainingReadiness": "Trainingsgereedheid",
"typeAcuteTrainingLoad": "Acute trainingsbelasting",
"sourceGarmin": "Garmin"
"sourceGarmin": "Garmin",
"valueOutOfRange": "Vul een waarde in tussen {min} en {max} {unit}.",
"valueOutOfRangeNoUnit": "Vul een waarde in tussen {min} en {max}."
},
"metricCatalog": {
"title": "Wat HealthLog kan bijhouden",
@@ -1531,7 +1589,8 @@
"gallbladderDiscomfort": "Galblaasklachten"
},
"deleteConfirmTitle": "Deze registratie verwijderen?",
"deleteConfirmBody": "Deze bijwerkingregistratie wordt definitief verwijderd."
"deleteConfirmBody": "Deze bijwerkingregistratie wordt definitief verwijderd.",
"saveError": "De registratie kon niet worden opgeslagen"
},
"scheduling": {
"naturalLanguage": {
@@ -1955,7 +2014,6 @@
},
"intake": {
"title": "Innamegeschiedenis",
"importButton": "Importeren",
"rowActions": {
"openMenu": "Rijacties openen",
"edit": "Bewerken",
@@ -1965,14 +2023,7 @@
"rowToggleLabel": "Rij selecteren"
},
"bulkDelete": {
"selectionCount": "{count} registraties geselecteerd",
"deleteButton": "Selectie verwijderen",
"cancelButton": "Annuleren",
"confirmTitle": "Geselecteerde registraties verwijderen?",
"confirmBody": "{count} registraties worden definitief verwijderd. Het medicijn zelf blijft bestaan.",
"confirmAction": "Verwijderen",
"toast": "Selectie verwijderd",
"failed": "De selectie kon niet worden verwijderd."
"cancelButton": "Annuleren"
},
"edit": {
"dialogTitle": "Inname bewerken",
@@ -2846,11 +2897,6 @@
"bmiEmptyTitle": "Stel je lengte in om je BMI te zien",
"bmiEmptyDescription": "Voor de BMI is je lengte nodig. Voeg die toe onder Instellingen → Account.",
"bmiEmptyAction": "Accountinstellingen openen",
"personalRecord": {
"badge": "PR",
"tooltip": "Persoonlijk record: {value} {unit} op {date}",
"benchmark": "Gebruikelijk op die leeftijd: {low} tot {high} {unit} ({source})"
},
"loadError": "Inzichten kunnen niet worden geladen",
"heroPersonalBaseline": "Op basis van je afgelopen 90 dagen",
"heroRegenerating": "Opnieuw genereren",
@@ -3017,8 +3063,6 @@
},
"correlationRow": {
"title": "Verbanden",
"experimentCta": "Probeer een experiment van 7 dagen",
"experimentTooltip": "Binnenkort beschikbaar",
"emptyTitle": "Meer gegevens nodig om dit verband te zien",
"sourceChip": "gebaseerd op {n} gekoppelde metingen ({window})",
"sourceWindowLast30": "afgelopen 30 dagen",
@@ -5179,7 +5223,9 @@
"connectCta": "Een apparaat koppelen",
"logCta": "Je eerste meting vastleggen",
"importCta": "Bestaande geschiedenis importeren",
"modulesCta": "Je modules kiezen"
"modulesCta": "Je modules kiezen",
"aiOptional": "AI is optioneel. Alles werkt ook zonder.",
"aiSetupCta": "AI instellen"
},
"welcomeBack": {
"title": "Welkom terug",
@@ -5200,37 +5246,6 @@
"allergiesPlaceholder": "bijv. penicilline, lactose",
"privacyNote": "Versleuteld opgeslagen en alleen gebruikt om je betere context te geven. We vragen hier niet naar je medicatie. Die kun je altijd toevoegen via de pagina Medicatie."
},
"ai": {
"panelTitle": "Bekijk hoe inzichten eruitzien",
"panelIntro": "Coach, de dagelijkse briefing en de toelichting per meetwaarde zetten je eigen cijfers om in begrijpelijke taal. Zo ziet dat eruit, nog voordat je iets koppelt.",
"showSample": "Voorbeeldbriefing bekijken",
"sampleTitle": "Dagelijkse briefing",
"sampleTag": "Voorbeeld",
"sampleAriaLabel": "Voorbeeld van een dagelijkse briefing",
"sampleBody": "Gewicht in twee weken 1,2 kg gedaald. Rusthartslag stabiel op 58 bpm. Bloeddruk binnen het bereik op 6 van de 7 dagen; de ene hoge meting volgde op een laat, zout avondeten.",
"sampleCaption": "Alleen een voorbeeld, niet je eigen gegevens. Zo leest een briefing zodra inzichten aanstaan.",
"ladderTitle": "Drie manieren om het aan te zetten",
"ladderLocalTitle": "Lokaal model: AI-verzoeken blijven op je eigen netwerk",
"ladderLocalBody": "Verwijs HealthLog naar Ollama of LM Studio op je eigen computer. Gratis en volledig privé, de rustige standaardkeuze.",
"ladderByokTitle": "Je eigen sleutel gebruiken",
"ladderByokBody": "Gebruik je eigen OpenAI- of Anthropic-sleutel. Die draait onder je eigen account en facturering, en via de API worden je gegevens standaard niet gebruikt voor training.",
"ladderOauthTitle": "Inloggen met een abonnement",
"ladderOauthBody": "Koppel een ChatGPT-account. Geen kosten per token, maar volgens de voorwaarden voor consumenten mag inhoud voor training worden gebruikt. Het is dus een bewuste keuze, nooit de standaard voor je gezondheidsgegevens.",
"keylessLine": "HealthLog is ook zonder AI volledig bruikbaar. Je tegels, grafieken, trends en exports werken allemaal. AI voegt alleen de toelichting in begrijpelijke taal toe.",
"setupCta": "Instellen (ongeveer een minuut)",
"sharedKeyNote": "Deze instantie biedt al een gedeelde AI-provider, dus inzichten werken nu al voor je, zonder instellen.",
"offer": {
"title": "Deze instantie kan je samenvatting in begrijpelijke taal schrijven.",
"body": "De beheerder stelt een provider beschikbaar die in de afgelopen 24 uur nog werkte. Met één tik zet je de toelichting in begrijpelijke taal aan; je gegevens gaan dan naar die ene provider en naar niemand anders.",
"cta": "Begrijpelijke taal aanzetten",
"granted": "Aangezet. De eerste samenvatting verschijnt op je dashboard.",
"error": "Aanzetten is niet gelukt. Probeer het zo meteen nog eens."
},
"unavailable": {
"title": "De provider van deze instantie reageert op dit moment niet.",
"body": "HealthLog is ook zonder de toelichting in begrijpelijke taal volledig bruikbaar. Je kunt later onder Instellingen een eigen provider toevoegen."
}
},
"steps": {
"who": "Voor wie dit dossier is",
"areas": "Wat je in de gaten wilt houden",
@@ -5944,6 +5959,7 @@
"inProgress": "Er loopt al een import van doseringsgeschiedenis. Wacht tot die klaar is.",
"workerUnavailable": "De achtergrondverwerking is niet beschikbaar, dus er is niets geïmporteerd.",
"failed": "De import is mislukt. Er is niets opgeslagen.",
"stillRunning": "De import duurt langer dan normaal en kan nog afronden. Kijk over een paar minuten opnieuw naar je innames.",
"columnsHeading": "Wat elke kolom doet",
"jsonOnly": "Alleen JSON",
"previewLine": "{rows} rijen gelezen, {queued} zouden worden geïmporteerd, {refused} geweigerd",
@@ -7792,7 +7808,9 @@
"hrv": "HRV",
"weight": "wegingen",
"cycle": "menstruatiecyclus"
}
},
"passkeyRenameFailed": "De naam van de passkey kon niet worden gewijzigd.",
"passkeyRemoveFailed": "De passkey kon niet worden verwijderd."
},
"admin": {
"title": "Beheer",
@@ -9314,16 +9332,8 @@
},
"eventMedicationReminder": "Medicatieherinnering",
"eventMedicationReminderDesc": "Wordt verstuurd op elk gepland innamemoment.",
"eventMeasurementAnomaly": "Ongebruikelijke meting",
"eventMeasurementAnomalyDesc": "Wordt verstuurd als een meetwaarde ver buiten je gebruikelijke bereik ligt.",
"eventComplianceLow": "Lage therapietrouw",
"eventComplianceLowDesc": "Wordt verstuurd als je therapietrouw over de afgelopen dagen onder 80% zakt.",
"eventWithingsSyncFailed": "Withings-synchronisatie mislukt",
"eventWithingsSyncFailedDesc": "Wordt verstuurd als de Withings-synchronisatie meerdere keren achter elkaar mislukt.",
"eventSystemAlert": "Systeembericht",
"eventSystemAlertDesc": "Algemene berichten van de HealthLog-server, bijv. onderhoudsvensters of opmerkingen over je account.",
"eventPersonalRecord": "Persoonlijk record",
"eventPersonalRecordDesc": "Wordt verstuurd als een meetwaarde je vorige beste overtreft.",
"eventMoodReminder": "Stemmingsherinnering",
"eventMoodReminderDesc": "Dagelijks seintje om je huidige stemming vast te leggen.",
"eventCyclePeriodSoon": "Menstruatie op komst",
@@ -10972,7 +10982,9 @@
"deleteTitle": "Eigen vaccin verwijderen?",
"sectionDescription": "Vaccins die je zelf hebt toegevoegd omdat ze niet in de catalogus staan.",
"catalogGroup": "Catalogus"
}
},
"deleted": "Vaccinatie verwijderd",
"restoreFailed": "De vaccinatie kon niet worden teruggezet."
},
"illness": {
"title": "Ziekte",
@@ -11186,7 +11198,6 @@
"title": "Plannen",
"pageDescription": "Als-dan-plannen die je in je gesprekken met de Coach hebt afgesproken. Bevestig een voorstel om het onderdeel te maken van het geheugen van de Coach.",
"readOnlyNote": "De Coach is nu niet beschikbaar, maar je kunt je plannen nog wel lezen en verwijderen.",
"proposalTitle": "Planvoorstel",
"ifThen": "Als {cue}, dan {action}.",
"targetPrefix": "Doel: {target}",
"reviewPrefix": "Evaluatie: {when}",
@@ -12481,6 +12492,10 @@
"purgeFailed": "Het document kon niet worden verwijderd.",
"purgeConfirmTitle": "Dit document definitief verwijderen?",
"purgeConfirmBody": "Het wordt nu verwijderd in plaats van aan het einde van de wachttijd en kan niet worden hersteld."
},
"picker": {
"capped": "De nieuwste {count} staan hier. Zoek om een ouder document te vinden.",
"cappedSearching": "De nieuwste {count} resultaten staan hier. Verfijn de zoekopdracht om een ouder document te vinden."
}
},
"selfDescription": {
@@ -12528,7 +12543,8 @@
"save": "Opslaan",
"savedToast": "Eigen meting opgeslagen",
"updatedToast": "Eigen meting bijgewerkt",
"saveError": "De eigen meting kon niet worden opgeslagen"
"saveError": "De eigen meting kon niet worden opgeslagen",
"nameTaken": "Er bestaat al een eigen meting met deze naam."
},
"entry": {
"value": "Waarde",
@@ -13483,7 +13499,9 @@
"defaultReason": "Geen eerder bezoek bij dezelfde praktijk, in hetzelfde specialisme of van hetzelfde soort.",
"capped": "De afgelopen twee jaar",
"cappedReason": "Het laatste passende bezoek is meer dan twee jaar geleden, en een rapport beslaat hooguit twee jaar."
}
},
"deleted": "Bezoek verwijderd",
"restoreFailed": "Het bezoek kon niet worden teruggezet."
},
"checkups": {
"viewSwitchLabel": "Controles, bezoeken, ingrepen of lichaamsdelen tonen",
@@ -13516,7 +13534,9 @@
"deleteTitle": "Deze vermelding verwijderen?",
"deleteDescription": "Je bestaande bezoeken blijven bestaan en houden deze naam.",
"deleteFailed": "De vermelding kon niet worden verwijderd.",
"noSpecialtyGroup": "Geen specialisme"
"noSpecialtyGroup": "Geen specialisme",
"deleted": "Vermelding verwijderd",
"restoreFailed": "De vermelding kon niet worden teruggezet."
},
"links": {
"picker": {
+85 -65
View File
@@ -142,7 +142,15 @@
"auth": {
"reproof": {
"required": "Potwierdź obecne hasło, aby to zmienić.",
"failed": "Weryfikacja nie powiodła się. Nic nie zostało zmienione."
"failed": "Weryfikacja nie powiodła się. Nic nie zostało zmienione.",
"too_weak": "Potwierdź drugim składnikiem lub kluczem dostępu. Na tym koncie samo hasło nie wystarczy."
},
"missing": "Twoja sesja wygasła, więc nic nie zostało zmienione. Zaloguj się ponownie, aby kontynuować.",
"passkey": {
"last_method": "Ten klucz dostępu to Twój jedyny sposób logowania. Najpierw ustaw hasło albo dodaj kolejny klucz dostępu."
},
"mfa": {
"already_active": "To konto ma już skonfigurowaną aplikację uwierzytelniającą. Najpierw ją wyłącz, aby skonfigurować nową."
}
},
"biomarkers": {
@@ -178,6 +186,54 @@
},
"aiRuns": {
"readerBusy": "Czytnik dokumentów jest zajęty innymi zadaniami. Spróbuj ponownie za minutę."
},
"http": {
"unauthenticated": "Twoja sesja wygasła, więc nic nie zostało zmienione. Zaloguj się ponownie, aby kontynuować.",
"rate_limited": "W tej chwili jest zbyt wiele zapytań, więc nic nie zostało zmienione. Zaczekaj chwilę i spróbuj ponownie.",
"forbidden": "Nie masz do tego uprawnień, więc nic nie zostało zmienione.",
"invalid": "Niektóre dane nie zostały przyjęte, więc nic nie zostało zmienione. Sprawdź je i spróbuj ponownie."
},
"ai_provider": {
"private_host": "Ten adres jest w sieci prywatnej. Operator musi najpierw dopuścić jego dokładne źródło w AI_PRIVATE_ORIGINS."
},
"encounter": {
"invalid": "Sprawdź dane wizyty i spróbuj ponownie.",
"practitioner-not-found": "Ten gabinet lub lekarz już nie istnieje. Wybierz inny wpis albo „Nie podano”.",
"reminder-not-found": "To badanie już nie istnieje. Wybierz inne albo pozostaw pole puste.",
"reminder-conflict": "Zaplanowana wizyta nie może zamknąć badania, bo jeszcze się nie odbyła."
},
"vaccination": {
"invalid": "Sprawdź dane szczepienia i spróbuj ponownie.",
"practitioner-not-found": "Ten gabinet lub lekarz już nie istnieje. Wybierz inny wpis albo „Nie podano”.",
"encounter-not-found": "Ta wizyta już nie istnieje. Wybierz inną albo pozostaw pole puste.",
"custom-vaccine-not-found": "Ta szczepionka już nie istnieje. Wybierz ją ponownie z listy.",
"not-found": "To szczepienie nie jest już dostępne.",
"identity-required": "Dawka potrzebuje nazwy: wybierz szczepionkę z listy albo wpisz to, co podano w karcie szczepień.",
"booster-invalid": "Sprawdź dane dawki przypominającej i spróbuj ponownie.",
"booster-no-antigen": "Ta dawka nie wskazuje szczepionki z katalogu, więc nie da się zaplanować dawki przypominającej.",
"booster-out-of-scope": "Twój dostęp do tej kartoteki nie obejmuje planowania dawki przypominającej."
},
"allergy": {
"invalid": "Sprawdź dane alergii i spróbuj ponownie."
},
"practitioner": {
"invalid": "Sprawdź dane gabinetu lub lekarza i spróbuj ponownie."
},
"workout": {
"batch": {
"invalid": "Sprawdź dane treningu i spróbuj ponownie.",
"too_large": "Za dużo treningów naraz. Wysyłaj mniej za jednym razem.",
"payload_too_large": "Dane treningu są za duże, by zapisać je za jednym razem.",
"source_not_permitted": "To połączenie nie może zapisywać treningów pod tym źródłem."
}
},
"ai": {
"provider": {
"none": "Nie skonfigurowano dostawcy AI, więc nic nie zostało wysłane. Skonfiguruj go w ustawieniach AI."
},
"budget": {
"exceeded": "Dzisiejszy budżet AI został wykorzystany, więc nic nie zostało wysłane. Odnowi się jutro."
}
}
},
"doctorReport": {
@@ -979,7 +1035,9 @@
"typeStressLevel": "Poziom stresu",
"typeTrainingReadiness": "Gotowość do treningu",
"typeAcuteTrainingLoad": "Ostre obciążenie treningowe",
"sourceGarmin": "Garmin"
"sourceGarmin": "Garmin",
"valueOutOfRange": "Wpisz wartość od {min} do {max} {unit}.",
"valueOutOfRangeNoUnit": "Wpisz wartość od {min} do {max}."
},
"metricCatalog": {
"title": "Co HealthLog potrafi śledzić",
@@ -1531,7 +1589,8 @@
"gallbladderDiscomfort": "Dolegliwości pęcherzyka żółciowego"
},
"deleteConfirmTitle": "Usunąć ten wpis?",
"deleteConfirmBody": "Ten wpis o skutku ubocznym zostanie trwale usunięty."
"deleteConfirmBody": "Ten wpis o skutku ubocznym zostanie trwale usunięty.",
"saveError": "Nie udało się zapisać wpisu"
},
"scheduling": {
"naturalLanguage": {
@@ -1955,7 +2014,6 @@
},
"intake": {
"title": "Historia przyjęć",
"importButton": "Importuj",
"rowActions": {
"openMenu": "Otwórz akcje wiersza",
"edit": "Edytuj",
@@ -1965,14 +2023,7 @@
"rowToggleLabel": "Zaznacz wiersz"
},
"bulkDelete": {
"selectionCount": "{count} wpisów zaznaczono",
"deleteButton": "Usuń zaznaczone",
"cancelButton": "Anuluj",
"confirmTitle": "Usunąć zaznaczone wpisy?",
"confirmBody": "{count} wpisów zostanie trwale usuniętych. Sam lek pozostaje.",
"confirmAction": "Usuń",
"toast": "Zaznaczone usunięto",
"failed": "Nie udało się usunąć zaznaczonych."
"cancelButton": "Anuluj"
},
"edit": {
"dialogTitle": "Edytuj przyjęcie",
@@ -2846,11 +2897,6 @@
"bmiEmptyTitle": "Dodaj wzrost, aby obliczyć BMI",
"bmiEmptyDescription": "BMI wymaga twojego wzrostu. Zapisz go w Ustawieniach → Konto.",
"bmiEmptyAction": "Otwórz ustawienia konta",
"personalRecord": {
"badge": "PR",
"tooltip": "Rekord osobisty: {value} {unit} dnia {date}",
"benchmark": "Typowo w tym wieku: od {low} do {high} {unit} ({source})"
},
"loadError": "Nie udało się załadować analiz",
"heroPersonalBaseline": "Na podstawie ostatnich 90 dni",
"heroRegenerating": "Generowanie",
@@ -3017,8 +3063,6 @@
},
"correlationRow": {
"title": "Korelacje",
"experimentCta": "Uruchom 7-dniowy eksperyment",
"experimentTooltip": "Wkrótce",
"emptyTitle": "Potrzeba więcej danych, aby pokazać tę korelację",
"sourceChip": "na podstawie {n} sparowanych pomiarów ({window})",
"sourceWindowLast30": "Ostatnie 30 dni",
@@ -5179,7 +5223,9 @@
"connectCta": "Połącz urządzenie",
"logCta": "Zapisz pierwszy pomiar",
"importCta": "Importuj istniejącą historię",
"modulesCta": "Wybierz swoje moduły"
"modulesCta": "Wybierz swoje moduły",
"aiOptional": "AI jest opcjonalne. Wszystko działa także bez niego.",
"aiSetupCta": "Skonfiguruj AI"
},
"welcomeBack": {
"title": "Witaj ponownie",
@@ -5200,37 +5246,6 @@
"allergiesPlaceholder": "np. penicylina, laktoza",
"privacyNote": "Przechowywane w postaci zaszyfrowanej i używane tylko po to, by dać Ci lepszy kontekst. Nie pytamy tu o leki, dodasz je w dowolnej chwili na stronie Leki."
},
"ai": {
"panelTitle": "Zobacz, jak wyglądają insighty",
"panelIntro": "Coach, codzienny briefing i oceny dla poszczególnych metryk zamieniają Twoje własne liczby w prosty język. Oto jak to wygląda, zanim cokolwiek podłączysz.",
"showSample": "Zobacz przykładowy briefing",
"sampleTitle": "Codzienny briefing",
"sampleTag": "Przykład",
"sampleAriaLabel": "Przykładowy codzienny briefing",
"sampleBody": "Waga niższa o 1,2 kg w ciągu dwóch tygodni. Tętno spoczynkowe stabilne na 58 bpm. Ciśnienie w normie przez 6 z 7 dni; jedyny wysoki odczyt nastąpił po późnej, słonej kolacji.",
"sampleCaption": "Tylko przykład, to nie są Twoje dane. Tak czyta się briefing, gdy insighty są włączone.",
"ladderTitle": "Trzy sposoby, by to włączyć",
"ladderLocalTitle": "Model lokalny: zapytania do AI zostają w Twojej sieci",
"ladderLocalBody": "Skieruj HealthLog na Ollama lub LM Studio na własnej maszynie. Za darmo i w pełni prywatnie: spokojny wybór domyślny.",
"ladderByokTitle": "Użyj własnego klucza",
"ladderByokBody": "Użyj własnego klucza OpenAI lub Anthropic. Działa na Twoim koncie i rozliczeniu, a poziom API domyślnie wyklucza Twoje dane z treningu.",
"ladderOauthTitle": "Zaloguj się przez subskrypcję",
"ladderOauthBody": "Podłącz konto ChatGPT. Bez kosztu za token, ale warunki poziomu konsumenckiego mogą wykorzystywać treści do treningu, więc to świadomy wybór, nigdy domyślny dla danych o zdrowiu.",
"keylessLine": "HealthLog jest w pełni użyteczny bez AI. Twoje kafelki, wykresy, trendy i eksporty działają; AI dokłada tylko odczyt prostym językiem.",
"setupCta": "Skonfiguruj (około minuty)",
"sharedKeyNote": "Ta instancja udostępnia już wspólnego dostawcę AI, więc insighty działają dla Ciebie od razu, bez konfiguracji.",
"offer": {
"title": "Ta instancja potrafi już napisać twoje podsumowanie zwykłym językiem.",
"body": "Operator udostępnia dostawcę, który działał po raz ostatni w ciągu ostatnich 24 godzin. Jedno dotknięcie włącza zwykły język; twoje dane trafią wtedy do tego jednego dostawcy i do nikogo więcej.",
"cta": "Włącz zwykły język",
"granted": "Włączone. Pierwsze podsumowanie pojawi się na pulpicie.",
"error": "Włączenie się nie powiodło. Spróbuj ponownie za chwilę."
},
"unavailable": {
"title": "Dostawca tej instancji w tej chwili nie odpowiada.",
"body": "HealthLog jest w pełni użyteczny bez zwykłego języka. Później możesz dodać w ustawieniach własnego dostawcę."
}
},
"steps": {
"who": "Dla kogo jest ta kartoteka",
"areas": "Co chcesz obserwować",
@@ -5944,6 +5959,7 @@
"inProgress": "Import historii dawek już trwa. Poczekaj, aż się zakończy.",
"workerUnavailable": "Proces w tle jest niedostępny, więc nic nie zostało zaimportowane.",
"failed": "Import nie udał się. Nic nie zostało zapisane.",
"stillRunning": "Import trwa dłużej niż zwykle i może się jeszcze zakończyć. Sprawdź swoje dawki ponownie za kilka minut.",
"columnsHeading": "Co się dzieje z każdą kolumną",
"jsonOnly": "tylko JSON",
"previewLine": "Odczytane wiersze: {rows}, do zaimportowania: {queued}, odrzucone: {refused}",
@@ -7792,7 +7808,9 @@
"hrv": "HRV",
"weight": "pomiary wagi",
"cycle": "cykl menstruacyjny"
}
},
"passkeyRenameFailed": "Nie udało się zmienić nazwy klucza dostępu.",
"passkeyRemoveFailed": "Nie udało się usunąć klucza dostępu."
},
"admin": {
"title": "Administracja",
@@ -9314,16 +9332,8 @@
},
"eventMedicationReminder": "Przypomnienie o leku",
"eventMedicationReminderDesc": "Wysyłane o każdej zaplanowanej godzinie dawki.",
"eventMeasurementAnomaly": "Nietypowy pomiar",
"eventMeasurementAnomalyDesc": "Wysyłane, gdy odczyt znacznie odbiega od Twojego zwykłego zakresu.",
"eventComplianceLow": "Niska zgodność",
"eventComplianceLowDesc": "Wysyłane, gdy odsetek przyjętych dawek z ostatnich dni spadnie poniżej 80 %.",
"eventWithingsSyncFailed": "Synchronizacja Withings nie powiodła się",
"eventWithingsSyncFailedDesc": "Wysyłane, gdy synchronizacja Withings nie powiedzie się kilka razy z rzędu.",
"eventSystemAlert": "Powiadomienie systemowe",
"eventSystemAlertDesc": "Ogólne komunikaty z serwera HealthLog, na przykład okna serwisowe lub uwagi do konta.",
"eventPersonalRecord": "Rekord osobisty",
"eventPersonalRecordDesc": "Wysyłane, gdy odczyt pobije Twój dotychczasowy rekord.",
"eventMoodReminder": "Przypomnienie o nastroju",
"eventMoodReminderDesc": "Codzienne przypomnienie, aby zapisać swój aktualny nastrój.",
"eventCyclePeriodSoon": "Zbliża się okres",
@@ -10972,7 +10982,9 @@
"deleteTitle": "Usunąć własną szczepionkę?",
"sectionDescription": "Szczepionki dodane przez Ciebie, bo nie ma ich w katalogu.",
"catalogGroup": "Katalog"
}
},
"deleted": "Szczepienie usunięte",
"restoreFailed": "Nie udało się przywrócić szczepienia."
},
"illness": {
"title": "Choroba",
@@ -11186,7 +11198,6 @@
"title": "Plany",
"pageDescription": "Plany typu „jeśli… to” uzgodnione w rozmowach z Coachem. Potwierdź propozycję, aby Coach ją zapamiętał.",
"readOnlyNote": "Coach jest teraz niedostępny, ale nadal możesz czytać i usuwać swoje plany.",
"proposalTitle": "Propozycja planu",
"ifThen": "Jeśli {cue}, to {action}.",
"targetPrefix": "Cel: {target}",
"reviewPrefix": "Przegląd: {when}",
@@ -12481,6 +12492,10 @@
"purgeFailed": "Nie udało się usunąć dokumentu.",
"purgeConfirmTitle": "Usunąć ten dokument na stałe?",
"purgeConfirmBody": "Zostanie usunięty teraz zamiast po okresie oczekiwania i nie będzie można go przywrócić."
},
"picker": {
"capped": "Widać {count} najnowszych. Starszy dokument znajdziesz przez wyszukiwanie.",
"cappedSearching": "Widać {count} najnowszych wyników. Zawęź wyszukiwanie, aby znaleźć starszy."
}
},
"selfDescription": {
@@ -12528,7 +12543,8 @@
"save": "Zapisz",
"savedToast": "Zapisano metrykę własną",
"updatedToast": "Zaktualizowano metrykę własną",
"saveError": "Nie udało się zapisać metryki własnej"
"saveError": "Nie udało się zapisać metryki własnej",
"nameTaken": "Metryka własna o tej nazwie już istnieje."
},
"entry": {
"value": "Wartość",
@@ -13483,7 +13499,9 @@
"defaultReason": "Brak wcześniejszej wizyty w tym samym gabinecie, tej samej specjalizacji lub tego samego rodzaju.",
"capped": "Ostatnie dwa lata",
"cappedReason": "Ostatnia pasująca wizyta była ponad dwa lata temu, a raport obejmuje najwyżej dwa lata."
}
},
"deleted": "Wizyta usunięta",
"restoreFailed": "Nie udało się przywrócić wizyty."
},
"checkups": {
"viewSwitchLabel": "Pokaż badania profilaktyczne, wizyty, zabiegi lub miejsca na ciele",
@@ -13516,7 +13534,9 @@
"deleteTitle": "Usunąć ten wpis?",
"deleteDescription": "Twoje dotychczasowe wizyty pozostają i zachowują tę nazwę.",
"deleteFailed": "Nie udało się usunąć wpisu.",
"noSpecialtyGroup": "Bez specjalizacji"
"noSpecialtyGroup": "Bez specjalizacji",
"deleted": "Wpis usunięty",
"restoreFailed": "Nie udało się przywrócić wpisu."
},
"links": {
"picker": {
+86 -66
View File
@@ -142,7 +142,15 @@
"auth": {
"reproof": {
"required": "Confirme sua senha atual para alterar isto.",
"failed": "A verificação não foi concluída. Nada foi alterado."
"failed": "A verificação não foi concluída. Nada foi alterado.",
"too_weak": "Confirme com seu segundo fator ou uma chave de acesso. Nesta conta, só a senha não basta."
},
"missing": "Sua sessão terminou, então nada foi alterado. Entre novamente para continuar.",
"passkey": {
"last_method": "Esta chave de acesso é sua única forma de entrar. Defina uma senha ou adicione outra chave de acesso primeiro."
},
"mfa": {
"already_active": "Esta conta já tem um aplicativo autenticador configurado. Desative-o primeiro para configurar um novo."
}
},
"biomarkers": {
@@ -178,6 +186,54 @@
},
"aiRuns": {
"readerBusy": "O leitor de documentos está ocupado com outras tarefas. Tente novamente em um minuto."
},
"http": {
"unauthenticated": "Sua sessão terminou, então nada foi alterado. Entre novamente para continuar.",
"rate_limited": "Há solicitações demais neste momento, então nada foi alterado. Aguarde um pouco e tente de novo.",
"forbidden": "Você não tem permissão para isso, então nada foi alterado.",
"invalid": "Alguns dados não foram aceitos, então nada foi alterado. Confira e tente de novo."
},
"ai_provider": {
"private_host": "Este endereço está em uma rede privada. O operador precisa primeiro permitir a origem exata em AI_PRIVATE_ORIGINS."
},
"encounter": {
"invalid": "Verifique os dados da consulta e tente novamente.",
"practitioner-not-found": "Esta clínica ou médico não existe mais. Escolha outro ou nenhum.",
"reminder-not-found": "Este check-up não existe mais. Escolha outro ou nenhum.",
"reminder-conflict": "Uma consulta planejada não pode fechar um check-up, porque ainda não aconteceu."
},
"vaccination": {
"invalid": "Verifique os dados da vacina e tente novamente.",
"practitioner-not-found": "Esta clínica ou médico não existe mais. Escolha outro ou nenhum.",
"encounter-not-found": "Esta consulta não existe mais. Escolha outra ou nenhuma.",
"custom-vaccine-not-found": "Esta vacina não existe mais. Escolha-a novamente na lista.",
"not-found": "Esta vacina não está mais disponível.",
"identity-required": "Uma dose precisa de um nome: escolha uma vacina da lista ou digite o que consta no registro.",
"booster-invalid": "Verifique os dados do reforço e tente novamente.",
"booster-no-antigen": "Esta dose não indica nenhuma vacina do catálogo, então não é possível planejar um reforço.",
"booster-out-of-scope": "Seu acesso a este registro não permite planejar um reforço."
},
"allergy": {
"invalid": "Verifique os dados da alergia e tente novamente."
},
"practitioner": {
"invalid": "Verifique os dados da clínica ou do médico e tente novamente."
},
"workout": {
"batch": {
"invalid": "Verifique os dados do treino e tente novamente.",
"too_large": "Treinos demais de uma vez. Envie menos por vez.",
"payload_too_large": "Os dados do treino são grandes demais para salvar de uma vez.",
"source_not_permitted": "Esta conexão não pode salvar treinos com essa origem."
}
},
"ai": {
"provider": {
"none": "Nenhum provedor de IA está configurado, então nada foi enviado. Configure um nas configurações de IA."
},
"budget": {
"exceeded": "O orçamento de IA de hoje acabou, então nada foi enviado. Ele é renovado amanhã."
}
}
},
"doctorReport": {
@@ -979,7 +1035,9 @@
"typeStressLevel": "Nível de estresse",
"typeTrainingReadiness": "Prontidão para treinar",
"typeAcuteTrainingLoad": "Carga de treino aguda",
"sourceGarmin": "Garmin"
"sourceGarmin": "Garmin",
"valueOutOfRange": "Informe um valor entre {min} e {max} {unit}.",
"valueOutOfRangeNoUnit": "Informe um valor entre {min} e {max}."
},
"metricCatalog": {
"title": "O que o HealthLog pode acompanhar",
@@ -1531,7 +1589,8 @@
"gallbladderDiscomfort": "Desconforto na vesícula"
},
"deleteConfirmTitle": "Excluir esta entrada?",
"deleteConfirmBody": "Esta entrada de efeito colateral será removida permanentemente."
"deleteConfirmBody": "Esta entrada de efeito colateral será removida permanentemente.",
"saveError": "Não foi possível salvar a entrada"
},
"scheduling": {
"naturalLanguage": {
@@ -1955,7 +2014,6 @@
},
"intake": {
"title": "Histórico de tomadas",
"importButton": "Importar",
"rowActions": {
"openMenu": "Abrir ações da linha",
"edit": "Editar",
@@ -1965,14 +2023,7 @@
"rowToggleLabel": "Selecionar linha"
},
"bulkDelete": {
"selectionCount": "{count} entradas selecionadas",
"deleteButton": "Excluir seleção",
"cancelButton": "Cancelar",
"confirmTitle": "Excluir as entradas selecionadas?",
"confirmBody": "{count} entradas serão removidas permanentemente. O medicamento em si permanece.",
"confirmAction": "Excluir",
"toast": "Seleção excluída",
"failed": "Não foi possível excluir a seleção."
"cancelButton": "Cancelar"
},
"edit": {
"dialogTitle": "Editar tomada",
@@ -2846,11 +2897,6 @@
"bmiEmptyTitle": "Defina sua altura para ver o IMC",
"bmiEmptyDescription": "O IMC precisa da sua altura. Adicione-a em Configurações → Conta.",
"bmiEmptyAction": "Abrir configurações da conta",
"personalRecord": {
"badge": "Recorde",
"tooltip": "Recorde pessoal: {value} {unit} em {date}",
"benchmark": "Típico nessa idade: {low} a {high} {unit} ({source})"
},
"loadError": "Falha ao carregar os insights",
"heroPersonalBaseline": "Com base nos seus últimos 90 dias",
"heroRegenerating": "Regenerando",
@@ -3017,8 +3063,6 @@
},
"correlationRow": {
"title": "Relações",
"experimentCta": "Tente um experimento de 7 dias",
"experimentTooltip": "Em breve",
"emptyTitle": "Precisa de mais dados para ver esta relação",
"sourceChip": "com base em {n} leituras pareadas ({window})",
"sourceWindowLast30": "últimos 30 dias",
@@ -5179,7 +5223,9 @@
"connectCta": "Conectar um dispositivo",
"logCta": "Registrar sua primeira medição",
"importCta": "Importar histórico existente",
"modulesCta": "Escolher seus módulos"
"modulesCta": "Escolher seus módulos",
"aiOptional": "A IA é opcional. Tudo funciona também sem ela.",
"aiSetupCta": "Configurar IA"
},
"welcomeBack": {
"title": "Bem-vindo de volta",
@@ -5200,37 +5246,6 @@
"allergiesPlaceholder": "ex.: penicilina, lactose",
"privacyNote": "Armazenado criptografado e usado apenas para dar melhor contexto. Não pedimos seus medicamentos aqui. Adicione-os a qualquer momento na página Medicamentos."
},
"ai": {
"panelTitle": "Veja como os insights ficam",
"panelIntro": "Coach, o briefing diário e as leituras por métrica transformam seus próprios números em linguagem simples. Aqui está a forma disso, antes de conectar qualquer coisa.",
"showSample": "Ver um briefing de exemplo",
"sampleTitle": "Briefing Diário",
"sampleTag": "Exemplo",
"sampleAriaLabel": "Exemplo de briefing diário",
"sampleBody": "Peso caiu 1,2 kg em duas semanas. Pulso em repouso estável em 58 bpm. Pressão arterial na faixa em 6 de 7 dias; a única leitura alta seguiu um jantar tardio e salgado.",
"sampleCaption": "Apenas exemplo, não seus próprios dados. É assim que um briefing lê quando os insights estão ligados.",
"ladderTitle": "Três formas de ligar",
"ladderLocalTitle": "Modelo local: solicitações de IA permanecem na sua rede",
"ladderLocalBody": "Aponte o HealthLog para Ollama ou LM Studio na sua própria máquina. Gratuito e totalmente privado, o padrão tranquilo.",
"ladderByokTitle": "Traga sua própria chave",
"ladderByokBody": "Use sua própria chave OpenAI ou Anthropic. Roda sob sua conta e cobrança, e o nível de API mantém seus dados fora de treinamento por padrão.",
"ladderOauthTitle": "Entre com uma assinatura",
"ladderOauthBody": "Conecte uma conta ChatGPT. Sem custo por token, mas os termos do nível consumidor podem usar conteúdo para treinamento, então é uma escolha deliberada, nunca o padrão para seus dados de saúde.",
"keylessLine": "O HealthLog é totalmente útil sem IA. Seus blocos, gráficos, tendências e exportações funcionam. A IA só adiciona a leitura em linguagem simples por cima.",
"setupCta": "Configurar (cerca de um minuto)",
"sharedKeyNote": "Esta instância já fornece um provedor de IA compartilhado, então os insights funcionam para você agora, sem configuração.",
"offer": {
"title": "Esta instância pode escrever seu resumo em linguagem simples.",
"body": "O operador fornece um provedor que funcionou pela última vez nas últimas 24 horas. Um toque liga a leitura em linguagem simples; seus dados vão para esse único provedor e para ninguém mais.",
"cta": "Ligar linguagem simples",
"granted": "Ligado. O primeiro resumo aparece no seu painel.",
"error": "Ligar não funcionou. Tente novamente em instantes."
},
"unavailable": {
"title": "O provedor desta instância não está respondendo agora.",
"body": "O HealthLog é totalmente útil sem a leitura em linguagem simples. Você pode adicionar um provedor próprio mais tarde nas Configurações."
}
},
"steps": {
"who": "Para quem é este registro",
"areas": "Em que prestar atenção",
@@ -5944,6 +5959,7 @@
"inProgress": "Uma importação de histórico de doses já está em andamento. Espere ela terminar.",
"workerUnavailable": "O worker em segundo plano não está disponível, então nada foi importado.",
"failed": "A importação falhou. Nada foi gravado.",
"stillRunning": "A importação está demorando mais que o normal e ainda pode terminar. Confira suas doses de novo em alguns minutos.",
"columnsHeading": "O que cada coluna faz",
"jsonOnly": "Somente JSON",
"previewLine": "{rows} linhas lidas, {queued} seriam importadas, {refused} recusadas",
@@ -7792,7 +7808,9 @@
"hrv": "VFC",
"weight": "pesagens",
"cycle": "ciclo menstrual"
}
},
"passkeyRenameFailed": "Não foi possível renomear a chave de acesso.",
"passkeyRemoveFailed": "Não foi possível remover a chave de acesso."
},
"admin": {
"title": "Administração",
@@ -9314,16 +9332,8 @@
},
"eventMedicationReminder": "Lembrete de medicamento",
"eventMedicationReminderDesc": "Enviado em cada horário programado de dose.",
"eventMeasurementAnomaly": "Medição incomum",
"eventMeasurementAnomalyDesc": "Enviado quando uma leitura está bem fora da sua faixa habitual.",
"eventComplianceLow": "Adesão baixa",
"eventComplianceLowDesc": "Enviado quando sua adesão nos últimos dias cai abaixo de 80%.",
"eventWithingsSyncFailed": "Falha na sincronização do Withings",
"eventWithingsSyncFailedDesc": "Enviado quando a sincronização do Withings falha várias tentativas seguidas.",
"eventSystemAlert": "Aviso do sistema",
"eventSystemAlertDesc": "Mensagens gerais do servidor do HealthLog, como janelas de manutenção ou notas da conta.",
"eventPersonalRecord": "Recorde pessoal",
"eventPersonalRecordDesc": "Enviado quando uma leitura supera seu recorde anterior.",
"eventMoodReminder": "Lembrete de humor",
"eventMoodReminderDesc": "Lembrete diário para registrar seu humor atual.",
"eventCyclePeriodSoon": "Menstruação se aproximando",
@@ -10972,7 +10982,9 @@
"deleteTitle": "Remover vacina própria?",
"sectionDescription": "Vacinas que você adicionou porque o catálogo não as lista.",
"catalogGroup": "Catálogo"
}
},
"deleted": "Vacina excluída",
"restoreFailed": "Não foi possível restaurar a vacina."
},
"illness": {
"title": "Doenças",
@@ -11186,7 +11198,6 @@
"title": "Planos",
"pageDescription": "Planos do tipo “se… então…” acordados nas suas conversas com o Coach. Confirme uma proposta para torná-la parte da memória do Coach.",
"readOnlyNote": "O Coach não está disponível agora, mas você ainda pode ler e excluir seus planos.",
"proposalTitle": "Proposta de plano",
"ifThen": "Se {cue}, então {action}.",
"targetPrefix": "Alvo: {target}",
"reviewPrefix": "Revisão: {when}",
@@ -12481,6 +12492,10 @@
"purgeFailed": "Não foi possível excluir o documento.",
"purgeConfirmTitle": "Excluir este documento para sempre?",
"purgeConfirmBody": "Ele é removido agora, e não no fim do período de espera, e não pode ser restaurado."
},
"picker": {
"capped": "Mostrando os {count} mais recentes. Use a busca para encontrar um documento mais antigo.",
"cappedSearching": "Mostrando os {count} resultados mais recentes. Refine a busca para encontrar um mais antigo."
}
},
"selfDescription": {
@@ -12528,7 +12543,8 @@
"save": "Salvar",
"savedToast": "Métrica personalizada salva",
"updatedToast": "Métrica personalizada atualizada",
"saveError": "Não foi possível salvar a métrica personalizada"
"saveError": "Não foi possível salvar a métrica personalizada",
"nameTaken": "Já existe uma métrica personalizada com este nome."
},
"entry": {
"value": "Valor",
@@ -13410,7 +13426,7 @@
"created": "Consulta salva",
"updated": "Consulta atualizada",
"saveFailed": "Não foi possível salvar a consulta.",
"checkupNotClosed": "Consulta salva. O check permanece aberto; seu acesso não cobre isso.",
"checkupNotClosed": "Consulta salva. O check-up permanece aberto; seu acesso não cobre isso.",
"status": {
"planned": "Planejada",
"done": "Comparecida",
@@ -13483,7 +13499,9 @@
"defaultReason": "Nenhuma consulta anterior com o mesmo consultório, especialidade ou tipo.",
"capped": "Os últimos dois anos",
"cappedReason": "A última consulta correspondente foi há mais de dois anos, e um relatório cobre no máximo dois anos."
}
},
"deleted": "Consulta excluída",
"restoreFailed": "Não foi possível restaurar a consulta."
},
"checkups": {
"viewSwitchLabel": "Mostrar check-ups, consultas, procedimentos ou locais do corpo",
@@ -13516,7 +13534,9 @@
"deleteTitle": "Excluir esta entrada?",
"deleteDescription": "Suas consultas existentes permanecem e mantêm este nome.",
"deleteFailed": "Não foi possível excluir a entrada.",
"noSpecialtyGroup": "Sem especialidade"
"noSpecialtyGroup": "Sem especialidade",
"deleted": "Entrada excluída",
"restoreFailed": "Não foi possível restaurar a entrada."
},
"links": {
"picker": {
+85 -65
View File
@@ -142,7 +142,15 @@
"auth": {
"reproof": {
"required": "Подтвердите текущий пароль, чтобы изменить это.",
"failed": "Не подтверждено. Ничего не изменено."
"failed": "Не подтверждено. Ничего не изменено.",
"too_weak": "Подтвердите вторым фактором или ключом доступа. Для этого аккаунта одного пароля недостаточно."
},
"missing": "Сеанс завершён, поэтому ничего не изменено. Войдите снова, чтобы продолжить.",
"passkey": {
"last_method": "Этот ключ доступа остаётся вашим единственным способом входа. Сначала задайте пароль или добавьте ещё один ключ доступа."
},
"mfa": {
"already_active": "Для этого аккаунта уже настроено приложение-аутентификатор. Сначала выключите его, чтобы настроить новое."
}
},
"biomarkers": {
@@ -178,6 +186,54 @@
},
"aiRuns": {
"readerBusy": "Средство чтения документов занято другой работой. Попробуйте через минуту."
},
"http": {
"unauthenticated": "Сеанс завершён, поэтому ничего не изменено. Войдите снова, чтобы продолжить.",
"rate_limited": "Сейчас слишком много запросов, поэтому ничего не изменено. Подождите немного и попробуйте снова.",
"forbidden": "У вас нет прав на это действие, поэтому ничего не изменено.",
"invalid": "Некоторые данные не приняты, поэтому ничего не изменено. Проверьте их и попробуйте снова."
},
"ai_provider": {
"private_host": "Этот адрес находится в частной сети. Сначала оператор должен разрешить его точный источник в AI_PRIVATE_ORIGINS."
},
"encounter": {
"invalid": "Проверьте данные визита и попробуйте снова.",
"practitioner-not-found": "Этой клиники или врача больше нет. Выберите другую запись или «Не указано».",
"reminder-not-found": "Этого осмотра больше нет. Выберите другой или оставьте поле пустым.",
"reminder-conflict": "Запланированный визит не может закрыть осмотр, потому что он ещё не состоялся."
},
"vaccination": {
"invalid": "Проверьте данные вакцинации и попробуйте снова.",
"practitioner-not-found": "Этой клиники или врача больше нет. Выберите другую запись или «Не указано».",
"encounter-not-found": "Этого визита больше нет. Выберите другой или оставьте поле пустым.",
"custom-vaccine-not-found": "Этой вакцины больше нет. Выберите её в списке ещё раз.",
"not-found": "Этой вакцинации больше нет.",
"identity-required": "У дозы должно быть название: выберите вакцину из списка или введите то, что указано в записи.",
"booster-invalid": "Проверьте данные ревакцинации и попробуйте снова.",
"booster-no-antigen": "В этой дозе не указана вакцина из каталога, поэтому ревакцинацию запланировать нельзя.",
"booster-out-of-scope": "Ваш доступ к этой карте не позволяет планировать ревакцинацию."
},
"allergy": {
"invalid": "Проверьте данные аллергии и попробуйте снова."
},
"practitioner": {
"invalid": "Проверьте данные клиники или врача и попробуйте снова."
},
"workout": {
"batch": {
"invalid": "Проверьте данные тренировки и попробуйте снова.",
"too_large": "Слишком много тренировок за раз. Отправляйте их небольшими частями.",
"payload_too_large": "Данные тренировки слишком велики, чтобы сохранить их за один раз.",
"source_not_permitted": "Это подключение не может сохранять тренировки с таким источником."
}
},
"ai": {
"provider": {
"none": "Поставщик ИИ не настроен, поэтому ничего не отправлено. Настройте его в настройках ИИ."
},
"budget": {
"exceeded": "Бюджет ИИ на сегодня исчерпан, поэтому ничего не отправлено. Он обновится завтра."
}
}
},
"doctorReport": {
@@ -979,7 +1035,9 @@
"typeStressLevel": "Уровень стресса",
"typeTrainingReadiness": "Готовность к тренировке",
"typeAcuteTrainingLoad": "Острая тренировочная нагрузка",
"sourceGarmin": "Garmin"
"sourceGarmin": "Garmin",
"valueOutOfRange": "Введите значение от {min} до {max} {unit}.",
"valueOutOfRangeNoUnit": "Введите значение от {min} до {max}."
},
"metricCatalog": {
"title": "Что умеет отслеживать HealthLog",
@@ -1531,7 +1589,8 @@
"gallbladderDiscomfort": "Дискомфорт в желчном пузыре"
},
"deleteConfirmTitle": "Удалить эту запись?",
"deleteConfirmBody": "Эта запись о побочном эффекте будет удалена навсегда."
"deleteConfirmBody": "Эта запись о побочном эффекте будет удалена навсегда.",
"saveError": "Не удалось сохранить запись"
},
"scheduling": {
"naturalLanguage": {
@@ -1955,7 +2014,6 @@
},
"intake": {
"title": "История приёма",
"importButton": "Импорт",
"rowActions": {
"openMenu": "Открыть действия строки",
"edit": "Изменить",
@@ -1965,14 +2023,7 @@
"rowToggleLabel": "Выбрать строку"
},
"bulkDelete": {
"selectionCount": "Выбрано записей: {count}",
"deleteButton": "Удалить выбранное",
"cancelButton": "Отмена",
"confirmTitle": "Удалить выбранные записи?",
"confirmBody": "Записи ({count}) будут удалены навсегда. Само лекарство останется.",
"confirmAction": "Удалить",
"toast": "Выбранное удалено",
"failed": "Не удалось удалить выбранное."
"cancelButton": "Отмена"
},
"edit": {
"dialogTitle": "Изменить приём",
@@ -2846,11 +2897,6 @@
"bmiEmptyTitle": "Укажите рост, чтобы увидеть ИМТ",
"bmiEmptyDescription": "Для ИМТ нужен ваш рост. Добавьте его в разделе «Настройки» → «Аккаунт».",
"bmiEmptyAction": "Открыть настройки аккаунта",
"personalRecord": {
"badge": "Рекорд",
"tooltip": "Личный рекорд: {value} {unit} {date}",
"benchmark": "Типично в этом возрасте: {low} до {high} {unit} ({source})"
},
"loadError": "Не удалось загрузить аналитику",
"heroPersonalBaseline": "На основе ваших последних 90 дней",
"heroRegenerating": "Обновление",
@@ -3017,8 +3063,6 @@
},
"correlationRow": {
"title": "Связи",
"experimentCta": "Попробовать 7-дневный эксперимент",
"experimentTooltip": "Скоро",
"emptyTitle": "Нужно больше данных, чтобы увидеть эту связь",
"sourceChip": "на основе {n} парных измерений ({window})",
"sourceWindowLast30": "последние 30 дней",
@@ -5179,7 +5223,9 @@
"connectCta": "Подключить устройство",
"logCta": "Записать первое измерение",
"importCta": "Импортировать существующую историю",
"modulesCta": "Выбрать модули"
"modulesCta": "Выбрать модули",
"aiOptional": "ИИ необязателен. Всё работает и без него.",
"aiSetupCta": "Настроить ИИ"
},
"welcomeBack": {
"title": "С возвращением",
@@ -5200,37 +5246,6 @@
"allergiesPlaceholder": "например, пенициллин, лактоза",
"privacyNote": "Хранится в зашифрованном виде и используется только для лучшего контекста. Здесь мы не спрашиваем о ваших лекарствах. Добавляйте их в любое время на странице «Лекарства»."
},
"ai": {
"panelTitle": "Посмотрите, как выглядит аналитика",
"panelIntro": "Коуч, ежедневная сводка и заметки по каждому показателю превращают ваши числа в понятный язык. Вот как это выглядит, прежде чем вы что-либо подключите.",
"showSample": "Посмотреть пример сводки",
"sampleTitle": "Ежедневная сводка",
"sampleTag": "Пример",
"sampleAriaLabel": "Пример ежедневной сводки",
"sampleBody": "Вес снизился на 1,2 кг за две недели. Пульс в покое стабилен на 58 уд/мин. АД в диапазоне 6 из 7 дней; единственное высокое измерение последовало за поздним солёным ужином.",
"sampleCaption": "Только пример, не ваши данные. Так выглядит сводка, когда аналитика включена.",
"ladderTitle": "Три способа включить",
"ladderLocalTitle": "Локальная модель: запросы ИИ остаются в вашей сети",
"ladderLocalBody": "Направьте HealthLog на Ollama или LM Studio на вашей машине. Бесплатно и полностью приватно — спокойный выбор по умолчанию.",
"ladderByokTitle": "Свой ключ",
"ladderByokBody": "Используйте свой ключ OpenAI или Anthropic. Он работает под вашим аккаунтом и оплатой, а API-тариф по умолчанию не использует ваши данные для обучения.",
"ladderOauthTitle": "Вход по подписке",
"ladderOauthBody": "Подключите аккаунт ChatGPT. Без оплаты за токены, но условия потребительского тарифа могут использовать контент для обучения, поэтому это осознанный выбор, а не значение по умолчанию для ваших данных о здоровье.",
"keylessLine": "HealthLog полностью полезен и без ИИ. Ваши плитки, графики, динамика и экспорт работают. ИИ лишь добавляет понятное описание сверху.",
"setupCta": "Настроить (около минуты)",
"sharedKeyNote": "Этот экземпляр уже предоставляет общий провайдер ИИ, поэтому аналитика работает у вас прямо сейчас, без настройки.",
"offer": {
"title": "Этот экземпляр может написать вашу сводку понятным языком.",
"body": "Оператор предоставляет провайдера, который последний раз работал в течение последних 24 часов. Одно нажатие включает понятное описание; ваши данные затем уходят только этому провайдеру и никому больше.",
"cta": "Включить понятный язык",
"granted": "Включено. Первая сводка появится на вашей сводке.",
"error": "Включить не удалось. Попробуйте ещё раз через минуту."
},
"unavailable": {
"title": "Провайдер этого экземпляра сейчас не отвечает.",
"body": "HealthLog полностью полезен и без понятного описания. Вы можете добавить своего провайдера позже в настройках."
}
},
"steps": {
"who": "Для кого эта запись",
"areas": "За чем следить",
@@ -5944,6 +5959,7 @@
"inProgress": "Импорт истории доз уже выполняется. Дождитесь его завершения.",
"workerUnavailable": "Фоновый обработчик недоступен, поэтому ничего не импортировано.",
"failed": "Импорт не удался. Ничего не записано.",
"stillRunning": "Импорт идёт дольше обычного и ещё может завершиться. Проверьте свои приёмы через несколько минут.",
"columnsHeading": "Что делает каждый столбец",
"jsonOnly": "Только JSON",
"previewLine": "Прочитано строк: {rows}, будет импортировано: {queued}, отклонено: {refused}",
@@ -7792,7 +7808,9 @@
"hrv": "ВСР",
"weight": "взвешивания",
"cycle": "менструальный цикл"
}
},
"passkeyRenameFailed": "Не удалось переименовать ключ доступа.",
"passkeyRemoveFailed": "Не удалось убрать ключ доступа."
},
"admin": {
"title": "Администрирование",
@@ -9314,16 +9332,8 @@
},
"eventMedicationReminder": "Напоминание о лекарстве",
"eventMedicationReminderDesc": "Отправляется в каждое запланированное время приёма.",
"eventMeasurementAnomaly": "Необычное измерение",
"eventMeasurementAnomalyDesc": "Отправляется, когда измерение заметно выходит за ваш обычный диапазон.",
"eventComplianceLow": "Низкая приверженность",
"eventComplianceLowDesc": "Отправляется, когда приверженность за последние несколько дней падает ниже 80 %.",
"eventWithingsSyncFailed": "Сбой синхронизации Withings",
"eventWithingsSyncFailedDesc": "Отправляется, когда синхронизация Withings не удаётся несколько раз подряд.",
"eventSystemAlert": "Системное уведомление",
"eventSystemAlertDesc": "Общие сообщения от сервера HealthLog, например об окнах обслуживания или заметки об аккаунте.",
"eventPersonalRecord": "Личный рекорд",
"eventPersonalRecordDesc": "Отправляется, когда измерение превосходит ваш прежний лучший результат.",
"eventMoodReminder": "Напоминание о настроении",
"eventMoodReminderDesc": "Ежедневное напоминание отметить текущее настроение.",
"eventCyclePeriodSoon": "Скоро менструация",
@@ -10972,7 +10982,9 @@
"deleteTitle": "Убрать свою вакцину?",
"sectionDescription": "Вакцины, которые вы добавили сами, потому что их нет в каталоге.",
"catalogGroup": "Каталог"
}
},
"deleted": "Вакцинация удалена",
"restoreFailed": "Не удалось восстановить вакцинацию."
},
"illness": {
"title": "Заболевание",
@@ -11186,7 +11198,6 @@
"title": "Планы",
"pageDescription": "Планы «если — то», согласованные в ваших беседах с Коучем. Подтвердите предложение, чтобы оно стало частью памяти Коуча.",
"readOnlyNote": "Коуч сейчас недоступен, но вы всё ещё можете читать и удалять свои планы.",
"proposalTitle": "Предложение плана",
"ifThen": "Если {cue}, то {action}.",
"targetPrefix": "Цель: {target}",
"reviewPrefix": "Проверка: {when}",
@@ -12481,6 +12492,10 @@
"purgeFailed": "Не удалось удалить документ.",
"purgeConfirmTitle": "Удалить этот документ навсегда?",
"purgeConfirmBody": "Он будет удалён сейчас, а не в конце периода ожидания, и его нельзя будет восстановить."
},
"picker": {
"capped": "Показаны {count} самых новых. Более старый документ найдите через поиск.",
"cappedSearching": "Показаны {count} самых новых совпадений. Уточните поиск, чтобы найти более старый документ."
}
},
"selfDescription": {
@@ -12528,7 +12543,8 @@
"save": "Сохранить",
"savedToast": "Свой показатель сохранён",
"updatedToast": "Свой показатель обновлён",
"saveError": "Не удалось сохранить свой показатель"
"saveError": "Не удалось сохранить свой показатель",
"nameTaken": "Свой показатель с таким названием уже существует."
},
"entry": {
"value": "Значение",
@@ -13483,7 +13499,9 @@
"defaultReason": "Нет более раннего визита с той же клиникой, специальностью или видом.",
"capped": "Последние два года",
"cappedReason": "Последний подходящий визит был более двух лет назад, а отчёт охватывает не больше двух лет."
}
},
"deleted": "Визит удалён",
"restoreFailed": "Визит не удалось восстановить."
},
"checkups": {
"viewSwitchLabel": "Показать осмотры, визиты, процедуры или области тела",
@@ -13516,7 +13534,9 @@
"deleteTitle": "Удалить эту запись?",
"deleteDescription": "Ваши существующие визиты остаются, и они сохраняют это имя.",
"deleteFailed": "Запись не удалось удалить.",
"noSpecialtyGroup": "Без специальности"
"noSpecialtyGroup": "Без специальности",
"deleted": "Запись удалена",
"restoreFailed": "Запись не удалось восстановить."
},
"links": {
"picker": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.47.2",
"version": "1.47.3",
"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",
-7
View File
@@ -279,13 +279,6 @@ export default defineConfig({
"setup-flow-a11y.spec.ts",
"setup-flow-connect.spec.ts",
"setup-flow-locale-overflow.spec.ts",
// The shared-provider offer journey states INSTANCE-wide facts to
// reason about: it writes the operator key into `app_settings` and
// replaces every `provider_health` row for both shared tags, then
// puts both back. Serial mode only orders tests inside one file, so
// a second project would hold and clear that state underneath the
// first — and underneath any other spec that reads AI availability.
"onboarding-ai-offer.spec.ts",
// The delegated-writes journey invites, accepts and revokes between
// its own two accounts. A second project would send a second invitation
// between the same pair while the first is live, and meet the 409.
+6 -5
View File
@@ -7240,8 +7240,8 @@ model CoachFact {
// `status` is an APP-SIDE closed enum (NOT a DB enum, to keep the migration
// purely additive): proposed | active | met | abandoned | review_due | reviewed.
// The last two are the v1.22 (W9, C2) n-of-1 experiment lifecycle: an
// "experiment" is just a CoachPlan with a reviewDate; when the review fires and
// the read-back is generated, `outcomeEncrypted` carries the grounded result.
// "experiment" is just a CoachPlan with a reviewDate; the review only moves the
// status. `outcomeEncrypted` is no longer written or read (see the column).
model CoachPlan {
id String @id @default(cuid())
userId String @map("user_id")
@@ -7260,9 +7260,10 @@ model CoachPlan {
status String @default("proposed")
// Optional review checkpoint the Coach can recall ("how did this go by …").
reviewDate DateTime? @map("review_date")
// v1.22 (W9, C2) — the n-of-1 read-back prose, grounded in the user's own
// before/after numbers and encrypted at rest like the cue/action. Written
// once when an experiment's review is processed; surfaced behind a flag.
// v1.22 (W9, C2) — the n-of-1 read-back prose an experiment's review used
// to write, encrypted at rest like the cue/action. Nothing writes or reads
// it now; older rows stay, rotated with the key and carried by the backup.
/// @internal: kept for rows written before the review stopped writing it.
outcomeEncrypted Bytes? @map("outcome_encrypted")
// Provenance — nullable so a future manual-entry surface can leave it null.
sourceConversationId String? @map("source_conversation_id")
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.47.2";
/* @sw-version-fallback */ "v1.47.3";
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/*`
@@ -1,12 +1,12 @@
{
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 84,
"total": 94047,
"total": 93833,
"entries": {
"app/medications/page-client.tsx": 839,
"components/charts/mood-chart.tsx": 1166,
"components/cycle/log-day-sheet.tsx": 1639,
"components/insights/coach-panel/chat-bubble.tsx": 822,
"components/insights/coach-panel/chat-bubble.tsx": 821,
"components/insights/coach-panel/coach-conversation.tsx": 1278,
"components/insights/coach-panel/use-coach.ts": 1486,
"components/insights/derived/vitals-dashboard.tsx": 1033,
@@ -25,12 +25,12 @@
"lib/ai/coach/coach-prose-grounding.ts": 1091,
"lib/ai/coach/persistence.ts": 1218,
"lib/ai/coach/results/metric-table-tool.ts": 1102,
"lib/ai/coach/snapshot.ts": 1346,
"lib/ai/coach/snapshot.ts": 1331,
"lib/ai/coach/stream-events.ts": 811,
"lib/ai/coach/types.ts": 1008,
"lib/ai/codex-client.ts": 907,
"lib/ai/prompts/insight-generator.ts": 1115,
"lib/ai/provider.ts": 1628,
"lib/ai/provider.ts": 1497,
"lib/analytics/score/reader.ts": 998,
"lib/analytics/sleep-night.ts": 1345,
"lib/analytics/summaries-slice.ts": 1178,
@@ -41,7 +41,7 @@
"lib/daily/today-overview.ts": 838,
"lib/dashboard-layout.ts": 977,
"lib/dashboard/snapshot.ts": 1550,
"lib/day/records.ts": 1174,
"lib/day/records.ts": 1172,
"lib/export/backup-plan.ts": 838,
"lib/export/coach-backup.ts": 1070,
"lib/export/profile-backup.ts": 1019,
@@ -56,7 +56,7 @@
"lib/import/health-connect/import.ts": 1689,
"lib/import/unzip-export-xml.ts": 825,
"lib/insights/comprehensive-aggregator.ts": 868,
"lib/insights/comprehensive-generate.ts": 1674,
"lib/insights/comprehensive-generate.ts": 1670,
"lib/insights/correlation-channel-series.ts": 1339,
"lib/insights/correlation-discovery.ts": 1240,
"lib/insights/correlations.ts": 1026,
@@ -82,10 +82,10 @@
"lib/search/search-records.ts": 996,
"lib/sharing/grants.ts": 806,
"lib/support/report.ts": 903,
"lib/telegram-webhook-handlers.ts": 1341,
"lib/telegram-webhook-handlers.ts": 1319,
"lib/validations/inbound-documents.ts": 1018,
"lib/withings/sync-sleep.ts": 884,
"lib/withings/sync.ts": 933,
"proxy.ts": 846
"proxy.ts": 845
}
}
@@ -1,7 +1,7 @@
{
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 344,
"total": 111520,
"count": 337,
"total": 109571,
"entries": {
"app/achievements/page.tsx :: AchievementsPage": 206,
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275,
@@ -17,7 +17,7 @@
"app/api/devices/route.ts :: apiHandler(arg)": 226,
"app/api/encounters/[id]/route.ts :: apiHandler(arg) #2": 269,
"app/api/environment/route.ts :: apiHandler(arg)": 201,
"app/api/export/health-record/route.ts :: apiHandler(arg)": 241,
"app/api/export/health-record/route.ts :: apiHandler(arg)": 240,
"app/api/ingest/medication/route.ts :: apiHandler(arg)": 259,
"app/api/insights/cards/route.ts :: apiHandler(arg)": 211,
"app/api/integrations/status/route.ts :: apiHandler(arg)": 476,
@@ -36,7 +36,7 @@
"app/api/nutrients/batch/route.ts :: postBatch": 304,
"app/api/onboarding/complete/route.ts :: completeNeedsFlow": 218,
"app/api/record-settings/[family]/route.ts :: apiHandler(arg) #2": 262,
"app/api/user/ai-provider/route.ts :: apiHandler(arg) #2": 248,
"app/api/user/ai-provider/route.ts :: apiHandler(arg) #2": 224,
"app/api/withings/callback/route.ts :: apiHandler(arg)": 247,
"app/auth/login/page.tsx :: LoginPage": 569,
"app/auth/register/page.tsx :: RegisterPage": 285,
@@ -45,46 +45,46 @@
"app/insights/catalog/page.tsx :: MetricCatalogPage": 204,
"app/insights/medications/page.tsx :: InsightsMedikamentePage": 265,
"app/insights/page-client.tsx :: InsightsPageClient": 375,
"app/labs/page.tsx :: LabsPage": 228,
"app/labs/page.tsx :: LabsPage": 216,
"app/medications/page-client.tsx :: MedicationsPageClient": 590,
"app/notifications/page.tsx :: NotificationsPage": 327,
"components/activity/activity-calendar-card.tsx :: ActivityCalendarCard": 470,
"components/admin/admin-shell.tsx :: AdminShell": 254,
"components/admin/api-token-overview-section.tsx :: ApiTokenOverviewSection": 212,
"components/admin/app-log-preview-section.tsx :: AppLogPreviewSection": 341,
"components/admin/central-codex-section.tsx :: CentralCodexSection": 241,
"components/admin/encryption-section.tsx :: EncryptionSection": 264,
"components/admin/central-codex-section.tsx :: CentralCodexSection": 228,
"components/admin/encryption-section.tsx :: EncryptionSection": 261,
"components/admin/general-settings-section.tsx :: GeneralSettingsSection": 220,
"components/admin/glitchtip-section.tsx :: GlitchtipSection": 233,
"components/admin/glitchtip-section.tsx :: GlitchtipSection": 231,
"components/admin/host-metrics-chart.tsx :: HostMetricsChart": 232,
"components/admin/invite-tokens-section.tsx :: InviteTokensSection": 525,
"components/admin/login-overview-section.tsx :: LoginOverviewSection": 503,
"components/admin/pulse-day-mean-card.tsx :: PulseDayMeanCard": 305,
"components/admin/reminders-section.tsx :: RemindersSection": 309,
"components/admin/user-management-section.tsx :: UserManagementSection": 622,
"components/admin/pulse-day-mean-card.tsx :: PulseDayMeanCard": 302,
"components/admin/reminders-section.tsx :: RemindersSection": 306,
"components/admin/user-management-section.tsx :: UserManagementSection": 630,
"components/admin/web-push-vapid-section.tsx :: WebPushVapidSection": 217,
"components/auth/claim-profile.tsx :: ClaimProfile": 429,
"components/auth/mfa-login-step.tsx :: MfaLoginStep": 217,
"components/charts/compliance-heatmap.tsx :: ComplianceHeatmap": 325,
"components/charts/health-chart.tsx :: HealthChart": 470,
"components/charts/health-chart.tsx :: HealthChart": 462,
"components/charts/medication-compliance-chart.tsx :: MedicationComplianceChart": 543,
"components/charts/mood-chart.tsx :: MoodChart": 896,
"components/charts/mood-heatmap.tsx :: MoodHeatmap": 296,
"components/charts/trend-card.tsx :: TrendCard": 365,
"components/clinician/clinician-view.tsx :: ClinicianView": 223,
"components/command-palette/command-palette.tsx :: PaletteBody": 333,
"components/custom-metrics/custom-metric-chart.tsx :: CustomMetricChart": 231,
"components/custom-metrics/custom-metric-chart.tsx :: CustomMetricChart": 228,
"components/custom-metrics/custom-metric-detail.tsx :: CustomMetricDetail": 277,
"components/custom-metrics/custom-metric-form.tsx :: CustomMetricForm": 214,
"components/custom-metrics/custom-metric-history-list.tsx :: CustomMetricHistoryList": 302,
"components/custom-metrics/custom-metric-form.tsx :: CustomMetricForm": 210,
"components/custom-metrics/custom-metric-history-list.tsx :: CustomMetricHistoryList": 298,
"components/cycle/bbt-chart.tsx :: BbtChart": 244,
"components/cycle/cycle-calendar.tsx :: CycleCalendar": 292,
"components/cycle/cycle-settings.tsx :: CycleSettings": 228,
"components/cycle/cycle-view.tsx :: CycleView": 407,
"components/cycle/cycle-view.tsx :: CycleView": 405,
"components/cycle/log-day-sheet.tsx :: LogDaySheet": 753,
"components/daily/today-hero.tsx :: TodayHero": 488,
"components/dashboard/dashboard-header.tsx :: DashboardHeader": 215,
"components/dashboard/medication-intake-quick-add.tsx :: MedicationIntakeQuickAdd": 384,
"components/dashboard/medication-intake-quick-add.tsx :: MedicationIntakeQuickAdd": 383,
"components/day/day-layer.tsx :: DayLayer": 358,
"components/day/day-view.tsx :: DayView": 309,
"components/documents/document-ai-section.tsx :: DocumentAiSection": 262,
@@ -94,17 +94,15 @@
"components/documents/document-facts-review.tsx :: DocumentFactsSection": 242,
"components/documents/document-facts-review.tsx :: FactRow": 261,
"components/documents/document-filter-bar.tsx :: DocumentFilterBar": 348,
"components/documents/document-record-links.tsx :: DocumentRecordLinks": 202,
"components/documents/document-timeline.tsx :: DocumentTimeline": 314,
"components/documents/documents-view.tsx :: DocumentsView": 303,
"components/documents/sources/document-source-picker.tsx :: DocumentSourcePicker": 563,
"components/illness/log-day-sheet.tsx :: LogDaySheet": 265,
"components/illness/new-episode-sheet.tsx :: NewEpisodeSheet": 264,
"components/insights/coach-panel/attachment-picker.tsx :: AttachmentPicker": 208,
"components/insights/coach-panel/chat-bubble.tsx :: ChatBubbleImpl": 419,
"components/insights/coach-panel/chat-bubble.tsx :: ChatBubbleImpl": 417,
"components/insights/coach-panel/coach-conversation.tsx :: CoachConversation": 1002,
"components/insights/coach-panel/coach-input.tsx :: CoachInput": 259,
"components/insights/coach-panel/coach-model-picker.tsx :: CoachModelPicker": 212,
"components/insights/coach-panel/coach-model-picker.tsx :: CoachModelPicker": 208,
"components/insights/coach-panel/conversations-panel.tsx :: ConversationsPanel": 317,
"components/insights/coach-panel/message-thread.tsx :: MessageThread": 390,
"components/insights/coach-panel/sources-rail.tsx :: SourcesRail": 203,
@@ -130,13 +128,13 @@
"components/insights/sub-page-shell.tsx :: SubPageShell": 266,
"components/insights/trends-row.tsx :: TrendsRow": 215,
"components/labs/biomarker-form.tsx :: BiomarkerForm": 229,
"components/labs/biomarker-manager.tsx :: BiomarkerManager": 242,
"components/labs/biomarker-merge-sheet.tsx :: BiomarkerMergeSheet": 239,
"components/labs/biomarker-manager.tsx :: BiomarkerManager": 240,
"components/labs/biomarker-merge-sheet.tsx :: BiomarkerMergeSheet": 238,
"components/labs/lab-biomarker-chart.tsx :: LabBiomarkerChart": 370,
"components/labs/lab-biomarker-detail.tsx :: LabBiomarkerDetail": 481,
"components/labs/lab-form.tsx :: LabForm": 473,
"components/labs/lab-history-list.tsx :: LabHistoryList": 534,
"components/labs/ocr-review-dialog.tsx :: OcrReviewDialog": 359,
"components/labs/lab-biomarker-detail.tsx :: LabBiomarkerDetail": 476,
"components/labs/lab-form.tsx :: LabForm": 474,
"components/labs/lab-history-list.tsx :: LabHistoryList": 527,
"components/labs/ocr-review-dialog.tsx :: OcrReviewDialog": 356,
"components/labs/ocr-row-editor.tsx :: OcrRowEditor": 378,
"components/layout/auth-shell.tsx :: AuthShell": 518,
"components/layout/bottom-nav.tsx :: BottomNav": 262,
@@ -146,45 +144,41 @@
"components/links/entity-link-picker.tsx :: EntityLinkPicker": 392,
"components/measurement-reminders/vorsorge-dashboard-card.tsx :: VorsorgeDashboardCard": 215,
"components/measurement-reminders/vorsorge-section.tsx :: VorsorgeSection": 352,
"components/measurements/measurement-form.tsx :: MeasurementForm": 500,
"components/measurements/measurement-form.tsx :: MeasurementForm": 496,
"components/medications/detail/efficacy/efficacy-chart.tsx :: EfficacyChart": 361,
"components/medications/detail/medication-detail-tabs.tsx :: MedicationDetailTabs": 767,
"components/medications/dose-history-add-dialog.tsx :: LedgerAddDialog": 223,
"components/medications/dose-history-add-dialog.tsx :: LedgerAddDialog": 221,
"components/medications/dose-strength-curve.tsx :: DoseStrengthCurve": 266,
"components/medications/glp1-medication-card.tsx :: Glp1MedicationCard": 405,
"components/medications/injection-site-picker.tsx :: InjectionSitePicker": 287,
"components/medications/intake-history-editable.tsx :: IntakeHistoryEditable": 224,
"components/medications/intake-history-list-v2.tsx :: IntakeHistoryListV2": 403,
"components/medications/intake-import-dialog.tsx :: IntakeImportDialog": 208,
"components/medications/intake-import-dialog.tsx :: IntakeImportDialog": 201,
"components/medications/log-intake-dialog.tsx :: LogIntakeDialog": 267,
"components/medications/medication-card.tsx :: MedicationCard": 413,
"components/medications/medication-table.tsx :: MedicationTableRowItem": 377,
"components/medications/scheduling/natural-language-extractor.tsx :: NaturalLanguageExtractor": 214,
"components/medications/scheduling/natural-language-extractor.tsx :: NaturalLanguageExtractor": 211,
"components/medications/scheduling/schedule-history-timeline.tsx :: EraDialog": 219,
"components/medications/scheduling/schedule-history-timeline.tsx :: ScheduleHistoryTimeline": 256,
"components/medications/sections/api-tokens-row.tsx :: ApiTokensRow": 296,
"components/medications/scheduling/schedule-history-timeline.tsx :: ScheduleHistoryTimeline": 254,
"components/medications/sections/api-tokens-row.tsx :: ApiTokensRow": 294,
"components/medications/sections/composition-section.tsx :: CompositionEditor": 362,
"components/medications/sections/inventory-dialogs.tsx :: AddInventoryDialog": 238,
"components/medications/sections/inventory-section.tsx :: InventorySection": 368,
"components/medications/side-effects-section.tsx :: SideEffectsSection": 516,
"components/medications/side-effects-section.tsx :: SideEffectsSection": 518,
"components/medications/wizard/medication-wizard-dialog.tsx :: WizardDialogShell": 516,
"components/medications/wizard/steps/step3-dose.tsx :: Step3Dose": 222,
"components/medications/wizard/steps/step8-summary.tsx :: Step8Summary": 230,
"components/mental-health/mental-wellbeing.tsx :: MentalWellbeing": 202,
"components/mood/manage/tag-groups-card.tsx :: TagGroupsCard": 256,
"components/mood/manage/tag-manager-card.tsx :: TagManagerCard": 270,
"components/mood/mood-form.tsx :: MoodForm": 513,
"components/mood/manage/tag-groups-card.tsx :: TagGroupsCard": 252,
"components/mood/manage/tag-manager-card.tsx :: TagManagerCard": 261,
"components/mood/mood-form.tsx :: MoodForm": 514,
"components/mood/mood-list.tsx :: MoodList": 1109,
"components/mood/mood-tag-picker.tsx :: MoodTagPicker": 257,
"components/onboarding/confirm-screen.tsx :: ConfirmScreen": 255,
"components/onboarding/done-screen.tsx :: DoneScreen": 408,
"components/onboarding/confirm-screen.tsx :: ConfirmScreen": 251,
"components/onboarding/getting-started-checklist.tsx :: GettingStartedChecklist": 423,
"components/onboarding/tour.tsx :: OnboardingTour": 544,
"components/practitioners/practitioner-list.tsx :: PractitionerList": 230,
"components/practitioners/practitioner-list.tsx :: PractitionerList": 225,
"components/provenance/source-provenance-view.tsx :: SourceProvenanceView": 363,
"components/records/allergy-form.tsx :: AllergyForm": 225,
"components/records/allergy-form.tsx :: AllergyForm": 222,
"components/records/audit-c-panel.tsx :: AuditCPanel": 285,
"components/records/free-text-take-over.tsx :: FreeTextTakeOver": 201,
"components/records/life-circumstances-manager.tsx :: SectionBody": 472,
"components/settings/about-section.tsx :: AboutSection": 236,
"components/settings/access/grant-invite-card.tsx :: GrantInviteCard": 311,
@@ -192,16 +186,16 @@
"components/settings/access/managed-profile-edit-form.tsx :: ManagedProfileEditFields": 208,
"components/settings/access/managed-profile-guardians.tsx :: ManagedProfileGuardians": 234,
"components/settings/account-section/index.tsx :: AccountSection": 659,
"components/settings/ai/codex-provider-form.tsx :: CodexProviderForm": 280,
"components/settings/ai/fallback-chain-card.tsx :: FallbackChainCard": 228,
"components/settings/ai/runtime-actions-row.tsx :: RuntimeActionsRow": 282,
"components/settings/ai/codex-provider-form.tsx :: CodexProviderForm": 270,
"components/settings/ai/fallback-chain-card.tsx :: FallbackChainCard": 224,
"components/settings/ai/runtime-actions-row.tsx :: RuntimeActionsRow": 284,
"components/settings/api-section.tsx :: ApiTokensCard": 269,
"components/settings/coach-nudge-card.tsx :: CoachNudgeCard": 251,
"components/settings/coach-prefs-section.tsx :: CoachPrefsSection": 350,
"components/settings/environment-section.tsx :: EnvironmentSection": 666,
"components/settings/import-panel/apple-health-import-card.tsx :: AppleHealthImportCard": 298,
"components/settings/import-panel/csv-import-card.tsx :: CsvImportCard": 212,
"components/settings/import-panel/dose-history-import-card.tsx :: DoseHistoryImportCard": 225,
"components/settings/import-panel/dose-history-import-card.tsx :: DoseHistoryImportCard": 222,
"components/settings/import-panel/health-connect-import-card.tsx :: HealthConnectImportCard": 293,
"components/settings/import-panel/record-csv-import-card.tsx :: RecordCsvImportSheet": 458,
"components/settings/insights-pill-order-section.tsx :: InsightsPillOrderSection": 255,
@@ -215,8 +209,8 @@
"components/settings/mood-reminder-card.tsx :: MoodReminderCard": 234,
"components/settings/report-selection/selection-panel.tsx :: HealthRecordExportPanel": 370,
"components/settings/score-section.tsx :: ScoreSection": 383,
"components/settings/security-section/passkey-list-section.tsx :: PasskeyListSection": 390,
"components/settings/security-section/security-keys-card.tsx :: SecurityKeysCard": 288,
"components/settings/security-section/passkey-list-section.tsx :: PasskeyListSection": 395,
"components/settings/security-section/security-keys-card.tsx :: SecurityKeysCard": 286,
"components/settings/security-section/totp-card.tsx :: TotpCard": 533,
"components/settings/security-sessions-card.tsx :: SecuritySessionsCard": 245,
"components/settings/settings-shell.tsx :: SettingsShell": 254,
@@ -228,22 +222,22 @@
"components/symptoms/symptom-entry-form.tsx :: SymptomEntryForm": 317,
"components/symptoms/symptoms-section.tsx :: SymptomsSection": 205,
"components/targets/range-bar.tsx :: RangeBar": 220,
"components/targets/target-edit-sheet.tsx :: TargetEditSheetBody": 474,
"components/targets/target-edit-sheet.tsx :: TargetEditSheetBody": 473,
"components/timeline/life-event-form.tsx :: LifeEventForm": 281,
"components/timeline/timeline-chart.tsx :: ChartBody": 236,
"components/timeline/timeline-chronicle.tsx :: TimelineChronicle": 246,
"components/timeline/timeline-view.tsx :: TimelineView": 486,
"components/timeline/timeline-view.tsx :: TimelineView": 478,
"components/ui/responsive-sheet.tsx :: ResponsiveSheet": 239,
"components/vaccinations/catalog-picker.tsx :: CatalogPicker": 221,
"components/vaccinations/custom-vaccine-sheet.tsx :: CustomVaccineSheet": 246,
"components/vaccinations/vaccination-form.tsx :: VaccinationForm": 218,
"components/workouts/manual-workout-form.tsx :: ManualWorkoutForm": 323,
"components/workouts/manual-workout-form.tsx :: ManualWorkoutForm": 321,
"hooks/use-sheet-drag.ts :: createSheetDrag": 312,
"lib/activity/calendar-math.ts :: buildActivityCalendar": 204,
"lib/ai/anthropic-client.ts :: generateCompletion": 294,
"lib/ai/coach/cycle-snapshot.ts :: buildCycleSnapshotBlock": 202,
"lib/ai/coach/glp1-snapshot.ts :: buildGlp1SnapshotBlock": 218,
"lib/ai/coach/snapshot.ts :: buildCoachSnapshotImpl": 880,
"lib/ai/coach/snapshot.ts :: buildCoachSnapshotImpl": 867,
"lib/ai/coach/tools/loop.ts :: runCoachToolLoop": 438,
"lib/ai/coach/turn/model.ts :: runTurnModel": 534,
"lib/ai/coach/turn/pipeline.ts :: runTurn": 274,
@@ -269,7 +263,7 @@
"lib/doctor-report-pdf/clinical-records-notes-section.ts :: buildClinicalRecordsNotesSection": 648,
"lib/doctor-report-pdf/measurements-charts-section.ts :: buildMeasurementsChartsSection": 384,
"lib/doctor-report-pdf/medication-mood-wellness-section.ts :: buildMedicationMoodWellnessSection": 509,
"lib/doctor-report/collect.ts :: collectDoctorReportData": 667,
"lib/doctor-report/collect.ts :: collectDoctorReportData": 665,
"lib/documents/fenced-chat.ts :: streamFencedReply": 309,
"lib/documents/ingest.ts :: ingestDocument": 272,
"lib/export/backup-json-scan.ts :: scanBackupJson": 225,
@@ -284,11 +278,11 @@
"lib/google-health/data-points.ts :: forEachDataPointPage > walk": 223,
"lib/google-health/sync-core.ts :: upsertGoogleHealthMeasurements": 452,
"lib/illness/correlation.ts :: computeIllnessCorrelation": 233,
"lib/import/labs-csv.ts :: importLabsCsv": 307,
"lib/import/labs-csv.ts :: importLabsCsv > flush": 229,
"lib/import/labs-csv.ts :: importLabsCsv": 305,
"lib/import/labs-csv.ts :: importLabsCsv > flush": 227,
"lib/insights/comprehensive-aggregator.ts :: buildFromRollups": 217,
"lib/insights/comprehensive-generate.ts :: generateComprehensiveInsight": 876,
"lib/insights/features.ts :: extractFeatures": 796,
"lib/insights/comprehensive-generate.ts :: generateComprehensiveInsight": 872,
"lib/insights/features.ts :: extractFeatures": 794,
"lib/insights/mood-aggregates.ts :: fetchMoodAggregates": 246,
"lib/insights/status-provider.ts :: runStatusCompletion": 215,
"lib/integrations/connection-health-load.ts :: loadConnectionFacts": 229,
@@ -307,7 +301,7 @@
"lib/jobs/measurement-reminder.ts :: runMeasurementReminderTick": 286,
"lib/jobs/medication-intake-import.ts :: processNextChunk": 220,
"lib/jobs/medication-intake-import.ts :: processNextChunk > client.$transaction(arg)": 212,
"lib/jobs/medication-low-stock.ts :: runMedicationLowStockTick": 286,
"lib/jobs/medication-low-stock.ts :: runMedicationLowStockTick": 283,
"lib/jobs/oura-backfill.ts :: runOuraBackfillForUser": 236,
"lib/jobs/reaction-line.ts :: runReactionLine": 337,
"lib/jobs/reminder/medication-reminder-check.ts :: handleReminderCheck": 592,
@@ -337,14 +331,14 @@
"lib/record-activity/activity-verb.ts :: recordActivityVerbLine": 261,
"lib/strava/sync.ts :: runStravaSync": 219,
"lib/support/report.ts :: collectSupportReportFacts": 332,
"lib/targets/build-response.ts :: buildTargetsResponse": 330,
"lib/targets/vitals-builder.ts :: buildVitalTargets": 324,
"lib/telegram-webhook-handlers.ts :: handleCallback": 599,
"lib/telegram-webhook-handlers.ts :: handleTextMessage": 337,
"lib/targets/build-response.ts :: buildTargetsResponse": 332,
"lib/targets/vitals-builder.ts :: buildVitalTargets": 326,
"lib/telegram-webhook-handlers.ts :: handleCallback": 582,
"lib/telegram-webhook-handlers.ts :: handleTextMessage": 322,
"lib/withings/sync-activity.ts :: syncUserActivity": 377,
"lib/withings/sync-ecg.ts :: syncUserEcg": 202,
"lib/withings/sync-sleep.ts :: syncUserSleep": 414,
"lib/withings/sync.ts :: syncUserMeasurements": 337,
"proxy.ts :: proxy": 471
"proxy.ts :: proxy": 470
}
}
+5 -5
View File
@@ -1,7 +1,7 @@
{
"$comment": "as never casts in tests, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.",
"count": 523,
"total": 5475,
"total": 5457,
"entries": {
"__tests__/api/auth/login/mfa-branch.test.ts": 10,
"__tests__/api/auth/mfa/verify.test.ts": 9,
@@ -196,7 +196,7 @@
"app/api/medications/[id]/intake/import/__tests__/route.test.ts": 9,
"app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts": 24,
"app/api/medications/compliance/__tests__/route.test.ts": 10,
"app/api/medications/extract/__tests__/route.test.ts": 38,
"app/api/medications/extract/__tests__/route.test.ts": 37,
"app/api/medications/intake/__tests__/route.test.ts": 59,
"app/api/medications/intake/bulk/__tests__/dose-arrival-scope.test.ts": 9,
"app/api/medications/intake/bulk/__tests__/route.test.ts": 68,
@@ -236,7 +236,7 @@
"app/api/sleep/rhythm/__tests__/route.test.ts": 6,
"app/api/sync/changes/__tests__/module-gate.test.ts": 7,
"app/api/sync/state/__tests__/route.test.ts": 8,
"app/api/telegram/webhook/__tests__/route.test.ts": 43,
"app/api/telegram/webhook/__tests__/route.test.ts": 41,
"app/api/tokens/documents/__tests__/route.test.ts": 25,
"app/api/tokens/measurements/__tests__/route.test.ts": 25,
"app/api/tokens/workouts/__tests__/route.test.ts": 25,
@@ -300,7 +300,7 @@
"lib/ai/__tests__/provider-reasoning-effort.test.ts": 5,
"lib/ai/__tests__/provider-response-timeout.test.ts": 2,
"lib/ai/__tests__/provider.test.ts": 35,
"lib/ai/__tests__/resolve-chain.test.ts": 12,
"lib/ai/__tests__/resolve-chain.test.ts": 2,
"lib/ai/__tests__/server-provider-health.test.ts": 1,
"lib/ai/capabilities/__tests__/egress.test.ts": 1,
"lib/ai/capabilities/__tests__/gate.test.ts": 1,
@@ -385,7 +385,7 @@
"lib/import/__tests__/record-import-route.test.ts": 1,
"lib/insights/__tests__/background-reasoning-wire.test.ts": 2,
"lib/insights/__tests__/biomarker-status.test.ts": 23,
"lib/insights/__tests__/blood-pressure-status.test.ts": 32,
"lib/insights/__tests__/blood-pressure-status.test.ts": 27,
"lib/insights/__tests__/bmi-status.test.ts": 21,
"lib/insights/__tests__/briefing-provider-budget.test.ts": 2,
"lib/insights/__tests__/comprehensive-briefing-reroll.test.ts": 1,
@@ -1,7 +1,7 @@
{
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
"count": 2892,
"total": 9220,
"count": 2884,
"total": 9190,
"entries": {
"__tests__/account-payload-consumer-guard.test.ts": 2,
"__tests__/acting-account-boundary-guard.test.ts": 7,
@@ -291,7 +291,7 @@
"app/api/insights/bmi-status/route.ts": 2,
"app/api/insights/breathing-screening/route.ts": 2,
"app/api/insights/cards/__tests__/route.test.ts": 2,
"app/api/insights/cards/route.ts": 9,
"app/api/insights/cards/route.ts": 8,
"app/api/insights/chat/__tests__/dialog-harness.ts": 1,
"app/api/insights/chat/__tests__/golden-transcripts.test.ts": 1,
"app/api/insights/chat/__tests__/route-activity.test.ts": 1,
@@ -331,7 +331,7 @@
"app/api/insights/ecg/[id]/route.ts": 2,
"app/api/insights/ecg/route.ts": 1,
"app/api/insights/feedback/route.ts": 2,
"app/api/insights/generate/__tests__/route.test.ts": 12,
"app/api/insights/generate/__tests__/route.test.ts": 11,
"app/api/insights/glp1-plateau/route.ts": 2,
"app/api/insights/glp1-timeline/route.ts": 4,
"app/api/insights/health-status/route.ts": 3,
@@ -355,7 +355,7 @@
"app/api/insights/rhythm-events/route.ts": 2,
"app/api/insights/settings/route.ts": 1,
"app/api/insights/targets/__tests__/route.test.ts": 1,
"app/api/insights/targets/route.ts": 2,
"app/api/insights/targets/route.ts": 1,
"app/api/insights/weight-status/route.ts": 2,
"app/api/integrations/healthkit/__tests__/route.test.ts": 4,
"app/api/integrations/healthkit/route.ts": 3,
@@ -643,7 +643,7 @@
"app/insights/walking-heart-rate/page.tsx": 1,
"app/insights/walking-speed/page.tsx": 1,
"app/insights/walking-steadiness/page.tsx": 1,
"app/insights/weight/page.tsx": 8,
"app/insights/weight/page.tsx": 5,
"app/insights/workouts/page-client.tsx": 2,
"app/insights/wrist-temperature/page.tsx": 1,
"app/invite/[token]/__tests__/page.test.tsx": 1,
@@ -869,7 +869,6 @@
"components/insights/__tests__/metric-stat-strip.test.tsx": 3,
"components/insights/__tests__/metric-target-summary-unit-preference.test.tsx": 1,
"components/insights/__tests__/metric-target-summary.test.tsx": 8,
"components/insights/__tests__/personal-record-badge.test.tsx": 1,
"components/insights/__tests__/rhythm-events-card.test.tsx": 1,
"components/insights/__tests__/sleep-stage-stacked-bar.test.tsx": 5,
"components/insights/__tests__/sub-page-shell.test.tsx": 4,
@@ -1031,7 +1030,6 @@
"components/insights/nutrients/micronutrients-card.tsx": 3,
"components/insights/nutrients/types.ts": 4,
"components/insights/period-narrative-card.tsx": 1,
"components/insights/personal-record-badge.tsx": 2,
"components/insights/prose-blocks.tsx": 1,
"components/insights/recovery/recovery-metric-block.tsx": 2,
"components/insights/recovery/recovery-section.tsx": 3,
@@ -1071,7 +1069,7 @@
"components/labs/lab-biomarker-chart.tsx": 3,
"components/labs/lab-biomarker-detail.tsx": 13,
"components/labs/lab-form.tsx": 6,
"components/labs/lab-history-list.tsx": 4,
"components/labs/lab-history-list.tsx": 3,
"components/labs/lab-list.tsx": 5,
"components/labs/lab-reference-range-bar.tsx": 1,
"components/labs/lab-trend-card.tsx": 1,
@@ -1127,7 +1125,6 @@
"components/medications/__tests__/intake-edit-dialog-seed.test.tsx": 1,
"components/medications/__tests__/intake-history-list-v2-row-actions.test.tsx": 1,
"components/medications/__tests__/intake-history-list-v2.test.tsx": 4,
"components/medications/__tests__/intake-history-preview-bulk-toolbar.test.tsx": 2,
"components/medications/__tests__/inventory-section-actions.test.tsx": 1,
"components/medications/__tests__/inventory-section-summary.test.tsx": 2,
"components/medications/__tests__/log-injection-site-dialog.test.tsx": 1,
@@ -1163,7 +1160,6 @@
"components/medications/glp1-medication-card.tsx": 36,
"components/medications/injection-site-picker.tsx": 2,
"components/medications/intake-edit-dialog.tsx": 2,
"components/medications/intake-history-editable.tsx": 2,
"components/medications/intake-history-list-v2.tsx": 13,
"components/medications/intake-import-dialog.tsx": 1,
"components/medications/log-injection-site-dialog.tsx": 2,
@@ -1200,7 +1196,6 @@
"components/medications/sections/courses-section.tsx": 1,
"components/medications/sections/data-portability-row.tsx": 1,
"components/medications/sections/destructive-zone-section.tsx": 5,
"components/medications/sections/intake-history-preview.tsx": 3,
"components/medications/sections/intake-tracking-section.tsx": 1,
"components/medications/sections/inventory-dialogs.tsx": 3,
"components/medications/sections/inventory-section.tsx": 8,
@@ -1257,7 +1252,6 @@
"components/onboarding/__tests__/anamnesis-card.test.tsx": 1,
"components/onboarding/__tests__/confirm-failure-recovery.test.ts": 1,
"components/onboarding/__tests__/confirm-screen.test.tsx": 1,
"components/onboarding/__tests__/done-screen.test.tsx": 1,
"components/onboarding/__tests__/first-result-screen.test.tsx": 2,
"components/onboarding/__tests__/getting-started-checklist.test.tsx": 1,
"components/onboarding/__tests__/question-screen.test.tsx": 1,
@@ -1268,7 +1262,7 @@
"components/onboarding/baseline-fields.tsx": 1,
"components/onboarding/baseline-form.tsx": 3,
"components/onboarding/confirm-screen.tsx": 5,
"components/onboarding/done-screen.tsx": 2,
"components/onboarding/done-screen.tsx": 1,
"components/onboarding/first-result-screen.tsx": 3,
"components/onboarding/getting-started-checklist.tsx": 13,
"components/onboarding/onboarding-shell.tsx": 1,
@@ -1634,7 +1628,6 @@
"lib/ai/coach/eval/red-team.ts": 3,
"lib/ai/coach/eval/run-case.ts": 1,
"lib/ai/coach/eval/scenarios.ts": 10,
"lib/ai/coach/experiment-flag.ts": 1,
"lib/ai/coach/facts.ts": 6,
"lib/ai/coach/follow-ups/__tests__/continue.test.ts": 1,
"lib/ai/coach/follow-ups/__tests__/derive.test.ts": 1,
@@ -1663,7 +1656,7 @@
"lib/ai/coach/memory/trail-read.ts": 1,
"lib/ai/coach/memory/turn-writes.ts": 1,
"lib/ai/coach/persistence.ts": 37,
"lib/ai/coach/plans.ts": 5,
"lib/ai/coach/plans.ts": 4,
"lib/ai/coach/prompt-cache-key.ts": 1,
"lib/ai/coach/reference-grounding.ts": 1,
"lib/ai/coach/reminders.ts": 2,
@@ -1963,10 +1956,10 @@
"lib/doctor-report-helpers.ts": 1,
"lib/doctor-report-pdf-core.ts": 4,
"lib/doctor-report-pdf/clinical-records-notes-section.ts": 6,
"lib/doctor-report-pdf/header-profile-section.ts": 3,
"lib/doctor-report-pdf/header-profile-section.ts": 2,
"lib/doctor-report-pdf/measurements-charts-section.ts": 3,
"lib/doctor-report-pdf/medication-mood-wellness-section.ts": 7,
"lib/doctor-report-types.ts": 21,
"lib/doctor-report-types.ts": 20,
"lib/doctor-report/__tests__/leaf-gating-sweep.test.ts": 1,
"lib/doctor-report/clinical-records.ts": 2,
"lib/doctor-report/collect.ts": 4,
@@ -2205,7 +2198,7 @@
"lib/insights/exclude-filter.ts": 1,
"lib/insights/feature-blocks.ts": 5,
"lib/insights/feature-cache.ts": 2,
"lib/insights/features.ts": 34,
"lib/insights/features.ts": 33,
"lib/insights/general-status.ts": 5,
"lib/insights/glp1-plateau.ts": 2,
"lib/insights/graded-series.ts": 3,
@@ -2296,7 +2289,6 @@
"lib/jobs/coach-message-cleanup.ts": 1,
"lib/jobs/coach-nudge-ai.ts": 3,
"lib/jobs/coach-nudge.ts": 17,
"lib/jobs/coach-plan-review.ts": 1,
"lib/jobs/coach-reminder-sweep.ts": 2,
"lib/jobs/cycle-reminder.ts": 2,
"lib/jobs/data-arrival.ts": 2,
@@ -2333,7 +2325,7 @@
"lib/jobs/med-notes-encryption-backfill.ts": 2,
"lib/jobs/medication-intake-import.ts": 1,
"lib/jobs/medication-inventory-expire.ts": 1,
"lib/jobs/medication-low-stock.ts": 13,
"lib/jobs/medication-low-stock.ts": 12,
"lib/jobs/mood-reminder.ts": 4,
"lib/jobs/note-encryption-backfill.ts": 2,
"lib/jobs/oidc-handoff-cleanup.ts": 1,
@@ -2606,7 +2598,7 @@
"lib/notifications/senders/telegram.ts": 4,
"lib/notifications/senders/web-push.ts": 5,
"lib/notifications/senders/webhook.ts": 1,
"lib/notifications/types.ts": 21,
"lib/notifications/types.ts": 20,
"lib/notifications/web-push-clear.ts": 1,
"lib/nutrients/__tests__/catalog.test.ts": 1,
"lib/nutrients/catalog.ts": 8,
@@ -2786,7 +2778,7 @@
"lib/targets/types.ts": 2,
"lib/targets/vitals-builder.ts": 2,
"lib/telegram-cleanup.ts": 1,
"lib/telegram-webhook-handlers.ts": 17,
"lib/telegram-webhook-handlers.ts": 16,
"lib/telegram.ts": 2,
"lib/time-window-format.ts": 1,
"lib/timeline/__tests__/lane-access.test.ts": 1,
@@ -77,13 +77,13 @@ const ALLOWLIST: readonly Allowed[] = [
file: "components/insights/coach-panel/attachment-picker.tsx",
limit: 50,
reason:
"Search-backed picker: the query narrows the whole document vault server-side.",
"Search-backed picker: the query narrows the whole document vault server-side, and a cut list says so and points to the search.",
},
{
file: "components/documents/document-picker-sheet.tsx",
limit: 50,
reason:
"Search-backed picker (episode links and share links): the query narrows the whole document vault server-side.",
"Search-backed picker (episode links and share links): the query narrows the whole document vault server-side, and a cut list says so and points to the search.",
},
{
file: "hooks/use-mood-day.ts",
@@ -154,9 +154,10 @@ describe("AI consent is granted only by the person's own tap", () => {
}
}
}
// Non-zero floors: a matcher that stops matching is red, not green.
expect(callers).toBeGreaterThanOrEqual(3);
expect(sites).toBeGreaterThanOrEqual(3);
// Non-zero floors: a matcher that stops matching is red, not green. Two
// callers today: the consent request and the document-reading prompt.
expect(callers).toBeGreaterThanOrEqual(2);
expect(sites).toBeGreaterThanOrEqual(2);
});
it("keeps both grant routes refusing a request without the confirmation", () => {
@@ -49,15 +49,15 @@ const FACTORY = "lib/query-keys/index.ts";
* match — the wrapper carries the refetch internally, so they are compliant by
* construction and correctly invisible here.
*
* The cycle / encounter / vaccination / illness families joined the matcher
* alongside the original three: the encounter and illness bundles now carry a
* daily-read key directly, the vaccination bundle's reminder root feeds the
* Today rail, and cycle rides along so a future daily-read key added to its
* The cycle / encounter / vaccination / illness / workout families joined the
* matcher alongside the original three: the encounter, illness and workout
* bundles carry a daily-read key directly, the vaccination bundle's reminder
* root feeds the Today rail, and cycle rides along so a future daily-read key added to its
* bundle cannot ship an unpaired site silently (its current sites are
* allowlisted below with the reason).
*/
const INVALIDATION =
/invalidateKeys\(\s*[^,]+,\s*\[?\s*(?:\.\.\.)?\s*(?:medicationDependentKeys|measurementDependentKeys|moodDependentKeys|cycleDependentKeys|encounterDependentKeys|vaccinationDependentKeys|illnessDependentKeys)\b/g;
/invalidateKeys\(\s*[^,]+,\s*\[?\s*(?:\.\.\.)?\s*(?:medicationDependentKeys|measurementDependentKeys|moodDependentKeys|cycleDependentKeys|encounterDependentKeys|vaccinationDependentKeys|illnessDependentKeys|workoutDependentKeys)\b/g;
// Match the CALL form only (open paren), never the bare import specifier —
// otherwise the `import { refetchInactiveDailyReads }` line would inflate the
// count and let a file drop one paired call while still "passing".
@@ -234,7 +234,6 @@ describe("daily-reads refetch guard", () => {
const routedThroughHelper = [
"components/medications/take-all-due.ts",
"components/medications/intake-edit-dialog.tsx",
"components/medications/intake-history-editable.tsx",
"components/medications/intake-import-dialog.tsx",
"components/medications/sections/destructive-zone-section.tsx",
"components/medications/use-medication-intake.ts",
@@ -469,17 +469,15 @@ const REGISTRY: DestructiveEntry[] = [
destroys:
"a visit with its reason, outcome and the links it collected — and, for a booked one, the appointment reminder that would have nudged about it",
triggers: ["components/encounters/encounter-sheet.tsx"],
// The route soft-deletes and a restore route exists, but nothing in the
// product reaches it yet, so from a person's side the visit is gone. This
// becomes "restorable" the day a surface offers the undo, not before.
recovery: "tombstoned-no-restore",
// The delete toast offers the Undo that calls the restore route.
recovery: "restorable",
confirm: ["AlertDialog"],
},
{
file: "hooks/use-practitioners.ts",
destroys: "one address-book entry",
triggers: ["components/practitioners/practitioner-list.tsx"],
recovery: "tombstoned-no-restore",
recovery: "restorable",
confirm: ["AlertDialog"],
},
{
@@ -487,9 +485,8 @@ const REGISTRY: DestructiveEntry[] = [
destroys:
"one logged dose with its lot, site and the document links it collected — the booster reminder it once satisfied is deliberately not rewound",
triggers: ["components/vaccinations/vaccination-sheet.tsx"],
// The route soft-deletes and a restore route exists, but no surface offers
// the undo yet, so from a person's side the dose is gone.
recovery: "tombstoned-no-restore",
// The delete toast offers the Undo that calls the restore route.
recovery: "restorable",
confirm: ["AlertDialog"],
},
@@ -528,13 +525,6 @@ const REGISTRY: DestructiveEntry[] = [
recovery: "permanent",
confirm: ["AlertDialog"],
},
{
file: "components/medications/intake-history-editable.tsx",
destroys: "one intake event, or every selected intake event",
triggers: ["components/medications/intake-history-list-v2.tsx"],
recovery: "tombstoned-no-restore",
confirm: ["AlertDialog"],
},
{
file: "components/medications/dose-history/use-dose-history-ledger.ts",
destroys: "one intake event",
@@ -45,8 +45,9 @@ const REGISTRY = "lib/measurements/display-transform.ts";
/**
* The one module allowed to import the unrounded escape hatch. The target
* adapter needs the unrounded pound edge of a canonical guardrail so it can
* round it INWARD — rounding to the nearest tenth first gives 66.1 lb for the
* 30 kg floor, which inverts to 29.98 kg and the server rejects it.
* round it INWARD: rounding to the nearest tenth first can put the pound
* edge outside the kilogram window, and the server rejects what converts
* back from it.
*/
const UNROUNDED_IMPORTERS = ["lib/targets/target-unit-display.ts"];
-49
View File
@@ -15,7 +15,6 @@
* - insights.emptyState.* (B4 — per-metric empty states)
* - notifications.admin.* (B5 — admin notification messages)
* - notifications.user.* (B5 — user-facing Telegram test body)
* - personalRecords + workouts (BL-P4-8 — PR + Workout strings)
*/
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
@@ -46,18 +45,6 @@ for (const locale of LOCALES) {
FLAT_BY_LOCALE.set(locale, flat);
}
function keysForPrefix(prefix: string): Set<string> {
// Source of truth: EN. The locale-integrity test ensures every
// locale carries the same key shape, so EN's view is canonical.
const enFlat = FLAT_BY_LOCALE.get("en");
if (!enFlat) throw new Error("EN bundle did not load");
const set = new Set<string>();
for (const k of enFlat.keys()) {
if (k === prefix || k.startsWith(`${prefix}.`)) set.add(k);
}
return set;
}
const REQUIRED_KEY_SETS: Array<{
label: string;
required: string[];
@@ -143,39 +130,3 @@ describe("v1.4.27 B6 i18n drift-guard — required keys present in every locale"
});
}
});
describe("v1.4.27 B6 i18n drift-guard — PR + Workout strings stay in lockstep", () => {
// BL-P4-8: PR and Workout strings were the trigger for the
// drift-guard ask. Personal-record badge / tooltip live under
// `insights.personalRecord.*`. Workout strings are not yet in the
// bundles — the v1.4.23 batch ingest landed the API, the UI
// surface ships in v1.4.28 / v1.5. The drift-guard keeps the
// namespace anchor live so the workout copy work in v1.4.28 lands
// with parity from the first commit.
const prKeys = [...keysForPrefix("insights.personalRecord")];
it("insights.personalRecord namespace has at least one key (sanity)", () => {
expect(
prKeys.length,
"insights.personalRecord.* keys exist in en.json",
).toBeGreaterThan(0);
});
it.each(LOCALES)(
"messages/%s.json carries every insights.personalRecord.* key with a non-empty value",
(locale) => {
const flat = FLAT_BY_LOCALE.get(locale)!;
const missing: string[] = [];
const empty: string[] = [];
for (const key of prKeys) {
const v = flat.get(key);
if (v === undefined) missing.push(key);
else if (v.trim() === "") empty.push(key);
}
expect(
{ missing, empty },
`Locale ${locale} drifted from the personal-record key set.`,
).toEqual({ missing: [], empty: [] });
},
);
});
@@ -111,12 +111,31 @@ export function intakeFacts(source: string, fileName: string): IntakeFacts {
callsHelper: false,
};
// A create form posts through the function `useIdempotentPost()` returns
// (one key per opened form), bound to a local name.
const posters = new Set<string>();
const findPosters = (node: ts.Node): void => {
if (
ts.isVariableDeclaration(node) &&
ts.isIdentifier(node.name) &&
node.initializer &&
ts.isCallExpression(node.initializer) &&
ts.isIdentifier(node.initializer.expression) &&
(aliases.get(node.initializer.expression.text) ??
node.initializer.expression.text) === "useIdempotentPost"
) {
posters.add(node.name.text);
}
node.forEachChild(findPosters);
};
parsed.forEachChild(findPosters);
const visit = (node: ts.Node): void => {
if (ts.isCallExpression(node)) {
const callee = node.expression;
if (ts.isIdentifier(callee)) {
const name = aliases.get(callee.text) ?? callee.text;
if (name === "apiPost") {
if (name === "apiPost" || posters.has(callee.text)) {
const url = urlText(node);
if (url !== null && url.endsWith("/intake")) facts.postsIntake = true;
}
@@ -444,12 +444,10 @@ const V143_FIELDS: ResolvedFieldEntry[] = [
{
field: "GET /api/personal-records benchmark",
props: ["awards.ts#benchmark"],
readers: [
{
file: "src/components/insights/personal-record-badge.tsx",
pattern: /fresh\.benchmark/,
},
],
iosOnly: {
reason:
"the band was added for the native records screen (ios #116 item 5); no web surface reads `/api/personal-records` since the never-mounted badge that did was deleted",
},
},
{
field: "GET /api/mood/insights patterns",
@@ -59,7 +59,7 @@ function emptyReport(): DoctorReportData {
end: "2026-01-31T00:00:00.000Z",
},
patient: {
username: null,
name: null,
dateOfBirth: null,
gender: null,
heightCm: null,
@@ -156,11 +156,10 @@ const DISPOSITIONS: Record<StructuredLeafId, Disposition> = {
kind: "rendered",
data: {
patient: {
username: "shared-account",
name: "A. Patient",
dateOfBirth: "1979-04-02T00:00:00.000Z",
gender: "FEMALE",
heightCm: 171,
fullName: "A. Patient",
},
},
marker: "A. Patient",
+2 -7
View File
@@ -1553,7 +1553,7 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
},
"app/api/insights/targets/route.ts": {
domain: "record",
why: "The record's resolved targets. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.",
why: "The record's resolved targets: the bands and reference ranges the dashboard snapshot already paints on the whole-record grant, read the same way, with no provider on the path. GET only; nothing is written here.",
},
"app/api/insights/ecg/route.ts": {
domain: "record",
@@ -2099,11 +2099,6 @@ const DELEGABLE_MANAGE_ROUTES: Record<string, ManageEntry> = {
conditions: [],
why: "A score's daily course, read from the same rows the score pages and the day view read. Deterministic, no provider, nothing enqueued.",
},
"app/api/insights/targets/route.ts": {
domain: "record",
conditions: [],
why: "The record's resolved targets.",
},
"app/api/insights/ecg/route.ts": {
domain: "record",
conditions: [],
@@ -2305,7 +2300,7 @@ const ACTOR_ROUTES: Record<string, string> = {
* make, or a managed profile's guardian's, never an adult record's delegate's.
* 276 -> 278.
*/
const FROZEN_ENTRY_COUNT = 278;
const FROZEN_ENTRY_COUNT = 277;
/**
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
@@ -216,6 +216,7 @@ const PINNED_AFFORDANCES: Record<
"src/components/documents/sources/document-source-picker.tsx": {
"text-success": 1,
},
"src/components/encounters/encounter-sheet.tsx": { "toast.success": 1 },
"src/components/error-details.tsx": { CheckCircle2: 2, "text-success": 1 },
"src/components/illness/episode-menu.tsx": {
CheckCircle2: 2,
@@ -284,9 +285,6 @@ const PINNED_AFFORDANCES: Record<
"toast.success": 4,
},
"src/components/medications/intake-edit-dialog.tsx": { "toast.success": 1 },
"src/components/medications/intake-history-editable.tsx": {
"toast.success": 2,
},
"src/components/medications/intake-history-list-v2.tsx": {
"text-success": 1,
},
@@ -347,8 +345,10 @@ const PINNED_AFFORDANCES: Record<
"src/components/symptoms/symptom-entry-form.tsx": { "toast.success": 1 },
"src/components/mood/mood-list.tsx": { "toast.success": 3 },
"src/components/onboarding/done-screen.tsx": { CheckCircle2: 2 },
"src/components/practitioners/practitioner-list.tsx": { "toast.success": 1 },
"src/components/records/allergy-form.tsx": { "toast.success": 1 },
"src/components/records/allergy-manager.tsx": { "toast.success": 1 },
"src/components/vaccinations/vaccination-sheet.tsx": { "toast.success": 1 },
"src/components/records/conditions-manager.tsx": { "toast.success": 1 },
"src/components/records/family-history-form.tsx": { "toast.success": 1 },
"src/components/records/family-history-manager.tsx": { "toast.success": 1 },
+7 -1
View File
@@ -37,6 +37,8 @@ import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { generateTotpSecret, buildOtpauthUri } from "@/lib/auth/mfa/totp";
import { checkCookieEnrollmentProof } from "@/lib/auth/existing-factor-proof";
const TOTP_ALREADY_ACTIVE_CODE = "auth.mfa.already_active";
export const dynamic = "force-dynamic";
const SETUP_RATE_LIMIT = 5;
@@ -64,7 +66,11 @@ export const POST = apiHandler(async (req: Request) => {
// live secret out from under the user's authenticator.
if (user.totpConfirmedAt) {
annotate({ action: { name: "auth.mfa.totp.setup.already_active" } });
throw new HttpError(409, "A second factor is already active");
throw new HttpError(
409,
"A second factor is already active",
TOTP_ALREADY_ACTIVE_CODE,
);
}
if (auth.transport === "cookie") {
@@ -6,6 +6,7 @@ import {
import { createMfaAuthenticationOptions } from "@/lib/auth/mfa/webauthn";
import {
RECENT_PROOF_MAX_AGE_MS,
REPROOF_FAILED_CODE,
REPROOF_REQUIRED_CODE,
isSecondFactorProof,
recordReproofFailure,
@@ -29,8 +30,12 @@ import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { stepUpMintSchema } from "@/lib/validations/step-up";
// A wrong or missing proof, not an ended session: the code keeps the web from
// reading the bare 401 as "sign in again".
const proofRequired = () =>
apiError("Fresh existing-factor proof required", 401);
apiError("Fresh existing-factor proof required", 401, {
errorCode: REPROOF_FAILED_CODE,
});
export const POST = apiHandler(async (request: NextRequest) => {
const { user, session } = await requireCookieAuth();
@@ -13,6 +13,7 @@ import {
import { NextRequest } from "next/server";
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { REPROOF_FAILED_CODE } from "@/lib/auth/existing-factor-proof";
export const POST = apiHandler(async (request: NextRequest) => {
const { user, session } = await requireCookieAuth();
@@ -40,7 +41,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
(freshSession.mfaVerifiedAt &&
freshSession.mfaVerifiedAt.getTime() < Date.now() - 5 * 60 * 1000)
) {
return apiError("Fresh reauthentication proof required", 401);
return apiError("Fresh reauthentication proof required", 401, {
errorCode: REPROOF_FAILED_CODE,
});
}
const claim = await consumeRegistrationChallenge({
@@ -49,12 +52,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
sessionId: session.id,
});
if (!claim.ok) {
return apiError(
claim.reason === "wrong_context"
? "Fresh reauthentication proof required"
: "Registration challenge invalid or expired",
claim.reason === "wrong_context" ? 401 : 400,
);
return claim.reason === "wrong_context"
? apiError("Fresh reauthentication proof required", 401, {
errorCode: REPROOF_FAILED_CODE,
})
: apiError("Registration challenge invalid or expired", 400);
}
let verification;
+1
View File
@@ -126,6 +126,7 @@ export const DELETE = apiHandler(
return apiError(
"Cannot delete. At least one authentication method must remain",
400,
{ errorCode: "auth.passkey.last_method" },
);
}
+4 -1
View File
@@ -40,6 +40,7 @@ import {
rateLimitHeaders,
} from "@/lib/rate-limit";
import {
REPROOF_FAILED_CODE,
REPROOF_LIMIT,
REPROOF_WINDOW_MS,
reproofBucket,
@@ -132,7 +133,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
action: { name: "auth.stepup.mint.failed" },
meta: { method: parsed.data.method, reason: failure },
});
return apiError("Verification failed", 401);
return apiError("Verification failed", 401, {
errorCode: REPROOF_FAILED_CODE,
});
}
const { token, expiresAt } = await mintStepUpElevation({
@@ -225,6 +225,7 @@ describe("PATCH /api/custom-metrics/[id]", () => {
const res = await PATCH(patchReq({ name: "Other" }), params("cm-1"));
expect(res.status).toBe(409);
expect((await res.json()).meta.errorCode).toBe("customMetric.name_taken");
expect(prisma.customMetric.update).not.toHaveBeenCalled();
});
});
+3 -1
View File
@@ -111,7 +111,9 @@ export const PATCH = apiHandler(
select: { id: true },
});
if (clash) {
return apiError("A custom metric with this name already exists", 409);
return apiError("A custom metric with this name already exists", 409, {
errorCode: "customMetric.name_taken",
});
}
}
@@ -134,6 +134,9 @@ describe("POST /api/custom-metrics", () => {
const res = await POST(postReq({ name: "Grip strength", unit: "kg" }));
expect(res.status).toBe(409);
// The form names the clash by this code: a 409 without it can be the
// first save still running under the same key.
expect((await res.json()).meta.errorCode).toBe("customMetric.name_taken");
expect(prisma.customMetric.create).not.toHaveBeenCalled();
});
+3 -1
View File
@@ -100,7 +100,9 @@ async function postCustomMetric(request: NextRequest) {
select: { id: true, deletedAt: true },
});
if (existing && existing.deletedAt === null) {
return apiError("A custom metric with this name already exists", 409);
return apiError("A custom metric with this name already exists", 409, {
errorCode: "customMetric.name_taken",
});
}
// Field-by-field assignment — never spread `parsed.data`.
+16 -5
View File
@@ -19,7 +19,7 @@ import { prisma } from "@/lib/db";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
import { readDayMeanSeries } from "@/lib/insights/derived/baseline";
import { getBpTargets } from "@/lib/analytics/bp-targets";
import { resolveEffectiveBpTargets } from "@/lib/analytics/effective-range";
import { isBpReadingInTarget } from "@/lib/analytics/bp-in-target";
import {
generateAlerts,
@@ -72,6 +72,17 @@ interface InsightCard {
export const dynamic = "force-dynamic";
/**
* The profile fields BMI and the BP band read: the person's own BP target
* where one is set, the age default otherwise.
*/
const PROFILE_SELECT = {
heightCm: true,
dateOfBirth: true,
gender: true,
thresholdsJson: true,
} as const;
export const GET = apiHandler(async () => {
// v1.37.0 — MANAGE-level read: computed over the whole record, with no
// provider anywhere on the path.
@@ -85,7 +96,7 @@ export const GET = apiHandler(async () => {
await Promise.all([
prisma.user.findUnique({
where: { id: user.id },
select: { heightCm: true, dateOfBirth: true },
select: PROFILE_SELECT,
}),
// WEIGHT + BP are manual entries (low volume, and BP needs exact-
// timestamp pairing below for `bpPctInTarget`), so a bounded raw read
@@ -168,14 +179,14 @@ export const GET = apiHandler(async () => {
}
let bpPctInTarget: number | null = null;
const bpTargets = getBpTargets(dbUser?.dateOfBirth ?? null);
const bpTargets =
dbUser && resolveEffectiveBpTargets(dbUser, dbUser.thresholdsJson);
if (bpTargets) {
const sysData = byType("BLOOD_PRESSURE_SYS");
const diaData = byType("BLOOD_PRESSURE_DIA");
const pairs = pairByTimestamp(sysData, diaData, 5 * 60_000);
if (pairs.length > 0) {
// v1.4.16 — one-sided ceiling semantics with hypotension
// floor. See lib/analytics/bp-in-target.ts.
// One-sided ceiling with a hypotension floor (`bp-in-target.ts`).
const inTarget = pairs.filter((p) =>
isBpReadingInTarget(p.a, p.b, bpTargets),
).length;
@@ -4715,7 +4715,7 @@ exports[`coach chat golden transcripts > 15 an unknown learn link is scrubbed, a
}
`;
exports[`coach chat golden transcripts > 16a coach.provider.none via an empty chain and no legacy provider 1`] = `
exports[`coach chat golden transcripts > 16a coach.provider.none via an empty chain 1`] = `
{
"body": null,
"calls": [
@@ -4757,342 +4757,6 @@ exports[`coach chat golden transcripts > 16a coach.provider.none via an empty ch
}
`;
exports[`coach chat golden transcripts > 16b an empty chain served by the legacy provider 1`] = `
{
"body": null,
"calls": [
{
"call": "createConversation",
},
{
"call": "appendMessage",
"content": "How is my blood pressure?",
"metricSource": null,
"model": null,
"providerType": null,
"role": "user",
"tokensUsed": null,
},
{
"call": "storeDeterministicFacts",
},
{
"call": "enqueueCoachMemoryRefresh",
},
{
"call": "assertConsentForChain",
"chain": [
"admin-openai",
],
},
{
"amount": 600,
"call": "reserveBudget",
"cap": 2000000,
"dateKey": "2026-09-26",
"owner": "user",
"surface": "coach",
},
{
"call": "runStreamingRawCompletionWithFallback",
"maxTokens": 600,
"messages": [
"user:70927bca288b:747",
],
"providers": [
"admin-openai",
],
"signal": true,
"surface": "coach",
"system": "af01480eff5e:1404",
"temperature": 0.4,
"timeoutMs": 180000,
},
{
"call": "annotate",
"metaKeys": [
"deltas",
],
"name": "coach.stream.deltas",
},
{
"actual": 42,
"attribution": {
"reservedOwner": "user",
"servedBy": "local",
},
"cached": 5,
"call": "reconcileSpend",
"dateKey": "2026-09-26",
"reserved": 600,
},
{
"call": "appendMessage",
"content": "Your systolic averaged 128 mmHg over the window.",
"metricSource": {
"activity": [
{
"count": 1,
"durationMs": 0,
"id": "a1",
"label": "Reading your record summary",
"labelKey": "coach.step.snapshot",
"phase": "fetch",
"round": 1,
"status": "done",
"stepRef": "s1",
},
{
"durationMs": 0,
"id": "a2",
"label": "Thinking…",
"labelKey": "insights.coach.activity.thinking",
"phase": "thinking",
"round": 1,
"status": "done",
},
{
"id": "a3",
"label": "Writing the answer…",
"labelKey": "insights.coach.activity.answer",
"phase": "answer",
"round": 1,
"status": "done",
},
],
"groundedFigures": [
128,
82,
46,
105,
35.9375,
56.09756097560976,
],
"metrics": [
"bp",
],
"steps": [
{
"count": 1,
"domain": "snapshot",
"id": "s1",
"label": "Reading your record summary",
"labelKey": "coach.step.snapshot",
"status": "done",
"tool": "snapshot",
},
],
"windows": [
"last30days",
],
},
"model": "m-local",
"providerType": "local",
"role": "assistant",
"tokensUsed": 42,
},
{
"call": "annotate",
"metaKeys": [
"activityEntries",
"clusterCount",
"conversationId",
"historyTurns",
"promptChars",
"promptVersion",
"provider",
"snapshotSent",
"stop",
"tokens",
"toolMode",
"toolsCalled",
"trailStored",
],
"name": "insights.coach.replied",
},
],
"contentType": "text/event-stream; charset=utf-8",
"frames": [
{
"step": {
"count": 1,
"domain": "snapshot",
"id": "s1",
"label": "Reading your record summary",
"labelKey": "coach.step.snapshot",
"status": "done",
"tool": "snapshot",
},
"type": "step",
},
{
"activity": {
"count": 1,
"id": "a1",
"label": "Reading your record summary",
"labelKey": "coach.step.snapshot",
"phase": "fetch",
"round": 1,
"status": "running",
"stepRef": "s1",
},
"type": "activity",
},
{
"activity": {
"count": 1,
"durationMs": 0,
"id": "a1",
"label": "Reading your record summary",
"labelKey": "coach.step.snapshot",
"phase": "fetch",
"round": 1,
"status": "done",
"stepRef": "s1",
},
"type": "activity",
},
{
"activity": {
"id": "a2",
"label": "Thinking…",
"labelKey": "insights.coach.activity.thinking",
"phase": "thinking",
"round": 1,
"status": "running",
},
"type": "activity",
},
{
"activity": {
"durationMs": 0,
"id": "a2",
"label": "Thinking…",
"labelKey": "insights.coach.activity.thinking",
"phase": "thinking",
"round": 1,
"status": "done",
},
"type": "activity",
},
{
"activity": {
"id": "a3",
"label": "Writing the answer…",
"labelKey": "insights.coach.activity.answer",
"phase": "answer",
"round": 1,
"status": "running",
},
"type": "activity",
},
{
"token": "Your ",
"type": "token",
},
{
"token": "systolic ",
"type": "token",
},
{
"token": "averaged ",
"type": "token",
},
{
"token": "128 ",
"type": "token",
},
{
"token": "mmHg ",
"type": "token",
},
{
"token": "over ",
"type": "token",
},
{
"token": "the ",
"type": "token",
},
{
"token": "window.",
"type": "token",
},
{
"metricSource": {
"activity": [
{
"count": 1,
"durationMs": 0,
"id": "a1",
"label": "Reading your record summary",
"labelKey": "coach.step.snapshot",
"phase": "fetch",
"round": 1,
"status": "done",
"stepRef": "s1",
},
{
"durationMs": 0,
"id": "a2",
"label": "Thinking…",
"labelKey": "insights.coach.activity.thinking",
"phase": "thinking",
"round": 1,
"status": "done",
},
{
"id": "a3",
"label": "Writing the answer…",
"labelKey": "insights.coach.activity.answer",
"phase": "answer",
"round": 1,
"status": "done",
},
],
"groundedFigures": [
128,
82,
46,
105,
35.9375,
56.09756097560976,
],
"metrics": [
"bp",
],
"steps": [
{
"count": 1,
"domain": "snapshot",
"id": "s1",
"label": "Reading your record summary",
"labelKey": "coach.step.snapshot",
"status": "done",
"tool": "snapshot",
},
],
"windows": [
"last30days",
],
},
"type": "provenance",
},
{
"conversationId": "c-new",
"messageId": "m2",
"type": "done",
"usage": {
"model": "m-local",
"totalTokens": 42,
},
},
],
"status": 200,
"thrown": null,
}
`;
exports[`coach chat golden transcripts > 16c coach.provider.none from the capability gate 1`] = `
{
"body": null,
@@ -119,7 +119,6 @@ export const m = {
resolveServerLocale: vi.fn(),
runStreamingRawCompletionWithFallback: vi.fn(),
resolveProviderChain: vi.fn(),
resolveProvider: vi.fn(),
assertConsentForChain: vi.fn(),
appendMessage: vi.fn(),
createConversation: vi.fn(),
@@ -201,7 +200,6 @@ export function resetGolden(): void {
m.resolveProviderChain.mockResolvedValue([
{ providerType: "anthropic", instance: {} },
]);
m.resolveProvider.mockResolvedValue({ type: "none" });
m.assertConsentForChain.mockImplementation(
async (args: { chain: Array<{ providerType: string }> }) => {
record("assertConsentForChain", {
@@ -81,7 +81,6 @@ vi.mock("@/lib/ai/provider-runner", () => ({
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProviderChain: h.m.resolveProviderChain,
resolveProvider: h.m.resolveProvider,
}));
vi.mock("@/lib/ai/consent-guard", () => ({
assertConsentForChain: h.m.assertConsentForChain,
@@ -416,21 +415,8 @@ describe("coach chat golden transcripts", () => {
});
// 16 ── no provider ──────────────────────────────────────────────────────
it("16a coach.provider.none via an empty chain and no legacy provider", async () => {
it("16a coach.provider.none via an empty chain", async () => {
m.resolveProviderChain.mockResolvedValue([]);
m.resolveProvider.mockResolvedValue({ type: "none" });
await golden({ message: "How is my blood pressure?" });
});
it("16b an empty chain served by the legacy provider", async () => {
m.resolveProviderChain.mockResolvedValue([]);
m.resolveProvider.mockResolvedValue({
type: "openai",
supportsTools: false,
});
m.runStreamingRawCompletionWithFallback.mockImplementation(
streamingResult("Your systolic averaged 128 mmHg over the window."),
);
await golden({ message: "How is my blood pressure?" });
});
@@ -93,11 +93,7 @@ vi.mock("@/lib/ai/provider", async () => {
);
return {
...actual,
resolveProvider: vi.fn(),
// v1.4.16: the route consults the multi-provider chain
// first; the existing single-provider mocks return an empty chain
// so the route falls through to `resolveProvider()` and the legacy
// test fixtures keep behaving exactly as before.
// Empty unless a test hands the route a provider (`serveFrom`).
resolveProviderChain: vi.fn(async () => []),
};
});
@@ -167,7 +163,7 @@ import { NextRequest } from "next/server";
import { GET, POST, resolveInsightsRateLimit } from "../route";
import { enqueueStatusRefillForUser } from "@/lib/insights/comprehensive-generate";
import { resolveProvider } from "@/lib/ai/provider";
import { resolveProviderChain } from "@/lib/ai/provider";
import { checkRateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/db";
import { hasUsableStatusProvider } from "@/lib/insights/status-provider";
@@ -214,8 +210,17 @@ beforeEach(() => {
});
});
type ChainEntries = Awaited<ReturnType<typeof resolveProviderChain>>;
/** The chain the route resolves: one operator-key entry serving `instance`. */
function serveFrom(instance: unknown) {
vi.mocked(resolveProviderChain).mockResolvedValue([
{ providerType: "admin-openai", instance },
] as unknown as ChainEntries);
}
function makeWorkingProvider() {
vi.mocked(resolveProvider).mockResolvedValue({
serveFrom({
type: "openai",
generateCompletion: vi.fn(async () => ({
content: JSON.stringify({
@@ -230,7 +235,7 @@ function makeWorkingProvider() {
providerType: "openai",
model: "gpt-4",
})),
} as unknown as Awaited<ReturnType<typeof resolveProvider>>);
});
}
interface ApiErrorEnvelope {
@@ -253,13 +258,12 @@ function jsonRequest(body: unknown = {}): Request {
function makeProviderThatThrows(
err: Error & { httpStatus?: number; bodyExcerpt?: string },
) {
vi.mocked(resolveProvider).mockResolvedValue({
serveFrom({
type: "openai",
generateCompletion: vi.fn(async () => {
throw err;
}),
// The route only calls generateCompletion; pad the type for TS.
} as unknown as Awaited<ReturnType<typeof resolveProvider>>);
});
}
describe("POST /api/insights/generate — daily token ceiling", () => {
@@ -314,7 +318,8 @@ describe("POST /api/insights/generate — a provider the health ledger benched",
httpStatus: 401,
});
makeProviderThatThrows(err);
const provider = await vi.mocked(resolveProvider)("u-1");
const [{ instance: provider }] =
await vi.mocked(resolveProviderChain)("u-1");
const body = JSON.stringify({ force: true });
const request = new NextRequest("http://localhost/api/insights/generate", {
method: "POST",
@@ -894,7 +899,7 @@ describe("GET /api/insights/generate — read-only advisor read", () => {
expect(body.data.cached).toBe(true);
expect(body.data.insights).toEqual(cached);
// No completion is ever run on the read path.
expect(resolveProvider).not.toHaveBeenCalled();
expect(resolveProviderChain).not.toHaveBeenCalled();
// A fresh cache (just now) does not trigger a warm — and the payload
// says so, so the client never starts a convergence poll.
expect(enqueueForceWarm).not.toHaveBeenCalled();
@@ -919,7 +924,7 @@ describe("GET /api/insights/generate — read-only advisor read", () => {
userId: "u-1",
locale: "en",
});
expect(resolveProvider).not.toHaveBeenCalled();
expect(resolveProviderChain).not.toHaveBeenCalled();
// The stale serve is honest: `revalidating: true` rides on the
// payload so the client polls (bounded) until the warm lands.
const body = (await res.json()) as { data: { revalidating: boolean } };
@@ -953,7 +958,7 @@ describe("GET /api/insights/generate — read-only advisor read", () => {
userId: "u-1",
locale: "en",
});
expect(resolveProvider).not.toHaveBeenCalled();
expect(resolveProviderChain).not.toHaveBeenCalled();
});
it("serves a fresh cache whose tag matches the reader's language", async () => {
@@ -48,6 +48,7 @@ export const GET = apiHandler(async () => {
biomarkerId: true,
unit: true,
value: true,
sourceValue: true,
referenceLow: true,
referenceHigh: true,
sourceReferenceLow: true,
@@ -65,6 +66,7 @@ export const GET = apiHandler(async () => {
biomarkerId: r.biomarkerId,
unit: r.unit,
value: r.value as number,
sourceValue: r.sourceValue,
referenceLow: r.referenceLow,
referenceHigh: r.referenceHigh,
sourceReferenceLow: r.sourceReferenceLow,
@@ -238,6 +238,48 @@ describe("PUT /api/insights/provider-chain", () => {
]);
});
it("drops the effort of a gateway the person switches off", async () => {
// A disabled gateway entry with an effort is the gateway form's holder,
// which the picked gateway still serves behind; a switch-off is not.
storedChain([
{
providerType: "openai-compatible",
priority: 1,
enabled: true,
reasoningEffort: "low",
},
]);
await put([{ providerType: "openai-compatible", enabled: false }]);
expect(persisted()).toEqual([
{ providerType: "openai-compatible", priority: 1, enabled: false },
]);
});
it("keeps the holder's effort when the chain is saved around it", async () => {
storedChain([
{ providerType: "local", priority: 1, enabled: true },
{
providerType: "openai-compatible",
priority: 2,
enabled: false,
reasoningEffort: "low",
},
]);
await put([
{ providerType: "openai-compatible", enabled: false },
{ providerType: "local", enabled: true },
]);
expect(persisted()).toEqual([
{
providerType: "openai-compatible",
priority: 1,
enabled: false,
reasoningEffort: "low",
},
{ providerType: "local", priority: 2, enabled: true },
]);
});
it("clears it on null", async () => {
storedChain([
{
+19 -9
View File
@@ -118,18 +118,28 @@ export const PUT = apiHandler(async (request: NextRequest) => {
where: { id: user.id },
select: { aiProviderChain: true },
});
const storedChain = parseProviderChain(stored?.aiProviderChain ?? null);
const storedEffort = new Map(
parseProviderChain(stored?.aiProviderChain ?? null).map((e) => [
e.providerType,
e.reasoningEffort ?? null,
]),
storedChain.map((e) => [e.providerType, e.reasoningEffort ?? null]),
);
// A disabled gateway entry with an effort is the holder the gateway form
// writes, which the picked gateway still serves behind
// (`servesPickedGateway`). Switching the gateway off here is the person's
// word, so its effort goes with the switch.
const gatewayWasOn =
storedChain.find((e) => e.providerType === "openai-compatible")?.enabled !==
false;
const normalised = parsed.data.chain.map((entry, idx) => {
const reasoningEffort = isReasoningProviderType(entry.providerType)
? entry.reasoningEffort === undefined
? (storedEffort.get(entry.providerType) ?? null)
: entry.reasoningEffort
: null;
const switchedOff =
entry.providerType === "openai-compatible" &&
!entry.enabled &&
gatewayWasOn;
const reasoningEffort =
!isReasoningProviderType(entry.providerType) || switchedOff
? null
: entry.reasoningEffort === undefined
? (storedEffort.get(entry.providerType) ?? null)
: entry.reasoningEffort;
return {
providerType: entry.providerType,
priority: idx + 1,
+4 -3
View File
@@ -15,9 +15,10 @@ import { buildTargetsResponse } from "@/lib/targets/build-response";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
// v1.37.0 — MANAGE-level read: computed over the whole record, with no
// provider anywhere on the path.
const { user } = await requireRecordAuth("manage", "record");
// A read of the whole record, with no provider anywhere on the path, at the
// level the dashboard snapshot answers: the weight band and the BMI chart
// read it inside a shared record, and a read or write grant got a 403.
const { user } = await requireRecordAuth("read", "record");
// v1.16.8 — stale-while-revalidate read. The cold build is the multi-query
// walk in `buildTargetsResponse`; past the 60 s fresh TTL the prior body
// serves immediately while ONE background rebuild warms the cell. Writes
@@ -156,6 +156,18 @@ describe("POST /api/measurements/bulk-delete", () => {
});
});
it("leaves a computed score out of the selection it deletes", async () => {
// The read is scoped like the write, so a computed row never reaches it.
vi.mocked(prisma.measurement.findMany).mockResolvedValue([]);
vi.mocked(prisma.measurement.updateMany).mockResolvedValue({ count: 0 });
await POST(postReq({ ids: ["score-1"] }));
expect(
vi.mocked(prisma.measurement.findMany).mock.calls[0][0]?.where,
).toMatchObject({ source: { not: "COMPUTED" } });
});
it("collapses the rollup recompute to the unique (type, day) set, not per-row", async () => {
// 4 deleted rows: 2× WEIGHT on the same UTC day, 1× WEIGHT next day,
// 1× PULSE same day as the first → 3 distinct (type, day) keys.
@@ -81,9 +81,17 @@ async function postBulkDelete(request: NextRequest): Promise<Response> {
// other half and the cuff's pulse, `bloodPressureSiblings`) into the same
// statement, so the shared `deletedAt` lets the restore route bring the
// reading back whole.
//
// A computed score (recovery, strain, stress) is skipped: the server
// rewrites it from its inputs, and a tombstone kept the recompute out.
const { affected, count } = await prisma.$transaction(async (tx) => {
const selected = await tx.measurement.findMany({
where: { id: { in: ids }, userId: user.id, deletedAt: null },
where: {
id: { in: ids },
userId: user.id,
deletedAt: null,
source: { not: "COMPUTED" },
},
select: { id: true, type: true, source: true, measuredAt: true },
});
const siblings = await bloodPressureSiblings(tx, user.id, selected, "live");
@@ -51,7 +51,6 @@ vi.mock("@/lib/ai/coach/budget", () => ({
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProvider: vi.fn(async () => ({ type: "none" })),
resolveProviderChain: vi.fn(async () => []),
}));
@@ -82,7 +81,7 @@ import {
reconcileSpend,
resolveDailyCap,
} from "@/lib/ai/coach/budget";
import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider";
import { resolveProviderChain } from "@/lib/ai/provider";
import {
runRawCompletionWithFallback,
AllProvidersFailedError,
@@ -382,9 +381,6 @@ describe("POST /api/medications/extract — validation + provider errors", () =>
it("returns 503 when no provider is configured for the user", async () => {
vi.mocked(requireAuth).mockResolvedValue(AUTH_OK as never);
vi.mocked(resolveProviderChain).mockResolvedValueOnce([]);
vi.mocked(resolveProvider).mockResolvedValueOnce({
type: "none",
} as never);
// The status stays 503 (a shipped client may branch on it) and gains the
// code that says what it means.
+8 -12
View File
@@ -72,7 +72,7 @@ import {
runRawCompletionWithFallback,
} from "@/lib/ai/provider-runner";
import { spentOnFailure } from "@/lib/ai/reasoning/answerless";
import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider";
import { resolveProviderChain } from "@/lib/ai/provider";
import { singleUserTurn } from "@/lib/ai/types";
import {
@@ -198,17 +198,13 @@ async function handleExtract(request: NextRequest): Promise<Response> {
// surface a structured 503 — the wizard hides the overlay.
const chain = await resolveProviderChain(userId);
if (chain.length === 0) {
const legacy = await resolveProvider(userId);
if (legacy.type === "none") {
annotate({ action: { name: "medications.extract.no-provider" } });
throw new AiUnavailableError(
"medicationExtract",
"no_provider",
null,
MEDICATION_EXTRACT_NO_PROVIDER,
);
}
chain.push({ providerType: "admin-openai", instance: legacy });
annotate({ action: { name: "medications.extract.no-provider" } });
throw new AiUnavailableError(
"medicationExtract",
"no_provider",
null,
MEDICATION_EXTRACT_NO_PROVIDER,
);
}
// The wire. The runner may cascade through every entry, so each one is
+13 -8
View File
@@ -84,20 +84,25 @@ export const GET = apiHandler(async () => {
globallyEnabled: globalToggles[ch.type] ?? true,
}));
// Admin-only events (backup health) are listed for admins only, and an
// event nothing sends is listed for nobody: a switch for either would
// switch nothing. A stored row for an unlisted event stays in the table and
// is not served.
const eventTypes: string[] = eventTypesForRole(user.role);
const preferences = channels.flatMap((ch) =>
ch.preferences.map((p) => ({
channelId: p.channelId,
eventType: p.eventType,
enabled: p.enabled,
})),
ch.preferences
.filter((p) => eventTypes.includes(p.eventType))
.map((p) => ({
channelId: p.channelId,
eventType: p.eventType,
enabled: p.enabled,
})),
);
return apiSuccess({
channels: channelData,
preferences,
// Admin-only events (backup health) are listed for admins only: nobody
// else is ever sent one, so a switch for it would switch nothing.
eventTypes: eventTypesForRole(user.role),
eventTypes,
// What an event does on a channel with no stored row, resolved for this
// account by the same function the dispatcher asks. A client renders a
// switch without a row from this map, never from its own copy of the
@@ -90,6 +90,20 @@ describe("POST /api/onboarding/complete", () => {
);
});
it("stores a date of birth from the iOS app as its day in the owner's zone", async () => {
vi.mocked(getSession).mockResolvedValue({
...SESSION_OK,
user: { ...SESSION_OK.user, timezone: "Europe/Berlin" },
} as unknown as Awaited<ReturnType<typeof getSession>>);
// 14 May picked at 00:30 in Berlin.
const res = await POST(req({ dateOfBirth: "1960-05-13T23:30:00Z" }));
expect(res.status).toBe(200);
const data = vi.mocked(prisma.user.update).mock.calls[0]![0]!.data as {
dateOfBirth: Date;
};
expect(data.dateOfBirth.toISOString()).toBe("1960-05-14T12:00:00.000Z");
});
it("still refuses an unsupported gender", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await POST(req({ gender: "diverse" }));
+8 -4
View File
@@ -29,6 +29,8 @@ import { readManagedProfileForGuardian } from "@/lib/managed-profiles/lifecycle"
import { writeRecordModulePreferences } from "@/lib/record-settings/modules";
import { NextRequest } from "next/server";
import { onboardingCompleteSchema } from "@/lib/validations/onboarding";
import { dateOfBirthForStorage } from "@/lib/tz/date-only";
import { resolveStoredTimezone } from "@/lib/tz/resolver";
/**
* Complete the onboarding flow. Saves optional profile data and marks
@@ -84,10 +86,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
if (result.data.dateOfBirth) {
const dob = new Date(result.data.dateOfBirth);
if (!isNaN(dob.getTime())) {
data.dateOfBirth = dob;
}
// The calendar day in the owner's zone: the iOS app sends an instant.
const dob = dateOfBirthForStorage(
result.data.dateOfBirth,
await resolveStoredTimezone(user.timezone),
);
if (dob) data.dateOfBirth = dob;
}
if (result.data.gender) {
@@ -97,6 +97,8 @@ vi.mock("@/lib/medications/dose-day-refresh", () => ({
refreshDoseSlot: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/modules/gate", () => ({ isModuleEnabled: vi.fn() }));
import { POST, GET } from "../route";
import { prisma } from "@/lib/db";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
@@ -112,6 +114,7 @@ import {
} from "@/lib/mood/create-from-telegram";
import { scheduleTelegramAutoDelete } from "@/lib/telegram-cleanup";
import { satisfyReminder } from "@/lib/measurement-reminders/satisfy";
import { isModuleEnabled } from "@/lib/modules/gate";
const ORIGINAL_SECRET = process.env.TELEGRAM_WEBHOOK_SECRET;
@@ -129,11 +132,14 @@ const MEDICATION = { id: "med-1", name: "Ramipril" };
* schedule projection (distinguishable by `select.schedules`); every other
* `medication.findFirst` in the webhook wants the bare id/name shape. The
* default projection carries no schedules, so the generic dispatch tests
* exercise the ad-hoc (standalone create) path unchanged.
* exercise the ad-hoc (standalone create) path unchanged. The dose lookup
* (`findTelegramDoseMedication`) reads every match by id or name through
* `findMany`; a list read (no id, no name) finds nothing unless a test says.
*/
function wireMedicationFindFirst(
schedules: unknown[] = [],
scheduleRevisions: unknown[] = [],
medication: Record<string, unknown> = MEDICATION,
) {
vi.mocked(prisma.medication.findFirst).mockImplementation(((args: {
select?: Record<string, unknown>;
@@ -149,8 +155,16 @@ function wireMedicationFindFirst(
scheduleRevisions,
});
}
return Promise.resolve(MEDICATION);
return Promise.resolve(medication);
}) as never);
vi.mocked(prisma.medication.findMany).mockImplementation(((args: {
where?: Record<string, unknown>;
}) =>
Promise.resolve(
args?.where && ("id" in args.where || "name" in args.where)
? [medication]
: [],
)) as never);
}
beforeEach(() => {
@@ -162,8 +176,8 @@ beforeEach(() => {
resetAt: Date.now() + 60_000,
} as never);
vi.mocked(prisma.user.findFirst).mockResolvedValue(TG_USER as never);
vi.mocked(isModuleEnabled).mockResolvedValue(true);
wireMedicationFindFirst();
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medication.update).mockResolvedValue({} as never);
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue(null);
vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValue({} as never);
@@ -300,6 +314,7 @@ describe("Telegram webhook — callback dispatch", () => {
expect(res.status).toBe(200);
expect(prisma.medication.findFirst).not.toHaveBeenCalled();
expect(prisma.medication.findMany).not.toHaveBeenCalled();
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
expect(prisma.medication.update).not.toHaveBeenCalled();
expect(deleteMessage).not.toHaveBeenCalled();
@@ -464,7 +479,7 @@ describe("Telegram webhook — callback dispatch", () => {
});
it("rejects 'taken' for an inactive medication and acks with the localised error", async () => {
vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce(null);
vi.mocked(prisma.medication.findMany).mockResolvedValueOnce([]);
const res = await POST(tgRequest(callbackUpdate("taken:med-1")));
expect(res.status).toBe(200);
@@ -482,7 +497,7 @@ describe("Telegram webhook — callback dispatch", () => {
});
it("'snooze' for a missing medication acks with medicationNotFound (no update)", async () => {
vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce(null);
vi.mocked(prisma.medication.findMany).mockResolvedValueOnce([]);
const res = await POST(tgRequest(callbackUpdate("snooze:med-X:60")));
expect(res.status).toBe(200);
expect(prisma.medication.update).not.toHaveBeenCalled();
@@ -637,7 +652,7 @@ describe("Telegram webhook — text-message dispatch", () => {
});
it("/add with no active medications replies with the 'no active' message", async () => {
vi.mocked(prisma.medication.findMany).mockResolvedValueOnce([] as never);
vi.mocked(prisma.medication.findMany).mockResolvedValueOnce([]);
const res = await POST(
tgRequest({
update_id: 203,
@@ -670,8 +685,8 @@ describe("Telegram webhook — text-message dispatch", () => {
}),
);
expect(res.status).toBe(200);
// markMedicationTaken → findFirst on active medication
expect(prisma.medication.findFirst).toHaveBeenCalled();
// The list, then markMedicationTaken's lookup of the one it found.
expect(prisma.medication.findMany).toHaveBeenCalledTimes(2);
expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1);
});
@@ -712,15 +727,6 @@ describe("Telegram webhook — text-message dispatch", () => {
});
it("'taken Ramipril' (text command) creates an intake event for the matched medication", async () => {
vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce({
id: "med-1",
} as never);
// Second findFirst inside markMedicationTaken
vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce({
id: "med-1",
name: "Ramipril",
} as never);
const res = await POST(
tgRequest({
update_id: 206,
@@ -762,8 +768,8 @@ describe("Telegram webhook — text-message dispatch", () => {
});
it("'taken UnknownMed' replies with the medication-not-found message and writes nothing", async () => {
// First findFirst (lookup by name) returns null
vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce(null);
// The lookup by name finds nothing.
vi.mocked(prisma.medication.findMany).mockResolvedValueOnce([]);
const res = await POST(
tgRequest({
@@ -907,6 +913,29 @@ describe("Telegram webhook — slot convergence (v1.16.9)", () => {
});
});
it("books the reminder's own dose on the last day of a course after it ended", async () => {
// The course ran through 9 June; yesterday's 09:00 reminder is confirmed
// the next morning. The dose belongs to a day the course covered.
wireMedicationFindFirst([scheduleRow], [], {
...MEDICATION,
dose: "5 mg",
startsOn: null,
endsOn: new Date("2026-06-09T00:00:00.000Z"),
endedAt: null,
});
vi.mocked(prisma.telegramReminderMessage.findFirst).mockResolvedValue({
date: "2026-06-09",
timeOfDay: "09:00",
} as never);
await POST(tgRequest(callbackUpdate("taken:med-1")));
expect(prisma.$transaction).toHaveBeenCalled();
expect(vi.mocked(answerTelegramCallbackQuery).mock.calls[0][2]).not.toMatch(
/not found or inactive/i,
);
});
it("a take converges via band attribution when no reminder row matches", async () => {
vi.mocked(prisma.telegramReminderMessage.findFirst).mockResolvedValue(null);
const res = await POST(tgRequest(callbackUpdate("taken:med-1")));
@@ -1219,7 +1248,7 @@ describe("POST /api/telegram/webhook — record-only medications", () => {
it("the 'taken' keyword resolves tracked medications only", async () => {
await POST(tgRequest(textUpdate("taken Ramipril")));
const where = whereOf(vi.mocked(prisma.medication.findFirst).mock.calls[0]);
const where = whereOf(vi.mocked(prisma.medication.findMany).mock.calls[0]);
expect(where).toMatchObject({ active: true, trackIntake: true });
});
@@ -1227,7 +1256,7 @@ describe("POST /api/telegram/webhook — record-only medications", () => {
await POST(tgRequest(callbackUpdate("taken:med-1")));
// The gate lookup: a record-only medication resolves to nothing here,
// so no dose is written (later lookups only bind an admitted dose).
const calls = vi.mocked(prisma.medication.findFirst).mock.calls;
const calls = vi.mocked(prisma.medication.findMany).mock.calls;
expect(calls.length).toBeGreaterThan(0);
expect(whereOf(calls[0])).toMatchObject({
id: "med-1",
@@ -1236,3 +1265,86 @@ describe("POST /api/telegram/webhook — record-only medications", () => {
});
});
});
/**
* With the Medications module switched off the bot offers no dose and books
* none: the web shows no medication surface and the reminder jobs are
* silent, so a reminder button left in the chat, the /add list and the
* "taken" keyword do nothing either.
*/
describe("POST /api/telegram/webhook with the Medications module off", () => {
function textUpdate(text: string) {
return {
update_id: 400,
message: { message_id: 40, text, chat: { id: 7777 }, from: { id: 7777 } },
};
}
beforeEach(() => {
vi.mocked(isModuleEnabled).mockResolvedValue(false);
});
it.each(["taken:med-1", "skip:med-1", "add:med-1"])(
"the %s button books nothing",
async (data) => {
const res = await POST(tgRequest(callbackUpdate(data)));
expect(res.status).toBe(200);
expect(isModuleEnabled).toHaveBeenCalledWith("user-1", "medications");
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
expect(prisma.$transaction).not.toHaveBeenCalled();
},
);
it("the snooze button snoozes nothing", async () => {
await POST(tgRequest(callbackUpdate("snooze:med-1:60")));
expect(prisma.medication.update).not.toHaveBeenCalled();
expect(vi.mocked(answerTelegramCallbackQuery).mock.calls[0][2]).toMatch(
/not found/i,
);
});
it("/add offers nothing", async () => {
await POST(tgRequest(textUpdate("/add")));
expect(prisma.medication.findMany).not.toHaveBeenCalled();
expect(vi.mocked(sendTelegramMessage).mock.calls[0][2]).toMatch(
/no active medications/i,
);
});
it("the 'taken' keyword records nothing", async () => {
await POST(tgRequest(textUpdate("taken Ramipril")));
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
expect(prisma.$transaction).not.toHaveBeenCalled();
});
});
/**
* A reminder button left in the chat takes no dose for a course that has
* since ended, which would also take a unit off the stock. The list side is
* pinned in `src/lib/__tests__/telegram-dose-medications.test.ts`.
*/
describe("POST /api/telegram/webhook on an ended course", () => {
const DAY_MS = 24 * 60 * 60 * 1000;
const ENDED = {
...MEDICATION,
dose: "5 mg",
startsOn: new Date(Date.now() - 20 * DAY_MS),
endsOn: new Date(Date.now() - 3 * DAY_MS),
endedAt: null,
};
it("the taken button on an ended course books nothing", async () => {
wireMedicationFindFirst([], [], ENDED);
await POST(tgRequest(callbackUpdate("taken:med-1")));
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
expect(prisma.$transaction).not.toHaveBeenCalled();
expect(vi.mocked(answerTelegramCallbackQuery).mock.calls[0][2]).toMatch(
/not found or inactive/i,
);
});
it("the skip button on an ended course books nothing", async () => {
wireMedicationFindFirst([], [], ENDED);
await POST(tgRequest(callbackUpdate("skip:med-1")));
expect(prisma.$transaction).not.toHaveBeenCalled();
});
});
@@ -751,6 +751,24 @@ describe("PATCH /api/user/ai-provider — reasoning setting (#1126)", () => {
]);
});
it("switches the gateway's entry on when the gateway form saves it as the pick", async () => {
// The disabled holder above, written for a picked gateway, read as the
// person switching off the provider they had just picked.
storedChain(null);
await patch(
patchRequest({
provider: "OPENAI_COMPATIBLE",
compatReasoningEffort: "low",
}),
);
const chain = written()?.aiProviderChain as Array<{
providerType: string;
}>;
expect(
chain.find((e) => e.providerType === "openai-compatible"),
).toMatchObject({ enabled: true, reasoningEffort: "low" });
});
it("does not touch the chain when Default is restated on a default chain", async () => {
storedChain(null);
await patch(
+53 -31
View File
@@ -16,6 +16,7 @@ import {
providerWorkAuthorityForRecord,
} from "@/lib/sharing/provider-work-authority";
import { annotate } from "@/lib/logging/context";
import type { z } from "zod/v4";
import { aiProviderPatchSchema } from "@/lib/validations/ai-provider";
import {
chainWithReasoningEffort,
@@ -191,6 +192,51 @@ export const GET = apiHandler(async () => {
});
});
/**
* #1126 — the chain to write after the Local or gateway form set its entry's
* reasoning effort, or undefined when nothing changed. Stored on the entry in
* `aiProviderChain` rather than in a column of their own, so the setting is
* per entry and a chain can mix a thinking model with one that does not know
* the field. See `chainWithReasoningEffort` for how a default chain, or a
* provider absent from the chain, holds the value.
*/
async function chainAfterReasoningSave(
userId: string,
body: Pick<
z.infer<typeof aiProviderPatchSchema>,
"localReasoningEffort" | "compatReasoningEffort"
>,
pickedNow: unknown,
): Promise<unknown> {
const current = await prisma.user.findUnique({
where: { id: userId },
select: { aiProviderChain: true, aiProvider: true },
});
let chain: unknown = current?.aiProviderChain ?? null;
if (body.localReasoningEffort !== undefined) {
chain = chainWithReasoningEffort(chain, "local", body.localReasoningEffort);
}
if (body.compatReasoningEffort !== undefined) {
// Saving the gateway form picks the gateway, so its entry is on: a
// disabled one read as the person switching off what they had just picked.
const picked = pickedNow !== undefined ? pickedNow : current?.aiProvider;
chain = chainWithReasoningEffort(
chain,
"openai-compatible",
body.compatReasoningEffort,
String(picked ?? "").toUpperCase() === "OPENAI_COMPATIBLE",
);
}
// Written only when it changed: restating Default on a chain that was
// never customised must not freeze today's default chain into the row.
const before = serializeProviderChain(
parseProviderChain(current?.aiProviderChain ?? null),
);
return serializeProviderChain(parseProviderChain(chain)) !== before
? chain
: undefined;
}
export const PATCH = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "user.ai-provider.update" } });
@@ -265,6 +311,7 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
return apiError(
"Base URL points to an internal/private host. The operator must allow its exact origin on this instance via AI_PRIVATE_ORIGINS (e.g. http://ollama.lan:11434) for a self-hosted Ollama / LM Studio.",
422,
{ errorCode: "ai_provider.private_host" },
);
}
updates.aiBaseUrl = trimmed;
@@ -289,6 +336,7 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
return apiError(
"Base URL points to an internal/private host. The operator must allow its exact origin on this instance via AI_PRIVATE_ORIGINS (e.g. http://litellm.lan:4000) for a self-hosted gateway.",
422,
{ errorCode: "ai_provider.private_host" },
);
}
updates.aiCompatBaseUrl = trimmed;
@@ -335,42 +383,16 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
updates.aiResponseTimeoutSeconds = body.responseTimeoutSeconds;
}
// ── #1126 — reasoning settings of the Local and gateway entries ──
// Stored on the entry in `aiProviderChain` rather than in a column of their
// own, so the setting is per entry and a chain can mix a thinking model with
// one that does not know the field. See `chainWithReasoningEffort` for how a
// default chain, or a provider absent from the chain, holds the value.
const reasoningTouched =
body.localReasoningEffort !== undefined ||
body.compatReasoningEffort !== undefined;
if (reasoningTouched) {
const current = await prisma.user.findUnique({
where: { id: user.id },
select: { aiProviderChain: true },
});
let chain: unknown = current?.aiProviderChain ?? null;
if (body.localReasoningEffort !== undefined) {
chain = chainWithReasoningEffort(
chain,
"local",
body.localReasoningEffort,
);
}
if (body.compatReasoningEffort !== undefined) {
chain = chainWithReasoningEffort(
chain,
"openai-compatible",
body.compatReasoningEffort,
);
}
// Written only when it changed: restating Default on a chain that was
// never customised must not freeze today's default chain into the row.
const before = serializeProviderChain(
parseProviderChain(current?.aiProviderChain ?? null),
const chain = await chainAfterReasoningSave(
user.id,
body,
updates.aiProvider,
);
if (serializeProviderChain(parseProviderChain(chain)) !== before) {
updates.aiProviderChain = chain;
}
if (chain !== undefined) updates.aiProviderChain = chain;
}
if (Object.keys(updates).length === 0 && !reasoningTouched) {
@@ -96,7 +96,7 @@ beforeEach(() => {
describe("PUT /api/user/thresholds — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
// WEIGHT bounds are min=30, max=300. Sending min > max + a value
// WEIGHT bounds are min=1, max=300. Sending min > max + a value
// out of bounds on PULSE forces two range errors.
const res = await PUT(
putReq({
@@ -97,7 +97,7 @@ function report(): DoctorReportData {
end: "2026-01-31T00:00:00.000Z",
},
patient: {
username: null,
name: null,
dateOfBirth: null,
gender: null,
heightCm: null,
@@ -18,9 +18,9 @@ vi.mock("@/hooks/use-ai-capability", () => ({
useAiCapabilityAnswer: () => capability.answer,
}));
const plan = (id: string, status: string) => ({
const plan = (id: string, status: string, metric = "WEIGHT") => ({
id,
metric: "WEIGHT",
metric,
ifCue: `cue ${id}`,
thenAction: `action ${id}`,
target: null,
@@ -35,7 +35,7 @@ vi.mock("@/hooks/use-coach-plans", () => ({
data: [
plan("p-proposed", "proposed"),
plan("p-active", "active"),
plan("p-met", "met"),
plan("p-met", "met", "mood vibes"),
],
isError: false,
isLoading: false,
@@ -48,12 +48,16 @@ vi.mock("@/hooks/use-coach-plans", () => ({
}));
import { I18nProvider } from "@/lib/i18n/context";
import { primeMessages } from "@/lib/i18n/load-locale";
import deMessages from "../../../../../messages/de.json";
import CoachPlansPage from "../page";
function render(): string {
primeMessages("de", deMessages);
function render(locale: "en" | "de" = "en"): string {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<I18nProvider initialLocale={locale}>
<CoachPlansPage />
</I18nProvider>,
);
@@ -90,4 +94,12 @@ describe("/coach/plans", () => {
expect(count(html, "coach-plan-delete")).toBe(1);
expect(html).not.toContain("you can still read and delete your plans");
});
it("names the metric in the reader's language, and an unknown key as written", () => {
capability.answer = { available: true };
const html = render("de");
expect(html.split("<span>Gewicht</span>").length - 1).toBe(2);
expect(html).toContain("<span>mood vibes</span>");
expect(html).not.toContain("WEIGHT");
});
});
+2 -1
View File
@@ -23,6 +23,7 @@ import {
type CoachPlanDTO,
} from "@/hooks/use-coach-plans";
import { useAiCapabilityAnswer } from "@/hooks/use-ai-capability";
import { planMetricLabel } from "@/lib/ai/coach/plan-metric";
import { useTranslations } from "@/lib/i18n/context";
import { formatDateOrRelative } from "@/lib/format";
import { cn } from "@/lib/utils";
@@ -109,7 +110,7 @@ function CoachPlansBody({ readOnly }: { readOnly: boolean }) {
</p>
) : null}
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span className="uppercase">{plan.metric}</span>
<span>{planMetricLabel(plan.metric, t)}</span>
<span>{t(`coach.plans.status.${plan.status}`)}</span>
<span>{formatDateOrRelative(plan.updatedAt, t)}</span>
{plan.reviewDate ? (
@@ -0,0 +1,101 @@
/**
* `/insights/bmi` decides from a height whether it can show anything. It used
* the height of the person at the keyboard, so inside somebody else's record
* a viewer without a height of their own was asked to set it, and a viewer
* with one hid that the record has none.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
const shellProps = vi.fn();
vi.mock("@/components/insights/sub-page-shell", () => ({
SubPageShell: (props: Record<string, unknown>) => {
shellProps(props);
return null;
},
}));
vi.mock("@/lib/i18n/context", () => ({
useTranslations: () => ({ t: (key: string) => key, locale: "en" }),
}));
const state = {
viewerHeight: null as number | null,
inSharedRecord: true,
recordHeight: 118 as number | null,
bmiRange: { min: 18.5, max: 24.9 } as { min: number; max: number } | null,
};
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
isAuthenticated: true,
user: { heightCm: state.viewerHeight, timezone: "UTC" },
}),
}));
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => ({ inSharedRecord: state.inSharedRecord }),
}));
vi.mock("@/hooks/use-insights-analytics", () => ({
useInsightsAnalytics: () => ({ isEmpty: false }),
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: ({ enabled }: { enabled?: boolean }) => ({
data:
enabled === false
? undefined
: {
targets: [{ type: "BMI", range: state.bmiRange }],
profile: { heightCm: state.recordHeight },
},
}),
}));
import InsightsBmiPage from "../page";
/** True when the page rendered its "set your height" state. */
function asksForHeight(): boolean {
renderToStaticMarkup(<InsightsBmiPage />);
const props = shellProps.mock.calls.at(-1)![0] as { coachLaunch?: boolean };
// The chart branch launches the Coach from the shell; the empty states do not.
return props.coachLaunch !== true;
}
describe("/insights/bmi height gate", () => {
beforeEach(() => {
shellProps.mockClear();
state.viewerHeight = null;
state.inSharedRecord = true;
state.recordHeight = 118;
state.bmiRange = { min: 18.5, max: 24.9 };
});
it("reads the record's height inside somebody else's record", () => {
expect(asksForHeight()).toBe(false);
state.viewerHeight = 180;
state.recordHeight = null;
expect(asksForHeight()).toBe(true);
});
it("reads the account's own height in one's own record", () => {
state.inSharedRecord = false;
state.viewerHeight = 180;
expect(asksForHeight()).toBe(false);
state.viewerHeight = null;
expect(asksForHeight()).toBe(true);
});
it("draws the adult bands only where the record's BMI range is named", () => {
const chartBands = () => {
renderToStaticMarkup(<InsightsBmiPage />);
const { children } = shellProps.mock.calls.at(-1)![0] as {
children: Array<{ props?: { valueBands?: unknown } }>;
};
return children.find(
(child) => child?.props && "valueBands" in child.props,
)?.props?.valueBands;
};
expect(chartBands()).toBeDefined();
// A child's record: the server names no BMI range.
state.bmiRange = null;
expect(chartBands()).toBeUndefined();
});
});
+29 -2
View File
@@ -1,9 +1,13 @@
"use client";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { Ruler } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
@@ -45,6 +49,29 @@ export default function InsightsBmiPage() {
const { isEmpty } = useInsightsAnalytics("BMI");
// `useAuth()` is the person at the keyboard. Inside somebody else's record
// the height that decides is the record's, from the route that knows which
// record is on screen (the reference panel below reads the same cell).
const { inSharedRecord } = useRecordCapabilities();
const { data: targets } = useQuery({
queryKey: queryKeys.insightsTargets(),
queryFn: () =>
apiGet<{
targets: ReadonlyArray<{ type: string; range: unknown }>;
profile?: { heightCm: number | null };
}>("/api/insights/targets"),
enabled: isAuthenticated,
});
const heightCm = inSharedRecord
? (targets?.profile?.heightCm ?? null)
: (user?.heightCm ?? null);
const heightKnown = !inSharedRecord || targets !== undefined;
// The adult bands only where the server names the BMI range: it names none
// for a child's record, which they do not describe.
const bmiBands = targets?.targets.find((entry) => entry.type === "BMI")?.range
? BMI_BANDS
: undefined;
// v1.4.27 — BMI is derived from WEIGHT. When no weight readings
// exist yet, the existing "set your height" branch can never compute
// anything useful either; surface the empty-state CTA pointing at
@@ -75,7 +102,7 @@ export default function InsightsBmiPage() {
);
}
if (!user?.heightCm) {
if (heightKnown && !heightCm) {
return (
<SubPageShell
title={t("insights.bmiSectionTitle")}
@@ -119,7 +146,7 @@ export default function InsightsBmiPage() {
colors={["var(--chart-5)"]}
unit="kg/m²"
valueMode="bmi"
valueBands={BMI_BANDS}
valueBands={bmiBands}
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
showDataTable
@@ -0,0 +1,105 @@
/**
* `/insights/weight` shades the band of the record on screen. It used to take
* the height of the person at the keyboard (`useAuth()`) and their own target
* (`/api/user/thresholds`, refused under a switch), so a parent of 180 cm on
* a child's record saw the parent's healthy band behind the child's weights.
*
* `SubPageShell` is stubbed to a probe so the test reads the band the page
* hands the chart, not rendered pixels.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
const shellProps = vi.fn();
vi.mock("@/components/insights/sub-page-shell", () => ({
SubPageShell: (props: Record<string, unknown>) => {
shellProps(props);
return null;
},
}));
vi.mock("@/lib/i18n/context", () => ({
useTranslations: () => ({ t: (key: string) => key, locale: "en" }),
}));
// The viewer: 180 cm, signed in, looking at somebody else's record.
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
isAuthenticated: true,
user: { heightCm: 180, timezone: "UTC", unitPreference: "metric" },
}),
useAccountOnceMounted: () => null,
}));
vi.mock("@/hooks/use-insights-analytics", () => ({
useInsightsAnalytics: () => ({
data: { summaries: { WEIGHT: { count: 10 } } },
isLoading: false,
isEmpty: false,
}),
}));
// The record on screen: 118 cm, no target of its own.
const targets = {
current: {
targets: [
{ type: "WEIGHT", range: { min: 25.8, max: 34.7 }, source: "WHO BMI" },
],
profile: { heightCm: 118 },
} as unknown,
};
vi.mock("@tanstack/react-query", () => ({
useQuery: ({ queryKey }: { queryKey: readonly unknown[] }) => ({
data: JSON.stringify(queryKey).includes("targets")
? targets.current
: undefined,
isLoading: false,
error: null,
}),
}));
import InsightsGewichtPage from "../page";
import { buildWeightRangeFromHeight } from "@/lib/analytics/value-bands";
function chartBands(): Array<{ min: number; max: number }> | undefined {
renderToStaticMarkup(<InsightsGewichtPage />);
const props = shellProps.mock.calls.at(-1)![0] as { children: unknown };
const children = Array.isArray(props.children)
? props.children
: [props.children];
const chart = children.find(
(el) =>
el &&
typeof el === "object" &&
Array.isArray((el as { props?: { types?: unknown } }).props?.types),
) as { props: { valueBands?: Array<{ min: number; max: number }> } };
return chart.props.valueBands;
}
describe("/insights/weight band", () => {
beforeEach(() => shellProps.mockClear());
it("shades the WHO band for the record's height, not the viewer's", () => {
const bands = chartBands();
const record = buildWeightRangeFromHeight(118);
const viewer = buildWeightRangeFromHeight(180);
expect(bands).toContainEqual(
expect.objectContaining({ min: record.greenMin, max: record.greenMax }),
);
expect(bands).not.toContainEqual(
expect.objectContaining({ min: viewer.greenMin, max: viewer.greenMax }),
);
});
it("shades no band when the record's targets are not readable", () => {
targets.current = undefined;
try {
expect(chartBands()).toBeUndefined();
} finally {
targets.current = {
targets: [],
profile: { heightCm: 118 },
};
}
});
});
@@ -0,0 +1,57 @@
/**
* The weight band belongs to the record on screen. It used to be built from
* the signed-in person's height, so a parent of 180 cm looking at a child's
* record saw a healthy band of about 60 to 81 kg behind the child's weights.
*/
import { describe, expect, it } from "vitest";
import { buildWeightRangeFromHeight } from "@/lib/analytics/value-bands";
import { selectWeightBandRange } from "../weight-band";
describe("selectWeightBandRange", () => {
it("builds the WHO band from the record's height", () => {
const range = selectWeightBandRange({
targets: [
{ type: "WEIGHT", range: { min: 59.9, max: 81 }, source: "WHO BMI" },
],
profile: { heightCm: 180 },
});
expect(range).toEqual(buildWeightRangeFromHeight(180));
});
it("paints no adult band behind a child's weights", () => {
// A 118 cm child: the server names no WHO range for a record under 18,
// where the adult band read a normal 21 kg as underweight.
expect(
selectWeightBandRange({
targets: [{ type: "WEIGHT", range: null, source: "WHO BMI" }],
profile: { heightCm: 118 },
}),
).toBeNull();
});
it("paints the record's own target with the override wings", () => {
const range = selectWeightBandRange({
targets: [
{ type: "WEIGHT", range: { min: 70, max: 80 }, source: "Custom" },
],
profile: { heightCm: 180 },
});
expect(range).toEqual({
greenMin: 70,
greenMax: 80,
orangeMin: 68.5,
orangeMax: 81.5,
});
});
it("paints nothing without a target or a height, or before the read lands", () => {
expect(
selectWeightBandRange({
targets: [{ type: "WEIGHT", range: null, source: "WHO BMI" }],
profile: { heightCm: null },
}),
).toBeNull();
expect(selectWeightBandRange(null)).toBeNull();
});
});
+18 -39
View File
@@ -23,11 +23,9 @@ import { MetricCorrelationCard } from "@/components/insights/metric-correlation-
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import {
buildBandsFromTrafficRange,
buildWeightBandsFromHeight,
type TrafficRange,
} from "@/lib/analytics/value-bands";
import { METRIC_BOUNDS } from "@/lib/analytics/effective-range";
import { buildBandsFromTrafficRange } from "@/lib/analytics/value-bands";
import { selectWeightBandRange, type WeightTargetsSlice } from "./weight-band";
/**
* v1.4.25 — `/insights/weight`.
@@ -37,8 +35,8 @@ import {
* (`chartKey="weight"`) lets the user toggle trend lines + comparison
* overlay independently from the dashboard weight card.
*
* v1.34 — the bands follow the user's own weight target when one is set,
* and fall back to the height-derived WHO band only when it is not.
* The bands follow the record's own weight target when one is set, and fall
* back to the WHO band for the record's height only when it is not.
*
* v1.4.28 — analytics fetch + empty-state
* branch now consume `useInsightsAnalytics()` + `<MetricEmptyState>`.
@@ -56,19 +54,13 @@ export default function InsightsGewichtPage() {
} = useInsightsAnalytics("WEIGHT");
const weightSummary = analytics?.summaries?.WEIGHT ?? null;
// v1.34 — the user's own weight target, read through the shared
// `queryKeys.userThresholds()` cell the targets editor also subscribes to, so
// editing the target repaints this chart's band without a reload.
const { data: thresholds } = useQuery({
queryKey: queryKeys.userThresholds(),
queryFn: async () => {
return apiGet<{
effective: Record<
string,
{ range: TrafficRange | null; isOverride: boolean }
>;
}>("/api/user/thresholds");
},
// The record's own target and height, from the route that knows which
// record is on screen (see `selectWeightBandRange`). Shares the
// `insightsTargets()` cell with the reference panel below; the targets
// editor invalidates it on save, so the band repaints without a reload.
const { data: targets } = useQuery({
queryKey: queryKeys.insightsTargets(),
queryFn: () => apiGet<WeightTargetsSlice>("/api/insights/targets"),
enabled: isAuthenticated,
});
@@ -136,27 +128,14 @@ export default function InsightsGewichtPage() {
);
}
// v1.34 — the shaded zone follows the user's OWN target when they set one.
// The page used to shade the height-derived WHO band unconditionally, so
// someone who had entered a target on `/targets` kept reading a chart that
// ignored the answer. `/api/user/thresholds` already resolves the override
// (with its orange wings) through `getEffectiveRange`, so the page reads the
// resolved band rather than re-deriving one. No target and no height → no
// bands, unchanged.
const overrideRange = thresholds?.effective?.WEIGHT?.isOverride
? (thresholds.effective.WEIGHT.range ?? null)
: null;
const weightBands = overrideRange
? buildBandsFromTrafficRange(overrideRange, {
lowerBound: 30,
// No target and no height → no bands.
const weightRange = selectWeightBandRange(targets ?? null);
const weightBands = weightRange
? buildBandsFromTrafficRange(weightRange, {
lowerBound: METRIC_BOUNDS.WEIGHT.min,
upperBound: 250,
})
: user?.heightCm
? buildWeightBandsFromHeight(user.heightCm, {
lowerBound: 30,
upperBound: 250,
})
: undefined;
: undefined;
// The bands are kg bounds → scale into the display unit so the shaded zone
// tracks the converted line (factor-only; weight carries no offset).
const displayBands =
+45
View File
@@ -0,0 +1,45 @@
import { getEffectiveRange } from "@/lib/analytics/effective-range";
import {
buildWeightRangeFromHeight,
type TrafficRange,
} from "@/lib/analytics/value-bands";
/** The slice of `GET /api/insights/targets` the weight band needs. */
export interface WeightTargetsSlice {
targets: ReadonlyArray<{
type: string;
range: { min: number; max: number } | null;
source: string;
}>;
profile?: { heightCm: number | null };
}
/**
* The weight band for the record on screen, as the server resolved it.
*
* The page used to read the person at the keyboard: their height from
* `useAuth()` and their own target from `/api/user/thresholds`, which
* refuses inside somebody else's record. A parent on a child's record saw a
* band built from the parent's height. `/api/insights/targets` resolves the
* record's own target and height, and the reference panel under the chart
* reads the same payload. The record's own target (`source: "Custom"`) gets
* the wings `getEffectiveRange` gives every override; otherwise the band is
* the height-derived WHO one. Where the server names no range there is none:
* no target and no height, or a child's record, which the adult WHO band does
* not describe.
*/
export function selectWeightBandRange(
data: WeightTargetsSlice | null,
): TrafficRange | null {
const weight = data?.targets.find((entry) => entry.type === "WEIGHT");
if (!data || !weight?.range) return null;
if (weight.source === "Custom") {
return getEffectiveRange(
"WEIGHT",
{ heightCm: null, dateOfBirth: null, gender: null },
{ WEIGHT: weight.range },
).range;
}
const heightCm = data.profile?.heightCm ?? null;
return heightCm ? buildWeightRangeFromHeight(heightCm) : null;
}
+3 -15
View File
@@ -37,7 +37,7 @@ import { useMounted } from "@/hooks/use-mounted";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { invalidateKeys, labDependentKeys, queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import type { LabResultListResponse } from "@/components/labs/types";
import {
@@ -231,18 +231,14 @@ export default function LabsPage() {
footerSlot={addFooterEl}
onSuccess={() => {
setDialogOpen(false);
queryClient.invalidateQueries({
queryKey: queryKeys.labResults(),
});
void invalidateKeys(queryClient, labDependentKeys);
}}
// v1.30.1 — "Save & add another" keeps the sheet open; the
// form clears the reading itself, this callback just refreshes
// the list underneath so the just-saved row is there once the
// user is done and closes the sheet.
onSavedKeepOpen={() => {
queryClient.invalidateQueries({
queryKey: queryKeys.labResults(),
});
void invalidateKeys(queryClient, labDependentKeys);
}}
onCancel={() => setDialogOpen(false)}
/>
@@ -255,14 +251,6 @@ export default function LabsPage() {
mode={ocrCapability.data.mode}
pdfSupported={ocrCapability.data.pdfSupported}
consentRequired={labsOcr.reason === "consent_required"}
onCommitted={() => {
queryClient.invalidateQueries({
queryKey: queryKeys.labResults(),
});
queryClient.invalidateQueries({
queryKey: queryKeys.biomarkers(),
});
}}
/>
) : null}
@@ -143,8 +143,8 @@ describe("NotificationsPage", () => {
},
],
preferences: [],
eventTypes: ["MOOD_REMINDER", "PERSONAL_RECORD"],
eventDefaults: { MOOD_REMINDER: true, PERSONAL_RECORD: false },
eventTypes: ["MOOD_REMINDER", "DAILY_BRIEFING"],
eventDefaults: { MOOD_REMINDER: true, DAILY_BRIEFING: false },
};
const html = render();
const state = (label: string) =>
@@ -152,10 +152,10 @@ describe("NotificationsPage", () => {
`aria-label="${label} - Telegram"[^>]*data-state="(checked|unchecked)"|data-state="(checked|unchecked)"[^>]*aria-label="${label} - Telegram"`,
).exec(html);
const mood = state("Mood reminder");
const record = state("Personal record");
const briefing = state("Daily briefing");
expect(mood, "mood switch rendered").not.toBeNull();
expect(mood?.[1] ?? mood?.[2]).toBe("checked");
expect(record?.[1] ?? record?.[2]).toBe("unchecked");
expect(briefing?.[1] ?? briefing?.[2]).toBe("unchecked");
});
it("a stored row still wins over the default", () => {
+4 -19
View File
@@ -12,6 +12,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { PasswordInput as SharedPasswordInput } from "@/components/ui/password-input";
import { apiGet, apiPut } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
/**
* Re-export the canonical password input so admin sections have access to
@@ -323,29 +324,13 @@ export function useUpdateSettings() {
toast.success(t("common.saved"));
},
onError: (err) => {
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.settingsSaveError"),
{ cause: err },
);
showErrorToast(localizedApiError(err, t, "admin.settingsSaveError"), {
cause: err,
});
},
});
}
export async function getApiErrorMessage(response: Response): Promise<string> {
const fallback = `HTTP ${response.status}`;
try {
const json = (await response.json()) as { error?: string };
if (typeof json?.error === "string" && json.error.trim().length > 0) {
return json.error;
}
} catch {
return fallback;
}
return fallback;
}
/**
* Shared `auth.*` audit-action label map. Two surfaces consume it
* today (`/admin/login-overview` and the dashboard recent-activity
@@ -21,6 +21,7 @@ import {
queryKeys,
} from "@/lib/query-keys";
import { apiGet, apiPut } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { PasswordInput } from "./_shared";
/**
@@ -90,10 +91,9 @@ export function AiServerKeySection() {
toast.success(t("admin.aiServerKey.saved"));
},
onError: (error: unknown) => {
showErrorToast(
error instanceof Error ? error.message : t("admin.aiServerKey.error"),
{ cause: error },
);
showErrorToast(localizedApiError(error, t, "admin.aiServerKey.error"), {
cause: error,
});
},
});
+4 -6
View File
@@ -35,6 +35,7 @@ import {
type AiUsageLevel,
} from "@/lib/ai/usage-level";
import { apiGet, apiPut } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { useTranslations } from "@/lib/i18n/context";
import {
aiInputDependentKeys,
@@ -122,12 +123,9 @@ function AiUsageForm({ bounds }: { bounds: AiUsageBounds }) {
toast.success(t("common.saved"));
},
onError: (err) => {
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.settingsSaveError"),
{ cause: err },
);
showErrorToast(localizedApiError(err, t, "admin.settingsSaveError"), {
cause: err,
});
},
});
+4 -6
View File
@@ -15,6 +15,7 @@ import {
} from "@/lib/query-keys";
import { SettingsToggle } from "./_shared";
import { apiGet, apiPut } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import {
@@ -105,12 +106,9 @@ function useUpdateAssistantFlags() {
toast.success(t("common.saved"));
},
onError: (err) => {
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.settingsSaveError"),
{ cause: err },
);
showErrorToast(localizedApiError(err, t, "admin.settingsSaveError"), {
cause: err,
});
client.invalidateQueries({
queryKey: queryKeys.adminAssistantFlags(),
});
@@ -7,14 +7,13 @@ import {
ReproofCancelledError,
throwIfReproofRequired,
} from "@/components/settings/security-section/use-recent-proof";
import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
import { apiFetchRaw, apiPost, throwApiError } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { BackupRow } from "@/types/backups";
import { getApiErrorMessage } from "../_shared";
/** "Run backup now": enqueues the pg-boss `data-backup` job. */
export function useRunBackup() {
const { t } = useTranslations();
@@ -34,9 +33,7 @@ export function useRunBackup() {
},
onError: (err) => {
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.section.backups.runFailed"),
localizedApiError(err, t, "admin.section.backups.runFailed"),
{ cause: err },
);
},
@@ -62,9 +59,7 @@ export function useBackupDownload(
await apiFetchRaw(`/api/admin/backups/${row.id}/download`),
),
);
if (!res.ok) {
throw new Error(await getApiErrorMessage(res));
}
if (!res.ok) await throwApiError(res);
// Use the server-provided Content-Disposition filename if present,
// otherwise fall back to a deterministic client-side name. The
// server filename is more accurate (uses the actual createdAt).
@@ -86,9 +81,7 @@ export function useBackupDownload(
} catch (err) {
if (err instanceof ReproofCancelledError) return;
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.section.backups.downloadFailed"),
localizedApiError(err, t, "admin.section.backups.downloadFailed"),
{ cause: err },
);
} finally {
@@ -5,6 +5,7 @@ import { toast } from "sonner";
import type { useRecentProof } from "@/components/settings/security-section/use-recent-proof";
import { ReproofCancelledError } from "@/components/settings/security-section/use-recent-proof";
import { ApiError, apiGet, apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { showErrorToast } from "@/lib/errors/error-toast";
import type {
MissingBackupSection,
@@ -188,9 +189,7 @@ export function useBackupRestores(
}
setRefusedSections([]);
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.section.backups.restoreFailed"),
localizedApiError(err, t, "admin.section.backups.restoreFailed"),
{ cause: err },
);
},
@@ -5,6 +5,7 @@ import { toast } from "sonner";
import type { useRecentProof } from "@/components/settings/security-section/use-recent-proof";
import { ReproofCancelledError } from "@/components/settings/security-section/use-recent-proof";
import { ApiError, apiFetch, apiGet } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -167,9 +168,7 @@ export function useBackupUpload(
return;
}
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.section.backups.uploadFailed"),
localizedApiError(err, t, "admin.section.backups.uploadFailed"),
{ cause: err },
);
},
+24 -36
View File
@@ -28,7 +28,8 @@ import { Button } from "@/components/ui/button";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import { apiDelete, apiGet, apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import {
@@ -66,21 +67,20 @@ export function CentralCodexSection() {
async function handleConnect() {
setDevicePolling(true);
try {
const res = await apiFetchRaw("/api/admin/central-codex/device-start", {
method: "POST",
});
const json = await res.json();
if (!res.ok) throw new Error(json.error || t("admin.centralCodex.error"));
const data = await apiPost<{
userCode: string;
verificationUrl: string;
intervalSeconds: number;
}>("/api/admin/central-codex/device-start");
setDeviceCode({
userCode: json.data.userCode,
verificationUrl: json.data.verificationUrl,
intervalSeconds: json.data.intervalSeconds,
userCode: data.userCode,
verificationUrl: data.verificationUrl,
intervalSeconds: data.intervalSeconds,
});
} catch (err) {
showErrorToast(
err instanceof Error ? err.message : t("admin.centralCodex.error"),
{ cause: err },
);
showErrorToast(localizedApiError(err, t, "admin.centralCodex.error"), {
cause: err,
});
setDevicePolling(false);
}
}
@@ -92,15 +92,11 @@ export function CentralCodexSection() {
async function tick() {
try {
const res = await apiFetchRaw("/api/admin/central-codex/device-poll", {
method: "POST",
});
const json = await res.json();
const data = await apiPost<{ status?: string } | null>(
"/api/admin/central-codex/device-poll",
);
if (cancelled) return;
if (!res.ok) {
throw new Error(json.error || t("admin.centralCodex.error"));
}
if (json.data?.status === "connected") {
if (data?.status === "connected") {
setDeviceCode(null);
setDevicePolling(false);
toast.success(t("admin.centralCodex.connected"));
@@ -113,10 +109,9 @@ export function CentralCodexSection() {
if (!cancelled) setTimeout(tick, intervalMs);
} catch (err) {
if (cancelled) return;
showErrorToast(
err instanceof Error ? err.message : t("admin.centralCodex.error"),
{ cause: err },
);
showErrorToast(localizedApiError(err, t, "admin.centralCodex.error"), {
cause: err,
});
setDeviceCode(null);
setDevicePolling(false);
}
@@ -133,23 +128,16 @@ export function CentralCodexSection() {
async function handleDisconnect() {
setDisconnecting(true);
try {
const res = await apiFetchRaw("/api/admin/central-codex", {
method: "DELETE",
});
if (!res.ok) {
const json = await res.json();
throw new Error(json.error || t("admin.centralCodex.error"));
}
await apiDelete("/api/admin/central-codex");
toast.success(t("admin.centralCodex.disconnected"));
queryClient.invalidateQueries({
queryKey: queryKeys.adminCentralCodex(),
});
void invalidateKeys(queryClient, aiInputDependentKeys);
} catch (err) {
showErrorToast(
err instanceof Error ? err.message : t("admin.centralCodex.error"),
{ cause: err },
);
showErrorToast(localizedApiError(err, t, "admin.centralCodex.error"), {
cause: err,
});
} finally {
setDisconnecting(false);
}
+5 -2
View File
@@ -22,6 +22,7 @@ import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { apiDelete } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { randomId } from "@/lib/random-id";
import {
recentProofErrorMessage,
@@ -81,8 +82,10 @@ export function DangerZoneSection() {
}),
);
},
onError: (err: Error) => {
setWipeMsg(recentProofErrorMessage(err, err.message));
onError: (err) => {
setWipeMsg(
recentProofErrorMessage(err, localizedApiError(err, t, "common.error")),
);
},
});
+2 -3
View File
@@ -7,6 +7,7 @@ import { ConfirmButton } from "@/components/ui/confirm-button";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { showErrorToast } from "@/lib/errors/error-toast";
import { apiDelete } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { formatDate } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -90,9 +91,7 @@ function DeleteUserButton({ user }: { user: AdminUser }) {
onError: (err) => {
const message = recentProofErrorMessage(
err,
err instanceof Error && err.message
? err.message
: t("admin.section.users.deleteUserFailed"),
localizedApiError(err, t, "admin.section.users.deleteUserFailed"),
);
if (message) showErrorToast(message, { cause: err });
},
+11 -9
View File
@@ -43,8 +43,13 @@ import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import { getApiErrorMessage } from "./_shared";
import {
ApiError,
apiFetchRaw,
apiGet,
throwApiError,
} from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import {
recentProofErrorMessage,
throwIfReproofRequired,
@@ -133,12 +138,7 @@ export function EncryptionSection() {
}),
),
);
if (!res.ok) {
if (res.status === 401) {
throw new Error(t("admin.section.encryption.stepUpNeeded"));
}
throw new Error(await getApiErrorMessage(res));
}
if (!res.ok) await throwApiError(res);
return res.json();
},
onSuccess: () => {
@@ -150,7 +150,9 @@ export function EncryptionSection() {
onError: (err: Error) => {
const message = recentProofErrorMessage(
err,
err.message || t("admin.section.encryption.rotateFailed"),
err instanceof ApiError && err.status === 401
? t("admin.section.encryption.stepUpNeeded")
: localizedApiError(err, t, "admin.section.encryption.rotateFailed"),
);
if (message === null) return;
showErrorToast(message, { cause: err });
+2 -3
View File
@@ -20,6 +20,7 @@ import {
ConfiguredBadge,
} from "./_shared";
import { apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { queryKeys } from "@/lib/query-keys";
export function GlitchtipSection() {
@@ -113,9 +114,7 @@ export function GlitchtipSection() {
},
onError: (error) => {
showErrorToast(
error instanceof Error
? error.message
: t("admin.monitoringTestFailed"),
localizedApiError(error, t, "admin.monitoringTestFailed"),
{ cause: error },
);
void queryClient.invalidateQueries({
@@ -33,12 +33,12 @@ import {
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import { apiFetchRaw, apiGet, throwApiError } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { formatBytes } from "@/lib/format/bytes";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { MeasurementMaintenanceStatus } from "@/lib/jobs/measurement-maintenance-status";
import { queryKeys } from "@/lib/query-keys";
import { getApiErrorMessage } from "./_shared";
const ENDPOINT = "/api/admin/maintenance/measurements";
@@ -70,7 +70,7 @@ export function MeasurementMaintenanceCard() {
headers: { "content-type": "application/json" },
body: JSON.stringify({ vacuum: true, reindex: true }),
});
if (!res.ok) throw new Error(await getApiErrorMessage(res));
if (!res.ok) await throwApiError(res);
return ((await res.json()) as { data: { enqueued: boolean } }).data;
},
onSuccess: ({ enqueued }) => {
@@ -80,9 +80,9 @@ export function MeasurementMaintenanceCard() {
queryKey: queryKeys.adminMeasurementMaintenance(),
});
},
onError: (err: Error) => {
onError: (err) => {
showErrorToast(
err.message || t("admin.section.maintenance.startFailed"),
localizedApiError(err, t, "admin.section.maintenance.startFailed"),
{ cause: err },
);
},
@@ -23,6 +23,7 @@ import {
import type { ModuleKey } from "@/lib/modules/registry";
import { SettingsToggle } from "./_shared";
import { apiGet, apiPatch } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { InlineAction } from "@/components/ui/inline-action";
/**
@@ -76,12 +77,9 @@ function useUpdateModuleAvailability() {
toast.success(t("common.saved"));
},
onError: (err) => {
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.modules.saveError"),
{ cause: err },
);
showErrorToast(localizedApiError(err, t, "admin.modules.saveError"), {
cause: err,
});
client.invalidateQueries({
queryKey: queryKeys.adminModuleAvailability(),
});
@@ -8,6 +8,7 @@ import { Badge } from "@/components/ui/badge";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { showErrorToast } from "@/lib/errors/error-toast";
import { apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { formatDate } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -41,9 +42,7 @@ export function PendingDeletionControls({ user }: { user: AdminUser }) {
},
onError: (err) => {
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.section.users.undoDeletionFailed"),
localizedApiError(err, t, "admin.section.users.undoDeletionFailed"),
{ cause: err },
);
},
+23 -20
View File
@@ -32,7 +32,13 @@ import {
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import {
ApiError,
apiFetchRaw,
apiGet,
throwApiError,
} from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -77,23 +83,7 @@ export function PulseDayMeanCard() {
headers: { "content-type": "application/json" },
body: JSON.stringify({ action }),
});
if (!res.ok) {
let code: string | undefined;
try {
const body = (await res.json()) as { meta?: { errorCode?: string } };
code = body.meta?.errorCode;
} catch {
// No envelope: the generic message below.
}
// The route's two refusals each have their own message.
throw new Error(
code === "maintenance.pulseDayMean.busy"
? t("admin.section.pulseDayMean.error.busy")
: code === "maintenance.pulseDayMean.dryRunRequired"
? t("admin.section.pulseDayMean.error.dryRunRequired")
: t("admin.section.pulseDayMean.error.generic"),
);
}
if (!res.ok) await throwApiError(res);
const { enqueued } = (
(await res.json()) as { data: { enqueued: boolean } }
).data;
@@ -117,8 +107,21 @@ export function PulseDayMeanCard() {
queryKey: queryKeys.adminPulseDayMean(),
});
},
onError: (err: Error) => {
showErrorToast(err.message, { cause: err });
// The route's two refusals each have their own message.
onError: (err) => {
const code = err instanceof ApiError ? err.meta?.errorCode : undefined;
showErrorToast(
code === "maintenance.pulseDayMean.busy"
? t("admin.section.pulseDayMean.error.busy")
: code === "maintenance.pulseDayMean.dryRunRequired"
? t("admin.section.pulseDayMean.error.dryRunRequired")
: localizedApiError(
err,
t,
"admin.section.pulseDayMean.error.generic",
),
{ cause: err },
);
},
});
+12 -7
View File
@@ -32,6 +32,7 @@ import { NativeSelect } from "@/components/ui/native-select";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ApiError, apiFetch, apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -264,11 +265,14 @@ function CheckMyCopy({ kit }: { kit: RecoveryKit }) {
setCheckResult(data.matches);
setCandidate("");
},
onError: (err: Error) => {
onError: (err) => {
setCheckResult(null);
showErrorToast(err.message || t("admin.recoveryKit.checkFailed"), {
cause: err,
});
showErrorToast(
localizedApiError(err, t, "admin.recoveryKit.checkFailed"),
{
cause: err,
},
);
},
});
@@ -395,9 +399,10 @@ export function RecoveryKitCard() {
await invalidate();
return;
}
showErrorToast(err.message || t("admin.recoveryKit.confirmFailed"), {
cause: err,
});
showErrorToast(
localizedApiError(err, t, "admin.recoveryKit.confirmFailed"),
{ cause: err },
);
},
});
+5 -7
View File
@@ -23,6 +23,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { useAdminSettings, useUpdateSettings } from "./_shared";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import {
describeChannelTestFailures,
type ChannelTestResult,
@@ -163,9 +164,7 @@ export function RemindersSection() {
},
onError: (error) => {
showErrorToast(
error instanceof Error
? error.message
: t("admin.notificationTestFailed"),
localizedApiError(error, t, "admin.notificationTestFailed"),
{ cause: error },
);
},
@@ -201,10 +200,9 @@ export function RemindersSection() {
toast.success(data?.message ?? t("admin.reminderCheckSuccess"));
},
onError: (error) => {
showErrorToast(
error instanceof Error ? error.message : t("admin.reminderCheckFailed"),
{ cause: error },
);
showErrorToast(localizedApiError(error, t, "admin.reminderCheckFailed"), {
cause: error,
});
},
});
+2 -3
View File
@@ -19,6 +19,7 @@ import {
ConfiguredBadge,
} from "./_shared";
import { apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
export function UmamiSection() {
const { t } = useTranslations();
@@ -52,9 +53,7 @@ export function UmamiSection() {
},
onError: (error) => {
showErrorToast(
error instanceof Error
? error.message
: t("admin.monitoringTestFailed"),
localizedApiError(error, t, "admin.monitoringTestFailed"),
{ cause: error },
);
},
@@ -41,7 +41,8 @@ import { queryKeys } from "@/lib/query-keys";
import { type AdminUser, PasswordInput } from "./_shared";
import { PendingDeletionControls } from "./pending-deletion-controls";
import { DeleteUserControl } from "./delete-user-control";
import { apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
import { ApiError, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import {
recentProofErrorMessage,
useRecentProof,
@@ -109,12 +110,9 @@ export function UserManagementSection() {
toast.success(t("common.saved"));
},
onError: (err) => {
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.settingsSaveError"),
{ cause: err },
);
showErrorToast(localizedApiError(err, t, "admin.settingsSaveError"), {
cause: err,
});
},
});
@@ -132,8 +130,17 @@ export function UserManagementSection() {
setResetMsg(t("admin.passwordReset"));
setResetPassword("");
},
onError: (err: Error) => {
setResetMsg(recentProofErrorMessage(err, err.message));
// The server words its password verdict (too weak, found in a breach) in
// the account's language; any other refusal reads through the catalogue.
onError: (err) => {
setResetMsg(
recentProofErrorMessage(
err,
err instanceof ApiError && err.status === 422
? err.message
: localizedApiError(err, t, "common.error"),
),
);
},
});
@@ -156,9 +163,7 @@ export function UserManagementSection() {
},
onError: (err) => {
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.section.users.forceLogoutFailed"),
localizedApiError(err, t, "admin.section.users.forceLogoutFailed"),
{ cause: err },
);
},
@@ -560,7 +565,11 @@ export function UserManagementSection() {
</div>
{updateUser.isError && (
<p className="text-destructive text-sm">
{(updateUser.error as Error).message}
{localizedApiError(
updateUser.error,
t,
"admin.settingsSaveError",
)}
</p>
)}
<SettingsCardActions>
@@ -0,0 +1,59 @@
/**
* The BMI chart divides the weights on screen by the height of the record on
* screen. It used to know only the viewer's height, so inside another
* person's record it drew nothing at all.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const h = vi.hoisted(() => ({
inSharedRecord: false,
recordHeightCm: 120 as number | null,
enabled: [] as boolean[],
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ isAuthenticated: true, user: { heightCm: 180 } }),
}));
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => ({ inSharedRecord: h.inSharedRecord }),
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: (options: { enabled: boolean }) => {
h.enabled.push(options.enabled);
return options.enabled
? { data: { profile: { heightCm: h.recordHeightCm } } }
: { data: undefined };
},
}));
import { useBmiDivisor } from "../health-chart/use-bmi-divisor";
describe("useBmiDivisor", () => {
beforeEach(() => {
h.inSharedRecord = false;
h.recordHeightCm = 120;
h.enabled.length = 0;
});
it("uses the viewer's own height in their own record", () => {
expect(useBmiDivisor("bmi")).toBeCloseTo(3.24);
expect(h.enabled).toEqual([false]);
});
it("uses the record's height inside somebody else's record", () => {
h.inSharedRecord = true;
expect(useBmiDivisor("bmi")).toBeCloseTo(1.44);
});
it("draws no BMI when the record has no height", () => {
h.inSharedRecord = true;
h.recordHeightCm = null;
expect(useBmiDivisor("bmi")).toBeNull();
});
it("asks for nothing outside the BMI view", () => {
h.inSharedRecord = true;
expect(useBmiDivisor("raw")).toBeNull();
expect(h.enabled).toEqual([false]);
});
});
+2 -10
View File
@@ -19,7 +19,6 @@ import {
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { useChartOverlayPrefs } from "@/hooks/use-chart-overlay-prefs";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useViewportWidth } from "@/hooks/use-viewport-width";
import { axisTickUnitSuffix } from "@/lib/charts/axis-unit";
import { makeBucketLabelFormatters } from "@/lib/charts/bucket-label";
@@ -61,6 +60,7 @@ import {
useHealthChartLabels,
useHealthChartOverlays,
} from "./health-chart/use-health-chart-overlays";
import { useBmiDivisor } from "./health-chart/use-bmi-divisor";
import { useHealthChartSeries } from "./health-chart/use-health-chart-series";
import { useHealthChartView } from "./health-chart/use-health-chart-view";
import { resolveMiniRangePoints } from "./mini-window";
@@ -157,15 +157,7 @@ export function HealthChart({
// Viewport-aware tick density: 4 to 10 labels, whatever the point count.
const viewportWidth = useViewportWidth();
// `useAuth()` is the person at the keyboard, and under a switch that is the
// delegate: dividing the owner's weights by the delegate's height squared
// gives a plausible BMI that is somebody else's. Inside a shared record the
// chart draws no BMI rather than a wrong one.
const { inSharedRecord } = useRecordCapabilities();
const bmiDivisor =
valueMode === "bmi" && !inSharedRecord && user?.heightCm
? (user.heightCm / 100) ** 2
: null;
const bmiDivisor = useBmiDivisor(valueMode);
const { data, isLoading, isError, error, isPlaceholderData, refetch } =
useHealthChartSeries({
@@ -0,0 +1,36 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
/**
* The BMI view's divisor: the height of the record on screen, in metres,
* squared. `useAuth()` is the person at the keyboard, and under a switch that
* is the delegate, so inside somebody else's record the height comes from
* `/api/insights/targets`, the route that knows which record is on screen
* (the BMI page and the reference panel read the same cell). No height, no
* BMI.
*/
export function useBmiDivisor(
valueMode: "raw" | "bmi" | undefined,
): number | null {
const { isAuthenticated, user } = useAuth();
const { inSharedRecord } = useRecordCapabilities();
const { data: targets } = useQuery({
queryKey: queryKeys.insightsTargets(),
queryFn: () =>
apiGet<{ profile?: { heightCm: number | null } }>(
"/api/insights/targets",
),
enabled: isAuthenticated && valueMode === "bmi" && inSharedRecord,
});
if (valueMode !== "bmi") return null;
const heightCm = inSharedRecord
? (targets?.profile?.heightCm ?? null)
: (user?.heightCm ?? null);
return heightCm ? (heightCm / 100) ** 2 : null;
}
Loaded 100 of 381 files, more files were not shown because too many files have changed in this diff. Show more