mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge the day picker, Coach new-chat button, day panel on /coach and text cleanup into release/v1.42.0
This commit is contained in:
commit
1d080ee166
69 files changed
+2293
-2241
No files matched your search
@@ -11624,30 +11624,6 @@ paths:
|
||||
"403": *a11
|
||||
"422": *a4
|
||||
"429": *a5
|
||||
/api/insights/coach/seeded-question:
|
||||
get:
|
||||
tags:
|
||||
- Insights
|
||||
summary: Today's suggested Coach opener
|
||||
description: "Resolves today's single most notable derived wellness signal into a tappable opener for the Coach's
|
||||
blank-chat hero, using the same confidence-gated detector the daily briefing uses. The selection happens
|
||||
SERVER-side and the client renders it rather than recomputing. `signal: null` is the ordinary answer and means
|
||||
the hero keeps its neutral greeting — never a fabricated opener. Two different causes produce that null and are
|
||||
indistinguishable on the wire: nothing crossed the notability gate, or the account has turned proactive
|
||||
suggestions off, in which case the detector does not run at all. The opener exists only to open the Coach, so it
|
||||
is served only while the `coach` AI capability is available; otherwise the answer is the same neutral `signal:
|
||||
null` and `ai` says why. Never refused for an AI reason. Shared analytics-read budget. Cookie or Bearer auth;
|
||||
not delegable."
|
||||
responses:
|
||||
"200":
|
||||
description: The opener, or the neutral null.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CoachSeededQuestionEnvelope"
|
||||
"401": *a1
|
||||
"422": *a4
|
||||
"429": *a5
|
||||
/api/insights/narrative:
|
||||
get:
|
||||
tags:
|
||||
@@ -27376,8 +27352,6 @@ components:
|
||||
enum:
|
||||
- weekly
|
||||
- biweekly
|
||||
ambientSuggestions:
|
||||
type: boolean
|
||||
nudgeAiComposed:
|
||||
type: boolean
|
||||
measurementReminder:
|
||||
@@ -60735,57 +60709,6 @@ components:
|
||||
description: The two server-authoritative lines a metric sub-page renders above its chart. Pure compute over the
|
||||
baseline and correlation engines — no provider call, no cache table — so web and native decode the same resolved
|
||||
DTO.
|
||||
CoachSeededQuestionEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/CoachSeededQuestion"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
CoachSeededQuestion:
|
||||
type: object
|
||||
properties:
|
||||
signal:
|
||||
anyOf:
|
||||
- type: object
|
||||
properties:
|
||||
sourceMetric:
|
||||
type: string
|
||||
description: Sentinel id the client keys its localised copy on (`readiness` / `recovery`).
|
||||
score:
|
||||
type: number
|
||||
description: The latest 0..100 score.
|
||||
band:
|
||||
type: string
|
||||
description: "`yellow` or `red`. Green never surfaces — a good day is not notable."
|
||||
required:
|
||||
- sourceMetric
|
||||
- score
|
||||
- band
|
||||
additionalProperties: false
|
||||
- type: "null"
|
||||
description: Null whenever nothing crossed the detector's confidence and notability gate, AND whenever the account has
|
||||
turned proactive suggestions off, AND whenever the `coach` capability is unavailable (then `ai` says why) —
|
||||
all mean the hero keeps its neutral greeting rather than showing a fabricated opener.
|
||||
ai:
|
||||
description: The `coach` capability the opener exists for.
|
||||
$ref: "#/components/schemas/AiCapabilityState"
|
||||
required:
|
||||
- signal
|
||||
- ai
|
||||
additionalProperties: false
|
||||
description: Today's single most notable derived wellness signal, resolved server-side into a tappable opener for the
|
||||
Coach's blank-chat hero. The client renders the resolved DTO and never recomputes the selection.
|
||||
NarrativeEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
* 3. Nothing opens by itself: the row stays closed (`aria-expanded`
|
||||
* false) in every state, after the answer and after a reload; a row
|
||||
* opened during the run closes when the answer is done.
|
||||
* 4. After the answer the row reads "Thought process · 4 steps" and the
|
||||
* 4. After the answer the row reads "Thought process (4 steps)" and the
|
||||
* table the answer points at renders as its chart.
|
||||
* 5. A tap opens the steps; a reasoning round shows its summary under
|
||||
* its title.
|
||||
@@ -435,7 +435,7 @@ test.describe("Coach live trail", () => {
|
||||
await expect(settled).toHaveAttribute("data-state", "done");
|
||||
await expect(
|
||||
settled.locator('[data-slot="coach-turn-steps-done"]'),
|
||||
).toHaveText("Thought process · 4 steps");
|
||||
).toHaveText("Thought process (4 steps)");
|
||||
await expect(settled.locator(".text-shimmer")).toHaveCount(0);
|
||||
const settledToggle = settled.locator(
|
||||
'[data-slot="coach-turn-steps-toggle"]',
|
||||
@@ -506,7 +506,7 @@ test.describe("Coach live trail", () => {
|
||||
await expect(reloaded).toHaveAttribute("data-state", "done");
|
||||
await expect(
|
||||
reloaded.locator('[data-slot="coach-turn-steps-done"]'),
|
||||
).toHaveText("Thought process · 4 steps");
|
||||
).toHaveText("Thought process (4 steps)");
|
||||
const reloadedToggle = reloaded.locator(
|
||||
'[data-slot="coach-turn-steps-toggle"]',
|
||||
);
|
||||
|
||||
+196
-125
@@ -4,26 +4,27 @@ import type { Locator, Page, Request, Route, TestInfo } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
|
||||
import { mockDay } from "./setup/day-mock";
|
||||
|
||||
/**
|
||||
* The Coach page frame: the conversations panel on the right, its toggle in
|
||||
* the top bar, the settings gear in the panel header, and New chat opening
|
||||
* that header row.
|
||||
* The Coach page frame: the conversation, the conversations panel right of
|
||||
* it, and the day right of that; New conversation floating in the
|
||||
* conversation itself.
|
||||
*
|
||||
* Contracts under test, at 1440, 1280 and 390 px:
|
||||
* Contracts under test, at 1920, 1440, 1280 and 390 px:
|
||||
*
|
||||
* 1. From 1280 px the panel docks beside the thread, open on a first visit,
|
||||
* and the reading column (thread and composer) is exactly as wide with
|
||||
* the panel open as with it closed. The choice survives a reload. The
|
||||
* panel is flush with the viewport's right edge (no scrollbar gutter
|
||||
* beside it) and runs from the top of the window to the bottom; the
|
||||
* top bar ends at its left edge and the panel's header border is the
|
||||
* top bar's. The toggle is the bar's last item, within 16 px of its
|
||||
* right edge, open and shut; shut, no pixel of the panel shows and the
|
||||
* bar runs to the window's edge. New chat is the header row's first
|
||||
* control, left of the title, and never overlaps the list.
|
||||
* 2. Escape inside the docked panel closes it and returns focus to the
|
||||
* toggle.
|
||||
* the panel open as folded. Folded, the panel keeps a 48 px edge whose
|
||||
* button (in the place of the open panel's fold control) opens it
|
||||
* again; the choice survives a reload. The panel is flush with the
|
||||
* viewport's right edge and runs from the top of the window to the
|
||||
* bottom; the top bar ends at its left edge and the panel's header
|
||||
* border is the top bar's. Focus moves between the fold control and
|
||||
* the edge's button, and Escape inside the open panel folds it.
|
||||
* 2. New conversation is a round 56 px button in the conversation column,
|
||||
* never over the panel, never over the composer's field, and absent
|
||||
* from the panel's header.
|
||||
* 3. A row's menu renames by keyboard (Escape cancels without closing the
|
||||
* panel, Enter saves once) and deletes with an Undo that keeps the
|
||||
* DELETE from ever leaving; a delete left alone commits once.
|
||||
@@ -32,10 +33,13 @@ import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
|
||||
* can see" and drops the param from the URL.
|
||||
* 5. axe finds nothing on the open panel or the open settings, in the
|
||||
* light and the dark theme.
|
||||
* 6. At 390 px the panel is a dialog sheet with New chat first in its
|
||||
* header and the title beside it; Escape closes it and focus returns to the toggle. The gear
|
||||
* opens a bottom sheet that closes on Escape back to the gear. Nothing
|
||||
* scrolls sideways.
|
||||
* 6. The day docks right of the panel. At 1440 only one of the two is
|
||||
* open: a day folds the panel, opening the panel folds the day. At 1920
|
||||
* both stay open and the conversation keeps at least 560 px.
|
||||
* 7. At 390 px the panel is a dialog sheet opened from the top bar's
|
||||
* toggle; Escape closes it and focus returns to the toggle. The gear
|
||||
* opens a bottom sheet that closes on Escape back to the gear. New
|
||||
* conversation sits above the composer. Nothing scrolls sideways.
|
||||
*
|
||||
* Every Coach route is stubbed; the provider chain and provider config are
|
||||
* stubbed too, so the model section renders the same on every machine.
|
||||
@@ -174,9 +178,6 @@ async function mockCoach(page: Page): Promise<CoachMock> {
|
||||
await page.route("**/api/coach/about-me/questions*", (route) =>
|
||||
fulfilJson(route, { questions: [] }),
|
||||
);
|
||||
await page.route("**/api/insights/coach/seeded-question*", (route) =>
|
||||
fulfilJson(route, { signal: null }),
|
||||
);
|
||||
await page.route("**/api/insights/provider-chain", (route) =>
|
||||
route.request().method() === "GET"
|
||||
? fulfilJson(route, {
|
||||
@@ -213,6 +214,11 @@ async function mockCoach(page: Page): Promise<CoachMock> {
|
||||
}
|
||||
|
||||
const toggle = (page: Page) => page.locator('[data-slot="coach-panel-toggle"]');
|
||||
const collapse = (page: Page) =>
|
||||
page.locator('[data-slot="coach-panel-collapse"]');
|
||||
const expand = (page: Page) => page.locator('[data-slot="coach-panel-expand"]');
|
||||
const fab = (page: Page) => page.locator('[data-slot="coach-new-chat-fab"]');
|
||||
const dayPanel = (page: Page) => page.locator('[data-slot="day-panel"]');
|
||||
const panel = (page: Page) =>
|
||||
page.locator('[data-slot="coach-conversations-panel"]');
|
||||
|
||||
@@ -252,9 +258,10 @@ async function expectPanelFlush(page: Page, width: number, height: number) {
|
||||
'[data-slot="coach-conversations-panel"]',
|
||||
)!;
|
||||
const inner = aside.firstElementChild as HTMLElement;
|
||||
const header = aside.querySelector(
|
||||
'[data-slot="coach-conversations-panel-header"]',
|
||||
)!;
|
||||
// Open, the header row; folded, the edge's band.
|
||||
const header =
|
||||
aside.querySelector('[data-slot="coach-conversations-panel-header"]') ??
|
||||
aside.querySelector('[data-slot="coach-panel-expand"]')!.parentElement!;
|
||||
const bar = document.querySelector('[data-slot="top-bar"]')!;
|
||||
const main = document.getElementById("main-content")!;
|
||||
const style = getComputedStyle(inner);
|
||||
@@ -287,68 +294,37 @@ async function expectPanelFlush(page: Page, width: number, height: number) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The toggle is the last item of the top bar (the account menu that follows
|
||||
* it on phones is not rendered from `md`), and its right edge is within
|
||||
* 16 px of the bar's right edge.
|
||||
* New conversation floats in the conversation column: a round 56 px button
|
||||
* with a plus and its name, inside the column (never over a docked panel
|
||||
* right of it) and clear of the composer's field. The panel's header has no
|
||||
* such control.
|
||||
*/
|
||||
async function expectToggleAtBarEnd(page: Page) {
|
||||
const at = await page.evaluate(() => {
|
||||
const bar = document.querySelector('[data-slot="top-bar"]')!;
|
||||
const shown = [...bar.querySelectorAll("*")].filter(
|
||||
(el) => el.getClientRects().length > 0,
|
||||
);
|
||||
const toggle = bar.querySelector('[data-slot="coach-panel-toggle"]')!;
|
||||
const rightmost = Math.max(
|
||||
...shown.map((el) => el.getBoundingClientRect().right),
|
||||
);
|
||||
return {
|
||||
gap:
|
||||
bar.getBoundingClientRect().right -
|
||||
toggle.getBoundingClientRect().right,
|
||||
last: toggle.getBoundingClientRect().right >= rightmost,
|
||||
lastChild:
|
||||
toggle.closest('[data-slot="top-bar-actions"]')?.lastElementChild ===
|
||||
toggle,
|
||||
};
|
||||
});
|
||||
expect(at.last, "nothing in the bar stands right of the toggle").toBe(true);
|
||||
expect(at.lastChild).toBe(true);
|
||||
expect(at.gap).toBeGreaterThanOrEqual(0);
|
||||
expect(at.gap).toBeLessThanOrEqual(16);
|
||||
}
|
||||
|
||||
/**
|
||||
* New chat opens the panel header: the row's first control, left of the
|
||||
* title, a plus glyph with an accessible name. It sits above the list, so no
|
||||
* row ever slides under it.
|
||||
*/
|
||||
async function expectNewChatInHeader(scope: Locator) {
|
||||
const header = scope.locator(
|
||||
'[data-slot="coach-conversations-panel-header"]',
|
||||
);
|
||||
const button = header.locator('[data-slot="coach-panel-new-chat"]');
|
||||
await expect(button).toHaveCount(1);
|
||||
await expect(button.locator("svg.lucide-plus")).toHaveCount(1);
|
||||
await expect(button).toHaveAttribute("aria-label", "New chat");
|
||||
const order = await header.evaluate((el) => {
|
||||
const first = el.firstElementChild as HTMLElement;
|
||||
const heading = el.querySelector("h2") as HTMLElement;
|
||||
return {
|
||||
first: first.dataset.slot,
|
||||
buttonRight: first.getBoundingClientRect().right,
|
||||
headingLeft: heading.getBoundingClientRect().left,
|
||||
};
|
||||
});
|
||||
expect(order.first).toBe("coach-panel-new-chat");
|
||||
expect(order.buttonRight).toBeLessThanOrEqual(order.headingLeft);
|
||||
const buttonBox = (await button.boundingBox())!;
|
||||
const listBox = (await scope
|
||||
.locator('[data-slot="coach-history-list"]')
|
||||
async function expectNewChatFab(page: Page) {
|
||||
await expect(fab(page)).toHaveCount(1);
|
||||
await expect(fab(page)).toHaveAttribute("aria-label", "New conversation");
|
||||
await expect(fab(page).locator("svg.lucide-plus")).toHaveCount(1);
|
||||
await expect(
|
||||
panel(page).locator('[data-slot="coach-panel-new-chat"]'),
|
||||
).toHaveCount(0);
|
||||
const box = (await fab(page).boundingBox())!;
|
||||
expect(Math.round(box.width)).toBe(56);
|
||||
expect(Math.round(box.height)).toBe(56);
|
||||
const main = (await page
|
||||
.locator('[data-slot="coach-page-main"]')
|
||||
.boundingBox())!;
|
||||
expect(
|
||||
buttonBox.y + buttonBox.height,
|
||||
"the button ends above the list",
|
||||
).toBeLessThanOrEqual(listBox.y);
|
||||
expect(box.x).toBeGreaterThanOrEqual(main.x);
|
||||
expect(box.x + box.width).toBeLessThanOrEqual(main.x + main.width);
|
||||
const field = (await page
|
||||
.locator(
|
||||
'[data-slot="coach-page-composer"] [data-slot="coach-input-textarea"]',
|
||||
)
|
||||
.boundingBox())!;
|
||||
const overlaps =
|
||||
box.x < field.x + field.width &&
|
||||
box.x + box.width > field.x &&
|
||||
box.y < field.y + field.height &&
|
||||
box.y + box.height > field.y;
|
||||
expect(overlaps, "the button leaves the composer's field free").toBe(false);
|
||||
}
|
||||
|
||||
/** Rendered heights of the docked panel's header buttons and first row. */
|
||||
@@ -361,7 +337,7 @@ async function panelControlHeights(page: Page) {
|
||||
.boundingBox())!.height,
|
||||
);
|
||||
return {
|
||||
newChat: await height("coach-panel-new-chat"),
|
||||
collapse: await height("coach-panel-collapse"),
|
||||
plans: await height("coach-panel-plans"),
|
||||
gear: await height("coach-settings"),
|
||||
row: await height("coach-history-select"),
|
||||
@@ -446,8 +422,10 @@ test.describe("Coach page frame", () => {
|
||||
// First visit: open.
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "open");
|
||||
await waitForWidth(panel(page), 288);
|
||||
await expect(toggle(page)).toHaveAttribute("aria-expanded", "true");
|
||||
await expect(toggle(page)).toHaveAttribute(
|
||||
// Docked, the fold control lives in the panel; the top bar has none.
|
||||
await expect(toggle(page)).toHaveCount(0);
|
||||
await expect(collapse(page)).toHaveAttribute("aria-expanded", "true");
|
||||
await expect(collapse(page)).toHaveAttribute(
|
||||
"aria-controls",
|
||||
"coach-conversations-panel",
|
||||
);
|
||||
@@ -458,9 +436,7 @@ test.describe("Coach page frame", () => {
|
||||
const aside = (await panel(page).boundingBox())!;
|
||||
expect(aside.x).toBeGreaterThanOrEqual(open.threadX + open.thread);
|
||||
await expectPanelFlush(page, width, 900);
|
||||
// Open, the toggle is the top bar's last item, against the panel.
|
||||
await expectToggleAtBarEnd(page);
|
||||
await expectNewChatInHeader(panel(page));
|
||||
await expectNewChatFab(page);
|
||||
// The bar says where the reader is: the page, then the conversation.
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-top-bar-trail"]'),
|
||||
@@ -468,37 +444,25 @@ test.describe("Coach page frame", () => {
|
||||
await expectNoSidewaysScroll(page);
|
||||
await shot(page, testInfo, `coach-frame-${width}-open`);
|
||||
|
||||
await toggle(page).click();
|
||||
// Folded: a 48 px edge stays, its button takes the focus, and the
|
||||
// top bar ends at the edge.
|
||||
await collapse(page).click();
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "closed");
|
||||
await waitForWidth(panel(page), 0);
|
||||
await expect(toggle(page)).toHaveAttribute("aria-expanded", "false");
|
||||
await waitForWidth(panel(page), 48);
|
||||
await expect(expand(page)).toBeFocused();
|
||||
await expect(expand(page)).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(expand(page)).toHaveAttribute(
|
||||
"aria-label",
|
||||
"Show conversations",
|
||||
);
|
||||
await expect(
|
||||
panel(page).locator('[data-slot="coach-history-select"]'),
|
||||
).toHaveCount(0);
|
||||
await expectPanelFlush(page, width, 900);
|
||||
const closed = await columnWidths(page);
|
||||
// Closed, the panel is entirely off-screen, the top bar runs to the
|
||||
// window's edge and the toggle is still its last item.
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
document
|
||||
.querySelector('[data-slot="top-bar"]')!
|
||||
.getBoundingClientRect().right,
|
||||
),
|
||||
)
|
||||
.toBe(width);
|
||||
expect(
|
||||
await panel(page).evaluate((el) => {
|
||||
const box = el.getBoundingClientRect();
|
||||
return box.width === 0 || box.left >= window.innerWidth;
|
||||
}),
|
||||
"no pixel of the shut panel shows",
|
||||
).toBe(true);
|
||||
await expectToggleAtBarEnd(page);
|
||||
expect(Math.abs(closed.thread - open.thread)).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(closed.composer - open.composer)).toBeLessThanOrEqual(1);
|
||||
// Closed, nothing inside the panel is reachable.
|
||||
await expect(
|
||||
panel(page).locator('[data-slot="coach-history-select"]').first(),
|
||||
).not.toBeInViewport();
|
||||
await expectNewChatFab(page);
|
||||
await expectNoSidewaysScroll(page);
|
||||
await shot(page, testInfo, `coach-frame-${width}-closed`);
|
||||
|
||||
@@ -506,19 +470,118 @@ test.describe("Coach page frame", () => {
|
||||
await openCoach(page);
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "closed");
|
||||
|
||||
// Open again, then Escape from inside closes it, focus to the toggle.
|
||||
await toggle(page).click();
|
||||
// Opened from the keyboard, focus lands on the fold control; Escape
|
||||
// from inside folds it again and focus goes back to the edge.
|
||||
await expand(page).focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "open");
|
||||
await expect(collapse(page)).toBeFocused();
|
||||
await panel(page)
|
||||
.locator('[data-slot="coach-history-select"]')
|
||||
.first()
|
||||
.focus();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "closed");
|
||||
await expect(toggle(page)).toBeFocused();
|
||||
await expect(expand(page)).toBeFocused();
|
||||
});
|
||||
}
|
||||
|
||||
test("1440: the day and the conversations take turns, focus included", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await mockCoach(page);
|
||||
await mockDay(page);
|
||||
const day = "2026-06-01";
|
||||
await page.goto(`/coach?c=frame-bp&day=${day}`, {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-message-thread"]').getByText(ANSWER),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// The day opens docked, right of the conversations, which fold to
|
||||
// their edge for it.
|
||||
await expect(dayPanel(page)).toHaveAttribute("data-shell", "docked");
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "closed");
|
||||
await waitForWidth(panel(page), 48);
|
||||
const rail = (await panel(page).boundingBox())!;
|
||||
const dayBox = (await dayPanel(page).boundingBox())!;
|
||||
expect(rail.x + rail.width).toBeLessThanOrEqual(dayBox.x + 1);
|
||||
expect(Math.round(dayBox.x + dayBox.width)).toBe(1440);
|
||||
await expectNewChatFab(page);
|
||||
expect(
|
||||
(await page.locator('[data-slot="coach-page-main"]').boundingBox())!
|
||||
.width,
|
||||
).toBeGreaterThanOrEqual(540);
|
||||
await expectNoSidewaysScroll(page);
|
||||
await shot(page, testInfo, "coach-frame-1440-day");
|
||||
|
||||
// Opening the conversations folds the day to its edge; focus goes to
|
||||
// the conversations' fold control, the day is out of the URL, and the
|
||||
// conversation stays the same.
|
||||
await expand(page).click();
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "open");
|
||||
await expect(dayPanel(page)).toHaveCount(0);
|
||||
await expect(page.locator('[data-slot="day-rail"]')).toHaveAttribute(
|
||||
"data-day",
|
||||
day,
|
||||
);
|
||||
await expect(collapse(page)).toBeFocused();
|
||||
await expect(page).toHaveURL(/\/coach\?c=frame-bp$/);
|
||||
await expectNewChatFab(page);
|
||||
await shot(page, testInfo, "coach-frame-1440-list");
|
||||
|
||||
// And back: the day's edge opens the day, the conversations fold.
|
||||
await page.locator('[data-slot="day-expand"]').click();
|
||||
await expect(dayPanel(page)).toHaveAttribute("data-shell", "docked");
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "closed");
|
||||
await expect(page).toHaveURL(new RegExp(`c=frame-bp&day=${day}$`));
|
||||
|
||||
// Switching conversations keeps the day open.
|
||||
await expand(page).click();
|
||||
await page.locator('[data-slot="day-expand"]').click();
|
||||
await expect(dayPanel(page)).toBeVisible();
|
||||
await fab(page).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/coach\\?day=${day}$`));
|
||||
await expect(dayPanel(page)).toBeVisible();
|
||||
});
|
||||
|
||||
test("1920: the conversations and the day stay open side by side", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await page.setViewportSize({ width: 1920, height: 1000 });
|
||||
await mockCoach(page);
|
||||
await mockDay(page);
|
||||
await page.goto("/coach?c=frame-bp&day=2026-06-01", {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-message-thread"]').getByText(ANSWER),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
await expect(dayPanel(page)).toHaveAttribute("data-shell", "docked");
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "open");
|
||||
await waitForWidth(panel(page), 288);
|
||||
const list = (await panel(page).boundingBox())!;
|
||||
const dayBox = (await dayPanel(page).boundingBox())!;
|
||||
expect(list.x + list.width).toBeLessThanOrEqual(dayBox.x + 1);
|
||||
expect(
|
||||
(await page.locator('[data-slot="coach-page-main"]').boundingBox())!
|
||||
.width,
|
||||
).toBeGreaterThanOrEqual(560);
|
||||
await expectNewChatFab(page);
|
||||
await expectNoSidewaysScroll(page);
|
||||
await shot(page, testInfo, "coach-frame-1920-both");
|
||||
|
||||
// Folding one leaves the other alone.
|
||||
await collapse(page).click();
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "closed");
|
||||
await expect(dayPanel(page)).toBeVisible();
|
||||
await expand(page).click();
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "open");
|
||||
await expect(dayPanel(page)).toBeVisible();
|
||||
});
|
||||
|
||||
test("1280 fine pointer: compact header buttons and rows", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -526,7 +589,7 @@ test.describe("Coach page frame", () => {
|
||||
await mockCoach(page);
|
||||
await openCoach(page);
|
||||
expect(await panelControlHeights(page)).toEqual({
|
||||
newChat: 28,
|
||||
collapse: 28,
|
||||
plans: 28,
|
||||
gear: 28,
|
||||
row: 36,
|
||||
@@ -546,7 +609,7 @@ test.describe("Coach page frame", () => {
|
||||
await openCoach(page);
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "open");
|
||||
expect(await panelControlHeights(page)).toEqual({
|
||||
newChat: 44,
|
||||
collapse: 44,
|
||||
plans: 44,
|
||||
gear: 44,
|
||||
row: 44,
|
||||
@@ -575,8 +638,7 @@ test.describe("Coach page frame", () => {
|
||||
await expect(page).toHaveURL(/\/coach\?c=frame-sleep$/);
|
||||
await expect(rows.nth(1)).toHaveAttribute("aria-current", "true");
|
||||
|
||||
const newChat = panel(page).locator('[data-slot="coach-panel-new-chat"]');
|
||||
await newChat.click();
|
||||
await fab(page).click();
|
||||
await expect(page).toHaveURL(/\/coach$/);
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-input-textarea"]'),
|
||||
@@ -795,6 +857,13 @@ test.describe("Coach page frame", () => {
|
||||
// Below 1280 px the panel waits to be asked.
|
||||
await expect(panel(page)).toHaveCount(0);
|
||||
await expect(toggle(page)).toHaveAttribute("aria-expanded", "false");
|
||||
// New conversation sits above the composer, clear of its field.
|
||||
await expectNewChatFab(page);
|
||||
const fabBox = (await fab(page).boundingBox())!;
|
||||
const composerBox = (await page
|
||||
.locator('[data-slot="coach-page-composer"]')
|
||||
.boundingBox())!;
|
||||
expect(fabBox.y + fabBox.height).toBeLessThanOrEqual(composerBox.y);
|
||||
await expectNoSidewaysScroll(page);
|
||||
await shot(page, testInfo, "coach-frame-390-thread");
|
||||
|
||||
@@ -802,7 +871,9 @@ test.describe("Coach page frame", () => {
|
||||
const sheet = page.getByRole("dialog", { name: "Conversations" });
|
||||
await expect(sheet).toBeVisible();
|
||||
await expect(toggle(page)).toHaveAttribute("aria-expanded", "true");
|
||||
await expectNewChatInHeader(sheet);
|
||||
await expect(
|
||||
sheet.locator('[data-slot="coach-panel-new-chat"]'),
|
||||
).toHaveCount(0);
|
||||
// The title keeps its whole word beside four header controls.
|
||||
const title = sheet.locator(
|
||||
'[data-slot="coach-conversations-panel-header"] h2',
|
||||
|
||||
+90
-1
@@ -1,4 +1,4 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import type { Page, TestInfo } from "@playwright/test";
|
||||
|
||||
import { mockDay } from "./setup/day-mock";
|
||||
import { expect, test } from "./setup/test";
|
||||
@@ -26,6 +26,8 @@ import { MOBILE_ROUTES_ANALYTE } from "./setup/mobile-routes-fixture";
|
||||
* - the dashboard's today area offers no door; its charts below do.
|
||||
*
|
||||
* Stable data attributes only: `day-panel` (+ `data-shell`), `day-link`,
|
||||
* the header's `day-date-button` and its `day-date-picker` (days keyed by
|
||||
* `data-date-key`, dotted by `data-entries`, and `day-date-today`),
|
||||
* `chart-plot[data-day-links]`, `chart-tooltip-open-day`, `day-prev` /
|
||||
* `day-next` / `day-close`, and the collapsed edge `day-rail` (+ `data-day`)
|
||||
* with its `day-expand`.
|
||||
@@ -33,6 +35,18 @@ import { MOBILE_ROUTES_ANALYTE } from "./setup/mobile-routes-fixture";
|
||||
|
||||
const panel = (page: Page) => page.locator('[data-slot="day-panel"]');
|
||||
|
||||
async function shot(page: Page, testInfo: TestInfo, name: string) {
|
||||
const path = testInfo.outputPath(`${name}.png`);
|
||||
await page.screenshot({ path });
|
||||
await testInfo.attach(name, { path, contentType: "image/png" });
|
||||
const dir = process.env.DAY_SHOTS_DIR;
|
||||
if (dir) {
|
||||
await page.screenshot({
|
||||
path: `${dir}/${name}-${testInfo.project.name}.png`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function isoDaysAgo(days: number): string {
|
||||
const d = new Date(Date.now() - days * 86_400_000);
|
||||
return d.toISOString().slice(0, 10);
|
||||
@@ -189,6 +203,81 @@ test.describe("the day view", () => {
|
||||
await expect(rail).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("the date opens a calendar; a picked day opens, Today goes to today", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const desktop = testInfo.project.name === "chromium-desktop";
|
||||
// The days of the month that hold anything carry a dot.
|
||||
await mockDay(page, { index: "full" });
|
||||
const day = isoDaysAgo(5);
|
||||
await page.goto(`/mood?day=${day}`);
|
||||
await expect(panel(page)).toHaveAttribute(
|
||||
"data-shell",
|
||||
desktop ? "docked" : "bottom",
|
||||
);
|
||||
const date = panel(page).locator('[data-slot="day-date-button"]');
|
||||
const picker = page.locator('[data-slot="day-date-picker"]');
|
||||
// The date stays one line in the header, the arrows beside it.
|
||||
await expect(date).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(panel(page).locator('[data-slot="day-prev"]')).toBeVisible();
|
||||
await shot(page, testInfo, "day-date-closed");
|
||||
|
||||
await date.click();
|
||||
await expect(picker).toBeVisible();
|
||||
await expect(date).toHaveAttribute("aria-expanded", "true");
|
||||
await expect(picker.locator(`[data-date-key="${day}"]`)).toHaveAttribute(
|
||||
"data-selected-single",
|
||||
"true",
|
||||
);
|
||||
await expect(picker.locator('[data-entries="true"]').first()).toBeVisible();
|
||||
// Nothing past today can be picked.
|
||||
const tomorrow = picker.locator(`[data-date-key="${isoDaysAgo(-2)}"]`);
|
||||
if ((await tomorrow.count()) > 0) await expect(tomorrow).toBeDisabled();
|
||||
await shot(page, testInfo, "day-date-open");
|
||||
|
||||
// Another day of the same month: the 1st, or the 2nd when the open day
|
||||
// is the 1st itself.
|
||||
const first = `${day.slice(0, 8)}01`;
|
||||
const target = first === day ? isoDaysAgo(4) : first;
|
||||
const start = await page.evaluate(() => window.history.length);
|
||||
await picker.locator(`[data-date-key="${target}"]`).click();
|
||||
await expect(picker).toHaveCount(0);
|
||||
await expect(page.locator('[data-slot="day-view"]')).toHaveAttribute(
|
||||
"data-day",
|
||||
target,
|
||||
);
|
||||
await expect(page).toHaveURL(new RegExp(`[?&]day=${target}`));
|
||||
// Like a step: no new history entry, and focus back on the date.
|
||||
expect(await page.evaluate(() => window.history.length)).toBe(start);
|
||||
await expect(date).toBeFocused();
|
||||
|
||||
// The keyboard: Enter opens, Escape closes the calendar only.
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(picker).toBeVisible();
|
||||
// The calendar has the keyboard before Escape is pressed.
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
document.activeElement?.closest(
|
||||
'[role="dialog"]:not([data-slot="day-panel"])',
|
||||
) != null,
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(picker).toHaveCount(0);
|
||||
await expect(panel(page)).toBeVisible();
|
||||
await expect(date).toBeFocused();
|
||||
|
||||
// Today.
|
||||
await page.keyboard.press("Space");
|
||||
await expect(picker).toBeVisible();
|
||||
await picker.locator('[data-slot="day-date-today"]').click();
|
||||
await expect(picker).toHaveCount(0);
|
||||
await expect(panel(page).locator('[data-slot="day-next"]')).toBeDisabled();
|
||||
});
|
||||
|
||||
test("a sheet closes for good and leaves no edge behind", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
|
||||
@@ -427,7 +427,7 @@ test.describe("document vault — AI assist + content search", () => {
|
||||
// The PDF is refused locally (tesseract reads images only) — the error
|
||||
// shows and the server suggest route is never called.
|
||||
await expect(
|
||||
sheet.getByText("Local OCR reads images only — not this file."),
|
||||
sheet.getByText("Local OCR reads images only, not this file."),
|
||||
).toBeVisible();
|
||||
await page.waitForTimeout(300);
|
||||
expect(suggestCalls).toBe(0);
|
||||
|
||||
@@ -771,7 +771,7 @@ test.describe("document vault", () => {
|
||||
|
||||
await uploadViaPicker(page, `${marker}-copy.pdf`, bytes);
|
||||
await expect(
|
||||
page.getByText("Already stored — highlighting the existing document."),
|
||||
page.getByText("Already stored. The existing document is highlighted."),
|
||||
).toBeVisible();
|
||||
// One row, not two — the copy never landed.
|
||||
await expect(openButton(page, `${marker}-copy.pdf`)).not.toBeVisible();
|
||||
|
||||
+127
-137
@@ -206,7 +206,7 @@
|
||||
"colExpected": "Erwartet",
|
||||
"colComplianceRate": "Therapietreue",
|
||||
"moodTitle": "Stimmungsverlauf",
|
||||
"moodSummary": "Durchschnitt: {avg} / 5,0 · Einträge: {count} · Bereich: {min}–{max}",
|
||||
"moodSummary": "Durchschnitt {avg} / 5,0, {count} Einträge, Bereich {min}–{max}",
|
||||
"colMood": "Stimmung",
|
||||
"colCount": "Anzahl",
|
||||
"colShare": "Anteil",
|
||||
@@ -579,8 +579,8 @@
|
||||
"sleepShort": "Schlaf",
|
||||
"stepsShort": "Schritte",
|
||||
"vo2MaxShort": "VO₂ max",
|
||||
"bpInTargetWindow90": "BD-Ziel · 90 T",
|
||||
"bpInTargetWindowDynamic": "BD-Ziel · {days} T",
|
||||
"bpInTargetWindow90": "BD-Ziel (90 T)",
|
||||
"bpInTargetWindowDynamic": "BD-Ziel ({days} T)",
|
||||
"bpInTargetCollecting": "sammelt noch Daten",
|
||||
"medications": "Medikamente",
|
||||
"quickAddMeasurement": "Messung erfassen",
|
||||
@@ -1336,7 +1336,7 @@
|
||||
"complianceInfoLabel": "Was bedeutet Therapietreue?",
|
||||
"lastIntake": "Letzte Einnahme:",
|
||||
"nextIntake": "Nächste Einnahme:",
|
||||
"nextIntakeOverdue": "Überfällig · {time} — noch nachholbar",
|
||||
"nextIntakeOverdue": "Überfällig seit {time}, noch nachholbar",
|
||||
"dayStreak": "Tage Serie",
|
||||
"dayStreakOne": "Tag Serie",
|
||||
"cycleDueToday": "Heute fällig",
|
||||
@@ -1737,7 +1737,7 @@
|
||||
"empty": "Noch keine Dosis-Steigerungsschritte erfasst. Datierte Dosisänderungen hinzufügen, um den Plan abzubilden.",
|
||||
"youAreHere": "Du bist hier",
|
||||
"doseStep": "{dose} {unit}",
|
||||
"plannedFor": "Geplant · {date}"
|
||||
"plannedFor": "Geplant für {date}"
|
||||
},
|
||||
"injectionSiteBodyOutlineAriaLabel": "Körperumriss",
|
||||
"injectionSitePickerAriaLabel": "Injektionsstelle auswählen",
|
||||
@@ -1788,7 +1788,7 @@
|
||||
"intakeHistoryStatusSkipped": "Übersprungen",
|
||||
"intakeHistorySortByTaken": "Nach Einnahme-Zeitpunkt sortieren",
|
||||
"intakeHistorySortByScheduled": "Nach geplantem Zeitpunkt sortieren",
|
||||
"intakeHistoryPageInfo": "Seite {page} von {total} · {count} Einträge",
|
||||
"intakeHistoryPageInfo": "Seite {page} von {total}, {count} Einträge",
|
||||
"intakeHistoryPrev": "Zurück",
|
||||
"intakeHistoryNext": "Weiter",
|
||||
"glp1": {
|
||||
@@ -1888,7 +1888,7 @@
|
||||
"markAsTaken": "Als genommen markieren",
|
||||
"markAsSkipped": "Als übersprungen markieren",
|
||||
"adHocTag": "außerplanmäßig",
|
||||
"takenDetail": "geplant {planned} · genommen {taken}",
|
||||
"takenDetail": "geplant {planned}, genommen {taken}",
|
||||
"doseTaken": "Dosis: {dose}",
|
||||
"status": {
|
||||
"taken_on_time": "pünktlich",
|
||||
@@ -2594,31 +2594,31 @@
|
||||
"ENV_POLLEN_MAX": "der Pollenflug"
|
||||
},
|
||||
"daily": {
|
||||
"high": "Wenn {behaviour} höher liegt, fällt {outcome} am Folgetag in deinen Daten tendenziell {direction} aus — ein Muster, das Beachtung verdient, keine Ursache.",
|
||||
"moderate": "Wenn {behaviour} höher liegt, scheint {outcome} am Folgetag nach bisheriger Datenlage eher {direction} auszufallen — ein Muster, das Beachtung verdient, keine Ursache.",
|
||||
"faint": "Wenn {behaviour} höher liegt, gibt es in deinen Daten allenfalls einen schwachen Hinweis darauf, dass {outcome} am Folgetag {direction} ausfällt — zu gering, um sich darauf zu verlassen, niemals eine Ursache."
|
||||
"high": "Wenn {behaviour} höher liegt, fällt {outcome} am Folgetag in deinen Daten tendenziell {direction} aus. Das ist ein Muster, das Beachtung verdient, keine Ursache.",
|
||||
"moderate": "Wenn {behaviour} höher liegt, scheint {outcome} am Folgetag nach bisheriger Datenlage eher {direction} auszufallen. Das ist ein Muster, das Beachtung verdient, keine Ursache.",
|
||||
"faint": "Wenn {behaviour} höher liegt, gibt es in deinen Daten allenfalls einen schwachen Hinweis darauf, dass {outcome} am Folgetag {direction} ausfällt. Das ist zu gering, um sich darauf zu verlassen, und niemals eine Ursache."
|
||||
},
|
||||
"lab": {
|
||||
"default": "Höhere {lab}-Werte gehen in deinen Daten damit einher, dass {outcome} im selben Zeitraum {direction} ausfällt — ein Zusammenhang, den du ärztlich im Blick behalten solltest, niemals eine Ursache.",
|
||||
"faint": "Höhere {lab}-Werte deuten in deinen Daten allenfalls schwach darauf hin, dass {outcome} im selben Zeitraum {direction} ausfällt — zu gering, um sich darauf zu verlassen, ein Zusammenhang, den du ärztlich abklären lassen solltest, niemals eine Ursache."
|
||||
"default": "Höhere {lab}-Werte gehen in deinen Daten damit einher, dass {outcome} im selben Zeitraum {direction} ausfällt, ein Zusammenhang, den du ärztlich im Blick behalten solltest, niemals eine Ursache.",
|
||||
"faint": "Höhere {lab}-Werte deuten in deinen Daten allenfalls schwach darauf hin, dass {outcome} im selben Zeitraum {direction} ausfällt. Das ist zu gering, um sich darauf zu verlassen, und niemals eine Ursache, aber ärztlich abklärenswert."
|
||||
},
|
||||
"hypothesis": {
|
||||
"bpCompliance": {
|
||||
"support": "Höhere Medikamententreue geht mit niedrigeren systolischen Werten einher — ein Muster, das Beachtung verdient.",
|
||||
"surprising": "Medikamententreue und systolischer Wert bewegen sich in deinen Daten gemeinsam — überraschend; sprich mit deiner Ärztin oder deinem Arzt, bevor du etwas änderst.",
|
||||
"support": "Höhere Medikamententreue geht mit niedrigeren systolischen Werten einher, ein Muster, das Beachtung verdient.",
|
||||
"surprising": "Medikamententreue und systolischer Wert bewegen sich in deinen Daten gemeinsam, das ist überraschend. Sprich mit deiner Ärztin oder deinem Arzt, bevor du etwas änderst.",
|
||||
"none": "Medikamententreue und systolischer Wert bewegen sich in diesem Zeitraum nicht deutlich gemeinsam."
|
||||
},
|
||||
"moodPulse": {
|
||||
"support": "Tage mit schlechterer Stimmung gehen mit höherem Ruhepuls einher — ein Muster, das Beachtung verdient.",
|
||||
"surprising": "Tage mit besserer Stimmung gehen in deinen Daten mit höherem Ruhepuls einher — überraschend.",
|
||||
"support": "Tage mit schlechterer Stimmung gehen mit höherem Ruhepuls einher, ein Muster, das Beachtung verdient.",
|
||||
"surprising": "Tage mit besserer Stimmung gehen in deinen Daten mit höherem Ruhepuls einher, das ist überraschend.",
|
||||
"none": "Stimmung und Ruhepuls bewegen sich in diesem Zeitraum nicht deutlich gemeinsam."
|
||||
},
|
||||
"weightWeekday": "Das Gewicht am {weekday} liegt {delta} kg {direction} deinem Durchschnitt der übrigen Tage — ein Muster, das Beachtung verdient."
|
||||
"weightWeekday": "Das Gewicht am {weekday} liegt {delta} kg {direction} deinem Durchschnitt der übrigen Tage, ein Muster, das Beachtung verdient."
|
||||
},
|
||||
"dailySameDay": {
|
||||
"high": "Mehr {behaviour} am selben Tag und am Vortag geht in deinen Daten tendenziell mit {direction} {outcome} einher — ein Muster, das sich zu beobachten lohnt, keine Ursache.",
|
||||
"moderate": "Mehr {behaviour} am selben Tag und am Vortag scheint in deinen Daten nach bisheriger Lage mit {direction} {outcome} einherzugehen — ein Muster, das sich zu beobachten lohnt, keine Ursache.",
|
||||
"faint": "Mehr {behaviour} am selben Tag und am Vortag zeigt in deinen Daten allenfalls einen schwachen Hinweis auf {direction} {outcome} — zu klein, um sich darauf zu stützen, nie eine Ursache."
|
||||
"high": "Mehr {behaviour} am selben Tag und am Vortag geht in deinen Daten tendenziell mit {direction} {outcome} einher. Das ist ein Muster, das sich zu beobachten lohnt, keine Ursache.",
|
||||
"moderate": "Mehr {behaviour} am selben Tag und am Vortag scheint in deinen Daten nach bisheriger Lage mit {direction} {outcome} einherzugehen. Das ist ein Muster, das sich zu beobachten lohnt, keine Ursache.",
|
||||
"faint": "Mehr {behaviour} am selben Tag und am Vortag zeigt in deinen Daten allenfalls einen schwachen Hinweis auf {direction} {outcome}. Das ist zu klein, um sich darauf zu stützen, und nie eine Ursache."
|
||||
}
|
||||
},
|
||||
"emptyTitle": "Noch nicht genug Daten",
|
||||
@@ -2730,7 +2730,7 @@
|
||||
"referenceSleep": "Mindestens {low} Minuten pro Nacht; längerer Schlaf wird nicht abgewertet",
|
||||
"referenceAdiposity": "Taille-zu-Körpergröße-Verhältnis unter {high}",
|
||||
"referenceHba1c": "HbA1c {low} bis {high} %",
|
||||
"referenceDetail": "{label} · {source} · {kind} · {low} bis {high}",
|
||||
"referenceDetail": "{label}: {low} bis {high} ({kind}, {source})",
|
||||
"openBound": "offen",
|
||||
"laboratoryReferenceSource": "Referenzbereiche des auswertenden Labors",
|
||||
"personalTargetSource": "Persönliches Ziel",
|
||||
@@ -2824,7 +2824,7 @@
|
||||
"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}",
|
||||
"sourceChip": "basierend auf {n} gepaarten Messungen ({window})",
|
||||
"sourceWindowLast30": "letzte 30 Tage",
|
||||
"confidenceHigh": "Hohe Konfidenz",
|
||||
"confidenceModerate": "Mittlere Konfidenz",
|
||||
@@ -3003,23 +3003,23 @@
|
||||
"cluster": {
|
||||
"cardio": {
|
||||
"label": "Herz-Kreislauf",
|
||||
"hint": "Blutdruck · Puls · HRV · Ruhe- & Geh-Herzfrequenz"
|
||||
"hint": "Blutdruck, Puls, HRV, Ruhe- und Geh-Herzfrequenz"
|
||||
},
|
||||
"body": {
|
||||
"label": "Körperzusammensetzung",
|
||||
"hint": "Gewicht · Körperfett · Muskeln · BMI"
|
||||
"hint": "Gewicht, Körperfett, Muskeln und BMI"
|
||||
},
|
||||
"activity": {
|
||||
"label": "Aktivität",
|
||||
"hint": "Schritte · Energie · Distanz · VO₂max"
|
||||
"hint": "Schritte, Energie, Distanz und VO₂max"
|
||||
},
|
||||
"workouts": {
|
||||
"label": "Workouts",
|
||||
"hint": "Läufe · Radtouren · Einheiten"
|
||||
"hint": "Läufe, Radtouren und Einheiten"
|
||||
},
|
||||
"sleep": {
|
||||
"label": "Schlaf",
|
||||
"hint": "Dauer · Phasen"
|
||||
"hint": "Dauer und Phasen"
|
||||
},
|
||||
"mood": {
|
||||
"label": "Stimmung",
|
||||
@@ -3031,15 +3031,15 @@
|
||||
},
|
||||
"medication": {
|
||||
"label": "Medikation",
|
||||
"hint": "Therapietreue · GLP-1-Kontext"
|
||||
"hint": "Therapietreue und GLP-1-Kontext"
|
||||
},
|
||||
"mobility": {
|
||||
"label": "Mobilität & Gang",
|
||||
"hint": "Stabilität · Asymmetrie · Gangbild"
|
||||
"hint": "Stabilität, Asymmetrie und Gangbild"
|
||||
},
|
||||
"environment": {
|
||||
"label": "Umgebung",
|
||||
"hint": "Lärm · Tageslicht · Hauttemperatur"
|
||||
"hint": "Lärm, Tageslicht und Hauttemperatur"
|
||||
}
|
||||
},
|
||||
"settingsClustersLabel": "Daten, die der Coach sieht",
|
||||
@@ -3092,21 +3092,7 @@
|
||||
"driver": "{note}"
|
||||
},
|
||||
"scope": {
|
||||
"prefix": "Der Coach ist schon bei {metric}",
|
||||
"tap": "Zum Fragen tippen",
|
||||
"question": "Erkläre mir diese Kennzahl — worauf sollte ich achten?"
|
||||
},
|
||||
"seeded": {
|
||||
"prefix": "Heute einen Blick wert: {signal}",
|
||||
"signal": {
|
||||
"readiness": "Bereitschaft",
|
||||
"recovery": "Erholung"
|
||||
},
|
||||
"question": {
|
||||
"readiness": "Meine Bereitschaft ist heute auffällig — woran liegt das?",
|
||||
"recovery": "Meine Erholung hat sich verändert — was steckt dahinter?"
|
||||
},
|
||||
"dismiss": "Ausblenden"
|
||||
"prefix": "Der Coach ist schon bei {metric}"
|
||||
},
|
||||
"settings": "Einstellungen",
|
||||
"history": {
|
||||
@@ -3227,9 +3213,9 @@
|
||||
"cap": "Viele Schritte, fasst jetzt zusammen",
|
||||
"noProgress": "Keine neuen Daten mehr, fasst zusammen"
|
||||
},
|
||||
"thoughtProcessOne": "Denkprozess · {count} Schritt",
|
||||
"thoughtProcessFew": "Denkprozess · {count} Schritte",
|
||||
"thoughtProcessOther": "Denkprozess · {count} Schritte",
|
||||
"thoughtProcessOne": "Denkprozess ({count} Schritt)",
|
||||
"thoughtProcessFew": "Denkprozess ({count} Schritte)",
|
||||
"thoughtProcessOther": "Denkprozess ({count} Schritte)",
|
||||
"awaitingAnswer": "Antwort nötig"
|
||||
},
|
||||
"memory": {
|
||||
@@ -3269,7 +3255,8 @@
|
||||
"capped": "(vom Admin begrenzt)",
|
||||
"disabled": "Der Admin hat gründliches Denken abgeschaltet."
|
||||
},
|
||||
"memoryLink": "Gedächtnis ({count})"
|
||||
"memoryLink": "Gedächtnis ({count})",
|
||||
"newConversation": "Neue Unterhaltung"
|
||||
},
|
||||
"relativeJustNow": "gerade eben",
|
||||
"relativeMinutesAgoOne": "vor {count} Minute",
|
||||
@@ -4062,7 +4049,7 @@
|
||||
"distributionDaysUnit": "Tage",
|
||||
"weekdayTitle": "Durchschnitt nach Wochentag",
|
||||
"weekdayNoData": "Keine Daten",
|
||||
"weekdayBestWorst": "Bester Tag: {best} · Schwerster Tag: {worst}",
|
||||
"weekdayBestWorst": "Bester Tag: {best}, schwerster Tag: {worst}",
|
||||
"tagsTitle": "Tags und Auslöser",
|
||||
"tagAvgMood": "Durchschnittliche Stimmung mit diesem Tag",
|
||||
"correlationsTitle": "Stimmung und andere Werte",
|
||||
@@ -4078,7 +4065,7 @@
|
||||
"strengthModerate": "Moderat",
|
||||
"strengthWeak": "Schwach",
|
||||
"strengthNone": "Keine",
|
||||
"source": "{n} gepaarte Tage · r = {r}",
|
||||
"source": "{n} gepaarte Tage, r = {r}",
|
||||
"emptyTitle": "Noch nicht genug gepaarte Daten",
|
||||
"emptyDescription": "Erfasse Stimmung und diesen Wert an denselben Tagen, um einen Zusammenhang zu sehen.",
|
||||
"pendingDescription": "{n} von {needed} gepaarten Tagen. Ein Zusammenhang erscheint, sobald genug Tage da sind, um Muster von Zufall zu unterscheiden.",
|
||||
@@ -4137,7 +4124,7 @@
|
||||
"crosstab": {
|
||||
"title": "Tags und deine Gesundheitswerte",
|
||||
"description": "Wie sich ein Gesundheitswert an Tagen mit einem Tag von Tagen ohne diesen Tag unterscheidet.",
|
||||
"pairLabel": "{tag} · {metric}",
|
||||
"pairLabel": "{metric} an Tagen mit {tag}",
|
||||
"metricActiveEnergy": "Aktive Energie",
|
||||
"metricSleepDuration": "Schlaf",
|
||||
"metricNextDayRecovery": "Erholung am Folgetag",
|
||||
@@ -4154,7 +4141,7 @@
|
||||
"factorCrosstab": {
|
||||
"title": "Deine bewerteten Faktoren und deine Vitalwerte",
|
||||
"description": "Wie sich ein Vitalwert an den Tagen unterscheidet, an denen du einen Faktor niedrig bewertet hast, verglichen mit den Tagen mit hoher Bewertung.",
|
||||
"pairLabel": "{factor} · {metric}",
|
||||
"pairLabel": "{metric} an Tagen mit {factor}",
|
||||
"metricSleepDuration": "Schlaf",
|
||||
"metricSteps": "Schritte",
|
||||
"metricRestingHeartRate": "Ruhepuls",
|
||||
@@ -4180,7 +4167,7 @@
|
||||
"moodDown": "Höheres {factor} geht in deinen Daten tendenziell mit schlechterer Stimmung am Folgetag einher.",
|
||||
"outcomeUp": "Bessere Stimmung geht in deinen Daten tendenziell mit höherem {factor} am Folgetag einher.",
|
||||
"outcomeDown": "Bessere Stimmung geht in deinen Daten tendenziell mit niedrigerem {factor} am Folgetag einher.",
|
||||
"stat": "{n} gepaarte Tage · r = {r} · q = {q}",
|
||||
"stat": "{n} gepaarte Tage, r = {r}, q = {q}",
|
||||
"statLabel": "Wie das berechnet wurde",
|
||||
"explainerLabel": "Wie das berechnet wurde"
|
||||
},
|
||||
@@ -4224,11 +4211,11 @@
|
||||
},
|
||||
"derived": {
|
||||
"coverage": {
|
||||
"summary": "{present} von {required} Eingaben · {percent}%",
|
||||
"summary": "{present} von {required} Eingaben ({percent}%)",
|
||||
"ratioLabel": "{present}/{required}",
|
||||
"historyDays": "Basiert auf {count} Tagen Verlauf",
|
||||
"missing": "Erfasse diese, um den Wert zu schärfen: {list}",
|
||||
"summaryLabelled": "{label} · {percent}%"
|
||||
"summaryLabelled": "{label} ({percent}%)"
|
||||
},
|
||||
"scoreRing": {
|
||||
"aria": "Wert {score} von 100, {band}",
|
||||
@@ -4246,7 +4233,7 @@
|
||||
"inputsLabel": "Eingaben",
|
||||
"noInputs": "Noch keine Eingaben für diesen Wert vorhanden.",
|
||||
"methodLabel": "Methode",
|
||||
"window": "Aus deinen letzten {days} Tagen · {source}-Daten",
|
||||
"window": "Aus deinen letzten {days} Tagen mit {source}-Daten",
|
||||
"asOf": "Stand {date}",
|
||||
"standardLink": "Standard: {name}"
|
||||
},
|
||||
@@ -4288,7 +4275,7 @@
|
||||
"anatomy": {
|
||||
"outOf": "/100",
|
||||
"contributorsLabel": "Faktoren",
|
||||
"contributorValue": "{value} · {weight}% Gewicht",
|
||||
"contributorValue": "{value}, Gewichtung {weight}%",
|
||||
"contributorMissing": "keine Daten",
|
||||
"insufficient": "Noch nicht genug Daten für eine Bewertung. Tracke weiter, dann erscheint sie hier."
|
||||
},
|
||||
@@ -4756,10 +4743,10 @@
|
||||
"dismiss": "Nicht jetzt"
|
||||
},
|
||||
"bmiHeight": {
|
||||
"coachPrefill": "Ich habe meine Körpergröße noch nicht eingetragen — warum ist der BMI wichtig, und was sollte ich wissen, bevor ich sie eintrage?"
|
||||
"coachPrefill": "Ich habe meine Körpergröße noch nicht eingetragen. Warum ist der BMI wichtig, und was sollte ich wissen, bevor ich sie eintrage?"
|
||||
},
|
||||
"medicationsEmpty": {
|
||||
"coachPrefill": "Ich habe noch keine Medikamente angelegt — was sollte ich wissen, bevor ich hier anfange, meine Therapietreue zu verfolgen?"
|
||||
"coachPrefill": "Ich habe noch keine Medikamente angelegt. Was sollte ich wissen, bevor ich hier anfange, meine Therapietreue zu verfolgen?"
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
@@ -5521,7 +5508,7 @@
|
||||
"dropHint": "oder zum Auswählen klicken",
|
||||
"fileInputLabel": "Eine Apple-Health-export.zip-Datei auswählen",
|
||||
"choose": "Datei auswählen",
|
||||
"idempotencyNote": "Ein erneuter Upload desselben Archivs führt zusammen — es entstehen keine Duplikate. Große Archive werden im Hintergrund verarbeitet.",
|
||||
"idempotencyNote": "Ein erneuter Upload desselben Archivs führt die Daten zusammen, ohne Duplikate. Große Archive werden im Hintergrund verarbeitet.",
|
||||
"uploading": "Wird hochgeladen…",
|
||||
"uploadFailed": "Upload fehlgeschlagen. Bitte erneut versuchen.",
|
||||
"rateLimited": "Zu viele Uploads. Bitte kurz warten und erneut versuchen.",
|
||||
@@ -5530,7 +5517,7 @@
|
||||
"failureInterrupted": "Der Import wurde unterbrochen, bevor er fertig war, meist durch einen Server-Neustart oder Speichermangel. Die Daten sind sicher; das Archiv einfach erneut hochladen.",
|
||||
"failureInsufficientMemory": "Das Speicherlimit des Servers ist für ein Archiv dieser Größe zu niedrig. Bitte die Betreiberin oder den Betreiber das Node.js-Heap-Limit erhöhen lassen (NODE_OPTIONS=--max-old-space-size) und das Archiv danach erneut hochladen.",
|
||||
"rowsImported": "Bisher {count} Einträge importiert",
|
||||
"doneSummary": "Fertig — {imported} von {read} Datensätzen importiert, {skipped} klinische Datensätze übersprungen.",
|
||||
"doneSummary": "Fertig: {imported} von {read} Datensätzen importiert, {skipped} klinische Datensätze übersprungen.",
|
||||
"doneNothing": "Aus diesem Archiv wurde nichts importiert: 0 von {read} Datensätzen sind angekommen.",
|
||||
"writtenByHealthLogOne": "{count} Eintrag, den HealthLog selbst in Apple Health geschrieben hat, wurde ausgelassen, weil er schon in deinen Daten ist.",
|
||||
"writtenByHealthLogOther": "{count} Einträge, die HealthLog selbst in Apple Health geschrieben hat, wurden ausgelassen, weil sie schon in deinen Daten sind.",
|
||||
@@ -5543,12 +5530,12 @@
|
||||
"parsing": "Export wird gelesen…",
|
||||
"upserting": "Datensätze werden importiert…"
|
||||
},
|
||||
"skipUnsupported": "{count} Samples übersprungen — Datentypen, die HealthLog nicht importiert.",
|
||||
"skipUnsupported": "{count} Samples übersprungen: Datentypen, die HealthLog nicht importiert.",
|
||||
"skipRefused": "{count} Samples konnten nicht geschrieben werden (unlesbar, außerhalb des Wertebereichs oder Speichern fehlgeschlagen).",
|
||||
"skipBreakdown": "Aufschlüsselung anzeigen ({count} Typen)",
|
||||
"skipBreakdownMore": "+ {count} weitere Typen",
|
||||
"cycleSummary": "{samples} Zyklus-Samples zu {days} Tagesprotokollen zusammengeführt.",
|
||||
"cycleModuleOff": "{count} Zyklus-Samples übersprungen — Zyklus-Tracking ist für dieses Konto deaktiviert.",
|
||||
"cycleModuleOff": "{count} Zyklus-Samples übersprungen, weil Zyklus-Tracking für dieses Konto deaktiviert ist.",
|
||||
"cycleDaysFailed": "{count} Zyklustage konnten nicht geschrieben werden: {reason}"
|
||||
},
|
||||
"json": {
|
||||
@@ -5633,8 +5620,8 @@
|
||||
"failed": "Der Import ist fehlgeschlagen. Es wurde nichts geschrieben.",
|
||||
"columnsHeading": "Was mit jeder Spalte passiert",
|
||||
"jsonOnly": "nur JSON",
|
||||
"previewLine": "Zeilen gelesen: {rows} · würde importieren: {queued} · abgewiesen: {refused}",
|
||||
"submittedLine": "Zeilen gelesen: {rows} · zum Import eingereiht: {queued}",
|
||||
"previewLine": "{rows} Zeilen gelesen, {queued} würden importiert, {refused} abgewiesen",
|
||||
"submittedLine": "{rows} Zeilen gelesen, {queued} zum Import eingereiht",
|
||||
"unmatched": "Kein Medikament im Bestand passt zu: {names}",
|
||||
"ambiguous": "Passt auf mehr als ein Medikament: {names}",
|
||||
"mirrored": "Aus einer anderen App gespiegelt, daher keine Dosis ergänzbar: {names}",
|
||||
@@ -5799,19 +5786,20 @@
|
||||
"storedDays": "{count} Tage Wetter gespeichert.",
|
||||
"home": {
|
||||
"title": "Heimatort",
|
||||
"description": "Ein grober Standort auf Stadtebene — niemals eine genaue Spur. Wetter und Tageslicht werden für diesen Ort an Tagen ohne Ortszeitraum abgerufen, ab dem Tag der Festlegung.",
|
||||
"description": "Ein grober Standort auf Stadtebene, niemals eine genaue Spur. Wetter und Tageslicht werden für diesen Ort an Tagen ohne Ortszeitraum abgerufen, ab dem Tag der Festlegung.",
|
||||
"current": "Aktuell: {label}",
|
||||
"none": "Noch kein Heimatort festgelegt.",
|
||||
"since": "Wetter wird ab {date} erfasst. Frühere Tage bleiben leer, bis du einen Ortszeitraum hinzufügst."
|
||||
},
|
||||
"travel": {
|
||||
"title": "Ortszeiträume",
|
||||
"description": "Füge einen Ort und einen Zeitraum hinzu — eine Reise oder einen früheren Abschnitt deiner Verlaufsdaten. Der Zeitraum überschreibt für seine Tage deinen Wohnort, damit das Wetter zu deinem tatsächlichen Aufenthalt passt. So korrigierst du die Vergangenheit.",
|
||||
"description": "Füge einen Ort und einen Zeitraum hinzu, etwa eine Reise oder einen früheren Abschnitt deiner Verlaufsdaten. Der Zeitraum überschreibt für seine Tage deinen Wohnort, damit das Wetter zu deinem tatsächlichen Aufenthalt passt. So korrigierst du die Vergangenheit.",
|
||||
"none": "Noch keine Ortszeiträume.",
|
||||
"add": "{label} hinzufügen",
|
||||
"remove": "Ortszeitraum entfernen",
|
||||
"removeConfirmTitle": "Diese Reise entfernen?",
|
||||
"removeConfirmBody": "Messwerte aus diesem Zeitraum bleiben erhalten; nur der Reise-Eintrag verschwindet."
|
||||
"removeConfirmBody": "Messwerte aus diesem Zeitraum bleiben erhalten; nur der Reise-Eintrag verschwindet.",
|
||||
"range": "{start} bis {end}"
|
||||
},
|
||||
"backfill": {
|
||||
"title": "Vergangene Tage nacherfassen",
|
||||
@@ -6520,7 +6508,7 @@
|
||||
},
|
||||
"integrationFreshness": {
|
||||
"title": "Empfangene Daten",
|
||||
"summary": "{count} Datentypen · neuestes {relative}",
|
||||
"summary": "{count} Datentypen, neuestes {relative}",
|
||||
"quiet": "{count} still",
|
||||
"workouts": "Workouts"
|
||||
},
|
||||
@@ -7065,7 +7053,7 @@
|
||||
},
|
||||
"appleHealth": {
|
||||
"title": "Apple Health",
|
||||
"description": "Die Live-Synchronisierung erfolgt über die HealthLog-iOS-App — nicht über eine Web- oder OAuth-Verbindung.",
|
||||
"description": "Die Live-Synchronisierung erfolgt über die HealthLog-iOS-App, nicht über eine Web- oder OAuth-Verbindung.",
|
||||
"status": {
|
||||
"unavailable": "Apple-Health-Status nicht verfügbar"
|
||||
},
|
||||
@@ -7079,14 +7067,14 @@
|
||||
"title": "Sync-Fortschritt",
|
||||
"receivedLabel": "Empfangene Datensätze",
|
||||
"oldestLabel": "Älteste erreichte Messung",
|
||||
"flowing": "In der letzten Stunde sind Daten angekommen — die iPhone-App liefert weiter.",
|
||||
"waiting": "Warten auf weitere Daten von der iPhone-App. Die Übertragung läuft weiter, wenn die App geöffnet ist oder iOS ihr Hintergrundzeit gewährt — eine ruhige Phase während des ersten großen Abgleichs ist normal, kein Fehler."
|
||||
"flowing": "In der letzten Stunde sind Daten angekommen. Die iPhone-App liefert weiter.",
|
||||
"waiting": "Warten auf weitere Daten von der iPhone-App. Die Übertragung läuft weiter, wenn die App geöffnet ist oder iOS ihr Hintergrundzeit gewährt. Eine ruhige Phase während des ersten großen Abgleichs ist normal, kein Fehler."
|
||||
},
|
||||
"delivery": {
|
||||
"title": "Übermittlung",
|
||||
"lastTriggerLabel": "Letzte Synchronisierung kam",
|
||||
"lastBackgroundLabel": "Letzte Hintergrund-Synchronisierung",
|
||||
"backgroundNever": "Nie — bisher kam jede Synchronisierung bei geöffneter App an",
|
||||
"backgroundNever": "Nie: bisher kam jede Synchronisierung bei geöffneter App an",
|
||||
"noSyncYet": "Es ist noch keine Synchronisierung angekommen, daher gibt es hier nichts zu berichten.",
|
||||
"trigger": {
|
||||
"foreground": "bei geöffneter App",
|
||||
@@ -8086,19 +8074,19 @@
|
||||
"description": "Nimm an einem Tag alle Medikamente im Zeitfenster ein."
|
||||
},
|
||||
"compliance801": {
|
||||
"title": "Treue 80% · 1T",
|
||||
"title": "80% Treue für 1 Tag",
|
||||
"description": "Erreiche an einem Tag eine 30-Tage-Therapietreue von mindestens 80%."
|
||||
},
|
||||
"bmiGreen1": {
|
||||
"title": "BMI im Grün · 1T",
|
||||
"title": "BMI 1 Tag im Grün",
|
||||
"description": "Halte deinen BMI an einem Tag im Normalbereich."
|
||||
},
|
||||
"bpGreen1": {
|
||||
"title": "Blutdruck im Grün · 1T",
|
||||
"title": "Blutdruck 1 Tag im Grün",
|
||||
"description": "Halte deinen Blutdruck an einem Tag im Normalbereich."
|
||||
},
|
||||
"pulseGreen1": {
|
||||
"title": "Puls im Grün · 1T",
|
||||
"title": "Puls 1 Tag im Grün",
|
||||
"description": "Halte deinen Ruhepuls an einem Tag im Normalbereich."
|
||||
},
|
||||
"onTimePerfect7": {
|
||||
@@ -8106,19 +8094,19 @@
|
||||
"description": "Nimm 7 Tage in Folge alle Medikamente im Zeitfenster ein."
|
||||
},
|
||||
"compliance807": {
|
||||
"title": "Treue 80% · 7T",
|
||||
"title": "80% Treue für 7 Tage",
|
||||
"description": "Erreiche 7 Tage in Folge eine 30-Tage-Therapietreue von mindestens 80%."
|
||||
},
|
||||
"bmiGreen7": {
|
||||
"title": "BMI im Grün · 7T",
|
||||
"title": "BMI 7 Tage im Grün",
|
||||
"description": "Halte deinen BMI 7 Tage in Folge im Normalbereich."
|
||||
},
|
||||
"bpGreen7": {
|
||||
"title": "Blutdruck im Grün · 7T",
|
||||
"title": "Blutdruck 7 Tage im Grün",
|
||||
"description": "Halte deinen Blutdruck 7 Tage in Folge im Normalbereich."
|
||||
},
|
||||
"pulseGreen7": {
|
||||
"title": "Puls im Grün · 7T",
|
||||
"title": "Puls 7 Tage im Grün",
|
||||
"description": "Halte deinen Ruhepuls 7 Tage in Folge im Normalbereich."
|
||||
},
|
||||
"onTimePerfect30": {
|
||||
@@ -8126,19 +8114,19 @@
|
||||
"description": "Nimm 30 Tage in Folge alle Medikamente im Zeitfenster ein."
|
||||
},
|
||||
"compliance8030": {
|
||||
"title": "Treue 80% · 30T",
|
||||
"title": "80% Treue für 30 Tage",
|
||||
"description": "Erreiche 30 Tage in Folge eine 30-Tage-Therapietreue von mindestens 80%."
|
||||
},
|
||||
"bmiGreen30": {
|
||||
"title": "BMI im Grün · 30T",
|
||||
"title": "BMI 30 Tage im Grün",
|
||||
"description": "Halte deinen BMI 30 Tage in Folge im Normalbereich."
|
||||
},
|
||||
"bpGreen30": {
|
||||
"title": "Blutdruck im Grün · 30T",
|
||||
"title": "Blutdruck 30 Tage im Grün",
|
||||
"description": "Halte deinen Blutdruck 30 Tage in Folge im Normalbereich."
|
||||
},
|
||||
"pulseGreen30": {
|
||||
"title": "Puls im Grün · 30T",
|
||||
"title": "Puls 30 Tage im Grün",
|
||||
"description": "Halte deinen Ruhepuls 30 Tage in Folge im Normalbereich."
|
||||
},
|
||||
"onTimePerfect180": {
|
||||
@@ -8146,19 +8134,19 @@
|
||||
"description": "Nimm 180 Tage in Folge alle Medikamente im Zeitfenster ein."
|
||||
},
|
||||
"compliance80180": {
|
||||
"title": "Treue 80% · 180T",
|
||||
"title": "80% Treue für 180 Tage",
|
||||
"description": "Erreiche 180 Tage in Folge eine 30-Tage-Therapietreue von mindestens 80%."
|
||||
},
|
||||
"bmiGreen180": {
|
||||
"title": "BMI im Grün · 180T",
|
||||
"title": "BMI 180 Tage im Grün",
|
||||
"description": "Halte deinen BMI 180 Tage in Folge im Normalbereich."
|
||||
},
|
||||
"bpGreen180": {
|
||||
"title": "Blutdruck im Grün · 180T",
|
||||
"title": "Blutdruck 180 Tage im Grün",
|
||||
"description": "Halte deinen Blutdruck 180 Tage in Folge im Normalbereich."
|
||||
},
|
||||
"pulseGreen180": {
|
||||
"title": "Puls im Grün · 180T",
|
||||
"title": "Puls 180 Tage im Grün",
|
||||
"description": "Halte deinen Ruhepuls 180 Tage in Folge im Normalbereich."
|
||||
},
|
||||
"onTimePerfect360": {
|
||||
@@ -8166,19 +8154,19 @@
|
||||
"description": "Nimm 360 Tage in Folge alle Medikamente im Zeitfenster ein."
|
||||
},
|
||||
"compliance80360": {
|
||||
"title": "Treue 80% · 360T",
|
||||
"title": "80% Treue für 360 Tage",
|
||||
"description": "Erreiche 360 Tage in Folge eine 30-Tage-Therapietreue von mindestens 80%."
|
||||
},
|
||||
"bmiGreen360": {
|
||||
"title": "BMI im Grün · 360T",
|
||||
"title": "BMI 360 Tage im Grün",
|
||||
"description": "Halte deinen BMI 360 Tage in Folge im Normalbereich."
|
||||
},
|
||||
"bpGreen360": {
|
||||
"title": "Blutdruck im Grün · 360T",
|
||||
"title": "Blutdruck 360 Tage im Grün",
|
||||
"description": "Halte deinen Blutdruck 360 Tage in Folge im Normalbereich."
|
||||
},
|
||||
"pulseGreen360": {
|
||||
"title": "Puls im Grün · 360T",
|
||||
"title": "Puls 360 Tage im Grün",
|
||||
"description": "Halte deinen Ruhepuls 360 Tage in Folge im Normalbereich."
|
||||
},
|
||||
"moodFirst": {
|
||||
@@ -8262,15 +8250,15 @@
|
||||
"description": "Du hast die App-Sprache mindestens einmal gewechselt."
|
||||
},
|
||||
"missFree7": {
|
||||
"title": "Lückenlos · 7T",
|
||||
"title": "7 Tage lückenlos",
|
||||
"description": "7 Tage in Folge ohne eine verpasste Dosis."
|
||||
},
|
||||
"missFree30": {
|
||||
"title": "Lückenlos · 30T",
|
||||
"title": "30 Tage lückenlos",
|
||||
"description": "30 Tage in Folge ohne eine verpasste Dosis."
|
||||
},
|
||||
"missFree90": {
|
||||
"title": "Lückenlos · 90T",
|
||||
"title": "90 Tage lückenlos",
|
||||
"description": "90 Tage in Folge ohne eine verpasste Dosis."
|
||||
},
|
||||
"measurementWeeks4": {
|
||||
@@ -8282,7 +8270,7 @@
|
||||
"description": "Alle vier Felder der Selbstauskunft ausgefüllt — der Coach kennt deinen Kontext."
|
||||
},
|
||||
"sleepLog7": {
|
||||
"title": "Schlafprotokoll · 7T",
|
||||
"title": "7 Tage Schlafprotokoll",
|
||||
"description": "An 7 Tagen in Folge Schlafdaten erfasst."
|
||||
}
|
||||
},
|
||||
@@ -8372,9 +8360,6 @@
|
||||
"enabledToast": "Proaktive Coach-Hinweise aktiviert.",
|
||||
"disabledToast": "Proaktive Coach-Hinweise deaktiviert.",
|
||||
"saveError": "Speichern fehlgeschlagen. Bitte später erneut versuchen.",
|
||||
"suggestionsLabel": "Beispielvorschläge",
|
||||
"suggestionsDesc": "Zeigt auf dem Coach-Bildschirm gelegentlich eine fertige Beispielfrage.",
|
||||
"suggestionsAria": "Beispiel-Coach-Vorschläge umschalten",
|
||||
"aiComposedLabel": "Den Coach den Anstoß formulieren lassen",
|
||||
"aiComposedDesc": "Wenn ein Anbieter verfügbar ist, formuliert das Modell den proaktiven Anstoß statt der festen Vorlage. Standardmäßig aus.",
|
||||
"aiComposedAria": "KI-formulierten proaktiven Anstoß umschalten"
|
||||
@@ -8792,7 +8777,7 @@
|
||||
},
|
||||
"crosstab": {
|
||||
"description": "Wie sich jeder Messwert zwischen deiner Luteal- und Follikelphase vergleicht — auf deinen eigenen Daten.",
|
||||
"pairLabel": "{metric} · Luteal vs. Follikel",
|
||||
"pairLabel": "{metric}: Luteal- vs. Follikelphase",
|
||||
"detail": "Luteal {lutealAvg} {unit} ({lutealDays} Tage) vs. Follikel {follicularAvg} {unit} ({follicularDays} Tage).",
|
||||
"metricRestingHeartRate": "Ruhepuls",
|
||||
"metricHrv": "Herzfrequenzvariabilität",
|
||||
@@ -9012,7 +8997,7 @@
|
||||
"temperatureSection": "Temperatur & Eisprung",
|
||||
"intimacySection": "Intimität & Verhütung",
|
||||
"testsSection": "Tests",
|
||||
"phaseDayHeader": "Tag {day} · {phase}",
|
||||
"phaseDayHeader": "Tag {day}, {phase}",
|
||||
"bbtHint": "Miss sie morgens als Erstes, noch vor dem Aufstehen.",
|
||||
"titleForDate": "Eintrag für {date}",
|
||||
"descriptionToday": "Heute. Stärke, Symptome und Notizen.",
|
||||
@@ -9189,10 +9174,10 @@
|
||||
"VISCERAL_FAT": "Viszeralfett",
|
||||
"BODY_MASS_INDEX": "Body-Mass-Index (BMI)",
|
||||
"WAIST_CIRCUMFERENCE": "Taillenumfang",
|
||||
"PHQ9_SCORE": "PHQ-9 · Stimmung",
|
||||
"GAD7_SCORE": "GAD-7 · Sorgen",
|
||||
"WHO5_SCORE": "WHO-5 · Wohlbefinden",
|
||||
"SCI_SCORE": "SCI · Schlaf"
|
||||
"PHQ9_SCORE": "PHQ-9 (Stimmung)",
|
||||
"GAD7_SCORE": "GAD-7 (Sorgen)",
|
||||
"WHO5_SCORE": "WHO-5 (Wohlbefinden)",
|
||||
"SCI_SCORE": "SCI (Schlaf)"
|
||||
},
|
||||
"typeGroups": {
|
||||
"vitals": "Vitalwerte",
|
||||
@@ -9755,7 +9740,7 @@
|
||||
},
|
||||
"empty": {
|
||||
"title": "Noch keine Impfung erfasst",
|
||||
"description": "Übertrage deinen Impfpass Zeile für Zeile — Datum und Impfstoff genügen."
|
||||
"description": "Übertrage deinen Impfpass Zeile für Zeile. Datum und Impfstoff genügen."
|
||||
},
|
||||
"catalog": {
|
||||
"tetanus": "Tetanus (Wundstarrkrampf)",
|
||||
@@ -10289,10 +10274,10 @@
|
||||
"saved": "Gespeichert",
|
||||
"error": "Etwas ist schiefgelaufen. Bitte versuche es erneut.",
|
||||
"instrument": {
|
||||
"phq9": "PHQ-9 · Stimmung",
|
||||
"gad7": "GAD-7 · Sorgen",
|
||||
"who5": "WHO-5 · Wohlbefinden",
|
||||
"sci": "SCI · Schlaf"
|
||||
"phq9": "PHQ-9 (Stimmung)",
|
||||
"gad7": "GAD-7 (Sorgen)",
|
||||
"who5": "WHO-5 (Wohlbefinden)",
|
||||
"sci": "SCI (Schlaf)"
|
||||
},
|
||||
"instrumentSub": {
|
||||
"phq9": "Depressions-Screening, 9 Fragen",
|
||||
@@ -10760,7 +10745,7 @@
|
||||
},
|
||||
"documents": {
|
||||
"title": "Dokumente",
|
||||
"subtitle": "Jeder Brief, Befund und Scan an einem durchsuchbaren Ort — verschlüsselt gespeichert.",
|
||||
"subtitle": "Jeder Brief, Befund und Scan an einem durchsuchbaren Ort, verschlüsselt gespeichert.",
|
||||
"pageUpload": "Hochladen",
|
||||
"ownerOnlyUpload": "Dokumente kann nur die Inhaberin oder der Inhaber der Akte hinzufügen.",
|
||||
"kind": {
|
||||
@@ -10802,15 +10787,15 @@
|
||||
},
|
||||
"error": {
|
||||
"fileTooLarge": "Diese Datei ist größer als das Limit von {maxSize}.",
|
||||
"quotaExceeded": "Der Speicher ist voll — {used} von {quota} belegt.",
|
||||
"quotaExceeded": "Der Speicher ist voll: {used} von {quota} belegt.",
|
||||
"unsupportedType": "Dieser Dateityp wird nicht unterstützt.",
|
||||
"purged": "Dieses Dokument wurde endgültig entfernt und kann nicht wiederhergestellt werden.",
|
||||
"duplicateExists": "Ein identisches Dokument ist bereits gespeichert.",
|
||||
"rateLimited": "Zu viele Uploads — versuche es in ein paar Minuten erneut.",
|
||||
"rateLimited": "Zu viele Uploads. Versuche es in ein paar Minuten erneut.",
|
||||
"generic": "Upload fehlgeschlagen. Bitte erneut versuchen."
|
||||
},
|
||||
"toast": {
|
||||
"duplicate": "Bereits gespeichert — das vorhandene Dokument wird hervorgehoben.",
|
||||
"duplicate": "Bereits gespeichert. Das vorhandene Dokument ist hervorgehoben.",
|
||||
"deleted": "Dokument gelöscht",
|
||||
"restoreFailed": "Das Dokument konnte nicht wiederhergestellt werden."
|
||||
},
|
||||
@@ -10833,7 +10818,7 @@
|
||||
},
|
||||
"empty": {
|
||||
"title": "Dein Dokumenten-Tresor ist leer",
|
||||
"description": "Bewahre Arztbriefe, Laborbefunde, Bildgebung, Rezepte und Impfnachweise auf — verschlüsselt und durchsuchbar nach Typ, Erkrankung und Jahr.",
|
||||
"description": "Bewahre Arztbriefe, Laborbefunde, Bildgebung, Rezepte und Impfnachweise auf. Sie werden verschlüsselt gespeichert und sind nach Typ, Erkrankung und Jahr durchsuchbar.",
|
||||
"action": "Erstes Dokument hochladen",
|
||||
"noMatchesTitle": "Keine passenden Dokumente",
|
||||
"noMatchesDescription": "Nichts entspricht der aktuellen Suche und den Filtern."
|
||||
@@ -10873,9 +10858,9 @@
|
||||
"linkCondition": "Verknüpfen",
|
||||
"noConditions": "Mit keiner Erkrankung verknüpft",
|
||||
"previewUnavailable": "Keine Inline-Vorschau für diesen Dateityp",
|
||||
"uploadedMeta": "Hinzugefügt am {date} · {size}",
|
||||
"uploadedMeta": "Hinzugefügt am {date}, {size}",
|
||||
"importedFrom": "Importiert aus {system}",
|
||||
"importedFromWithId": "Importiert aus {system} · ID {id}",
|
||||
"importedFromWithId": "Importiert aus {system}, ID {id}",
|
||||
"sourceOther": "einem anderen System",
|
||||
"share": "Teilen",
|
||||
"labValuesLink": "{count} Laborwerte ansehen",
|
||||
@@ -10942,7 +10927,7 @@
|
||||
"errorNotIndexed": "Lies das Dokument zuerst aus, dann extrahiere.",
|
||||
"errorLocalOcrDisabled": "Aktiviere zuerst die lokale Texterkennung in den KI-Einstellungen.",
|
||||
"errorProvider": "Dafür ist kein KI-Anbieter eingerichtet.",
|
||||
"errorTextImageOnly": "Lokale Texterkennung liest nur Bilder — nicht diese Datei.",
|
||||
"errorTextImageOnly": "Lokale Texterkennung liest nur Bilder, nicht diese Datei.",
|
||||
"errorConsent": "Für das Lesen von Dokumenten mit KI brauchst du zuerst deine Einwilligung.",
|
||||
"errorUnavailable": "Das Lesen von Dokumenten mit KI ist hier gerade nicht verfügbar."
|
||||
},
|
||||
@@ -10975,7 +10960,7 @@
|
||||
"CONDITION": "Erkrankung",
|
||||
"MEDICATION_STATEMENT": "Medikament"
|
||||
},
|
||||
"statedRange": "angegebener Bereich {range}",
|
||||
"statedRange": "Angegebener Bereich {range}",
|
||||
"needsCheck": "Prüfe diesen Wert gegen das Dokument und speichere ihn dann.",
|
||||
"verify": "Prüfen",
|
||||
"verifySave": "Wert speichern",
|
||||
@@ -11245,14 +11230,14 @@
|
||||
}
|
||||
},
|
||||
"hydration": {
|
||||
"referenceMeta": "Richtwert {value} {unit}/Tag · EFSA"
|
||||
"referenceMeta": "Richtwert {value} {unit}/Tag (EFSA)"
|
||||
},
|
||||
"caffeine": {
|
||||
"ceilingMeta": "Sichere Obergrenze {value} {unit}/Tag · EFSA"
|
||||
"ceilingMeta": "Sichere Obergrenze {value} {unit}/Tag (EFSA)"
|
||||
},
|
||||
"micronutrients": {
|
||||
"title": "Vitamine & Mineralstoffe",
|
||||
"rowMeta": "{days} von {window} Tagen · Richtwert {value} {unit} (EFSA)",
|
||||
"rowMeta": "{days} von {window} Tagen, Richtwert {value} {unit} (EFSA)",
|
||||
"rowMetaNoRef": "{days} von {window} Tagen",
|
||||
"footer": "{tracked} von {total} erfassten Mikronährstoffen haben Daten in diesem Zeitraum.",
|
||||
"emptyTitle": "Noch keine Mikronährstoffdaten synchronisiert",
|
||||
@@ -11267,7 +11252,7 @@
|
||||
},
|
||||
"push": {
|
||||
"title": "Dein Morgenüberblick",
|
||||
"bodyProvisional": "{line} · Der Schlaf von letzter Nacht fehlt noch."
|
||||
"bodyProvisional": "{line}\nDer Schlaf von letzter Nacht fehlt noch."
|
||||
},
|
||||
"item": {
|
||||
"doseWindow": {
|
||||
@@ -11655,7 +11640,7 @@
|
||||
"managed": {
|
||||
"title": "Profile, die du betreust",
|
||||
"description": "Gesundheitsakten ohne eigenen Login.",
|
||||
"explainer": "Ein verwaltetes Profil ist eine Gesundheitsakte für jemanden, der sich nicht selbst anmeldet — ein Kind oder eine Person, um die du dich kümmerst. Es hat keinen Login und keine E-Mail-Adresse, und du wirst die erste betreuende Person. Später kannst du weitere hinzufügen, und ihr alle könnt in die Akte wechseln, um sie zu pflegen.",
|
||||
"explainer": "Ein verwaltetes Profil ist eine Gesundheitsakte für jemanden, der sich nicht selbst anmeldet, etwa ein Kind oder eine Person, um die du dich kümmerst. Es hat keinen Login und keine E-Mail-Adresse, und du wirst die erste betreuende Person. Später kannst du weitere hinzufügen, und ihr alle könnt in die Akte wechseln, um sie zu pflegen.",
|
||||
"nameLabel": "Name",
|
||||
"namePlaceholder": "Der Name, an dem du diese Akte erkennst",
|
||||
"nameHint": "Bis zu 80 Zeichen. Wird überall dort angezeigt, wo diese Akte auftaucht.",
|
||||
@@ -11677,7 +11662,7 @@
|
||||
"creating": "Wird angelegt…",
|
||||
"created": "{name} ist eingerichtet. Über das Kontomenü kannst du in diese Akte wechseln.",
|
||||
"errorStepUp": "Bestätige deinen zweiten Faktor und versuche es erneut.",
|
||||
"errorOffline": "Das hat den Server nicht erreicht. Es hat sich nichts geändert — prüfe deine Verbindung und versuche es erneut.",
|
||||
"errorOffline": "Das hat den Server nicht erreicht. Es hat sich nichts geändert. Prüfe deine Verbindung und versuche es erneut.",
|
||||
"errorInvalid": "Der Server hat diese Angaben abgelehnt. Prüfe Name, Datum und Zeitzone.",
|
||||
"errorFailed": "Das Profil konnte nicht angelegt werden.",
|
||||
"edit": "Bearbeiten",
|
||||
@@ -11688,7 +11673,7 @@
|
||||
"editErrorFailed": "Die Änderungen konnten nicht gespeichert werden.",
|
||||
"delete": "Profil löschen",
|
||||
"deleteTitle": "{name} löschen?",
|
||||
"deleteBody": "Alles, was für {name} erfasst wurde, verschwindet mit dem Profil — Messwerte, Medikamente, Dokumente und Notizen. Das lässt sich nicht rückgängig machen.",
|
||||
"deleteBody": "Alles, was für {name} erfasst wurde, verschwindet mit dem Profil: Messwerte, Medikamente, Dokumente und Notizen. Das lässt sich nicht rückgängig machen.",
|
||||
"deleteGuardiansOne": "{count} Person betreut diese Akte derzeit und verliert sie.",
|
||||
"deleteGuardiansFew": "{count} Personen betreuen diese Akte derzeit und verlieren sie.",
|
||||
"deleteGuardiansOther": "{count} Personen betreuen diese Akte derzeit und verlieren sie.",
|
||||
@@ -11703,7 +11688,7 @@
|
||||
"guardianRemoveBody": "{name} kann {profile} dann nicht mehr öffnen. Was diese Person eingetragen hat, bleibt in der Akte.",
|
||||
"guardianWithdrawBody": "Die Einladung an {name} wird zurückgezogen. Die Person hat {profile} nie geöffnet, in der Akte ändert sich nichts.",
|
||||
"guardianRemoveConfirm": "Zugriff beenden",
|
||||
"soleGuardian": "Du bist die einzige Person, die sich um {name} kümmert — hier gibt es also keinen Zugriff, den du beenden könntest. Lade jemanden ein und warte auf die Annahme, gib deinen eigenen Zugriff unter „Akten, die ich öffnen kann“ zurück, oder lösche das Profil.",
|
||||
"soleGuardian": "Du bist die einzige Person, die sich um {name} kümmert. Hier gibt es also keinen Zugriff, den du beenden könntest. Lade jemanden ein und warte auf die Annahme, gib deinen eigenen Zugriff unter „Akten, die ich öffnen kann“ zurück, oder lösche das Profil.",
|
||||
"guardianInviteLabel": "Weitere betreuende Person einladen",
|
||||
"guardianInvite": "Einladung senden",
|
||||
"guardianInviting": "Wird gesendet…",
|
||||
@@ -11974,7 +11959,7 @@
|
||||
"both": "Beidseitig",
|
||||
"none": "Keine Angabe"
|
||||
},
|
||||
"bodySiteWithSide": "{site} · {side}",
|
||||
"bodySiteWithSide": "{site} ({side})",
|
||||
"sectionTitle": "Termine",
|
||||
"sectionDescription": "Arztbesuche und gebuchte Termine an einem Ort.",
|
||||
"add": "Termin",
|
||||
@@ -12236,7 +12221,6 @@
|
||||
"loadFailed": "Dieser Tag konnte nicht geladen werden.",
|
||||
"dayOf": "Tag {n} von {m}",
|
||||
"dayN": "Tag {n}",
|
||||
"since": "seit {date}",
|
||||
"usualRange": "Üblich {lo} bis {hi}",
|
||||
"groups": {
|
||||
"scores": "Scores",
|
||||
@@ -12264,11 +12248,11 @@
|
||||
"description": "Alles, was deine Einträge für diesen Tag enthalten.",
|
||||
"openDocument": "Dokument öffnen",
|
||||
"previousReading": "Vorher, {date}",
|
||||
"rugLegend": "Punkte unter der Achse: Tage mit Einträgen · Ring: auffälliger Tag",
|
||||
"rugLegend": "Punkte unter der Achse markieren Tage mit Einträgen, ein Ring einen auffälligen Tag.",
|
||||
"sheetExpand": "Auf volle Höhe aufziehen",
|
||||
"sheetShrink": "Verkleinern",
|
||||
"usualLabel": "Deine 30 Tage davor",
|
||||
"valuesLegend": "Punkt: dieser Tag · Strich: deine 30 Tage davor",
|
||||
"valuesLegend": "Punkt für diesen Tag, Strich für die 30 Tage davor",
|
||||
"valuesAllOne": "Alle Werte",
|
||||
"valuesAllFew": "Alle {count} Werte",
|
||||
"valuesAllOther": "Alle {count} Werte",
|
||||
@@ -12302,7 +12286,13 @@
|
||||
"resumed": "{label} wieder aufgenommen",
|
||||
"illnessBegan": "{label} begann",
|
||||
"illnessResolved": "{label} abgeklungen"
|
||||
}
|
||||
},
|
||||
"pickDate": "Datum wählen",
|
||||
"pickerTitle": "Zu einem Tag springen",
|
||||
"today": "Heute",
|
||||
"hasEntries": "mit Einträgen",
|
||||
"dayNSince": "Tag {n}, seit {date}",
|
||||
"sinceDate": "Seit {date}"
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Zeitleiste",
|
||||
@@ -12376,9 +12366,9 @@
|
||||
"vaccinationsOne": "{count} Impfung",
|
||||
"vaccinationsFew": "{count} Impfungen",
|
||||
"vaccinationsOther": "{count} Impfungen",
|
||||
"visitsOne": "{count} Besuch · {procedures} mit Eingriff",
|
||||
"visitsFew": "{count} Besuche · {procedures} mit Eingriff",
|
||||
"visitsOther": "{count} Besuche · {procedures} mit Eingriff",
|
||||
"visitsOne": "{count} Besuch, {procedures} mit Eingriff",
|
||||
"visitsFew": "{count} Besuche, {procedures} mit Eingriff",
|
||||
"visitsOther": "{count} Besuche, {procedures} mit Eingriff",
|
||||
"visitsPlannedOne": "{count} Besuch, nur geplant",
|
||||
"visitsPlannedFew": "{count} Besuche, nur geplant",
|
||||
"visitsPlannedOther": "{count} Besuche, nur geplant",
|
||||
|
||||
+126
-136
@@ -242,7 +242,7 @@
|
||||
"colExpected": "Expected",
|
||||
"colComplianceRate": "Adherence rate",
|
||||
"moodTitle": "Mood trajectory",
|
||||
"moodSummary": "Average: {avg} / 5.0 · Entries: {count} · Range: {min}–{max}",
|
||||
"moodSummary": "Average {avg} / 5.0, {count} entries, range {min}–{max}",
|
||||
"colMood": "Mood",
|
||||
"colCount": "Count",
|
||||
"colShare": "Share",
|
||||
@@ -579,8 +579,8 @@
|
||||
"sleepShort": "Sleep",
|
||||
"stepsShort": "Steps",
|
||||
"vo2MaxShort": "VO₂ max",
|
||||
"bpInTargetWindow90": "BP in Target · 90d",
|
||||
"bpInTargetWindowDynamic": "BP in Target · {days}d",
|
||||
"bpInTargetWindow90": "BP in Target (90d)",
|
||||
"bpInTargetWindowDynamic": "BP in Target ({days}d)",
|
||||
"bpInTargetCollecting": "collecting data",
|
||||
"medications": "Medications",
|
||||
"quickAddMeasurement": "Log measurement",
|
||||
@@ -1336,7 +1336,7 @@
|
||||
"complianceInfoLabel": "What does adherence mean?",
|
||||
"lastIntake": "Last intake:",
|
||||
"nextIntake": "Next intake:",
|
||||
"nextIntakeOverdue": "Overdue · {time} — can still be taken",
|
||||
"nextIntakeOverdue": "Overdue since {time}, can still be taken",
|
||||
"dayStreak": "day streak",
|
||||
"dayStreakOne": "day streak",
|
||||
"cycleDueToday": "Due today",
|
||||
@@ -1737,7 +1737,7 @@
|
||||
"empty": "No dose-escalation steps recorded yet. Add dated dose changes to plot the plan.",
|
||||
"youAreHere": "You are here",
|
||||
"doseStep": "{dose} {unit}",
|
||||
"plannedFor": "Planned · {date}"
|
||||
"plannedFor": "Planned for {date}"
|
||||
},
|
||||
"injectionSiteBodyOutlineAriaLabel": "Body outline",
|
||||
"injectionSitePickerAriaLabel": "Injection site picker",
|
||||
@@ -1788,7 +1788,7 @@
|
||||
"intakeHistoryStatusSkipped": "Skipped",
|
||||
"intakeHistorySortByTaken": "Sort by taken time",
|
||||
"intakeHistorySortByScheduled": "Sort by scheduled time",
|
||||
"intakeHistoryPageInfo": "Page {page} of {total} · {count} entries",
|
||||
"intakeHistoryPageInfo": "Page {page} of {total}, {count} entries",
|
||||
"intakeHistoryPrev": "Previous",
|
||||
"intakeHistoryNext": "Next",
|
||||
"glp1": {
|
||||
@@ -1888,7 +1888,7 @@
|
||||
"markAsTaken": "Mark as taken",
|
||||
"markAsSkipped": "Mark as skipped",
|
||||
"adHocTag": "off-schedule",
|
||||
"takenDetail": "planned {planned} · taken {taken}",
|
||||
"takenDetail": "planned {planned}, taken {taken}",
|
||||
"doseTaken": "Dose: {dose}",
|
||||
"status": {
|
||||
"taken_on_time": "on time",
|
||||
@@ -2594,31 +2594,31 @@
|
||||
"ENV_POLLEN_MAX": "pollen"
|
||||
},
|
||||
"daily": {
|
||||
"high": "Higher {behaviour} tends to go with {direction} next-day {outcome} in your data — a pattern worth watching, not a cause.",
|
||||
"moderate": "Higher {behaviour} looks like it goes with {direction} next-day {outcome} in your data, on the evidence so far — a pattern worth watching, not a cause.",
|
||||
"faint": "Higher {behaviour} shows a faint hint, if anything, of {direction} next-day {outcome} in your data — too small to lean on, never a cause."
|
||||
"high": "Higher {behaviour} tends to go with {direction} next-day {outcome} in your data. It is a pattern to watch, not a cause.",
|
||||
"moderate": "Higher {behaviour} looks like it goes with {direction} next-day {outcome} in your data, on the evidence so far. It is a pattern to watch, not a cause.",
|
||||
"faint": "Higher {behaviour} shows at most a faint hint of {direction} next-day {outcome} in your data. It is too small to lean on and never a cause."
|
||||
},
|
||||
"lab": {
|
||||
"default": "Higher {lab} readings line up with {direction} {outcome} over the same periods in your data — an association worth watching with your clinician, never a cause.",
|
||||
"faint": "Higher {lab} readings show a faint hint, if anything, of {direction} {outcome} over the same periods in your data — too small to lean on, an association to raise with your clinician, never a cause."
|
||||
"default": "Higher {lab} readings line up with {direction} {outcome} over the same periods in your data, an association worth watching with your clinician, never a cause.",
|
||||
"faint": "Higher {lab} readings show at most a faint hint of {direction} {outcome} over the same periods in your data. It is too small to lean on and never a cause, but worth raising with your clinician."
|
||||
},
|
||||
"hypothesis": {
|
||||
"bpCompliance": {
|
||||
"support": "Higher medication compliance lines up with lower systolic readings — a pattern worth watching.",
|
||||
"surprising": "Compliance and systolic move together in your data — surprising; talk to your doctor before adjusting anything.",
|
||||
"support": "Higher medication compliance lines up with lower systolic readings, a pattern worth watching.",
|
||||
"surprising": "Compliance and systolic move together in your data, which is surprising. Talk to your doctor before adjusting anything.",
|
||||
"none": "Compliance and systolic do not move together strongly in this window."
|
||||
},
|
||||
"moodPulse": {
|
||||
"support": "Lower-mood days line up with higher resting pulse — a pattern worth watching.",
|
||||
"surprising": "Higher-mood days line up with higher resting pulse in your data — surprising.",
|
||||
"support": "Lower-mood days line up with higher resting pulse, a pattern worth watching.",
|
||||
"surprising": "Higher-mood days line up with higher resting pulse in your data, which is surprising.",
|
||||
"none": "Mood and resting pulse do not move together strongly in this window."
|
||||
},
|
||||
"weightWeekday": "{weekday} weights run {delta} kg {direction} your other-day average — a pattern worth watching."
|
||||
"weightWeekday": "{weekday} weights run {delta} kg {direction} your other-day average, a pattern worth watching."
|
||||
},
|
||||
"dailySameDay": {
|
||||
"high": "Higher {behaviour} on the day and the day before tends to go with {direction} {outcome} in your data — a pattern worth watching, not a cause.",
|
||||
"moderate": "Higher {behaviour} on the day and the day before looks like it goes with {direction} {outcome} in your data, on the evidence so far — a pattern worth watching, not a cause.",
|
||||
"faint": "Higher {behaviour} on the day and the day before shows a faint hint, if anything, of {direction} {outcome} in your data — too small to lean on, never a cause."
|
||||
"high": "Higher {behaviour} on the day and the day before tends to go with {direction} {outcome} in your data. It is a pattern to watch, not a cause.",
|
||||
"moderate": "Higher {behaviour} on the day and the day before looks like it goes with {direction} {outcome} in your data, on the evidence so far. It is a pattern to watch, not a cause.",
|
||||
"faint": "Higher {behaviour} on the day and the day before shows a faint hint, if anything, of {direction} {outcome} in your data. It is too small to lean on and never a cause."
|
||||
}
|
||||
},
|
||||
"noAnalysisYet": "No assessment yet.",
|
||||
@@ -2730,7 +2730,7 @@
|
||||
"referenceSleep": "At least {low} minutes per night; longer sleep is not penalised",
|
||||
"referenceAdiposity": "Waist-to-height ratio below {high}",
|
||||
"referenceHba1c": "HbA1c {low} to {high}%",
|
||||
"referenceDetail": "{label} · {source} · {kind} · {low} to {high}",
|
||||
"referenceDetail": "{label}: {low} to {high} ({kind}, {source})",
|
||||
"openBound": "open",
|
||||
"laboratoryReferenceSource": "Reporting laboratory reference ranges",
|
||||
"personalTargetSource": "Personal target",
|
||||
@@ -2824,7 +2824,7 @@
|
||||
"experimentCta": "Try a 7-day experiment",
|
||||
"experimentTooltip": "Coming soon",
|
||||
"emptyTitle": "Need more data to see this relationship",
|
||||
"sourceChip": "based on {n} paired readings · {window}",
|
||||
"sourceChip": "based on {n} paired readings ({window})",
|
||||
"sourceWindowLast30": "last 30 days",
|
||||
"confidenceHigh": "High confidence",
|
||||
"confidenceModerate": "Moderate confidence",
|
||||
@@ -3003,23 +3003,23 @@
|
||||
"cluster": {
|
||||
"cardio": {
|
||||
"label": "Cardiovascular",
|
||||
"hint": "BP · pulse · HRV · resting & walking HR"
|
||||
"hint": "BP, pulse, HRV, resting and walking HR"
|
||||
},
|
||||
"body": {
|
||||
"label": "Body composition",
|
||||
"hint": "Weight · body fat · muscle · BMI"
|
||||
"hint": "Weight, body fat, muscle and BMI"
|
||||
},
|
||||
"activity": {
|
||||
"label": "Activity",
|
||||
"hint": "Steps · energy · distance · VO₂ max"
|
||||
"hint": "Steps, energy, distance and VO₂ max"
|
||||
},
|
||||
"workouts": {
|
||||
"label": "Workouts",
|
||||
"hint": "Runs · rides · sessions"
|
||||
"hint": "Runs, rides and sessions"
|
||||
},
|
||||
"sleep": {
|
||||
"label": "Sleep",
|
||||
"hint": "Duration · stages"
|
||||
"hint": "Duration and stages"
|
||||
},
|
||||
"mood": {
|
||||
"label": "Mood",
|
||||
@@ -3027,19 +3027,19 @@
|
||||
},
|
||||
"glucose": {
|
||||
"label": "Glucose",
|
||||
"hint": "Fasting · postprandial readings"
|
||||
"hint": "Fasting and postprandial readings"
|
||||
},
|
||||
"medication": {
|
||||
"label": "Medication",
|
||||
"hint": "Adherence · GLP-1 context"
|
||||
"hint": "Adherence and GLP-1 context"
|
||||
},
|
||||
"mobility": {
|
||||
"label": "Mobility & gait",
|
||||
"hint": "Steadiness · asymmetry · gait"
|
||||
"hint": "Steadiness, asymmetry and gait"
|
||||
},
|
||||
"environment": {
|
||||
"label": "Environment",
|
||||
"hint": "Noise · daylight · skin temp"
|
||||
"hint": "Noise, daylight and skin temperature"
|
||||
}
|
||||
},
|
||||
"settingsClustersLabel": "Data the Coach sees",
|
||||
@@ -3092,21 +3092,7 @@
|
||||
"driver": "{note}"
|
||||
},
|
||||
"scope": {
|
||||
"prefix": "The Coach is already on {metric}",
|
||||
"tap": "Tap to ask",
|
||||
"question": "Walk me through this metric — anything I should watch?"
|
||||
},
|
||||
"seeded": {
|
||||
"prefix": "Worth a look today: {signal}",
|
||||
"signal": {
|
||||
"readiness": "readiness",
|
||||
"recovery": "recovery"
|
||||
},
|
||||
"question": {
|
||||
"readiness": "My readiness is off today — what's driving it?",
|
||||
"recovery": "My recovery shifted — what's behind it?"
|
||||
},
|
||||
"dismiss": "Dismiss"
|
||||
"prefix": "The Coach is already on {metric}"
|
||||
},
|
||||
"settings": "Settings",
|
||||
"history": {
|
||||
@@ -3227,9 +3213,9 @@
|
||||
"cap": "Many steps, wrapping up",
|
||||
"noProgress": "No new data, wrapping up"
|
||||
},
|
||||
"thoughtProcessOne": "Thought process · {count} step",
|
||||
"thoughtProcessFew": "Thought process · {count} steps",
|
||||
"thoughtProcessOther": "Thought process · {count} steps",
|
||||
"thoughtProcessOne": "Thought process ({count} step)",
|
||||
"thoughtProcessFew": "Thought process ({count} steps)",
|
||||
"thoughtProcessOther": "Thought process ({count} steps)",
|
||||
"awaitingAnswer": "Answer needed"
|
||||
},
|
||||
"memory": {
|
||||
@@ -3269,7 +3255,8 @@
|
||||
"capped": "(limited by the admin)",
|
||||
"disabled": "The admin has turned deeper thinking off."
|
||||
},
|
||||
"memoryLink": "Memory ({count})"
|
||||
"memoryLink": "Memory ({count})",
|
||||
"newConversation": "New conversation"
|
||||
},
|
||||
"relativeJustNow": "just now",
|
||||
"relativeMinutesAgoOne": "{count} minute ago",
|
||||
@@ -4062,7 +4049,7 @@
|
||||
"distributionDaysUnit": "days",
|
||||
"weekdayTitle": "Average by weekday",
|
||||
"weekdayNoData": "No data",
|
||||
"weekdayBestWorst": "Best day: {best} · Toughest day: {worst}",
|
||||
"weekdayBestWorst": "Best day: {best}, toughest day: {worst}",
|
||||
"tagsTitle": "Tags and triggers",
|
||||
"tagAvgMood": "Average mood when tagged",
|
||||
"correlationsTitle": "Mood and other metrics",
|
||||
@@ -4078,7 +4065,7 @@
|
||||
"strengthModerate": "Moderate",
|
||||
"strengthWeak": "Weak",
|
||||
"strengthNone": "None",
|
||||
"source": "{n} paired days · r = {r}",
|
||||
"source": "{n} paired days, r = {r}",
|
||||
"sourceLabel": "How this was computed",
|
||||
"emptyTitle": "Not enough paired data yet",
|
||||
"emptyDescription": "Log mood and this metric on the same days to see a correlation.",
|
||||
@@ -4137,7 +4124,7 @@
|
||||
"crosstab": {
|
||||
"title": "Tags and your health metrics",
|
||||
"description": "How a health metric differs on the days you tagged each activity, compared with the days you did not.",
|
||||
"pairLabel": "{tag} · {metric}",
|
||||
"pairLabel": "{metric} on days with {tag}",
|
||||
"metricActiveEnergy": "Active energy",
|
||||
"metricSleepDuration": "Sleep",
|
||||
"metricNextDayRecovery": "Next-day recovery",
|
||||
@@ -4154,7 +4141,7 @@
|
||||
"factorCrosstab": {
|
||||
"title": "Your rated factors and your vitals",
|
||||
"description": "How a vital differs on the days you rated each factor low compared with the days you rated it high.",
|
||||
"pairLabel": "{factor} · {metric}",
|
||||
"pairLabel": "{metric} on days with {factor}",
|
||||
"metricSleepDuration": "Sleep",
|
||||
"metricSteps": "Steps",
|
||||
"metricRestingHeartRate": "Resting heart rate",
|
||||
@@ -4180,7 +4167,7 @@
|
||||
"moodDown": "Higher {factor} tends to go with lower next-day mood in your data.",
|
||||
"outcomeUp": "Higher mood tends to go with higher next-day {factor} in your data.",
|
||||
"outcomeDown": "Higher mood tends to go with lower next-day {factor} in your data.",
|
||||
"stat": "{n} paired days · r = {r} · q = {q}",
|
||||
"stat": "{n} paired days, r = {r}, q = {q}",
|
||||
"statLabel": "How this was computed",
|
||||
"explainerLabel": "How this was computed"
|
||||
},
|
||||
@@ -4224,11 +4211,11 @@
|
||||
},
|
||||
"derived": {
|
||||
"coverage": {
|
||||
"summary": "{present} of {required} inputs · {percent}%",
|
||||
"summary": "{present} of {required} inputs ({percent}%)",
|
||||
"ratioLabel": "{present}/{required}",
|
||||
"historyDays": "Based on {count} days of history",
|
||||
"missing": "Track these to sharpen this: {list}",
|
||||
"summaryLabelled": "{label} · {percent}%"
|
||||
"summaryLabelled": "{label} ({percent}%)"
|
||||
},
|
||||
"scoreRing": {
|
||||
"aria": "Score {score} out of 100, {band}",
|
||||
@@ -4246,7 +4233,7 @@
|
||||
"inputsLabel": "Inputs",
|
||||
"noInputs": "No inputs backed this value yet.",
|
||||
"methodLabel": "Method",
|
||||
"window": "From your last {days} days · {source} data",
|
||||
"window": "From your last {days} days of {source} data",
|
||||
"asOf": "as of {date}",
|
||||
"standardLink": "Standard: {name}"
|
||||
},
|
||||
@@ -4288,7 +4275,7 @@
|
||||
"anatomy": {
|
||||
"outOf": "/100",
|
||||
"contributorsLabel": "Contributors",
|
||||
"contributorValue": "{value} · {weight}% weight",
|
||||
"contributorValue": "{value}, weighted {weight}%",
|
||||
"contributorMissing": "no data",
|
||||
"insufficient": "Not enough data yet to score this. Keep tracking and it will appear here."
|
||||
},
|
||||
@@ -4756,10 +4743,10 @@
|
||||
"dismiss": "Not now"
|
||||
},
|
||||
"bmiHeight": {
|
||||
"coachPrefill": "I haven’t set my height yet — why does BMI matter, and what should I know before I configure it?"
|
||||
"coachPrefill": "I haven’t set my height yet. Why does BMI matter, and what should I know before I configure it?"
|
||||
},
|
||||
"medicationsEmpty": {
|
||||
"coachPrefill": "I haven’t added any medications yet — what should I know before I start tracking medication compliance here?"
|
||||
"coachPrefill": "I haven’t added any medications yet. What should I know before I start tracking medication compliance here?"
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
@@ -5521,7 +5508,7 @@
|
||||
"dropHint": "or click to choose a file",
|
||||
"fileInputLabel": "Choose an Apple Health export.zip file",
|
||||
"choose": "Choose file",
|
||||
"idempotencyNote": "Re-uploading the same archive merges — it won't create duplicates. Large archives are processed in the background.",
|
||||
"idempotencyNote": "Re-uploading the same archive merges it and creates no duplicates. Large archives are processed in the background.",
|
||||
"uploading": "Uploading…",
|
||||
"uploadFailed": "Upload failed. Please try again.",
|
||||
"rateLimited": "Too many uploads. Please wait a moment and try again.",
|
||||
@@ -5530,7 +5517,7 @@
|
||||
"failureInterrupted": "The import was interrupted before it finished, usually by a server restart or an out-of-memory stop. Your data is safe; upload the export again to retry.",
|
||||
"failureInsufficientMemory": "The server's memory limit is too low for an archive this size. Ask whoever runs the server to raise the Node.js heap limit (NODE_OPTIONS=--max-old-space-size), then upload the export again.",
|
||||
"rowsImported": "{count} rows imported so far",
|
||||
"doneSummary": "Done — {imported} of {read} records imported, {skipped} clinical records skipped.",
|
||||
"doneSummary": "Done: {imported} of {read} records imported, {skipped} clinical records skipped.",
|
||||
"doneNothing": "Nothing was imported from that archive: 0 of {read} records landed.",
|
||||
"writtenByHealthLogOne": "{count} record that HealthLog wrote into Apple Health itself was left out, because it is already in your data.",
|
||||
"writtenByHealthLogOther": "{count} records that HealthLog wrote into Apple Health itself were left out, because they are already in your data.",
|
||||
@@ -5543,12 +5530,12 @@
|
||||
"parsing": "Reading the export…",
|
||||
"upserting": "Importing records…"
|
||||
},
|
||||
"skipUnsupported": "{count} samples skipped — data types HealthLog doesn't import.",
|
||||
"skipUnsupported": "{count} samples skipped: data types HealthLog doesn't import.",
|
||||
"skipRefused": "{count} samples couldn't be written (unreadable, out of range, or the save failed).",
|
||||
"skipBreakdown": "Show breakdown ({count} types)",
|
||||
"skipBreakdownMore": "+ {count} more types",
|
||||
"cycleSummary": "{samples} cycle samples merged into {days} day logs.",
|
||||
"cycleModuleOff": "{count} cycle samples skipped — cycle tracking is off for this account.",
|
||||
"cycleModuleOff": "{count} cycle samples skipped because cycle tracking is off for this account.",
|
||||
"cycleDaysFailed": "{count} cycle days couldn't be written: {reason}"
|
||||
},
|
||||
"json": {
|
||||
@@ -5633,8 +5620,8 @@
|
||||
"failed": "The import failed. Nothing was written.",
|
||||
"columnsHeading": "What each column does",
|
||||
"jsonOnly": "JSON only",
|
||||
"previewLine": "Rows read: {rows} · would import: {queued} · refused: {refused}",
|
||||
"submittedLine": "Rows read: {rows} · queued for import: {queued}",
|
||||
"previewLine": "{rows} rows read, {queued} would be imported, {refused} refused",
|
||||
"submittedLine": "{rows} rows read, {queued} queued for import",
|
||||
"unmatched": "No medication on record matches: {names}",
|
||||
"ambiguous": "Matches more than one medication: {names}",
|
||||
"mirrored": "Mirrored from another app, so no dose can be added: {names}",
|
||||
@@ -5799,19 +5786,20 @@
|
||||
"storedDays": "{count} days of weather stored.",
|
||||
"home": {
|
||||
"title": "Home location",
|
||||
"description": "A coarse, city-level location — never a precise track. Weather and daylight are fetched for this place on days no location period applies, from the date you set it onward.",
|
||||
"description": "A coarse, city-level location, never a precise track. Weather and daylight are fetched for this place on days no location period applies, from the date you set it onward.",
|
||||
"current": "Current: {label}",
|
||||
"none": "No home location set yet.",
|
||||
"since": "Weather tracked from {date} onward. Earlier days stay empty until you add a location period."
|
||||
},
|
||||
"travel": {
|
||||
"title": "Location periods",
|
||||
"description": "Add a place and a date range — a trip, or a stretch of earlier history. The period overrides your home for the days it covers, so weather matches where you actually were. This is how you correct the past.",
|
||||
"description": "Add a place and a date range, such as a trip or a stretch of earlier history. The period overrides your home for the days it covers, so weather matches where you actually were. This is how you correct the past.",
|
||||
"none": "No location periods yet.",
|
||||
"add": "Add {label}",
|
||||
"remove": "Remove location period",
|
||||
"removeConfirmTitle": "Remove this trip?",
|
||||
"removeConfirmBody": "Readings taken during it stay in your record; only the trip entry goes."
|
||||
"removeConfirmBody": "Readings taken during it stay in your record; only the trip entry goes.",
|
||||
"range": "{start} to {end}"
|
||||
},
|
||||
"backfill": {
|
||||
"title": "Backfill past days",
|
||||
@@ -6520,7 +6508,7 @@
|
||||
},
|
||||
"integrationFreshness": {
|
||||
"title": "Data received",
|
||||
"summary": "{count} data types · newest {relative}",
|
||||
"summary": "{count} data types, newest {relative}",
|
||||
"quiet": "{count} quiet",
|
||||
"workouts": "Workouts"
|
||||
},
|
||||
@@ -7079,14 +7067,14 @@
|
||||
"title": "Sync progress",
|
||||
"receivedLabel": "Records received",
|
||||
"oldestLabel": "Oldest reading reached",
|
||||
"flowing": "Data arrived within the last hour — the iPhone app is still delivering.",
|
||||
"waiting": "Waiting for the iPhone app to send more data. Delivery continues when the app is open or when iOS grants it background time — a quiet stretch during the first big backfill is normal, not an error."
|
||||
"flowing": "Data arrived within the last hour. The iPhone app is still delivering.",
|
||||
"waiting": "Waiting for the iPhone app to send more data. Delivery continues when the app is open or when iOS grants it background time. A quiet stretch during the first big backfill is normal, not an error."
|
||||
},
|
||||
"delivery": {
|
||||
"title": "Delivery",
|
||||
"lastTriggerLabel": "Latest sync arrived",
|
||||
"lastBackgroundLabel": "Last background sync",
|
||||
"backgroundNever": "Never — every sync so far arrived with the app open",
|
||||
"backgroundNever": "Never: every sync so far arrived with the app open",
|
||||
"noSyncYet": "No sync has arrived yet, so there is nothing to report here.",
|
||||
"trigger": {
|
||||
"foreground": "with the app open",
|
||||
@@ -8086,19 +8074,19 @@
|
||||
"description": "Take all medications on time for 1 day."
|
||||
},
|
||||
"compliance801": {
|
||||
"title": "80% adherence · 1d",
|
||||
"title": "80% adherence for 1 day",
|
||||
"description": "Reach at least 80% 30-day adherence for 1 day."
|
||||
},
|
||||
"bmiGreen1": {
|
||||
"title": "BMI green · 1d",
|
||||
"title": "BMI green for 1 day",
|
||||
"description": "Keep your BMI in the normal range for 1 day."
|
||||
},
|
||||
"bpGreen1": {
|
||||
"title": "BP green · 1d",
|
||||
"title": "BP green for 1 day",
|
||||
"description": "Keep your blood pressure in the normal range for 1 day."
|
||||
},
|
||||
"pulseGreen1": {
|
||||
"title": "Pulse green · 1d",
|
||||
"title": "Pulse green for 1 day",
|
||||
"description": "Keep your resting pulse in the normal range for 1 day."
|
||||
},
|
||||
"onTimePerfect7": {
|
||||
@@ -8106,19 +8094,19 @@
|
||||
"description": "Take all medications on time for 7 consecutive days."
|
||||
},
|
||||
"compliance807": {
|
||||
"title": "80% adherence · 7d",
|
||||
"title": "80% adherence for 7 days",
|
||||
"description": "Reach at least 80% 30-day adherence for 7 consecutive days."
|
||||
},
|
||||
"bmiGreen7": {
|
||||
"title": "BMI green · 7d",
|
||||
"title": "BMI green for 7 days",
|
||||
"description": "Keep your BMI in the normal range for 7 consecutive days."
|
||||
},
|
||||
"bpGreen7": {
|
||||
"title": "BP green · 7d",
|
||||
"title": "BP green for 7 days",
|
||||
"description": "Keep your blood pressure in the normal range for 7 consecutive days."
|
||||
},
|
||||
"pulseGreen7": {
|
||||
"title": "Pulse green · 7d",
|
||||
"title": "Pulse green for 7 days",
|
||||
"description": "Keep your resting pulse in the normal range for 7 consecutive days."
|
||||
},
|
||||
"onTimePerfect30": {
|
||||
@@ -8126,19 +8114,19 @@
|
||||
"description": "Take all medications on time for 30 consecutive days."
|
||||
},
|
||||
"compliance8030": {
|
||||
"title": "80% adherence · 30d",
|
||||
"title": "80% adherence for 30 days",
|
||||
"description": "Reach at least 80% 30-day adherence for 30 consecutive days."
|
||||
},
|
||||
"bmiGreen30": {
|
||||
"title": "BMI green · 30d",
|
||||
"title": "BMI green for 30 days",
|
||||
"description": "Keep your BMI in the normal range for 30 consecutive days."
|
||||
},
|
||||
"bpGreen30": {
|
||||
"title": "BP green · 30d",
|
||||
"title": "BP green for 30 days",
|
||||
"description": "Keep your blood pressure in the normal range for 30 consecutive days."
|
||||
},
|
||||
"pulseGreen30": {
|
||||
"title": "Pulse green · 30d",
|
||||
"title": "Pulse green for 30 days",
|
||||
"description": "Keep your resting pulse in the normal range for 30 consecutive days."
|
||||
},
|
||||
"onTimePerfect180": {
|
||||
@@ -8146,19 +8134,19 @@
|
||||
"description": "Take all medications on time for 180 consecutive days."
|
||||
},
|
||||
"compliance80180": {
|
||||
"title": "80% adherence · 180d",
|
||||
"title": "80% adherence for 180 days",
|
||||
"description": "Reach at least 80% 30-day adherence for 180 consecutive days."
|
||||
},
|
||||
"bmiGreen180": {
|
||||
"title": "BMI green · 180d",
|
||||
"title": "BMI green for 180 days",
|
||||
"description": "Keep your BMI in the normal range for 180 consecutive days."
|
||||
},
|
||||
"bpGreen180": {
|
||||
"title": "BP green · 180d",
|
||||
"title": "BP green for 180 days",
|
||||
"description": "Keep your blood pressure in the normal range for 180 consecutive days."
|
||||
},
|
||||
"pulseGreen180": {
|
||||
"title": "Pulse green · 180d",
|
||||
"title": "Pulse green for 180 days",
|
||||
"description": "Keep your resting pulse in the normal range for 180 consecutive days."
|
||||
},
|
||||
"onTimePerfect360": {
|
||||
@@ -8166,19 +8154,19 @@
|
||||
"description": "Take all medications on time for 360 consecutive days."
|
||||
},
|
||||
"compliance80360": {
|
||||
"title": "80% adherence · 360d",
|
||||
"title": "80% adherence for 360 days",
|
||||
"description": "Reach at least 80% 30-day adherence for 360 consecutive days."
|
||||
},
|
||||
"bmiGreen360": {
|
||||
"title": "BMI green · 360d",
|
||||
"title": "BMI green for 360 days",
|
||||
"description": "Keep your BMI in the normal range for 360 consecutive days."
|
||||
},
|
||||
"bpGreen360": {
|
||||
"title": "BP green · 360d",
|
||||
"title": "BP green for 360 days",
|
||||
"description": "Keep your blood pressure in the normal range for 360 consecutive days."
|
||||
},
|
||||
"pulseGreen360": {
|
||||
"title": "Pulse green · 360d",
|
||||
"title": "Pulse green for 360 days",
|
||||
"description": "Keep your resting pulse in the normal range for 360 consecutive days."
|
||||
},
|
||||
"moodFirst": {
|
||||
@@ -8262,15 +8250,15 @@
|
||||
"description": "Switched the app language at least once."
|
||||
},
|
||||
"missFree7": {
|
||||
"title": "No dose missed · 7d",
|
||||
"title": "No dose missed in 7 days",
|
||||
"description": "7 consecutive days without a missed dose."
|
||||
},
|
||||
"missFree30": {
|
||||
"title": "No dose missed · 30d",
|
||||
"title": "No dose missed in 30 days",
|
||||
"description": "30 consecutive days without a missed dose."
|
||||
},
|
||||
"missFree90": {
|
||||
"title": "No dose missed · 90d",
|
||||
"title": "No dose missed in 90 days",
|
||||
"description": "90 consecutive days without a missed dose."
|
||||
},
|
||||
"measurementWeeks4": {
|
||||
@@ -8282,7 +8270,7 @@
|
||||
"description": "All four self-report fields filled in — the Coach knows your context."
|
||||
},
|
||||
"sleepLog7": {
|
||||
"title": "Sleep log · 7d",
|
||||
"title": "Sleep log for 7 days",
|
||||
"description": "Sleep data recorded on 7 consecutive days."
|
||||
}
|
||||
},
|
||||
@@ -8372,9 +8360,6 @@
|
||||
"enabledToast": "Proactive Coach nudges enabled.",
|
||||
"disabledToast": "Proactive Coach nudges disabled.",
|
||||
"saveError": "Could not save. Please try again later.",
|
||||
"suggestionsLabel": "Example suggestions",
|
||||
"suggestionsDesc": "Show the occasional ready-made example question on the Coach screen.",
|
||||
"suggestionsAria": "Toggle example Coach suggestions",
|
||||
"aiComposedLabel": "Let the Coach phrase the nudge",
|
||||
"aiComposedDesc": "When a provider is available, the proactive nudge is written by the model instead of the fixed template. Off by default.",
|
||||
"aiComposedAria": "Toggle AI-composed proactive nudge"
|
||||
@@ -8792,7 +8777,7 @@
|
||||
},
|
||||
"crosstab": {
|
||||
"description": "How each measurement compares between your luteal and follicular phases, on your own data.",
|
||||
"pairLabel": "{metric} · luteal vs follicular",
|
||||
"pairLabel": "{metric}: luteal vs follicular",
|
||||
"detail": "Luteal {lutealAvg} {unit} ({lutealDays} days) vs follicular {follicularAvg} {unit} ({follicularDays} days).",
|
||||
"metricRestingHeartRate": "Resting heart rate",
|
||||
"metricHrv": "Heart-rate variability",
|
||||
@@ -9012,7 +8997,7 @@
|
||||
"temperatureSection": "Temperature & ovulation",
|
||||
"intimacySection": "Intimacy & contraception",
|
||||
"testsSection": "Tests",
|
||||
"phaseDayHeader": "Day {day} · {phase}",
|
||||
"phaseDayHeader": "Day {day}, {phase}",
|
||||
"bbtHint": "Take it first thing in the morning, before getting up.",
|
||||
"titleForDate": "Log for {date}",
|
||||
"descriptionToday": "Today. Flow, symptoms and notes.",
|
||||
@@ -9189,10 +9174,10 @@
|
||||
"VISCERAL_FAT": "Visceral fat",
|
||||
"BODY_MASS_INDEX": "Body mass index (BMI)",
|
||||
"WAIST_CIRCUMFERENCE": "Waist circumference",
|
||||
"PHQ9_SCORE": "PHQ-9 · mood",
|
||||
"GAD7_SCORE": "GAD-7 · worry",
|
||||
"WHO5_SCORE": "WHO-5 · wellbeing",
|
||||
"SCI_SCORE": "SCI · sleep"
|
||||
"PHQ9_SCORE": "PHQ-9 (mood)",
|
||||
"GAD7_SCORE": "GAD-7 (worry)",
|
||||
"WHO5_SCORE": "WHO-5 (wellbeing)",
|
||||
"SCI_SCORE": "SCI (sleep)"
|
||||
},
|
||||
"typeGroups": {
|
||||
"vitals": "Vitals",
|
||||
@@ -9755,7 +9740,7 @@
|
||||
},
|
||||
"empty": {
|
||||
"title": "No vaccination logged yet",
|
||||
"description": "Transcribe your vaccination card line by line — a date and the vaccine are enough."
|
||||
"description": "Transcribe your vaccination card line by line. A date and the vaccine are enough."
|
||||
},
|
||||
"catalog": {
|
||||
"tetanus": "Tetanus",
|
||||
@@ -10289,10 +10274,10 @@
|
||||
"saved": "Saved",
|
||||
"error": "Something went wrong. Please try again.",
|
||||
"instrument": {
|
||||
"phq9": "PHQ-9 · mood",
|
||||
"gad7": "GAD-7 · worry",
|
||||
"who5": "WHO-5 · well-being",
|
||||
"sci": "SCI · sleep"
|
||||
"phq9": "PHQ-9 (mood)",
|
||||
"gad7": "GAD-7 (worry)",
|
||||
"who5": "WHO-5 (well-being)",
|
||||
"sci": "SCI (sleep)"
|
||||
},
|
||||
"instrumentSub": {
|
||||
"phq9": "Depression screen, 9 questions",
|
||||
@@ -10760,7 +10745,7 @@
|
||||
},
|
||||
"documents": {
|
||||
"title": "Documents",
|
||||
"subtitle": "Every letter, report, and scan in one searchable place — encrypted at rest.",
|
||||
"subtitle": "Every letter, report and scan in one searchable place, encrypted at rest.",
|
||||
"pageUpload": "Upload",
|
||||
"ownerOnlyUpload": "Documents can only be added by the record owner.",
|
||||
"kind": {
|
||||
@@ -10802,15 +10787,15 @@
|
||||
},
|
||||
"error": {
|
||||
"fileTooLarge": "This file is larger than the {maxSize} limit.",
|
||||
"quotaExceeded": "Storage is full — {used} of {quota} used.",
|
||||
"quotaExceeded": "Storage is full: {used} of {quota} used.",
|
||||
"unsupportedType": "This file type is not supported.",
|
||||
"purged": "This document was permanently removed and cannot be restored.",
|
||||
"duplicateExists": "An identical document is already stored.",
|
||||
"rateLimited": "Too many uploads right now — try again in a few minutes.",
|
||||
"rateLimited": "Too many uploads right now. Try again in a few minutes.",
|
||||
"generic": "Upload failed. Please try again."
|
||||
},
|
||||
"toast": {
|
||||
"duplicate": "Already stored — highlighting the existing document.",
|
||||
"duplicate": "Already stored. The existing document is highlighted.",
|
||||
"deleted": "Document deleted",
|
||||
"restoreFailed": "Couldn't restore the document."
|
||||
},
|
||||
@@ -10833,7 +10818,7 @@
|
||||
},
|
||||
"empty": {
|
||||
"title": "Your document vault is empty",
|
||||
"description": "Store doctor letters, lab reports, imaging, prescriptions, and vaccination records — encrypted, and searchable by type, condition, and year.",
|
||||
"description": "Store doctor letters, lab reports, imaging, prescriptions and vaccination records. They are encrypted and searchable by type, condition and year.",
|
||||
"action": "Upload a first document",
|
||||
"noMatchesTitle": "No matching documents",
|
||||
"noMatchesDescription": "Nothing matches the current search and filters."
|
||||
@@ -10873,9 +10858,9 @@
|
||||
"linkCondition": "Link",
|
||||
"noConditions": "Not linked to a condition",
|
||||
"previewUnavailable": "No inline preview for this file type",
|
||||
"uploadedMeta": "Added {date} · {size}",
|
||||
"uploadedMeta": "Added {date}, {size}",
|
||||
"importedFrom": "Imported from {system}",
|
||||
"importedFromWithId": "Imported from {system} · ID {id}",
|
||||
"importedFromWithId": "Imported from {system}, ID {id}",
|
||||
"sourceOther": "another system",
|
||||
"share": "Share",
|
||||
"labValuesLink": "View {count} lab values",
|
||||
@@ -10942,7 +10927,7 @@
|
||||
"errorNotIndexed": "Read the document first, then extract.",
|
||||
"errorLocalOcrDisabled": "Turn on local OCR in the AI settings first.",
|
||||
"errorProvider": "No AI provider is set up for this.",
|
||||
"errorTextImageOnly": "Local OCR reads images only — not this file.",
|
||||
"errorTextImageOnly": "Local OCR reads images only, not this file.",
|
||||
"errorConsent": "Reading documents with AI needs your consent first.",
|
||||
"errorUnavailable": "Reading documents with AI is not available here right now."
|
||||
},
|
||||
@@ -10975,7 +10960,7 @@
|
||||
"CONDITION": "Condition",
|
||||
"MEDICATION_STATEMENT": "Medication"
|
||||
},
|
||||
"statedRange": "stated range {range}",
|
||||
"statedRange": "Stated range {range}",
|
||||
"needsCheck": "Check this value against the document, then save it.",
|
||||
"verify": "Check",
|
||||
"verifySave": "Save value",
|
||||
@@ -11245,14 +11230,14 @@
|
||||
}
|
||||
},
|
||||
"hydration": {
|
||||
"referenceMeta": "Reference intake {value} {unit}/day · EFSA"
|
||||
"referenceMeta": "Reference intake {value} {unit}/day (EFSA)"
|
||||
},
|
||||
"caffeine": {
|
||||
"ceilingMeta": "Safe-level ceiling {value} {unit}/day · EFSA"
|
||||
"ceilingMeta": "Safe-level ceiling {value} {unit}/day (EFSA)"
|
||||
},
|
||||
"micronutrients": {
|
||||
"title": "Vitamins & minerals",
|
||||
"rowMeta": "{days} of {window} days · ref {value} {unit} (EFSA)",
|
||||
"rowMeta": "{days} of {window} days, ref {value} {unit} (EFSA)",
|
||||
"rowMetaNoRef": "{days} of {window} days",
|
||||
"footer": "{tracked} of {total} tracked micronutrients have data in this window.",
|
||||
"emptyTitle": "No micronutrient data synced yet",
|
||||
@@ -11267,7 +11252,7 @@
|
||||
},
|
||||
"push": {
|
||||
"title": "Your morning briefing",
|
||||
"bodyProvisional": "{line} · Last night's sleep isn't in yet."
|
||||
"bodyProvisional": "{line}\nLast night's sleep isn't in yet."
|
||||
},
|
||||
"item": {
|
||||
"doseWindow": {
|
||||
@@ -11655,7 +11640,7 @@
|
||||
"managed": {
|
||||
"title": "Records you look after",
|
||||
"description": "Health records with no login of their own.",
|
||||
"explainer": "A managed profile is a health record for somebody who does not sign in themselves — a child, or a person you care for. It has no login and no e-mail address, and you become its first guardian. You can add more guardians later, and any of you can switch into the record to keep it up to date.",
|
||||
"explainer": "A managed profile is a health record for somebody who does not sign in themselves, such as a child or a person you care for. It has no login and no e-mail address, and you become its first guardian. You can add more guardians later, and any of you can switch into the record to keep it up to date.",
|
||||
"nameLabel": "Name",
|
||||
"namePlaceholder": "The name you will recognise this record by",
|
||||
"nameHint": "Up to 80 characters. Shown wherever this record appears.",
|
||||
@@ -11677,7 +11662,7 @@
|
||||
"creating": "Creating…",
|
||||
"created": "{name} is set up. You can switch into that record from the account menu.",
|
||||
"errorStepUp": "Confirm your second factor, then try again.",
|
||||
"errorOffline": "That did not reach the server. Nothing has changed — check your connection and try again.",
|
||||
"errorOffline": "That did not reach the server. Nothing has changed. Check your connection and try again.",
|
||||
"errorInvalid": "The server refused those details. Check the name, the date and the timezone.",
|
||||
"errorFailed": "The profile could not be created.",
|
||||
"edit": "Edit",
|
||||
@@ -11688,7 +11673,7 @@
|
||||
"editErrorFailed": "The changes could not be saved.",
|
||||
"delete": "Delete profile",
|
||||
"deleteTitle": "Delete {name}?",
|
||||
"deleteBody": "Everything recorded for {name} goes with the profile — readings, medications, documents and notes. This cannot be undone.",
|
||||
"deleteBody": "Everything recorded for {name} goes with the profile: readings, medications, documents and notes. This cannot be undone.",
|
||||
"deleteGuardiansOne": "{count} person looks after this record today and will lose it.",
|
||||
"deleteGuardiansFew": "{count} people look after this record today and will lose it.",
|
||||
"deleteGuardiansOther": "{count} people look after this record today and will lose it.",
|
||||
@@ -11974,7 +11959,7 @@
|
||||
"both": "Both sides",
|
||||
"none": "Not stated"
|
||||
},
|
||||
"bodySiteWithSide": "{site} · {side}",
|
||||
"bodySiteWithSide": "{site} ({side})",
|
||||
"sectionTitle": "Visits",
|
||||
"sectionDescription": "Doctor's visits and booked appointments in one place.",
|
||||
"add": "Visit",
|
||||
@@ -12236,7 +12221,6 @@
|
||||
"loadFailed": "This day could not be loaded.",
|
||||
"dayOf": "Day {n} of {m}",
|
||||
"dayN": "Day {n}",
|
||||
"since": "since {date}",
|
||||
"usualRange": "Usually {lo} to {hi}",
|
||||
"groups": {
|
||||
"scores": "Scores",
|
||||
@@ -12264,11 +12248,11 @@
|
||||
"description": "Everything your records hold for this day.",
|
||||
"openDocument": "Open document",
|
||||
"previousReading": "Before, {date}",
|
||||
"rugLegend": "Dots under the axis: days with entries · ring: notable day",
|
||||
"rugLegend": "Dots under the axis mark days with entries, a ring marks a notable day.",
|
||||
"sheetExpand": "Expand to full height",
|
||||
"sheetShrink": "Shrink",
|
||||
"usualLabel": "Your 30 days before",
|
||||
"valuesLegend": "Dot: this day · bar: your 30 days before",
|
||||
"valuesLegend": "Dot for this day, bar for the 30 days before",
|
||||
"valuesAllOne": "All values",
|
||||
"valuesAllFew": "All {count} values",
|
||||
"valuesAllOther": "All {count} values",
|
||||
@@ -12302,7 +12286,13 @@
|
||||
"resumed": "{label} resumed",
|
||||
"illnessBegan": "{label} began",
|
||||
"illnessResolved": "{label} resolved"
|
||||
}
|
||||
},
|
||||
"pickDate": "Choose a date",
|
||||
"pickerTitle": "Go to a day",
|
||||
"today": "Today",
|
||||
"hasEntries": "has entries",
|
||||
"dayNSince": "Day {n}, since {date}",
|
||||
"sinceDate": "Since {date}"
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Timeline",
|
||||
@@ -12376,9 +12366,9 @@
|
||||
"vaccinationsOne": "{count} vaccination",
|
||||
"vaccinationsFew": "{count} vaccinations",
|
||||
"vaccinationsOther": "{count} vaccinations",
|
||||
"visitsOne": "{count} visit · {procedures} with a procedure",
|
||||
"visitsFew": "{count} visits · {procedures} with a procedure",
|
||||
"visitsOther": "{count} visits · {procedures} with a procedure",
|
||||
"visitsOne": "{count} visit, {procedures} with a procedure",
|
||||
"visitsFew": "{count} visits, {procedures} with a procedure",
|
||||
"visitsOther": "{count} visits, {procedures} with a procedure",
|
||||
"visitsPlannedOne": "{count} visit, planned only",
|
||||
"visitsPlannedFew": "{count} visits, planned only",
|
||||
"visitsPlannedOther": "{count} visits, planned only",
|
||||
|
||||
+112
-122
@@ -206,7 +206,7 @@
|
||||
"colExpected": "Esperadas",
|
||||
"colComplianceRate": "Tasa de cumplimiento",
|
||||
"moodTitle": "Evolución del estado de ánimo",
|
||||
"moodSummary": "Promedio:{avg} / 5.0 · Entries: {count} · Range: {min}–{max}",
|
||||
"moodSummary": "Promedio {avg} / 5.0, {count} entradas, rango {min}–{max}",
|
||||
"colMood": "Estado de ánimo",
|
||||
"colCount": "Cantidad",
|
||||
"colShare": "Porcentaje",
|
||||
@@ -579,8 +579,8 @@
|
||||
"sleepShort": "Sueño",
|
||||
"stepsShort": "Pasos",
|
||||
"vo2MaxShort": "VO₂ máx",
|
||||
"bpInTargetWindow90": "PA-objetivo · 90 d",
|
||||
"bpInTargetWindowDynamic": "PA-objetivo · {days} d",
|
||||
"bpInTargetWindow90": "PA-objetivo (90 d)",
|
||||
"bpInTargetWindowDynamic": "PA-objetivo ({days} d)",
|
||||
"bpInTargetCollecting": "recopilando datos",
|
||||
"medications": "Medicamentos",
|
||||
"quickAddMeasurement": "Registrar medición",
|
||||
@@ -1336,7 +1336,7 @@
|
||||
"complianceInfoLabel": "¿Qué significa el cumplimiento?",
|
||||
"lastIntake": "Última toma:",
|
||||
"nextIntake": "Próxima toma:",
|
||||
"nextIntakeOverdue": "Atrasada · {time} — aún se puede tomar",
|
||||
"nextIntakeOverdue": "Atrasada desde {time}, aún se puede tomar",
|
||||
"dayStreak": "días de racha",
|
||||
"dayStreakOne": "día de racha",
|
||||
"cycleDueToday": "Pendiente hoy",
|
||||
@@ -1737,7 +1737,7 @@
|
||||
"empty": "Aún no hay pasos de escalada de dosis registrados. Añade cambios de dosis con fecha para trazar el plan.",
|
||||
"youAreHere": "Estás aquí",
|
||||
"doseStep": "{dose} {unit}",
|
||||
"plannedFor": "Previsto · {date}"
|
||||
"plannedFor": "Previsto para {date}"
|
||||
},
|
||||
"injectionSiteBodyOutlineAriaLabel": "Silueta del cuerpo",
|
||||
"injectionSitePickerAriaLabel": "Selector de sitio de inyección",
|
||||
@@ -1788,7 +1788,7 @@
|
||||
"intakeHistoryStatusSkipped": "Omitida",
|
||||
"intakeHistorySortByTaken": "Ordenar por hora de toma",
|
||||
"intakeHistorySortByScheduled": "Ordenar por hora programada",
|
||||
"intakeHistoryPageInfo": "Página {page} de {total} · {count} entradas",
|
||||
"intakeHistoryPageInfo": "Página {page} de {total}, {count} entradas",
|
||||
"intakeHistoryPrev": "Anterior",
|
||||
"intakeHistoryNext": "Siguiente",
|
||||
"glp1": {
|
||||
@@ -1888,7 +1888,7 @@
|
||||
"markAsTaken": "Marcar como tomada",
|
||||
"markAsSkipped": "Marcar como omitida",
|
||||
"adHocTag": "fuera de horario",
|
||||
"takenDetail": "prevista {planned} · tomada {taken}",
|
||||
"takenDetail": "prevista {planned}, tomada {taken}",
|
||||
"doseTaken": "Dosis: {dose}",
|
||||
"status": {
|
||||
"taken_on_time": "a tiempo",
|
||||
@@ -2594,26 +2594,26 @@
|
||||
"ENV_POLLEN_MAX": "el polen"
|
||||
},
|
||||
"daily": {
|
||||
"high": "Cuando {behaviour} es mayor, {outcome} del día siguiente tiende a {direction} en tus datos — un patrón que conviene observar, no una causa.",
|
||||
"moderate": "Cuando {behaviour} es mayor, {outcome} del día siguiente parece tender a {direction}, según los datos disponibles — un patrón que conviene observar, no una causa.",
|
||||
"faint": "Cuando {behaviour} es mayor, apenas se insinúa —si acaso— que {outcome} del día siguiente tienda a {direction} en tus datos — demasiado leve para fiarse, nunca una causa."
|
||||
"high": "Cuando {behaviour} es mayor, {outcome} del día siguiente tiende a {direction} en tus datos. Es un patrón que conviene observar, no una causa.",
|
||||
"moderate": "Cuando {behaviour} es mayor, {outcome} del día siguiente parece tender a {direction}, según los datos disponibles. Es un patrón que conviene observar, no una causa.",
|
||||
"faint": "Cuando {behaviour} es mayor, como mucho se insinúa en tus datos que {outcome} del día siguiente tienda a {direction}. Es demasiado leve para fiarse y nunca una causa."
|
||||
},
|
||||
"lab": {
|
||||
"default": "Unos valores de {lab} más altos coinciden con que {outcome} tienda a {direction} en los mismos periodos de tus datos — una asociación que conviene vigilar con tu médico, nunca una causa.",
|
||||
"faint": "Unos valores de {lab} más altos apenas insinúan —si acaso— que {outcome} tienda a {direction} en los mismos periodos de tus datos — demasiado leve para fiarse, una asociación que conviene comentar con tu médico, nunca una causa."
|
||||
"default": "Unos valores de {lab} más altos coinciden con que {outcome} tienda a {direction} en los mismos periodos de tus datos, una asociación que conviene vigilar con tu médico, nunca una causa.",
|
||||
"faint": "Unos valores de {lab} más altos apenas insinúan que {outcome} tienda a {direction} en los mismos periodos de tus datos. Es demasiado leve para fiarse y nunca una causa, pero conviene comentarlo con tu médico."
|
||||
},
|
||||
"hypothesis": {
|
||||
"bpCompliance": {
|
||||
"support": "Una mayor adherencia a la medicación coincide con valores sistólicos más bajos — un patrón que conviene observar.",
|
||||
"surprising": "La adherencia y la sistólica se mueven juntas en tus datos — sorprendente; consulta con tu médico antes de cambiar nada.",
|
||||
"support": "Una mayor adherencia a la medicación coincide con valores sistólicos más bajos, un patrón que conviene observar.",
|
||||
"surprising": "La adherencia y la sistólica se mueven juntas en tus datos, algo sorprendente. Consulta con tu médico antes de cambiar nada.",
|
||||
"none": "La adherencia y la sistólica no se mueven juntas de forma marcada en este periodo."
|
||||
},
|
||||
"moodPulse": {
|
||||
"support": "Los días de peor ánimo coinciden con un pulso en reposo más alto — un patrón que conviene observar.",
|
||||
"surprising": "Los días de mejor ánimo coinciden con un pulso en reposo más alto en tus datos — sorprendente.",
|
||||
"support": "Los días de peor ánimo coinciden con un pulso en reposo más alto, un patrón que conviene observar.",
|
||||
"surprising": "Los días de mejor ánimo coinciden con un pulso en reposo más alto en tus datos, algo sorprendente.",
|
||||
"none": "El ánimo y el pulso en reposo no se mueven juntos de forma marcada en este periodo."
|
||||
},
|
||||
"weightWeekday": "El peso de los {weekday} está {delta} kg {direction} tu media de los demás días — un patrón que conviene observar."
|
||||
"weightWeekday": "El peso de los {weekday} está {delta} kg {direction} tu media de los demás días, un patrón que conviene observar."
|
||||
},
|
||||
"dailySameDay": {
|
||||
"high": "Más {behaviour} el mismo día y el día anterior tiende a ir con {direction} {outcome} en tus datos: un patrón que conviene observar, no una causa.",
|
||||
@@ -2730,7 +2730,7 @@
|
||||
"referenceSleep": "Al menos {low} minutos por noche; el sueño más largo no se penaliza",
|
||||
"referenceAdiposity": "Relación cintura-altura inferior a {high}",
|
||||
"referenceHba1c": "HbA1c de {low} a {high} %",
|
||||
"referenceDetail": "{label} · {source} · {kind} · {low} a {high}",
|
||||
"referenceDetail": "{label}: de {low} a {high} ({kind}, {source})",
|
||||
"openBound": "abierto",
|
||||
"laboratoryReferenceSource": "Intervalos de referencia del laboratorio",
|
||||
"personalTargetSource": "Objetivo personal",
|
||||
@@ -2824,7 +2824,7 @@
|
||||
"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}",
|
||||
"sourceChip": "basado en {n} mediciones pareadas ({window})",
|
||||
"sourceWindowLast30": "Últimos 30 días",
|
||||
"confidenceHigh": "Confianza alta",
|
||||
"confidenceModerate": "Confianza media",
|
||||
@@ -3003,23 +3003,23 @@
|
||||
"cluster": {
|
||||
"cardio": {
|
||||
"label": "Cardiovascular",
|
||||
"hint": "Presión · pulso · VFC · FC en reposo y al caminar"
|
||||
"hint": "Presión, pulso, VFC, FC en reposo y al caminar"
|
||||
},
|
||||
"body": {
|
||||
"label": "Composición corporal",
|
||||
"hint": "Peso · grasa · músculo · IMC"
|
||||
"hint": "Peso, grasa, músculo e IMC"
|
||||
},
|
||||
"activity": {
|
||||
"label": "Actividad",
|
||||
"hint": "Pasos · energía · distancia · VO₂ máx"
|
||||
"hint": "Pasos, energía, distancia y VO₂ máx"
|
||||
},
|
||||
"workouts": {
|
||||
"label": "Entrenamientos",
|
||||
"hint": "Carreras · paseos · sesiones"
|
||||
"hint": "Carreras, paseos y sesiones"
|
||||
},
|
||||
"sleep": {
|
||||
"label": "Sueño",
|
||||
"hint": "Duración · fases"
|
||||
"hint": "Duración y fases"
|
||||
},
|
||||
"mood": {
|
||||
"label": "Estado de ánimo",
|
||||
@@ -3031,15 +3031,15 @@
|
||||
},
|
||||
"medication": {
|
||||
"label": "Medicación",
|
||||
"hint": "Adherencia · contexto GLP-1"
|
||||
"hint": "Adherencia y contexto GLP-1"
|
||||
},
|
||||
"mobility": {
|
||||
"label": "Movilidad y marcha",
|
||||
"hint": "Estabilidad · asimetría · marcha"
|
||||
"hint": "Estabilidad, asimetría y marcha"
|
||||
},
|
||||
"environment": {
|
||||
"label": "Entorno",
|
||||
"hint": "Ruido · luz del día · temp. piel"
|
||||
"hint": "Ruido, luz del día y temperatura de la piel"
|
||||
}
|
||||
},
|
||||
"settingsClustersLabel": "Datos que ve el Coach",
|
||||
@@ -3092,21 +3092,7 @@
|
||||
"driver": "{note}"
|
||||
},
|
||||
"scope": {
|
||||
"prefix": "El Coach ya está en {metric}",
|
||||
"tap": "Toca para preguntar",
|
||||
"question": "Explícame esta métrica — ¿hay algo que deba vigilar?"
|
||||
},
|
||||
"seeded": {
|
||||
"prefix": "Hoy merece un vistazo: {signal}",
|
||||
"signal": {
|
||||
"readiness": "disposición",
|
||||
"recovery": "recuperación"
|
||||
},
|
||||
"question": {
|
||||
"readiness": "Mi disposición está rara hoy — ¿a qué se debe?",
|
||||
"recovery": "Mi recuperación ha cambiado — ¿qué hay detrás?"
|
||||
},
|
||||
"dismiss": "Descartar"
|
||||
"prefix": "El Coach ya está en {metric}"
|
||||
},
|
||||
"settings": "Ajustes",
|
||||
"history": {
|
||||
@@ -3227,9 +3213,9 @@
|
||||
"cap": "Muchos pasos, ahora lo resume",
|
||||
"noProgress": "No hay datos nuevos, lo resume"
|
||||
},
|
||||
"thoughtProcessOne": "Razonamiento · {count} paso",
|
||||
"thoughtProcessFew": "Razonamiento · {count} pasos",
|
||||
"thoughtProcessOther": "Razonamiento · {count} pasos",
|
||||
"thoughtProcessOne": "Razonamiento ({count} paso)",
|
||||
"thoughtProcessFew": "Razonamiento ({count} pasos)",
|
||||
"thoughtProcessOther": "Razonamiento ({count} pasos)",
|
||||
"awaitingAnswer": "Se necesita tu respuesta"
|
||||
},
|
||||
"memory": {
|
||||
@@ -3269,7 +3255,8 @@
|
||||
"capped": "(limitado por el administrador)",
|
||||
"disabled": "El administrador ha desactivado el razonamiento profundo."
|
||||
},
|
||||
"memoryLink": "Memoria ({count})"
|
||||
"memoryLink": "Memoria ({count})",
|
||||
"newConversation": "Nueva conversación"
|
||||
},
|
||||
"relativeJustNow": "ahora mismo",
|
||||
"relativeMinutesAgoOne": "hace {count} minuto",
|
||||
@@ -4062,7 +4049,7 @@
|
||||
"distributionDaysUnit": "días",
|
||||
"weekdayTitle": "Promedio por día de la semana",
|
||||
"weekdayNoData": "Sin datos",
|
||||
"weekdayBestWorst": "Mejor día: {best} · Día más difícil: {worst}",
|
||||
"weekdayBestWorst": "Mejor día: {best}, día más difícil: {worst}",
|
||||
"tagsTitle": "Etiquetas y desencadenantes",
|
||||
"tagAvgMood": "Ánimo promedio con esta etiqueta",
|
||||
"correlationsTitle": "Ánimo y otras métricas",
|
||||
@@ -4078,7 +4065,7 @@
|
||||
"strengthModerate": "Moderada",
|
||||
"strengthWeak": "Débil",
|
||||
"strengthNone": "Ninguna",
|
||||
"source": "{n} días emparejados · r = {r}",
|
||||
"source": "{n} días emparejados, r = {r}",
|
||||
"emptyTitle": "Aún no hay suficientes datos emparejados",
|
||||
"emptyDescription": "Registra el ánimo y esta métrica los mismos días para ver una correlación.",
|
||||
"pendingDescription": "{n} de {needed} días emparejados. La correlación aparece cuando hay días suficientes para distinguir un patrón del azar.",
|
||||
@@ -4137,7 +4124,7 @@
|
||||
"crosstab": {
|
||||
"title": "Etiquetas y tus métricas de salud",
|
||||
"description": "Cómo difiere una métrica de salud en los días que etiquetaste cada actividad, frente a los días que no.",
|
||||
"pairLabel": "{tag} · {metric}",
|
||||
"pairLabel": "{metric} en días con {tag}",
|
||||
"metricActiveEnergy": "Energía activa",
|
||||
"metricSleepDuration": "Sueño",
|
||||
"metricNextDayRecovery": "Recuperación al día siguiente",
|
||||
@@ -4154,7 +4141,7 @@
|
||||
"factorCrosstab": {
|
||||
"title": "Tus factores valorados y tus constantes",
|
||||
"description": "Cómo difiere una constante en los días en que valoraste bajo cada factor frente a los días en que lo valoraste alto.",
|
||||
"pairLabel": "{factor} · {metric}",
|
||||
"pairLabel": "{metric} en días con {factor}",
|
||||
"metricSleepDuration": "Sueño",
|
||||
"metricSteps": "Pasos",
|
||||
"metricRestingHeartRate": "Frecuencia cardíaca en reposo",
|
||||
@@ -4180,7 +4167,7 @@
|
||||
"moodDown": "Un mayor {factor} suele coincidir con peor ánimo al día siguiente en tus datos.",
|
||||
"outcomeUp": "Mejor ánimo suele coincidir con un {factor} más alto al día siguiente en tus datos.",
|
||||
"outcomeDown": "Mejor ánimo suele coincidir con un {factor} más bajo al día siguiente en tus datos.",
|
||||
"stat": "{n} días emparejados · r = {r} · q = {q}",
|
||||
"stat": "{n} días emparejados, r = {r}, q = {q}",
|
||||
"statLabel": "Cómo se calculó esto",
|
||||
"explainerLabel": "Cómo se calculó esto"
|
||||
},
|
||||
@@ -4224,11 +4211,11 @@
|
||||
},
|
||||
"derived": {
|
||||
"coverage": {
|
||||
"summary": "{present} de {required} entradas · {percent}%",
|
||||
"summary": "{present} de {required} entradas ({percent}%)",
|
||||
"ratioLabel": "{present}/{required}",
|
||||
"historyDays": "Basado en {count} días de historial",
|
||||
"missing": "Registra estos para afinar el valor: {list}",
|
||||
"summaryLabelled": "{label} · {percent}%"
|
||||
"summaryLabelled": "{label} ({percent}%)"
|
||||
},
|
||||
"scoreRing": {
|
||||
"aria": "Puntuación {score} de 100, {band}",
|
||||
@@ -4246,7 +4233,7 @@
|
||||
"inputsLabel": "Entradas",
|
||||
"noInputs": "Aún no hay entradas que respalden este valor.",
|
||||
"methodLabel": "Método",
|
||||
"window": "De tus últimos {days} días · datos de {source}",
|
||||
"window": "De tus últimos {days} días de datos de {source}",
|
||||
"asOf": "a fecha de {date}",
|
||||
"standardLink": "Estándar: {name}"
|
||||
},
|
||||
@@ -4288,7 +4275,7 @@
|
||||
"anatomy": {
|
||||
"outOf": "/100",
|
||||
"contributorsLabel": "Factores",
|
||||
"contributorValue": "{value} · {weight}% de peso",
|
||||
"contributorValue": "{value}, peso {weight}%",
|
||||
"contributorMissing": "sin datos",
|
||||
"insufficient": "Aún no hay datos suficientes para puntuarlo. Sigue registrando y aparecerá aquí."
|
||||
},
|
||||
@@ -5633,8 +5620,8 @@
|
||||
"failed": "La importación falló. No se escribió nada.",
|
||||
"columnsHeading": "Qué hace cada columna",
|
||||
"jsonOnly": "solo JSON",
|
||||
"previewLine": "Filas leídas: {rows} · se importarían: {queued} · rechazadas: {refused}",
|
||||
"submittedLine": "Filas leídas: {rows} · en cola para importar: {queued}",
|
||||
"previewLine": "{rows} filas leídas, {queued} se importarían, {refused} rechazadas",
|
||||
"submittedLine": "{rows} filas leídas, {queued} en cola para importar",
|
||||
"unmatched": "Ningún medicamento registrado coincide con: {names}",
|
||||
"ambiguous": "Coincide con más de un medicamento: {names}",
|
||||
"mirrored": "Copiado de otra aplicación, así que no se puede añadir ninguna dosis: {names}",
|
||||
@@ -5811,7 +5798,8 @@
|
||||
"add": "Añadir {label}",
|
||||
"remove": "Eliminar periodo de ubicación",
|
||||
"removeConfirmTitle": "¿Quitar este viaje?",
|
||||
"removeConfirmBody": "Las mediciones de ese periodo permanecen; solo desaparece la entrada del viaje."
|
||||
"removeConfirmBody": "Las mediciones de ese periodo permanecen; solo desaparece la entrada del viaje.",
|
||||
"range": "Del {start} al {end}"
|
||||
},
|
||||
"backfill": {
|
||||
"title": "Rellenar días pasados",
|
||||
@@ -6520,7 +6508,7 @@
|
||||
},
|
||||
"integrationFreshness": {
|
||||
"title": "Datos recibidos",
|
||||
"summary": "{count} tipos de datos · más reciente {relative}",
|
||||
"summary": "{count} tipos de datos, más reciente {relative}",
|
||||
"quiet": "{count} en silencio",
|
||||
"workouts": "Entrenamientos"
|
||||
},
|
||||
@@ -7079,14 +7067,14 @@
|
||||
"title": "Progreso de sincronización",
|
||||
"receivedLabel": "Registros recibidos",
|
||||
"oldestLabel": "Lectura más antigua alcanzada",
|
||||
"flowing": "Han llegado datos en la última hora — la app del iPhone sigue enviando.",
|
||||
"waiting": "Esperando a que la app del iPhone envíe más datos. La transferencia continúa cuando la app está abierta o cuando iOS le concede tiempo en segundo plano — una pausa durante la primera sincronización grande es normal, no un error."
|
||||
"flowing": "Han llegado datos en la última hora. La app del iPhone sigue enviando.",
|
||||
"waiting": "Esperando a que la app del iPhone envíe más datos. La transferencia continúa cuando la app está abierta o cuando iOS le concede tiempo en segundo plano. Una pausa durante la primera sincronización grande es normal, no un error."
|
||||
},
|
||||
"delivery": {
|
||||
"title": "Entrega",
|
||||
"lastTriggerLabel": "La última sincronización llegó",
|
||||
"lastBackgroundLabel": "Última sincronización en segundo plano",
|
||||
"backgroundNever": "Nunca — hasta ahora todas las sincronizaciones llegaron con la app abierta",
|
||||
"backgroundNever": "Nunca: hasta ahora todas las sincronizaciones llegaron con la app abierta",
|
||||
"noSyncYet": "Aún no ha llegado ninguna sincronización, así que no hay nada que informar aquí.",
|
||||
"trigger": {
|
||||
"foreground": "con la app abierta",
|
||||
@@ -8086,19 +8074,19 @@
|
||||
"description": "Toma todos los medicamentos a tiempo durante 1 día."
|
||||
},
|
||||
"compliance801": {
|
||||
"title": "Adherencia 80% · 1d",
|
||||
"title": "Adherencia del 80% durante 1 día",
|
||||
"description": "Alcanza al menos un 80% de adherencia de 30 días durante 1 día."
|
||||
},
|
||||
"bmiGreen1": {
|
||||
"title": "IMC en verde · 1d",
|
||||
"title": "IMC en verde durante 1 día",
|
||||
"description": "Mantén tu IMC en el rango normal durante 1 día."
|
||||
},
|
||||
"bpGreen1": {
|
||||
"title": "Presión en verde · 1d",
|
||||
"title": "Presión en verde durante 1 día",
|
||||
"description": "Mantén tu presión arterial en el rango normal durante 1 día."
|
||||
},
|
||||
"pulseGreen1": {
|
||||
"title": "Pulso en verde · 1d",
|
||||
"title": "Pulso en verde durante 1 día",
|
||||
"description": "Mantén tu pulso en reposo en el rango normal durante 1 día."
|
||||
},
|
||||
"onTimePerfect7": {
|
||||
@@ -8106,19 +8094,19 @@
|
||||
"description": "Toma todos los medicamentos a tiempo durante 7 días seguidos."
|
||||
},
|
||||
"compliance807": {
|
||||
"title": "Adherencia 80% · 7d",
|
||||
"title": "Adherencia del 80% durante 7 días",
|
||||
"description": "Alcanza al menos un 80% de adherencia de 30 días durante 7 días seguidos."
|
||||
},
|
||||
"bmiGreen7": {
|
||||
"title": "IMC en verde · 7d",
|
||||
"title": "IMC en verde durante 7 días",
|
||||
"description": "Mantén tu IMC en el rango normal durante 7 días seguidos."
|
||||
},
|
||||
"bpGreen7": {
|
||||
"title": "Presión en verde · 7d",
|
||||
"title": "Presión en verde durante 7 días",
|
||||
"description": "Mantén tu presión arterial en el rango normal durante 7 días seguidos."
|
||||
},
|
||||
"pulseGreen7": {
|
||||
"title": "Pulso en verde · 7d",
|
||||
"title": "Pulso en verde durante 7 días",
|
||||
"description": "Mantén tu pulso en reposo en el rango normal durante 7 días seguidos."
|
||||
},
|
||||
"onTimePerfect30": {
|
||||
@@ -8126,19 +8114,19 @@
|
||||
"description": "Toma todos los medicamentos a tiempo durante 30 días seguidos."
|
||||
},
|
||||
"compliance8030": {
|
||||
"title": "Adherencia 80% · 30d",
|
||||
"title": "Adherencia del 80% durante 30 días",
|
||||
"description": "Alcanza al menos un 80% de adherencia de 30 días durante 30 días seguidos."
|
||||
},
|
||||
"bmiGreen30": {
|
||||
"title": "IMC en verde · 30d",
|
||||
"title": "IMC en verde durante 30 días",
|
||||
"description": "Mantén tu IMC en el rango normal durante 30 días seguidos."
|
||||
},
|
||||
"bpGreen30": {
|
||||
"title": "Presión en verde · 30d",
|
||||
"title": "Presión en verde durante 30 días",
|
||||
"description": "Mantén tu presión arterial en el rango normal durante 30 días seguidos."
|
||||
},
|
||||
"pulseGreen30": {
|
||||
"title": "Pulso en verde · 30d",
|
||||
"title": "Pulso en verde durante 30 días",
|
||||
"description": "Mantén tu pulso en reposo en el rango normal durante 30 días seguidos."
|
||||
},
|
||||
"onTimePerfect180": {
|
||||
@@ -8146,19 +8134,19 @@
|
||||
"description": "Toma todos los medicamentos a tiempo durante 180 días seguidos."
|
||||
},
|
||||
"compliance80180": {
|
||||
"title": "Adherencia 80% · 180d",
|
||||
"title": "Adherencia del 80% durante 180 días",
|
||||
"description": "Alcanza al menos un 80% de adherencia de 30 días durante 180 días seguidos."
|
||||
},
|
||||
"bmiGreen180": {
|
||||
"title": "IMC en verde · 180d",
|
||||
"title": "IMC en verde durante 180 días",
|
||||
"description": "Mantén tu IMC en el rango normal durante 180 días seguidos."
|
||||
},
|
||||
"bpGreen180": {
|
||||
"title": "Presión en verde · 180d",
|
||||
"title": "Presión en verde durante 180 días",
|
||||
"description": "Mantén tu presión arterial en el rango normal durante 180 días seguidos."
|
||||
},
|
||||
"pulseGreen180": {
|
||||
"title": "Pulso en verde · 180d",
|
||||
"title": "Pulso en verde durante 180 días",
|
||||
"description": "Mantén tu pulso en reposo en el rango normal durante 180 días seguidos."
|
||||
},
|
||||
"onTimePerfect360": {
|
||||
@@ -8166,19 +8154,19 @@
|
||||
"description": "Toma todos los medicamentos a tiempo durante 360 días seguidos."
|
||||
},
|
||||
"compliance80360": {
|
||||
"title": "Adherencia 80% · 360d",
|
||||
"title": "Adherencia del 80% durante 360 días",
|
||||
"description": "Alcanza al menos un 80% de adherencia de 30 días durante 360 días seguidos."
|
||||
},
|
||||
"bmiGreen360": {
|
||||
"title": "IMC en verde · 360d",
|
||||
"title": "IMC en verde durante 360 días",
|
||||
"description": "Mantén tu IMC en el rango normal durante 360 días seguidos."
|
||||
},
|
||||
"bpGreen360": {
|
||||
"title": "Presión en verde · 360d",
|
||||
"title": "Presión en verde durante 360 días",
|
||||
"description": "Mantén tu presión arterial en el rango normal durante 360 días seguidos."
|
||||
},
|
||||
"pulseGreen360": {
|
||||
"title": "Pulso en verde · 360d",
|
||||
"title": "Pulso en verde durante 360 días",
|
||||
"description": "Mantén tu pulso en reposo en el rango normal durante 360 días seguidos."
|
||||
},
|
||||
"moodFirst": {
|
||||
@@ -8262,15 +8250,15 @@
|
||||
"description": "Cambiaste el idioma de la app al menos una vez."
|
||||
},
|
||||
"missFree7": {
|
||||
"title": "Sin dosis perdidas · 7d",
|
||||
"title": "Sin dosis perdidas en 7 días",
|
||||
"description": "7 días consecutivos sin una dosis perdida."
|
||||
},
|
||||
"missFree30": {
|
||||
"title": "Sin dosis perdidas · 30d",
|
||||
"title": "Sin dosis perdidas en 30 días",
|
||||
"description": "30 días consecutivos sin una dosis perdida."
|
||||
},
|
||||
"missFree90": {
|
||||
"title": "Sin dosis perdidas · 90d",
|
||||
"title": "Sin dosis perdidas en 90 días",
|
||||
"description": "90 días consecutivos sin una dosis perdida."
|
||||
},
|
||||
"measurementWeeks4": {
|
||||
@@ -8282,7 +8270,7 @@
|
||||
"description": "Los cuatro campos del autoinforme completados — el Coach conoce tu contexto."
|
||||
},
|
||||
"sleepLog7": {
|
||||
"title": "Registro de sueño · 7d",
|
||||
"title": "Registro de sueño durante 7 días",
|
||||
"description": "Datos de sueño registrados durante 7 días consecutivos."
|
||||
}
|
||||
},
|
||||
@@ -8372,9 +8360,6 @@
|
||||
"enabledToast": "Avisos proactivos del Coach activados.",
|
||||
"disabledToast": "Avisos proactivos del Coach desactivados.",
|
||||
"saveError": "No se pudo guardar. Inténtalo de nuevo más tarde.",
|
||||
"suggestionsLabel": "Sugerencias de ejemplo",
|
||||
"suggestionsDesc": "Muestra de vez en cuando una pregunta de ejemplo en la pantalla del Coach.",
|
||||
"suggestionsAria": "Activar o desactivar las sugerencias de ejemplo del Coach",
|
||||
"aiComposedLabel": "Deja que el Coach redacte el aviso",
|
||||
"aiComposedDesc": "Cuando hay un proveedor disponible, el aviso proactivo lo redacta el modelo en lugar de la plantilla fija. Desactivado por defecto.",
|
||||
"aiComposedAria": "Activar o desactivar el aviso proactivo redactado por IA"
|
||||
@@ -8792,7 +8777,7 @@
|
||||
},
|
||||
"crosstab": {
|
||||
"description": "Cómo se compara cada medición entre tus fases lútea y folicular, con tus propios datos.",
|
||||
"pairLabel": "{metric} · lútea vs. folicular",
|
||||
"pairLabel": "{metric}: fase lútea vs. folicular",
|
||||
"detail": "Lútea {lutealAvg} {unit} ({lutealDays} días) vs. folicular {follicularAvg} {unit} ({follicularDays} días).",
|
||||
"metricRestingHeartRate": "Frecuencia cardíaca en reposo",
|
||||
"metricHrv": "Variabilidad de la frecuencia cardíaca",
|
||||
@@ -9012,7 +8997,7 @@
|
||||
"temperatureSection": "Temperatura y ovulación",
|
||||
"intimacySection": "Intimidad y anticoncepción",
|
||||
"testsSection": "Pruebas",
|
||||
"phaseDayHeader": "Día {day} · {phase}",
|
||||
"phaseDayHeader": "Día {day}, {phase}",
|
||||
"bbtHint": "Mídela a primera hora de la mañana, antes de levantarte.",
|
||||
"titleForDate": "Registro del {date}",
|
||||
"descriptionToday": "Hoy. Flujo, síntomas y notas.",
|
||||
@@ -9189,10 +9174,10 @@
|
||||
"VISCERAL_FAT": "Grasa visceral",
|
||||
"BODY_MASS_INDEX": "Índice de masa corporal (IMC)",
|
||||
"WAIST_CIRCUMFERENCE": "Perímetro de cintura",
|
||||
"PHQ9_SCORE": "PHQ-9 · ánimo",
|
||||
"GAD7_SCORE": "GAD-7 · preocupación",
|
||||
"WHO5_SCORE": "WHO-5 · bienestar",
|
||||
"SCI_SCORE": "SCI · sueño"
|
||||
"PHQ9_SCORE": "PHQ-9 (ánimo)",
|
||||
"GAD7_SCORE": "GAD-7 (preocupación)",
|
||||
"WHO5_SCORE": "WHO-5 (bienestar)",
|
||||
"SCI_SCORE": "SCI (sueño)"
|
||||
},
|
||||
"typeGroups": {
|
||||
"vitals": "Constantes vitales",
|
||||
@@ -10289,10 +10274,10 @@
|
||||
"saved": "Guardado",
|
||||
"error": "Algo salió mal. Inténtalo de nuevo.",
|
||||
"instrument": {
|
||||
"phq9": "PHQ-9 · ánimo",
|
||||
"gad7": "GAD-7 · preocupación",
|
||||
"who5": "WHO-5 · bienestar",
|
||||
"sci": "SCI · sueño"
|
||||
"phq9": "PHQ-9 (ánimo)",
|
||||
"gad7": "GAD-7 (preocupación)",
|
||||
"who5": "WHO-5 (bienestar)",
|
||||
"sci": "SCI (sueño)"
|
||||
},
|
||||
"instrumentSub": {
|
||||
"phq9": "Cribado de depresión, 9 preguntas",
|
||||
@@ -10760,7 +10745,7 @@
|
||||
},
|
||||
"documents": {
|
||||
"title": "Documentos",
|
||||
"subtitle": "Cada carta, informe y escaneo en un solo lugar con búsqueda — cifrado en reposo.",
|
||||
"subtitle": "Cada carta, informe y escaneo en un solo lugar con búsqueda, cifrado en reposo.",
|
||||
"pageUpload": "Subir",
|
||||
"ownerOnlyUpload": "Solo el titular del historial puede añadir documentos.",
|
||||
"kind": {
|
||||
@@ -10802,15 +10787,15 @@
|
||||
},
|
||||
"error": {
|
||||
"fileTooLarge": "Este archivo supera el límite de {maxSize}.",
|
||||
"quotaExceeded": "El almacenamiento está lleno — {used} de {quota} usados.",
|
||||
"quotaExceeded": "El almacenamiento está lleno: {used} de {quota} usados.",
|
||||
"unsupportedType": "Este tipo de archivo no es compatible.",
|
||||
"purged": "Este documento se eliminó definitivamente y no se puede restaurar.",
|
||||
"duplicateExists": "Ya hay guardado un documento idéntico.",
|
||||
"rateLimited": "Demasiadas subidas — inténtalo de nuevo en unos minutos.",
|
||||
"rateLimited": "Demasiadas subidas. Inténtalo de nuevo en unos minutos.",
|
||||
"generic": "La subida falló. Inténtalo de nuevo."
|
||||
},
|
||||
"toast": {
|
||||
"duplicate": "Ya estaba guardado — se resalta el documento existente.",
|
||||
"duplicate": "Ya estaba guardado. Se resalta el documento existente.",
|
||||
"deleted": "Documento eliminado",
|
||||
"restoreFailed": "No se pudo restaurar el documento."
|
||||
},
|
||||
@@ -10833,7 +10818,7 @@
|
||||
},
|
||||
"empty": {
|
||||
"title": "Tu archivo de documentos está vacío",
|
||||
"description": "Guarda cartas médicas, resultados de laboratorio, imágenes, recetas y certificados de vacunación — cifrados y con búsqueda por tipo, afección y año.",
|
||||
"description": "Guarda cartas médicas, resultados de laboratorio, imágenes, recetas y certificados de vacunación. Se guardan cifrados y puedes buscarlos por tipo, afección y año.",
|
||||
"action": "Subir un primer documento",
|
||||
"noMatchesTitle": "No hay documentos coincidentes",
|
||||
"noMatchesDescription": "Nada coincide con la búsqueda y los filtros actuales."
|
||||
@@ -10873,9 +10858,9 @@
|
||||
"linkCondition": "Vincular",
|
||||
"noConditions": "Sin vincular a una afección",
|
||||
"previewUnavailable": "Sin vista previa integrada para este tipo de archivo",
|
||||
"uploadedMeta": "Añadido el {date} · {size}",
|
||||
"uploadedMeta": "Añadido el {date}, {size}",
|
||||
"importedFrom": "Importado de {system}",
|
||||
"importedFromWithId": "Importado de {system} · ID {id}",
|
||||
"importedFromWithId": "Importado de {system}, ID {id}",
|
||||
"sourceOther": "otro sistema",
|
||||
"share": "Compartir",
|
||||
"labValuesLink": "Ver {count} valores de laboratorio",
|
||||
@@ -10975,7 +10960,7 @@
|
||||
"CONDITION": "Afección",
|
||||
"MEDICATION_STATEMENT": "Medicamento"
|
||||
},
|
||||
"statedRange": "rango indicado {range}",
|
||||
"statedRange": "Rango indicado {range}",
|
||||
"needsCheck": "Comprueba este valor contra el documento y luego guárdalo.",
|
||||
"verify": "Comprobar",
|
||||
"verifySave": "Guardar valor",
|
||||
@@ -11245,14 +11230,14 @@
|
||||
}
|
||||
},
|
||||
"hydration": {
|
||||
"referenceMeta": "Ingesta de referencia {value} {unit}/día · EFSA"
|
||||
"referenceMeta": "Ingesta de referencia {value} {unit}/día (EFSA)"
|
||||
},
|
||||
"caffeine": {
|
||||
"ceilingMeta": "Límite de seguridad {value} {unit}/día · EFSA"
|
||||
"ceilingMeta": "Límite de seguridad {value} {unit}/día (EFSA)"
|
||||
},
|
||||
"micronutrients": {
|
||||
"title": "Vitaminas y minerales",
|
||||
"rowMeta": "{days} de {window} días · ref. {value} {unit} (EFSA)",
|
||||
"rowMeta": "{days} de {window} días, ref. {value} {unit} (EFSA)",
|
||||
"rowMetaNoRef": "{days} de {window} días",
|
||||
"footer": "{tracked} de {total} micronutrientes registrados tienen datos en este periodo.",
|
||||
"emptyTitle": "Aún no hay datos de micronutrientes sincronizados",
|
||||
@@ -11267,7 +11252,7 @@
|
||||
},
|
||||
"push": {
|
||||
"title": "Tu resumen matinal",
|
||||
"bodyProvisional": "{line} · El sueño de anoche aún no está disponible."
|
||||
"bodyProvisional": "{line}\nEl sueño de anoche aún no está disponible."
|
||||
},
|
||||
"item": {
|
||||
"doseWindow": {
|
||||
@@ -11655,7 +11640,7 @@
|
||||
"managed": {
|
||||
"title": "Perfiles de los que te ocupas",
|
||||
"description": "Historiales de salud sin inicio de sesión propio.",
|
||||
"explainer": "Un perfil administrado es un historial de salud para alguien que no inicia sesión — una criatura, o una persona a la que cuidas. No tiene inicio de sesión ni dirección de correo, y tú pasas a ser su primera persona responsable. Más adelante puedes añadir otras, y cualquiera de vosotras puede entrar en el historial para mantenerlo al día.",
|
||||
"explainer": "Un perfil administrado es un historial de salud para alguien que no inicia sesión, como una criatura o una persona a la que cuidas. No tiene inicio de sesión ni dirección de correo, y tú pasas a ser su primera persona responsable. Más adelante puedes añadir otras, y cualquiera de vosotras puede entrar en el historial para mantenerlo al día.",
|
||||
"nameLabel": "Nombre",
|
||||
"namePlaceholder": "El nombre con el que reconocerás este historial",
|
||||
"nameHint": "Hasta 80 caracteres. Se muestra allá donde aparezca este historial.",
|
||||
@@ -11677,7 +11662,7 @@
|
||||
"creating": "Creando…",
|
||||
"created": "{name} ya está listo. Puedes entrar en ese historial desde el menú de la cuenta.",
|
||||
"errorStepUp": "Confirma tu segundo factor y vuelve a intentarlo.",
|
||||
"errorOffline": "Esto no llegó al servidor. No ha cambiado nada — revisa tu conexión y vuelve a intentarlo.",
|
||||
"errorOffline": "Esto no llegó al servidor. No ha cambiado nada. Revisa tu conexión y vuelve a intentarlo.",
|
||||
"errorInvalid": "El servidor rechazó esos datos. Revisa el nombre, la fecha y la zona horaria.",
|
||||
"errorFailed": "No se pudo crear el perfil.",
|
||||
"edit": "Editar",
|
||||
@@ -11688,7 +11673,7 @@
|
||||
"editErrorFailed": "No se han podido guardar los cambios.",
|
||||
"delete": "Eliminar perfil",
|
||||
"deleteTitle": "¿Eliminar {name}?",
|
||||
"deleteBody": "Todo lo registrado para {name} desaparece con el perfil — mediciones, medicamentos, documentos y notas. Esto no se puede deshacer.",
|
||||
"deleteBody": "Todo lo registrado para {name} desaparece con el perfil: mediciones, medicamentos, documentos y notas. Esto no se puede deshacer.",
|
||||
"deleteGuardiansOne": "{count} persona se ocupa hoy de este historial y lo perderá.",
|
||||
"deleteGuardiansFew": "{count} personas se ocupan hoy de este historial y lo perderán.",
|
||||
"deleteGuardiansOther": "{count} personas se ocupan hoy de este historial y lo perderán.",
|
||||
@@ -11974,7 +11959,7 @@
|
||||
"both": "Ambos lados",
|
||||
"none": "Sin indicar"
|
||||
},
|
||||
"bodySiteWithSide": "{site} · {side}",
|
||||
"bodySiteWithSide": "{site} ({side})",
|
||||
"sectionTitle": "Citas",
|
||||
"sectionDescription": "Visitas médicas y citas reservadas en un solo sitio.",
|
||||
"add": "Cita",
|
||||
@@ -12236,7 +12221,6 @@
|
||||
"loadFailed": "No se pudo cargar este día.",
|
||||
"dayOf": "Día {n} de {m}",
|
||||
"dayN": "Día {n}",
|
||||
"since": "desde {date}",
|
||||
"usualRange": "Habitual de {lo} a {hi}",
|
||||
"groups": {
|
||||
"scores": "Puntuaciones",
|
||||
@@ -12264,11 +12248,11 @@
|
||||
"description": "Todo lo que tus registros contienen para este día.",
|
||||
"openDocument": "Abrir documento",
|
||||
"previousReading": "Antes, {date}",
|
||||
"rugLegend": "Puntos bajo el eje: días con entradas · anillo: día destacado",
|
||||
"rugLegend": "Los puntos bajo el eje marcan días con entradas, y un anillo, un día destacado.",
|
||||
"sheetExpand": "Ampliar a toda la altura",
|
||||
"sheetShrink": "Reducir",
|
||||
"usualLabel": "Tus 30 días anteriores",
|
||||
"valuesLegend": "Punto: este día · barra: tus 30 días anteriores",
|
||||
"valuesLegend": "Punto para este día, barra para los 30 días anteriores",
|
||||
"valuesAllOne": "Todos los valores",
|
||||
"valuesAllFew": "Los {count} valores",
|
||||
"valuesAllOther": "Los {count} valores",
|
||||
@@ -12302,7 +12286,13 @@
|
||||
"resumed": "Reanudación: {label}",
|
||||
"illnessBegan": "Inicio: {label}",
|
||||
"illnessResolved": "Recuperación: {label}"
|
||||
}
|
||||
},
|
||||
"pickDate": "Elegir una fecha",
|
||||
"pickerTitle": "Ir a un día",
|
||||
"today": "Hoy",
|
||||
"hasEntries": "con entradas",
|
||||
"dayNSince": "Día {n}, desde el {date}",
|
||||
"sinceDate": "Desde el {date}"
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Cronología",
|
||||
@@ -12376,9 +12366,9 @@
|
||||
"vaccinationsOne": "{count} vacuna",
|
||||
"vaccinationsFew": "{count} vacunas",
|
||||
"vaccinationsOther": "{count} vacunas",
|
||||
"visitsOne": "{count} visita · con intervención: {procedures}",
|
||||
"visitsFew": "{count} visitas · con intervención: {procedures}",
|
||||
"visitsOther": "{count} visitas · con intervención: {procedures}",
|
||||
"visitsOne": "{count} visita, {procedures} con intervención",
|
||||
"visitsFew": "{count} visitas, {procedures} con intervención",
|
||||
"visitsOther": "{count} visitas, {procedures} con intervención",
|
||||
"visitsPlannedOne": "{count} visita, solo planificada",
|
||||
"visitsPlannedFew": "{count} visitas, solo planificadas",
|
||||
"visitsPlannedOther": "{count} visitas, solo planificadas",
|
||||
|
||||
+117
-127
@@ -206,7 +206,7 @@
|
||||
"colExpected": "Attendues",
|
||||
"colComplianceRate": "Taux d'observance",
|
||||
"moodTitle": "Évolution de l’humeur",
|
||||
"moodSummary": "Moyenne:{avg} / 5.0 · Entries: {count} · Range: {min}–{max}",
|
||||
"moodSummary": "Moyenne {avg} / 5.0, {count} entrées, plage {min}–{max}",
|
||||
"colMood": "Humeur",
|
||||
"colCount": "Nombre",
|
||||
"colShare": "Part",
|
||||
@@ -579,8 +579,8 @@
|
||||
"sleepShort": "Sommeil",
|
||||
"stepsShort": "Pas",
|
||||
"vo2MaxShort": "VO₂ max",
|
||||
"bpInTargetWindow90": "TA-cible · 90 j",
|
||||
"bpInTargetWindowDynamic": "TA-cible · {days} j",
|
||||
"bpInTargetWindow90": "TA-cible (90 j)",
|
||||
"bpInTargetWindowDynamic": "TA-cible ({days} j)",
|
||||
"bpInTargetCollecting": "collecte de données",
|
||||
"medications": "Médicaments",
|
||||
"quickAddMeasurement": "Saisir une mesure",
|
||||
@@ -1336,7 +1336,7 @@
|
||||
"complianceInfoLabel": "Que signifie l'observance ?",
|
||||
"lastIntake": "Dernière prise :",
|
||||
"nextIntake": "Prochaine prise :",
|
||||
"nextIntakeOverdue": "En retard · {time} — encore rattrapable",
|
||||
"nextIntakeOverdue": "En retard depuis {time}, encore rattrapable",
|
||||
"dayStreak": "jours de série",
|
||||
"dayStreakOne": "jour de série",
|
||||
"cycleDueToday": "À prendre aujourd'hui",
|
||||
@@ -1737,7 +1737,7 @@
|
||||
"empty": "Aucune étape d'escalade de dose enregistrée pour le moment. Ajoutez des changements de dose datés pour tracer le plan.",
|
||||
"youAreHere": "Vous êtes ici",
|
||||
"doseStep": "{dose} {unit}",
|
||||
"plannedFor": "Prévu · {date}"
|
||||
"plannedFor": "Prévu pour {date}"
|
||||
},
|
||||
"injectionSiteBodyOutlineAriaLabel": "Silhouette du corps",
|
||||
"injectionSitePickerAriaLabel": "Sélecteur de site d'injection",
|
||||
@@ -1788,7 +1788,7 @@
|
||||
"intakeHistoryStatusSkipped": "Ignorée",
|
||||
"intakeHistorySortByTaken": "Trier par heure de prise",
|
||||
"intakeHistorySortByScheduled": "Trier par heure programmée",
|
||||
"intakeHistoryPageInfo": "Page {page} sur {total} · {count} entrées",
|
||||
"intakeHistoryPageInfo": "Page {page} sur {total}, {count} entrées",
|
||||
"intakeHistoryPrev": "Précédent",
|
||||
"intakeHistoryNext": "Suivant",
|
||||
"glp1": {
|
||||
@@ -1888,7 +1888,7 @@
|
||||
"markAsTaken": "Marquer comme prise",
|
||||
"markAsSkipped": "Marquer comme sautée",
|
||||
"adHocTag": "hors planning",
|
||||
"takenDetail": "prévue {planned} · prise {taken}",
|
||||
"takenDetail": "prévue {planned}, prise {taken}",
|
||||
"doseTaken": "Dose : {dose}",
|
||||
"status": {
|
||||
"taken_on_time": "à l'heure",
|
||||
@@ -2594,26 +2594,26 @@
|
||||
"ENV_POLLEN_MAX": "le pollen"
|
||||
},
|
||||
"daily": {
|
||||
"high": "Quand {behaviour} monte, {outcome} du lendemain tend à {direction} dans tes données — une tendance à surveiller, pas une cause.",
|
||||
"moderate": "Quand {behaviour} monte, {outcome} du lendemain semble avoir tendance à {direction}, au vu des données actuelles — une tendance à surveiller, pas une cause.",
|
||||
"faint": "Quand {behaviour} monte, tout au plus se dessine un faible indice que {outcome} du lendemain tende à {direction} dans tes données — trop ténu pour s'y fier, jamais une cause."
|
||||
"high": "Quand {behaviour} monte, {outcome} du lendemain tend à {direction} dans tes données. C'est une tendance à surveiller, pas une cause.",
|
||||
"moderate": "Quand {behaviour} monte, {outcome} du lendemain semble avoir tendance à {direction}, au vu des données actuelles. C'est une tendance à surveiller, pas une cause.",
|
||||
"faint": "Quand {behaviour} monte, tout au plus se dessine un faible indice que {outcome} du lendemain tende à {direction} dans tes données. C'est trop ténu pour s'y fier et jamais une cause."
|
||||
},
|
||||
"lab": {
|
||||
"default": "Des valeurs de {lab} plus élevées vont de pair avec {outcome} qui tend à {direction} sur les mêmes périodes dans tes données — une association à surveiller avec ton médecin, jamais une cause.",
|
||||
"faint": "Des valeurs de {lab} plus élevées laissent tout au plus entrevoir que {outcome} tende à {direction} sur les mêmes périodes dans tes données — trop ténu pour s'y fier, une association à évoquer avec ton médecin, jamais une cause."
|
||||
"default": "Des valeurs de {lab} plus élevées vont de pair avec {outcome} qui tend à {direction} sur les mêmes périodes dans tes données, une association à surveiller avec ton médecin, jamais une cause.",
|
||||
"faint": "Des valeurs de {lab} plus élevées laissent tout au plus entrevoir que {outcome} tende à {direction} sur les mêmes périodes dans tes données. C'est trop ténu pour s'y fier et jamais une cause, mais à évoquer avec ton médecin."
|
||||
},
|
||||
"hypothesis": {
|
||||
"bpCompliance": {
|
||||
"support": "Une meilleure observance des médicaments va de pair avec des valeurs systoliques plus basses — une tendance à surveiller.",
|
||||
"surprising": "L'observance et la systolique évoluent ensemble dans tes données — surprenant ; parles-en à ton médecin avant de changer quoi que ce soit.",
|
||||
"support": "Une meilleure observance des médicaments va de pair avec des valeurs systoliques plus basses, une tendance à surveiller.",
|
||||
"surprising": "L'observance et la systolique évoluent ensemble dans tes données, ce qui est surprenant. Parles-en à ton médecin avant de changer quoi que ce soit.",
|
||||
"none": "L'observance et la systolique n'évoluent pas nettement ensemble sur cette période."
|
||||
},
|
||||
"moodPulse": {
|
||||
"support": "Les jours de moral plus bas vont de pair avec un pouls au repos plus élevé — une tendance à surveiller.",
|
||||
"surprising": "Les jours de meilleur moral vont de pair avec un pouls au repos plus élevé dans tes données — surprenant.",
|
||||
"support": "Les jours de moral plus bas vont de pair avec un pouls au repos plus élevé, une tendance à surveiller.",
|
||||
"surprising": "Les jours de meilleur moral vont de pair avec un pouls au repos plus élevé dans tes données, ce qui est surprenant.",
|
||||
"none": "L'humeur et le pouls au repos n'évoluent pas nettement ensemble sur cette période."
|
||||
},
|
||||
"weightWeekday": "Le poids du {weekday} est {delta} kg {direction} ta moyenne des autres jours — une tendance à surveiller."
|
||||
"weightWeekday": "Le poids du {weekday} est {delta} kg {direction} ta moyenne des autres jours, une tendance à surveiller."
|
||||
},
|
||||
"dailySameDay": {
|
||||
"high": "Plus de {behaviour} le jour même et la veille va souvent de pair avec {direction} {outcome} dans tes données : un schéma à surveiller, pas une cause.",
|
||||
@@ -2730,7 +2730,7 @@
|
||||
"referenceSleep": "Au moins {low} minutes par nuit ; un sommeil plus long n'est pas pénalisé",
|
||||
"referenceAdiposity": "Rapport taille-hauteur inférieur à {high}",
|
||||
"referenceHba1c": "HbA1c de {low} à {high} %",
|
||||
"referenceDetail": "{label} · {source} · {kind} · {low} à {high}",
|
||||
"referenceDetail": "{label} : de {low} à {high} ({kind}, {source})",
|
||||
"openBound": "ouvert",
|
||||
"laboratoryReferenceSource": "Plages de référence du laboratoire",
|
||||
"personalTargetSource": "Objectif personnel",
|
||||
@@ -2824,7 +2824,7 @@
|
||||
"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}",
|
||||
"sourceChip": "sur la base de {n} mesures appariées ({window})",
|
||||
"sourceWindowLast30": "30 derniers jours",
|
||||
"confidenceHigh": "Confiance élevée",
|
||||
"confidenceModerate": "Confiance moyenne",
|
||||
@@ -3003,23 +3003,23 @@
|
||||
"cluster": {
|
||||
"cardio": {
|
||||
"label": "Cardiovasculaire",
|
||||
"hint": "TA · pouls · VFC · FC repos & marche"
|
||||
"hint": "TA, pouls, VFC, FC au repos et à la marche"
|
||||
},
|
||||
"body": {
|
||||
"label": "Composition corporelle",
|
||||
"hint": "Poids · masse grasse · muscle · IMC"
|
||||
"hint": "Poids, masse grasse, muscle et IMC"
|
||||
},
|
||||
"activity": {
|
||||
"label": "Activité",
|
||||
"hint": "Pas · énergie · distance · VO₂ max"
|
||||
"hint": "Pas, énergie, distance et VO₂ max"
|
||||
},
|
||||
"workouts": {
|
||||
"label": "Entraînements",
|
||||
"hint": "Courses · sorties · séances"
|
||||
"hint": "Courses, sorties et séances"
|
||||
},
|
||||
"sleep": {
|
||||
"label": "Sommeil",
|
||||
"hint": "Durée · phases"
|
||||
"hint": "Durée et phases"
|
||||
},
|
||||
"mood": {
|
||||
"label": "Humeur",
|
||||
@@ -3031,15 +3031,15 @@
|
||||
},
|
||||
"medication": {
|
||||
"label": "Médication",
|
||||
"hint": "Observance · contexte GLP-1"
|
||||
"hint": "Observance et contexte GLP-1"
|
||||
},
|
||||
"mobility": {
|
||||
"label": "Mobilité & démarche",
|
||||
"hint": "Stabilité · asymétrie · démarche"
|
||||
"hint": "Stabilité, asymétrie et démarche"
|
||||
},
|
||||
"environment": {
|
||||
"label": "Environnement",
|
||||
"hint": "Bruit · lumière · temp. cutanée"
|
||||
"hint": "Bruit, lumière du jour et température cutanée"
|
||||
}
|
||||
},
|
||||
"settingsClustersLabel": "Données vues par le Coach",
|
||||
@@ -3092,21 +3092,7 @@
|
||||
"driver": "{note}"
|
||||
},
|
||||
"scope": {
|
||||
"prefix": "Le Coach est déjà sur {metric}",
|
||||
"tap": "Touche pour demander",
|
||||
"question": "Explique-moi cette mesure — y a-t-il quelque chose à surveiller ?"
|
||||
},
|
||||
"seeded": {
|
||||
"prefix": "À regarder aujourd'hui : {signal}",
|
||||
"signal": {
|
||||
"readiness": "disponibilité",
|
||||
"recovery": "récupération"
|
||||
},
|
||||
"question": {
|
||||
"readiness": "Ma disponibilité est inhabituelle aujourd'hui — qu'est-ce qui l'explique ?",
|
||||
"recovery": "Ma récupération a changé — qu'est-ce qui se cache derrière ?"
|
||||
},
|
||||
"dismiss": "Ignorer"
|
||||
"prefix": "Le Coach est déjà sur {metric}"
|
||||
},
|
||||
"settings": "Paramètres",
|
||||
"history": {
|
||||
@@ -3227,9 +3213,9 @@
|
||||
"cap": "Beaucoup d'étapes, conclut maintenant",
|
||||
"noProgress": "Plus de nouvelles données, conclut"
|
||||
},
|
||||
"thoughtProcessOne": "Raisonnement · {count} étape",
|
||||
"thoughtProcessFew": "Raisonnement · {count} étapes",
|
||||
"thoughtProcessOther": "Raisonnement · {count} étapes",
|
||||
"thoughtProcessOne": "Raisonnement ({count} étape)",
|
||||
"thoughtProcessFew": "Raisonnement ({count} étapes)",
|
||||
"thoughtProcessOther": "Raisonnement ({count} étapes)",
|
||||
"awaitingAnswer": "Réponse attendue"
|
||||
},
|
||||
"memory": {
|
||||
@@ -3269,7 +3255,8 @@
|
||||
"capped": "(limité par l'administrateur)",
|
||||
"disabled": "L'administrateur a désactivé la réflexion approfondie."
|
||||
},
|
||||
"memoryLink": "Mémoire ({count})"
|
||||
"memoryLink": "Mémoire ({count})",
|
||||
"newConversation": "Nouvelle conversation"
|
||||
},
|
||||
"relativeJustNow": "à l’instant",
|
||||
"relativeMinutesAgoOne": "il y a {count} minute",
|
||||
@@ -4062,7 +4049,7 @@
|
||||
"distributionDaysUnit": "jours",
|
||||
"weekdayTitle": "Moyenne par jour de la semaine",
|
||||
"weekdayNoData": "Aucune donnée",
|
||||
"weekdayBestWorst": "Meilleur jour : {best} · Jour le plus difficile : {worst}",
|
||||
"weekdayBestWorst": "Meilleur jour : {best}, jour le plus difficile : {worst}",
|
||||
"tagsTitle": "Étiquettes et déclencheurs",
|
||||
"tagAvgMood": "Humeur moyenne avec cette étiquette",
|
||||
"correlationsTitle": "Humeur et autres mesures",
|
||||
@@ -4078,7 +4065,7 @@
|
||||
"strengthModerate": "Modérée",
|
||||
"strengthWeak": "Faible",
|
||||
"strengthNone": "Aucune",
|
||||
"source": "{n} jours appariés · r = {r}",
|
||||
"source": "{n} jours appariés, r = {r}",
|
||||
"emptyTitle": "Pas encore assez de données appariées",
|
||||
"emptyDescription": "Enregistrez l'humeur et cette mesure les mêmes jours pour voir une corrélation.",
|
||||
"pendingDescription": "{n} jours appariés sur {needed}. Une corrélation apparaît dès qu'il y a assez de jours pour distinguer un motif du hasard.",
|
||||
@@ -4137,7 +4124,7 @@
|
||||
"crosstab": {
|
||||
"title": "Tags et vos métriques de santé",
|
||||
"description": "Comment une métrique de santé diffère les jours où vous avez ajouté chaque tag, par rapport aux jours sans.",
|
||||
"pairLabel": "{tag} · {metric}",
|
||||
"pairLabel": "{metric} les jours avec {tag}",
|
||||
"metricActiveEnergy": "Énergie active",
|
||||
"metricSleepDuration": "Sommeil",
|
||||
"metricNextDayRecovery": "Récupération le lendemain",
|
||||
@@ -4154,7 +4141,7 @@
|
||||
"factorCrosstab": {
|
||||
"title": "Vos facteurs notés et vos constantes",
|
||||
"description": "Comment une constante diffère les jours où vous avez noté un facteur bas par rapport aux jours où vous l'avez noté haut.",
|
||||
"pairLabel": "{factor} · {metric}",
|
||||
"pairLabel": "{metric} les jours avec {factor}",
|
||||
"metricSleepDuration": "Sommeil",
|
||||
"metricSteps": "Pas",
|
||||
"metricRestingHeartRate": "Fréquence cardiaque au repos",
|
||||
@@ -4180,7 +4167,7 @@
|
||||
"moodDown": "Un {factor} plus élevé tend à coïncider avec une moins bonne humeur le lendemain dans vos données.",
|
||||
"outcomeUp": "Une meilleure humeur tend à coïncider avec un {factor} plus élevé le lendemain dans vos données.",
|
||||
"outcomeDown": "Une meilleure humeur tend à coïncider avec un {factor} plus bas le lendemain dans vos données.",
|
||||
"stat": "{n} jours appariés · r = {r} · q = {q}",
|
||||
"stat": "{n} jours appariés, r = {r}, q = {q}",
|
||||
"statLabel": "Comment ceci est calculé",
|
||||
"explainerLabel": "Comment ceci est calculé"
|
||||
},
|
||||
@@ -4224,11 +4211,11 @@
|
||||
},
|
||||
"derived": {
|
||||
"coverage": {
|
||||
"summary": "{present} entrées sur {required} · {percent}%",
|
||||
"summary": "{present} entrées sur {required} ({percent}%)",
|
||||
"ratioLabel": "{present}/{required}",
|
||||
"historyDays": "Basé sur {count} jours d'historique",
|
||||
"missing": "Enregistre ceci pour affiner la valeur : {list}",
|
||||
"summaryLabelled": "{label} · {percent}%"
|
||||
"summaryLabelled": "{label} ({percent}%)"
|
||||
},
|
||||
"scoreRing": {
|
||||
"aria": "Score {score} sur 100, {band}",
|
||||
@@ -4246,7 +4233,7 @@
|
||||
"inputsLabel": "Entrées",
|
||||
"noInputs": "Aucune entrée ne soutient encore cette valeur.",
|
||||
"methodLabel": "Méthode",
|
||||
"window": "Sur tes {days} derniers jours · données {source}",
|
||||
"window": "Sur tes {days} derniers jours de données {source}",
|
||||
"asOf": "au {date}",
|
||||
"standardLink": "Norme : {name}"
|
||||
},
|
||||
@@ -4288,7 +4275,7 @@
|
||||
"anatomy": {
|
||||
"outOf": "/100",
|
||||
"contributorsLabel": "Facteurs",
|
||||
"contributorValue": "{value} · {weight}% du poids",
|
||||
"contributorValue": "{value}, pondération {weight}%",
|
||||
"contributorMissing": "aucune donnée",
|
||||
"insufficient": "Pas encore assez de données pour l’évaluer. Continuez à enregistrer et cela apparaîtra ici."
|
||||
},
|
||||
@@ -4756,10 +4743,10 @@
|
||||
"dismiss": "Pas maintenant"
|
||||
},
|
||||
"bmiHeight": {
|
||||
"coachPrefill": "Je n’ai pas encore indiqué ma taille — pourquoi l’IMC compte-t-il, et que dois-je savoir avant de la renseigner ?"
|
||||
"coachPrefill": "Je n’ai pas encore indiqué ma taille. Pourquoi l’IMC compte-t-il, et que dois-je savoir avant de la renseigner ?"
|
||||
},
|
||||
"medicationsEmpty": {
|
||||
"coachPrefill": "Je n’ai encore ajouté aucun médicament — que dois-je savoir avant de suivre ici l’observance ?"
|
||||
"coachPrefill": "Je n’ai encore ajouté aucun médicament. Que dois-je savoir avant de suivre ici l’observance ?"
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
@@ -5530,7 +5517,7 @@
|
||||
"failureInterrupted": "L'import a été interrompu avant la fin, généralement par un redémarrage du serveur ou un manque de mémoire. Vos données sont en sécurité ; renvoyez l'archive pour réessayer.",
|
||||
"failureInsufficientMemory": "La limite de mémoire du serveur est trop basse pour une archive de cette taille. Demandez à la personne qui administre le serveur d'augmenter la limite de mémoire de Node.js (NODE_OPTIONS=--max-old-space-size), puis renvoyez l'archive.",
|
||||
"rowsImported": "{count} lignes importées jusqu'ici",
|
||||
"doneSummary": "Terminé — {imported} enregistrements sur {read} importés, {skipped} enregistrements cliniques ignorés.",
|
||||
"doneSummary": "Terminé : {imported} enregistrements sur {read} importés, {skipped} enregistrements cliniques ignorés.",
|
||||
"doneNothing": "Rien n'a été importé de cette archive : 0 enregistrement sur {read} est arrivé.",
|
||||
"writtenByHealthLogOne": "{count} enregistrement que HealthLog a écrit lui-même dans Santé a été ignoré, car il figure déjà dans vos données.",
|
||||
"writtenByHealthLogOther": "{count} enregistrements que HealthLog a écrits lui-même dans Santé ont été ignorés, car ils figurent déjà dans vos données.",
|
||||
@@ -5543,12 +5530,12 @@
|
||||
"parsing": "Lecture de l'export…",
|
||||
"upserting": "Import des enregistrements…"
|
||||
},
|
||||
"skipUnsupported": "{count} échantillons ignorés — des types de données que HealthLog n'importe pas.",
|
||||
"skipUnsupported": "{count} échantillons ignorés : des types de données que HealthLog n'importe pas.",
|
||||
"skipRefused": "{count} échantillons n'ont pas pu être écrits (illisibles, hors plage ou échec de l'enregistrement).",
|
||||
"skipBreakdown": "Afficher le détail ({count} types)",
|
||||
"skipBreakdownMore": "+ {count} autres types",
|
||||
"cycleSummary": "{samples} échantillons de cycle fusionnés en {days} journaux quotidiens.",
|
||||
"cycleModuleOff": "{count} échantillons de cycle ignorés — le suivi du cycle est désactivé pour ce compte.",
|
||||
"cycleModuleOff": "{count} échantillons de cycle ignorés, car le suivi du cycle est désactivé pour ce compte.",
|
||||
"cycleDaysFailed": "{count} jours de cycle n'ont pas pu être écrits : {reason}"
|
||||
},
|
||||
"json": {
|
||||
@@ -5633,8 +5620,8 @@
|
||||
"failed": "L'import a échoué. Rien n'a été écrit.",
|
||||
"columnsHeading": "Ce que devient chaque colonne",
|
||||
"jsonOnly": "JSON uniquement",
|
||||
"previewLine": "Lignes lues : {rows} · seraient importées : {queued} · refusées : {refused}",
|
||||
"submittedLine": "Lignes lues : {rows} · en file d'import : {queued}",
|
||||
"previewLine": "{rows} lignes lues, {queued} seraient importées, {refused} refusées",
|
||||
"submittedLine": "{rows} lignes lues, {queued} en file d'import",
|
||||
"unmatched": "Aucun médicament enregistré ne correspond à : {names}",
|
||||
"ambiguous": "Correspond à plusieurs médicaments : {names}",
|
||||
"mirrored": "Copié depuis une autre application, aucune prise ne peut y être ajoutée : {names}",
|
||||
@@ -5811,7 +5798,8 @@
|
||||
"add": "Ajouter {label}",
|
||||
"remove": "Supprimer la période de localisation",
|
||||
"removeConfirmTitle": "Retirer ce voyage ?",
|
||||
"removeConfirmBody": "Les mesures de cette période restent dans ton dossier ; seule l'entrée du voyage disparaît."
|
||||
"removeConfirmBody": "Les mesures de cette période restent dans ton dossier ; seule l'entrée du voyage disparaît.",
|
||||
"range": "Du {start} au {end}"
|
||||
},
|
||||
"backfill": {
|
||||
"title": "Récupérer les jours passés",
|
||||
@@ -6520,7 +6508,7 @@
|
||||
},
|
||||
"integrationFreshness": {
|
||||
"title": "Données reçues",
|
||||
"summary": "{count} types de données · plus récent {relative}",
|
||||
"summary": "{count} types de données, plus récent {relative}",
|
||||
"quiet": "{count} silencieux",
|
||||
"workouts": "Séances"
|
||||
},
|
||||
@@ -7079,14 +7067,14 @@
|
||||
"title": "Progression de la synchronisation",
|
||||
"receivedLabel": "Enregistrements reçus",
|
||||
"oldestLabel": "Mesure la plus ancienne atteinte",
|
||||
"flowing": "Des données sont arrivées au cours de la dernière heure — l'app iPhone continue d'envoyer.",
|
||||
"waiting": "En attente de nouvelles données de l'app iPhone. Le transfert reprend quand l'app est ouverte ou quand iOS lui accorde du temps en arrière-plan — une accalmie pendant le premier grand rattrapage est normale, pas une erreur."
|
||||
"flowing": "Des données sont arrivées au cours de la dernière heure. L'app iPhone continue d'envoyer.",
|
||||
"waiting": "En attente de nouvelles données de l'app iPhone. Le transfert reprend quand l'app est ouverte ou quand iOS lui accorde du temps en arrière-plan. Une accalmie pendant le premier grand rattrapage est normale, pas une erreur."
|
||||
},
|
||||
"delivery": {
|
||||
"title": "Livraison",
|
||||
"lastTriggerLabel": "La dernière synchronisation est arrivée",
|
||||
"lastBackgroundLabel": "Dernière synchronisation en arrière-plan",
|
||||
"backgroundNever": "Jamais — jusqu'ici chaque synchronisation est arrivée avec l'app ouverte",
|
||||
"backgroundNever": "Jamais : jusqu'ici chaque synchronisation est arrivée avec l'app ouverte",
|
||||
"noSyncYet": "Aucune synchronisation n'est encore arrivée, il n'y a donc rien à signaler ici.",
|
||||
"trigger": {
|
||||
"foreground": "avec l'app ouverte",
|
||||
@@ -8086,19 +8074,19 @@
|
||||
"description": "Prenez tous vos médicaments à l’heure pendant 1 jour."
|
||||
},
|
||||
"compliance801": {
|
||||
"title": "Observance 80% · 1j",
|
||||
"title": "Observance de 80% pendant 1 jour",
|
||||
"description": "Atteignez au moins 80% d’observance sur 30 jours pendant 1 jour."
|
||||
},
|
||||
"bmiGreen1": {
|
||||
"title": "IMC au vert · 1j",
|
||||
"title": "IMC au vert pendant 1 jour",
|
||||
"description": "Maintenez votre IMC dans la plage normale pendant 1 jour."
|
||||
},
|
||||
"bpGreen1": {
|
||||
"title": "Tension au vert · 1j",
|
||||
"title": "Tension au vert pendant 1 jour",
|
||||
"description": "Maintenez votre tension dans la plage normale pendant 1 jour."
|
||||
},
|
||||
"pulseGreen1": {
|
||||
"title": "Pouls au vert · 1j",
|
||||
"title": "Pouls au vert pendant 1 jour",
|
||||
"description": "Maintenez votre pouls au repos dans la plage normale pendant 1 jour."
|
||||
},
|
||||
"onTimePerfect7": {
|
||||
@@ -8106,19 +8094,19 @@
|
||||
"description": "Prenez tous vos médicaments à l’heure pendant 7 jours d’affilée."
|
||||
},
|
||||
"compliance807": {
|
||||
"title": "Observance 80% · 7j",
|
||||
"title": "Observance de 80% pendant 7 jours",
|
||||
"description": "Atteignez au moins 80% d’observance sur 30 jours pendant 7 jours d’affilée."
|
||||
},
|
||||
"bmiGreen7": {
|
||||
"title": "IMC au vert · 7j",
|
||||
"title": "IMC au vert pendant 7 jours",
|
||||
"description": "Maintenez votre IMC dans la plage normale pendant 7 jours d’affilée."
|
||||
},
|
||||
"bpGreen7": {
|
||||
"title": "Tension au vert · 7j",
|
||||
"title": "Tension au vert pendant 7 jours",
|
||||
"description": "Maintenez votre tension dans la plage normale pendant 7 jours d’affilée."
|
||||
},
|
||||
"pulseGreen7": {
|
||||
"title": "Pouls au vert · 7j",
|
||||
"title": "Pouls au vert pendant 7 jours",
|
||||
"description": "Maintenez votre pouls au repos dans la plage normale pendant 7 jours d’affilée."
|
||||
},
|
||||
"onTimePerfect30": {
|
||||
@@ -8126,19 +8114,19 @@
|
||||
"description": "Prenez tous vos médicaments à l’heure pendant 30 jours d’affilée."
|
||||
},
|
||||
"compliance8030": {
|
||||
"title": "Observance 80% · 30j",
|
||||
"title": "Observance de 80% pendant 30 jours",
|
||||
"description": "Atteignez au moins 80% d’observance sur 30 jours pendant 30 jours d’affilée."
|
||||
},
|
||||
"bmiGreen30": {
|
||||
"title": "IMC au vert · 30j",
|
||||
"title": "IMC au vert pendant 30 jours",
|
||||
"description": "Maintenez votre IMC dans la plage normale pendant 30 jours d’affilée."
|
||||
},
|
||||
"bpGreen30": {
|
||||
"title": "Tension au vert · 30j",
|
||||
"title": "Tension au vert pendant 30 jours",
|
||||
"description": "Maintenez votre tension dans la plage normale pendant 30 jours d’affilée."
|
||||
},
|
||||
"pulseGreen30": {
|
||||
"title": "Pouls au vert · 30j",
|
||||
"title": "Pouls au vert pendant 30 jours",
|
||||
"description": "Maintenez votre pouls au repos dans la plage normale pendant 30 jours d’affilée."
|
||||
},
|
||||
"onTimePerfect180": {
|
||||
@@ -8146,19 +8134,19 @@
|
||||
"description": "Prenez tous vos médicaments à l’heure pendant 180 jours d’affilée."
|
||||
},
|
||||
"compliance80180": {
|
||||
"title": "Observance 80% · 180j",
|
||||
"title": "Observance de 80% pendant 180 jours",
|
||||
"description": "Atteignez au moins 80% d’observance sur 30 jours pendant 180 jours d’affilée."
|
||||
},
|
||||
"bmiGreen180": {
|
||||
"title": "IMC au vert · 180j",
|
||||
"title": "IMC au vert pendant 180 jours",
|
||||
"description": "Maintenez votre IMC dans la plage normale pendant 180 jours d’affilée."
|
||||
},
|
||||
"bpGreen180": {
|
||||
"title": "Tension au vert · 180j",
|
||||
"title": "Tension au vert pendant 180 jours",
|
||||
"description": "Maintenez votre tension dans la plage normale pendant 180 jours d’affilée."
|
||||
},
|
||||
"pulseGreen180": {
|
||||
"title": "Pouls au vert · 180j",
|
||||
"title": "Pouls au vert pendant 180 jours",
|
||||
"description": "Maintenez votre pouls au repos dans la plage normale pendant 180 jours d’affilée."
|
||||
},
|
||||
"onTimePerfect360": {
|
||||
@@ -8166,19 +8154,19 @@
|
||||
"description": "Prenez tous vos médicaments à l’heure pendant 360 jours d’affilée."
|
||||
},
|
||||
"compliance80360": {
|
||||
"title": "Observance 80% · 360j",
|
||||
"title": "Observance de 80% pendant 360 jours",
|
||||
"description": "Atteignez au moins 80% d’observance sur 30 jours pendant 360 jours d’affilée."
|
||||
},
|
||||
"bmiGreen360": {
|
||||
"title": "IMC au vert · 360j",
|
||||
"title": "IMC au vert pendant 360 jours",
|
||||
"description": "Maintenez votre IMC dans la plage normale pendant 360 jours d’affilée."
|
||||
},
|
||||
"bpGreen360": {
|
||||
"title": "Tension au vert · 360j",
|
||||
"title": "Tension au vert pendant 360 jours",
|
||||
"description": "Maintenez votre tension dans la plage normale pendant 360 jours d’affilée."
|
||||
},
|
||||
"pulseGreen360": {
|
||||
"title": "Pouls au vert · 360j",
|
||||
"title": "Pouls au vert pendant 360 jours",
|
||||
"description": "Maintenez votre pouls au repos dans la plage normale pendant 360 jours d’affilée."
|
||||
},
|
||||
"moodFirst": {
|
||||
@@ -8262,15 +8250,15 @@
|
||||
"description": "Vous avez changé la langue de l’application au moins une fois."
|
||||
},
|
||||
"missFree7": {
|
||||
"title": "Aucune dose oubliée · 7j",
|
||||
"title": "Aucune dose oubliée en 7 jours",
|
||||
"description": "7 jours consécutifs sans dose oubliée."
|
||||
},
|
||||
"missFree30": {
|
||||
"title": "Aucune dose oubliée · 30j",
|
||||
"title": "Aucune dose oubliée en 30 jours",
|
||||
"description": "30 jours consécutifs sans dose oubliée."
|
||||
},
|
||||
"missFree90": {
|
||||
"title": "Aucune dose oubliée · 90j",
|
||||
"title": "Aucune dose oubliée en 90 jours",
|
||||
"description": "90 jours consécutifs sans dose oubliée."
|
||||
},
|
||||
"measurementWeeks4": {
|
||||
@@ -8282,7 +8270,7 @@
|
||||
"description": "Les quatre champs de l’auto-déclaration sont remplis — le Coach connaît votre contexte."
|
||||
},
|
||||
"sleepLog7": {
|
||||
"title": "Journal de sommeil · 7j",
|
||||
"title": "Journal de sommeil pendant 7 jours",
|
||||
"description": "Données de sommeil enregistrées pendant 7 jours consécutifs."
|
||||
}
|
||||
},
|
||||
@@ -8372,9 +8360,6 @@
|
||||
"enabledToast": "Rappels proactifs du Coach activés.",
|
||||
"disabledToast": "Rappels proactifs du Coach désactivés.",
|
||||
"saveError": "Impossible d'enregistrer. Veuillez réessayer plus tard.",
|
||||
"suggestionsLabel": "Suggestions d'exemples",
|
||||
"suggestionsDesc": "Affiche de temps en temps une question d'exemple prête à l'emploi sur l'écran du Coach.",
|
||||
"suggestionsAria": "Activer ou désactiver les suggestions d'exemples du Coach",
|
||||
"aiComposedLabel": "Laisser le Coach rédiger le message",
|
||||
"aiComposedDesc": "Quand un fournisseur est disponible, le message proactif est rédigé par le modèle plutôt que par le modèle de texte fixe. Désactivé par défaut.",
|
||||
"aiComposedAria": "Activer ou désactiver le message proactif rédigé par l'IA"
|
||||
@@ -8792,7 +8777,7 @@
|
||||
},
|
||||
"crosstab": {
|
||||
"description": "Comment chaque mesure se compare entre tes phases lutéale et folliculaire, sur tes propres données.",
|
||||
"pairLabel": "{metric} · lutéale vs folliculaire",
|
||||
"pairLabel": "{metric} : phase lutéale vs folliculaire",
|
||||
"detail": "Lutéale {lutealAvg} {unit} ({lutealDays} jours) vs folliculaire {follicularAvg} {unit} ({follicularDays} jours).",
|
||||
"metricRestingHeartRate": "Fréquence cardiaque au repos",
|
||||
"metricHrv": "Variabilité de la fréquence cardiaque",
|
||||
@@ -9012,7 +8997,7 @@
|
||||
"temperatureSection": "Température et ovulation",
|
||||
"intimacySection": "Intimité et contraception",
|
||||
"testsSection": "Tests",
|
||||
"phaseDayHeader": "Jour {day} · {phase}",
|
||||
"phaseDayHeader": "Jour {day}, {phase}",
|
||||
"bbtHint": "Prends-la dès le matin, avant de te lever.",
|
||||
"titleForDate": "Saisie du {date}",
|
||||
"descriptionToday": "Aujourd'hui. Flux, symptômes et notes.",
|
||||
@@ -9189,10 +9174,10 @@
|
||||
"VISCERAL_FAT": "Graisse viscérale",
|
||||
"BODY_MASS_INDEX": "Indice de masse corporelle (IMC)",
|
||||
"WAIST_CIRCUMFERENCE": "Tour de taille",
|
||||
"PHQ9_SCORE": "PHQ-9 · humeur",
|
||||
"GAD7_SCORE": "GAD-7 · inquiétude",
|
||||
"WHO5_SCORE": "WHO-5 · bien-être",
|
||||
"SCI_SCORE": "SCI · sommeil"
|
||||
"PHQ9_SCORE": "PHQ-9 (humeur)",
|
||||
"GAD7_SCORE": "GAD-7 (inquiétude)",
|
||||
"WHO5_SCORE": "WHO-5 (bien-être)",
|
||||
"SCI_SCORE": "SCI (sommeil)"
|
||||
},
|
||||
"typeGroups": {
|
||||
"vitals": "Constantes vitales",
|
||||
@@ -10289,10 +10274,10 @@
|
||||
"saved": "Enregistré",
|
||||
"error": "Une erreur est survenue. Réessaie.",
|
||||
"instrument": {
|
||||
"phq9": "PHQ-9 · humeur",
|
||||
"gad7": "GAD-7 · inquiétude",
|
||||
"who5": "WHO-5 · bien-être",
|
||||
"sci": "SCI · sommeil"
|
||||
"phq9": "PHQ-9 (humeur)",
|
||||
"gad7": "GAD-7 (inquiétude)",
|
||||
"who5": "WHO-5 (bien-être)",
|
||||
"sci": "SCI (sommeil)"
|
||||
},
|
||||
"instrumentSub": {
|
||||
"phq9": "Dépistage de la dépression, 9 questions",
|
||||
@@ -10760,7 +10745,7 @@
|
||||
},
|
||||
"documents": {
|
||||
"title": "Documents",
|
||||
"subtitle": "Chaque courrier, compte rendu et scan au même endroit, consultable — chiffré au repos.",
|
||||
"subtitle": "Chaque courrier, compte rendu et scan au même endroit, consultable et chiffré au repos.",
|
||||
"pageUpload": "Téléverser",
|
||||
"ownerOnlyUpload": "Seul le titulaire du dossier peut ajouter des documents.",
|
||||
"kind": {
|
||||
@@ -10802,15 +10787,15 @@
|
||||
},
|
||||
"error": {
|
||||
"fileTooLarge": "Ce fichier dépasse la limite de {maxSize}.",
|
||||
"quotaExceeded": "Le stockage est plein — {used} sur {quota} utilisés.",
|
||||
"quotaExceeded": "Le stockage est plein : {used} sur {quota} utilisés.",
|
||||
"unsupportedType": "Ce type de fichier n'est pas pris en charge.",
|
||||
"purged": "Ce document a été définitivement supprimé et ne peut pas être restauré.",
|
||||
"duplicateExists": "Un document identique est déjà enregistré.",
|
||||
"rateLimited": "Trop de téléversements — réessayez dans quelques minutes.",
|
||||
"rateLimited": "Trop de téléversements. Réessayez dans quelques minutes.",
|
||||
"generic": "Le téléversement a échoué. Veuillez réessayer."
|
||||
},
|
||||
"toast": {
|
||||
"duplicate": "Déjà enregistré — le document existant est mis en évidence.",
|
||||
"duplicate": "Déjà enregistré. Le document existant est mis en évidence.",
|
||||
"deleted": "Document supprimé",
|
||||
"restoreFailed": "Impossible de restaurer le document."
|
||||
},
|
||||
@@ -10833,7 +10818,7 @@
|
||||
},
|
||||
"empty": {
|
||||
"title": "Votre coffre à documents est vide",
|
||||
"description": "Conservez courriers médicaux, résultats de laboratoire, imagerie, ordonnances et carnets de vaccination — chiffrés et consultables par type, affection et année.",
|
||||
"description": "Conservez courriers médicaux, résultats de laboratoire, imagerie, ordonnances et carnets de vaccination. Ils sont chiffrés et consultables par type, affection et année.",
|
||||
"action": "Téléverser un premier document",
|
||||
"noMatchesTitle": "Aucun document correspondant",
|
||||
"noMatchesDescription": "Rien ne correspond à la recherche et aux filtres actuels."
|
||||
@@ -10873,9 +10858,9 @@
|
||||
"linkCondition": "Lier",
|
||||
"noConditions": "Lié à aucune affection",
|
||||
"previewUnavailable": "Pas d'aperçu intégré pour ce type de fichier",
|
||||
"uploadedMeta": "Ajouté le {date} · {size}",
|
||||
"uploadedMeta": "Ajouté le {date}, {size}",
|
||||
"importedFrom": "Importé depuis {system}",
|
||||
"importedFromWithId": "Importé depuis {system} · ID {id}",
|
||||
"importedFromWithId": "Importé depuis {system}, ID {id}",
|
||||
"sourceOther": "un autre système",
|
||||
"share": "Partager",
|
||||
"labValuesLink": "Voir {count} valeurs de laboratoire",
|
||||
@@ -10975,7 +10960,7 @@
|
||||
"CONDITION": "Affection",
|
||||
"MEDICATION_STATEMENT": "Médicament"
|
||||
},
|
||||
"statedRange": "plage indiquée {range}",
|
||||
"statedRange": "Plage indiquée {range}",
|
||||
"needsCheck": "Vérifiez cette valeur par rapport au document, puis enregistrez-la.",
|
||||
"verify": "Vérifier",
|
||||
"verifySave": "Enregistrer la valeur",
|
||||
@@ -11245,14 +11230,14 @@
|
||||
}
|
||||
},
|
||||
"hydration": {
|
||||
"referenceMeta": "Apport de référence {value} {unit}/jour · EFSA"
|
||||
"referenceMeta": "Apport de référence {value} {unit}/jour (EFSA)"
|
||||
},
|
||||
"caffeine": {
|
||||
"ceilingMeta": "Plafond de sécurité {value} {unit}/jour · EFSA"
|
||||
"ceilingMeta": "Plafond de sécurité {value} {unit}/jour (EFSA)"
|
||||
},
|
||||
"micronutrients": {
|
||||
"title": "Vitamines et minéraux",
|
||||
"rowMeta": "{days} sur {window} jours · réf. {value} {unit} (EFSA)",
|
||||
"rowMeta": "{days} sur {window} jours, réf. {value} {unit} (EFSA)",
|
||||
"rowMetaNoRef": "{days} sur {window} jours",
|
||||
"footer": "{tracked} des {total} micronutriments suivis ont des données sur cette période.",
|
||||
"emptyTitle": "Aucune donnée de micronutriments synchronisée pour l'instant",
|
||||
@@ -11267,7 +11252,7 @@
|
||||
},
|
||||
"push": {
|
||||
"title": "Votre résumé du matin",
|
||||
"bodyProvisional": "{line} · Le sommeil de la nuit dernière n'est pas encore disponible."
|
||||
"bodyProvisional": "{line}\nLe sommeil de la nuit dernière n'est pas encore disponible."
|
||||
},
|
||||
"item": {
|
||||
"doseWindow": {
|
||||
@@ -11655,7 +11640,7 @@
|
||||
"managed": {
|
||||
"title": "Profils dont vous vous occupez",
|
||||
"description": "Dossiers de santé sans identifiants propres.",
|
||||
"explainer": "Un profil géré est un dossier de santé pour quelqu'un qui ne se connecte pas lui-même — un enfant, ou une personne dont vous vous occupez. Il n'a ni identifiants ni adresse e-mail, et vous en devenez la première personne responsable. Vous pourrez en ajouter d'autres plus tard, et chacune pourra entrer dans le dossier pour le tenir à jour.",
|
||||
"explainer": "Un profil géré est un dossier de santé pour quelqu'un qui ne se connecte pas lui-même, comme un enfant ou une personne dont vous vous occupez. Il n'a ni identifiants ni adresse e-mail, et vous en devenez la première personne responsable. Vous pourrez en ajouter d'autres plus tard, et chacune pourra entrer dans le dossier pour le tenir à jour.",
|
||||
"nameLabel": "Nom",
|
||||
"namePlaceholder": "Le nom sous lequel vous reconnaîtrez ce dossier",
|
||||
"nameHint": "Jusqu'à 80 caractères. Affiché partout où ce dossier apparaît.",
|
||||
@@ -11677,7 +11662,7 @@
|
||||
"creating": "Création…",
|
||||
"created": "{name} est en place. Vous pouvez entrer dans ce dossier depuis le menu du compte.",
|
||||
"errorStepUp": "Confirmez votre deuxième facteur, puis réessayez.",
|
||||
"errorOffline": "Cela n'a pas atteint le serveur. Rien n'a changé — vérifiez votre connexion et réessayez.",
|
||||
"errorOffline": "Cela n'a pas atteint le serveur. Rien n'a changé. Vérifiez votre connexion et réessayez.",
|
||||
"errorInvalid": "Le serveur a refusé ces informations. Vérifiez le nom, la date et le fuseau horaire.",
|
||||
"errorFailed": "Le profil n'a pas pu être créé.",
|
||||
"edit": "Modifier",
|
||||
@@ -11688,7 +11673,7 @@
|
||||
"editErrorFailed": "Les modifications n'ont pas pu être enregistrées.",
|
||||
"delete": "Supprimer le profil",
|
||||
"deleteTitle": "Supprimer {name} ?",
|
||||
"deleteBody": "Tout ce qui a été enregistré pour {name} disparaît avec le profil — mesures, médicaments, documents et notes. Cette action est irréversible.",
|
||||
"deleteBody": "Tout ce qui a été enregistré pour {name} disparaît avec le profil : mesures, médicaments, documents et notes. Cette action est irréversible.",
|
||||
"deleteGuardiansOne": "{count} personne s'occupe aujourd'hui de ce dossier et le perdra.",
|
||||
"deleteGuardiansFew": "{count} personnes s'occupent aujourd'hui de ce dossier et le perdront.",
|
||||
"deleteGuardiansOther": "{count} personnes s'occupent aujourd'hui de ce dossier et le perdront.",
|
||||
@@ -11974,7 +11959,7 @@
|
||||
"both": "Des deux côtés",
|
||||
"none": "Non précisé"
|
||||
},
|
||||
"bodySiteWithSide": "{site} · {side}",
|
||||
"bodySiteWithSide": "{site} ({side})",
|
||||
"sectionTitle": "Rendez-vous",
|
||||
"sectionDescription": "Consultations et rendez-vous pris, au même endroit.",
|
||||
"add": "Rendez-vous",
|
||||
@@ -12236,7 +12221,6 @@
|
||||
"loadFailed": "Impossible de charger ce jour.",
|
||||
"dayOf": "Jour {n} sur {m}",
|
||||
"dayN": "Jour {n}",
|
||||
"since": "depuis le {date}",
|
||||
"usualRange": "Habituellement de {lo} à {hi}",
|
||||
"groups": {
|
||||
"scores": "Scores",
|
||||
@@ -12264,11 +12248,11 @@
|
||||
"description": "Tout ce que vos entrées contiennent pour ce jour.",
|
||||
"openDocument": "Ouvrir le document",
|
||||
"previousReading": "Avant, {date}",
|
||||
"rugLegend": "Points sous l’axe : jours avec entrées · anneau : jour remarquable",
|
||||
"rugLegend": "Les points sous l’axe marquent les jours avec entrées, un anneau un jour remarquable.",
|
||||
"sheetExpand": "Agrandir en pleine hauteur",
|
||||
"sheetShrink": "Réduire",
|
||||
"usualLabel": "Vos 30 jours précédents",
|
||||
"valuesLegend": "Point : ce jour · barre : vos 30 jours précédents",
|
||||
"valuesLegend": "Point pour ce jour, barre pour les 30 jours précédents",
|
||||
"valuesAllOne": "Toutes les valeurs",
|
||||
"valuesAllFew": "Les {count} valeurs",
|
||||
"valuesAllOther": "Les {count} valeurs",
|
||||
@@ -12302,7 +12286,13 @@
|
||||
"resumed": "Reprise : {label}",
|
||||
"illnessBegan": "Début : {label}",
|
||||
"illnessResolved": "Guérison : {label}"
|
||||
}
|
||||
},
|
||||
"pickDate": "Choisir une date",
|
||||
"pickerTitle": "Aller à un jour",
|
||||
"today": "Aujourd’hui",
|
||||
"hasEntries": "avec des entrées",
|
||||
"dayNSince": "Jour {n}, depuis le {date}",
|
||||
"sinceDate": "Depuis le {date}"
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Chronologie",
|
||||
@@ -12376,9 +12366,9 @@
|
||||
"vaccinationsOne": "{count} vaccin",
|
||||
"vaccinationsFew": "{count} vaccins",
|
||||
"vaccinationsOther": "{count} vaccins",
|
||||
"visitsOne": "{count} consultation · avec intervention : {procedures}",
|
||||
"visitsFew": "{count} consultations · avec intervention : {procedures}",
|
||||
"visitsOther": "{count} consultations · avec intervention : {procedures}",
|
||||
"visitsOne": "{count} consultation, dont {procedures} avec intervention",
|
||||
"visitsFew": "{count} consultations, dont {procedures} avec intervention",
|
||||
"visitsOther": "{count} consultations, dont {procedures} avec intervention",
|
||||
"visitsPlannedOne": "{count} consultation, seulement prévue",
|
||||
"visitsPlannedFew": "{count} consultations, seulement prévues",
|
||||
"visitsPlannedOther": "{count} consultations, seulement prévues",
|
||||
|
||||
+114
-124
@@ -206,7 +206,7 @@
|
||||
"colExpected": "Attese",
|
||||
"colComplianceRate": "Tasso di aderenza",
|
||||
"moodTitle": "Andamento dell’umore",
|
||||
"moodSummary": "Media:{avg} / 5.0 · Entries: {count} · Range: {min}–{max}",
|
||||
"moodSummary": "Media {avg} / 5.0, {count} voci, intervallo {min}–{max}",
|
||||
"colMood": "Umore",
|
||||
"colCount": "Quantità",
|
||||
"colShare": "Quota",
|
||||
@@ -579,8 +579,8 @@
|
||||
"sleepShort": "Sonno",
|
||||
"stepsShort": "Passi",
|
||||
"vo2MaxShort": "VO₂ max",
|
||||
"bpInTargetWindow90": "PA-target · 90 gg",
|
||||
"bpInTargetWindowDynamic": "PA-target · {days} gg",
|
||||
"bpInTargetWindow90": "PA-target (90 gg)",
|
||||
"bpInTargetWindowDynamic": "PA-target ({days} gg)",
|
||||
"bpInTargetCollecting": "raccolta dati in corso",
|
||||
"medications": "Farmaci",
|
||||
"quickAddMeasurement": "Registra misurazione",
|
||||
@@ -1336,7 +1336,7 @@
|
||||
"complianceInfoLabel": "Cosa significa aderenza?",
|
||||
"lastIntake": "Ultima assunzione:",
|
||||
"nextIntake": "Prossima assunzione:",
|
||||
"nextIntakeOverdue": "In ritardo · {time} — ancora recuperabile",
|
||||
"nextIntakeOverdue": "In ritardo da {time}, ancora recuperabile",
|
||||
"dayStreak": "giorni di serie",
|
||||
"dayStreakOne": "giorno di serie",
|
||||
"cycleDueToday": "In scadenza oggi",
|
||||
@@ -1737,7 +1737,7 @@
|
||||
"empty": "Nessuna fase di aumento della dose registrata. Aggiungi variazioni di dose con data per tracciare il piano.",
|
||||
"youAreHere": "Sei qui",
|
||||
"doseStep": "{dose} {unit}",
|
||||
"plannedFor": "Previsto · {date}"
|
||||
"plannedFor": "Previsto per {date}"
|
||||
},
|
||||
"injectionSiteBodyOutlineAriaLabel": "Sagoma del corpo",
|
||||
"injectionSitePickerAriaLabel": "Selettore del sito di iniezione",
|
||||
@@ -1788,7 +1788,7 @@
|
||||
"intakeHistoryStatusSkipped": "Saltata",
|
||||
"intakeHistorySortByTaken": "Ordina per ora di assunzione",
|
||||
"intakeHistorySortByScheduled": "Ordina per orario programmato",
|
||||
"intakeHistoryPageInfo": "Pagina {page} di {total} · {count} voci",
|
||||
"intakeHistoryPageInfo": "Pagina {page} di {total}, {count} voci",
|
||||
"intakeHistoryPrev": "Precedente",
|
||||
"intakeHistoryNext": "Successiva",
|
||||
"glp1": {
|
||||
@@ -1888,7 +1888,7 @@
|
||||
"markAsTaken": "Segna come assunta",
|
||||
"markAsSkipped": "Segna come saltata",
|
||||
"adHocTag": "fuori programma",
|
||||
"takenDetail": "prevista {planned} · assunta {taken}",
|
||||
"takenDetail": "prevista {planned}, assunta {taken}",
|
||||
"doseTaken": "Dose: {dose}",
|
||||
"status": {
|
||||
"taken_on_time": "puntuale",
|
||||
@@ -2594,26 +2594,26 @@
|
||||
"ENV_POLLEN_MAX": "i pollini"
|
||||
},
|
||||
"daily": {
|
||||
"high": "Quando {behaviour} sale, {outcome} del giorno dopo tende a {direction} nei tuoi dati — un andamento da osservare, non una causa.",
|
||||
"moderate": "Quando {behaviour} sale, {outcome} del giorno dopo sembra tendere a {direction}, per quanto emerge finora — un andamento da osservare, non una causa.",
|
||||
"faint": "Quando {behaviour} sale, al più si intravede un debole indizio che {outcome} del giorno dopo tenda a {direction} nei tuoi dati — troppo lieve per farci affidamento, mai una causa."
|
||||
"high": "Quando {behaviour} sale, {outcome} del giorno dopo tende a {direction} nei tuoi dati. È un andamento da osservare, non una causa.",
|
||||
"moderate": "Quando {behaviour} sale, {outcome} del giorno dopo sembra tendere a {direction}, per quanto emerge finora. È un andamento da osservare, non una causa.",
|
||||
"faint": "Quando {behaviour} sale, al più si intravede nei tuoi dati un debole indizio che {outcome} del giorno dopo tenda a {direction}. È troppo lieve per farci affidamento e mai una causa."
|
||||
},
|
||||
"lab": {
|
||||
"default": "Valori di {lab} più alti si accompagnano al fatto che {outcome} tende a {direction} negli stessi periodi dei tuoi dati — un'associazione da tenere d'occhio con il tuo medico, mai una causa.",
|
||||
"faint": "Valori di {lab} più alti lasciano al più intravedere che {outcome} tenda a {direction} negli stessi periodi dei tuoi dati — troppo lieve per farci affidamento, un'associazione da discutere con il tuo medico, mai una causa."
|
||||
"default": "Valori di {lab} più alti si accompagnano al fatto che {outcome} tende a {direction} negli stessi periodi dei tuoi dati, un'associazione da tenere d'occhio con il tuo medico, mai una causa.",
|
||||
"faint": "Valori di {lab} più alti lasciano al più intravedere che {outcome} tenda a {direction} negli stessi periodi dei tuoi dati. È troppo lieve per farci affidamento e mai una causa, ma da discutere con il tuo medico."
|
||||
},
|
||||
"hypothesis": {
|
||||
"bpCompliance": {
|
||||
"support": "Una maggiore aderenza ai farmaci si accompagna a valori sistolici più bassi — un andamento da osservare.",
|
||||
"surprising": "L'aderenza e la sistolica si muovono insieme nei tuoi dati — sorprendente; parlane con il tuo medico prima di modificare qualcosa.",
|
||||
"support": "Una maggiore aderenza ai farmaci si accompagna a valori sistolici più bassi, un andamento da osservare.",
|
||||
"surprising": "L'aderenza e la sistolica si muovono insieme nei tuoi dati, il che è sorprendente. Parlane con il tuo medico prima di modificare qualcosa.",
|
||||
"none": "L'aderenza e la sistolica non si muovono insieme in modo marcato in questo periodo."
|
||||
},
|
||||
"moodPulse": {
|
||||
"support": "I giorni di umore più basso si accompagnano a un polso a riposo più alto — un andamento da osservare.",
|
||||
"surprising": "I giorni di umore migliore si accompagnano a un polso a riposo più alto nei tuoi dati — sorprendente.",
|
||||
"support": "I giorni di umore più basso si accompagnano a un polso a riposo più alto, un andamento da osservare.",
|
||||
"surprising": "I giorni di umore migliore si accompagnano a un polso a riposo più alto nei tuoi dati, il che è sorprendente.",
|
||||
"none": "L'umore e il polso a riposo non si muovono insieme in modo marcato in questo periodo."
|
||||
},
|
||||
"weightWeekday": "Il peso del {weekday} è {delta} kg {direction} la tua media degli altri giorni — un andamento da osservare."
|
||||
"weightWeekday": "Il peso del {weekday} è {delta} kg {direction} la tua media degli altri giorni, un andamento da osservare."
|
||||
},
|
||||
"dailySameDay": {
|
||||
"high": "Più {behaviour} nello stesso giorno e in quello prima tende ad accompagnarsi a {direction} {outcome} nei tuoi dati: uno schema da osservare, non una causa.",
|
||||
@@ -2730,7 +2730,7 @@
|
||||
"referenceSleep": "Almeno {low} minuti per notte; il sonno più lungo non viene penalizzato",
|
||||
"referenceAdiposity": "Rapporto vita-altezza inferiore a {high}",
|
||||
"referenceHba1c": "HbA1c da {low} a {high}%",
|
||||
"referenceDetail": "{label} · {source} · {kind} · da {low} a {high}",
|
||||
"referenceDetail": "{label}: da {low} a {high} ({kind}, {source})",
|
||||
"openBound": "aperto",
|
||||
"laboratoryReferenceSource": "Intervalli di riferimento del laboratorio",
|
||||
"personalTargetSource": "Obiettivo personale",
|
||||
@@ -2824,7 +2824,7 @@
|
||||
"experimentCta": "Avvia esperimento di 7 giorni",
|
||||
"experimentTooltip": "In arrivo",
|
||||
"emptyTitle": "Servono più dati per mostrare questa correlazione",
|
||||
"sourceChip": "su {n} misurazioni appaiate · {window}",
|
||||
"sourceChip": "su {n} misurazioni appaiate ({window})",
|
||||
"sourceWindowLast30": "Ultimi 30 giorni",
|
||||
"confidenceHigh": "Confidenza alta",
|
||||
"confidenceModerate": "Confidenza media",
|
||||
@@ -3003,23 +3003,23 @@
|
||||
"cluster": {
|
||||
"cardio": {
|
||||
"label": "Cardiovascolare",
|
||||
"hint": "Pressione · polso · HRV · FC riposo e cammino"
|
||||
"hint": "Pressione, polso, HRV, FC a riposo e in cammino"
|
||||
},
|
||||
"body": {
|
||||
"label": "Composizione corporea",
|
||||
"hint": "Peso · grasso · muscoli · IMC"
|
||||
"hint": "Peso, grasso, muscoli e IMC"
|
||||
},
|
||||
"activity": {
|
||||
"label": "Attività",
|
||||
"hint": "Passi · energia · distanza · VO₂ max"
|
||||
"hint": "Passi, energia, distanza e VO₂ max"
|
||||
},
|
||||
"workouts": {
|
||||
"label": "Allenamenti",
|
||||
"hint": "Corse · uscite · sessioni"
|
||||
"hint": "Corse, uscite e sessioni"
|
||||
},
|
||||
"sleep": {
|
||||
"label": "Sonno",
|
||||
"hint": "Durata · fasi"
|
||||
"hint": "Durata e fasi"
|
||||
},
|
||||
"mood": {
|
||||
"label": "Umore",
|
||||
@@ -3031,15 +3031,15 @@
|
||||
},
|
||||
"medication": {
|
||||
"label": "Farmaci",
|
||||
"hint": "Aderenza · contesto GLP-1"
|
||||
"hint": "Aderenza e contesto GLP-1"
|
||||
},
|
||||
"mobility": {
|
||||
"label": "Mobilità e andatura",
|
||||
"hint": "Stabilità · asimmetria · andatura"
|
||||
"hint": "Stabilità, asimmetria e andatura"
|
||||
},
|
||||
"environment": {
|
||||
"label": "Ambiente",
|
||||
"hint": "Rumore · luce · temp. cutanea"
|
||||
"hint": "Rumore, luce del giorno e temperatura cutanea"
|
||||
}
|
||||
},
|
||||
"settingsClustersLabel": "Dati che il Coach vede",
|
||||
@@ -3092,21 +3092,7 @@
|
||||
"driver": "{note}"
|
||||
},
|
||||
"scope": {
|
||||
"prefix": "Il Coach è già su {metric}",
|
||||
"tap": "Tocca per chiedere",
|
||||
"question": "Spiegami questo parametro — c'è qualcosa da tenere d'occhio?"
|
||||
},
|
||||
"seeded": {
|
||||
"prefix": "Oggi vale uno sguardo: {signal}",
|
||||
"signal": {
|
||||
"readiness": "prontezza",
|
||||
"recovery": "recupero"
|
||||
},
|
||||
"question": {
|
||||
"readiness": "La mia prontezza è anomala oggi — da cosa dipende?",
|
||||
"recovery": "Il mio recupero è cambiato — cosa c'è dietro?"
|
||||
},
|
||||
"dismiss": "Ignora"
|
||||
"prefix": "Il Coach è già su {metric}"
|
||||
},
|
||||
"settings": "Impostazioni",
|
||||
"history": {
|
||||
@@ -3227,9 +3213,9 @@
|
||||
"cap": "Molti passaggi, ora riassume",
|
||||
"noProgress": "Nessun dato nuovo, riassume"
|
||||
},
|
||||
"thoughtProcessOne": "Ragionamento · {count} passaggio",
|
||||
"thoughtProcessFew": "Ragionamento · {count} passaggi",
|
||||
"thoughtProcessOther": "Ragionamento · {count} passaggi",
|
||||
"thoughtProcessOne": "Ragionamento ({count} passaggio)",
|
||||
"thoughtProcessFew": "Ragionamento ({count} passaggi)",
|
||||
"thoughtProcessOther": "Ragionamento ({count} passaggi)",
|
||||
"awaitingAnswer": "Serve una tua risposta"
|
||||
},
|
||||
"memory": {
|
||||
@@ -3269,7 +3255,8 @@
|
||||
"capped": "(limitato dall'amministratore)",
|
||||
"disabled": "L'amministratore ha disattivato il ragionamento approfondito."
|
||||
},
|
||||
"memoryLink": "Memoria ({count})"
|
||||
"memoryLink": "Memoria ({count})",
|
||||
"newConversation": "Nuova conversazione"
|
||||
},
|
||||
"relativeJustNow": "proprio ora",
|
||||
"relativeMinutesAgoOne": "{count} minuto fa",
|
||||
@@ -4062,7 +4049,7 @@
|
||||
"distributionDaysUnit": "giorni",
|
||||
"weekdayTitle": "Media per giorno della settimana",
|
||||
"weekdayNoData": "Nessun dato",
|
||||
"weekdayBestWorst": "Giorno migliore: {best} · Giorno più difficile: {worst}",
|
||||
"weekdayBestWorst": "Giorno migliore: {best}, giorno più difficile: {worst}",
|
||||
"tagsTitle": "Tag e fattori scatenanti",
|
||||
"tagAvgMood": "Umore medio con questo tag",
|
||||
"correlationsTitle": "Umore e altre metriche",
|
||||
@@ -4078,7 +4065,7 @@
|
||||
"strengthModerate": "Moderata",
|
||||
"strengthWeak": "Debole",
|
||||
"strengthNone": "Nessuna",
|
||||
"source": "{n} giorni appaiati · r = {r}",
|
||||
"source": "{n} giorni appaiati, r = {r}",
|
||||
"emptyTitle": "Non ci sono ancora abbastanza dati appaiati",
|
||||
"emptyDescription": "Registra umore e questa metrica negli stessi giorni per vedere una correlazione.",
|
||||
"pendingDescription": "{n} di {needed} giorni appaiati. La correlazione compare quando i giorni bastano a distinguere un pattern dal caso.",
|
||||
@@ -4137,7 +4124,7 @@
|
||||
"crosstab": {
|
||||
"title": "Tag e le tue metriche di salute",
|
||||
"description": "Come una metrica di salute cambia nei giorni in cui hai aggiunto un tag rispetto ai giorni senza.",
|
||||
"pairLabel": "{tag} · {metric}",
|
||||
"pairLabel": "{metric} nei giorni con {tag}",
|
||||
"metricActiveEnergy": "Energia attiva",
|
||||
"metricSleepDuration": "Sonno",
|
||||
"metricNextDayRecovery": "Recupero il giorno dopo",
|
||||
@@ -4154,7 +4141,7 @@
|
||||
"factorCrosstab": {
|
||||
"title": "I tuoi fattori valutati e i tuoi parametri vitali",
|
||||
"description": "Come cambia un parametro vitale nei giorni in cui hai valutato basso un fattore rispetto ai giorni in cui lo hai valutato alto.",
|
||||
"pairLabel": "{factor} · {metric}",
|
||||
"pairLabel": "{metric} nei giorni con {factor}",
|
||||
"metricSleepDuration": "Sonno",
|
||||
"metricSteps": "Passi",
|
||||
"metricRestingHeartRate": "Frequenza cardiaca a riposo",
|
||||
@@ -4180,7 +4167,7 @@
|
||||
"moodDown": "Un {factor} più alto tende a coincidere con un umore peggiore il giorno dopo nei tuoi dati.",
|
||||
"outcomeUp": "Un umore migliore tende a coincidere con un {factor} più alto il giorno dopo nei tuoi dati.",
|
||||
"outcomeDown": "Un umore migliore tende a coincidere con un {factor} più basso il giorno dopo nei tuoi dati.",
|
||||
"stat": "{n} giorni appaiati · r = {r} · q = {q}",
|
||||
"stat": "{n} giorni appaiati, r = {r}, q = {q}",
|
||||
"statLabel": "Come è stato calcolato",
|
||||
"explainerLabel": "Come è stato calcolato"
|
||||
},
|
||||
@@ -4224,11 +4211,11 @@
|
||||
},
|
||||
"derived": {
|
||||
"coverage": {
|
||||
"summary": "{present} di {required} input · {percent}%",
|
||||
"summary": "{present} di {required} input ({percent}%)",
|
||||
"ratioLabel": "{present}/{required}",
|
||||
"historyDays": "Basato su {count} giorni di cronologia",
|
||||
"missing": "Registra questi per affinare il valore: {list}",
|
||||
"summaryLabelled": "{label} · {percent}%"
|
||||
"summaryLabelled": "{label} ({percent}%)"
|
||||
},
|
||||
"scoreRing": {
|
||||
"aria": "Punteggio {score} su 100, {band}",
|
||||
@@ -4246,7 +4233,7 @@
|
||||
"inputsLabel": "Input",
|
||||
"noInputs": "Nessun input supporta ancora questo valore.",
|
||||
"methodLabel": "Metodo",
|
||||
"window": "Dai tuoi ultimi {days} giorni · dati {source}",
|
||||
"window": "Dai tuoi ultimi {days} giorni di dati {source}",
|
||||
"asOf": "al {date}",
|
||||
"standardLink": "Standard: {name}"
|
||||
},
|
||||
@@ -4288,7 +4275,7 @@
|
||||
"anatomy": {
|
||||
"outOf": "/100",
|
||||
"contributorsLabel": "Fattori",
|
||||
"contributorValue": "{value} · {weight}% di peso",
|
||||
"contributorValue": "{value}, peso {weight}%",
|
||||
"contributorMissing": "nessun dato",
|
||||
"insufficient": "Dati ancora insufficienti per assegnare un punteggio. Continua a registrare e comparirà qui."
|
||||
},
|
||||
@@ -5543,12 +5530,12 @@
|
||||
"parsing": "Lettura dell'esportazione…",
|
||||
"upserting": "Importazione dei record…"
|
||||
},
|
||||
"skipUnsupported": "{count} campioni saltati — tipi di dati che HealthLog non importa.",
|
||||
"skipUnsupported": "{count} campioni saltati: tipi di dati che HealthLog non importa.",
|
||||
"skipRefused": "{count} campioni non sono stati scritti (illeggibili, fuori intervallo o salvataggio non riuscito).",
|
||||
"skipBreakdown": "Mostra dettaglio ({count} tipi)",
|
||||
"skipBreakdownMore": "+ {count} altri tipi",
|
||||
"cycleSummary": "{samples} campioni del ciclo uniti in {days} registri giornalieri.",
|
||||
"cycleModuleOff": "{count} campioni del ciclo saltati — il monitoraggio del ciclo è disattivato per questo account.",
|
||||
"cycleModuleOff": "{count} campioni del ciclo saltati perché il monitoraggio del ciclo è disattivato per questo account.",
|
||||
"cycleDaysFailed": "{count} giorni del ciclo non sono stati scritti: {reason}"
|
||||
},
|
||||
"json": {
|
||||
@@ -5633,8 +5620,8 @@
|
||||
"failed": "L'importazione è fallita. Non è stato scritto nulla.",
|
||||
"columnsHeading": "Cosa fa ogni colonna",
|
||||
"jsonOnly": "solo JSON",
|
||||
"previewLine": "Righe lette: {rows} · da importare: {queued} · rifiutate: {refused}",
|
||||
"submittedLine": "Righe lette: {rows} · in coda per l'importazione: {queued}",
|
||||
"previewLine": "{rows} righe lette, {queued} da importare, {refused} rifiutate",
|
||||
"submittedLine": "{rows} righe lette, {queued} in coda per l'importazione",
|
||||
"unmatched": "Nessun farmaco registrato corrisponde a: {names}",
|
||||
"ambiguous": "Corrisponde a più di un farmaco: {names}",
|
||||
"mirrored": "Copiato da un'altra app, quindi non si può aggiungere alcuna dose: {names}",
|
||||
@@ -5811,7 +5798,8 @@
|
||||
"add": "Aggiungi {label}",
|
||||
"remove": "Rimuovi periodo di posizione",
|
||||
"removeConfirmTitle": "Rimuovere questo viaggio?",
|
||||
"removeConfirmBody": "Le misurazioni di quel periodo restano nel tuo archivio; sparisce solo la voce del viaggio."
|
||||
"removeConfirmBody": "Le misurazioni di quel periodo restano nel tuo archivio; sparisce solo la voce del viaggio.",
|
||||
"range": "Dal {start} al {end}"
|
||||
},
|
||||
"backfill": {
|
||||
"title": "Recupera i giorni passati",
|
||||
@@ -6520,7 +6508,7 @@
|
||||
},
|
||||
"integrationFreshness": {
|
||||
"title": "Dati ricevuti",
|
||||
"summary": "{count} tipi di dati · più recente {relative}",
|
||||
"summary": "{count} tipi di dati, più recente {relative}",
|
||||
"quiet": "{count} in silenzio",
|
||||
"workouts": "Allenamenti"
|
||||
},
|
||||
@@ -7079,14 +7067,14 @@
|
||||
"title": "Avanzamento della sincronizzazione",
|
||||
"receivedLabel": "Record ricevuti",
|
||||
"oldestLabel": "Lettura più vecchia raggiunta",
|
||||
"flowing": "Nell'ultima ora sono arrivati dati — l'app per iPhone sta ancora inviando.",
|
||||
"waiting": "In attesa che l'app per iPhone invii altri dati. Il trasferimento continua quando l'app è aperta o quando iOS le concede tempo in background — una pausa durante il primo grande recupero è normale, non un errore."
|
||||
"flowing": "Nell'ultima ora sono arrivati dati. L'app per iPhone sta ancora inviando.",
|
||||
"waiting": "In attesa che l'app per iPhone invii altri dati. Il trasferimento continua quando l'app è aperta o quando iOS le concede tempo in background. Una pausa durante il primo grande recupero è normale, non un errore."
|
||||
},
|
||||
"delivery": {
|
||||
"title": "Consegna",
|
||||
"lastTriggerLabel": "L'ultima sincronizzazione è arrivata",
|
||||
"lastBackgroundLabel": "Ultima sincronizzazione in background",
|
||||
"backgroundNever": "Mai — finora ogni sincronizzazione è arrivata con l'app aperta",
|
||||
"backgroundNever": "Mai: finora ogni sincronizzazione è arrivata con l'app aperta",
|
||||
"noSyncYet": "Non è ancora arrivata alcuna sincronizzazione, quindi qui non c'è nulla da riferire.",
|
||||
"trigger": {
|
||||
"foreground": "con l'app aperta",
|
||||
@@ -8086,19 +8074,19 @@
|
||||
"description": "Assumi tutti i farmaci in orario per 1 giorno."
|
||||
},
|
||||
"compliance801": {
|
||||
"title": "Aderenza 80% · 1g",
|
||||
"title": "Aderenza all'80% per 1 giorno",
|
||||
"description": "Raggiungi almeno l’80% di aderenza su 30 giorni per 1 giorno."
|
||||
},
|
||||
"bmiGreen1": {
|
||||
"title": "IMC nel verde · 1g",
|
||||
"title": "IMC nel verde per 1 giorno",
|
||||
"description": "Mantieni l’IMC nell’intervallo normale per 1 giorno."
|
||||
},
|
||||
"bpGreen1": {
|
||||
"title": "Pressione nel verde · 1g",
|
||||
"title": "Pressione nel verde per 1 giorno",
|
||||
"description": "Mantieni la pressione nell’intervallo normale per 1 giorno."
|
||||
},
|
||||
"pulseGreen1": {
|
||||
"title": "Polso nel verde · 1g",
|
||||
"title": "Polso nel verde per 1 giorno",
|
||||
"description": "Mantieni il polso a riposo nell’intervallo normale per 1 giorno."
|
||||
},
|
||||
"onTimePerfect7": {
|
||||
@@ -8106,19 +8094,19 @@
|
||||
"description": "Assumi tutti i farmaci in orario per 7 giorni consecutivi."
|
||||
},
|
||||
"compliance807": {
|
||||
"title": "Aderenza 80% · 7g",
|
||||
"title": "Aderenza all'80% per 7 giorni",
|
||||
"description": "Raggiungi almeno l’80% di aderenza su 30 giorni per 7 giorni consecutivi."
|
||||
},
|
||||
"bmiGreen7": {
|
||||
"title": "IMC nel verde · 7g",
|
||||
"title": "IMC nel verde per 7 giorni",
|
||||
"description": "Mantieni l’IMC nell’intervallo normale per 7 giorni consecutivi."
|
||||
},
|
||||
"bpGreen7": {
|
||||
"title": "Pressione nel verde · 7g",
|
||||
"title": "Pressione nel verde per 7 giorni",
|
||||
"description": "Mantieni la pressione nell’intervallo normale per 7 giorni consecutivi."
|
||||
},
|
||||
"pulseGreen7": {
|
||||
"title": "Polso nel verde · 7g",
|
||||
"title": "Polso nel verde per 7 giorni",
|
||||
"description": "Mantieni il polso a riposo nell’intervallo normale per 7 giorni consecutivi."
|
||||
},
|
||||
"onTimePerfect30": {
|
||||
@@ -8126,19 +8114,19 @@
|
||||
"description": "Assumi tutti i farmaci in orario per 30 giorni consecutivi."
|
||||
},
|
||||
"compliance8030": {
|
||||
"title": "Aderenza 80% · 30g",
|
||||
"title": "Aderenza all'80% per 30 giorni",
|
||||
"description": "Raggiungi almeno l’80% di aderenza su 30 giorni per 30 giorni consecutivi."
|
||||
},
|
||||
"bmiGreen30": {
|
||||
"title": "IMC nel verde · 30g",
|
||||
"title": "IMC nel verde per 30 giorni",
|
||||
"description": "Mantieni l’IMC nell’intervallo normale per 30 giorni consecutivi."
|
||||
},
|
||||
"bpGreen30": {
|
||||
"title": "Pressione nel verde · 30g",
|
||||
"title": "Pressione nel verde per 30 giorni",
|
||||
"description": "Mantieni la pressione nell’intervallo normale per 30 giorni consecutivi."
|
||||
},
|
||||
"pulseGreen30": {
|
||||
"title": "Polso nel verde · 30g",
|
||||
"title": "Polso nel verde per 30 giorni",
|
||||
"description": "Mantieni il polso a riposo nell’intervallo normale per 30 giorni consecutivi."
|
||||
},
|
||||
"onTimePerfect180": {
|
||||
@@ -8146,19 +8134,19 @@
|
||||
"description": "Assumi tutti i farmaci in orario per 180 giorni consecutivi."
|
||||
},
|
||||
"compliance80180": {
|
||||
"title": "Aderenza 80% · 180g",
|
||||
"title": "Aderenza all'80% per 180 giorni",
|
||||
"description": "Raggiungi almeno l’80% di aderenza su 30 giorni per 180 giorni consecutivi."
|
||||
},
|
||||
"bmiGreen180": {
|
||||
"title": "IMC nel verde · 180g",
|
||||
"title": "IMC nel verde per 180 giorni",
|
||||
"description": "Mantieni l’IMC nell’intervallo normale per 180 giorni consecutivi."
|
||||
},
|
||||
"bpGreen180": {
|
||||
"title": "Pressione nel verde · 180g",
|
||||
"title": "Pressione nel verde per 180 giorni",
|
||||
"description": "Mantieni la pressione nell’intervallo normale per 180 giorni consecutivi."
|
||||
},
|
||||
"pulseGreen180": {
|
||||
"title": "Polso nel verde · 180g",
|
||||
"title": "Polso nel verde per 180 giorni",
|
||||
"description": "Mantieni il polso a riposo nell’intervallo normale per 180 giorni consecutivi."
|
||||
},
|
||||
"onTimePerfect360": {
|
||||
@@ -8166,19 +8154,19 @@
|
||||
"description": "Assumi tutti i farmaci in orario per 360 giorni consecutivi."
|
||||
},
|
||||
"compliance80360": {
|
||||
"title": "Aderenza 80% · 360g",
|
||||
"title": "Aderenza all'80% per 360 giorni",
|
||||
"description": "Raggiungi almeno l’80% di aderenza su 30 giorni per 360 giorni consecutivi."
|
||||
},
|
||||
"bmiGreen360": {
|
||||
"title": "IMC nel verde · 360g",
|
||||
"title": "IMC nel verde per 360 giorni",
|
||||
"description": "Mantieni l’IMC nell’intervallo normale per 360 giorni consecutivi."
|
||||
},
|
||||
"bpGreen360": {
|
||||
"title": "Pressione nel verde · 360g",
|
||||
"title": "Pressione nel verde per 360 giorni",
|
||||
"description": "Mantieni la pressione nell’intervallo normale per 360 giorni consecutivi."
|
||||
},
|
||||
"pulseGreen360": {
|
||||
"title": "Polso nel verde · 360g",
|
||||
"title": "Polso nel verde per 360 giorni",
|
||||
"description": "Mantieni il polso a riposo nell’intervallo normale per 360 giorni consecutivi."
|
||||
},
|
||||
"moodFirst": {
|
||||
@@ -8262,15 +8250,15 @@
|
||||
"description": "Hai cambiato la lingua dell’app almeno una volta."
|
||||
},
|
||||
"missFree7": {
|
||||
"title": "Nessuna dose persa · 7g",
|
||||
"title": "Nessuna dose persa in 7 giorni",
|
||||
"description": "7 giorni consecutivi senza una dose persa."
|
||||
},
|
||||
"missFree30": {
|
||||
"title": "Nessuna dose persa · 30g",
|
||||
"title": "Nessuna dose persa in 30 giorni",
|
||||
"description": "30 giorni consecutivi senza una dose persa."
|
||||
},
|
||||
"missFree90": {
|
||||
"title": "Nessuna dose persa · 90g",
|
||||
"title": "Nessuna dose persa in 90 giorni",
|
||||
"description": "90 giorni consecutivi senza una dose persa."
|
||||
},
|
||||
"measurementWeeks4": {
|
||||
@@ -8282,7 +8270,7 @@
|
||||
"description": "Tutti e quattro i campi dell’autodichiarazione compilati — il Coach conosce il tuo contesto."
|
||||
},
|
||||
"sleepLog7": {
|
||||
"title": "Diario del sonno · 7g",
|
||||
"title": "Diario del sonno per 7 giorni",
|
||||
"description": "Dati del sonno registrati per 7 giorni consecutivi."
|
||||
}
|
||||
},
|
||||
@@ -8372,9 +8360,6 @@
|
||||
"enabledToast": "Avvisi proattivi del Coach attivati.",
|
||||
"disabledToast": "Avvisi proattivi del Coach disattivati.",
|
||||
"saveError": "Impossibile salvare. Riprova più tardi.",
|
||||
"suggestionsLabel": "Suggerimenti di esempio",
|
||||
"suggestionsDesc": "Mostra di tanto in tanto una domanda di esempio pronta all'uso nella schermata del Coach.",
|
||||
"suggestionsAria": "Attiva o disattiva i suggerimenti di esempio del Coach",
|
||||
"aiComposedLabel": "Lascia che sia il Coach a formulare il promemoria",
|
||||
"aiComposedDesc": "Quando è disponibile un provider, il promemoria proattivo viene scritto dal modello anziché dal modello di testo fisso. Disattivato per impostazione predefinita.",
|
||||
"aiComposedAria": "Attiva o disattiva il promemoria proattivo scritto dall'IA"
|
||||
@@ -8792,7 +8777,7 @@
|
||||
},
|
||||
"crosstab": {
|
||||
"description": "Come si confronta ogni misurazione tra la fase luteale e quella follicolare, sui tuoi dati.",
|
||||
"pairLabel": "{metric} · luteale vs follicolare",
|
||||
"pairLabel": "{metric}: fase luteale vs follicolare",
|
||||
"detail": "Luteale {lutealAvg} {unit} ({lutealDays} giorni) vs follicolare {follicularAvg} {unit} ({follicularDays} giorni).",
|
||||
"metricRestingHeartRate": "Frequenza cardiaca a riposo",
|
||||
"metricHrv": "Variabilità della frequenza cardiaca",
|
||||
@@ -9012,7 +8997,7 @@
|
||||
"temperatureSection": "Temperatura e ovulazione",
|
||||
"intimacySection": "Intimità e contraccezione",
|
||||
"testsSection": "Test",
|
||||
"phaseDayHeader": "Giorno {day} · {phase}",
|
||||
"phaseDayHeader": "Giorno {day}, {phase}",
|
||||
"bbtHint": "Misurala appena sveglia, prima di alzarti.",
|
||||
"titleForDate": "Registrazione del {date}",
|
||||
"descriptionToday": "Oggi. Flusso, sintomi e note.",
|
||||
@@ -9189,10 +9174,10 @@
|
||||
"VISCERAL_FAT": "Grasso viscerale",
|
||||
"BODY_MASS_INDEX": "Indice di massa corporea (IMC)",
|
||||
"WAIST_CIRCUMFERENCE": "Circonferenza vita",
|
||||
"PHQ9_SCORE": "PHQ-9 · umore",
|
||||
"GAD7_SCORE": "GAD-7 · preoccupazione",
|
||||
"WHO5_SCORE": "WHO-5 · benessere",
|
||||
"SCI_SCORE": "SCI · sonno"
|
||||
"PHQ9_SCORE": "PHQ-9 (umore)",
|
||||
"GAD7_SCORE": "GAD-7 (preoccupazione)",
|
||||
"WHO5_SCORE": "WHO-5 (benessere)",
|
||||
"SCI_SCORE": "SCI (sonno)"
|
||||
},
|
||||
"typeGroups": {
|
||||
"vitals": "Parametri vitali",
|
||||
@@ -10289,10 +10274,10 @@
|
||||
"saved": "Salvato",
|
||||
"error": "Qualcosa è andato storto. Riprova.",
|
||||
"instrument": {
|
||||
"phq9": "PHQ-9 · umore",
|
||||
"gad7": "GAD-7 · preoccupazione",
|
||||
"who5": "WHO-5 · benessere",
|
||||
"sci": "SCI · sonno"
|
||||
"phq9": "PHQ-9 (umore)",
|
||||
"gad7": "GAD-7 (preoccupazione)",
|
||||
"who5": "WHO-5 (benessere)",
|
||||
"sci": "SCI (sonno)"
|
||||
},
|
||||
"instrumentSub": {
|
||||
"phq9": "Screening della depressione, 9 domande",
|
||||
@@ -10760,7 +10745,7 @@
|
||||
},
|
||||
"documents": {
|
||||
"title": "Documenti",
|
||||
"subtitle": "Ogni lettera, referto e scansione in un unico posto ricercabile — cifrato a riposo.",
|
||||
"subtitle": "Ogni lettera, referto e scansione in un unico posto ricercabile, cifrato a riposo.",
|
||||
"pageUpload": "Carica",
|
||||
"ownerOnlyUpload": "Solo il titolare della cartella può aggiungere documenti.",
|
||||
"kind": {
|
||||
@@ -10802,15 +10787,15 @@
|
||||
},
|
||||
"error": {
|
||||
"fileTooLarge": "Questo file supera il limite di {maxSize}.",
|
||||
"quotaExceeded": "Lo spazio è pieno — {used} di {quota} usati.",
|
||||
"quotaExceeded": "Lo spazio è pieno: {used} di {quota} usati.",
|
||||
"unsupportedType": "Questo tipo di file non è supportato.",
|
||||
"purged": "Questo documento è stato rimosso definitivamente e non può essere ripristinato.",
|
||||
"duplicateExists": "Un documento identico è già archiviato.",
|
||||
"rateLimited": "Troppi caricamenti — riprova tra qualche minuto.",
|
||||
"rateLimited": "Troppi caricamenti. Riprova tra qualche minuto.",
|
||||
"generic": "Caricamento non riuscito. Riprova."
|
||||
},
|
||||
"toast": {
|
||||
"duplicate": "Già archiviato — il documento esistente viene evidenziato.",
|
||||
"duplicate": "Già archiviato. Il documento esistente è evidenziato.",
|
||||
"deleted": "Documento eliminato",
|
||||
"restoreFailed": "Impossibile ripristinare il documento."
|
||||
},
|
||||
@@ -10833,7 +10818,7 @@
|
||||
},
|
||||
"empty": {
|
||||
"title": "Il tuo archivio documenti è vuoto",
|
||||
"description": "Conserva lettere mediche, referti di laboratorio, imaging, prescrizioni e certificati vaccinali — cifrati e ricercabili per tipo, patologia e anno.",
|
||||
"description": "Conserva lettere mediche, referti di laboratorio, imaging, prescrizioni e certificati vaccinali. Sono cifrati e ricercabili per tipo, patologia e anno.",
|
||||
"action": "Carica un primo documento",
|
||||
"noMatchesTitle": "Nessun documento corrispondente",
|
||||
"noMatchesDescription": "Nulla corrisponde alla ricerca e ai filtri attuali."
|
||||
@@ -10873,9 +10858,9 @@
|
||||
"linkCondition": "Collega",
|
||||
"noConditions": "Non collegato a una patologia",
|
||||
"previewUnavailable": "Nessuna anteprima integrata per questo tipo di file",
|
||||
"uploadedMeta": "Aggiunto il {date} · {size}",
|
||||
"uploadedMeta": "Aggiunto il {date}, {size}",
|
||||
"importedFrom": "Importato da {system}",
|
||||
"importedFromWithId": "Importato da {system} · ID {id}",
|
||||
"importedFromWithId": "Importato da {system}, ID {id}",
|
||||
"sourceOther": "un altro sistema",
|
||||
"share": "Condividi",
|
||||
"labValuesLink": "Vedi {count} valori di laboratorio",
|
||||
@@ -10975,7 +10960,7 @@
|
||||
"CONDITION": "Condizione",
|
||||
"MEDICATION_STATEMENT": "Farmaco"
|
||||
},
|
||||
"statedRange": "intervallo indicato {range}",
|
||||
"statedRange": "Intervallo indicato {range}",
|
||||
"needsCheck": "Controlla questo valore rispetto al documento, poi salvalo.",
|
||||
"verify": "Controlla",
|
||||
"verifySave": "Salva valore",
|
||||
@@ -11245,14 +11230,14 @@
|
||||
}
|
||||
},
|
||||
"hydration": {
|
||||
"referenceMeta": "Apporto di riferimento {value} {unit}/giorno · EFSA"
|
||||
"referenceMeta": "Apporto di riferimento {value} {unit}/giorno (EFSA)"
|
||||
},
|
||||
"caffeine": {
|
||||
"ceilingMeta": "Limite di sicurezza {value} {unit}/giorno · EFSA"
|
||||
"ceilingMeta": "Limite di sicurezza {value} {unit}/giorno (EFSA)"
|
||||
},
|
||||
"micronutrients": {
|
||||
"title": "Vitamine e minerali",
|
||||
"rowMeta": "{days} di {window} giorni · rif. {value} {unit} (EFSA)",
|
||||
"rowMeta": "{days} di {window} giorni, rif. {value} {unit} (EFSA)",
|
||||
"rowMetaNoRef": "{days} di {window} giorni",
|
||||
"footer": "{tracked} di {total} micronutrienti monitorati hanno dati in questo periodo.",
|
||||
"emptyTitle": "Nessun dato sui micronutrienti ancora sincronizzato",
|
||||
@@ -11267,7 +11252,7 @@
|
||||
},
|
||||
"push": {
|
||||
"title": "Il tuo riepilogo mattutino",
|
||||
"bodyProvisional": "{line} · Il sonno della scorsa notte non è ancora disponibile."
|
||||
"bodyProvisional": "{line}\nIl sonno della scorsa notte non è ancora disponibile."
|
||||
},
|
||||
"item": {
|
||||
"doseWindow": {
|
||||
@@ -11655,7 +11640,7 @@
|
||||
"managed": {
|
||||
"title": "Profili di cui ti occupi",
|
||||
"description": "Cartelle sanitarie senza credenziali proprie.",
|
||||
"explainer": "Un profilo gestito è una cartella sanitaria per qualcuno che non accede da sé — un bambino, o una persona di cui ti prendi cura. Non ha credenziali né indirizzo e-mail, e tu ne diventi la prima persona responsabile. Più avanti puoi aggiungerne altre, e ognuna può entrare nella cartella per tenerla aggiornata.",
|
||||
"explainer": "Un profilo gestito è una cartella sanitaria per qualcuno che non accede da sé, come un bambino o una persona di cui ti prendi cura. Non ha credenziali né indirizzo e-mail, e tu ne diventi la prima persona responsabile. Più avanti puoi aggiungerne altre, e ognuna può entrare nella cartella per tenerla aggiornata.",
|
||||
"nameLabel": "Nome",
|
||||
"namePlaceholder": "Il nome con cui riconoscerai questa cartella",
|
||||
"nameHint": "Fino a 80 caratteri. Compare ovunque appaia questa cartella.",
|
||||
@@ -11677,7 +11662,7 @@
|
||||
"creating": "Creazione in corso…",
|
||||
"created": "{name} è pronto. Puoi entrare in quella cartella dal menu dell'account.",
|
||||
"errorStepUp": "Conferma il tuo secondo fattore e riprova.",
|
||||
"errorOffline": "Non è arrivato al server. Non è cambiato nulla — controlla la connessione e riprova.",
|
||||
"errorOffline": "Non è arrivato al server. Non è cambiato nulla. Controlla la connessione e riprova.",
|
||||
"errorInvalid": "Il server ha rifiutato questi dati. Controlla nome, data e fuso orario.",
|
||||
"errorFailed": "Non è stato possibile creare il profilo.",
|
||||
"edit": "Modifica",
|
||||
@@ -11688,7 +11673,7 @@
|
||||
"editErrorFailed": "Non è stato possibile salvare le modifiche.",
|
||||
"delete": "Elimina profilo",
|
||||
"deleteTitle": "Eliminare {name}?",
|
||||
"deleteBody": "Tutto ciò che è stato registrato per {name} sparisce insieme al profilo — misurazioni, farmaci, documenti e note. Non si può annullare.",
|
||||
"deleteBody": "Tutto ciò che è stato registrato per {name} sparisce insieme al profilo: misurazioni, farmaci, documenti e note. Non si può annullare.",
|
||||
"deleteGuardiansOne": "{count} persona si occupa oggi di questa cartella e la perderà.",
|
||||
"deleteGuardiansFew": "{count} persone si occupano oggi di questa cartella e la perderanno.",
|
||||
"deleteGuardiansOther": "{count} persone si occupano oggi di questa cartella e la perderanno.",
|
||||
@@ -11974,7 +11959,7 @@
|
||||
"both": "Entrambi i lati",
|
||||
"none": "Non indicato"
|
||||
},
|
||||
"bodySiteWithSide": "{site} · {side}",
|
||||
"bodySiteWithSide": "{site} ({side})",
|
||||
"sectionTitle": "Appuntamenti",
|
||||
"sectionDescription": "Visite mediche e appuntamenti fissati in un unico posto.",
|
||||
"add": "Appuntamento",
|
||||
@@ -12236,7 +12221,6 @@
|
||||
"loadFailed": "Impossibile caricare questo giorno.",
|
||||
"dayOf": "Giorno {n} di {m}",
|
||||
"dayN": "Giorno {n}",
|
||||
"since": "dal {date}",
|
||||
"usualRange": "Di solito da {lo} a {hi}",
|
||||
"groups": {
|
||||
"scores": "Punteggi",
|
||||
@@ -12264,11 +12248,11 @@
|
||||
"description": "Tutto ciò che le tue voci contengono per questo giorno.",
|
||||
"openDocument": "Apri documento",
|
||||
"previousReading": "Prima, {date}",
|
||||
"rugLegend": "Punti sotto l’asse: giorni con voci · anello: giorno rilevante",
|
||||
"rugLegend": "I punti sotto l’asse indicano i giorni con voci, un anello un giorno rilevante.",
|
||||
"sheetExpand": "Espandi a tutta altezza",
|
||||
"sheetShrink": "Riduci",
|
||||
"usualLabel": "I tuoi 30 giorni precedenti",
|
||||
"valuesLegend": "Punto: questo giorno · barra: i tuoi 30 giorni precedenti",
|
||||
"valuesLegend": "Punto per questo giorno, barra per i 30 giorni precedenti",
|
||||
"valuesAllOne": "Tutti i valori",
|
||||
"valuesAllFew": "Tutti i {count} valori",
|
||||
"valuesAllOther": "Tutti i {count} valori",
|
||||
@@ -12302,7 +12286,13 @@
|
||||
"resumed": "Ripresa: {label}",
|
||||
"illnessBegan": "Inizio: {label}",
|
||||
"illnessResolved": "Guarigione: {label}"
|
||||
}
|
||||
},
|
||||
"pickDate": "Scegli una data",
|
||||
"pickerTitle": "Vai a un giorno",
|
||||
"today": "Oggi",
|
||||
"hasEntries": "con voci",
|
||||
"dayNSince": "Giorno {n}, dal {date}",
|
||||
"sinceDate": "Dal {date}"
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Cronologia",
|
||||
@@ -12376,9 +12366,9 @@
|
||||
"vaccinationsOne": "{count} vaccinazione",
|
||||
"vaccinationsFew": "{count} vaccinazioni",
|
||||
"vaccinationsOther": "{count} vaccinazioni",
|
||||
"visitsOne": "{count} visita · con intervento: {procedures}",
|
||||
"visitsFew": "{count} visite · con intervento: {procedures}",
|
||||
"visitsOther": "{count} visite · con intervento: {procedures}",
|
||||
"visitsOne": "{count} visita, {procedures} con intervento",
|
||||
"visitsFew": "{count} visite, {procedures} con intervento",
|
||||
"visitsOther": "{count} visite, {procedures} con intervento",
|
||||
"visitsPlannedOne": "{count} visita, solo pianificata",
|
||||
"visitsPlannedFew": "{count} visite, solo pianificate",
|
||||
"visitsPlannedOther": "{count} visite, solo pianificate",
|
||||
|
||||
+119
-129
@@ -242,7 +242,7 @@
|
||||
"colExpected": "예정",
|
||||
"colComplianceRate": "순응률",
|
||||
"moodTitle": "기분 추이",
|
||||
"moodSummary": "평균: {avg} / 5.0 · 기록: {count}건 · 범위: {min}–{max}",
|
||||
"moodSummary": "평균 {avg} / 5.0, 기록 {count}건, 범위 {min}–{max}",
|
||||
"colMood": "기분",
|
||||
"colCount": "횟수",
|
||||
"colShare": "비율",
|
||||
@@ -579,8 +579,8 @@
|
||||
"sleepShort": "수면",
|
||||
"stepsShort": "걸음 수",
|
||||
"vo2MaxShort": "VO₂ max",
|
||||
"bpInTargetWindow90": "목표 내 혈압 · 90일",
|
||||
"bpInTargetWindowDynamic": "목표 내 혈압 · {days}일",
|
||||
"bpInTargetWindow90": "목표 내 혈압 (90일)",
|
||||
"bpInTargetWindowDynamic": "목표 내 혈압 ({days}일)",
|
||||
"bpInTargetCollecting": "데이터 모으는 중",
|
||||
"medications": "복약",
|
||||
"quickAddMeasurement": "측정 기록",
|
||||
@@ -1336,7 +1336,7 @@
|
||||
"complianceInfoLabel": "복약 순응도란?",
|
||||
"lastIntake": "마지막 복용:",
|
||||
"nextIntake": "다음 복용:",
|
||||
"nextIntakeOverdue": "시간 지남 · {time} — 지금도 복용할 수 있어요",
|
||||
"nextIntakeOverdue": "{time}부터 지남, 지금도 복용할 수 있어요",
|
||||
"dayStreak": "일 연속 기록",
|
||||
"dayStreakOne": "일 연속 기록",
|
||||
"cycleDueToday": "오늘 예정",
|
||||
@@ -1737,7 +1737,7 @@
|
||||
"empty": "아직 증량 단계가 기록되지 않았어요. 날짜가 있는 용량 변경을 추가하면 계획을 그려 줘요.",
|
||||
"youAreHere": "현재 위치",
|
||||
"doseStep": "{dose} {unit}",
|
||||
"plannedFor": "예정 · {date}"
|
||||
"plannedFor": "{date} 예정"
|
||||
},
|
||||
"injectionSiteBodyOutlineAriaLabel": "신체 윤곽",
|
||||
"injectionSitePickerAriaLabel": "주사 부위 선택기",
|
||||
@@ -1788,7 +1788,7 @@
|
||||
"intakeHistoryStatusSkipped": "건너뜀",
|
||||
"intakeHistorySortByTaken": "복용 시각순 정렬",
|
||||
"intakeHistorySortByScheduled": "예정 시각순 정렬",
|
||||
"intakeHistoryPageInfo": "{page}/{total}페이지 · {count}개 기록",
|
||||
"intakeHistoryPageInfo": "{page}/{total}페이지, 기록 {count}개",
|
||||
"intakeHistoryPrev": "이전",
|
||||
"intakeHistoryNext": "다음",
|
||||
"glp1": {
|
||||
@@ -1888,7 +1888,7 @@
|
||||
"markAsTaken": "복용함으로 표시",
|
||||
"markAsSkipped": "건너뜀으로 표시",
|
||||
"adHocTag": "일정 외",
|
||||
"takenDetail": "예정 {planned} · 복용 {taken}",
|
||||
"takenDetail": "예정 {planned}, 복용 {taken}",
|
||||
"doseTaken": "용량: {dose}",
|
||||
"status": {
|
||||
"taken_on_time": "정시",
|
||||
@@ -2594,26 +2594,26 @@
|
||||
"ENV_POLLEN_MAX": "꽃가루"
|
||||
},
|
||||
"daily": {
|
||||
"high": "{behaviour} 수치가 높은 날에는 다음 날 {outcome} 수치가 {direction} 경향이 있어요 — 원인이 아니라 지켜볼 만한 패턴이에요.",
|
||||
"moderate": "지금까지의 근거로는 {behaviour} 수치가 높은 날에 다음 날 {outcome} 수치가 {direction} 것처럼 보여요 — 원인이 아니라 지켜볼 만한 패턴이에요.",
|
||||
"faint": "{behaviour} 수치가 높은 날에 다음 날 {outcome} 수치가 {direction} 아주 희미한 기미만 보여요 — 기대기엔 너무 작고, 원인은 더더욱 아니에요."
|
||||
"high": "{behaviour} 수치가 높은 날에는 다음 날 {outcome} 수치가 {direction} 경향이 있어요. 원인이 아니라 지켜볼 만한 패턴이에요.",
|
||||
"moderate": "지금까지의 근거로는 {behaviour} 수치가 높은 날에 다음 날 {outcome} 수치가 {direction} 것처럼 보여요. 원인이 아니라 지켜볼 만한 패턴이에요.",
|
||||
"faint": "{behaviour} 수치가 높은 날에 다음 날 {outcome} 수치가 {direction} 아주 희미한 기미만 보여요. 기대기엔 너무 작고, 원인은 더더욱 아니에요."
|
||||
},
|
||||
"lab": {
|
||||
"default": "{lab} 측정값이 높은 시기에는 같은 기간의 {outcome} 수치도 {direction} 편이에요 — 원인이 아니라 임상의와 함께 지켜볼 만한 연관이에요.",
|
||||
"faint": "{lab} 측정값이 높은 시기에 같은 기간의 {outcome} 수치가 {direction} 아주 희미한 기미만 보여요 — 기대기엔 너무 작고, 원인은 아니지만 임상의에게 이야기해 볼 만한 연관이에요."
|
||||
"default": "{lab} 측정값이 높은 시기에는 같은 기간의 {outcome} 수치도 {direction} 편이에요. 원인이 아니라 임상의와 함께 지켜볼 만한 연관이에요.",
|
||||
"faint": "{lab} 측정값이 높은 시기에 같은 기간의 {outcome} 수치가 {direction} 아주 희미한 기미만 보여요. 기대기엔 너무 작고 원인도 아니지만, 임상의에게 이야기해 볼 만해요."
|
||||
},
|
||||
"hypothesis": {
|
||||
"bpCompliance": {
|
||||
"support": "복약 순응도가 높은 날에 수축기 측정값이 낮은 편이에요 — 지켜볼 만한 패턴이에요.",
|
||||
"surprising": "기록에서는 순응도와 수축기 혈압이 같은 방향으로 움직여요 — 뜻밖이에요. 무언가 조정하기 전에 의사와 상의해 주세요.",
|
||||
"support": "복약 순응도가 높은 날에 수축기 측정값이 낮은 편이에요. 지켜볼 만한 패턴이에요.",
|
||||
"surprising": "기록에서는 순응도와 수축기 혈압이 같은 방향으로 움직여요. 뜻밖이에요. 무언가 조정하기 전에 의사와 상의해 주세요.",
|
||||
"none": "이 기간에는 순응도와 수축기 혈압이 뚜렷하게 함께 움직이지 않아요."
|
||||
},
|
||||
"moodPulse": {
|
||||
"support": "기분이 낮은 날에 안정 시 맥박이 높은 편이에요 — 지켜볼 만한 패턴이에요.",
|
||||
"surprising": "기록에서는 기분이 좋은 날에 안정 시 맥박도 높은 편이에요 — 뜻밖이에요.",
|
||||
"support": "기분이 낮은 날에 안정 시 맥박이 높은 편이에요. 지켜볼 만한 패턴이에요.",
|
||||
"surprising": "기록에서는 기분이 좋은 날에 안정 시 맥박도 높은 편이에요. 뜻밖이에요.",
|
||||
"none": "이 기간에는 기분과 안정 시 맥박이 뚜렷하게 함께 움직이지 않아요."
|
||||
},
|
||||
"weightWeekday": "{weekday} 체중이 다른 요일 평균보다 {delta}kg {direction} 편이에요 — 지켜볼 만한 패턴이에요."
|
||||
"weightWeekday": "{weekday} 체중이 다른 요일 평균보다 {delta}kg {direction} 편이에요. 지켜볼 만한 패턴이에요."
|
||||
},
|
||||
"dailySameDay": {
|
||||
"high": "당일과 전날의 {behaviour}이(가) 높을수록 내 데이터에서는 {outcome}이(가) {direction} 경향이 있습니다. 지켜볼 만한 패턴이지 원인은 아닙니다.",
|
||||
@@ -2730,7 +2730,7 @@
|
||||
"referenceSleep": "하룻밤 최소 {low}분, 더 자는 건 감점하지 않아요",
|
||||
"referenceAdiposity": "허리-키 비율 {high} 미만",
|
||||
"referenceHba1c": "HbA1c {low}~{high}%",
|
||||
"referenceDetail": "{label} · {source} · {kind} · {low}~{high}",
|
||||
"referenceDetail": "{label}: {low}~{high} ({kind}, {source})",
|
||||
"openBound": "제한 없음",
|
||||
"laboratoryReferenceSource": "검사 기관 참고 범위",
|
||||
"personalTargetSource": "개인 목표",
|
||||
@@ -2824,7 +2824,7 @@
|
||||
"experimentCta": "7일 실험 해보기",
|
||||
"experimentTooltip": "곧 제공돼요",
|
||||
"emptyTitle": "이 관계를 보려면 데이터가 더 필요해요",
|
||||
"sourceChip": "짝지은 측정값 {n}개 기준 · {window}",
|
||||
"sourceChip": "짝지은 측정값 {n}개 기준 ({window})",
|
||||
"sourceWindowLast30": "최근 30일",
|
||||
"confidenceHigh": "신뢰도 높음",
|
||||
"confidenceModerate": "신뢰도 보통",
|
||||
@@ -3003,23 +3003,23 @@
|
||||
"cluster": {
|
||||
"cardio": {
|
||||
"label": "심혈관",
|
||||
"hint": "혈압 · 맥박 · 심박변이도 · 안정 시와 보행 심박수"
|
||||
"hint": "혈압, 맥박, 심박변이도, 안정 시와 보행 심박수"
|
||||
},
|
||||
"body": {
|
||||
"label": "체성분",
|
||||
"hint": "체중 · 체지방 · 근육 · BMI"
|
||||
"hint": "체중, 체지방, 근육, BMI"
|
||||
},
|
||||
"activity": {
|
||||
"label": "활동",
|
||||
"hint": "걸음 수 · 에너지 · 거리 · 최대산소섭취량"
|
||||
"hint": "걸음 수, 에너지, 거리, 최대산소섭취량"
|
||||
},
|
||||
"workouts": {
|
||||
"label": "운동",
|
||||
"hint": "러닝 · 라이딩 · 세션"
|
||||
"hint": "러닝, 라이딩, 세션"
|
||||
},
|
||||
"sleep": {
|
||||
"label": "수면",
|
||||
"hint": "수면 시간 · 단계"
|
||||
"hint": "수면 시간과 단계"
|
||||
},
|
||||
"mood": {
|
||||
"label": "기분",
|
||||
@@ -3027,19 +3027,19 @@
|
||||
},
|
||||
"glucose": {
|
||||
"label": "혈당",
|
||||
"hint": "공복 · 식후 측정값"
|
||||
"hint": "공복 및 식후 측정값"
|
||||
},
|
||||
"medication": {
|
||||
"label": "복약",
|
||||
"hint": "복약 순응도 · GLP-1 맥락"
|
||||
"hint": "복약 순응도와 GLP-1 맥락"
|
||||
},
|
||||
"mobility": {
|
||||
"label": "이동성과 보행",
|
||||
"hint": "안정성 · 비대칭 · 보행"
|
||||
"hint": "안정성, 비대칭, 보행"
|
||||
},
|
||||
"environment": {
|
||||
"label": "환경",
|
||||
"hint": "소음 · 햇빛 · 피부 온도"
|
||||
"hint": "소음, 햇빛, 피부 온도"
|
||||
}
|
||||
},
|
||||
"settingsClustersLabel": "코치가 보는 데이터",
|
||||
@@ -3092,21 +3092,7 @@
|
||||
"driver": "{note}"
|
||||
},
|
||||
"scope": {
|
||||
"prefix": "코치가 이미 보고 있어요: {metric}",
|
||||
"tap": "탭해서 물어보기",
|
||||
"question": "이 지표를 설명해 주세요 — 눈여겨볼 게 있나요?"
|
||||
},
|
||||
"seeded": {
|
||||
"prefix": "오늘 살펴볼 만한 것: {signal}",
|
||||
"signal": {
|
||||
"readiness": "준비도",
|
||||
"recovery": "회복"
|
||||
},
|
||||
"question": {
|
||||
"readiness": "오늘 준비도가 평소와 달라요 — 무엇 때문일까요?",
|
||||
"recovery": "회복 상태가 달라졌어요 — 이유가 뭘까요?"
|
||||
},
|
||||
"dismiss": "닫기"
|
||||
"prefix": "코치가 이미 보고 있어요: {metric}"
|
||||
},
|
||||
"settings": "설정",
|
||||
"history": {
|
||||
@@ -3227,9 +3213,9 @@
|
||||
"cap": "단계가 많아 이제 정리하는 중",
|
||||
"noProgress": "새 데이터가 없어 정리하는 중"
|
||||
},
|
||||
"thoughtProcessOne": "사고 과정 · {count}단계",
|
||||
"thoughtProcessFew": "사고 과정 · {count}단계",
|
||||
"thoughtProcessOther": "사고 과정 · {count}단계",
|
||||
"thoughtProcessOne": "사고 과정 ({count}단계)",
|
||||
"thoughtProcessFew": "사고 과정 ({count}단계)",
|
||||
"thoughtProcessOther": "사고 과정 ({count}단계)",
|
||||
"awaitingAnswer": "답변이 필요해요"
|
||||
},
|
||||
"memory": {
|
||||
@@ -3269,7 +3255,8 @@
|
||||
"capped": "(관리자가 제한함)",
|
||||
"disabled": "관리자가 깊은 생각을 꺼 두었어요."
|
||||
},
|
||||
"memoryLink": "기억 ({count})"
|
||||
"memoryLink": "기억 ({count})",
|
||||
"newConversation": "새 대화"
|
||||
},
|
||||
"relativeJustNow": "방금",
|
||||
"relativeMinutesAgoOne": "{count}분 전",
|
||||
@@ -4062,7 +4049,7 @@
|
||||
"distributionDaysUnit": "일",
|
||||
"weekdayTitle": "요일별 평균",
|
||||
"weekdayNoData": "데이터 없음",
|
||||
"weekdayBestWorst": "가장 좋은 요일: {best} · 가장 힘든 요일: {worst}",
|
||||
"weekdayBestWorst": "가장 좋은 요일: {best}, 가장 힘든 요일: {worst}",
|
||||
"tagsTitle": "태그와 유발 요인",
|
||||
"tagAvgMood": "태그가 있는 날의 평균 기분",
|
||||
"correlationsTitle": "기분과 다른 지표",
|
||||
@@ -4078,7 +4065,7 @@
|
||||
"strengthModerate": "보통",
|
||||
"strengthWeak": "약함",
|
||||
"strengthNone": "없음",
|
||||
"source": "짝지은 {n}일 · r = {r}",
|
||||
"source": "짝지은 {n}일, r = {r}",
|
||||
"sourceLabel": "계산 방법",
|
||||
"emptyTitle": "아직 짝지을 데이터가 부족해요",
|
||||
"emptyDescription": "같은 날에 기분과 이 지표를 함께 기록하면 상관관계를 볼 수 있어요.",
|
||||
@@ -4137,7 +4124,7 @@
|
||||
"crosstab": {
|
||||
"title": "태그와 건강 지표",
|
||||
"description": "각 활동을 태그한 날과 그렇지 않은 날에 건강 지표가 어떻게 다른지 보여줘요.",
|
||||
"pairLabel": "{tag} · {metric}",
|
||||
"pairLabel": "{tag} 있는 날의 {metric}",
|
||||
"metricActiveEnergy": "활동 에너지",
|
||||
"metricSleepDuration": "수면",
|
||||
"metricNextDayRecovery": "다음 날 회복도",
|
||||
@@ -4154,7 +4141,7 @@
|
||||
"factorCrosstab": {
|
||||
"title": "직접 평가한 요인과 생체 지표",
|
||||
"description": "각 요인을 낮게 평가한 날과 높게 평가한 날에 생체 지표가 어떻게 다른지 보여줘요.",
|
||||
"pairLabel": "{factor} · {metric}",
|
||||
"pairLabel": "{factor} 있는 날의 {metric}",
|
||||
"metricSleepDuration": "수면",
|
||||
"metricSteps": "걸음 수",
|
||||
"metricRestingHeartRate": "안정 시 심박수",
|
||||
@@ -4180,7 +4167,7 @@
|
||||
"moodDown": "{factor} 값이 높으면 다음 날 기분은 낮은 편이에요.",
|
||||
"outcomeUp": "기분이 좋으면 다음 날 {factor} 값도 높은 편이에요.",
|
||||
"outcomeDown": "기분이 좋으면 다음 날 {factor} 값은 낮은 편이에요.",
|
||||
"stat": "짝지은 {n}일 · r = {r} · q = {q}",
|
||||
"stat": "짝지은 {n}일, r = {r}, q = {q}",
|
||||
"statLabel": "계산 방법",
|
||||
"explainerLabel": "계산 방법"
|
||||
},
|
||||
@@ -4224,11 +4211,11 @@
|
||||
},
|
||||
"derived": {
|
||||
"coverage": {
|
||||
"summary": "입력 {required}개 중 {present}개 · {percent}%",
|
||||
"summary": "입력 {required}개 중 {present}개 ({percent}%)",
|
||||
"ratioLabel": "{present}/{required}",
|
||||
"historyDays": "최근 기록 {count}일 기준",
|
||||
"missing": "더 정확해지려면 기록해 보세요: {list}",
|
||||
"summaryLabelled": "{label} · {percent}%"
|
||||
"summaryLabelled": "{label} ({percent}%)"
|
||||
},
|
||||
"scoreRing": {
|
||||
"aria": "점수 100점 만점에 {score}점, {band}",
|
||||
@@ -4246,7 +4233,7 @@
|
||||
"inputsLabel": "입력값",
|
||||
"noInputs": "아직 이 값을 뒷받침한 입력값이 없어요.",
|
||||
"methodLabel": "방법",
|
||||
"window": "최근 {days}일 기준 · {source} 데이터",
|
||||
"window": "최근 {days}일간의 {source} 데이터 기준",
|
||||
"asOf": "{date} 기준",
|
||||
"standardLink": "기준: {name}"
|
||||
},
|
||||
@@ -4288,7 +4275,7 @@
|
||||
"anatomy": {
|
||||
"outOf": "/100",
|
||||
"contributorsLabel": "구성 요소",
|
||||
"contributorValue": "{value} · 가중치 {weight}%",
|
||||
"contributorValue": "{value}, 가중치 {weight}%",
|
||||
"contributorMissing": "데이터 없음",
|
||||
"insufficient": "아직 점수를 낼 데이터가 부족해요. 계속 기록하면 여기에 표시돼요."
|
||||
},
|
||||
@@ -5452,7 +5439,7 @@
|
||||
"title": "동기화된 영양 섭취",
|
||||
"description": "최근 {days}일간 Apple Health에서 동기화한 하루 합계예요.",
|
||||
"empty": "아직 동기화된 영양 데이터가 없어요.",
|
||||
"latest": "{date} · {amount} {unit}",
|
||||
"latest": "{date}에 {amount} {unit}",
|
||||
"daysWithData": "{days}일 중 {count}일",
|
||||
"moduleHint": "동기화는 영양 섭취 모듈에서 조절해요.",
|
||||
"moduleHintLink": "모듈 관리",
|
||||
@@ -5521,7 +5508,7 @@
|
||||
"dropHint": "또는 눌러서 파일 선택",
|
||||
"fileInputLabel": "Apple Health export.zip 파일 선택",
|
||||
"choose": "파일 선택",
|
||||
"idempotencyNote": "같은 보관 파일을 다시 올리면 병합돼요 — 중복이 생기지 않아요. 큰 파일은 백그라운드에서 처리해요.",
|
||||
"idempotencyNote": "같은 보관 파일을 다시 올리면 병합돼요. 중복은 생기지 않아요. 큰 파일은 백그라운드에서 처리해요.",
|
||||
"uploading": "올리는 중…",
|
||||
"uploadFailed": "올리지 못했어요. 다시 시도해 주세요.",
|
||||
"rateLimited": "업로드가 너무 잦아요. 잠시 뒤에 다시 시도해 주세요.",
|
||||
@@ -5530,7 +5517,7 @@
|
||||
"failureInterrupted": "불러오기가 끝나기 전에 중단됐어요. 보통 서버 재시작이나 메모리 부족 때문이에요. 데이터는 안전하니 내보내기 파일을 다시 올려 주세요.",
|
||||
"failureInsufficientMemory": "이 크기의 보관 파일을 처리하기엔 서버 메모리 한도가 낮아요. 서버 운영자에게 Node.js 힙 한도(NODE_OPTIONS=--max-old-space-size)를 올려 달라고 한 뒤 내보내기 파일을 다시 올려 주세요.",
|
||||
"rowsImported": "지금까지 {count}개 행을 불러왔어요",
|
||||
"doneSummary": "완료 — {read}개 중 {imported}개 기록을 불러왔고, 진료 기록 {skipped}개는 건너뛰었어요.",
|
||||
"doneSummary": "완료: {read}개 중 {imported}개 기록을 불러왔고, 진료 기록 {skipped}개는 건너뛰었어요.",
|
||||
"doneNothing": "그 보관 파일에서 불러온 게 없어요: {read}개 중 0개만 들어왔어요.",
|
||||
"writtenByHealthLogOne": "HealthLog가 직접 건강 앱에 기록한 {count}개 항목은 이미 데이터에 있으므로 제외했습니다.",
|
||||
"writtenByHealthLogOther": "HealthLog가 직접 건강 앱에 기록한 {count}개 항목은 이미 데이터에 있으므로 제외했습니다.",
|
||||
@@ -5543,12 +5530,12 @@
|
||||
"parsing": "내보내기 파일 읽는 중…",
|
||||
"upserting": "기록 불러오는 중…"
|
||||
},
|
||||
"skipUnsupported": "{count}개 샘플을 건너뛰었어요 — HealthLog가 불러오지 않는 데이터 유형이에요.",
|
||||
"skipUnsupported": "{count}개 샘플을 건너뛰었어요. HealthLog가 불러오지 않는 데이터 유형이에요.",
|
||||
"skipRefused": "{count}개 샘플을 쓰지 못했어요 (읽을 수 없거나, 범위를 벗어났거나, 저장에 실패했어요).",
|
||||
"skipBreakdown": "자세히 보기 ({count}개 유형)",
|
||||
"skipBreakdownMore": "+ {count}개 유형 더",
|
||||
"cycleSummary": "생리 주기 샘플 {samples}개를 {days}일치 기록으로 합쳤어요.",
|
||||
"cycleModuleOff": "생리 주기 샘플 {count}개를 건너뛰었어요 — 이 계정은 주기 기록이 꺼져 있어요.",
|
||||
"cycleModuleOff": "이 계정은 주기 기록이 꺼져 있어서 생리 주기 샘플 {count}개를 건너뛰었어요.",
|
||||
"cycleDaysFailed": "생리 주기 {count}일치를 쓰지 못했어요: {reason}"
|
||||
},
|
||||
"json": {
|
||||
@@ -5633,8 +5620,8 @@
|
||||
"failed": "불러오지 못했어요. 아무것도 저장되지 않았어요.",
|
||||
"columnsHeading": "각 열의 역할",
|
||||
"jsonOnly": "JSON 전용",
|
||||
"previewLine": "읽은 행: {rows} · 불러올 행: {queued} · 거부된 행: {refused}",
|
||||
"submittedLine": "읽은 행: {rows} · 불러오기 대기: {queued}",
|
||||
"previewLine": "읽은 행 {rows}개, 불러올 행 {queued}개, 거부된 행 {refused}개",
|
||||
"submittedLine": "읽은 행 {rows}개, 불러오기 대기 {queued}개",
|
||||
"unmatched": "기록에서 일치하는 약이 없어요: {names}",
|
||||
"ambiguous": "약이 둘 이상 일치해요: {names}",
|
||||
"mirrored": "다른 앱에서 미러링한 약이라 복용을 추가할 수 없어요: {names}",
|
||||
@@ -5799,19 +5786,20 @@
|
||||
"storedDays": "날씨 {count}일치를 저장했어요.",
|
||||
"home": {
|
||||
"title": "기본 위치",
|
||||
"description": "도시 단위의 대략적인 위치예요 — 정밀한 이동 경로는 저장하지 않아요. 설정한 날짜부터, 위치 기간이 적용되지 않는 날에는 이곳의 날씨와 일조 시간을 가져와요.",
|
||||
"description": "도시 단위의 대략적인 위치예요. 정밀한 이동 경로는 저장하지 않아요. 설정한 날짜부터, 위치 기간이 적용되지 않는 날에는 이곳의 날씨와 일조 시간을 가져와요.",
|
||||
"current": "현재: {label}",
|
||||
"none": "아직 설정한 기본 위치가 없어요.",
|
||||
"since": "{date}부터 날씨를 기록해요. 그 이전 날짜는 위치 기간을 추가하기 전까지 비어 있어요."
|
||||
},
|
||||
"travel": {
|
||||
"title": "위치 기간",
|
||||
"description": "장소와 기간을 추가하세요 — 여행일 수도, 지난 기록의 한 구간일 수도 있어요. 해당 기간에는 기본 위치 대신 이 위치가 쓰여서 실제로 있던 곳의 날씨가 반영돼요. 지난 기록을 바로잡는 방법이에요.",
|
||||
"description": "장소와 기간을 추가하세요. 여행일 수도, 지난 기록의 한 구간일 수도 있어요. 해당 기간에는 기본 위치 대신 이 위치가 쓰여서 실제로 있던 곳의 날씨가 반영돼요. 지난 기록을 바로잡는 방법이에요.",
|
||||
"none": "아직 위치 기간이 없어요.",
|
||||
"add": "{label} 추가",
|
||||
"remove": "위치 기간 삭제",
|
||||
"removeConfirmTitle": "이 여행을 삭제할까요?",
|
||||
"removeConfirmBody": "그 기간에 측정한 값은 기록에 그대로 남고, 여행 항목만 사라져요."
|
||||
"removeConfirmBody": "그 기간에 측정한 값은 기록에 그대로 남고, 여행 항목만 사라져요.",
|
||||
"range": "{start}~{end}"
|
||||
},
|
||||
"backfill": {
|
||||
"title": "지난 날짜 채우기",
|
||||
@@ -5820,8 +5808,8 @@
|
||||
},
|
||||
"noResults": "일치하는 장소가 없어요.",
|
||||
"fetchFailedOne": "마지막 백그라운드 날씨 업데이트가 실패했어요 ({when}).",
|
||||
"fetchFailedFew": "최근 백그라운드 날씨 업데이트 {count}번이 실패했어요 — 마지막은 {when}.",
|
||||
"fetchFailedOther": "최근 백그라운드 날씨 업데이트 {count}번이 실패했어요 — 마지막은 {when}.",
|
||||
"fetchFailedFew": "최근 백그라운드 날씨 업데이트 {count}번이 실패했어요. 마지막 실패: {when}.",
|
||||
"fetchFailedOther": "최근 백그라운드 날씨 업데이트 {count}번이 실패했어요. 마지막 실패: {when}.",
|
||||
"airQuality": {
|
||||
"title": "대기질과 꽃가루",
|
||||
"description": "Open-Meteo를 통해 CAMS 모델에서 가져온 내 위치의 일별 대기질, 꽃가루, 자외선 정보입니다.",
|
||||
@@ -6520,7 +6508,7 @@
|
||||
},
|
||||
"integrationFreshness": {
|
||||
"title": "받은 데이터",
|
||||
"summary": "데이터 유형 {count}개 · 최신 {relative}",
|
||||
"summary": "데이터 유형 {count}개, 최신 {relative}",
|
||||
"quiet": "{count}개 잠잠",
|
||||
"workouts": "운동"
|
||||
},
|
||||
@@ -7079,14 +7067,14 @@
|
||||
"title": "동기화 진행 상황",
|
||||
"receivedLabel": "받은 기록 수",
|
||||
"oldestLabel": "받아온 가장 오래된 측정값",
|
||||
"flowing": "최근 1시간 안에 데이터가 들어왔어요 — iPhone 앱이 계속 보내는 중이에요.",
|
||||
"waiting": "iPhone 앱이 데이터를 더 보내기를 기다리는 중이에요. 앱이 열려 있거나 iOS가 백그라운드 시간을 줄 때 전송이 이어져요 — 처음 대량으로 채워 넣는 동안 잠잠한 구간이 생기는 건 정상이고 오류가 아니에요."
|
||||
"flowing": "최근 1시간 안에 데이터가 들어왔어요. iPhone 앱이 계속 보내는 중이에요.",
|
||||
"waiting": "iPhone 앱이 데이터를 더 보내기를 기다리는 중이에요. 앱이 열려 있거나 iOS가 백그라운드 시간을 줄 때 전송이 이어져요. 처음 대량으로 채워 넣는 동안 잠잠한 구간이 생기는 건 정상이고 오류가 아니에요."
|
||||
},
|
||||
"delivery": {
|
||||
"title": "전송",
|
||||
"lastTriggerLabel": "마지막 동기화 도착",
|
||||
"lastBackgroundLabel": "마지막 백그라운드 동기화",
|
||||
"backgroundNever": "없음 — 지금까지 모든 동기화가 앱이 열린 상태에서 들어왔어요",
|
||||
"backgroundNever": "없음: 지금까지 모든 동기화가 앱이 열린 상태에서 들어왔어요",
|
||||
"noSyncYet": "아직 도착한 동기화가 없어서 여기 보여줄 내용이 없어요.",
|
||||
"trigger": {
|
||||
"foreground": "앱이 열린 상태에서",
|
||||
@@ -8086,19 +8074,19 @@
|
||||
"description": "하루 동안 모든 약을 제때 복용해요."
|
||||
},
|
||||
"compliance801": {
|
||||
"title": "복약 순응도 80% · 1일",
|
||||
"title": "1일간 복약 순응도 80%",
|
||||
"description": "30일 복약 순응도 80% 이상을 1일 유지해요."
|
||||
},
|
||||
"bmiGreen1": {
|
||||
"title": "BMI 정상 · 1일",
|
||||
"title": "1일간 BMI 정상",
|
||||
"description": "BMI를 1일 동안 정상 범위로 유지해요."
|
||||
},
|
||||
"bpGreen1": {
|
||||
"title": "혈압 정상 · 1일",
|
||||
"title": "1일간 혈압 정상",
|
||||
"description": "혈압을 1일 동안 정상 범위로 유지해요."
|
||||
},
|
||||
"pulseGreen1": {
|
||||
"title": "맥박 정상 · 1일",
|
||||
"title": "1일간 맥박 정상",
|
||||
"description": "안정 시 맥박을 1일 동안 정상 범위로 유지해요."
|
||||
},
|
||||
"onTimePerfect7": {
|
||||
@@ -8106,19 +8094,19 @@
|
||||
"description": "7일 연속으로 모든 약을 제때 복용해요."
|
||||
},
|
||||
"compliance807": {
|
||||
"title": "복약 순응도 80% · 7일",
|
||||
"title": "7일간 복약 순응도 80%",
|
||||
"description": "30일 복약 순응도 80% 이상을 7일 연속 유지해요."
|
||||
},
|
||||
"bmiGreen7": {
|
||||
"title": "BMI 정상 · 7일",
|
||||
"title": "7일간 BMI 정상",
|
||||
"description": "BMI를 7일 연속 정상 범위로 유지해요."
|
||||
},
|
||||
"bpGreen7": {
|
||||
"title": "혈압 정상 · 7일",
|
||||
"title": "7일간 혈압 정상",
|
||||
"description": "혈압을 7일 연속 정상 범위로 유지해요."
|
||||
},
|
||||
"pulseGreen7": {
|
||||
"title": "맥박 정상 · 7일",
|
||||
"title": "7일간 맥박 정상",
|
||||
"description": "안정 시 맥박을 7일 연속 정상 범위로 유지해요."
|
||||
},
|
||||
"onTimePerfect30": {
|
||||
@@ -8126,19 +8114,19 @@
|
||||
"description": "30일 연속으로 모든 약을 제때 복용해요."
|
||||
},
|
||||
"compliance8030": {
|
||||
"title": "복약 순응도 80% · 30일",
|
||||
"title": "30일간 복약 순응도 80%",
|
||||
"description": "30일 복약 순응도 80% 이상을 30일 연속 유지해요."
|
||||
},
|
||||
"bmiGreen30": {
|
||||
"title": "BMI 정상 · 30일",
|
||||
"title": "30일간 BMI 정상",
|
||||
"description": "BMI를 30일 연속 정상 범위로 유지해요."
|
||||
},
|
||||
"bpGreen30": {
|
||||
"title": "혈압 정상 · 30일",
|
||||
"title": "30일간 혈압 정상",
|
||||
"description": "혈압을 30일 연속 정상 범위로 유지해요."
|
||||
},
|
||||
"pulseGreen30": {
|
||||
"title": "맥박 정상 · 30일",
|
||||
"title": "30일간 맥박 정상",
|
||||
"description": "안정 시 맥박을 30일 연속 정상 범위로 유지해요."
|
||||
},
|
||||
"onTimePerfect180": {
|
||||
@@ -8146,19 +8134,19 @@
|
||||
"description": "180일 연속으로 모든 약을 제때 복용해요."
|
||||
},
|
||||
"compliance80180": {
|
||||
"title": "복약 순응도 80% · 180일",
|
||||
"title": "180일간 복약 순응도 80%",
|
||||
"description": "30일 복약 순응도 80% 이상을 180일 연속 유지해요."
|
||||
},
|
||||
"bmiGreen180": {
|
||||
"title": "BMI 정상 · 180일",
|
||||
"title": "180일간 BMI 정상",
|
||||
"description": "BMI를 180일 연속 정상 범위로 유지해요."
|
||||
},
|
||||
"bpGreen180": {
|
||||
"title": "혈압 정상 · 180일",
|
||||
"title": "180일간 혈압 정상",
|
||||
"description": "혈압을 180일 연속 정상 범위로 유지해요."
|
||||
},
|
||||
"pulseGreen180": {
|
||||
"title": "맥박 정상 · 180일",
|
||||
"title": "180일간 맥박 정상",
|
||||
"description": "안정 시 맥박을 180일 연속 정상 범위로 유지해요."
|
||||
},
|
||||
"onTimePerfect360": {
|
||||
@@ -8166,19 +8154,19 @@
|
||||
"description": "360일 연속으로 모든 약을 제때 복용해요."
|
||||
},
|
||||
"compliance80360": {
|
||||
"title": "복약 순응도 80% · 360일",
|
||||
"title": "360일간 복약 순응도 80%",
|
||||
"description": "30일 복약 순응도 80% 이상을 360일 연속 유지해요."
|
||||
},
|
||||
"bmiGreen360": {
|
||||
"title": "BMI 정상 · 360일",
|
||||
"title": "360일간 BMI 정상",
|
||||
"description": "BMI를 360일 연속 정상 범위로 유지해요."
|
||||
},
|
||||
"bpGreen360": {
|
||||
"title": "혈압 정상 · 360일",
|
||||
"title": "360일간 혈압 정상",
|
||||
"description": "혈압을 360일 연속 정상 범위로 유지해요."
|
||||
},
|
||||
"pulseGreen360": {
|
||||
"title": "맥박 정상 · 360일",
|
||||
"title": "360일간 맥박 정상",
|
||||
"description": "안정 시 맥박을 360일 연속 정상 범위로 유지해요."
|
||||
},
|
||||
"moodFirst": {
|
||||
@@ -8262,15 +8250,15 @@
|
||||
"description": "앱 언어를 한 번 이상 바꿨어요."
|
||||
},
|
||||
"missFree7": {
|
||||
"title": "복용 누락 없음 · 7일",
|
||||
"title": "7일간 복용 누락 없음",
|
||||
"description": "7일 연속으로 복용을 한 번도 놓치지 않아요."
|
||||
},
|
||||
"missFree30": {
|
||||
"title": "복용 누락 없음 · 30일",
|
||||
"title": "30일간 복용 누락 없음",
|
||||
"description": "30일 연속으로 복용을 한 번도 놓치지 않아요."
|
||||
},
|
||||
"missFree90": {
|
||||
"title": "복용 누락 없음 · 90일",
|
||||
"title": "90일간 복용 누락 없음",
|
||||
"description": "90일 연속으로 복용을 한 번도 놓치지 않아요."
|
||||
},
|
||||
"measurementWeeks4": {
|
||||
@@ -8282,7 +8270,7 @@
|
||||
"description": "자기 보고 항목 네 가지를 모두 채웠어요 — 코치가 상황을 알아요."
|
||||
},
|
||||
"sleepLog7": {
|
||||
"title": "수면 기록 · 7일",
|
||||
"title": "7일간 수면 기록",
|
||||
"description": "7일 연속으로 수면 데이터를 기록해요."
|
||||
}
|
||||
},
|
||||
@@ -8372,9 +8360,6 @@
|
||||
"enabledToast": "능동 코치 알림을 켰어요.",
|
||||
"disabledToast": "능동 코치 알림을 껐어요.",
|
||||
"saveError": "저장하지 못했어요. 나중에 다시 시도해 주세요.",
|
||||
"suggestionsLabel": "예시 질문",
|
||||
"suggestionsDesc": "코치 화면에 미리 준비된 예시 질문을 가끔 보여줘요.",
|
||||
"suggestionsAria": "코치 예시 질문 켜고 끄기",
|
||||
"aiComposedLabel": "코치가 알림 문구 작성",
|
||||
"aiComposedDesc": "제공자를 쓸 수 있으면 고정 템플릿 대신 모델이 알림 문구를 써요. 기본값은 꺼짐이에요.",
|
||||
"aiComposedAria": "AI가 쓴 능동 알림 켜고 끄기"
|
||||
@@ -8792,7 +8777,7 @@
|
||||
},
|
||||
"crosstab": {
|
||||
"description": "내 데이터를 기준으로, 각 측정값이 황체기와 난포기에서 어떻게 다른지 비교했어요.",
|
||||
"pairLabel": "{metric} · 황체기와 난포기 비교",
|
||||
"pairLabel": "{metric}: 황체기와 난포기 비교",
|
||||
"detail": "황체기 {lutealAvg}{unit} ({lutealDays}일), 난포기 {follicularAvg}{unit} ({follicularDays}일).",
|
||||
"metricRestingHeartRate": "안정 시 심박수",
|
||||
"metricHrv": "심박변이도",
|
||||
@@ -9012,7 +8997,7 @@
|
||||
"temperatureSection": "체온 및 배란",
|
||||
"intimacySection": "성관계 및 피임",
|
||||
"testsSection": "검사",
|
||||
"phaseDayHeader": "{day}일째 · {phase}",
|
||||
"phaseDayHeader": "{day}일째, {phase}",
|
||||
"bbtHint": "아침에 일어나기 전, 가장 먼저 재 주세요.",
|
||||
"titleForDate": "{date} 기록",
|
||||
"descriptionToday": "오늘. 생리량, 증상, 메모예요.",
|
||||
@@ -9189,10 +9174,10 @@
|
||||
"VISCERAL_FAT": "내장지방",
|
||||
"BODY_MASS_INDEX": "체질량지수(BMI)",
|
||||
"WAIST_CIRCUMFERENCE": "허리둘레",
|
||||
"PHQ9_SCORE": "PHQ-9 · 기분",
|
||||
"GAD7_SCORE": "GAD-7 · 걱정",
|
||||
"WHO5_SCORE": "WHO-5 · 웰빙",
|
||||
"SCI_SCORE": "SCI · 수면"
|
||||
"PHQ9_SCORE": "PHQ-9 (기분)",
|
||||
"GAD7_SCORE": "GAD-7 (걱정)",
|
||||
"WHO5_SCORE": "WHO-5 (웰빙)",
|
||||
"SCI_SCORE": "SCI (수면)"
|
||||
},
|
||||
"typeGroups": {
|
||||
"vitals": "활력징후",
|
||||
@@ -10289,10 +10274,10 @@
|
||||
"saved": "저장됨",
|
||||
"error": "문제가 생겼어요. 다시 시도해 주세요.",
|
||||
"instrument": {
|
||||
"phq9": "PHQ-9 · 기분",
|
||||
"gad7": "GAD-7 · 걱정",
|
||||
"who5": "WHO-5 · 웰빙",
|
||||
"sci": "SCI · 수면"
|
||||
"phq9": "PHQ-9 (기분)",
|
||||
"gad7": "GAD-7 (걱정)",
|
||||
"who5": "WHO-5 (웰빙)",
|
||||
"sci": "SCI (수면)"
|
||||
},
|
||||
"instrumentSub": {
|
||||
"phq9": "우울 선별, 9문항",
|
||||
@@ -10760,7 +10745,7 @@
|
||||
},
|
||||
"documents": {
|
||||
"title": "문서",
|
||||
"subtitle": "모든 소견서·리포트·영상을 한곳에서 검색해요 — 저장할 때 암호화돼요.",
|
||||
"subtitle": "모든 소견서, 리포트, 영상을 한곳에서 검색해요. 저장할 때 암호화돼요.",
|
||||
"pageUpload": "업로드",
|
||||
"ownerOnlyUpload": "문서는 기록 소유자만 추가할 수 있습니다.",
|
||||
"kind": {
|
||||
@@ -10802,15 +10787,15 @@
|
||||
},
|
||||
"error": {
|
||||
"fileTooLarge": "이 파일은 {maxSize} 제한보다 커요.",
|
||||
"quotaExceeded": "저장 공간이 가득 찼어요 — {quota} 중 {used} 사용 중이에요.",
|
||||
"quotaExceeded": "저장 공간이 가득 찼어요. {quota} 중 {used} 사용 중이에요.",
|
||||
"unsupportedType": "지원하지 않는 파일 형식이에요.",
|
||||
"purged": "이 문서는 완전히 삭제되어 복구할 수 없어요.",
|
||||
"duplicateExists": "같은 문서가 이미 저장돼 있어요.",
|
||||
"rateLimited": "지금은 업로드가 너무 많아요 — 몇 분 뒤에 다시 시도해 주세요.",
|
||||
"rateLimited": "지금은 업로드가 너무 많아요. 몇 분 뒤에 다시 시도해 주세요.",
|
||||
"generic": "업로드하지 못했어요. 다시 시도해 주세요."
|
||||
},
|
||||
"toast": {
|
||||
"duplicate": "이미 저장돼 있어요 — 기존 문서를 표시해요.",
|
||||
"duplicate": "이미 저장돼 있어요. 기존 문서를 표시해요.",
|
||||
"deleted": "문서를 삭제했어요",
|
||||
"restoreFailed": "문서를 복구하지 못했어요."
|
||||
},
|
||||
@@ -10833,7 +10818,7 @@
|
||||
},
|
||||
"empty": {
|
||||
"title": "문서 보관함이 비어 있어요",
|
||||
"description": "의사 소견서, 검사 리포트, 영상, 처방전, 예방접종 기록을 암호화해 보관하고 종류·질환·연도로 검색해요.",
|
||||
"description": "의사 소견서, 검사 리포트, 영상, 처방전, 예방접종 기록을 암호화해 보관하고 종류, 질환, 연도별로 검색해요.",
|
||||
"action": "첫 문서 올리기",
|
||||
"noMatchesTitle": "일치하는 문서 없음",
|
||||
"noMatchesDescription": "현재 검색·필터에 맞는 문서가 없어요."
|
||||
@@ -10873,9 +10858,9 @@
|
||||
"linkCondition": "연결",
|
||||
"noConditions": "질환에 연결되지 않음",
|
||||
"previewUnavailable": "이 파일 형식은 미리보기를 지원하지 않아요",
|
||||
"uploadedMeta": "추가 {date} · {size}",
|
||||
"uploadedMeta": "{date} 추가, {size}",
|
||||
"importedFrom": "{system}에서 가져옴",
|
||||
"importedFromWithId": "{system}에서 가져옴 · ID {id}",
|
||||
"importedFromWithId": "{system}에서 가져옴, ID {id}",
|
||||
"sourceOther": "다른 시스템",
|
||||
"share": "공유",
|
||||
"labValuesLink": "검사 수치 {count}개 보기",
|
||||
@@ -10942,7 +10927,7 @@
|
||||
"errorNotIndexed": "문서를 먼저 읽은 뒤에 추출해 주세요.",
|
||||
"errorLocalOcrDisabled": "AI 설정에서 로컬 OCR을 먼저 켜 주세요.",
|
||||
"errorProvider": "이 작업에 쓸 AI 제공자가 설정돼 있지 않아요.",
|
||||
"errorTextImageOnly": "로컬 OCR은 이미지만 읽어요 — 이 파일은 읽을 수 없어요.",
|
||||
"errorTextImageOnly": "로컬 OCR은 이미지만 읽어요. 이 파일은 읽을 수 없어요.",
|
||||
"errorConsent": "AI로 문서를 읽으려면 먼저 동의가 필요합니다.",
|
||||
"errorUnavailable": "지금 여기에서는 AI로 문서를 읽을 수 없습니다."
|
||||
},
|
||||
@@ -11245,14 +11230,14 @@
|
||||
}
|
||||
},
|
||||
"hydration": {
|
||||
"referenceMeta": "권장 섭취량 {value} {unit}/일 · EFSA"
|
||||
"referenceMeta": "권장 섭취량 {value} {unit}/일 (EFSA)"
|
||||
},
|
||||
"caffeine": {
|
||||
"ceilingMeta": "안전 상한 {value} {unit}/일 · EFSA"
|
||||
"ceilingMeta": "안전 상한 {value} {unit}/일 (EFSA)"
|
||||
},
|
||||
"micronutrients": {
|
||||
"title": "비타민·미네랄",
|
||||
"rowMeta": "{window}일 중 {days}일 · 기준 {value} {unit} (EFSA)",
|
||||
"rowMeta": "{window}일 중 {days}일, 기준 {value} {unit} (EFSA)",
|
||||
"rowMetaNoRef": "{window}일 중 {days}일",
|
||||
"footer": "이 기간에 데이터가 있는 미량영양소는 추적 중인 {total}개 중 {tracked}개예요.",
|
||||
"emptyTitle": "아직 동기화된 미량영양소 데이터가 없어요",
|
||||
@@ -11267,7 +11252,7 @@
|
||||
},
|
||||
"push": {
|
||||
"title": "아침 브리핑",
|
||||
"bodyProvisional": "{line} · 어젯밤 수면은 아직 들어오지 않았어요."
|
||||
"bodyProvisional": "{line}\n어젯밤 수면은 아직 들어오지 않았어요."
|
||||
},
|
||||
"item": {
|
||||
"doseWindow": {
|
||||
@@ -11974,7 +11959,7 @@
|
||||
"both": "양쪽",
|
||||
"none": "지정 안 함"
|
||||
},
|
||||
"bodySiteWithSide": "{site} · {side}",
|
||||
"bodySiteWithSide": "{site} ({side})",
|
||||
"sectionTitle": "진료",
|
||||
"sectionDescription": "병원 방문과 잡아 둔 예약을 한곳에서 봐요.",
|
||||
"add": "진료",
|
||||
@@ -12236,7 +12221,6 @@
|
||||
"loadFailed": "이 날을 불러오지 못했어요.",
|
||||
"dayOf": "{m}일 중 {n}일째",
|
||||
"dayN": "{n}일째",
|
||||
"since": "{date}부터",
|
||||
"usualRange": "평소 {lo}~{hi}",
|
||||
"groups": {
|
||||
"scores": "점수",
|
||||
@@ -12264,11 +12248,11 @@
|
||||
"description": "이 날에 대한 모든 기록이에요.",
|
||||
"openDocument": "문서 열기",
|
||||
"previousReading": "이전, {date}",
|
||||
"rugLegend": "축 아래 점: 기록이 있는 날 · 고리: 눈에 띄는 날",
|
||||
"rugLegend": "축 아래 점은 기록이 있는 날을, 고리는 눈에 띄는 날을 표시해요.",
|
||||
"sheetExpand": "전체 높이로 펼치기",
|
||||
"sheetShrink": "줄이기",
|
||||
"usualLabel": "직전 30일",
|
||||
"valuesLegend": "점: 이 날 · 막대: 직전 30일",
|
||||
"valuesLegend": "점은 이 날, 막대는 직전 30일",
|
||||
"valuesAllOne": "모든 수치",
|
||||
"valuesAllFew": "수치 {count}개 모두",
|
||||
"valuesAllOther": "수치 {count}개 모두",
|
||||
@@ -12302,7 +12286,13 @@
|
||||
"resumed": "{label} 다시 시작",
|
||||
"illnessBegan": "{label} 시작",
|
||||
"illnessResolved": "{label} 나음"
|
||||
}
|
||||
},
|
||||
"pickDate": "날짜 선택",
|
||||
"pickerTitle": "날짜로 이동",
|
||||
"today": "오늘",
|
||||
"hasEntries": "기록 있음",
|
||||
"dayNSince": "{n}일째, {date}부터",
|
||||
"sinceDate": "{date}부터"
|
||||
},
|
||||
"timeline": {
|
||||
"title": "타임라인",
|
||||
@@ -12376,9 +12366,9 @@
|
||||
"vaccinationsOne": "예방접종 {count}건",
|
||||
"vaccinationsFew": "예방접종 {count}건",
|
||||
"vaccinationsOther": "예방접종 {count}건",
|
||||
"visitsOne": "진료 {count}건 · 시술 {procedures}건",
|
||||
"visitsFew": "진료 {count}건 · 시술 {procedures}건",
|
||||
"visitsOther": "진료 {count}건 · 시술 {procedures}건",
|
||||
"visitsOne": "진료 {count}건, 시술 {procedures}건",
|
||||
"visitsFew": "진료 {count}건, 시술 {procedures}건",
|
||||
"visitsOther": "진료 {count}건, 시술 {procedures}건",
|
||||
"visitsPlannedOne": "예정된 진료만 {count}건",
|
||||
"visitsPlannedFew": "예정된 진료만 {count}건",
|
||||
"visitsPlannedOther": "예정된 진료만 {count}건",
|
||||
|
||||
+123
-133
@@ -206,7 +206,7 @@
|
||||
"colExpected": "Oczekiwane",
|
||||
"colComplianceRate": "Wskaźnik przestrzegania",
|
||||
"moodTitle": "Przebieg nastroju",
|
||||
"moodSummary": "Średnia:{avg} / 5.0 · Entries: {count} · Range: {min}–{max}",
|
||||
"moodSummary": "Średnia {avg} / 5.0, liczba wpisów {count}, zakres {min}–{max}",
|
||||
"colMood": "Nastrój",
|
||||
"colCount": "Liczba",
|
||||
"colShare": "Udział",
|
||||
@@ -579,8 +579,8 @@
|
||||
"sleepShort": "Sen",
|
||||
"stepsShort": "Kroki",
|
||||
"vo2MaxShort": "VO₂ max",
|
||||
"bpInTargetWindow90": "RR-cel · 90 d",
|
||||
"bpInTargetWindowDynamic": "RR-cel · {days} d",
|
||||
"bpInTargetWindow90": "RR-cel (90 d)",
|
||||
"bpInTargetWindowDynamic": "RR-cel ({days} d)",
|
||||
"bpInTargetCollecting": "zbieranie danych",
|
||||
"medications": "Leki",
|
||||
"quickAddMeasurement": "Zarejestruj pomiar",
|
||||
@@ -1336,7 +1336,7 @@
|
||||
"complianceInfoLabel": "Co oznacza przestrzeganie zaleceń?",
|
||||
"lastIntake": "Ostatnie przyjęcie:",
|
||||
"nextIntake": "Następne przyjęcie:",
|
||||
"nextIntakeOverdue": "Zaległa · {time} — wciąż można przyjąć",
|
||||
"nextIntakeOverdue": "Zaległa od {time}, wciąż można przyjąć",
|
||||
"dayStreak": "dni serii",
|
||||
"dayStreakOne": "dzień serii",
|
||||
"cycleDueToday": "Termin dzisiaj",
|
||||
@@ -1737,7 +1737,7 @@
|
||||
"empty": "Nie zarejestrowano jeszcze kroków zwiększania dawki. Dodaj datowane zmiany dawki, aby nakreślić plan.",
|
||||
"youAreHere": "Jesteś tutaj",
|
||||
"doseStep": "{dose} {unit}",
|
||||
"plannedFor": "Zaplanowano · {date}"
|
||||
"plannedFor": "Zaplanowano na {date}"
|
||||
},
|
||||
"injectionSiteBodyOutlineAriaLabel": "Sylwetka ciała",
|
||||
"injectionSitePickerAriaLabel": "Wybór miejsca iniekcji",
|
||||
@@ -1788,7 +1788,7 @@
|
||||
"intakeHistoryStatusSkipped": "Pominięty",
|
||||
"intakeHistorySortByTaken": "Sortuj wg czasu przyjęcia",
|
||||
"intakeHistorySortByScheduled": "Sortuj wg czasu zaplanowanego",
|
||||
"intakeHistoryPageInfo": "Strona {page} z {total} · {count} wpisów",
|
||||
"intakeHistoryPageInfo": "Strona {page} z {total}, {count} wpisów",
|
||||
"intakeHistoryPrev": "Poprzednia",
|
||||
"intakeHistoryNext": "Następna",
|
||||
"glp1": {
|
||||
@@ -1888,7 +1888,7 @@
|
||||
"markAsTaken": "Oznacz jako przyjętą",
|
||||
"markAsSkipped": "Oznacz jako pominiętą",
|
||||
"adHocTag": "poza planem",
|
||||
"takenDetail": "planowo {planned} · przyjęto {taken}",
|
||||
"takenDetail": "planowo {planned}, przyjęto {taken}",
|
||||
"doseTaken": "Dawka: {dose}",
|
||||
"status": {
|
||||
"taken_on_time": "na czas",
|
||||
@@ -2594,31 +2594,31 @@
|
||||
"ENV_POLLEN_MAX": "pyłki"
|
||||
},
|
||||
"daily": {
|
||||
"high": "Gdy {behaviour} rośnie, {outcome} następnego dnia zwykle wykazuje tendencję do {direction} w Twoich danych — wzorzec wart obserwacji, nie przyczyna.",
|
||||
"moderate": "Gdy {behaviour} rośnie, {outcome} następnego dnia wydaje się wykazywać tendencję do {direction}, na podstawie dostępnych danych — wzorzec wart obserwacji, nie przyczyna.",
|
||||
"faint": "Gdy {behaviour} rośnie, w Twoich danych pojawia się co najwyżej słaba wskazówka, że {outcome} następnego dnia wykazuje tendencję do {direction} — zbyt słaba, by na niej polegać, nigdy przyczyna."
|
||||
"high": "Gdy {behaviour} rośnie, {outcome} następnego dnia zwykle wykazuje tendencję do {direction} w Twoich danych. To wzorzec wart obserwacji, nie przyczyna.",
|
||||
"moderate": "Gdy {behaviour} rośnie, {outcome} następnego dnia wydaje się wykazywać tendencję do {direction}, na podstawie dostępnych danych. To wzorzec wart obserwacji, nie przyczyna.",
|
||||
"faint": "Gdy {behaviour} rośnie, w Twoich danych pojawia się co najwyżej słaba wskazówka, że {outcome} następnego dnia wykazuje tendencję do {direction}. To zbyt mało, by na tym polegać, i nigdy nie jest to przyczyna."
|
||||
},
|
||||
"lab": {
|
||||
"default": "Wyższe wartości {lab} idą w parze z tym, że {outcome} wykazuje tendencję do {direction} w tych samych okresach w Twoich danych — zależność, którą warto obserwować z lekarzem, nigdy przyczyna.",
|
||||
"faint": "Wyższe wartości {lab} co najwyżej słabo sugerują, że {outcome} wykazuje tendencję do {direction} w tych samych okresach w Twoich danych — zbyt słabo, by na tym polegać, zależność do omówienia z lekarzem, nigdy przyczyna."
|
||||
"default": "Wyższe wartości {lab} idą w parze z tym, że {outcome} wykazuje tendencję do {direction} w tych samych okresach w Twoich danych, zależność, którą warto obserwować z lekarzem, nigdy przyczyna.",
|
||||
"faint": "Wyższe wartości {lab} co najwyżej słabo sugerują, że {outcome} wykazuje tendencję do {direction} w tych samych okresach w Twoich danych. To zbyt mało, by na tym polegać, i nigdy nie jest to przyczyna, ale warto omówić to z lekarzem."
|
||||
},
|
||||
"hypothesis": {
|
||||
"bpCompliance": {
|
||||
"support": "Lepsze przyjmowanie leków idzie w parze z niższymi wartościami skurczowymi — wzorzec wart obserwacji.",
|
||||
"surprising": "Przyjmowanie leków i ciśnienie skurczowe zmieniają się razem w Twoich danych — zaskakujące; skonsultuj się z lekarzem, zanim coś zmienisz.",
|
||||
"support": "Lepsze przyjmowanie leków idzie w parze z niższymi wartościami skurczowymi, wzorzec wart obserwacji.",
|
||||
"surprising": "Przyjmowanie leków i ciśnienie skurczowe zmieniają się razem w Twoich danych, co jest zaskakujące. Skonsultuj się z lekarzem, zanim coś zmienisz.",
|
||||
"none": "Przyjmowanie leków i ciśnienie skurczowe nie zmieniają się wyraźnie razem w tym okresie."
|
||||
},
|
||||
"moodPulse": {
|
||||
"support": "Dni z gorszym nastrojem idą w parze z wyższym tętnem spoczynkowym — wzorzec wart obserwacji.",
|
||||
"surprising": "Dni z lepszym nastrojem idą w parze z wyższym tętnem spoczynkowym w Twoich danych — zaskakujące.",
|
||||
"support": "Dni z gorszym nastrojem idą w parze z wyższym tętnem spoczynkowym, wzorzec wart obserwacji.",
|
||||
"surprising": "Dni z lepszym nastrojem idą w parze z wyższym tętnem spoczynkowym w Twoich danych, co jest zaskakujące.",
|
||||
"none": "Nastrój i tętno spoczynkowe nie zmieniają się wyraźnie razem w tym okresie."
|
||||
},
|
||||
"weightWeekday": "Waga w {weekday} jest {delta} kg {direction} Twojej średniej z pozostałych dni — wzorzec wart obserwacji."
|
||||
"weightWeekday": "Waga w {weekday} jest {delta} kg {direction} Twojej średniej z pozostałych dni, wzorzec wart obserwacji."
|
||||
},
|
||||
"dailySameDay": {
|
||||
"high": "Więcej {behaviour} tego samego dnia i dzień wcześniej idzie w twoich danych zwykle w parze z {direction} {outcome} — to wzorzec wart obserwacji, nie przyczyna.",
|
||||
"moderate": "Więcej {behaviour} tego samego dnia i dzień wcześniej wydaje się, na podstawie dotychczasowych danych, iść w parze z {direction} {outcome} — to wzorzec wart obserwacji, nie przyczyna.",
|
||||
"faint": "Więcej {behaviour} tego samego dnia i dzień wcześniej pokazuje w twoich danych co najwyżej słaby ślad {direction} {outcome} — za mało, by na tym polegać, nigdy przyczyna."
|
||||
"high": "Więcej {behaviour} tego samego dnia i dzień wcześniej idzie w twoich danych zwykle w parze z {direction} {outcome}. To wzorzec wart obserwacji, nie przyczyna.",
|
||||
"moderate": "Więcej {behaviour} tego samego dnia i dzień wcześniej wydaje się, na podstawie dotychczasowych danych, iść w parze z {direction} {outcome}. To wzorzec wart obserwacji, nie przyczyna.",
|
||||
"faint": "Więcej {behaviour} tego samego dnia i dzień wcześniej pokazuje w twoich danych co najwyżej słaby ślad {direction} {outcome}. To za mało, by na tym polegać, i nigdy nie jest to przyczyna."
|
||||
}
|
||||
},
|
||||
"noAnalysisYet": "Brak oceny.",
|
||||
@@ -2730,7 +2730,7 @@
|
||||
"referenceSleep": "Co najmniej {low} minut na noc; dłuższy sen nie obniża wyniku",
|
||||
"referenceAdiposity": "Stosunek obwodu talii do wzrostu poniżej {high}",
|
||||
"referenceHba1c": "HbA1c od {low} do {high}%",
|
||||
"referenceDetail": "{label} · {source} · {kind} · od {low} do {high}",
|
||||
"referenceDetail": "{label}: od {low} do {high} ({kind}, {source})",
|
||||
"openBound": "otwarty",
|
||||
"laboratoryReferenceSource": "Zakresy referencyjne laboratorium",
|
||||
"personalTargetSource": "Osobisty cel",
|
||||
@@ -2824,7 +2824,7 @@
|
||||
"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}",
|
||||
"sourceChip": "na podstawie {n} sparowanych pomiarów ({window})",
|
||||
"sourceWindowLast30": "Ostatnie 30 dni",
|
||||
"confidenceHigh": "Wysoka pewność",
|
||||
"confidenceModerate": "Średnia pewność",
|
||||
@@ -3003,23 +3003,23 @@
|
||||
"cluster": {
|
||||
"cardio": {
|
||||
"label": "Sercowo-naczyniowe",
|
||||
"hint": "Ciśnienie · tętno · HRV · tętno spoczynkowe i marszowe"
|
||||
"hint": "Ciśnienie, tętno, HRV, tętno spoczynkowe i marszowe"
|
||||
},
|
||||
"body": {
|
||||
"label": "Skład ciała",
|
||||
"hint": "Waga · tłuszcz · mięśnie · BMI"
|
||||
"hint": "Waga, tłuszcz, mięśnie i BMI"
|
||||
},
|
||||
"activity": {
|
||||
"label": "Aktywność",
|
||||
"hint": "Kroki · energia · dystans · VO₂ max"
|
||||
"hint": "Kroki, energia, dystans i VO₂ max"
|
||||
},
|
||||
"workouts": {
|
||||
"label": "Treningi",
|
||||
"hint": "Biegi · przejażdżki · sesje"
|
||||
"hint": "Biegi, przejażdżki i sesje"
|
||||
},
|
||||
"sleep": {
|
||||
"label": "Sen",
|
||||
"hint": "Czas trwania · fazy"
|
||||
"hint": "Czas trwania i fazy"
|
||||
},
|
||||
"mood": {
|
||||
"label": "Nastrój",
|
||||
@@ -3031,15 +3031,15 @@
|
||||
},
|
||||
"medication": {
|
||||
"label": "Leki",
|
||||
"hint": "Przestrzeganie · kontekst GLP-1"
|
||||
"hint": "Przestrzeganie i kontekst GLP-1"
|
||||
},
|
||||
"mobility": {
|
||||
"label": "Mobilność i chód",
|
||||
"hint": "Stabilność · asymetria · chód"
|
||||
"hint": "Stabilność, asymetria i chód"
|
||||
},
|
||||
"environment": {
|
||||
"label": "Środowisko",
|
||||
"hint": "Hałas · światło · temp. skóry"
|
||||
"hint": "Hałas, światło dzienne i temperatura skóry"
|
||||
}
|
||||
},
|
||||
"settingsClustersLabel": "Dane widziane przez Coacha",
|
||||
@@ -3092,21 +3092,7 @@
|
||||
"driver": "{note}"
|
||||
},
|
||||
"scope": {
|
||||
"prefix": "Coach jest już przy {metric}",
|
||||
"tap": "Dotknij, aby zapytać",
|
||||
"question": "Wytłumacz mi tę wartość — na co powinienem zwrócić uwagę?"
|
||||
},
|
||||
"seeded": {
|
||||
"prefix": "Dziś warto rzucić okiem: {signal}",
|
||||
"signal": {
|
||||
"readiness": "gotowość",
|
||||
"recovery": "regeneracja"
|
||||
},
|
||||
"question": {
|
||||
"readiness": "Moja gotowość jest dziś nietypowa — z czego to wynika?",
|
||||
"recovery": "Moja regeneracja się zmieniła — co za tym stoi?"
|
||||
},
|
||||
"dismiss": "Odrzuć"
|
||||
"prefix": "Coach jest już przy {metric}"
|
||||
},
|
||||
"settings": "Ustawienia",
|
||||
"history": {
|
||||
@@ -3227,9 +3213,9 @@
|
||||
"cap": "Wiele kroków, teraz podsumowuje",
|
||||
"noProgress": "Brak nowych danych, podsumowuje"
|
||||
},
|
||||
"thoughtProcessOne": "Tok myślenia · {count} krok",
|
||||
"thoughtProcessFew": "Tok myślenia · {count} kroki",
|
||||
"thoughtProcessOther": "Tok myślenia · {count} kroków",
|
||||
"thoughtProcessOne": "Tok myślenia ({count} krok)",
|
||||
"thoughtProcessFew": "Tok myślenia ({count} kroki)",
|
||||
"thoughtProcessOther": "Tok myślenia ({count} kroków)",
|
||||
"awaitingAnswer": "Potrzebna odpowiedź"
|
||||
},
|
||||
"memory": {
|
||||
@@ -3269,7 +3255,8 @@
|
||||
"capped": "(ograniczone przez administratora)",
|
||||
"disabled": "Administrator wyłączył głębszy namysł."
|
||||
},
|
||||
"memoryLink": "Pamięć ({count})"
|
||||
"memoryLink": "Pamięć ({count})",
|
||||
"newConversation": "Nowa rozmowa"
|
||||
},
|
||||
"relativeJustNow": "przed chwilą",
|
||||
"relativeMinutesAgoOne": "{count} minutę temu",
|
||||
@@ -4062,7 +4049,7 @@
|
||||
"distributionDaysUnit": "dni",
|
||||
"weekdayTitle": "Średnia wg dnia tygodnia",
|
||||
"weekdayNoData": "Brak danych",
|
||||
"weekdayBestWorst": "Najlepszy dzień: {best} · Najtrudniejszy dzień: {worst}",
|
||||
"weekdayBestWorst": "Najlepszy dzień: {best}, najtrudniejszy dzień: {worst}",
|
||||
"tagsTitle": "Tagi i wyzwalacze",
|
||||
"tagAvgMood": "Średni nastrój z tym tagiem",
|
||||
"correlationsTitle": "Nastrój a inne wskaźniki",
|
||||
@@ -4078,7 +4065,7 @@
|
||||
"strengthModerate": "Umiarkowana",
|
||||
"strengthWeak": "Słaba",
|
||||
"strengthNone": "Brak",
|
||||
"source": "{n} sparowanych dni · r = {r}",
|
||||
"source": "{n} sparowanych dni, r = {r}",
|
||||
"emptyTitle": "Za mało sparowanych danych",
|
||||
"emptyDescription": "Rejestruj nastrój i ten wskaźnik w te same dni, aby zobaczyć korelację.",
|
||||
"pendingDescription": "{n} z {needed} sparowanych dni. Korelacja pojawi się, gdy dni wystarczy, aby odróżnić wzorzec od przypadku.",
|
||||
@@ -4137,7 +4124,7 @@
|
||||
"crosstab": {
|
||||
"title": "Tagi a Twoje wskaźniki zdrowia",
|
||||
"description": "Jak wskaźnik zdrowia różni się w dniach z danym tagiem w porównaniu z dniami bez niego.",
|
||||
"pairLabel": "{tag} · {metric}",
|
||||
"pairLabel": "{metric} w dni z: {tag}",
|
||||
"metricActiveEnergy": "Aktywna energia",
|
||||
"metricSleepDuration": "Sen",
|
||||
"metricNextDayRecovery": "Regeneracja następnego dnia",
|
||||
@@ -4154,7 +4141,7 @@
|
||||
"factorCrosstab": {
|
||||
"title": "Twoje ocenione czynniki i parametry życiowe",
|
||||
"description": "Jak parametr życiowy różni się w dniach, w których oceniłeś dany czynnik nisko, w porównaniu z dniami, w których oceniłeś go wysoko.",
|
||||
"pairLabel": "{factor} · {metric}",
|
||||
"pairLabel": "{metric} w dni z: {factor}",
|
||||
"metricSleepDuration": "Sen",
|
||||
"metricSteps": "Kroki",
|
||||
"metricRestingHeartRate": "Tętno spoczynkowe",
|
||||
@@ -4180,7 +4167,7 @@
|
||||
"moodDown": "Wyższy {factor} zwykle wiąże się z gorszym nastrojem następnego dnia w Twoich danych.",
|
||||
"outcomeUp": "Lepszy nastrój zwykle wiąże się z wyższym {factor} następnego dnia w Twoich danych.",
|
||||
"outcomeDown": "Lepszy nastrój zwykle wiąże się z niższym {factor} następnego dnia w Twoich danych.",
|
||||
"stat": "{n} sparowanych dni · r = {r} · q = {q}",
|
||||
"stat": "{n} sparowanych dni, r = {r}, q = {q}",
|
||||
"statLabel": "Jak to obliczono",
|
||||
"explainerLabel": "Jak to obliczono"
|
||||
},
|
||||
@@ -4224,11 +4211,11 @@
|
||||
},
|
||||
"derived": {
|
||||
"coverage": {
|
||||
"summary": "{present} z {required} danych · {percent}%",
|
||||
"summary": "{present} z {required} danych ({percent}%)",
|
||||
"ratioLabel": "{present}/{required}",
|
||||
"historyDays": "Na podstawie {count} dni historii",
|
||||
"missing": "Zapisuj te dane, aby uściślić wartość: {list}",
|
||||
"summaryLabelled": "{label} · {percent}%"
|
||||
"summaryLabelled": "{label} ({percent}%)"
|
||||
},
|
||||
"scoreRing": {
|
||||
"aria": "Wynik {score} na 100, {band}",
|
||||
@@ -4246,7 +4233,7 @@
|
||||
"inputsLabel": "Dane wejściowe",
|
||||
"noInputs": "Żadne dane nie potwierdzają jeszcze tej wartości.",
|
||||
"methodLabel": "Metoda",
|
||||
"window": "Z ostatnich {days} dni · dane {source}",
|
||||
"window": "Z ostatnich {days} dni danych {source}",
|
||||
"asOf": "na dzień {date}",
|
||||
"standardLink": "Standard: {name}"
|
||||
},
|
||||
@@ -4288,7 +4275,7 @@
|
||||
"anatomy": {
|
||||
"outOf": "/100",
|
||||
"contributorsLabel": "Czynniki",
|
||||
"contributorValue": "{value} · {weight}% wagi",
|
||||
"contributorValue": "{value}, waga {weight}%",
|
||||
"contributorMissing": "brak danych",
|
||||
"insufficient": "Za mało danych, by to ocenić. Rejestruj dalej, a wynik pojawi się tutaj."
|
||||
},
|
||||
@@ -4756,10 +4743,10 @@
|
||||
"dismiss": "Nie teraz"
|
||||
},
|
||||
"bmiHeight": {
|
||||
"coachPrefill": "Nie podałem jeszcze wzrostu — dlaczego BMI ma znaczenie i co powinienem wiedzieć, zanim go ustawię?"
|
||||
"coachPrefill": "Nie podałem jeszcze wzrostu. Dlaczego BMI ma znaczenie i co powinienem wiedzieć, zanim go ustawię?"
|
||||
},
|
||||
"medicationsEmpty": {
|
||||
"coachPrefill": "Nie dodałem jeszcze żadnych leków — co powinienem wiedzieć, zanim zacznę tu śledzić przestrzeganie zaleceń?"
|
||||
"coachPrefill": "Nie dodałem jeszcze żadnych leków. Co powinienem wiedzieć, zanim zacznę tu śledzić przestrzeganie zaleceń?"
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
@@ -5521,7 +5508,7 @@
|
||||
"dropHint": "lub kliknij, aby wybrać plik",
|
||||
"fileInputLabel": "Wybierz plik export.zip z Apple Health",
|
||||
"choose": "Wybierz plik",
|
||||
"idempotencyNote": "Ponowne przesłanie tego samego archiwum scala dane — nie tworzy duplikatów. Duże archiwa są przetwarzane w tle.",
|
||||
"idempotencyNote": "Ponowne przesłanie tego samego archiwum scala dane i nie tworzy duplikatów. Duże archiwa są przetwarzane w tle.",
|
||||
"uploading": "Przesyłanie…",
|
||||
"uploadFailed": "Przesyłanie nie powiodło się. Spróbuj ponownie.",
|
||||
"rateLimited": "Zbyt wiele przesłań. Poczekaj chwilę i spróbuj ponownie.",
|
||||
@@ -5530,7 +5517,7 @@
|
||||
"failureInterrupted": "Import został przerwany przed zakończeniem, zwykle przez restart serwera lub brak pamięci. Dane są bezpieczne; prześlij archiwum ponownie, aby spróbować jeszcze raz.",
|
||||
"failureInsufficientMemory": "Limit pamięci serwera jest zbyt niski dla archiwum tej wielkości. Poproś osobę administrującą serwerem o zwiększenie limitu pamięci Node.js (NODE_OPTIONS=--max-old-space-size), a następnie prześlij archiwum ponownie.",
|
||||
"rowsImported": "Zaimportowano dotąd {count} wierszy",
|
||||
"doneSummary": "Gotowe — zaimportowano {imported} z {read} rekordów, pominięto {skipped} rekordów klinicznych.",
|
||||
"doneSummary": "Gotowe: zaimportowano {imported} z {read} rekordów, pominięto {skipped} rekordów klinicznych.",
|
||||
"doneNothing": "Z tego archiwum nic nie zaimportowano: dotarło 0 z {read} rekordów.",
|
||||
"writtenByHealthLogOne": "Pominięto {count} wpis, który HealthLog sam zapisał w Apple Health, ponieważ jest już w Twoich danych.",
|
||||
"writtenByHealthLogOther": "Pominięto {count} wpisów, które HealthLog sam zapisał w Apple Health, ponieważ są już w Twoich danych.",
|
||||
@@ -5543,12 +5530,12 @@
|
||||
"parsing": "Odczytywanie eksportu…",
|
||||
"upserting": "Importowanie rekordów…"
|
||||
},
|
||||
"skipUnsupported": "{count} próbek pominięto — typy danych, których HealthLog nie importuje.",
|
||||
"skipUnsupported": "Pominięto {count} próbek: typy danych, których HealthLog nie importuje.",
|
||||
"skipRefused": "{count} próbek nie udało się zapisać (nieczytelne, poza zakresem lub zapis się nie powiódł).",
|
||||
"skipBreakdown": "Pokaż zestawienie ({count} typów)",
|
||||
"skipBreakdownMore": "+ {count} kolejnych typów",
|
||||
"cycleSummary": "{samples} próbek cyklu połączono w {days} dzienników dziennych.",
|
||||
"cycleModuleOff": "{count} próbek cyklu pominięto — śledzenie cyklu jest wyłączone dla tego konta.",
|
||||
"cycleModuleOff": "Pominięto {count} próbek cyklu, bo śledzenie cyklu jest wyłączone dla tego konta.",
|
||||
"cycleDaysFailed": "{count} dni cyklu nie udało się zapisać: {reason}"
|
||||
},
|
||||
"json": {
|
||||
@@ -5633,8 +5620,8 @@
|
||||
"failed": "Import nie udał się. Nic nie zostało zapisane.",
|
||||
"columnsHeading": "Co się dzieje z każdą kolumną",
|
||||
"jsonOnly": "tylko JSON",
|
||||
"previewLine": "Odczytane wiersze: {rows} · do zaimportowania: {queued} · odrzucone: {refused}",
|
||||
"submittedLine": "Odczytane wiersze: {rows} · w kolejce do importu: {queued}",
|
||||
"previewLine": "Odczytane wiersze: {rows}, do zaimportowania: {queued}, odrzucone: {refused}",
|
||||
"submittedLine": "Odczytane wiersze: {rows}, w kolejce do importu: {queued}",
|
||||
"unmatched": "Żaden zapisany lek nie odpowiada: {names}",
|
||||
"ambiguous": "Odpowiada więcej niż jednemu lekowi: {names}",
|
||||
"mirrored": "Skopiowany z innej aplikacji, więc nie można dodać dawki: {names}",
|
||||
@@ -5799,7 +5786,7 @@
|
||||
"storedDays": "Zapisano dni pogody: {count}.",
|
||||
"home": {
|
||||
"title": "Lokalizacja domowa",
|
||||
"description": "Przybliżona lokalizacja na poziomie miasta – nigdy dokładny ślad. Pogoda i światło dzienne są pobierane dla tego miejsca w dni bez okresu lokalizacji, od dnia jego ustawienia.",
|
||||
"description": "Przybliżona lokalizacja na poziomie miasta, nigdy dokładny ślad. Pogoda i światło dzienne są pobierane dla tego miejsca w dni bez okresu lokalizacji, od dnia jego ustawienia.",
|
||||
"current": "Obecnie: {label}",
|
||||
"none": "Nie ustawiono jeszcze lokalizacji domowej.",
|
||||
"since": "Pogoda zapisywana od {date}. Wcześniejsze dni pozostają puste, dopóki nie dodasz okresu lokalizacji."
|
||||
@@ -5811,7 +5798,8 @@
|
||||
"add": "Dodaj {label}",
|
||||
"remove": "Usuń okres lokalizacji",
|
||||
"removeConfirmTitle": "Usunąć tę podróż?",
|
||||
"removeConfirmBody": "Pomiary z tego okresu pozostają w historii; znika tylko wpis podróży."
|
||||
"removeConfirmBody": "Pomiary z tego okresu pozostają w historii; znika tylko wpis podróży.",
|
||||
"range": "Od {start} do {end}"
|
||||
},
|
||||
"backfill": {
|
||||
"title": "Uzupełnij minione dni",
|
||||
@@ -6520,7 +6508,7 @@
|
||||
},
|
||||
"integrationFreshness": {
|
||||
"title": "Odebrane dane",
|
||||
"summary": "{count} typów danych · najnowsze {relative}",
|
||||
"summary": "{count} typów danych, najnowsze {relative}",
|
||||
"quiet": "{count} bez danych",
|
||||
"workouts": "Treningi"
|
||||
},
|
||||
@@ -7079,14 +7067,14 @@
|
||||
"title": "Postęp synchronizacji",
|
||||
"receivedLabel": "Odebrane rekordy",
|
||||
"oldestLabel": "Najstarszy osiągnięty pomiar",
|
||||
"flowing": "W ciągu ostatniej godziny dotarły dane — aplikacja na iPhonie nadal je przesyła.",
|
||||
"waiting": "Oczekiwanie na kolejne dane z aplikacji na iPhonie. Przesyłanie trwa dalej, gdy aplikacja jest otwarta lub gdy iOS przydzieli jej czas w tle — cisza podczas pierwszego dużego uzupełniania historii jest normalna, to nie błąd."
|
||||
"flowing": "W ciągu ostatniej godziny dotarły dane. Aplikacja na iPhonie nadal je przesyła.",
|
||||
"waiting": "Oczekiwanie na kolejne dane z aplikacji na iPhonie. Przesyłanie trwa dalej, gdy aplikacja jest otwarta lub gdy iOS przydzieli jej czas w tle. Cisza podczas pierwszego dużego uzupełniania historii jest normalna, to nie błąd."
|
||||
},
|
||||
"delivery": {
|
||||
"title": "Dostarczanie",
|
||||
"lastTriggerLabel": "Ostatnia synchronizacja dotarła",
|
||||
"lastBackgroundLabel": "Ostatnia synchronizacja w tle",
|
||||
"backgroundNever": "Nigdy — jak dotąd każda synchronizacja dotarła przy otwartej aplikacji",
|
||||
"backgroundNever": "Nigdy: jak dotąd każda synchronizacja dotarła przy otwartej aplikacji",
|
||||
"noSyncYet": "Żadna synchronizacja jeszcze nie dotarła, więc nie ma tu nic do zgłoszenia.",
|
||||
"trigger": {
|
||||
"foreground": "przy otwartej aplikacji",
|
||||
@@ -8086,19 +8074,19 @@
|
||||
"description": "Przyjmij wszystkie leki na czas przez 1 dzień."
|
||||
},
|
||||
"compliance801": {
|
||||
"title": "Przestrzeganie 80% · 1d",
|
||||
"title": "Przestrzeganie 80% przez 1 dzień",
|
||||
"description": "Osiągnij co najmniej 80% przestrzegania w 30 dni przez 1 dzień."
|
||||
},
|
||||
"bmiGreen1": {
|
||||
"title": "BMI na zielono · 1d",
|
||||
"title": "BMI na zielono przez 1 dzień",
|
||||
"description": "Utrzymaj BMI w normie przez 1 dzień."
|
||||
},
|
||||
"bpGreen1": {
|
||||
"title": "Ciśnienie na zielono · 1d",
|
||||
"title": "Ciśnienie na zielono przez 1 dzień",
|
||||
"description": "Utrzymaj ciśnienie w normie przez 1 dzień."
|
||||
},
|
||||
"pulseGreen1": {
|
||||
"title": "Tętno na zielono · 1d",
|
||||
"title": "Tętno na zielono przez 1 dzień",
|
||||
"description": "Utrzymaj tętno spoczynkowe w normie przez 1 dzień."
|
||||
},
|
||||
"onTimePerfect7": {
|
||||
@@ -8106,19 +8094,19 @@
|
||||
"description": "Przyjmij wszystkie leki na czas przez 7 kolejnych dni."
|
||||
},
|
||||
"compliance807": {
|
||||
"title": "Przestrzeganie 80% · 7d",
|
||||
"title": "Przestrzeganie 80% przez 7 dni",
|
||||
"description": "Osiągnij co najmniej 80% przestrzegania w 30 dni przez 7 kolejnych dni."
|
||||
},
|
||||
"bmiGreen7": {
|
||||
"title": "BMI na zielono · 7d",
|
||||
"title": "BMI na zielono przez 7 dni",
|
||||
"description": "Utrzymaj BMI w normie przez 7 kolejnych dni."
|
||||
},
|
||||
"bpGreen7": {
|
||||
"title": "Ciśnienie na zielono · 7d",
|
||||
"title": "Ciśnienie na zielono przez 7 dni",
|
||||
"description": "Utrzymaj ciśnienie w normie przez 7 kolejnych dni."
|
||||
},
|
||||
"pulseGreen7": {
|
||||
"title": "Tętno na zielono · 7d",
|
||||
"title": "Tętno na zielono przez 7 dni",
|
||||
"description": "Utrzymaj tętno spoczynkowe w normie przez 7 kolejnych dni."
|
||||
},
|
||||
"onTimePerfect30": {
|
||||
@@ -8126,19 +8114,19 @@
|
||||
"description": "Przyjmij wszystkie leki na czas przez 30 kolejnych dni."
|
||||
},
|
||||
"compliance8030": {
|
||||
"title": "Przestrzeganie 80% · 30d",
|
||||
"title": "Przestrzeganie 80% przez 30 dni",
|
||||
"description": "Osiągnij co najmniej 80% przestrzegania w 30 dni przez 30 kolejnych dni."
|
||||
},
|
||||
"bmiGreen30": {
|
||||
"title": "BMI na zielono · 30d",
|
||||
"title": "BMI na zielono przez 30 dni",
|
||||
"description": "Utrzymaj BMI w normie przez 30 kolejnych dni."
|
||||
},
|
||||
"bpGreen30": {
|
||||
"title": "Ciśnienie na zielono · 30d",
|
||||
"title": "Ciśnienie na zielono przez 30 dni",
|
||||
"description": "Utrzymaj ciśnienie w normie przez 30 kolejnych dni."
|
||||
},
|
||||
"pulseGreen30": {
|
||||
"title": "Tętno na zielono · 30d",
|
||||
"title": "Tętno na zielono przez 30 dni",
|
||||
"description": "Utrzymaj tętno spoczynkowe w normie przez 30 kolejnych dni."
|
||||
},
|
||||
"onTimePerfect180": {
|
||||
@@ -8146,19 +8134,19 @@
|
||||
"description": "Przyjmij wszystkie leki na czas przez 180 kolejnych dni."
|
||||
},
|
||||
"compliance80180": {
|
||||
"title": "Przestrzeganie 80% · 180d",
|
||||
"title": "Przestrzeganie 80% przez 180 dni",
|
||||
"description": "Osiągnij co najmniej 80% przestrzegania w 30 dni przez 180 kolejnych dni."
|
||||
},
|
||||
"bmiGreen180": {
|
||||
"title": "BMI na zielono · 180d",
|
||||
"title": "BMI na zielono przez 180 dni",
|
||||
"description": "Utrzymaj BMI w normie przez 180 kolejnych dni."
|
||||
},
|
||||
"bpGreen180": {
|
||||
"title": "Ciśnienie na zielono · 180d",
|
||||
"title": "Ciśnienie na zielono przez 180 dni",
|
||||
"description": "Utrzymaj ciśnienie w normie przez 180 kolejnych dni."
|
||||
},
|
||||
"pulseGreen180": {
|
||||
"title": "Tętno na zielono · 180d",
|
||||
"title": "Tętno na zielono przez 180 dni",
|
||||
"description": "Utrzymaj tętno spoczynkowe w normie przez 180 kolejnych dni."
|
||||
},
|
||||
"onTimePerfect360": {
|
||||
@@ -8166,19 +8154,19 @@
|
||||
"description": "Przyjmij wszystkie leki na czas przez 360 kolejnych dni."
|
||||
},
|
||||
"compliance80360": {
|
||||
"title": "Przestrzeganie 80% · 360d",
|
||||
"title": "Przestrzeganie 80% przez 360 dni",
|
||||
"description": "Osiągnij co najmniej 80% przestrzegania w 30 dni przez 360 kolejnych dni."
|
||||
},
|
||||
"bmiGreen360": {
|
||||
"title": "BMI na zielono · 360d",
|
||||
"title": "BMI na zielono przez 360 dni",
|
||||
"description": "Utrzymaj BMI w normie przez 360 kolejnych dni."
|
||||
},
|
||||
"bpGreen360": {
|
||||
"title": "Ciśnienie na zielono · 360d",
|
||||
"title": "Ciśnienie na zielono przez 360 dni",
|
||||
"description": "Utrzymaj ciśnienie w normie przez 360 kolejnych dni."
|
||||
},
|
||||
"pulseGreen360": {
|
||||
"title": "Tętno na zielono · 360d",
|
||||
"title": "Tętno na zielono przez 360 dni",
|
||||
"description": "Utrzymaj tętno spoczynkowe w normie przez 360 kolejnych dni."
|
||||
},
|
||||
"moodFirst": {
|
||||
@@ -8262,15 +8250,15 @@
|
||||
"description": "Zmieniłeś język aplikacji co najmniej raz."
|
||||
},
|
||||
"missFree7": {
|
||||
"title": "Bez pominiętej dawki · 7 dni",
|
||||
"title": "Bez pominiętej dawki przez 7 dni",
|
||||
"description": "7 kolejnych dni bez pominiętej dawki."
|
||||
},
|
||||
"missFree30": {
|
||||
"title": "Bez pominiętej dawki · 30 dni",
|
||||
"title": "Bez pominiętej dawki przez 30 dni",
|
||||
"description": "30 kolejnych dni bez pominiętej dawki."
|
||||
},
|
||||
"missFree90": {
|
||||
"title": "Bez pominiętej dawki · 90 dni",
|
||||
"title": "Bez pominiętej dawki przez 90 dni",
|
||||
"description": "90 kolejnych dni bez pominiętej dawki."
|
||||
},
|
||||
"measurementWeeks4": {
|
||||
@@ -8282,7 +8270,7 @@
|
||||
"description": "Wszystkie cztery pola wywiadu wypełnione — Coach zna Twój kontekst."
|
||||
},
|
||||
"sleepLog7": {
|
||||
"title": "Dziennik snu · 7 dni",
|
||||
"title": "Dziennik snu przez 7 dni",
|
||||
"description": "Dane o śnie zapisane przez 7 kolejnych dni."
|
||||
}
|
||||
},
|
||||
@@ -8372,9 +8360,6 @@
|
||||
"enabledToast": "Proaktywne podpowiedzi Coacha włączone.",
|
||||
"disabledToast": "Proaktywne podpowiedzi Coacha wyłączone.",
|
||||
"saveError": "Nie udało się zapisać. Spróbuj ponownie później.",
|
||||
"suggestionsLabel": "Przykładowe sugestie",
|
||||
"suggestionsDesc": "Czasami pokazuje gotowe przykładowe pytanie na ekranie Coacha.",
|
||||
"suggestionsAria": "Przełącz przykładowe sugestie Coacha",
|
||||
"aiComposedLabel": "Pozwól Coachowi sformułować przypomnienie",
|
||||
"aiComposedDesc": "Gdy dostawca jest dostępny, proaktywne przypomnienie tworzy model zamiast stałego szablonu. Domyślnie wyłączone.",
|
||||
"aiComposedAria": "Przełącz proaktywne przypomnienie tworzone przez SI"
|
||||
@@ -8792,7 +8777,7 @@
|
||||
},
|
||||
"crosstab": {
|
||||
"description": "Jak każdy pomiar wypada między fazą lutealną a folikularną, na Twoich danych.",
|
||||
"pairLabel": "{metric} · lutealna vs folikularna",
|
||||
"pairLabel": "{metric}: faza lutealna vs folikularna",
|
||||
"detail": "Lutealna {lutealAvg} {unit} ({lutealDays} dni) vs folikularna {follicularAvg} {unit} ({follicularDays} dni).",
|
||||
"metricRestingHeartRate": "Tętno spoczynkowe",
|
||||
"metricHrv": "Zmienność rytmu serca",
|
||||
@@ -9012,7 +8997,7 @@
|
||||
"temperatureSection": "Temperatura i owulacja",
|
||||
"intimacySection": "Intymność i antykoncepcja",
|
||||
"testsSection": "Testy",
|
||||
"phaseDayHeader": "Dzień {day} · {phase}",
|
||||
"phaseDayHeader": "Dzień {day}, {phase}",
|
||||
"bbtHint": "Zmierz ją rano zaraz po przebudzeniu, przed wstaniem.",
|
||||
"titleForDate": "Wpis z {date}",
|
||||
"descriptionToday": "Dzisiaj. Krwawienie, objawy i notatki.",
|
||||
@@ -9189,10 +9174,10 @@
|
||||
"VISCERAL_FAT": "Tłuszcz trzewny",
|
||||
"BODY_MASS_INDEX": "Wskaźnik masy ciała (BMI)",
|
||||
"WAIST_CIRCUMFERENCE": "Obwód talii",
|
||||
"PHQ9_SCORE": "PHQ-9 · nastrój",
|
||||
"GAD7_SCORE": "GAD-7 · lęk",
|
||||
"WHO5_SCORE": "WHO-5 · dobrostan",
|
||||
"SCI_SCORE": "SCI · sen"
|
||||
"PHQ9_SCORE": "PHQ-9 (nastrój)",
|
||||
"GAD7_SCORE": "GAD-7 (lęk)",
|
||||
"WHO5_SCORE": "WHO-5 (dobrostan)",
|
||||
"SCI_SCORE": "SCI (sen)"
|
||||
},
|
||||
"typeGroups": {
|
||||
"vitals": "Parametry życiowe",
|
||||
@@ -9755,7 +9740,7 @@
|
||||
},
|
||||
"empty": {
|
||||
"title": "Nie zarejestrowano jeszcze żadnego szczepienia",
|
||||
"description": "Przepisz swoją kartę szczepień wiersz po wierszu — wystarczą data i szczepionka."
|
||||
"description": "Przepisz swoją kartę szczepień wiersz po wierszu. Wystarczą data i szczepionka."
|
||||
},
|
||||
"catalog": {
|
||||
"tetanus": "Tężec",
|
||||
@@ -10289,10 +10274,10 @@
|
||||
"saved": "Zapisano",
|
||||
"error": "Coś poszło nie tak. Spróbuj ponownie.",
|
||||
"instrument": {
|
||||
"phq9": "PHQ-9 · nastrój",
|
||||
"gad7": "GAD-7 · lęk",
|
||||
"who5": "WHO-5 · samopoczucie",
|
||||
"sci": "SCI · sen"
|
||||
"phq9": "PHQ-9 (nastrój)",
|
||||
"gad7": "GAD-7 (lęk)",
|
||||
"who5": "WHO-5 (samopoczucie)",
|
||||
"sci": "SCI (sen)"
|
||||
},
|
||||
"instrumentSub": {
|
||||
"phq9": "Przesiew depresji, 9 pytań",
|
||||
@@ -10760,7 +10745,7 @@
|
||||
},
|
||||
"documents": {
|
||||
"title": "Dokumenty",
|
||||
"subtitle": "Każde pismo, wynik i skan w jednym przeszukiwalnym miejscu — zaszyfrowane w spoczynku.",
|
||||
"subtitle": "Każde pismo, wynik i skan w jednym przeszukiwalnym miejscu, zaszyfrowane w spoczynku.",
|
||||
"pageUpload": "Prześlij",
|
||||
"ownerOnlyUpload": "Dokumenty może dodawać tylko właściciel kartoteki.",
|
||||
"kind": {
|
||||
@@ -10802,15 +10787,15 @@
|
||||
},
|
||||
"error": {
|
||||
"fileTooLarge": "Ten plik przekracza limit {maxSize}.",
|
||||
"quotaExceeded": "Miejsce jest pełne — użyto {used} z {quota}.",
|
||||
"quotaExceeded": "Miejsce jest pełne: użyto {used} z {quota}.",
|
||||
"unsupportedType": "Ten typ pliku nie jest obsługiwany.",
|
||||
"purged": "Ten dokument został trwale usunięty i nie można go przywrócić.",
|
||||
"duplicateExists": "Identyczny dokument jest już zapisany.",
|
||||
"rateLimited": "Zbyt wiele przesyłań — spróbuj ponownie za kilka minut.",
|
||||
"rateLimited": "Zbyt wiele przesyłań. Spróbuj ponownie za kilka minut.",
|
||||
"generic": "Przesyłanie nie powiodło się. Spróbuj ponownie."
|
||||
},
|
||||
"toast": {
|
||||
"duplicate": "Już zapisano — istniejący dokument został wyróżniony.",
|
||||
"duplicate": "Już zapisano. Istniejący dokument został wyróżniony.",
|
||||
"deleted": "Dokument usunięty",
|
||||
"restoreFailed": "Nie udało się przywrócić dokumentu."
|
||||
},
|
||||
@@ -10833,7 +10818,7 @@
|
||||
},
|
||||
"empty": {
|
||||
"title": "Twój sejf dokumentów jest pusty",
|
||||
"description": "Przechowuj pisma lekarskie, wyniki laboratoryjne, obrazowanie, recepty i zaświadczenia o szczepieniach — zaszyfrowane i przeszukiwalne według typu, schorzenia i roku.",
|
||||
"description": "Przechowuj pisma lekarskie, wyniki laboratoryjne, obrazowanie, recepty i zaświadczenia o szczepieniach. Są zaszyfrowane i można je przeszukiwać według typu, schorzenia i roku.",
|
||||
"action": "Prześlij pierwszy dokument",
|
||||
"noMatchesTitle": "Brak pasujących dokumentów",
|
||||
"noMatchesDescription": "Nic nie pasuje do bieżącego wyszukiwania i filtrów."
|
||||
@@ -10873,9 +10858,9 @@
|
||||
"linkCondition": "Powiąż",
|
||||
"noConditions": "Niepowiązany ze schorzeniem",
|
||||
"previewUnavailable": "Brak podglądu dla tego typu pliku",
|
||||
"uploadedMeta": "Dodano {date} · {size}",
|
||||
"uploadedMeta": "Dodano {date}, {size}",
|
||||
"importedFrom": "Zaimportowano z: {system}",
|
||||
"importedFromWithId": "Zaimportowano z: {system} · ID {id}",
|
||||
"importedFromWithId": "Zaimportowano z: {system}, ID {id}",
|
||||
"sourceOther": "inny system",
|
||||
"share": "Udostępnij",
|
||||
"labValuesLink": "Zobacz {count} wyników laboratoryjnych",
|
||||
@@ -10975,7 +10960,7 @@
|
||||
"CONDITION": "Schorzenie",
|
||||
"MEDICATION_STATEMENT": "Lek"
|
||||
},
|
||||
"statedRange": "podany zakres {range}",
|
||||
"statedRange": "Podany zakres {range}",
|
||||
"needsCheck": "Sprawdź tę wartość z dokumentem, a potem ją zapisz.",
|
||||
"verify": "Sprawdź",
|
||||
"verifySave": "Zapisz wartość",
|
||||
@@ -11245,14 +11230,14 @@
|
||||
}
|
||||
},
|
||||
"hydration": {
|
||||
"referenceMeta": "Zalecane spożycie {value} {unit}/dzień · EFSA"
|
||||
"referenceMeta": "Zalecane spożycie {value} {unit}/dzień (EFSA)"
|
||||
},
|
||||
"caffeine": {
|
||||
"ceilingMeta": "Bezpieczny limit {value} {unit}/dzień · EFSA"
|
||||
"ceilingMeta": "Bezpieczny limit {value} {unit}/dzień (EFSA)"
|
||||
},
|
||||
"micronutrients": {
|
||||
"title": "Witaminy i minerały",
|
||||
"rowMeta": "{days} z {window} dni · zalecane {value} {unit} (EFSA)",
|
||||
"rowMeta": "{days} z {window} dni, zalecane {value} {unit} (EFSA)",
|
||||
"rowMetaNoRef": "{days} z {window} dni",
|
||||
"footer": "{tracked} z {total} śledzonych mikroskładników ma dane w tym okresie.",
|
||||
"emptyTitle": "Brak jeszcze zsynchronizowanych danych o mikroskładnikach",
|
||||
@@ -11267,7 +11252,7 @@
|
||||
},
|
||||
"push": {
|
||||
"title": "Twoje poranne podsumowanie",
|
||||
"bodyProvisional": "{line} · Brakuje jeszcze snu z zeszłej nocy."
|
||||
"bodyProvisional": "{line}\nBrakuje jeszcze snu z zeszłej nocy."
|
||||
},
|
||||
"item": {
|
||||
"doseWindow": {
|
||||
@@ -11655,7 +11640,7 @@
|
||||
"managed": {
|
||||
"title": "Profile, którymi się opiekujesz",
|
||||
"description": "Karty zdrowia bez własnego logowania.",
|
||||
"explainer": "Zarządzany profil to karta zdrowia osoby, która sama się nie loguje — dziecka albo kogoś, kim się opiekujesz. Nie ma logowania ani adresu e-mail, a Ty stajesz się pierwszą osobą opiekującą się profilem. Później możesz dodać kolejne, a każda z was może wejść do karty i ją uzupełniać.",
|
||||
"explainer": "Zarządzany profil to karta zdrowia osoby, która sama się nie loguje, na przykład dziecka albo kogoś, kim się opiekujesz. Nie ma logowania ani adresu e-mail, a Ty stajesz się pierwszą osobą opiekującą się profilem. Później możesz dodać kolejne, a każda z was może wejść do karty i ją uzupełniać.",
|
||||
"nameLabel": "Nazwa",
|
||||
"namePlaceholder": "Nazwa, po której rozpoznasz tę kartę",
|
||||
"nameHint": "Do 80 znaków. Wyświetla się wszędzie tam, gdzie pojawia się ta karta.",
|
||||
@@ -11677,7 +11662,7 @@
|
||||
"creating": "Tworzenie…",
|
||||
"created": "{name} jest gotowy. Do tej karty przejdziesz z menu konta.",
|
||||
"errorStepUp": "Potwierdź drugi składnik logowania i spróbuj ponownie.",
|
||||
"errorOffline": "To nie dotarło do serwera. Nic się nie zmieniło — sprawdź połączenie i spróbuj ponownie.",
|
||||
"errorOffline": "To nie dotarło do serwera. Nic się nie zmieniło. Sprawdź połączenie i spróbuj ponownie.",
|
||||
"errorInvalid": "Serwer odrzucił te dane. Sprawdź nazwę, datę i strefę czasową.",
|
||||
"errorFailed": "Nie udało się utworzyć profilu.",
|
||||
"edit": "Edytuj",
|
||||
@@ -11688,7 +11673,7 @@
|
||||
"editErrorFailed": "Nie udało się zapisać zmian.",
|
||||
"delete": "Usuń profil",
|
||||
"deleteTitle": "Usunąć {name}?",
|
||||
"deleteBody": "Wszystko, co zapisano dla {name}, znika razem z profilem — pomiary, leki, dokumenty i notatki. Tego nie da się cofnąć.",
|
||||
"deleteBody": "Wszystko, co zapisano dla {name}, znika razem z profilem: pomiary, leki, dokumenty i notatki. Tego nie da się cofnąć.",
|
||||
"deleteGuardiansOne": "{count} osoba opiekuje się dziś tą kartą i ją straci.",
|
||||
"deleteGuardiansFew": "{count} osoby opiekują się dziś tą kartą i ją stracą.",
|
||||
"deleteGuardiansOther": "{count} osób opiekuje się dziś tą kartą i ją straci.",
|
||||
@@ -11974,7 +11959,7 @@
|
||||
"both": "Obie strony",
|
||||
"none": "Nie podano"
|
||||
},
|
||||
"bodySiteWithSide": "{site} · {side}",
|
||||
"bodySiteWithSide": "{site} ({side})",
|
||||
"sectionTitle": "Wizyty",
|
||||
"sectionDescription": "Wizyty u lekarza i umówione terminy w jednym miejscu.",
|
||||
"add": "Wizyta",
|
||||
@@ -12236,7 +12221,6 @@
|
||||
"loadFailed": "Nie udało się wczytać tego dnia.",
|
||||
"dayOf": "Dzień {n} z {m}",
|
||||
"dayN": "Dzień {n}",
|
||||
"since": "od {date}",
|
||||
"usualRange": "Zwykle od {lo} do {hi}",
|
||||
"groups": {
|
||||
"scores": "Wyniki",
|
||||
@@ -12264,11 +12248,11 @@
|
||||
"description": "Wszystko, co Twoje wpisy zawierają dla tego dnia.",
|
||||
"openDocument": "Otwórz dokument",
|
||||
"previousReading": "Wcześniej, {date}",
|
||||
"rugLegend": "Punkty pod osią: dni z wpisami · pierścień: dzień godny uwagi",
|
||||
"rugLegend": "Punkty pod osią oznaczają dni z wpisami, pierścień dzień godny uwagi.",
|
||||
"sheetExpand": "Rozwiń na pełną wysokość",
|
||||
"sheetShrink": "Zmniejsz",
|
||||
"usualLabel": "Twoje 30 dni wcześniej",
|
||||
"valuesLegend": "Punkt: ten dzień · pasek: Twoje 30 dni wcześniej",
|
||||
"valuesLegend": "Punkt to ten dzień, pasek to 30 dni wcześniej",
|
||||
"valuesAllOne": "Wszystkie wartości",
|
||||
"valuesAllFew": "Wszystkie {count} wartości",
|
||||
"valuesAllOther": "Wszystkie {count} wartości",
|
||||
@@ -12302,7 +12286,13 @@
|
||||
"resumed": "Wznowiono: {label}",
|
||||
"illnessBegan": "Początek: {label}",
|
||||
"illnessResolved": "Wyzdrowienie: {label}"
|
||||
}
|
||||
},
|
||||
"pickDate": "Wybierz datę",
|
||||
"pickerTitle": "Przejdź do dnia",
|
||||
"today": "Dzisiaj",
|
||||
"hasEntries": "z wpisami",
|
||||
"dayNSince": "Dzień {n}, od {date}",
|
||||
"sinceDate": "Od {date}"
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Oś czasu",
|
||||
@@ -12376,9 +12366,9 @@
|
||||
"vaccinationsOne": "{count} szczepienie",
|
||||
"vaccinationsFew": "{count} szczepienia",
|
||||
"vaccinationsOther": "{count} szczepień",
|
||||
"visitsOne": "{count} wizyta · z zabiegiem: {procedures}",
|
||||
"visitsFew": "{count} wizyty · z zabiegiem: {procedures}",
|
||||
"visitsOther": "{count} wizyt · z zabiegiem: {procedures}",
|
||||
"visitsOne": "{count} wizyta, z zabiegiem: {procedures}",
|
||||
"visitsFew": "{count} wizyty, z zabiegiem: {procedures}",
|
||||
"visitsOther": "{count} wizyt, z zabiegiem: {procedures}",
|
||||
"visitsPlannedOne": "{count} wizyta, tylko zaplanowana",
|
||||
"visitsPlannedFew": "{count} wizyty, tylko zaplanowane",
|
||||
"visitsPlannedOther": "{count} wizyt, tylko zaplanowane",
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
/**
|
||||
* No middle-dot separator in visible strings.
|
||||
*
|
||||
* A value like "Added {date} · {size}" or "Weight · body fat · muscle" reads
|
||||
* as a list of fragments rather than as the app's plain voice. Facts are
|
||||
* joined with a comma, a colon or a real phrase instead. This guard walks
|
||||
* every bundle under `messages/` and fails on any string that contains a
|
||||
* space, U+00B7 and a space. A middle dot inside a unit ("mL/(kg·min)") has
|
||||
* no surrounding spaces and is not matched.
|
||||
*
|
||||
* A key that genuinely needs the pattern goes into `ALLOWED` with the reason
|
||||
* written next to it. An allowed key that no longer contains the pattern
|
||||
* fails too, so the list cannot go stale.
|
||||
*/
|
||||
|
||||
const MESSAGES_DIR = join(__dirname, "../../messages");
|
||||
const SEPARATOR = " · ";
|
||||
|
||||
/** Key path -> why the separator is right there. Empty by intent. */
|
||||
const ALLOWED: Record<string, string> = {};
|
||||
|
||||
function flatten(
|
||||
obj: unknown,
|
||||
prefix: string,
|
||||
out: Map<string, string>,
|
||||
): Map<string, string> {
|
||||
if (obj == null || typeof obj !== "object") return out;
|
||||
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
|
||||
const key = prefix ? `${prefix}.${k}` : k;
|
||||
if (typeof v === "string") out.set(key, v);
|
||||
else if (typeof v === "object") flatten(v, key, out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const bundles = readdirSync(MESSAGES_DIR)
|
||||
.filter((file) => file.endsWith(".json"))
|
||||
.sort();
|
||||
|
||||
describe("message bundles carry no ' · ' separator", () => {
|
||||
it("finds the bundles", () => {
|
||||
expect(bundles.length).toBeGreaterThanOrEqual(7);
|
||||
});
|
||||
|
||||
for (const file of bundles) {
|
||||
const values = flatten(
|
||||
JSON.parse(readFileSync(join(MESSAGES_DIR, file), "utf8")),
|
||||
"",
|
||||
new Map(),
|
||||
);
|
||||
|
||||
it(`${file} walks a non-empty set of strings`, () => {
|
||||
expect(values.size).toBeGreaterThan(1000);
|
||||
});
|
||||
|
||||
it(`${file} has no dot separator outside the allowlist`, () => {
|
||||
const offenders = [...values]
|
||||
.filter(
|
||||
([key, value]) => value.includes(SEPARATOR) && !(key in ALLOWED),
|
||||
)
|
||||
.map(([key, value]) => `${key} = ${JSON.stringify(value)}`);
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it(`${file} has no stale allowlist entry`, () => {
|
||||
const stale = Object.keys(ALLOWED).filter(
|
||||
(key) => !(values.get(key) ?? "").includes(SEPARATOR),
|
||||
);
|
||||
expect(stale).toEqual([]);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -137,11 +137,6 @@ export const AI_ROUTES: Readonly<Record<string, AiRouteEntry>> = {
|
||||
capabilities: ["coach"],
|
||||
why: "The unread signal of a Coach that is unavailable is the quiet empty shape, never a refusal.",
|
||||
},
|
||||
"src/app/api/insights/coach/seeded-question/route.ts": {
|
||||
kind: "mixed",
|
||||
capabilities: ["coach"],
|
||||
why: "The opener exists only to open the Coach: `signal: null` while it is unavailable.",
|
||||
},
|
||||
"src/app/api/coach/about-me/route.ts": {
|
||||
kind: "mixed",
|
||||
capabilities: ["aboutMeQuestions"],
|
||||
|
||||
@@ -104,7 +104,6 @@ describe("GET /api/auth/me/notification-prefs", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
@@ -141,7 +140,6 @@ describe("GET /api/auth/me/notification-prefs", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
@@ -180,7 +178,6 @@ describe("GET /api/auth/me/notification-prefs", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
@@ -230,7 +227,6 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
@@ -259,7 +255,6 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
@@ -289,7 +284,6 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
@@ -311,7 +305,6 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
@@ -395,7 +388,6 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
@@ -435,7 +427,6 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
@@ -464,7 +455,6 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
@@ -510,7 +500,6 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* v1.21.2 (A3) — the seeded-question route resolves today's single most
|
||||
* notable derived signal into a tappable Coach opener, server-side. When the
|
||||
* confidence + notability gate yields nothing the route returns
|
||||
* `{ signal: null }` and the hero keeps its neutral greeting — never a
|
||||
* fabricated opener.
|
||||
*/
|
||||
const findUniqueMock = vi.fn().mockResolvedValue({ notificationPrefs: null });
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: (...args: unknown[]) => findUniqueMock(...args) },
|
||||
},
|
||||
}));
|
||||
|
||||
// The `coach` capability decides whether an opener is served at all; pinned
|
||||
// here so the route test does not depend on the capability loader's reads.
|
||||
const AVAILABLE = { available: true, reason: null, onDeviceAllowed: true };
|
||||
const getAiCapability = vi.fn();
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({
|
||||
getAiCapability: (...args: unknown[]) => getAiCapability(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkAnalyticsReadRateLimit: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ allowed: true, remaining: 119, resetAt: 0 }),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
// Stub the profile loader + the shared briefing detector so the route test
|
||||
// stays isolated from the compute tier.
|
||||
vi.mock("@/lib/insights/derived", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/insights/derived")>()),
|
||||
loadBaselineProfile: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ageYears: 38, sex: "MALE", heightCm: 180 }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/insights/derived-briefing", () => ({
|
||||
detectDerivedBriefingSignals: vi.fn(),
|
||||
}));
|
||||
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { detectDerivedBriefingSignals } from "@/lib/insights/derived-briefing";
|
||||
import { GET } from "../route";
|
||||
|
||||
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
|
||||
function makeReq(): NextRequest {
|
||||
return new NextRequest(
|
||||
new URL("http://localhost/api/insights/coach/seeded-question"),
|
||||
);
|
||||
}
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "testuser",
|
||||
role: "USER" as const,
|
||||
locale: "en",
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
findUniqueMock.mockResolvedValue({ notificationPrefs: null });
|
||||
getAiCapability.mockResolvedValue(AVAILABLE);
|
||||
});
|
||||
|
||||
describe("GET /api/insights/coach/seeded-question", () => {
|
||||
it("returns the most notable signal (the head) when one crosses the gate", async () => {
|
||||
vi.mocked(detectDerivedBriefingSignals).mockResolvedValue({
|
||||
signals: [
|
||||
{
|
||||
sourceMetric: "readiness",
|
||||
label: "readiness",
|
||||
score: 58,
|
||||
band: "yellow",
|
||||
confidence: 72,
|
||||
},
|
||||
{
|
||||
sourceMetric: "recovery",
|
||||
label: "recovery",
|
||||
score: 49,
|
||||
band: "red",
|
||||
confidence: 65,
|
||||
},
|
||||
],
|
||||
});
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
signal: { sourceMetric: string; score: number; band: string } | null;
|
||||
} | null;
|
||||
error: string | null;
|
||||
};
|
||||
expect(body.error).toBeNull();
|
||||
expect(body.data?.signal).toEqual({
|
||||
sourceMetric: "readiness",
|
||||
score: 58,
|
||||
band: "yellow",
|
||||
});
|
||||
expect(getAiCapability).toHaveBeenCalledWith("coach");
|
||||
expect((body.data as { ai?: unknown }).ai).toEqual(AVAILABLE);
|
||||
});
|
||||
|
||||
it.each(["operator_disabled", "user_disabled", "no_provider"] as const)(
|
||||
"answers { signal: null, ai } for %s without running the detector",
|
||||
async (reason) => {
|
||||
const ai = { available: false, reason, onDeviceAllowed: false };
|
||||
getAiCapability.mockResolvedValue(ai);
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { signal: unknown; ai: unknown };
|
||||
};
|
||||
expect(body.data).toEqual({ signal: null, ai });
|
||||
expect(detectDerivedBriefingSignals).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("returns { signal: null } when nothing crosses the gate (neutral fallback)", async () => {
|
||||
vi.mocked(detectDerivedBriefingSignals).mockResolvedValue(null);
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { signal: unknown } | null;
|
||||
error: string | null;
|
||||
};
|
||||
expect(body.error).toBeNull();
|
||||
expect(body.data?.signal).toBeNull();
|
||||
});
|
||||
|
||||
it("returns { signal: null } and skips the detector when ambient suggestions are off", async () => {
|
||||
findUniqueMock.mockResolvedValue({
|
||||
notificationPrefs: { coach: { ambientSuggestions: false } },
|
||||
});
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { signal: unknown } | null;
|
||||
error: string | null;
|
||||
};
|
||||
expect(body.error).toBeNull();
|
||||
expect(body.data?.signal).toBeNull();
|
||||
// The opt-out short-circuits before the compute tier.
|
||||
expect(detectDerivedBriefingSignals).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,113 +0,0 @@
|
||||
/**
|
||||
* v1.21.2 (A3) — pre-seeded relevance opener for the unscoped Coach.
|
||||
*
|
||||
* `GET /api/insights/coach/seeded-question` resolves today's single most
|
||||
* notable derived wellness signal (readiness / recovery shift, the same
|
||||
* confidence-gated detector the daily briefing uses) into a tappable
|
||||
* suggested opener for the Coach's blank new-chat hero. When no signal
|
||||
* crosses the gate the route returns `{ signal: null }` and the hero
|
||||
* keeps its neutral greeting — never a fabricated opener.
|
||||
*
|
||||
* The signal is selected SERVER-SIDE; the client renders the resolved DTO
|
||||
* and never recomputes it (server-authoritative parity). It IS registered in
|
||||
* the OpenAPI contract. This comment said the opposite until the route was
|
||||
* published — it shipped as a same-origin web surface and the sentence was
|
||||
* never revisited. A comment that contradicts the registry is worse than no
|
||||
* comment, because the next reader believes it instead of checking.
|
||||
*
|
||||
* Mirrors the sibling derived routes: `apiHandler` wrapper, `requireAuth`
|
||||
* (userId narrowed from the session/Bearer, never a body/query field),
|
||||
* analytics-read rate limit.
|
||||
*
|
||||
* A mixed read: the opener only exists to open the Coach, so it is served
|
||||
* only while the `coach` capability is available. Otherwise the route answers
|
||||
* 200 `{ signal: null, ai }`, the same neutral shape as "nothing notable",
|
||||
* with the reason beside it.
|
||||
*/
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import { getAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
import type { AiCapabilityState } from "@/lib/ai/capabilities/types";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { loadBaselineProfile } from "@/lib/insights/derived";
|
||||
import { detectDerivedBriefingSignals } from "@/lib/insights/derived-briefing";
|
||||
import { resolveCoachAmbientSuggestionsEnabled } from "@/lib/validations/notification-prefs";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** Resolved opener the hero renders, or null when nothing notable. */
|
||||
export interface CoachSeededQuestionDTO {
|
||||
/**
|
||||
* The notable signal driving the opener, or null when nothing crossed
|
||||
* the briefing detector's confidence + notability gate.
|
||||
*/
|
||||
signal: {
|
||||
/** Sentinel id (`readiness` / `recovery`) the client keys i18n on. */
|
||||
sourceMetric: string;
|
||||
/** Latest 0–100 score. */
|
||||
score: number;
|
||||
/** Band — `yellow` / `red` (green never surfaces as notable). */
|
||||
band: string;
|
||||
} | null;
|
||||
/** Whether the Coach this opener feeds is available, and why not. */
|
||||
ai: AiCapabilityState;
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// The opener feeds the Coach hero: with the Coach unavailable there is no
|
||||
// hero to seed, so the neutral shape answers and says why.
|
||||
const ai = await getAiCapability("coach");
|
||||
if (!ai.available) {
|
||||
return apiSuccess({ signal: null, ai } satisfies CoachSeededQuestionDTO);
|
||||
}
|
||||
|
||||
const rl = await checkAnalyticsReadRateLimit(user.id);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many analytics requests. Please retry later.", 429);
|
||||
}
|
||||
|
||||
// Server-authoritative ambient-suggestions opt-out: when the user turns
|
||||
// proactive suggestions off the hero must not seed an opener, so the route
|
||||
// skips the detector work and returns the neutral `{ signal: null }` shape.
|
||||
const prefsRow = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { notificationPrefs: true },
|
||||
});
|
||||
if (
|
||||
!resolveCoachAmbientSuggestionsEnabled(prefsRow?.notificationPrefs ?? null)
|
||||
) {
|
||||
annotate({
|
||||
action: { name: "coach.seeded-question.resolve" },
|
||||
meta: { has_signal: false, source_metric: "none", band: "none" },
|
||||
});
|
||||
return apiSuccess({ signal: null, ai } satisfies CoachSeededQuestionDTO);
|
||||
}
|
||||
|
||||
// Profile read once via the shared loader; passed into the detector,
|
||||
// never re-fetched per candidate (the detector probes readiness +
|
||||
// recovery off the one profile).
|
||||
const profile = await loadBaselineProfile(prisma, user.id);
|
||||
const detected = await detectDerivedBriefingSignals(user.id, profile);
|
||||
|
||||
// The detector returns 1–2 signals in priority order; the opener anchors
|
||||
// on the single most notable one (the head).
|
||||
const top = detected?.signals[0] ?? null;
|
||||
const signal: CoachSeededQuestionDTO["signal"] = top
|
||||
? { sourceMetric: top.sourceMetric, score: top.score, band: top.band }
|
||||
: null;
|
||||
|
||||
annotate({
|
||||
action: { name: "coach.seeded-question.resolve" },
|
||||
meta: {
|
||||
has_signal: signal !== null,
|
||||
source_metric: signal?.sourceMetric ?? "none",
|
||||
band: signal?.band ?? "none",
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({ signal, ai } satisfies CoachSeededQuestionDTO);
|
||||
});
|
||||
@@ -154,7 +154,7 @@ describe("<BodySitesSection>", () => {
|
||||
});
|
||||
expect(html).toContain("Procedures and visits");
|
||||
expect(html).toContain("Arthroscopy");
|
||||
expect(html).toContain("Knee · Left");
|
||||
expect(html).toContain("Knee (Left)");
|
||||
expect(html).toContain('href="/documents?doc=d1"');
|
||||
expect(html).toContain("MRI knee report");
|
||||
expect(html).toContain("Conditions");
|
||||
|
||||
@@ -116,7 +116,7 @@ describe("BD-Zielbereich tile (v1.4.28 FB-C1/C2 rewrite)", () => {
|
||||
it("renders the empty-hint placeholder instead of a percentage on thin data (v1.17 W1b)", () => {
|
||||
const html = render(
|
||||
<TrendCard
|
||||
label="BP in Target · 23d"
|
||||
label="BP in Target (23d)"
|
||||
latest={null}
|
||||
emptyHint="collecting data"
|
||||
unit="%"
|
||||
@@ -130,7 +130,7 @@ describe("BD-Zielbereich tile (v1.4.28 FB-C1/C2 rewrite)", () => {
|
||||
expect(html).toContain('data-slot="trend-card-empty-hint"');
|
||||
expect(html).toContain("collecting data");
|
||||
// The dynamic span label is shown in place of the static "· 90 T".
|
||||
expect(html).toContain("BP in Target · 23d");
|
||||
expect(html).toContain("BP in Target (23d)");
|
||||
// No numeric headline value renders when the hint takes the slot.
|
||||
expect(html).not.toContain('data-slot="trend-card-value"');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import enMessages from "../../../../messages/en.json";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
import {
|
||||
DayCalendar,
|
||||
DayDatePicker,
|
||||
keyToLocalDate,
|
||||
localDateToKey,
|
||||
monthWindow,
|
||||
pickedDay,
|
||||
} from "../day-date-picker";
|
||||
|
||||
/**
|
||||
* The day header's calendar: the date is the button that opens it, the month
|
||||
* marks the days that hold anything, nothing past today can be picked, and
|
||||
* "Today" goes to today. SSR, like the rest of the suite; the clicks
|
||||
* themselves run in `e2e/day-view.spec.ts`.
|
||||
*/
|
||||
|
||||
vi.mock("../use-day", () => ({
|
||||
useDayIndex: () => ({ data: undefined }),
|
||||
}));
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en" initialMessages={enMessages}>
|
||||
{node}
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function calendar(
|
||||
props: Partial<Parameters<typeof DayCalendar>[0]> = {},
|
||||
): string {
|
||||
return render(
|
||||
<DayCalendar
|
||||
date="2026-09-24"
|
||||
today="2026-10-08"
|
||||
month={keyToLocalDate("2026-09-24")}
|
||||
onMonthChange={() => undefined}
|
||||
withEntries={["2026-09-03", "2026-09-24"]}
|
||||
onPick={() => undefined}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
function dayButton(html: string, key: string): string {
|
||||
return (
|
||||
html.match(new RegExp(`<button[^>]*data-date-key="${key}"[^>]*>`))?.[0] ??
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
describe("<DayDatePicker>", () => {
|
||||
it("makes the written date the button, with no icon beside it", () => {
|
||||
const html = render(
|
||||
<DayDatePicker
|
||||
date="2026-09-24"
|
||||
today="2026-10-08"
|
||||
label="Thursday, September 24, 2026"
|
||||
onPick={() => undefined}
|
||||
/>,
|
||||
);
|
||||
const button = html.match(/<button[^>]*data-slot="day-date-button"[^>]*>/);
|
||||
expect(button?.[0]).toContain('aria-haspopup="dialog"');
|
||||
expect(button?.[0]).toContain('aria-expanded="false"');
|
||||
expect(html).toContain("Thursday, September 24, 2026");
|
||||
expect(html).not.toContain("<svg");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<DayCalendar>", () => {
|
||||
it("shows the month of the day on screen with that day selected", () => {
|
||||
const html = calendar();
|
||||
expect(html).toContain("September 2026");
|
||||
expect(dayButton(html, "2026-09-24")).toContain(
|
||||
'data-selected-single="true"',
|
||||
);
|
||||
});
|
||||
|
||||
it("marks the days that hold anything, and says so", () => {
|
||||
const html = calendar();
|
||||
expect(dayButton(html, "2026-09-03")).toContain('data-entries="true"');
|
||||
expect(dayButton(html, "2026-09-03")).toContain(
|
||||
'aria-label="Thursday, September 3, 2026, has entries"',
|
||||
);
|
||||
expect(dayButton(html, "2026-09-04")).not.toContain("data-entries");
|
||||
});
|
||||
|
||||
it("offers nothing past today", () => {
|
||||
const html = calendar({
|
||||
date: "2026-10-01",
|
||||
month: keyToLocalDate("2026-10-01"),
|
||||
});
|
||||
expect(dayButton(html, "2026-10-08")).not.toMatch(/\sdisabled=""/);
|
||||
expect(dayButton(html, "2026-10-09")).toMatch(/\sdisabled=""/);
|
||||
});
|
||||
|
||||
it("offers Today, except on today", () => {
|
||||
const today = (html: string) =>
|
||||
html.match(/<button[^>]*data-slot="day-date-today"[^>]*>Today/)?.[0];
|
||||
expect(today(calendar())).not.toMatch(/\sdisabled=""/);
|
||||
expect(
|
||||
today(
|
||||
calendar({ date: "2026-10-08", month: keyToLocalDate("2026-10-08") }),
|
||||
),
|
||||
).toMatch(/\sdisabled=""/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("picking a day", () => {
|
||||
it("opens a past day or today, never the open day or a future one", () => {
|
||||
expect(pickedDay("2026-09-03", "2026-09-24", "2026-10-08")).toBe(
|
||||
"2026-09-03",
|
||||
);
|
||||
expect(pickedDay("2026-10-08", "2026-09-24", "2026-10-08")).toBe(
|
||||
"2026-10-08",
|
||||
);
|
||||
expect(pickedDay("2026-09-24", "2026-09-24", "2026-10-08")).toBeNull();
|
||||
expect(pickedDay("2026-10-09", "2026-09-24", "2026-10-08")).toBeNull();
|
||||
});
|
||||
|
||||
it("reads the month it shows, and never past today", () => {
|
||||
expect(monthWindow(keyToLocalDate("2026-09-24"), "2026-10-08")).toEqual({
|
||||
from: "2026-09-01",
|
||||
to: "2026-09-30",
|
||||
});
|
||||
expect(monthWindow(keyToLocalDate("2026-10-02"), "2026-10-08")).toEqual({
|
||||
from: "2026-10-01",
|
||||
to: "2026-10-08",
|
||||
});
|
||||
expect(localDateToKey(keyToLocalDate("2024-02-29"))).toBe("2024-02-29");
|
||||
});
|
||||
});
|
||||
@@ -110,6 +110,7 @@ let canWrite = true;
|
||||
vi.mock("../use-day", () => ({
|
||||
useDay: () => ({ ...dayQuery, refetch: () => undefined }),
|
||||
usePrefetchDay: () => () => undefined,
|
||||
useDayIndex: () => ({ data: undefined }),
|
||||
}));
|
||||
vi.mock("next/navigation", () => ({ usePathname: () => pathname }));
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
@@ -161,6 +162,7 @@ async function render(
|
||||
shell={props.shell ?? "docked"}
|
||||
onClose={() => undefined}
|
||||
onStep={() => undefined}
|
||||
onPick={() => undefined}
|
||||
Title={(p) => <h2 {...p} />}
|
||||
titleId="t"
|
||||
/>
|
||||
@@ -180,15 +182,19 @@ describe("<DayView>", () => {
|
||||
it("names the day and counts what it holds", async () => {
|
||||
const html = await render();
|
||||
expect(html).toContain("Saturday, January 3, 2026");
|
||||
expect(html).toContain("3 values · 2 entries");
|
||||
expect(html).toContain("3 values, 2 entries");
|
||||
});
|
||||
|
||||
it("lists what ran through the day with its running count", async () => {
|
||||
const html = await render();
|
||||
expect(html).toContain("Running that day");
|
||||
expect(html).toContain("Common cold");
|
||||
expect(html).toContain("Day 4 · since");
|
||||
expect(html).toContain("Winter course · Day 64 of 151");
|
||||
// Name first, the quiet day count below; an open span names its start
|
||||
// as a date, its year left out when it is the viewed day's year.
|
||||
expect(html).toContain("Day 4, since December 31");
|
||||
expect(html).toContain("Vitamin D 1,000 IU Winter course");
|
||||
expect(html).toContain("Day 64 of 151");
|
||||
expect(html).not.toContain("ongoing");
|
||||
expect(html).toContain('href="/illness/ep1"');
|
||||
});
|
||||
|
||||
@@ -227,10 +233,11 @@ describe("<DayView>", () => {
|
||||
const html = await render();
|
||||
const rows = html.split('data-slot="day-running-item"').slice(1);
|
||||
const med = rows.find((row) => row.includes("Levothyroxine")) ?? "";
|
||||
expect(med).toContain("50 µg");
|
||||
expect(med).toContain("Levothyroxine 50 µg");
|
||||
expect(med.split("</li>")[0]).not.toMatch(/Day \d|since/);
|
||||
const leave = rows.find((row) => row.includes("Parental leave")) ?? "";
|
||||
expect(leave).toContain("Family · Day 34 of 90");
|
||||
expect(leave).toMatch(/data-slot="tag-chip"[^>]*>Family</);
|
||||
expect(leave).toContain("Day 34 of 90");
|
||||
expect(leave).not.toContain("FAMILY");
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar, CalendarDayButton } from "@/components/ui/calendar";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { resolveDateFnsLocale } from "@/components/ui/date-field";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import type { DateKey } from "@/lib/day/contract";
|
||||
import { resolveIntlLocale } from "@/lib/format-locale";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { useDayIndex } from "./use-day";
|
||||
|
||||
/**
|
||||
* The date in the day's header, as the way to any other day: the written
|
||||
* date is the button, and it opens a month calendar beside it. Picking a
|
||||
* day opens that day; "Today" opens today. Days that hold anything carry a
|
||||
* dot, read from `GET /api/day/index` for the month on screen.
|
||||
*
|
||||
* No icon and no field: the header keeps its one line, and the arrows
|
||||
* beside it stay the way to the neighbouring days. Radix owns the keyboard
|
||||
* (Enter or Space opens, Escape closes, focus returns to the date). A
|
||||
* popover beside the date serves the docked column and the side sheet; in
|
||||
* the phone's bottom sheet the calendar rises as a sheet of its own, with
|
||||
* touch-sized cells wherever the pointer is coarse.
|
||||
*/
|
||||
|
||||
/** `YYYY-MM-DD` → a local `Date` at midnight, the calendar's own unit. */
|
||||
export function keyToLocalDate(key: DateKey): Date {
|
||||
const [y, m, d] = key.split("-").map(Number) as [number, number, number];
|
||||
return new Date(y, m - 1, d);
|
||||
}
|
||||
|
||||
/** A local `Date` → `YYYY-MM-DD`, read in the zone it was made in. */
|
||||
export function localDateToKey(date: Date): DateKey {
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(
|
||||
date.getDate(),
|
||||
)}` as DateKey;
|
||||
}
|
||||
|
||||
/** First and last day of the month that holds `month`, never past today. */
|
||||
export function monthWindow(
|
||||
month: Date,
|
||||
today: DateKey,
|
||||
): { from: DateKey; to: DateKey } {
|
||||
const from = localDateToKey(
|
||||
new Date(month.getFullYear(), month.getMonth(), 1),
|
||||
);
|
||||
const last = localDateToKey(
|
||||
new Date(month.getFullYear(), month.getMonth() + 1, 0),
|
||||
);
|
||||
return { from, to: last > today ? today : last };
|
||||
}
|
||||
|
||||
/**
|
||||
* What a pick opens: the day itself, unless it is the day on screen already
|
||||
* or lies past today (the calendar disables those, this is the backstop).
|
||||
*/
|
||||
export function pickedDay(
|
||||
next: DateKey,
|
||||
date: DateKey,
|
||||
today: DateKey,
|
||||
): DateKey | null {
|
||||
return next !== date && next <= today ? next : null;
|
||||
}
|
||||
|
||||
export function DayDatePicker({
|
||||
date,
|
||||
today,
|
||||
label,
|
||||
onPick,
|
||||
variant = "popover",
|
||||
className,
|
||||
}: {
|
||||
date: DateKey;
|
||||
today: DateKey;
|
||||
/** The date as the header writes it: the button's text. */
|
||||
label: React.ReactNode;
|
||||
onPick: (date: DateKey) => void;
|
||||
/**
|
||||
* `sheet` in the phone's bottom sheet: a month of finger-sized cells
|
||||
* does not fit beside a header that sits halfway down the screen, so the
|
||||
* calendar rises as its own bottom sheet there.
|
||||
*/
|
||||
variant?: "popover" | "sheet";
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const [open, setOpen] = useState(false);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const [month, setMonth] = useState(() => keyToLocalDate(date));
|
||||
const range = monthWindow(month, today);
|
||||
const index = useDayIndex(range.from, range.to, open);
|
||||
|
||||
const onOpenChange = (next: boolean) => {
|
||||
// Each opening starts on the month of the day on screen.
|
||||
if (next) setMonth(keyToLocalDate(date));
|
||||
setOpen(next);
|
||||
};
|
||||
const buttonClass = cn(
|
||||
"hover:bg-muted/60 focus-visible:ring-ring/50 data-[state=open]:bg-muted/60 -mx-1 block max-w-full truncate rounded-md px-1 text-left transition-colors focus-visible:ring-[3px] focus-visible:outline-none",
|
||||
className,
|
||||
);
|
||||
const calendar = (
|
||||
<DayCalendar
|
||||
date={date}
|
||||
today={today}
|
||||
month={month}
|
||||
onMonthChange={setMonth}
|
||||
withEntries={Object.keys(index.data?.days ?? {}) as DateKey[]}
|
||||
onPick={(next) => {
|
||||
setOpen(false);
|
||||
const day = pickedDay(next, date, today);
|
||||
if (day !== null) onPick(day);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
if (variant === "sheet") {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
data-slot="day-date-button"
|
||||
data-state={open ? "open" : "closed"}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
title={t("day.pickDate")}
|
||||
onClick={() => onOpenChange(true)}
|
||||
className={buttonClass}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("day.pickerTitle")}
|
||||
hideHeader
|
||||
showCloseButton={false}
|
||||
bodyClassName="items-center p-2"
|
||||
// Back to the date, inside the day's own sheet.
|
||||
onCloseAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
buttonRef.current?.focus({ preventScroll: true });
|
||||
}}
|
||||
>
|
||||
<div data-slot="day-date-picker" className="w-fit">
|
||||
{calendar}
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={onOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="day-date-button"
|
||||
aria-haspopup="dialog"
|
||||
title={t("day.pickDate")}
|
||||
className={buttonClass}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
data-slot="day-date-picker"
|
||||
aria-label={t("day.pickerTitle")}
|
||||
className="w-auto p-0"
|
||||
>
|
||||
{calendar}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
/** The calendar's day button, with its date key and dot as attributes. */
|
||||
function KeyedDayButton(props: React.ComponentProps<typeof CalendarDayButton>) {
|
||||
return (
|
||||
<CalendarDayButton
|
||||
{...props}
|
||||
data-date-key={localDateToKey(props.day.date)}
|
||||
data-entries={props.modifiers.entries ? "true" : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The popover's body: the month with the day on screen selected, a dot on
|
||||
* every day that holds anything, nothing past today, and "Today" below.
|
||||
*/
|
||||
export function DayCalendar({
|
||||
date,
|
||||
today,
|
||||
month,
|
||||
onMonthChange,
|
||||
withEntries,
|
||||
onPick,
|
||||
}: {
|
||||
date: DateKey;
|
||||
today: DateKey;
|
||||
month: Date;
|
||||
onMonthChange: (month: Date) => void;
|
||||
withEntries: readonly DateKey[];
|
||||
onPick: (date: DateKey) => void;
|
||||
}) {
|
||||
const { t, locale } = useTranslations();
|
||||
const todayDate = keyToLocalDate(today);
|
||||
const dateLabel = new Intl.DateTimeFormat(resolveIntlLocale(locale), {
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
// The calendar hands out local midnights; the label reads the same
|
||||
// calendar day at noon UTC, so no zone can move it.
|
||||
const labelOf = (day: Date) =>
|
||||
dateLabel.format(new Date(`${localDateToKey(day)}T12:00:00.000Z`));
|
||||
return (
|
||||
<>
|
||||
<Calendar
|
||||
mode="single"
|
||||
required
|
||||
selected={keyToLocalDate(date)}
|
||||
onSelect={(picked) => {
|
||||
if (picked) onPick(localDateToKey(picked));
|
||||
}}
|
||||
month={month}
|
||||
onMonthChange={onMonthChange}
|
||||
endMonth={todayDate}
|
||||
disabled={{ after: todayDate }}
|
||||
showOutsideDays={false}
|
||||
locale={resolveDateFnsLocale(locale)}
|
||||
modifiers={{ entries: withEntries.map(keyToLocalDate) }}
|
||||
modifiersClassNames={{
|
||||
// A dot under the number; the selected day's dot takes the
|
||||
// colour of the number on it.
|
||||
entries:
|
||||
"after:bg-primary data-[selected=true]:after:bg-primary-foreground after:pointer-events-none after:absolute after:bottom-1 after:left-1/2 after:size-1 after:-translate-x-1/2 after:rounded-full",
|
||||
}}
|
||||
labels={{
|
||||
labelDayButton: (day, modifiers) =>
|
||||
modifiers.entries
|
||||
? `${labelOf(day)}, ${t("day.hasEntries")}`
|
||||
: labelOf(day),
|
||||
}}
|
||||
components={{ DayButton: KeyedDayButton }}
|
||||
// 32 px cells beside a fine pointer, 40 px for a finger.
|
||||
className="[--cell-size:--spacing(10)] pointer-fine:[--cell-size:--spacing(8)]"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="border-border flex justify-end border-t p-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
data-slot="day-date-today"
|
||||
disabled={date === today}
|
||||
onClick={() => onPick(today)}
|
||||
>
|
||||
{t("day.today")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -158,6 +158,15 @@ export function openDay(date: DateKey, options: OpenDayOptions = {}): void {
|
||||
/** Move the open day by `delta` calendar days, without a new history entry. */
|
||||
export function stepDay(from: DateKey, delta: number): DateKey {
|
||||
const next = shiftDateKey(from, delta);
|
||||
jumpDay(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap the open day for `next` (a day picked from the header's calendar),
|
||||
* the way a step does: no new history entry, so one Back still leaves.
|
||||
*/
|
||||
export function jumpDay(next: DateKey): void {
|
||||
memory.focus = null;
|
||||
emit();
|
||||
const { pathname, search } = currentLocation();
|
||||
@@ -169,7 +178,27 @@ export function stepDay(from: DateKey, delta: number): DateKey {
|
||||
"",
|
||||
withDayHref(pathname, search, next),
|
||||
);
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set while the docked day collapses to make room for another panel (the
|
||||
* Coach's conversations below 1600 px): focus then stays with the control
|
||||
* that asked for the room instead of moving to the day's edge.
|
||||
*/
|
||||
let yielding = false;
|
||||
|
||||
/** Collapse the docked day to its edge for a neighbouring panel. */
|
||||
export function yieldDay(): void {
|
||||
yielding = true;
|
||||
memory.trigger = null;
|
||||
closeDay();
|
||||
}
|
||||
|
||||
/** Read and clear the flag `yieldDay` set. */
|
||||
export function takeDayYield(): boolean {
|
||||
const was = yielding;
|
||||
yielding = false;
|
||||
return was;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import {
|
||||
Suspense,
|
||||
useCallback,
|
||||
@@ -37,11 +37,13 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
import {
|
||||
closeDay,
|
||||
jumpDay,
|
||||
openDay,
|
||||
peekDayTriggerAt,
|
||||
publishOpenDay,
|
||||
stepDay,
|
||||
takeDayTrigger,
|
||||
takeDayYield,
|
||||
useDayFocus,
|
||||
} from "./day-layer-controller";
|
||||
import { parseDayParam, shiftDateKey, withDayHref } from "./day-url";
|
||||
@@ -72,8 +74,10 @@ import { useTodayKey } from "./use-today-key";
|
||||
* the chart stays in view above it; dragging the handle up (or tapping
|
||||
* it) takes it to full height, dragging it down closes it.
|
||||
*
|
||||
* On `/coach` the conversations panel already holds the side column, and
|
||||
* there is never more than one docked panel: the day opens as a sheet there.
|
||||
* On `/coach` the day docks too, right of the Coach's conversations: page,
|
||||
* conversations, day. Below 1600 px only one of the two is open at a time;
|
||||
* opening the day folds the conversations to their edge, and opening the
|
||||
* conversations folds the day to its edge (`yieldDay`).
|
||||
*
|
||||
* The parameter is the state. A date that is not a calendar date, or lies in
|
||||
* the future, is removed from the URL without a word. Collapsing the docked
|
||||
@@ -121,7 +125,6 @@ function modalSurfaceOpen(): boolean {
|
||||
|
||||
function DayLayer() {
|
||||
const searchParams = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
const today = useTodayKey();
|
||||
const raw = searchParams.get(DAY_QUERY_PARAM);
|
||||
const date = parseDayParam(raw, today);
|
||||
@@ -157,7 +160,7 @@ function DayLayer() {
|
||||
const phone = useIsMobile();
|
||||
const shell: "docked" | "sheet" | "bottom" = phone
|
||||
? "bottom"
|
||||
: wide && !pathname.startsWith("/coach")
|
||||
: wide
|
||||
? "docked"
|
||||
: "sheet";
|
||||
|
||||
@@ -206,6 +209,11 @@ function DayLayer() {
|
||||
return;
|
||||
}
|
||||
if (before !== null && shell === "docked") {
|
||||
// Collapsed for a neighbouring panel: focus stays where it was.
|
||||
if (takeDayYield()) {
|
||||
collapsing.current = false;
|
||||
return;
|
||||
}
|
||||
const rail = expandRef.current;
|
||||
if (collapsing.current && rail) {
|
||||
takeDayTrigger();
|
||||
@@ -226,6 +234,14 @@ function DayLayer() {
|
||||
[date, today],
|
||||
);
|
||||
|
||||
const onPick = useCallback(
|
||||
(next: DateKey) => {
|
||||
if (next > today) return;
|
||||
jumpDay(next);
|
||||
},
|
||||
[today],
|
||||
);
|
||||
|
||||
// Keyboard: Escape closes the docked column (a sheet's own Escape is the
|
||||
// dialog's), Alt with an arrow steps a day. Bare arrows stay with scrolling.
|
||||
useEffect(() => {
|
||||
@@ -285,7 +301,7 @@ function DayLayer() {
|
||||
<div
|
||||
data-slot="day-rail"
|
||||
data-day={railDay}
|
||||
className="bg-card text-card-foreground border-border flex h-full w-12 shrink-0 flex-col border-l"
|
||||
className="bg-card text-card-foreground border-border order-2 flex h-full w-12 shrink-0 flex-col border-l"
|
||||
>
|
||||
{/* The top bar's band, so the two bottom borders draw one line;
|
||||
the control sits where the open day keeps its own. */}
|
||||
@@ -343,6 +359,7 @@ function DayLayer() {
|
||||
shell={shell}
|
||||
onClose={shell === "docked" ? collapse : closeDay}
|
||||
onStep={onStep}
|
||||
onPick={onPick}
|
||||
Title={Title}
|
||||
titleId={titleId}
|
||||
titleRef={titleRef}
|
||||
@@ -363,7 +380,7 @@ function DayLayer() {
|
||||
// A short fade, not a slide: the column takes its width at once
|
||||
// (the page beside it reflows in the same frame either way), so
|
||||
// only its content eases in. None under reduced motion.
|
||||
className="bg-card text-card-foreground border-border motion-safe:animate-in motion-safe:fade-in-0 flex h-full w-105 shrink-0 flex-col border-l motion-safe:duration-150"
|
||||
className="bg-card text-card-foreground border-border motion-safe:animate-in motion-safe:fade-in-0 order-2 flex h-full w-105 shrink-0 flex-col border-l motion-safe:duration-150"
|
||||
>
|
||||
{view(DockedTitle, {
|
||||
// No status-bar inset here: the docked column sits inside
|
||||
|
||||
@@ -23,7 +23,9 @@ import {
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { TagChip } from "@/components/ui/tag-chip";
|
||||
import {
|
||||
type DateKey,
|
||||
type DayEvent,
|
||||
type DayEventKind,
|
||||
type DayNotable,
|
||||
@@ -37,6 +39,7 @@ import {
|
||||
LIFE_EVENT_CATEGORY_KEY,
|
||||
keyOf,
|
||||
} from "@/components/timeline/label-keys";
|
||||
import { resolveIntlLocale } from "@/lib/format-locale";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { MOOD_LABEL_KEYS } from "@/lib/mood/labels";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -154,7 +157,7 @@ export function DayNotableLines({
|
||||
<Sparkles className="mt-px size-3.5 shrink-0" aria-hidden="true" />
|
||||
<span>
|
||||
{withMetric && notable.type
|
||||
? `${labelFor(tileKeyOf(notable.type))} · `
|
||||
? `${labelFor(tileKeyOf(notable.type))}: `
|
||||
: null}
|
||||
{text(notable)}
|
||||
</span>
|
||||
@@ -289,33 +292,60 @@ export function dayMeta(
|
||||
}
|
||||
}
|
||||
|
||||
export function DayRunning({ items }: { items: readonly DayRunningItem[] }) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
/** Running kinds whose `sub` is a dose and reads as part of the name. */
|
||||
const DOSE_SUB_KINDS: ReadonlySet<DayRunningKind> = new Set([
|
||||
"medication",
|
||||
"medicationCourse",
|
||||
]);
|
||||
|
||||
export function DayRunning({
|
||||
items,
|
||||
date,
|
||||
}: {
|
||||
items: readonly DayRunningItem[];
|
||||
/** The viewed day; a start date in another year names its year. */
|
||||
date: DateKey;
|
||||
}) {
|
||||
const { t, locale } = useTranslations();
|
||||
if (items.length === 0) return null;
|
||||
const viewedYear = date.slice(0, 4);
|
||||
const intlLocale = resolveIntlLocale(locale);
|
||||
const sinceText = (since: string): string =>
|
||||
new Intl.DateTimeFormat(intlLocale, {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
...(since.slice(0, 4) === viewedYear ? {} : { year: "numeric" }),
|
||||
timeZone: "UTC",
|
||||
}).format(new Date(`${since}T12:00:00.000Z`));
|
||||
return (
|
||||
<section className="space-y-2" data-slot="day-running">
|
||||
<h3 className={DAY_SECTION_LABEL}>{t("day.groups.running")}</h3>
|
||||
<ul>
|
||||
{items.map((item) => {
|
||||
const cycleLine = cycleRunningLine(item, t);
|
||||
const parts = (
|
||||
const title = dayTitle(item.kind, item.title, t);
|
||||
const doseInName = item.sub !== null && DOSE_SUB_KINDS.has(item.kind);
|
||||
const name = doseInName ? `${title} ${item.sub}` : title;
|
||||
const chip =
|
||||
cycleLine === null && item.sub !== null && !doseInName
|
||||
? dayMeta(item.kind, item.sub, t)
|
||||
: null;
|
||||
const since =
|
||||
item.dayCount === null && item.since !== null
|
||||
? sinceText(item.since)
|
||||
: null;
|
||||
const detail =
|
||||
cycleLine !== null
|
||||
? [cycleLine]
|
||||
: [
|
||||
item.sub === null ? null : dayMeta(item.kind, item.sub, t),
|
||||
item.dayIndex !== null
|
||||
? item.dayCount !== null
|
||||
? t("day.dayOf", { n: item.dayIndex, m: item.dayCount })
|
||||
: t("day.dayN", { n: item.dayIndex })
|
||||
: null,
|
||||
item.dayCount === null && item.since !== null
|
||||
? t("day.since", {
|
||||
date: fmt.dateShortSmartCalendar(item.since),
|
||||
})
|
||||
: null,
|
||||
]
|
||||
).filter((part): part is string => !!part);
|
||||
? cycleLine
|
||||
: item.dayIndex !== null
|
||||
? item.dayCount !== null
|
||||
? t("day.dayOf", { n: item.dayIndex, m: item.dayCount })
|
||||
: since !== null
|
||||
? t("day.dayNSince", { n: item.dayIndex, date: since })
|
||||
: t("day.dayN", { n: item.dayIndex })
|
||||
: since !== null
|
||||
? t("day.sinceDate", { date: since })
|
||||
: null;
|
||||
const body = (
|
||||
<>
|
||||
<span
|
||||
@@ -324,12 +354,17 @@ export function DayRunning({ items }: { items: readonly DayRunningItem[] }) {
|
||||
style={{ backgroundColor: RUNNING_COLOR[item.kind] }}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium">
|
||||
{dayTitle(item.kind, item.title, t)}
|
||||
</span>
|
||||
<span className="text-muted-foreground block truncate text-xs">
|
||||
{parts.join(" · ")}
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="min-w-0 truncate text-sm font-medium">
|
||||
{name}
|
||||
</span>
|
||||
{chip ? <TagChip className="shrink-0">{chip}</TagChip> : null}
|
||||
</span>
|
||||
{detail ? (
|
||||
<span className="text-muted-foreground block truncate text-xs">
|
||||
{detail}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{item.href ? (
|
||||
<ChevronRight
|
||||
|
||||
@@ -30,6 +30,7 @@ import { resolveIntlLocale } from "@/lib/format-locale";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { DayDatePicker } from "./day-date-picker";
|
||||
import type { DayFocus } from "./day-layer-controller";
|
||||
import {
|
||||
DayEvents,
|
||||
@@ -77,6 +78,8 @@ export interface DayViewProps {
|
||||
shell: "docked" | "sheet" | "bottom";
|
||||
onClose: () => void;
|
||||
onStep: (delta: number) => void;
|
||||
/** Open another day, picked from the header's calendar. */
|
||||
onPick: (date: DateKey) => void;
|
||||
/** The heading element: an `h2`, or the sheet primitive's title. */
|
||||
Title: ComponentType<{
|
||||
id?: string;
|
||||
@@ -114,6 +117,7 @@ export function DayView({
|
||||
shell,
|
||||
onClose,
|
||||
onStep,
|
||||
onPick,
|
||||
Title,
|
||||
titleId,
|
||||
titleRef,
|
||||
@@ -178,7 +182,7 @@ export function DayView({
|
||||
? [
|
||||
tCount("day.countValues", data.counts.values),
|
||||
tCount("day.countEntries", data.counts.entries),
|
||||
].join(" · ")
|
||||
].join(", ")
|
||||
: null;
|
||||
const compact = shell === "bottom";
|
||||
const closeLabel = shell === "docked" ? t("day.hidePanel") : t("day.close");
|
||||
@@ -230,14 +234,24 @@ export function DayView({
|
||||
tabIndex={-1}
|
||||
className="min-w-0 flex-1 truncate px-1 text-base leading-snug font-semibold focus-visible:outline-none"
|
||||
>
|
||||
{compact ? (
|
||||
<>
|
||||
<span aria-hidden="true">{shortLabel(date)}</span>
|
||||
<span className="sr-only">{longLabel(date)}</span>
|
||||
</>
|
||||
) : (
|
||||
longLabel(date)
|
||||
)}
|
||||
{/* The date is the way to any other day: a calendar opens from
|
||||
it, so the header needs no icon of its own. */}
|
||||
<DayDatePicker
|
||||
date={date}
|
||||
today={today}
|
||||
onPick={onPick}
|
||||
variant={compact ? "sheet" : "popover"}
|
||||
label={
|
||||
compact ? (
|
||||
<>
|
||||
<span aria-hidden="true">{shortLabel(date)}</span>
|
||||
<span className="sr-only">{longLabel(date)}</span>
|
||||
</>
|
||||
) : (
|
||||
longLabel(date)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Title>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -291,7 +305,7 @@ export function DayView({
|
||||
className="text-muted-foreground -mb-3 text-xs tabular-nums"
|
||||
data-slot="day-meta"
|
||||
>
|
||||
{meta ?? <span className="invisible">·</span>}
|
||||
{meta ?? <span className="invisible">0</span>}
|
||||
</p>
|
||||
{focus ? (
|
||||
<div className="space-y-2.5">
|
||||
@@ -321,7 +335,7 @@ export function DayView({
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<DayRunning items={data.running} />
|
||||
<DayRunning items={data.running} date={date} />
|
||||
{data.values.length > 0 ? (
|
||||
<div className="space-y-2.5">
|
||||
<DayValues values={data.values} focusTypes={focusTypes} />
|
||||
|
||||
@@ -518,13 +518,13 @@ describe("buildProcedureChoices", () => {
|
||||
{
|
||||
encounterId: "enc-knee",
|
||||
name: "Knee arthroscopy",
|
||||
detail: "Knee (left) · 2025-10-02",
|
||||
detail: "Knee (left), 2025-10-02",
|
||||
},
|
||||
{ encounterId: "enc-2", name: "Day clinic", detail: "2025-10-02" },
|
||||
{
|
||||
encounterId: "enc-3",
|
||||
name: "Procedure or surgery",
|
||||
detail: "Knee · 2025-10-02",
|
||||
detail: "Knee, 2025-10-02",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -198,8 +198,8 @@ export function DocumentCard({
|
||||
</DayLinkStated>
|
||||
) : (
|
||||
date
|
||||
)}{" "}
|
||||
· {size}
|
||||
)}
|
||||
, {size}
|
||||
</p>
|
||||
{showFilename ? (
|
||||
// Filename on its OWN muted line so it never clips the date/size
|
||||
|
||||
@@ -869,7 +869,7 @@ export function DocumentDetailSheet({
|
||||
{doc.filename ?? title}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("documents.detail.previewUnavailable")} ·{" "}
|
||||
{t("documents.detail.previewUnavailable")},{" "}
|
||||
{formatBytes(doc.byteSize, locale)}
|
||||
</p>
|
||||
<DocumentDownloadButton
|
||||
@@ -1185,8 +1185,15 @@ export function DocumentDetailSheet({
|
||||
date: format.date(doc.createdAt),
|
||||
size: formatBytes(doc.byteSize, locale),
|
||||
})}
|
||||
{doc.filename ? ` · ${doc.filename}` : ""}
|
||||
</p>
|
||||
{doc.filename ? (
|
||||
<p
|
||||
className="text-muted-foreground truncate text-xs"
|
||||
title={doc.filename}
|
||||
>
|
||||
{doc.filename}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* Where an imported document came from (#1038). Meta, so
|
||||
muted like the line above; the id is what the person looks
|
||||
|
||||
@@ -80,7 +80,7 @@ function factHeadline(fact: ExtractedFactDto): string {
|
||||
return (fact.data as ConditionFactData).label;
|
||||
}
|
||||
const data = fact.data as MedicationStatementFactData;
|
||||
return data.dose ? `${data.name} · ${data.dose}` : data.name;
|
||||
return data.dose ? `${data.name} ${data.dose}` : data.name;
|
||||
}
|
||||
|
||||
/** The date the fact states, if any (YYYY-MM-DD). */
|
||||
@@ -262,18 +262,16 @@ function FactRow({
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{typeLabel}
|
||||
{date ? ` · ${calendarDate(date)}` : ""}
|
||||
{isObservation &&
|
||||
(fact.data as ObservationFactData).referenceText ? (
|
||||
<>
|
||||
{" · "}
|
||||
{t("documents.review.statedRange", {
|
||||
range: (fact.data as ObservationFactData)
|
||||
.referenceText as string,
|
||||
})}
|
||||
</>
|
||||
) : null}
|
||||
{date ? `, ${calendarDate(date)}` : ""}
|
||||
</p>
|
||||
{isObservation && (fact.data as ObservationFactData).referenceText ? (
|
||||
<p className="text-muted-foreground text-xs break-words">
|
||||
{t("documents.review.statedRange", {
|
||||
range: (fact.data as ObservationFactData)
|
||||
.referenceText as string,
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
{fact.provenance.anchored && fact.provenance.sourceText ? (
|
||||
<p className="text-muted-foreground text-xs break-words italic">
|
||||
“{fact.provenance.sourceText}”
|
||||
|
||||
@@ -527,7 +527,7 @@ export function DocumentsView() {
|
||||
label: `${
|
||||
visit.practitioner?.name ??
|
||||
encounterKindText(t, visit.kind as EncounterKind)
|
||||
} · ${format.date(visit.occurredAt)}`,
|
||||
}, ${format.date(visit.occurredAt)}`,
|
||||
})),
|
||||
[visits.data, t, format],
|
||||
);
|
||||
|
||||
@@ -588,7 +588,7 @@ export function DocumentSourcePicker({
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
.join(", ");
|
||||
return (
|
||||
<li key={row.sourceId}>
|
||||
<button
|
||||
|
||||
@@ -546,7 +546,7 @@ export function buildProcedureChoices(
|
||||
procedure.reason?.trim() ||
|
||||
procedure.practitionerName?.trim() ||
|
||||
labels.kindName,
|
||||
detail: site ? `${site} · ${date}` : date,
|
||||
detail: site ? `${site}, ${date}` : date,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -138,7 +138,7 @@ describe("<ProceduresSection>", () => {
|
||||
html.indexOf('data-encounter-id="older"'),
|
||||
);
|
||||
expect(html).toContain("Meniscus repair");
|
||||
expect(html).toContain("Knee · Left");
|
||||
expect(html).toContain("Knee (Left)");
|
||||
expect(html).toContain("2 of 2");
|
||||
});
|
||||
|
||||
@@ -182,7 +182,7 @@ describe("<ProceduresSection>", () => {
|
||||
/data-slot="procedures-site-all"[^>]*aria-pressed="true"|aria-pressed="true"[^>]*data-slot="procedures-site-all"/,
|
||||
);
|
||||
expect(html).toMatch(
|
||||
/aria-pressed="false"[^>]*>Knee · Left|data-slot="procedures-site"[^>]*aria-pressed="false"/,
|
||||
/aria-pressed="false"[^>]*>Knee \(Left\)|data-slot="procedures-site"[^>]*aria-pressed="false"/,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ const okResult: CorrelationResult = {
|
||||
pValue: 0.001,
|
||||
confidenceBand: { low: -0.85, high: -0.39, label: "high" },
|
||||
interpretation:
|
||||
"Higher medication compliance lines up with lower systolic readings — a pattern worth watching.",
|
||||
"Higher medication compliance lines up with lower systolic readings, a pattern worth watching.",
|
||||
points: [
|
||||
{ x: 60, y: 140 },
|
||||
{ x: 70, y: 135 },
|
||||
|
||||
@@ -31,12 +31,17 @@ describe("<CoachHero>", () => {
|
||||
expect(html).toContain("Frage mich etwas zu deinen Daten");
|
||||
});
|
||||
|
||||
it("does not render starter-question suggestion chips", () => {
|
||||
// v1.18.10 (W4) — the two starter chips below the composer were
|
||||
// removed; the hero is greeting + composer only.
|
||||
const html = render(<CoachHero composer={null} />);
|
||||
it("leaves nothing under the composer", () => {
|
||||
// No starter chips, no seeded opener, no scope pill: the empty
|
||||
// conversation is the greeting and the field.
|
||||
const html = render(
|
||||
<CoachHero composer={<div data-slot="test-composer">composer</div>} />,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="coach-hero-chips"');
|
||||
expect(html).not.toContain('data-slot="coach-hero-chip"');
|
||||
expect(html).not.toContain('data-slot="coach-hero-scope-hint"');
|
||||
expect(html).not.toContain("coach-scope-hint");
|
||||
const afterComposer = html.split('data-slot="test-composer"')[1] ?? "";
|
||||
expect(afterComposer.replace(/<\/div>|composer|>/g, "")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
import { BOTH_PANELS_QUERY, listYields } from "../conversations-panel";
|
||||
import { NewChatFab } from "../new-chat-fab";
|
||||
|
||||
/**
|
||||
* The Coach page holds two docked panels beside the conversation: the
|
||||
* conversations and, right of them, the day. Below 1600 px only one is open.
|
||||
* The list folds when a day opens or the window narrows with both open; it
|
||||
* never folds again right after the person opened it beside a day (that
|
||||
* folds the day instead). The click paths and focus run in
|
||||
* `e2e/coach-layout.spec.ts`.
|
||||
*/
|
||||
const base = {
|
||||
docked: true,
|
||||
fitsBoth: false,
|
||||
listOpen: true,
|
||||
dayOpen: true,
|
||||
dayOpened: false,
|
||||
narrowed: false,
|
||||
};
|
||||
|
||||
describe("listYields", () => {
|
||||
it("folds the list when a day opens below 1600 px", () => {
|
||||
expect(listYields({ ...base, dayOpened: true })).toBe(true);
|
||||
});
|
||||
|
||||
it("folds the list when the window narrows with both open", () => {
|
||||
expect(listYields({ ...base, narrowed: true })).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps both open from 1600 px", () => {
|
||||
expect(listYields({ ...base, fitsBoth: true, dayOpened: true })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a list the person opened beside a day alone", () => {
|
||||
// No opening and no narrowing: the day is the one that gives way.
|
||||
expect(listYields(base)).toBe(false);
|
||||
});
|
||||
|
||||
it("does nothing for a folded list, a closed day, or the sheet", () => {
|
||||
expect(listYields({ ...base, dayOpened: true, listOpen: false })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(listYields({ ...base, narrowed: true, dayOpen: false })).toBe(false);
|
||||
expect(listYields({ ...base, dayOpened: true, docked: false })).toBe(false);
|
||||
});
|
||||
|
||||
it("splits at 1600 px", () => {
|
||||
expect(BOTH_PANELS_QUERY).toBe("(min-width: 1600px)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the folded list", () => {
|
||||
const source = readFileSync(
|
||||
join(
|
||||
process.cwd(),
|
||||
"src/components/insights/coach-panel/conversations-panel.tsx",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
it("keeps an edge with its button, and sits left of the day", () => {
|
||||
expect(source).toContain('data-slot="coach-panel-rail"');
|
||||
expect(source).toContain('data-slot="coach-panel-expand"');
|
||||
expect(source).toContain('dockedOpen ? "w-72" : "w-12"');
|
||||
expect(source).toMatch(/order-1/);
|
||||
});
|
||||
|
||||
it("no longer starts a conversation from the list's header", () => {
|
||||
expect(source).not.toContain("coach-panel-new-chat");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<NewChatFab>", () => {
|
||||
it("is the Coach button's round face with a plus, named New conversation", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<NewChatFab onNewChat={() => undefined} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
const button = html.match(/<button[^>]*>/)?.[0] ?? "";
|
||||
expect(button).toContain('data-slot="coach-new-chat-fab"');
|
||||
expect(button).toContain('aria-label="New conversation"');
|
||||
expect(button).toContain("size-14");
|
||||
expect(button).toContain("rounded-full");
|
||||
expect(button).toContain("from-primary");
|
||||
// Above the composer by default, beside it where the column is wide.
|
||||
expect(button).toContain("bottom-full");
|
||||
expect(button).toContain("@min-[54rem]/composer:bottom-8");
|
||||
expect(html).toContain("lucide-plus");
|
||||
});
|
||||
});
|
||||
|
||||
describe("coachPageHref", () => {
|
||||
it("keeps an open day when the conversation changes", async () => {
|
||||
const { coachPageHref } = await import("../coach-conversation");
|
||||
expect(coachPageHref("c1")).toBe("/coach?c=c1");
|
||||
expect(coachPageHref(null)).toBe("/coach");
|
||||
expect(coachPageHref("c1", "?c=c0&day=2026-09-24")).toBe(
|
||||
"/coach?c=c1&day=2026-09-24",
|
||||
);
|
||||
expect(coachPageHref(null, "?day=2026-09-24&c=c0")).toBe(
|
||||
"/coach?day=2026-09-24",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -255,7 +255,7 @@ describe("<MessageThread>", () => {
|
||||
it("folds an older message's provenance into the thought-process row, never a raw key", () => {
|
||||
const html = render(<MessageThread conversation={baseConversation} />);
|
||||
expect(html).toContain('data-slot="coach-turn-steps-done"');
|
||||
expect(html).toContain("Thought process · 1 step");
|
||||
expect(html).toContain("Thought process (1 step)");
|
||||
expect(html).not.toContain('data-slot="coach-source-chips"');
|
||||
expect(html).not.toContain("insights.coach.metric.");
|
||||
expect(html).not.toContain("n=26");
|
||||
@@ -479,7 +479,7 @@ describe("<MessageThread>", () => {
|
||||
expect(html).not.toContain("What I'm looking at");
|
||||
expect(html).not.toContain("138 mmHg");
|
||||
// The areas the answer drew on stay reachable from the row.
|
||||
expect(html).toContain("Thought process · 1 step");
|
||||
expect(html).toContain("Thought process (1 step)");
|
||||
});
|
||||
|
||||
it("names the thought-process row in German under 'de'", () => {
|
||||
@@ -487,7 +487,7 @@ describe("<MessageThread>", () => {
|
||||
<MessageThread conversation={baseConversation} />,
|
||||
"de",
|
||||
);
|
||||
expect(html).toContain("Denkprozess · 1 Schritt");
|
||||
expect(html).toContain("Denkprozess (1 Schritt)");
|
||||
expect(html).not.toContain("Worauf bezieht sich das?");
|
||||
});
|
||||
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { ScopeHintBadge } from "../scope-hint-badge";
|
||||
import { CoachHero } from "../coach-hero";
|
||||
import { metricScopeLabelFallback } from "@/components/insights/coach-metric-scope";
|
||||
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.21.2 (A2 + A3) — the visible scope/opener affordance.
|
||||
*
|
||||
* The component renders in the `node` test environment via static markup, so
|
||||
* the tests assert the structural slots + the caller-resolved label / question
|
||||
* (those are passed in already-localised, not looked up in the bundle). The
|
||||
* prefix/tap copy resolves through the live i18n bundle.
|
||||
*/
|
||||
describe("<ScopeHintBadge>", () => {
|
||||
it("renders the scope pill with the active metric and the tappable seed", () => {
|
||||
const html = render(
|
||||
<ScopeHintBadge
|
||||
variant="scope"
|
||||
label="blood pressure"
|
||||
question="Walk me through my blood pressure trend."
|
||||
onSeed={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="coach-scope-hint"');
|
||||
expect(html).toContain('data-variant="scope"');
|
||||
// The metric label is surfaced in the visible pill.
|
||||
expect(html).toContain('data-slot="coach-scope-hint-pill"');
|
||||
expect(html).toContain("blood pressure");
|
||||
// The seed question is a real button so it is keyboard reachable.
|
||||
expect(html).toContain('data-slot="coach-scope-hint-seed"');
|
||||
expect(html).toContain("Walk me through my blood pressure trend.");
|
||||
});
|
||||
|
||||
it("renders the seeded variant for a notable derived signal", () => {
|
||||
const html = render(
|
||||
<ScopeHintBadge
|
||||
variant="seeded"
|
||||
label="readiness"
|
||||
question="Why is my readiness lower today?"
|
||||
onSeed={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-variant="seeded"');
|
||||
expect(html).toContain("readiness");
|
||||
expect(html).toContain("Why is my readiness lower today?");
|
||||
});
|
||||
|
||||
it("invokes onSeed with the question when the seed button is activated", () => {
|
||||
// The button's onClick is wired to `onSeed(question)`; assert the binding
|
||||
// directly (the node test env has no DOM event loop to click through).
|
||||
const onSeed = vi.fn();
|
||||
const question = "Walk me through my pulse readings.";
|
||||
// Render once to prove no throw, then exercise the handler contract.
|
||||
render(
|
||||
<ScopeHintBadge
|
||||
variant="scope"
|
||||
label="pulse"
|
||||
question={question}
|
||||
onSeed={onSeed}
|
||||
/>,
|
||||
);
|
||||
onSeed(question);
|
||||
expect(onSeed).toHaveBeenCalledWith(question);
|
||||
});
|
||||
|
||||
// v1.21.4 (C2) — the seeded opener carries a dismiss control so the user can
|
||||
// wave off today's suggestion. The control is gated to the seeded variant.
|
||||
it("renders the dismiss control for the seeded variant when onDismiss is given", () => {
|
||||
const html = render(
|
||||
<ScopeHintBadge
|
||||
variant="seeded"
|
||||
label="readiness"
|
||||
question="Why is my readiness lower today?"
|
||||
onSeed={() => {}}
|
||||
onDismiss={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="coach-scope-hint-dismiss"');
|
||||
});
|
||||
|
||||
it("omits the dismiss control for the seeded variant without onDismiss", () => {
|
||||
const html = render(
|
||||
<ScopeHintBadge
|
||||
variant="seeded"
|
||||
label="readiness"
|
||||
question="Why is my readiness lower today?"
|
||||
onSeed={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="coach-scope-hint-dismiss"');
|
||||
});
|
||||
|
||||
it("never renders the dismiss control for the non-dismissable scope pill", () => {
|
||||
const html = render(
|
||||
<ScopeHintBadge
|
||||
variant="scope"
|
||||
label="blood pressure"
|
||||
question="Walk me through my blood pressure trend."
|
||||
onSeed={() => {}}
|
||||
onDismiss={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="coach-scope-hint-dismiss"');
|
||||
});
|
||||
|
||||
it("sizes the seeded opener to its text and keeps it single-line", () => {
|
||||
const html = render(
|
||||
<ScopeHintBadge
|
||||
variant="seeded"
|
||||
label="readiness"
|
||||
question="Why is my readiness lower today?"
|
||||
onSeed={() => {}}
|
||||
onDismiss={() => {}}
|
||||
/>,
|
||||
);
|
||||
const seedTag = html.match(
|
||||
/<button[^>]*data-slot="coach-scope-hint-seed"[^>]*>/,
|
||||
);
|
||||
expect(seedTag).not.toBeNull();
|
||||
// v1.25.0 — intrinsic width, not stretched to the composer column: the
|
||||
// opener grows to its text and caps at the full width. The old narrow cap
|
||||
// and the full-width stretch are both gone.
|
||||
expect(html).not.toContain("max-w-md");
|
||||
// Intrinsic width capped at the column: `inline-flex` + `max-w-full`, not
|
||||
// the old `flex-1` / `w-full` full-width stretch.
|
||||
expect(seedTag?.[0]).toContain("inline-flex");
|
||||
expect(seedTag?.[0]).toContain("max-w-full");
|
||||
expect(seedTag?.[0]).not.toMatch(/\bflex-1\b/);
|
||||
// The question span truncates with an ellipsis rather than wrapping.
|
||||
expect(html).toContain("truncate");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<CoachHero> scope hint slot", () => {
|
||||
it("renders the scope hint above-composer slot when provided", () => {
|
||||
const html = render(
|
||||
<CoachHero
|
||||
composer={<div data-slot="test-composer">composer</div>}
|
||||
scopeHint={<div data-slot="injected-hint">hint</div>}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="coach-hero-scope-hint"');
|
||||
expect(html).toContain('data-slot="injected-hint"');
|
||||
});
|
||||
|
||||
it("falls back to the neutral hero when no scope hint is given", () => {
|
||||
// A3 fallback: no signal crossed the gate (scopeHint null), so the hero
|
||||
// is the calm greeting + composer with NO opener affordance.
|
||||
const html = render(
|
||||
<CoachHero composer={<div data-slot="test-composer">composer</div>} />,
|
||||
);
|
||||
expect(html).toContain('data-slot="coach-hero"');
|
||||
expect(html).toContain("Ask me anything about your data");
|
||||
expect(html).not.toContain('data-slot="coach-hero-scope-hint"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("metricScopeLabelFallback", () => {
|
||||
it("maps a scope source to its brand-free domain label", () => {
|
||||
expect(metricScopeLabelFallback("bp")).toBe("blood pressure");
|
||||
expect(metricScopeLabelFallback("resting_hr")).toBe("resting heart rate");
|
||||
});
|
||||
|
||||
it("returns null for an absent metric (a generic open)", () => {
|
||||
expect(metricScopeLabelFallback(null)).toBeNull();
|
||||
expect(metricScopeLabelFallback(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -413,7 +413,7 @@ describe("after the answer", () => {
|
||||
);
|
||||
expect(html).toContain('data-state="done"');
|
||||
expect(html).toMatch(
|
||||
/data-slot="coach-turn-steps-done"[^>]*>Thought process · 5 steps</,
|
||||
/data-slot="coach-turn-steps-done"[^>]*>Thought process \(5 steps\)</,
|
||||
);
|
||||
expect(html).not.toContain("text-shimmer");
|
||||
expect(html).not.toContain("waiting-dot");
|
||||
@@ -428,7 +428,7 @@ describe("after the answer", () => {
|
||||
<CoachTurnActivity activity={[ANSWER_DONE]} steps={[]} active={false} />,
|
||||
"de",
|
||||
);
|
||||
expect(de).toContain("Denkprozess · 1 Schritt<");
|
||||
expect(de).toContain("Denkprozess (1 Schritt)<");
|
||||
const pl = render(
|
||||
<CoachTurnActivity
|
||||
activity={[THINKING_DONE, FETCH_BP, ANSWER_DONE]}
|
||||
@@ -437,7 +437,7 @@ describe("after the answer", () => {
|
||||
/>,
|
||||
"pl",
|
||||
);
|
||||
expect(pl).toContain("Tok myślenia · 3 kroki<");
|
||||
expect(pl).toContain("Tok myślenia (3 kroki)<");
|
||||
});
|
||||
|
||||
it("an older message counts its steps, or the areas it drew on", () => {
|
||||
@@ -445,7 +445,7 @@ describe("after the answer", () => {
|
||||
render(
|
||||
<CoachTurnActivity activity={[]} steps={[BP, SLEEP]} active={false} />,
|
||||
),
|
||||
).toContain("Thought process · 2 steps<");
|
||||
).toContain("Thought process (2 steps)<");
|
||||
expect(
|
||||
render(
|
||||
<CoachTurnActivity
|
||||
@@ -455,7 +455,7 @@ describe("after the answer", () => {
|
||||
areas={["bp"]}
|
||||
/>,
|
||||
),
|
||||
).toContain("Thought process · 1 step<");
|
||||
).toContain("Thought process (1 step)<");
|
||||
});
|
||||
|
||||
it("says an answer is needed while a question waits, without shimmer", () => {
|
||||
@@ -681,7 +681,7 @@ describe("the visible row text", () => {
|
||||
),
|
||||
),
|
||||
),
|
||||
).toBe("Thought process · 5 steps");
|
||||
).toBe("Thought process (5 steps)");
|
||||
expect(
|
||||
textOf(
|
||||
rowHtml(render(<CoachTurnActivity activity={[]} steps={[]} active />)),
|
||||
|
||||
@@ -7,18 +7,17 @@ import {
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { Paperclip, Plus, Sparkles } from "lucide-react";
|
||||
import { Paperclip, Sparkles } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { DAY_QUERY_PARAM } from "@/lib/day/contract";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { randomId } from "@/lib/random-id";
|
||||
import { apiDelete, apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
import type { CoachFollowUp, CoachScope } from "@/lib/ai/coach/types";
|
||||
import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context";
|
||||
import type { CoachSeededQuestionDTO } from "@/app/api/insights/coach/seeded-question/route";
|
||||
import type { InboundDocumentDetailDto } from "@/lib/validations/inbound-documents";
|
||||
import { MAX_COACH_ATTACHMENTS } from "@/lib/validations/inbound-documents";
|
||||
import { useModuleEnabled } from "@/hooks/use-module-enabled";
|
||||
@@ -30,7 +29,6 @@ import {
|
||||
|
||||
import { CoachDrawerBody } from "./coach-drawer-body";
|
||||
import { CoachHero } from "./coach-hero";
|
||||
import { ScopeHintBadge } from "./scope-hint-badge";
|
||||
import { CoachInput } from "./coach-input";
|
||||
import {
|
||||
GuidedQuestionBubble,
|
||||
@@ -53,12 +51,12 @@ import { CoachSettingsOverlay } from "./coach-settings-overlay";
|
||||
import { CoachTopBarTrail } from "./coach-top-bar-trail";
|
||||
import { ConversationsPanel } from "./conversations-panel";
|
||||
import { MobileRailTray } from "./mobile-rail-tray";
|
||||
import { NewChatFab } from "./new-chat-fab";
|
||||
import { SelfContextAdoptOffer } from "./self-context-adopt-offer";
|
||||
import { SourcesRail } from "./sources-rail";
|
||||
import { AttachmentPills, type AttachmentPillItem } from "./attachment-pills";
|
||||
import { AttachmentPicker } from "./attachment-picker";
|
||||
import { useResettableValue } from "./use-resettable-value";
|
||||
import { useCoachAmbientSuggestionsEnabled } from "@/hooks/use-coach-ambient-suggestions";
|
||||
import {
|
||||
useAttachCoachDocument,
|
||||
useCoachConversation,
|
||||
@@ -127,19 +125,6 @@ export function dropInvalidStagedAttachments(
|
||||
return pendingAttachmentIds.filter((_, i) => isErrorByIndex[i] === true);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.21.4 (C2) — the localStorage key that records the seeded "worth a look"
|
||||
* opener as dismissed for a given LOCAL calendar day. Date-stamped so the
|
||||
* dismissal resets at midnight: a new day mints a new key the flag has not
|
||||
* been written under yet, and the opener returns.
|
||||
*/
|
||||
function seededDismissStorageKey(now: Date = new Date()): string {
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(now.getDate()).padStart(2, "0");
|
||||
return `coach-seeded-dismissed:${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
export interface CoachConversationProps {
|
||||
/**
|
||||
* Pre-fill for the composer (suggested-prompt chip click). Resets the
|
||||
@@ -269,10 +254,18 @@ export interface CoachConversationProps {
|
||||
* `useSearchParams` in step with it) so a reload lands on the same thread
|
||||
* without a server round trip or a new history entry per switch.
|
||||
*/
|
||||
function coachPageHref(conversationId: string | null): string {
|
||||
return conversationId
|
||||
export function coachPageHref(
|
||||
conversationId: string | null,
|
||||
search = "",
|
||||
): string {
|
||||
const base = conversationId
|
||||
? `/coach?c=${encodeURIComponent(conversationId)}`
|
||||
: "/coach";
|
||||
// An open day stays open: the day docked beside the conversation is the
|
||||
// URL's `?day=`, and switching conversations must not close it.
|
||||
const day = new URLSearchParams(search).get(DAY_QUERY_PARAM);
|
||||
if (!day) return base;
|
||||
return `${base}${conversationId ? "&" : "?"}${DAY_QUERY_PARAM}=${encodeURIComponent(day)}`;
|
||||
}
|
||||
|
||||
export function CoachConversation({
|
||||
@@ -372,7 +365,7 @@ export function CoachConversation({
|
||||
// a shared tab) lands on it. The drawer has no URL of its own.
|
||||
function syncPageUrl(conversationId: string | null) {
|
||||
if (surface !== "page" || typeof window === "undefined") return;
|
||||
const href = coachPageHref(conversationId);
|
||||
const href = coachPageHref(conversationId, window.location.search);
|
||||
if (window.location.pathname + window.location.search === href) return;
|
||||
window.history.replaceState(window.history.state, "", href);
|
||||
}
|
||||
@@ -973,62 +966,16 @@ export function CoachConversation({
|
||||
pendingQuestions.length === 0 &&
|
||||
!pendingAdopt;
|
||||
|
||||
// v1.21.2 (A2 + A3) — the visible scope/opener affordance for the hero.
|
||||
//
|
||||
// A2 (scoped launch): the Coach was opened narrowed to a metric. Make it
|
||||
// visible — a "the Coach is already on <metric>" pill plus the data-aware
|
||||
// seed question (the launch prefill) the user can tap into the composer —
|
||||
// instead of the old hidden prefill.
|
||||
//
|
||||
// A3 (unscoped launch): no launch scope, so resolve today's single most
|
||||
// notable derived signal server-side and offer it as a tappable opener. The
|
||||
// query only fires when the hero is on screen AND there is no launch scope
|
||||
// (an A2 launch already has its opener), so a scoped open pays nothing for
|
||||
// it. When the server returns no signal the hint is null and the neutral
|
||||
// greeting stands — never a fabricated opener.
|
||||
// Something to leave: a stored conversation or a turn on its way.
|
||||
const threadStarted =
|
||||
currentConversationId !== null ||
|
||||
send.isStreaming ||
|
||||
!!send.optimisticUser ||
|
||||
!!send.streaming.content;
|
||||
|
||||
// The launch scope, for the drawer's sources rail: a conversation opened
|
||||
// from a metric surface names the metric there until its first turn.
|
||||
const a2Metric = launchScope?.metric ?? null;
|
||||
// v1.21.4 (C2) — once the user dismisses today's seeded opener it stays gone
|
||||
// for the rest of the local calendar day; the fetch is skipped too, so a
|
||||
// dismissed day costs nothing. SSR-safe lazy init guards `window`.
|
||||
const [seededDismissedToday, setSeededDismissedToday] = useState(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
return window.localStorage.getItem(seededDismissStorageKey()) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
// v1.25.0 — the per-user opt-out for proactive ambient suggestions gates the
|
||||
// seeded opener (and skips its fetch when off). The A2 launch scope below is
|
||||
// NOT ambient — it reflects how the chat was opened — so it stays unaffected.
|
||||
const ambientSuggestionsEnabled = useCoachAmbientSuggestionsEnabled();
|
||||
const seededEnabled =
|
||||
heroActive &&
|
||||
a2Metric === null &&
|
||||
!seededDismissedToday &&
|
||||
ambientSuggestionsEnabled;
|
||||
const { data: seeded } = useQuery({
|
||||
queryKey: queryKeys.coachSeededQuestion(),
|
||||
queryFn: async () =>
|
||||
apiGet<CoachSeededQuestionDTO>("/api/insights/coach/seeded-question"),
|
||||
enabled: seededEnabled,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
function seedComposer(question: string) {
|
||||
setInputValue(question);
|
||||
}
|
||||
|
||||
function dismissSeeded() {
|
||||
setSeededDismissedToday(true);
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(seededDismissStorageKey(), "1");
|
||||
} catch {
|
||||
// Storage can throw (private mode / quota); the in-memory flag still
|
||||
// hides the opener for this session.
|
||||
}
|
||||
}
|
||||
|
||||
// `t()` returns the key string itself when a key is missing, so the only
|
||||
// reliable "is this key defined" signal is `t(key) !== key`.
|
||||
@@ -1037,10 +984,9 @@ export function CoachConversation({
|
||||
return resolved === key ? null : resolved;
|
||||
}
|
||||
|
||||
// The resolved A2 metric label: prefers a per-source i18n key, falls back
|
||||
// to the brand-free English domain phrase so every source has a label.
|
||||
// Hoisted so both the hero hint (page) and the sources rail (drawer) read
|
||||
// the same string. Null when the launch carried no metric.
|
||||
// The resolved metric label: prefers a per-source i18n key, falls back to
|
||||
// the brand-free English domain phrase so every source has a label. Null
|
||||
// when the launch carried no metric.
|
||||
const a2LabelKey = scopeSourceMetricLabelKey(a2Metric);
|
||||
const a2MetricLabel = a2Metric
|
||||
? (tOrNull(`insights.coach.scope.metric.${a2Metric}`) ??
|
||||
@@ -1049,51 +995,6 @@ export function CoachConversation({
|
||||
a2Metric)
|
||||
: null;
|
||||
|
||||
let scopeHint: React.ReactNode = null;
|
||||
if (fenced) {
|
||||
// v1.29.x (S7) — a fenced conversation has NO health snapshot to scope, so
|
||||
// the A2/A3 "the Coach is already on <metric>" openers are suppressed. The
|
||||
// honest fencing banner (below) is the only chrome the fenced hero shows.
|
||||
scopeHint = null;
|
||||
} else if (a2Metric && a2MetricLabel) {
|
||||
// A2 — the launch prefill IS the data-aware opener; fall back to the
|
||||
// generic per-metric question when the launch carried no prefill.
|
||||
const seedQuestion =
|
||||
(prefill ?? "").trim() || t("insights.coach.scope.question");
|
||||
scopeHint = (
|
||||
<ScopeHintBadge
|
||||
variant="scope"
|
||||
label={a2MetricLabel}
|
||||
question={seedQuestion}
|
||||
onSeed={seedComposer}
|
||||
/>
|
||||
);
|
||||
} else if (
|
||||
seeded?.signal &&
|
||||
!seededDismissedToday &&
|
||||
ambientSuggestionsEnabled
|
||||
) {
|
||||
// A3 — the notable derived signal. The label + opener are keyed on the
|
||||
// signal's sourceMetric (`readiness` / `recovery`); an unknown sentinel
|
||||
// (future detector additions) skips the opener rather than guessing.
|
||||
const sentinel = seeded.signal.sourceMetric;
|
||||
const signalLabel = tOrNull(`insights.coach.seeded.signal.${sentinel}`);
|
||||
const signalQuestion = tOrNull(
|
||||
`insights.coach.seeded.question.${sentinel}`,
|
||||
);
|
||||
if (signalLabel && signalQuestion) {
|
||||
scopeHint = (
|
||||
<ScopeHintBadge
|
||||
variant="seeded"
|
||||
label={signalLabel}
|
||||
question={signalQuestion}
|
||||
onSeed={seedComposer}
|
||||
onDismiss={dismissSeeded}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// v1.18.11 (W11) — the docked composer column: the quiet adopt offer and
|
||||
// the guided-questions entry card stack above the live composer. Shared by
|
||||
// the drawer body (via `CoachDrawerBody`) and the page surface (rendered
|
||||
@@ -1218,7 +1119,7 @@ export function CoachConversation({
|
||||
/>
|
||||
{!heroActive ? <h1 className="sr-only">{title}</h1> : null}
|
||||
{heroActive ? (
|
||||
<CoachHero composer={composerNode} scopeHint={scopeHint} />
|
||||
<CoachHero composer={composerNode} />
|
||||
) : (
|
||||
<>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
@@ -1234,9 +1135,10 @@ export function CoachConversation({
|
||||
</div>
|
||||
<div
|
||||
data-slot="coach-page-composer"
|
||||
className="shrink-0 px-4 pt-2 pb-3 sm:px-6 sm:pb-4"
|
||||
className="@container/composer relative shrink-0 px-4 pt-2 pb-3 sm:px-6 sm:pb-4"
|
||||
>
|
||||
<div className="mx-auto w-full max-w-2xl">{composerStack}</div>
|
||||
<NewChatFab onNewChat={handleNewChat} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -1300,18 +1202,6 @@ export function CoachConversation({
|
||||
<p className="text-muted-foreground truncate text-xs">{tagline}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleNewChat}
|
||||
data-slot="coach-new-chat"
|
||||
aria-label={t("insights.coach.newChat")}
|
||||
title={t("insights.coach.newChat")}
|
||||
className="text-muted-foreground hover:text-foreground size-11 shrink-0"
|
||||
>
|
||||
<Plus className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
{/* The same quick settings the page's panel opens: model and what
|
||||
the Coach may read. Everything else stays in Settings → AI. */}
|
||||
<CoachSettingsOverlay
|
||||
@@ -1342,6 +1232,11 @@ export function CoachConversation({
|
||||
/>
|
||||
}
|
||||
composer={composerStack}
|
||||
// New conversation floats in the conversation itself, once there is
|
||||
// one to leave.
|
||||
newChat={
|
||||
threadStarted ? <NewChatFab onNewChat={handleNewChat} /> : null
|
||||
}
|
||||
onHistoryClick={onRequestFullView ?? (() => setHistoryTrayOpen(true))}
|
||||
onOpenSourcesTray={() => setSourcesTrayOpen(true)}
|
||||
/>
|
||||
|
||||
@@ -42,6 +42,8 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
export interface CoachDrawerBodyProps {
|
||||
thread: React.ReactNode;
|
||||
composer: React.ReactNode;
|
||||
/** The New conversation button, floating above the composer. */
|
||||
newChat?: React.ReactNode;
|
||||
/**
|
||||
* Inline conversation list (page surface only). When set, it renders
|
||||
* as a collapsible left column on lg+ and the history button collapses
|
||||
@@ -67,6 +69,7 @@ export interface CoachDrawerBodyProps {
|
||||
export function CoachDrawerBody({
|
||||
thread,
|
||||
composer,
|
||||
newChat,
|
||||
historyRail,
|
||||
historyOpen = false,
|
||||
onToggleHistory,
|
||||
@@ -234,9 +237,10 @@ export function CoachDrawerBody({
|
||||
{/* Composer pinned to the bottom. */}
|
||||
<div
|
||||
data-slot="coach-drawer-composer"
|
||||
className="border-border/70 flex flex-col gap-2 border-t p-3 pb-[calc(env(safe-area-inset-bottom,0px)+0.75rem)] sm:p-4 sm:pb-[calc(env(safe-area-inset-bottom,0px)+1rem)]"
|
||||
className="border-border/70 @container/composer relative flex flex-col gap-2 border-t p-3 pb-[calc(env(safe-area-inset-bottom,0px)+0.75rem)] sm:p-4 sm:pb-[calc(env(safe-area-inset-bottom,0px)+1rem)]"
|
||||
>
|
||||
{composer}
|
||||
{newChat}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -28,25 +28,15 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
* in via `motion-safe` `animate-in` with capped delays; reduced-motion
|
||||
* users get the layout instantly.
|
||||
*
|
||||
* v1.21.2 (A2 + A3) — the hero accepts an optional `scopeHint` slot
|
||||
* (a `<ScopeHintBadge>`): the visible "the Coach is already on <metric>"
|
||||
* pill + seed question when launched scoped (A2), or the pre-seeded
|
||||
* notable-signal opener when launched unscoped (A3). When absent the hero
|
||||
* is the calm greeting + composer exactly as before, so the neutral
|
||||
* blank-state fallback is structural.
|
||||
* The space under the composer stays empty: no suggested prompts and no
|
||||
* openers, so an empty conversation is the greeting and the field.
|
||||
*/
|
||||
export interface CoachHeroProps {
|
||||
/** The live `<CoachInput>` composer, re-parented into the hero. */
|
||||
composer: React.ReactNode;
|
||||
/**
|
||||
* v1.21.2 — optional scope/opener affordance rendered above the
|
||||
* composer. The parent owns the data (launch scope / seeded signal) and
|
||||
* passes a resolved `<ScopeHintBadge>`; null keeps the neutral hero.
|
||||
*/
|
||||
scopeHint?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function CoachHero({ composer, scopeHint }: CoachHeroProps) {
|
||||
export function CoachHero({ composer }: CoachHeroProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
@@ -104,23 +94,6 @@ export function CoachHero({ composer, scopeHint }: CoachHeroProps) {
|
||||
>
|
||||
{composer}
|
||||
</div>
|
||||
|
||||
{/* v1.21.2 (A2/A3) — visible scope pill + tappable opener, beneath
|
||||
the composer so the greeting → composer rhythm stays intact and
|
||||
the opener reads as a follow-on suggestion. Renders only when the
|
||||
parent resolved a scope or a notable signal. */}
|
||||
{scopeHint ? (
|
||||
<div
|
||||
data-slot="coach-hero-scope-hint"
|
||||
className={cn(
|
||||
"w-full",
|
||||
"motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-bottom-2 motion-safe:duration-500",
|
||||
)}
|
||||
style={{ animationDelay: "240ms", animationFillMode: "both" }}
|
||||
>
|
||||
{scopeHint}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useSyncExternalStore } from "react";
|
||||
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
import Link from "next/link";
|
||||
import { PanelRightClose, PanelRightOpen, Plus, Target, X } from "lucide-react";
|
||||
import { PanelRightClose, PanelRightOpen, Target, X } from "lucide-react";
|
||||
|
||||
import { useOpenDay, yieldDay } from "@/components/day/day-layer-controller";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Sheet,
|
||||
@@ -42,18 +43,26 @@ import { useDeleteCoachConversationWithUndo } from "./use-coach";
|
||||
* Docked, the panel is a column of the shell beside the content column
|
||||
* (top bar and page): it runs the full height of the window, and the top bar
|
||||
* ends at its left edge. The panel's header row is the top bar's band, so
|
||||
* the two bottom borders draw one line. Shut, it is gone entirely (no rail)
|
||||
* and the top bar runs to the window's edge. The toggle is the last item of
|
||||
* the top bar, so it sits against the open panel and at the window's edge
|
||||
* when the panel is shut. The panel header carries the title, a link to
|
||||
* Plans, the settings gear and, in the sheet, a close button. New chat
|
||||
* opens the header row, left of the title: a fixed control that never sits
|
||||
* over the list, so the last conversation needs no reserve below it.
|
||||
* the two bottom borders draw one line. It folds like the day beside it: the
|
||||
* header's first control folds it to a narrow edge, and the edge's button
|
||||
* (in the same place) opens it again. Folded, the edge names the list.
|
||||
* The header carries the title, a link to Plans, the settings gear and, in
|
||||
* the sheet, a close button. New conversation is not here: it floats in the
|
||||
* conversation itself (`NewChatFab`).
|
||||
*
|
||||
* Keyboard: Escape closes the docked panel when focus is inside it and
|
||||
* The day docks right of the panel on this page too (page, conversations,
|
||||
* day). Below 1600 px only one of the two is open: a day opening folds the
|
||||
* list to its edge without touching the remembered choice, and opening the
|
||||
* list folds the day to its edge (`yieldDay`). From 1600 px both stay open.
|
||||
*
|
||||
* Below 1280 px the panel is a sheet, opened from the toggle at the end of
|
||||
* the top bar.
|
||||
*
|
||||
* Keyboard: Escape folds the docked panel when focus is inside it and
|
||||
* nothing inside (a row menu, the rename field, the settings popover) has
|
||||
* already claimed the key, then focus returns to the toggle. In the sheet
|
||||
* Radix owns Escape, the focus trap and the return.
|
||||
* already claimed the key; focus moves to the edge's button, and opening
|
||||
* from there lands on the fold control. In the sheet Radix owns Escape, the
|
||||
* focus trap and the return.
|
||||
*/
|
||||
export const COACH_PANEL_ID = "coach-conversations-panel";
|
||||
|
||||
@@ -68,6 +77,50 @@ const PANEL_HEADER_ICON = "size-5 pointer-fine:size-4";
|
||||
|
||||
const noSubscription = () => () => {};
|
||||
|
||||
/** Wide enough for the conversations and a day beside the conversation. */
|
||||
export const BOTH_PANELS_QUERY = "(min-width: 1600px)";
|
||||
|
||||
function subscribeBothPanels(callback: () => void): () => void {
|
||||
if (typeof window === "undefined" || !window.matchMedia) return () => {};
|
||||
const mql = window.matchMedia(BOTH_PANELS_QUERY);
|
||||
mql.addEventListener("change", callback);
|
||||
return () => mql.removeEventListener("change", callback);
|
||||
}
|
||||
|
||||
function useBothPanelsFit(): boolean {
|
||||
return useSyncExternalStore(
|
||||
subscribeBothPanels,
|
||||
() =>
|
||||
typeof window !== "undefined" && !!window.matchMedia
|
||||
? window.matchMedia(BOTH_PANELS_QUERY).matches
|
||||
: false,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the docked list gives way to an open day: it does when the day
|
||||
* opens, or the window narrows, while both would be open below 1600 px.
|
||||
* Only those two moments fold it, so opening the list again beside an open
|
||||
* day (which folds the day) is never undone.
|
||||
*/
|
||||
export function listYields(state: {
|
||||
docked: boolean;
|
||||
fitsBoth: boolean;
|
||||
listOpen: boolean;
|
||||
dayOpened: boolean;
|
||||
narrowed: boolean;
|
||||
dayOpen: boolean;
|
||||
}): boolean {
|
||||
return (
|
||||
state.docked &&
|
||||
!state.fitsBoth &&
|
||||
state.listOpen &&
|
||||
state.dayOpen &&
|
||||
(state.dayOpened || state.narrowed)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the phone sheet is open. A settings deep link opens it only once
|
||||
* the viewport is known: the server and hydration renders take the narrow
|
||||
@@ -181,22 +234,62 @@ export function ConversationsPanel({
|
||||
const [settingsOpen, setSettingsOpen] = useState(openSettingsOnData);
|
||||
const [settingsOnData, setSettingsOnData] = useState(openSettingsOnData);
|
||||
|
||||
const dockedOpen = rememberedOpen || revealed;
|
||||
const expanded = docked ? dockedOpen : sheetOpen;
|
||||
|
||||
function toggle() {
|
||||
if (docked) {
|
||||
setRevealed(false);
|
||||
setOpen(!dockedOpen);
|
||||
} else {
|
||||
setSheetOpen(!sheetOpen);
|
||||
// The day beside the list (docked on this page from 1280 px too).
|
||||
const openDay = useOpenDay();
|
||||
const fitsBoth = useBothPanelsFit();
|
||||
// Folded for the day, without rewriting the remembered choice.
|
||||
const [yielded, setYielded] = useState(false);
|
||||
const dockedOpen = (rememberedOpen || revealed) && !yielded;
|
||||
const [seen, setSeen] = useState<{ day: string | null; fitsBoth: boolean }>({
|
||||
day: null,
|
||||
fitsBoth,
|
||||
});
|
||||
if (seen.day !== openDay || seen.fitsBoth !== fitsBoth) {
|
||||
setSeen({ day: openDay, fitsBoth });
|
||||
if (
|
||||
listYields({
|
||||
docked,
|
||||
fitsBoth,
|
||||
listOpen: dockedOpen,
|
||||
dayOpen: openDay !== null,
|
||||
dayOpened: seen.day === null && openDay !== null,
|
||||
narrowed: seen.fitsBoth && !fitsBoth,
|
||||
})
|
||||
) {
|
||||
setYielded(true);
|
||||
}
|
||||
}
|
||||
|
||||
function closeDocked() {
|
||||
const expanded = docked ? dockedOpen : sheetOpen;
|
||||
const collapseRef = useRef<HTMLButtonElement>(null);
|
||||
const expandRef = useRef<HTMLButtonElement>(null);
|
||||
// Where focus goes once the docked panel has folded or opened.
|
||||
const focusAfter = useRef<"collapse" | "expand" | null>(null);
|
||||
useEffect(() => {
|
||||
const target = focusAfter.current;
|
||||
focusAfter.current = null;
|
||||
if (target === "collapse") collapseRef.current?.focus();
|
||||
if (target === "expand") expandRef.current?.focus();
|
||||
}, [dockedOpen]);
|
||||
|
||||
function toggleSheet() {
|
||||
setSheetOpen(!sheetOpen);
|
||||
}
|
||||
|
||||
function expandDocked() {
|
||||
focusAfter.current = "collapse";
|
||||
setYielded(false);
|
||||
setRevealed(false);
|
||||
setOpen(true);
|
||||
// Below 1600 px the day makes room: it folds to its edge.
|
||||
if (!fitsBoth && openDay !== null) yieldDay();
|
||||
}
|
||||
|
||||
function collapseDocked() {
|
||||
focusAfter.current = "expand";
|
||||
setYielded(false);
|
||||
setRevealed(false);
|
||||
setOpen(false);
|
||||
toggleRef.current?.focus();
|
||||
}
|
||||
|
||||
function afterPick() {
|
||||
@@ -224,32 +317,31 @@ export function ConversationsPanel({
|
||||
: cn("border-sidebar-border px-3", SHELL_HEADER_BAND),
|
||||
)}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
onNewChat();
|
||||
afterPick();
|
||||
}}
|
||||
data-slot="coach-panel-new-chat"
|
||||
aria-label={t("insights.coach.newChat")}
|
||||
// The panel's one action, so the primary wash marks it apart
|
||||
// from the muted utility buttons on the right.
|
||||
className={cn(
|
||||
PANEL_HEADER_BUTTON,
|
||||
"bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary",
|
||||
)}
|
||||
>
|
||||
<Plus className={PANEL_HEADER_ICON} aria-hidden="true" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{t("insights.coach.newChat")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{inSheet ? null : (
|
||||
// A title, not a tooltip: focus lands here when the panel opens
|
||||
// from its edge, and a tooltip opened by that focus would take the
|
||||
// next Escape for itself.
|
||||
<Button
|
||||
ref={collapseRef}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={collapseDocked}
|
||||
data-slot="coach-panel-collapse"
|
||||
aria-controls={COACH_PANEL_ID}
|
||||
aria-expanded={true}
|
||||
aria-label={t("insights.coach.frame.hidePanel")}
|
||||
title={t("insights.coach.frame.hidePanel")}
|
||||
className={PANEL_HEADER_BUTTON}
|
||||
>
|
||||
{/* The day's own fold control and glyph, mirrored so it points
|
||||
the way the panel goes. */}
|
||||
<PanelRightClose
|
||||
className={cn(PANEL_HEADER_ICON, "-scale-x-100")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
{inSheet ? (
|
||||
<SheetTitle className="min-w-0 flex-1 truncate text-lg leading-tight font-semibold">
|
||||
{t("insights.coach.historyTitle")}
|
||||
@@ -321,14 +413,16 @@ export function ConversationsPanel({
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<TopBarActions>
|
||||
<PanelToggle
|
||||
ref={toggleRef}
|
||||
expanded={expanded}
|
||||
label={toggleLabel}
|
||||
onToggle={toggle}
|
||||
/>
|
||||
</TopBarActions>
|
||||
{docked ? null : (
|
||||
<TopBarActions>
|
||||
<PanelToggle
|
||||
ref={toggleRef}
|
||||
expanded={expanded}
|
||||
label={toggleLabel}
|
||||
onToggle={toggleSheet}
|
||||
/>
|
||||
</TopBarActions>
|
||||
)}
|
||||
|
||||
{docked ? (
|
||||
<ShellSidePanel>
|
||||
@@ -337,28 +431,81 @@ export function ConversationsPanel({
|
||||
aria-label={t("insights.coach.historyTitle")}
|
||||
data-slot="coach-conversations-panel"
|
||||
data-state={dockedOpen ? "open" : "closed"}
|
||||
inert={!dockedOpen}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Escape" || event.defaultPrevented) return;
|
||||
if (
|
||||
!dockedOpen ||
|
||||
event.key !== "Escape" ||
|
||||
event.defaultPrevented
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
closeDocked();
|
||||
collapseDocked();
|
||||
}}
|
||||
className={cn(
|
||||
// A column of the shell's content row (`ShellSidePanel`), so it
|
||||
// runs from the top of the window to the bottom beside the top
|
||||
// bar. The width animates (offcanvas: shut, nothing of it
|
||||
// shows); the inner column
|
||||
// keeps 18rem and is pinned to the panel's left edge, so it
|
||||
// slides out to the right instead of squeezing. The thread column
|
||||
// keeps its width; only its centring moves.
|
||||
"bg-sidebar text-sidebar-foreground relative h-full shrink-0 overflow-hidden",
|
||||
// bar, left of a docked day (`order-1`, the day is `order-2`).
|
||||
// The width animates between the open column and the folded
|
||||
// edge; the open column keeps 18rem and is pinned to the
|
||||
// panel's left edge, so it slides instead of squeezing.
|
||||
"bg-sidebar text-sidebar-foreground relative order-1 h-full shrink-0 overflow-hidden",
|
||||
"transition-[width] duration-200 ease-linear motion-reduce:transition-none",
|
||||
dockedOpen ? "w-72" : "w-0",
|
||||
dockedOpen ? "w-72" : "w-12",
|
||||
)}
|
||||
>
|
||||
<div className="border-sidebar-border absolute inset-y-0 left-0 flex w-72 flex-col border-l">
|
||||
{body(false)}
|
||||
</div>
|
||||
{dockedOpen ? (
|
||||
<div className="border-sidebar-border absolute inset-y-0 left-0 flex w-72 flex-col border-l">
|
||||
{body(false)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
data-slot="coach-panel-rail"
|
||||
className="border-sidebar-border absolute inset-y-0 left-0 flex w-12 flex-col border-l"
|
||||
>
|
||||
{/* The top bar's band, so the bottom borders draw one line;
|
||||
the button sits where the open panel keeps its fold
|
||||
control. */}
|
||||
<div
|
||||
className={cn(
|
||||
SHELL_HEADER_BAND,
|
||||
"border-sidebar-border flex shrink-0 items-center justify-center",
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
ref={expandRef}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={expandDocked}
|
||||
data-slot="coach-panel-expand"
|
||||
aria-controls={COACH_PANEL_ID}
|
||||
aria-expanded={false}
|
||||
aria-label={t("insights.coach.frame.showPanel")}
|
||||
title={t("insights.coach.frame.showPanel")}
|
||||
className={PANEL_HEADER_BUTTON}
|
||||
>
|
||||
<PanelRightOpen
|
||||
className={cn(PANEL_HEADER_ICON, "-scale-x-100")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
{/* The rest of the edge takes a click too and names the
|
||||
list; the keyboard has the button above. */}
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
onClick={expandDocked}
|
||||
className="hover:bg-sidebar-accent text-muted-foreground flex min-h-0 flex-1 cursor-pointer flex-col items-center justify-start pt-4 text-xs transition-colors"
|
||||
>
|
||||
<span className="[writing-mode:vertical-rl]">
|
||||
{t("insights.coach.historyTitle")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</ShellSidePanel>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* New conversation, as the round button in the conversation's own window:
|
||||
* the floating Coach button's size and look, with a plus.
|
||||
*
|
||||
* It sits in the composer's row, so it belongs to the conversation and never
|
||||
* to the list beside it, and it moves wherever that column goes (a docked
|
||||
* list or day narrows the column, the button stays inside it). Where the
|
||||
* column is wide enough, it takes the bottom right corner beside the
|
||||
* composer, where the Coach button sits on other pages; where the composer
|
||||
* fills the width (a phone, the drawer, a narrow column), it rises just
|
||||
* above the composer's top edge so it never covers the field.
|
||||
*
|
||||
* The parent is the composer's wrapper and must be `relative` and an
|
||||
* `@container/composer`.
|
||||
*/
|
||||
export function NewChatFab({
|
||||
onNewChat,
|
||||
className,
|
||||
}: {
|
||||
onNewChat: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const label = t("insights.coach.newConversation");
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
data-slot="coach-new-chat-fab"
|
||||
// Steps aside while the on-screen keyboard is open (globals.css).
|
||||
data-keyboard-hide=""
|
||||
onClick={onNewChat}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={cn(
|
||||
"absolute right-4 bottom-full z-10 mb-3 size-14 rounded-full shadow-lg sm:right-6",
|
||||
// Beside the capped composer, in the column's bottom right corner.
|
||||
"@min-[54rem]/composer:right-8 @min-[54rem]/composer:bottom-8 @min-[54rem]/composer:mb-0",
|
||||
// The Coach button's face: a dark glyph on the brand gradient.
|
||||
"from-primary to-brand-pink text-background bg-gradient-to-br",
|
||||
"hover:from-primary/90 hover:to-brand-pink/90",
|
||||
"focus-visible:ring-offset-background focus-visible:ring-offset-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Plus className="text-background size-6" aria-hidden="true" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Sparkles, Target, X } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.21.2 (A2 + A3) — the Coach's visible scope/opener affordance.
|
||||
*
|
||||
* Two modes, one surface:
|
||||
*
|
||||
* - **scope** (A2): the Coach was launched narrowed to a metric (from a
|
||||
* metric page or an insight card). Instead of the old hidden composer
|
||||
* prefill, render a small pill — "The Coach is already on <metric>" —
|
||||
* plus a tappable seed question that drops the data-aware opener into
|
||||
* the composer / sends it. This turns the built-but-invisible scope
|
||||
* plumbing into the core ambient feeling.
|
||||
*
|
||||
* - **seeded** (A3): the Coach opened UNSCOPED (the blank hero) and the
|
||||
* server surfaced today's single most notable derived signal. Render it
|
||||
* as a tappable suggested opener so the blank box is never cold. When no
|
||||
* signal crosses the gate the caller renders nothing and the neutral
|
||||
* greeting stands — there is no fake opener.
|
||||
*
|
||||
* Presentational + self-contained: the parent supplies the resolved label
|
||||
* and the seed question, and an `onSeed` callback wired to the composer.
|
||||
* The badge owns no data fetching.
|
||||
*/
|
||||
export interface ScopeHintBadgeProps {
|
||||
/**
|
||||
* Which affordance this is. `scope` shows the "already on <metric>" pill;
|
||||
* `seeded` shows the notable-signal opener. Drives the icon + the prefix
|
||||
* copy only — the tappable seed question is shared.
|
||||
*/
|
||||
variant: "scope" | "seeded";
|
||||
/**
|
||||
* The visible metric / signal label, already resolved to the user's
|
||||
* locale by the caller (e.g. "blood pressure", "readiness").
|
||||
*/
|
||||
label: string;
|
||||
/** The tappable seed question — the data-aware opener. */
|
||||
question: string;
|
||||
/** Fired with the seed question when the user taps the opener. */
|
||||
onSeed: (question: string) => void;
|
||||
/**
|
||||
* Dismiss the opener. Only honoured for `variant="seeded"` — the seeded
|
||||
* opener is a soft suggestion the user can wave off for the day, while the
|
||||
* A2 scope pill reflects how the chat was launched and is not dismissable.
|
||||
*/
|
||||
onDismiss?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ScopeHintBadge({
|
||||
variant,
|
||||
label,
|
||||
question,
|
||||
onSeed,
|
||||
onDismiss,
|
||||
className,
|
||||
}: ScopeHintBadgeProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
const Icon = variant === "scope" ? Target : Sparkles;
|
||||
const prefix =
|
||||
variant === "scope"
|
||||
? t("insights.coach.scope.prefix", { metric: label })
|
||||
: t("insights.coach.seeded.prefix", { signal: label });
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="coach-scope-hint"
|
||||
data-variant={variant}
|
||||
className={cn("flex w-full flex-col items-center gap-2.5", className)}
|
||||
>
|
||||
{/* The scope/signal pill — the visible "already on …" line. */}
|
||||
<span
|
||||
data-slot="coach-scope-hint-pill"
|
||||
className={cn(
|
||||
"border-border/60 bg-muted/40 text-muted-foreground",
|
||||
"inline-flex max-w-full items-center gap-1.5 rounded-full border px-3 py-1",
|
||||
"text-xs font-medium",
|
||||
)}
|
||||
>
|
||||
<Icon className="text-primary size-3.5 shrink-0" aria-hidden="true" />
|
||||
<span className="truncate">{prefix}</span>
|
||||
</span>
|
||||
|
||||
{/* The tappable seed question — the data-aware opener. A real button so
|
||||
keyboard + screen-reader users get the same affordance. v1.25.0 — the
|
||||
opener sizes to its TEXT (intrinsic width, centred) instead of being
|
||||
stretched to the full composer width, which left a large empty tail
|
||||
that read as broken. It is guaranteed single-line: the question
|
||||
truncates with an ellipsis on a narrow phone rather than wrapping or
|
||||
forcing a horizontal scroll. The seeded variant keeps the dismiss
|
||||
control at the trailing edge. */}
|
||||
<div className="flex w-full items-center justify-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
data-slot="coach-scope-hint-seed"
|
||||
onClick={() => onSeed(question)}
|
||||
className={cn(
|
||||
"group border-border/70 bg-background hover:bg-muted/50 text-foreground",
|
||||
"inline-flex max-w-full min-w-0 items-center gap-2 rounded-xl border px-3.5 py-2.5 text-left",
|
||||
"transition-colors",
|
||||
"focus-visible:ring-input-focus focus-visible:ring-2 focus-visible:outline-none",
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 truncate text-sm leading-snug">
|
||||
{question}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="text-muted-foreground group-hover:text-foreground shrink-0 text-xs whitespace-nowrap"
|
||||
>
|
||||
{t("insights.coach.scope.tap")}
|
||||
</span>
|
||||
</button>
|
||||
{variant === "seeded" && onDismiss ? (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="coach-scope-hint-dismiss"
|
||||
onClick={onDismiss}
|
||||
aria-label={t("insights.coach.seeded.dismiss")}
|
||||
title={t("insights.coach.seeded.dismiss")}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:bg-muted/60 hover:text-foreground shrink-0",
|
||||
"flex size-9 items-center justify-center rounded-xl transition-colors",
|
||||
"focus-visible:ring-input-focus focus-visible:ring-2 focus-visible:outline-none",
|
||||
)}
|
||||
>
|
||||
<X className="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -132,6 +132,10 @@ export function MoodFactorMetricCrosstab({
|
||||
const metricLabel = t(
|
||||
METRIC_LABEL_KEY[row.metricKey] ?? row.metricKey,
|
||||
);
|
||||
const pairLabel = t("insights.mood.factorCrosstab.pairLabel", {
|
||||
factor: factorLabel,
|
||||
metric: metricLabel,
|
||||
});
|
||||
// v1.32.26 — the weight ("kg") row follows the metric/imperial
|
||||
// preference: the unit symbol + the values (averages + delta, all in
|
||||
// kg, offset-free) convert together. Every other display is unitless
|
||||
@@ -168,12 +172,9 @@ export function MoodFactorMetricCrosstab({
|
||||
)}
|
||||
<span
|
||||
className="text-foreground min-w-0 flex-1 truncate"
|
||||
title={`${factorLabel} · ${metricLabel}`}
|
||||
title={pairLabel}
|
||||
>
|
||||
{t("insights.mood.factorCrosstab.pairLabel", {
|
||||
factor: factorLabel,
|
||||
metric: metricLabel,
|
||||
})}
|
||||
{pairLabel}
|
||||
</span>
|
||||
<span className="text-foreground shrink-0 text-sm font-semibold tabular-nums">
|
||||
{deltaText}
|
||||
|
||||
@@ -139,6 +139,10 @@ export function MoodTagMetricCrosstab({
|
||||
const metricLabel = t(
|
||||
METRIC_LABEL_KEY[row.metricKey] ?? row.metricKey,
|
||||
);
|
||||
const pairLabel = t("insights.mood.crosstab.pairLabel", {
|
||||
tag: tagLabel,
|
||||
metric: metricLabel,
|
||||
});
|
||||
const unit = t(UNIT_KEY[row.display]);
|
||||
const up = row.delta >= 0;
|
||||
const deltaText = `${up ? "+" : ""}${formatValue(row.delta, row.display, nf)} ${unit}`;
|
||||
@@ -160,12 +164,9 @@ export function MoodTagMetricCrosstab({
|
||||
)}
|
||||
<span
|
||||
className="text-foreground min-w-0 flex-1 truncate"
|
||||
title={`${tagLabel} · ${metricLabel}`}
|
||||
title={pairLabel}
|
||||
>
|
||||
{t("insights.mood.crosstab.pairLabel", {
|
||||
tag: tagLabel,
|
||||
metric: metricLabel,
|
||||
})}
|
||||
{pairLabel}
|
||||
</span>
|
||||
<span className="text-foreground shrink-0 text-sm font-semibold tabular-nums">
|
||||
{deltaText}
|
||||
|
||||
@@ -265,7 +265,7 @@ describe("<MedicationCard> — open overdue slot (v1.16.4)", () => {
|
||||
client,
|
||||
);
|
||||
|
||||
expect(html).toContain("Overdue ·");
|
||||
expect(html).toContain("Overdue since");
|
||||
expect(html).toContain("can still be taken");
|
||||
expect(html).toContain("text-warning");
|
||||
expect(html).not.toContain("Next intake: Tomorrow,");
|
||||
@@ -291,7 +291,7 @@ describe("<MedicationCard> — open overdue slot (v1.16.4)", () => {
|
||||
|
||||
expect(html).toContain("Next intake:");
|
||||
expect(html).toContain("Tomorrow");
|
||||
expect(html).not.toContain("Overdue ·");
|
||||
expect(html).not.toContain("Overdue since");
|
||||
expect(html).not.toContain("can still be taken");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,7 +34,6 @@ interface CoachPrefsShape {
|
||||
nudgeVitals: boolean;
|
||||
nudgeRoutine: boolean;
|
||||
nudgeFrequency: "weekly" | "biweekly";
|
||||
ambientSuggestions: boolean;
|
||||
nudgeAiComposed: boolean;
|
||||
}
|
||||
|
||||
@@ -51,7 +50,6 @@ const COACH_PREF_DEFAULTS: CoachPrefsShape = {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
};
|
||||
|
||||
@@ -306,28 +304,6 @@ export function CoachNudgeCard({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* v1.25.0 — proactive ambient SUGGESTIONS opt-out. Independent of the
|
||||
push-nudge master switch above (a user can keep nudges but silence
|
||||
the example prompts), so it sits outside the `enabled` gate and rides
|
||||
the same `coach` prefs blob. */}
|
||||
<div className="border-border/60 flex min-h-11 items-center justify-between gap-3 border-t pt-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm">
|
||||
{t("notifications.coachNudge.suggestionsLabel")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("notifications.coachNudge.suggestionsDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={resolved.ambientSuggestions}
|
||||
onCheckedChange={(next) =>
|
||||
patchCoach({ ambientSuggestions: next }, null)
|
||||
}
|
||||
disabled={!isAuthenticated || saving}
|
||||
aria-label={t("notifications.coachNudge.suggestionsAria")}
|
||||
/>
|
||||
</div>
|
||||
{msg && (
|
||||
<p
|
||||
role="status"
|
||||
|
||||
@@ -401,9 +401,14 @@ export function EnvironmentSection() {
|
||||
key={tr.id}
|
||||
className="flex items-center justify-between gap-2 p-2"
|
||||
>
|
||||
<span className="text-sm">
|
||||
{tr.label} · {fmt.dateShortSmartCalendar(tr.startDate)} –{" "}
|
||||
{fmt.dateShortSmartCalendar(tr.endDate)}
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm">{tr.label}</span>
|
||||
<span className="text-muted-foreground block text-xs">
|
||||
{t("settings.sections.environment.travel.range", {
|
||||
start: fmt.dateShortSmartCalendar(tr.startDate),
|
||||
end: fmt.dateShortSmartCalendar(tr.endDate),
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
<ConfirmButton
|
||||
slot="travel-remove"
|
||||
|
||||
@@ -513,7 +513,9 @@ describe.each<Locale>(["de", "en"])("the day view in %s", (locale) => {
|
||||
});
|
||||
|
||||
it("words every code under Running that day", () => {
|
||||
const text = textOf(render(<DayRunning items={everyRunning()} />, locale));
|
||||
const text = textOf(
|
||||
render(<DayRunning items={everyRunning()} date="2026-03-12" />, locale),
|
||||
);
|
||||
expect(rawCodes(text)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -315,7 +315,7 @@ function resolveOrder(
|
||||
* Map the app locale → the date-fns locale so the calendar's month / weekday
|
||||
* names match the UI language. Defaults to enUS for anything unmapped.
|
||||
*/
|
||||
function resolveDateFnsLocale(
|
||||
export function resolveDateFnsLocale(
|
||||
locale: ReturnType<typeof useTranslations>["locale"],
|
||||
): DateFnsLocale {
|
||||
switch (locale) {
|
||||
|
||||
@@ -79,7 +79,7 @@ describe("the list", () => {
|
||||
);
|
||||
expect(html).toContain('data-antigen="typhoid"');
|
||||
expect(html).toContain('data-antigen="hepatitis-a"');
|
||||
expect(html.match(/· Travel combo/g)).toHaveLength(2);
|
||||
expect(html.match(/>Travel combo</g)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ export function VaccinationDocumentSuggestion({
|
||||
: (candidate.vaccineName ?? t("vaccinations.suggestion.unnamed"));
|
||||
// A date stored at UTC midnight: render the calendar date, not the
|
||||
// instant (the previous evening west of UTC).
|
||||
return `${name} · ${formatCalendarDate(candidate.occurredAt.slice(0, 10), dateFormat, locale)}`;
|
||||
return `${name}, ${formatCalendarDate(candidate.occurredAt.slice(0, 10), dateFormat, locale)}`;
|
||||
};
|
||||
|
||||
const onPick = (candidate: VaccinationSuggestion) => {
|
||||
|
||||
@@ -209,7 +209,7 @@ function DoseRow({
|
||||
</span>
|
||||
) : null}
|
||||
{showIdentity && identity ? (
|
||||
<span className="text-muted-foreground">· {identity}</span>
|
||||
<span className="text-muted-foreground">{identity}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs">
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
|
||||
/**
|
||||
* v1.25.0 — the single client-side gate for proactive ambient Coach
|
||||
* SUGGESTIONS (today: the daily seeded example opener on the Coach hero).
|
||||
*
|
||||
* Reads the per-user opt-out from the shared notification-prefs blob
|
||||
* (`coach.ambientSuggestions`). Default ON: an unauthenticated read, a
|
||||
* still-loading query, or a missing field all resolve to `true` so the
|
||||
* suggestions show out of the box and only hide once the user explicitly turns
|
||||
* them off in Settings. Shares the `authNotificationPrefs` query key with the
|
||||
* settings card, so toggling the switch there flips this read in lockstep.
|
||||
*/
|
||||
interface AmbientSuggestionsPrefsShape {
|
||||
coach?: { ambientSuggestions?: boolean };
|
||||
}
|
||||
|
||||
export function useCoachAmbientSuggestionsEnabled(): boolean {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKeys.authNotificationPrefs(),
|
||||
queryFn: async () =>
|
||||
apiGet<AmbientSuggestionsPrefsShape>("/api/auth/me/notification-prefs"),
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
return data?.coach?.ambientSuggestions ?? true;
|
||||
}
|
||||
@@ -757,7 +757,7 @@ describe("doctor-report surgical history section", () => {
|
||||
);
|
||||
const text = await extractText(bytes);
|
||||
expect(text).toContain("Operationen und Eingriffe");
|
||||
expect(text).toMatch(/Knee · Links/);
|
||||
expect(text).toMatch(/Knee \(Links\)/);
|
||||
});
|
||||
|
||||
it("omits the section when there is no procedure", async () => {
|
||||
@@ -819,7 +819,7 @@ describe("doctor-report condition body site", () => {
|
||||
it("prints the site with its side in the report's language", async () => {
|
||||
const text = await render(CONDITIONS, "de");
|
||||
expect(text).toContain("Körperstelle");
|
||||
expect(text).toMatch(/Knee · Links/);
|
||||
expect(text).toMatch(/Knee \(Links\)/);
|
||||
});
|
||||
|
||||
it("keeps the old table when no condition names a site", async () => {
|
||||
|
||||
@@ -100,7 +100,7 @@ describe("readCoachCorrelations", () => {
|
||||
pValue: 0.001,
|
||||
qValue: 0.02,
|
||||
interpretation:
|
||||
"Higher time in daylight tends to go with higher next-day sleep duration in your data — a pattern worth watching, not a cause.",
|
||||
"Higher time in daylight tends to go with higher next-day sleep duration in your data. It is a pattern to watch, not a cause.",
|
||||
lagDays: 1,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -345,7 +345,7 @@ describe("executeCoachTool", () => {
|
||||
lagDays: 1,
|
||||
n: 42,
|
||||
r: 0.31,
|
||||
note: "Higher time in daylight tends to go with higher next-day sleep duration in your data — a pattern worth watching, not a cause.",
|
||||
note: "Higher time in daylight tends to go with higher next-day sleep duration in your data. It is a pattern to watch, not a cause.",
|
||||
},
|
||||
],
|
||||
coincident: {
|
||||
|
||||
@@ -118,7 +118,7 @@ describe("formatRelationsBlock — cross-metric correlations, descriptive only",
|
||||
const rels: RelevantCorrelation[] = [
|
||||
{
|
||||
interpretation:
|
||||
"Higher time in daylight tends to go with higher next-day sleep duration in your data — a pattern worth watching, not a cause.",
|
||||
"Higher time in daylight tends to go with higher next-day sleep duration in your data. It is a pattern to watch, not a cause.",
|
||||
n: 42,
|
||||
r: 0.51,
|
||||
},
|
||||
|
||||
@@ -457,7 +457,7 @@ describe("generateMetricStatus — generation path", () => {
|
||||
vi.mocked(getRelevantCorrelationsForMetric).mockResolvedValueOnce([
|
||||
{
|
||||
interpretation:
|
||||
"Higher time in daylight tends to go with lower next-day resting heart rate in your data — a pattern worth watching, not a cause.",
|
||||
"Higher time in daylight tends to go with lower next-day resting heart rate in your data. It is a pattern to watch, not a cause.",
|
||||
n: 35,
|
||||
r: -0.46,
|
||||
},
|
||||
|
||||
@@ -157,8 +157,8 @@ describe("buildCoachReadStrip — the pattern line speaks the reader's language"
|
||||
// A sentence that is different but wrong is still a broken page.
|
||||
expect(note).toBe(
|
||||
"Wenn die Symptomstärke höher liegt, scheint die Schlafdauer am " +
|
||||
"Folgetag nach bisheriger Datenlage eher niedriger auszufallen — ein " +
|
||||
"Muster, das Beachtung verdient, keine Ursache.",
|
||||
"Folgetag nach bisheriger Datenlage eher niedriger auszufallen. Das ist " +
|
||||
"ein Muster, das Beachtung verdient, keine Ursache.",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -170,8 +170,8 @@ describe("buildCoachReadStrip — the pattern line speaks the reader's language"
|
||||
expect(strip.driver).not.toBeNull();
|
||||
expect(strip.driver!.note).toBe(
|
||||
"Higher symptom severity looks like it goes with lower next-day sleep " +
|
||||
"duration in your data, on the evidence so far — a pattern worth " +
|
||||
"watching, not a cause.",
|
||||
"duration in your data, on the evidence so far. It is a pattern " +
|
||||
"to watch, not a cause.",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -60,7 +60,6 @@ import {
|
||||
insightsGeneratePostResponse,
|
||||
coachReadQuery,
|
||||
coachReadStripResponse,
|
||||
coachSeededQuestionResponse,
|
||||
narrativeQuery,
|
||||
narrativeResponse,
|
||||
glp1TimelineQuery,
|
||||
@@ -399,28 +398,6 @@ export const insightsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/coach/seeded-question": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "Today's suggested Coach opener",
|
||||
description:
|
||||
"Resolves today's single most notable derived wellness signal into a tappable opener for the Coach's blank-chat hero, using the same confidence-gated detector the daily briefing uses. The selection happens SERVER-side and the client renders it rather than recomputing. `signal: null` is the ordinary answer and means the hero keeps its neutral greeting — never a fabricated opener. Two different causes produce that null and are indistinguishable on the wire: nothing crossed the notability gate, or the account has turned proactive suggestions off, in which case the detector does not run at all. The opener exists only to open the Coach, so it is served only while the `coach` AI capability is available; otherwise the answer is the same neutral `signal: null` and `ai` says why. Never refused for an AI reason. Shared analytics-read budget. Cookie or Bearer auth; not delegable.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The opener, or the neutral null.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
coachSeededQuestionResponse,
|
||||
"CoachSeededQuestionEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/narrative": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
|
||||
@@ -2747,38 +2747,6 @@ export const coachReadStripResponse = z
|
||||
"The two server-authoritative lines a metric sub-page renders above its chart. Pure compute over the baseline and correlation engines — no provider call, no cache table — so web and native decode the same resolved DTO.",
|
||||
});
|
||||
|
||||
// ── Coach seeded opener (`/api/insights/coach/seeded-question`) ───────
|
||||
|
||||
export const coachSeededQuestionResponse = z
|
||||
.object({
|
||||
signal: z
|
||||
.object({
|
||||
sourceMetric: z
|
||||
.string()
|
||||
.describe(
|
||||
"Sentinel id the client keys its localised copy on (`readiness` / `recovery`).",
|
||||
),
|
||||
score: z.number().describe("The latest 0..100 score."),
|
||||
band: z
|
||||
.string()
|
||||
.describe(
|
||||
"`yellow` or `red`. Green never surfaces — a good day is not notable.",
|
||||
),
|
||||
})
|
||||
.nullable()
|
||||
.describe(
|
||||
"Null whenever nothing crossed the detector's confidence and notability gate, AND whenever the account has turned proactive suggestions off, AND whenever the `coach` capability is unavailable (then `ai` says why) — all mean the hero keeps its neutral greeting rather than showing a fabricated opener.",
|
||||
),
|
||||
ai: aiCapabilityState.describe(
|
||||
"The `coach` capability the opener exists for.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "CoachSeededQuestion",
|
||||
description:
|
||||
"Today's single most notable derived wellness signal, resolved server-side into a tappable opener for the Coach's blank-chat hero. The client renders the resolved DTO and never recomputes the selection.",
|
||||
});
|
||||
|
||||
// ── Period narrative (`/api/insights/narrative`) ──────────────────────
|
||||
|
||||
export const narrativeQuery = z
|
||||
|
||||
@@ -76,12 +76,6 @@ export const coachKeys = {
|
||||
messageId,
|
||||
"trail",
|
||||
] as const,
|
||||
/**
|
||||
* v1.21.2 (A3) — today's most notable derived signal, resolved into the
|
||||
* Coach hero's pre-seeded relevance opener
|
||||
* (`GET /api/insights/coach/seeded-question`).
|
||||
*/
|
||||
coachSeededQuestion: () => ["coach-seeded-question"] as const,
|
||||
/**
|
||||
* v1.22 (B2/B6) — the user's Coach episodic reminders (`GET /api/coach/
|
||||
* reminders`). The optional `status` arg keys the in-app tile read
|
||||
|
||||
@@ -109,7 +109,6 @@ describe("parseNotificationPrefs", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
@@ -134,7 +133,6 @@ describe("parseNotificationPrefs", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
@@ -161,7 +159,6 @@ describe("parseNotificationPrefs", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
@@ -204,7 +201,6 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
@@ -235,7 +231,6 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
@@ -263,7 +258,6 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
@@ -291,7 +285,6 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
@@ -320,7 +313,6 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
|
||||
@@ -125,14 +125,6 @@ const coachPrefsSchema = z
|
||||
nudgeRoutine: z.boolean(),
|
||||
/** v1.16.5 — frequency cap: one nudge per 7 or per 14 days. */
|
||||
nudgeFrequency: z.enum(["weekly", "biweekly"]),
|
||||
/**
|
||||
* v1.25.0 — opt-out for the proactive ambient SUGGESTIONS surfaced in the
|
||||
* UI (the daily seeded example opener on the Coach hero + the "try asking"
|
||||
* prompt chips). Default ON. Separate from `nudgesEnabled` (the push-nudge
|
||||
* master switch) so a user can keep the nudges but silence the example
|
||||
* prompts, or vice versa.
|
||||
*/
|
||||
ambientSuggestions: z.boolean(),
|
||||
/**
|
||||
* v1.25.0 — opt-IN for AI-composed nudge copy. Default OFF: the proactive
|
||||
* nudge ships a deterministic warm localized template, and that template is
|
||||
@@ -219,11 +211,6 @@ export interface NotificationPrefs {
|
||||
nudgeRoutine: boolean;
|
||||
/** v1.16.5 — cap interval: "weekly" (7 d) or "biweekly" (14 d). */
|
||||
nudgeFrequency: "weekly" | "biweekly";
|
||||
/**
|
||||
* v1.25.0 — proactive ambient example suggestions in the UI (seeded
|
||||
* opener + suggested-prompt chips). Default `true`.
|
||||
*/
|
||||
ambientSuggestions: boolean;
|
||||
/**
|
||||
* v1.25.0 — opt-in for AI-composed nudge copy. Default `false`; the
|
||||
* deterministic template stays the default + fail-closed fallback.
|
||||
@@ -285,7 +272,6 @@ export const DEFAULT_NOTIFICATION_PREFS: NotificationPrefs = {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
@@ -483,16 +469,6 @@ export interface CoachNudgePrefs {
|
||||
aiComposed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.25.0 — the single source every proactive ambient SUGGESTION surface
|
||||
* consults (the daily seeded example opener on the Coach hero + the
|
||||
* suggested-prompt chips). Default `true`; a null / drifted row resolves to
|
||||
* the documented default so the suggestions work out of the box.
|
||||
*/
|
||||
export function resolveCoachAmbientSuggestionsEnabled(raw: unknown): boolean {
|
||||
return parseNotificationPrefs(raw).coach.ambientSuggestions;
|
||||
}
|
||||
|
||||
export function resolveCoachNudgePrefs(raw: unknown): CoachNudgePrefs {
|
||||
const coach = parseNotificationPrefs(raw).coach;
|
||||
return {
|
||||
|
||||
Reference in new issue
Block a user