Merge pull request #730 from MBombeck/refactor/settings-admin-one-piece

Settings and admin, one piece
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-08 05:36:10 +02:00
commit 89a749b811
167 files changed
+3609 -2812

No files matched your search

+406
View File
@@ -0,0 +1,406 @@
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { settleBeforeMeasure } from "./utils/settle";
/**
* Settings + Admin read as one surface — measured, not eyeballed.
*
* Three claims, each with a number behind it:
*
* 1. **No jump on a route switch.** Every settings and admin route puts its
* heading at the SAME distance from the top of the scroll container, on
* both viewports. This is the U3 complaint stated as a measurement: the
* shells hand-rolled the heading, one page used `text-xs sm:text-sm` for
* the explainer and another `text-sm`, so the first card started at a
* different y depending on where you came from.
* 2. **One card rhythm.** Inside a settings/admin card, the header's bottom
* and the first body block's top sit exactly one gap apart — the card's
* own gap, `gap-4` under `md` and `gap-6` above it. Three call-site
* techniques used to produce three values here.
* 3. **One reading edge.** Every card body starts at the card's inner edge.
* The `pl-7` gutter put some bodies 28 px in and some at the edge, on the
* same page, one card apart.
*
* Runs in EN and DE, at desktop and phone widths. German runs 15-25% longer,
* and an explainer that wraps in German where English fits is the same defect
* the one-sentence rule exists to prevent — so at desktop width a wrap is a
* failure in either language. On a phone it is reported, not asserted: a
* 70-character sentence needs about 380 px of column at `text-sm`, so a
* 393 px viewport wraps most of them, and the alternative is the truncation
* this work removed.
*/
test.use({ storageState: STORAGE_STATE_PATH });
const SETTINGS_ROUTES = [
"/settings",
"/settings/account",
"/settings/security",
"/settings/notifications",
"/settings/integrations",
"/settings/ai",
"/settings/coach",
"/settings/modules",
"/settings/layout",
"/settings/thresholds",
"/settings/sources",
"/settings/environment",
"/settings/export",
"/settings/gesundheitsakte",
"/settings/privacy",
"/settings/api",
"/settings/mcp",
"/settings/advanced",
"/settings/about",
] as const;
/**
* The Layout hub's seven children. They are the routes whose header block
* changed MOST — the shell used to hand-roll a `space-y-6` between the
* back-link and the title, and `PageHeader`'s documented contract is
* `space-y-1.5`. Nothing measured them before, so the step went from 24 px
* to 6 px unobserved. 6 px is the standards' own number (§1's PageHeader
* row), so the measurement pins it rather than reverting it.
*/
const LAYOUT_CHILD_ROUTES = [
"/settings/layout/dashboard",
"/settings/layout/insights",
"/settings/layout/medications",
"/settings/layout/mood",
"/settings/layout/labs",
"/settings/layout/illness",
"/settings/layout/vorsorge",
] as const;
/** The `space-y-1.5` between a back-link and the title it sits above. */
const BACK_LINK_STEP = 6;
const ADMIN_ROUTES = [
"/admin",
"/admin/system-status",
"/admin/general",
"/admin/services",
"/admin/users",
"/admin/invites",
"/admin/integrations",
"/admin/coach",
"/admin/reminders",
"/admin/backups",
"/admin/encryption",
"/admin/app-logs",
"/admin/api-tokens",
"/admin/login-overview",
"/admin/danger-zone",
] as const;
const ROUTES = [...SETTINGS_ROUTES, ...LAYOUT_CHILD_ROUTES, ...ADMIN_ROUTES];
/**
* The card's own gap. `SettingsCard` inherits the `<Card>` contract —
* `gap-4` under `md`, `gap-6` from `md` up — so the expected value is a
* function of the viewport, not a constant. Asserting a constant here would
* have hidden exactly the defect this run found: the `as="section"` branch
* was writing a flat `gap-4` and sat one step tighter than its neighbour on
* every desktop width.
*/
const cardGapFor = (width: number) => (width >= 768 ? 24 : 16);
/** Sub-pixel and font-metric slack. */
const TOLERANCE = 1.5;
type HeaderGeometry = {
route: string;
/** `<h1>` top, relative to the scroll container's content top. */
headingTop: number;
/** Height of the whole title + explainer block. */
headerHeight: number;
/** Rendered line count of the explainer (1 = it stayed on one line). */
explainerLines: number;
explainerText: string;
/**
* Distance from a hub back-link's bottom to the heading's top, or `null`
* on a route that has no back-link. A route WITH one has a taller header
* by construction, so the two families are compared separately.
*/
backLinkStep: number | null;
};
async function measureHeader(
page: import("@playwright/test").Page,
route: string,
): Promise<HeaderGeometry | null> {
return page.evaluate(
({ route }) => {
const main = document.querySelector("main");
// On a desktop width the visible heading is the real `<h1>`; on a phone
// the shells paint it above the chip strip as
// `role="heading" aria-level="1"` and the `<h1>` in the grid row is
// display:none. Measure whichever one has a box — measuring the hidden
// copy reports a constant zero and calls it stability.
const heading = Array.from(
document.querySelectorAll<HTMLElement>(
'main h1, main [role="heading"][aria-level="1"]',
),
).find((el) => el.getBoundingClientRect().height > 0);
if (!main || !heading) return null;
const block = heading.parentElement!;
const explainer = block.querySelector("p");
const mainTop = main.getBoundingClientRect().top;
const headBox = heading.getBoundingClientRect();
const explainerBox = explainer?.getBoundingClientRect();
// The hub back-link rides above the title inside the same header — and
// like the heading it exists twice, because the shell paints the whole
// block for both breakpoints. `querySelector` returns the first in
// DOCUMENT order, which on a desktop width is the hidden mobile copy;
// take the one with a box, the same way the heading is chosen.
const backLink = Array.from(
document.querySelectorAll<HTMLElement>(
'main [data-slot="settings-hub-back-link"]',
),
).find((el) => el.getBoundingClientRect().height > 0);
const backLinkBox = backLink?.getBoundingClientRect();
const style = explainer ? getComputedStyle(explainer) : null;
const lineHeight = style ? parseFloat(style.lineHeight) : 0;
return {
route,
headingTop: headBox.top - mainTop,
// Heading top to explainer bottom. The parent block's own box is not
// a reliable proxy on a phone, where the heading is rendered outside
// the grid row it belongs to on desktop.
headerHeight: (explainerBox?.bottom ?? headBox.bottom) - headBox.top,
explainerLines:
lineHeight > 0 && explainerBox
? Math.round(explainerBox.height / lineHeight)
: 0,
explainerText: explainer?.textContent?.trim() ?? "",
backLinkStep:
backLinkBox && backLinkBox.height > 0
? headBox.top - backLinkBox.bottom
: null,
};
},
{ route },
);
}
/**
* Header-to-body gap and body left edge, per card on the page.
* Returns one entry per card that has both a header and a following sibling.
*/
async function measureCards(page: import("@playwright/test").Page) {
return page.evaluate(() => {
const out: { gap: number; bodyInset: number }[] = [];
for (const card of document.querySelectorAll('[data-slot="card"]')) {
const header = card.querySelector(":scope > header");
if (!header) continue;
const next = header.nextElementSibling as HTMLElement | null;
if (!next) continue;
if (next.getBoundingClientRect().height === 0) continue;
const cardBox = card.getBoundingClientRect();
const cardStyle = getComputedStyle(card);
const cardInnerLeft = cardBox.left + parseFloat(cardStyle.paddingLeft);
out.push({
gap:
next.getBoundingClientRect().top -
header.getBoundingClientRect().bottom,
bodyInset: next.getBoundingClientRect().left - cardInnerLeft,
});
}
return out;
});
}
for (const locale of ["en", "de"] as const) {
test.describe(`one piece — ${locale}`, () => {
test(`page headers land at one height across every route (${locale})`, async ({
page,
}) => {
test.setTimeout(420_000);
await page.context().addCookies([
{
name: "healthlog-locale",
value: locale,
url: process.env.E2E_BASE_URL ?? "http://localhost:3000",
},
]);
const measured: HeaderGeometry[] = [];
const skipped: string[] = [];
for (const route of ROUTES) {
const response = await page.goto(route, {
waitUntil: "domcontentloaded",
});
if (response && response.status() >= 400) continue;
// A hub route may client-redirect to its first section; wait for the
// heading and skip a route that legitimately has none rather than
// failing the sweep on it.
const heading = page
.locator(
'main h1:visible, main [role="heading"][aria-level="1"]:visible',
)
.first();
if (!(await heading.isVisible().catch(() => false))) {
await heading
.waitFor({ state: "visible", timeout: 8_000 })
.catch(() => {});
}
if (!(await heading.isVisible().catch(() => false))) {
skipped.push(route);
continue;
}
await settleBeforeMeasure(page, heading);
const geometry = await measureHeader(page, route);
if (geometry) measured.push(geometry);
}
console.log(
`[${locale}] routes without an <h1>: ${skipped.join(", ") || "none"}`,
);
// The sweep has to actually look at something.
expect(measured.length).toBeGreaterThan(32);
// Two families, compared separately: a route with a hub back-link
// carries an extra row above the title, so it is TALLER by
// construction and comparing it against a plain route would report
// that structure as drift.
const plain = measured.filter((m) => m.backLinkStep === null);
const withBackLink = measured.filter((m) => m.backLinkStep !== null);
expect(
withBackLink.length,
"the Layout children were meant to be measured",
).toBe(LAYOUT_CHILD_ROUTES.length);
const tops = plain.map((m) => m.headingTop);
const heights = plain.map((m) => m.headerHeight);
const spreadTop = Math.max(...tops) - Math.min(...tops);
const spreadHeight = Math.max(...heights) - Math.min(...heights);
const childTops = withBackLink.map((m) => m.headingTop);
const childSpreadTop = Math.max(...childTops) - Math.min(...childTops);
const steps = withBackLink.map((m) => m.backLinkStep!);
console.log(
`[${locale}] back-link routes: heading top ${Math.min(...childTops).toFixed(1)}…${Math.max(...childTops).toFixed(1)} ` +
`(spread ${childSpreadTop.toFixed(1)}px); back-link→title step ${[
...new Set(steps.map((v) => v.toFixed(1))),
].join(", ")}px`,
);
// Attached so a failure reports WHICH route moved, not just that one did.
console.log(
`[${locale}] heading top: ${Math.min(...tops).toFixed(1)}…${Math.max(...tops).toFixed(1)} (spread ${spreadTop.toFixed(1)}px)\n` +
`[${locale}] header height: ${Math.min(...heights).toFixed(1)}…${Math.max(...heights).toFixed(1)} (spread ${spreadHeight.toFixed(1)}px)\n` +
measured
.map(
(m) =>
` ${m.route.padEnd(32)} top ${m.headingTop.toFixed(1)} h ${m.headerHeight.toFixed(1)} lines ${m.explainerLines}`,
)
.join("\n"),
);
// A page explainer that wraps is a text problem, and it is the one the
// ≤120-character rule exists to prevent. Reported per locale.
const wrapped = measured.filter((m) => m.explainerLines > 1);
console.log(
`[${locale}] explainers wrapping past one line: ${wrapped.length}` +
wrapped.map((m) => `\n ${m.route}: ${m.explainerText}`).join(""),
);
// The no-jump claim, on every viewport: the title starts at the same y
// whichever route you came from.
expect(
spreadTop,
"heading top drifts between routes",
).toBeLessThanOrEqual(TOLERANCE);
expect(
childSpreadTop,
"heading top drifts between the Layout children",
).toBeLessThanOrEqual(TOLERANCE);
// The back-link step is `PageHeader`'s documented `space-y-1.5`. The
// shell used to hand-roll `space-y-6` here; 6 px is the standards'
// number, and this is what stops it drifting back to 24.
for (const measurement of withBackLink) {
expect(
Math.abs(measurement.backLinkStep! - BACK_LINK_STEP),
`${measurement.route}: back-link→title step is ${measurement.backLinkStep!.toFixed(1)}px, not ${BACK_LINK_STEP}px`,
).toBeLessThanOrEqual(TOLERANCE);
}
// The header's HEIGHT can only be constant where every explainer fits on
// one line, and on a 393 px phone most one-sentence explainers do not —
// a 70-character sentence at `text-sm` needs about 380 px of column. The
// honest rule there is that the block grows by whole lines of text, not
// that it never grows; forcing one height on a phone means truncating,
// which is the defect this work removed. So the height is pinned at the
// width where one line is achievable, and on a phone the wrap count is
// reported rather than asserted.
const isPhone = (page.viewportSize()?.width ?? 1280) < 768;
if (!isPhone) {
expect(
spreadHeight,
"header block height drifts between routes",
).toBeLessThanOrEqual(TOLERANCE);
expect(
wrapped.map((m) => `${m.route}: ${m.explainerText}`),
"a page explainer wraps to a second line at desktop width",
).toEqual([]);
} else {
// Every header is one or two lines of the same type scale — never a
// third, which would mean a subtitle nobody trimmed.
expect(
measured.filter((m) => m.explainerLines > 2).map((m) => m.route),
"a page explainer runs to three lines on a phone",
).toEqual([]);
}
});
test(`cards share one header gap and one reading edge (${locale})`, async ({
page,
}) => {
test.setTimeout(420_000);
await page.context().addCookies([
{
name: "healthlog-locale",
value: locale,
url: process.env.E2E_BASE_URL ?? "http://localhost:3000",
},
]);
const gaps: number[] = [];
const insets: number[] = [];
const expectedGap = cardGapFor(page.viewportSize()?.width ?? 1280);
for (const route of ROUTES) {
const response = await page.goto(route, {
waitUntil: "domcontentloaded",
});
if (response && response.status() >= 400) continue;
const heading = page
.locator(
'main h1:visible, main [role="heading"][aria-level="1"]:visible',
)
.first();
if (!(await heading.isVisible().catch(() => false))) {
await heading
.waitFor({ state: "visible", timeout: 8_000 })
.catch(() => {});
}
await settleBeforeMeasure(page, page.locator("main").first());
for (const card of await measureCards(page)) {
gaps.push(card.gap);
insets.push(card.bodyInset);
}
}
expect(gaps.length).toBeGreaterThan(30);
const offGap = gaps.filter((g) => Math.abs(g - expectedGap) > TOLERANCE);
const offEdge = insets.filter((i) => Math.abs(i) > TOLERANCE);
console.log(
`[${locale}] cards measured: ${gaps.length}; header→body ≠ ${expectedGap}px: ${offGap.length} (${[...new Set(offGap.map((g) => g.toFixed(1)))].join(", ")})\n` +
`[${locale}] bodies off the card edge: ${offEdge.length} (${[...new Set(offEdge.map((i) => i.toFixed(1)))].join(", ")})`,
);
expect(offGap, "header→body gap is not the card's own gap").toEqual([]);
expect(offEdge, "card body does not start at the card edge").toEqual([]);
});
});
}
+16 -4
View File
@@ -182,11 +182,13 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
fallback.locator('[data-testid="integration-status-pill"]'),
).toHaveAttribute("data-state", "stalled");
// Card anatomy is uniform now that Nightscout, the OAuth cards and Apple
// Health all carry the divider.
// Card anatomy is uniform because no integration card paints a rule under
// its header any more. The `<hr>` existed only in this folder — nine cards
// carrying a separator no other Settings card has — and the card's own gap
// does the separating now.
await expect(
page.locator('[data-testid="integration-card-divider"]'),
).toHaveCount(9);
).toHaveCount(0);
// Withings is connected in this fixture; WHOOP is a BYO-keys card that
// stays dormant until an operator adds credentials, so assert the one
@@ -251,8 +253,18 @@ test.describe("Apple Health guidance", () => {
const card = page.getByTestId("apple-health-card");
await expect(card).toBeVisible();
// The product name is a proper noun and stays assertable; the sentence
// around it is not. The description SLOT is the stable target — this spec
// is about the card rendering its explainer, not about its wording, and
// the wording is pinned by `apple-health-card.test.tsx` against the same
// i18n bundle it renders from.
await expect(card).toContainText("HealthLog iOS app");
await expect(card).toContainText("not a web or OAuth connection");
const description = card.locator('[data-slot="settings-card-description"]');
await expect(description).toBeVisible();
await expect(description).not.toHaveText("");
// A raw key means the bundle lookup failed, which is the failure this
// assertion can actually see from a browser.
await expect(description).not.toContainText("settings.appleHealth");
await expect(page.getByTestId("apple-health-status")).toHaveAttribute(
"data-state",
"pending-setup",
+90 -93
View File
@@ -2984,7 +2984,8 @@
"valuesBack": "Zurück zu Insights",
"valuesBackToMetric": "Zurück zu {metric}",
"scoresBack": "Zurück zu Insights",
"ecgDescription": "Einkanal-EKG-Aufzeichnungen deines Geräts, jeweils mit dem Ergebnis des Geräts. HealthLog zeigt die Kurve und die Aufzeichnung – die Kurve wird nicht interpretiert."
"ecgDescription": "Einkanal-EKG-Aufzeichnungen deines Geräts, jeweils mit dem Ergebnis des Geräts.",
"ecgDisclaimer": "HealthLog zeigt die Kurve und die Aufzeichnung – die Kurve wird nicht interpretiert."
},
"units": {
"steps": "Schritte",
@@ -3971,12 +3972,13 @@
},
"pillOrder": {
"title": "Navigations-Pills sortieren",
"description": "Ziehe die Pills in die gewünschte Reihenfolge und blende sie über das Augensymbol ein oder aus. Ausgeblendete Detailseiten verschwinden auch aus der oberen Navigation.",
"description": "Ziehe die Pills in die gewünschte Reihenfolge und blende sie über das Augensymbol ein oder aus.",
"dragHandle": "Verschieben",
"saveSuccess": "Reihenfolge der Pills gespeichert.",
"saveError": "Speichern der Pill-Reihenfolge fehlgeschlagen.",
"save": "Speichern",
"empty": "Noch keine Navigations-Pills verfügbar."
"empty": "Noch keine Navigations-Pills verfügbar.",
"detail": "Ausgeblendete Detailseiten verschwinden auch aus der oberen Navigation."
},
"customize": "Insights anpassen",
"regenerateRateLimited": "Aktualisierungslimit erreicht — bitte später erneut versuchen.",
@@ -4458,7 +4460,7 @@
"settings": {
"researchMode": {
"sectionTitle": "Forschungsmodus",
"subtitle": "Optionale Anzeige einer geschätzten GLP-1-Wirkstoffkurve auf Basis der von der EMA veröffentlichten Populations-Pharmakokinetik. Nur zur Anschauung — niemals eine Messung oder ein medizinischer Rat.",
"subtitle": "Optionale Kurve des geschätzten GLP-1-Wirkstoffspiegels nach EMA-Daten.",
"toggleLabel": "Geschätzte Wirkstoffkurve anzeigen",
"disabledStatus": "Deaktiviert. Die Wirkstoffkurve ist ausgeblendet.",
"enabledStaleStatus": "Der Disclaimer wurde aktualisiert. Bestätige unten erneut, um die Kurve wieder anzuzeigen.",
@@ -4466,7 +4468,8 @@
"rePromptTitle": "Disclaimer aktualisiert",
"rePromptBody": "Der Disclaimer des Forschungsmodus wurde seit deiner letzten Bestätigung aktualisiert. Lies ihn erneut und bestätige, damit die Kurve sichtbar bleibt.",
"rePromptCta": "Disclaimer erneut bestätigen",
"disableError": "Forschungsmodus konnte nicht deaktiviert werden. Bitte erneut versuchen."
"disableError": "Forschungsmodus konnte nicht deaktiviert werden. Bitte erneut versuchen.",
"subtitleDetail": "Nur zur Anschauung — niemals eine Messung oder ein medizinischer Rat."
},
"shell": {
"sectionsNav": "Einstellungs-Bereiche",
@@ -4593,7 +4596,8 @@
},
"unavailableTitle": "In diesem geteilten Datensatz nicht verfügbar",
"unavailableDescription": "Dieser Einstellungsbereich ist beim Anzeigen des Datensatzes einer anderen Person bewusst nicht verfügbar.",
"integrationStatusDescription": "Verbindungsstatus für diesen verwalteten Datensatz. Verbindungssteuerungen sind nicht verfügbar."
"integrationStatusDescription": "Verbindungsstatus für diesen verwalteten Datensatz.",
"integrationStatusDetail": "Verbindungssteuerungen sind nicht verfügbar."
},
"sections": {
"account": {
@@ -4606,7 +4610,6 @@
"description": "Verbundene Dienste, Zustellkanäle und die Quellen-Priorität an einem Ort.",
"subtitle": "Datenquellen und Geräte verbinden und verwalten.",
"connectionsHeading": "Verbindungen",
"connectionsDescription": "Datenquellen und Geräte verbinden und verwalten.",
"channelsHeading": "Zustellkanäle",
"channelsDescription": "Wohin Erinnerungen und Hinweise zugestellt werden.",
"matrixLink": "Benachrichtigungen je Kanal"
@@ -4616,7 +4619,6 @@
"description": "Alle Erinnerungen an einem Ort — was wann erinnert wird und wohin es zugestellt wird.",
"subtitle": "Wähle, welche Erinnerungen du erhältst und wohin sie zugestellt werden.",
"remindersHeading": "Erinnerungen",
"remindersDescription": "Was du erhältst.",
"matrixLink": "Benachrichtigungen je Kanal",
"channelsLink": "Zustellkanäle"
},
@@ -4670,7 +4672,7 @@
"integrationsHint": "Neue Datenquellen verbindest oder trennst du auf der Integrationen-Seite.",
"integrationsHintLink": "Integrationen öffnen",
"cardTitle": "Quellen-Priorität pro Metrik",
"help": "Liefern mehrere Quellen denselben Wert am selben Tag, zählt die oberste Quelle als kanonisch. Die übrigen bleiben für die Audit-Spur gespeichert.",
"help": "Liefern mehrere Quellen denselben Wert am selben Tag, zählt die oberste Quelle als kanonisch.",
"resetDefaults": "Auf Standard zurücksetzen",
"resetDefaultsTitle": "Quellen-Reihenfolge zurücksetzen?",
"resetDefaultsBody": "Jede von dir angeordnete Rangfolge kehrt zur Standardreihenfolge zurück, für alle Messgrößen und Gerätetypen auf einmal. Das wird sofort gespeichert und lässt sich nicht wiederherstellen.",
@@ -4730,13 +4732,15 @@
"subtitle": "Lege fest, welche Quelle gewinnt, wenn zwei dieselbe Messgröße liefern.",
"nutrients": {
"title": "Synchronisierte Nährstoffzufuhr",
"description": "Tagessummen aus Apple Health der letzten {days} Tage. Nur lesend — die Quell-App besitzt die Werte.",
"description": "Tagessummen aus Apple Health der letzten {days} Tage.",
"empty": "Noch keine Nährstoffdaten synchronisiert.",
"latest": "{amount} {unit} am {date}",
"daysWithData": "{count} von {days} Tagen",
"moduleHint": "Die Synchronisierung steuert das Modul „Nährstoffzufuhr“.",
"moduleHintLink": "Module verwalten"
}
"moduleHintLink": "Module verwalten",
"detail": "Nur lesend — die Quell-App besitzt die Werte."
},
"helpDetail": "Die übrigen bleiben für die Audit-Spur gespeichert."
},
"ai": {
"title": "KI-Anbieter",
@@ -4781,13 +4785,15 @@
},
"fullBackup": {
"title": "Voll-Backup",
"description": "Deine Messwerte, Medikamente, Stimmung, Zyklus, Laborwerte, Krankheitsepisoden, Allergien, Familienanamnese, Nährstoffe, Workouts, dein Gesundheitsprofil und deine eigenen Messgrößen in einer JSON-Datei. Gleiche Form wie die wöchentliche automatische Sicherung. Einige Satzarten sind noch nicht enthalten — das Manifest der Datei nennt, was drinsteht.",
"description": "Deine gesamte Akte in einer JSON-Datei.",
"scopeNote": "Dokumentmetadaten und die KI-Zusammenfassung, falls vorhanden, sind als Klartext enthalten; die Originaldateien der Dokumente sowie GPS-Routen und Einzelmesswerte von Workouts sind es nicht — lade diese separat aus dem Dokumententresor bzw. dem Workout-Export herunter.",
"encryptToggle": "Mit einer Passphrase verschlüsseln",
"passphrasePlaceholder": "Passphrase (mind. 12 Zeichen)",
"passphraseHelp": "Leitet den Archivschlüssel per Argon2id ab und versiegelt ihn mit AES-256-GCM. Speichere die Datei mit der Endung .hlx.",
"noRecoveryWarning": "Es gibt keine Wiederherstellung — geht diese Passphrase verloren, lässt sich das Archiv nicht mehr öffnen. Bewahre sie sicher auf.",
"stepUpNeeded": "Bestätige deinen zweiten Faktor und versuche es erneut."
"stepUpNeeded": "Bestätige deinen zweiten Faktor und versuche es erneut.",
"detail": "Gleiche Form wie die wöchentliche automatische Sicherung. Einige Satzarten sind noch nicht enthalten — das Manifest der Datei nennt, was drinsteht.",
"contents": "Deine Messwerte, Medikamente, Stimmung, Zyklus, Laborwerte, Krankheitsepisoden, Allergien, Familienanamnese, Nährstoffe, Workouts, dein Gesundheitsprofil und deine eigenen Messgrößen in einer JSON-Datei."
}
},
"import": {
@@ -4841,7 +4847,7 @@
},
"csv": {
"title": "CSV-Datei",
"description": "Messwerte aus einer Tabelle oder einem Messgerät-Export importieren. Eine Zeile pro Wert.",
"description": "Messwerte aus einer Tabelle oder einem Messgerät-Export importieren.",
"pasteLabel": "CSV einfügen",
"fileInputLabel": "CSV-Datei zum Importieren auswählen",
"schemaHint": "Spalten: type, value, unit, measuredAt (mit Zeitzonen-Offset) sowie optional glucoseContext, notes, externalId.",
@@ -4881,11 +4887,12 @@
"reasonExternalIdTooLong": "Externe ID ist zu lang",
"reasonUnstableExternalId": "Externe ID ist über Exporte hinweg nicht stabil",
"reasonDuplicate": "bereits importiert",
"reasonUnknown": "übersprungen"
"reasonUnknown": "übersprungen",
"detail": "Eine Zeile pro Wert."
},
"doseHistory": {
"title": "Einnahmeverlauf",
"description": "Ein Einnahmeverlauf, wie ihn ein anderer Medikamenten-Tracker exportiert, direkt gelesen. Eine Datei deckt alle Medikamente darin ab.",
"description": "Ein Einnahmeverlauf, wie ihn ein anderer Medikamenten-Tracker exportiert, direkt gelesen.",
"pasteLabel": "Export einfügen",
"fileInputLabel": "Datei mit Einnahmeverlauf wählen",
"uploadFile": "Datei wählen",
@@ -4935,7 +4942,8 @@
"jsonNotAnArray": "Dieses JSON enthält keine Liste von Dosen.",
"jsonNoIntakeTime": "Der JSON-Export hält fest, wann eine Dosis fällig war, nicht wann sie genommen wurde, und eine geplante Zeit wird nicht als Einnahmezeit geschrieben. Stattdessen die CSV exportieren, die beides trägt.",
"tooManyRows": "Diese Datei enthält mehr Zeilen, als ein Import aufnehmen kann. Teilen und in Teilen importieren."
}
},
"detail": "Eine Datei deckt alle Medikamente darin ab."
}
},
"subtitle": "Exportiere deine Daten oder importiere aus einer Sicherung."
@@ -4986,7 +4994,7 @@
"error": "Konnte nicht gespeichert werden. Bitte erneut versuchen.",
"toggleable": {
"title": "Was du trackst",
"description": "Optionale Bereiche. Deaktivierte Module verschwinden aus Navigation, Kacheln und Auswertungen — deine Daten bleiben erhalten."
"description": "Deaktivierte Module verschwinden aus Navigation, Kacheln und Auswertungen — deine Daten bleiben erhalten."
},
"manageIn": "In {section} verwalten",
"operatorDisabled": "Serverweit deaktiviert",
@@ -5374,7 +5382,7 @@
"testReasonUnreachable": "Der KI-Anbieter war nicht erreichbar.",
"coachMemory": {
"title": "Woran sich der Coach erinnert",
"description": "Dauerhafte Angaben, die du dem Coach mitgeteilt hast. Er nutzt sie, damit seine Antworten relevant bleiben. Entferne alles, was er besser vergessen sollte.",
"description": "Dauerhafte Angaben, die du dem Coach mitgeteilt hast.",
"empty": "Der Coach hat noch keine Angaben gespeichert. Im Gespräch merkt er sich dauerhafte Details, die du teilst – deine Ziele, Beschwerden, Vorlieben – und du kannst sie hier jederzeit löschen.",
"learnedPrefix": "gelernt {when}",
"categoryPreference": "Vorlieben",
@@ -5397,11 +5405,12 @@
"forgotToast": "Angabe vergessen.",
"forgotError": "Konnte diese Angabe nicht vergessen. Bitte erneut versuchen.",
"clearedToast": "{count} gespeicherte Angaben vergessen.",
"clearError": "Konnte die gespeicherten Angaben nicht löschen. Bitte erneut versuchen."
"clearError": "Konnte die gespeicherten Angaben nicht löschen. Bitte erneut versuchen.",
"detail": "Er nutzt sie, damit seine Antworten relevant bleiben. Entferne alles, was er besser vergessen sollte."
},
"coachReminders": {
"title": "Deine Erinnerungen",
"description": "Dinge, um die du den Coach gebeten hast, später darauf zurückzukommen. Fällige erscheinen hier und beim Coach.",
"description": "Dinge, um die du den Coach gebeten hast, später darauf zurückzukommen.",
"empty": "Noch keine Erinnerungen. Bitte den Coach, dich an etwas zu erinnern — dann erscheint es hier.",
"loadError": "Konnte deine Erinnerungen nicht laden.",
"dueHeading": "Jetzt fällig",
@@ -5422,7 +5431,8 @@
},
"deleteConfirmTitle": "Diese Erinnerung löschen?",
"deleteConfirmBody": "Sie wird nicht mehr ausgelöst. An bereits erfassten Daten ändert sich nichts.",
"deleteConfirmAction": "Löschen"
"deleteConfirmAction": "Löschen",
"detail": "Fällige erscheinen hier und beim Coach."
},
"aboutMe": {
"title": "Über mich",
@@ -5596,7 +5606,6 @@
"googleHealthTag": "Fitbit & Pixel",
"googleHealthBetaBadge": "Beta",
"googleHealthDescription": "Verbinde Fitbit, Pixel Watch und Fitbit Air über dein Google-Konto — Gewicht, Herzfrequenz, Schlaf und Aktivität. Das ersetzt die klassische Fitbit-Verbindung, die Google im September 2026 abschaltet.",
"googleHealthDescriptionSecondary": "Du registrierst deinen eigenen Google-Cloud-Client und hinterlegst die Zugangsdaten hier. Stress und Erholung liefert die API nicht, und Google kann dich von Zeit zu Zeit erneut um Zustimmung bitten. Neu bei der Einrichtung? Folge der",
"googleHealthCredentials": "API-Zugangsdaten",
"googleHealthClientId": "Client-ID",
"googleHealthClientSecret": "Client-Secret",
@@ -5696,7 +5705,7 @@
"workouts": "Workouts"
},
"integrationFallback": {
"description": "Für dieses Konto eingerichtet, hat aber keine eigene Einstellungskarte. Diese Zeile sorgt dafür, dass eine Verbindung nicht unbemerkt ausfallen kann.",
"description": "Für dieses Konto eingerichtet, hat aber keine eigene Einstellungskarte.",
"lastAttempt": "Letzter Versuch {relative}",
"noAttempt": "Kein Sync-Versuch aufgezeichnet"
},
@@ -5705,7 +5714,7 @@
"fitbitViewData": "Schlaf ansehen",
"googleHealthViewData": "Schritte ansehen",
"apiTokens": "API-Tokens",
"apiTokensDescription": "API-Tokens für die externe Medikamenten-Einnahme prüfen und widerrufen. Tokens werden pro Medikament über dessen API-Endpoint-Schalter ausgestellt.",
"apiTokensDescription": "API-Tokens für die externe Medikamenten-Einnahme prüfen und widerrufen.",
"tokenMintMovedDescription": "Tokens werden pro Medikament ausgestellt. Öffne ein Medikament und aktiviere dessen API-Endpoint, um ein Token zu erhalten, das nur für dieses Medikament gilt.",
"tokenNamePlaceholder": "Token-Name (z.B. iPhone Shortcut)",
"tokenCreated": "Token erstellt — kopiere ihn jetzt! Er wird nicht erneut angezeigt.",
@@ -5736,14 +5745,14 @@
"apiEndpointAuth": "Authentifizierung",
"apiEndpointExample": "Body-Beispiel",
"dangerZone": "Alle Daten löschen",
"dangerZoneDescription": "Löscht deine gesamte Gesundheitsakte und jede Integrationsverbindung. Dein Konto und deine Anmeldedaten bleiben erhalten.",
"dangerZoneDescription": "Löscht deine gesamte Gesundheitsakte und jede Integrationsverbindung.",
"dangerZoneConfirm": "Alle Daten löschen?",
"dangerZoneConfirmDescription": "Dieser Vorgang löscht unwiderruflich alles in deiner Akte: Messwerte, Medikamente, Stimmungseinträge, Laborwerte, Zykluseinträge, Dokumente, KI-Gespräche und jede Integrationsverbindung, dazu alles daraus Abgeleitete und alles, was du früher gelöscht hast. Freigabe-Links, die du an eine Praxis gegeben hast, funktionieren sofort nicht mehr. Dein Konto bleibt bestehen, ebenso deine Anmeldedaten und Anzeigeeinstellungen wie Sprache, Zeitzone und Einheiten. Das lässt sich nicht rückgängig machen.",
"dangerZoneSuccess": "Deine Gesundheitsakte wurde gelöscht. Dein Konto und deine Anmeldung bleiben unverändert.",
"dangerZoneDeleteFailed": "Löschen fehlgeschlagen",
"finalDelete": "Endgültig löschen",
"deleteAccountCardTitle": "Konto vollständig löschen",
"deleteAccountCardDescription": "Löscht dein Konto inklusive Passkeys, Audit-Log und Sessions. Dieser Vorgang kann nicht rückgängig gemacht werden.",
"deleteAccountCardDescription": "Löscht dein Konto inklusive Passkeys, Audit-Log und Sessions.",
"deleteAccountCta": "Konto löschen",
"deleteAccountConfirmTitle": "Konto endgültig löschen?",
"deleteAccountConfirmDescription": "Dein Konto, alle Gesundheitsdaten, Passkeys, Sessions und Audit-Einträge werden unwiderruflich gelöscht. Du wirst danach automatisch abgemeldet.",
@@ -5761,8 +5770,6 @@
"changelog": "Changelog",
"docs": "Dokumentation",
"linksHeading": "Quellen & Dokumentation",
"identityDescription": "Version, Lizenz und Build-Details dieser Instanz.",
"linksDescription": "Quellcode, Änderungsprotokoll und Dokumentation.",
"newerAvailable": "Neue Version: {tag}"
},
"testConnection": {
@@ -5790,7 +5797,7 @@
"notificationStatus": {
"title": "Zustellstatus",
"description": "Pro Kanal aktueller Status; \"Automatisch deaktiviert\" stoppt nach dauerhaften Fehlern.",
"emptyDescription": "Noch keine Kanäle konfiguriert. Richte unter Zustellkanäle einen Kanal ein, um die Zustellung zu verfolgen.",
"emptyDescription": "Richte unter Einstellungen › Integrationen › Zustellkanäle einen Kanal ein.",
"stateActive": "Aktiv",
"stateAutoDisabled": "Automatisch deaktiviert",
"stateSendingPaused": "Versand pausiert",
@@ -5801,7 +5808,8 @@
"disabledReason": "Grund",
"nextRetry": "Nächster Versuch",
"reEnable": "Wieder aktivieren",
"sendTest": "Test senden"
"sendTest": "Test senden",
"emptyTitle": "Noch keine Kanäle konfiguriert"
},
"identity": {
"description": "Optionale Angaben für die Gesundheitsakte (PDF-Deckblatt + FHIR-Export). Alle Felder sind freiwillig.",
@@ -5814,7 +5822,7 @@
},
"healthRecord": {
"title": "Gesundheitsakte exportieren",
"description": "Erstelle eine vollständige Gesundheitsakte für die ärztliche Vorlage — als PDF, strukturiert (FHIR) oder als kombiniertes Paket.",
"description": "Eine vollständige Akte für die ärztliche Vorlage — als PDF, als FHIR oder beides.",
"format": "Format",
"format_pdf": "PDF",
"format_fhir": "Strukturiert (FHIR)",
@@ -5839,10 +5847,10 @@
"errorModuleDisabled": "Der Gesundheitsakten-Export ist für dieses Konto ausgeschaltet."
},
"globalExcludedInjectionSitesLabel": "Global ausgeschlossene Injektionsstellen",
"globalExcludedInjectionSitesHint": "Hier gelistete Stellen werden für keine Medikation angeboten und beim Erfassen einer Dosis abgelehnt — auch wenn eine Medikation sie bevorzugt.",
"globalExcludedInjectionSitesHint": "Nie angeboten und beim Erfassen abgelehnt, auch wenn eine Medikation sie bevorzugt.",
"sharing": {
"createTitle": "Neuer Freigabe-Link",
"createDescription": "Der Link ist schreibgeschützt und zeitlich begrenzt. Der Token wird nur einmal bei der Erstellung angezeigt — sichere ihn dann; er lässt sich nicht wiederherstellen.",
"createDescription": "Der Link ist schreibgeschützt und zeitlich begrenzt.",
"label": "Bezeichnung",
"labelPlaceholder": "z. B. Dr. Schmidt — Jahres-Check-up",
"range": "Tage Verlauf",
@@ -5892,15 +5900,17 @@
"scopeInsuranceExcluded": "Versichertendaten werden über einen Link nie geteilt.",
"statusNeedsReselect": "Inhalt neu wählen",
"reselectReason": "Dieser Link lieferte einen Umfang, den niemand gewählt hatte, und wurde deshalb geschlossen. Legen Sie einen neuen an und wählen Sie den Inhalt.",
"reselectAction": "Ersatz anlegen"
"reselectAction": "Ersatz anlegen",
"createDetail": "Der Token wird nur einmal bei der Erstellung angezeigt — sichere ihn dann; er lässt sich nicht wiederherstellen."
},
"genderOther": "Divers",
"cycleTracking": {
"title": "Zyklus-Tracking",
"description": "Verfolge deinen Menstruationszyklus, Vorhersagen, Symptome und Phasen-Insights. Standardmäßig an für als weiblich eingestellte Konten; für alle verfügbar.",
"description": "Verfolge deinen Menstruationszyklus, Vorhersagen, Symptome und Phasen-Insights.",
"enable": "Zyklus-Tracking aktivieren",
"manage": "Zyklus-Tracking öffnen",
"error": "Zyklus-Tracking konnte nicht aktualisiert werden."
"error": "Zyklus-Tracking konnte nicht aktualisiert werden.",
"detail": "Standardmäßig an für als weiblich eingestellte Konten; für alle verfügbar."
},
"nightscout": "Nightscout",
"nightscoutTag": "CGM",
@@ -5986,20 +5996,13 @@
"coach": {
"activate": {
"title": "Coach aktivieren",
"description": "Zeigt den Coach-Button und das Coach-Panel. Standardmäßig an — ausschalten, um den Coach überall auszublenden.",
"description": "Zeigt den Coach-Button und das Coach-Panel.",
"toggleAria": "Coach-Button und Coach-Panel aktivieren",
"savedOn": "Coach ist aktiviert.",
"savedOff": "Coach ist ausgeblendet.",
"saveError": "Speichern fehlgeschlagen. Bitte erneut versuchen."
}
},
"withingsDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der",
"whoopDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der",
"fitbitDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der",
"polarDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der",
"ouraDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der",
"nightscoutDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der",
"integrationCredentialsHint": "Wo du diese Zugangsdaten findest und wie du deine eigene App registrierst, steht in der Einrichtungsanleitung oben.",
"integrationRedirectGuide": {
"intro": "Die Verbindung läuft über deine eigene {provider}-Entwickler-App – etwa 5 Minuten Einrichtung:",
"step1": "Erstelle eine App im {provider}-Entwicklerportal.",
@@ -6113,19 +6116,13 @@
"signOutEverywhereConfirmBody": "Jedes Gerät außer diesem muss sich neu anmelden. Dieses Gerät bleibt angemeldet. An deinen erfassten Daten ändert sich nichts."
},
"privacy": {
"intro": {
"title": "Deine Daten bleiben deine",
"body": "HealthLog wird selbst gehostet. Diese Seite fasst zusammen, wie deine Daten geschützt werden, wie lange sie gespeichert bleiben, wie du sie exportierst und wie du sie löschst."
},
"encryption": {
"title": "Verschlüsselt gespeichert",
"description": "Sensible Felder werden in der Datenbank verschlüsselt.",
"summary": "Deine sensiblen Daten werden mit {algorithm} verschlüsselt gespeichert – über {columns} Felder in {models} Datensatztypen.",
"statement": "Deine sensiblen Daten werden mit AES-256-GCM verschlüsselt gespeichert."
},
"stored": {
"title": "Was gespeichert wird",
"description": "Die Datenkategorien, die dieses Konto enthält.",
"measurements": "Messwerte (Gewicht, Blutdruck, Puls, Glukose, Schlaf, Körperzusammensetzung)",
"medications": "Medikamente und Einnahmeverlauf",
"moodLabs": "Stimmungseinträge und Laborwerte",
@@ -6143,7 +6140,7 @@
},
"export": {
"title": "Meine Daten herunterladen",
"description": "Exportiere deine Datensätze als CSV oder JSON, optional passphrasen-verschlüsselt, oder erstelle eine vollständige Gesundheitsakte.",
"description": "Deine Datensätze als CSV oder JSON — oder eine vollständige Akte für die Praxis.",
"openExport": "Export & Backup",
"openHealthRecord": "Gesundheitsakte"
},
@@ -6162,30 +6159,31 @@
},
"mcp": {
"enableTitle": "Remote-MCP-Endpunkt",
"enableDescription": "Erlaube einem MCP-fähigen Assistenten (Claude.ai, ChatGPT), deine Gesundheitsdaten über ein begrenztes Token zu lesen. Standardmäßig aus.",
"enableDescription": "Erlaube einem MCP-fähigen Assistenten (Claude.ai, ChatGPT), deine Gesundheitsdaten über ein begrenztes Token zu lesen.",
"enabled": "Aktiv",
"disabled": "Inaktiv",
"enableToggleLabel": "MCP-Endpunkt aktivieren",
"endpointLabel": "Connector-URL",
"connectDescription": "Füge diese URL in die Einrichtung des eigenen Connectors deines Assistenten ein und autorisiere dich mit dem Token unten oder melde dich bei Aufforderung an.",
"tokensTitle": "Connector-Tokens",
"tokensDescription": "Erzeuge ein schreibgeschütztes Token, um einen Assistenten zu verbinden. Das Token wird nur einmal angezeigt.",
"tokensDescription": "Erzeuge ein schreibgeschütztes Token, um einen Assistenten zu verbinden.",
"scopeNote": "Tokens sind auf health:read beschränkt — nur Lesezugriff auf deine eigenen Daten. Sie können niemals Admin-Funktionen erreichen.",
"writeScopeToggleLabel": "Eintragen erlauben (health:write)",
"writeScopeNote": "Fügt den Scope health:write hinzu, damit der Assistent neue Messwerte und Stimmungseinträge in dein Konto eintragen kann. Jeder Schreibvorgang wird zuvor bestätigt. Das Token kann weiterhin keine vorhandenen Einträge löschen oder ändern, keine Medikamente bearbeiten und keine REST- oder Admin-Funktionen erreichen. Für reinen Lesezugriff ausgeschaltet lassen.",
"connectionsTitle": "Verbundene Assistenten",
"connectionsDescription": "Assistenten, die du über die Anmeldung autorisiert hast. Das Widerrufen einer Verbindung beendet den Zugriff sofort, einschließlich der Möglichkeit zur Erneuerung.",
"connectionsDescription": "Assistenten, die du über die Anmeldung autorisiert hast.",
"noConnections": "Keine verbundenen Assistenten.",
"connectionConnected": "Verbunden",
"connectionLastUsed": "Zuletzt verwendet",
"connectionNeverUsed": "Nie verwendet",
"connectionRevoke": "Trennen",
"connectionRevokeDescription": "Dies beendet den Zugriff des Assistenten sofort und verhindert eine Erneuerung. Für eine erneute Verbindung ist eine neue Autorisierung nötig.",
"connectionRevokeFailed": "Assistent konnte nicht getrennt werden."
"connectionRevokeFailed": "Assistent konnte nicht getrennt werden.",
"tokensDetail": "Das Token wird nur einmal angezeigt.",
"connectionsDetail": "Das Widerrufen einer Verbindung beendet den Zugriff sofort, einschließlich der Möglichkeit zur Erneuerung."
},
"strava": "Strava",
"stravaDescription": "Verbinde dein Strava-Konto, um deine Aktivitäten in deinen Trainingsverlauf zu übernehmen.",
"stravaDescriptionSecondary": "Aktivitäten fließen im Hintergrund in dein Dashboard. Neu bei der Einrichtung? Folge der",
"stravaViewData": "Trainings ansehen",
"stravaCredentials": "API-Zugangsdaten",
"stravaClientId": "Client-ID",
@@ -6251,7 +6249,9 @@
"explainer": "Aus werden Werte gegen das allgemeine nicht-diabetische Referenzband bewertet. An werden sie gegen das engere glykämische Zielband der ADA Standards of Care bewertet, und der Coach begründet seine Glukose-Aussagen auf diesem Band. Deine eigenen Zielwert-Überschreibungen unten haben weiterhin Vorrang.",
"disclaimer": "Das wählt ein Referenzband aus. Es ist keine Diagnose, wird nie aus einem Messwert abgeleitet, und die App handelt klinisch nicht danach. Welche Zielwerte für dich gelten, klärst du mit deiner Ärztin oder deinem Arzt.",
"error": "Die Einstellung konnte nicht gespeichert werden."
}
},
"dangerZoneDetail": "Dein Konto und deine Anmeldedaten bleiben erhalten.",
"deleteAccountCardDetail": "Dieser Vorgang kann nicht rückgängig gemacht werden."
},
"admin": {
"title": "Administration",
@@ -6282,7 +6282,7 @@
},
"aiServerKey": {
"title": "KI-Server-Schlüssel",
"description": "Ein betreiberweiter Provider-Schlüssel, auf den jedes Konto zurückfallen kann, wenn kein eigener Schlüssel hinterlegt ist.",
"description": "Ein Betreiber-Schlüssel für Konten, die keinen eigenen hinterlegt haben.",
"byokHint": "Der eigene Schlüssel (BYOK, ChatGPT-Login oder lokaler Provider) hat immer Vorrang — der Server-Schlüssel ist die letzte Stufe der Provider-Kette.",
"consentHint": "Anfragen über den Server-Schlüssel erfordern die aktive KI-Einwilligung der Person; ohne Einwilligungsbeleg wird nichts gesendet.",
"statusConfigured": "Eingerichtet",
@@ -6388,7 +6388,7 @@
"monitoringTestFailed": "Monitoring-Test fehlgeschlagen",
"assistant": {
"title": "Assistenz-Oberflächen",
"description": "Jeder Toggle versteckt eine modellgestützte Oberfläche app-weit, ohne die Provider-Konfiguration zu entfernen. Der Hauptschalter erzwingt jede Unter-Option aus.",
"description": "Jeder Toggle versteckt eine modellgestützte Oberfläche app-weit, ohne die Provider-Konfiguration zu entfernen.",
"master": {
"title": "Assistent aktiv",
"description": "Hauptschalter für jede modellgestützte Oberfläche. Wenn aus, sind alle Unter-Optionen unten zwangsweise aus."
@@ -6412,13 +6412,13 @@
},
"modules": {
"title": "Modulverfügbarkeit",
"description": "Schalte ein Modul für den gesamten Server ab. Ein hier deaktiviertes Modul ist für jedes Konto aus – unabhängig von den persönlichen Einstellungen. Kerndaten (Gewicht, Blutdruck, Puls, Medikamente) sind immer aktiv und lassen sich nicht abschalten.",
"saveError": "Modulverfügbarkeit konnte nicht aktualisiert werden."
"description": "Schalte ein Modul für den gesamten Server ab.",
"saveError": "Modulverfügbarkeit konnte nicht aktualisiert werden.",
"detail": "Ein hier deaktiviertes Modul ist für jedes Konto aus – unabhängig von den persönlichen Einstellungen. Kerndaten (Gewicht, Blutdruck, Puls, Medikamente) sind immer aktiv und lassen sich nicht abschalten."
},
"invites": {
"title": "Einladungen",
"description": "Einladungslinks für die Registrierung — funktionieren auch bei geschlossener Registrierung. Der Link wird nur einmal angezeigt.",
"adminOnlyHint": "Nur Admins können Einladungen erstellen und widerrufen.",
"description": "Einladungslinks für die Registrierung — funktionieren auch bei geschlossener Registrierung.",
"create": "Einladung erstellen",
"createTitle": "Einladung erstellen",
"createDescription": "Der Link erlaubt eine Registrierung auch bei geschlossener Registrierung.",
@@ -6524,7 +6524,7 @@
"outcomeFailed": "Fehlgeschlagen",
"auditDetails": "Details",
"deleteAllData": "Alle Daten global löschen",
"deleteAllDescription": "Löscht alle Datensätze aller Konten: Messwerte, Medikamente, Laborwerte, Dokumente, Zyklus-Einträge, Stimmung, Workouts, Schlaf, Coach-Unterhaltungen, Integrationen und die Audit-Historie. Die Konten selbst und die Zugangsdaten, mit denen man sich anmeldet, bleiben erhalten.",
"deleteAllDescription": "Löscht alle Datensätze aller Konten; die Konten selbst und ihre Zugangsdaten bleiben erhalten.",
"deleteAllConfirm": "Alle Daten wirklich löschen?",
"deleteAllConfirmDescription": "Dieser Vorgang löscht unwiderruflich alle Datensätze aller Konten in der gesamten Datenbank, nicht eine Auswahl von Tabellen. Erhalten bleiben: die Nutzerkonten und ihre Anmelde-Zugangsdaten, die Konfiguration der Instanz und die Berechtigungen, über die betreute Profile erreichbar bleiben. Ein Beleg für diesen Vorgang wird danach ins Audit-Log geschrieben; die Audit-Historie davor geht mit allem anderen.",
"deleteButton": "Alle Daten löschen",
@@ -6573,15 +6573,10 @@
"sectionsNav": "Admin-Bereiche"
},
"overview": {
"welcomeTitle": "Willkommen, {name}",
"welcomeSubtitle": "Du bist als Administrator angemeldet. Wähle einen Bereich, um HealthLog zu verwalten.",
"snapshotTitle": "System-Snapshot",
"snapshotVersion": "Version",
"snapshotDatabase": "Datenbank",
"snapshotWorker": "Worker",
"snapshotStarted": "Gestartet",
"snapshotBuildSha": "Build-SHA",
"snapshotBuiltAt": "Gebaut am",
"snapshotOfflineGeo": "Geodaten",
"snapshotOfflineGeoOn": "Offline (GeoLite2)",
"snapshotOfflineGeoOff": "Online ({host})",
@@ -6618,7 +6613,7 @@
},
"coach": {
"title": "Coach",
"subtitle": "Assistenz-Oberflächen, Server-Schlüssel sowie die Feedback-Qualitäts-Tabellen für Coach und Auswertungen an einem Ort."
"subtitle": "Assistenz-Oberflächen, Server-Schlüssel und Feedback-Qualität an einem Ort."
},
"reminders": {
"title": "Erinnerungen",
@@ -6674,7 +6669,7 @@
"app-logs": {
"title": "App-Logs",
"subtitle": "Letzte strukturierte Wide-Events dieses Prozesses anzeigen.",
"processNote": "Es werden nur App-Prozess-Logs angezeigt — Worker-Logs landen in Loki, sofern die Integration konfiguriert ist. Der Puffer ist flüchtig und auf 500 Einträge begrenzt.",
"processNote": "Es werden nur App-Prozess-Logs angezeigt — Worker-Logs landen in Loki, sofern die Integration konfiguriert ist.",
"filterTraceId": "Trace-ID",
"filterAction": "Aktion enthält…",
"filterLevel": "Level",
@@ -6695,7 +6690,8 @@
"emptyDescription": "Es passen noch keine Events zu den Filtern. Nach etwas Traffic auf Aktualisieren tippen.",
"refresh": "Aktualisieren",
"eventDetails": "Event-Details",
"closeDetails": "Details schließen"
"closeDetails": "Details schließen",
"processDetail": "Der Puffer ist flüchtig und auf 500 Einträge begrenzt."
},
"backups": {
"title": "Backups",
@@ -6763,7 +6759,7 @@
"loading": "Verschlüsselungsstatus wird geladen…",
"loadError": "Verschlüsselungsstatus konnte nicht geladen werden.",
"coverageTitle": "Abdeckung",
"coverageDescription": "Wie viele verschlüsselte Zeilen unter welcher Schlüssel-ID liegen. Es werden nur Schlüssel-IDs und Anzahlen gezeigt — niemals Schlüsselmaterial.",
"coverageDescription": "Wie viele verschlüsselte Zeilen unter welcher Schlüssel-ID liegen.",
"activeKeyId": "Aktive Schlüssel-ID",
"configuredKeys": "Konfigurierte Schlüssel",
"totalRows": "Verschlüsselte Zeilen",
@@ -6771,7 +6767,7 @@
"safeToDropLegacy": "Alter Schlüssel kann entfernt werden",
"rotationIncomplete": "{count} Zeilen brauchen noch eine Rotation",
"rotationTitle": "Schlüsselrotation",
"rotationDescription": "Jede verschlüsselte Spalte auf den aktiven Schlüssel neu verschlüsseln. Das Kommandozeilen-Werkzeug bleibt der kanonische Weg; dieser Auslöser ist eine Bequemlichkeit.",
"rotationDescription": "Jede verschlüsselte Spalte auf den aktiven Schlüssel neu verschlüsseln.",
"rotationState": "Status",
"lastRun": "Letzter Lauf: {scanned} geprüft, {rotated} rotiert, {errors} Fehler.",
"rotateNow": "Jetzt rotieren",
@@ -6794,7 +6790,8 @@
"running": "Läuft",
"completed": "Abgeschlossen",
"failed": "Fehlgeschlagen"
}
},
"coverageDetail": "Es werden nur Schlüssel-IDs und Anzahlen gezeigt — niemals Schlüsselmaterial."
}
},
"carrier": "Netzbetreiber",
@@ -6840,7 +6837,8 @@
"countFew": "{count} Fehlschläge",
"countOther": "{count} Fehlschläge",
"lastFailed": "Letzter Fehlschlag {when}"
}
},
"deleteAllDetail": "Nutzerkonten bleiben bestehen."
},
"achievements": {
"title": "Erfolge",
@@ -7166,7 +7164,7 @@
},
"moodReminder": {
"title": "Tägliche Stimmungs-Erinnerung",
"description": "Sanfter Anstoß zur eingestellten Zeit, falls du heute noch keine Stimmung erfasst hast. Greift nur auf bereits eingerichtete Kanäle (Telegram, ntfy, Web Push, Apple Push) zurück und schweigt, sobald du heute geloggt hast.",
"description": "Sanfter Anstoß zur eingestellten Zeit, falls du heute noch keine Stimmung erfasst hast.",
"toggleAria": "Tägliche Stimmungs-Erinnerung umschalten",
"statusOn": "Aktiv zu deiner eingestellten Zeit",
"statusOff": "Aus",
@@ -7175,11 +7173,12 @@
"saveError": "Speichern fehlgeschlagen. Bitte später erneut versuchen.",
"hourLabel": "Erinnerungszeit",
"hourAria": "Stunde der Stimmungs-Erinnerung",
"hourSavedToast": "Erinnerungszeit aktualisiert."
"hourSavedToast": "Erinnerungszeit aktualisiert.",
"detail": "Greift nur auf bereits eingerichtete Kanäle (Telegram, ntfy, Web Push, Apple Push) zurück und schweigt, sobald du heute geloggt hast."
},
"coachNudge": {
"title": "Proaktiver Coach",
"description": "Meldet sich, wenn deine Daten einen Blick in den Coach nahelegen — etwa bei niedriger Therapietreue oder erhöhtem Blutdruck-Wochenmittel. Themen und Häufigkeit sind einstellbar.",
"description": "Meldet sich, wenn deine Daten einen Blick in den Coach nahelegen.",
"toggleAria": "Proaktive Coach-Hinweise umschalten",
"statusOn": "Aktiv",
"statusOff": "Aus",
@@ -7206,7 +7205,7 @@
},
"lowStock": {
"title": "Vorratswarnung",
"description": "Prüft deine erfassten Medikamentenvorräte einmal täglich und benachrichtigt dich, wenn einer weniger Tage abdeckt als diese Schwelle. Eine Benachrichtigung pro Engpass; nach dem Auffüllen meldet sie sich wieder.",
"description": "Meldet täglich, wenn ein Vorrat weniger Tage abdeckt als diese Schwelle.",
"toggleAria": "Vorratswarnungen umschalten",
"statusOn": "Aktiv",
"statusOff": "Aus",
@@ -7220,7 +7219,8 @@
"enabledToast": "Vorratswarnungen aktiviert.",
"disabledToast": "Vorratswarnungen deaktiviert.",
"saveError": "Speichern fehlgeschlagen. Bitte später erneut versuchen.",
"medicationsLink": "Zu deinen Medikamenten"
"medicationsLink": "Zu deinen Medikamenten",
"detail": "Eine Benachrichtigung pro Engpass; nach dem Auffüllen meldet sie sich wieder."
},
"eventMedicationReminder": "Medikamenten-Erinnerung",
"eventMedicationReminderDesc": "Wird zu jeder geplanten Einnahmezeit gesendet.",
@@ -8049,7 +8049,8 @@
"customize": "Laborwerte anpassen",
"localOcr": {
"heading": "Scans auf diesem Gerät lesen",
"description": "Ermöglicht das Scannen eines Laborbefunds, auch wenn dein KI-Anbieter keine Bilder lesen kann: Das Foto wird auf deinem Gerät gelesen und nur der erkannte Text an deinen Anbieter gesendet. Weniger genau – prüfe jeden Wert vor dem Speichern."
"description": "Liest das Foto auf deinem Gerät und sendet nur den erkannten Text an deinen Anbieter.",
"detail": "Nützlich, wenn dein KI-Anbieter keine Bilder lesen kann. Weniger genau — prüfe jeden Wert vor dem Speichern."
},
"sort": {
"heading": "Sortierung",
@@ -9623,7 +9624,7 @@
},
"invite": {
"title": "Jemanden einladen",
"description": "Vor der Zustimmung wird nichts geteilt. Bis dahin liegt die Einladung in den Einstellungen der anderen Person und gibt ihr nichts.",
"description": "Vor der Zustimmung wird nichts geteilt.",
"identifierLabel": "Benutzername oder E-Mail",
"identifierPlaceholder": "Der Name, mit dem sie sich anmeldet",
"identifierHint": "Die Person braucht ein Konto auf dieser Instanz. An eine E-Mail-Adresse ohne Registrierung lässt sich keine Einladung schicken.",
@@ -9677,7 +9678,7 @@
},
"received": {
"title": "Akten, die ich öffnen kann",
"description": "Eine Einladung gibt dir nichts, bevor du sie annimmst. Du kannst den Zugriff jederzeit zurückgeben.",
"description": "Eine Einladung gibt dir nichts, bevor du sie annimmst.",
"emptyTitle": "Niemand hat eine Akte mit dir geteilt",
"emptyDescription": "Wenn dich jemand einlädt, erscheint die Einladung hier.",
"loadError": "Die Liste konnte nicht geladen werden.",
@@ -9689,7 +9690,8 @@
"renounce": "Zurückgeben",
"renounceTitle": "Akte von {name} zurückgeben?",
"renounceBody": "Dein Zugriff endet sofort. Die Person kann dich jederzeit erneut einladen.",
"renounceConfirm": "Zurückgeben"
"renounceConfirm": "Zurückgeben",
"detail": "Du kannst den Zugriff jederzeit zurückgeben."
},
"activity": {
"title": "Wann meine Akte geöffnet wurde",
@@ -9707,13 +9709,8 @@
"addOnlyNote": "Du kannst zu dieser Akte etwas hinzufügen. Einträge ändern oder löschen kann nur {name}."
},
"lookingAfter": {
"title": "Ich kümmere mich um",
"description": "Akten, die andere mit dir geteilt haben.",
"levelRead": "Darf lesen",
"levelWrite": "Darf lesen und hinzufügen",
"kindShared": "Geteilte Akte",
"kindManaged": "Verwaltetes Profil",
"open": "Akte von {name} öffnen"
"kindManaged": "Verwaltetes Profil"
},
"managed": {
"title": "Profile, die du betreust",
+91 -94
View File
@@ -2984,7 +2984,8 @@
"valuesBack": "Back to insights",
"valuesBackToMetric": "Back to {metric}",
"scoresBack": "Back to insights",
"ecgDescription": "Single-lead ECG recordings from your device, each with the device's own result. HealthLog shows the trace and the recording — it does not interpret the waveform."
"ecgDescription": "Single-lead ECG recordings from your device, each with the device's own result.",
"ecgDisclaimer": "HealthLog shows the trace and the recording — it does not interpret the waveform."
},
"units": {
"steps": "steps",
@@ -3971,12 +3972,13 @@
},
"pillOrder": {
"title": "Sort the navigation pills",
"description": "Drag the pills into the order you want and show or hide them via the eye icon. Hidden detail pages are also removed from the top navigation.",
"description": "Drag the pills into the order you want and show or hide them via the eye icon.",
"dragHandle": "Reorder",
"saveSuccess": "Pill order saved.",
"saveError": "Saving the pill order failed.",
"save": "Save",
"empty": "No navigation pills available yet."
"empty": "No navigation pills available yet.",
"detail": "Hidden detail pages are also removed from the top navigation."
},
"customize": "Customize Insights",
"regenerateRateLimited": "Refresh limit reached — please try again later.",
@@ -4458,7 +4460,7 @@
"settings": {
"researchMode": {
"sectionTitle": "Research Mode",
"subtitle": "Opt-in display of an estimated GLP-1 drug-level curve, derived from EMA-published population pharmacokinetics. Reading-only — never a measurement or medical advice.",
"subtitle": "Opt-in curve of the estimated GLP-1 drug level, from EMA-published data.",
"toggleLabel": "Show the estimated drug-level chart",
"disabledStatus": "Disabled. The drug-level chart is hidden.",
"enabledStaleStatus": "The disclaimer was updated. Re-acknowledge below to bring the chart back.",
@@ -4466,7 +4468,8 @@
"rePromptTitle": "Disclaimer updated",
"rePromptBody": "The Research Mode disclaimer was updated since you last acknowledged it. Re-read and re-confirm to keep the chart visible.",
"rePromptCta": "Re-acknowledge disclaimer",
"disableError": "Could not disable Research Mode. Please try again."
"disableError": "Could not disable Research Mode. Please try again.",
"subtitleDetail": "Reading-only — never a measurement or medical advice."
},
"shell": {
"sectionsNav": "Settings sections",
@@ -4593,7 +4596,8 @@
},
"unavailableTitle": "Unavailable in this shared record",
"unavailableDescription": "This Settings area is intentionally unavailable while viewing another person's record.",
"integrationStatusDescription": "Connection status for this managed record. Connection controls are unavailable."
"integrationStatusDescription": "Connection status for this managed record.",
"integrationStatusDetail": "Connection controls are unavailable."
},
"sections": {
"account": {
@@ -4606,7 +4610,6 @@
"description": "Connected services, delivery channels, and source priority in one place.",
"subtitle": "Connect and manage your data sources and devices.",
"connectionsHeading": "Connections",
"connectionsDescription": "Connect and manage your data sources and devices.",
"channelsHeading": "Delivery channels",
"channelsDescription": "Where reminders and alerts are delivered.",
"matrixLink": "Per-channel notifications"
@@ -4616,7 +4619,6 @@
"description": "Every reminder in one place — what gets reminded and when, and where it's delivered.",
"subtitle": "Choose which reminders you receive and where they're delivered.",
"remindersHeading": "Reminders",
"remindersDescription": "What you receive.",
"matrixLink": "Per-channel notifications",
"channelsLink": "Delivery channels"
},
@@ -4670,7 +4672,7 @@
"integrationsHint": "Connect or disconnect data sources on the Integrations page.",
"integrationsHintLink": "Open integrations",
"cardTitle": "Source priority by metric",
"help": "When several sources deliver the same value on the same day, the top source counts as canonical. The others stay stored for the audit trail.",
"help": "When several sources deliver the same value on the same day, the top source counts as canonical.",
"resetDefaults": "Reset to defaults",
"resetDefaultsTitle": "Reset the source order?",
"resetDefaultsBody": "Every ladder you arranged goes back to the default order, for all metrics and device types at once. This is saved immediately and cannot be brought back.",
@@ -4730,13 +4732,15 @@
"subtitle": "Decide which source wins when two report the same metric.",
"nutrients": {
"title": "Synced nutrient intake",
"description": "Daily totals synced from Apple Health over the last {days} days. Read-only — the source app owns the numbers.",
"description": "Daily totals synced from Apple Health over the last {days} days.",
"empty": "No nutrient data has been synced yet.",
"latest": "{amount} {unit} on {date}",
"daysWithData": "{count} of {days} days",
"moduleHint": "Syncing is controlled by the Nutrient intake module.",
"moduleHintLink": "Manage modules"
}
"moduleHintLink": "Manage modules",
"detail": "Read-only — the source app owns the numbers."
},
"helpDetail": "The others stay stored for the audit trail."
},
"ai": {
"title": "AI provider",
@@ -4781,13 +4785,15 @@
},
"fullBackup": {
"title": "Full backup",
"description": "Your measurements, medications, mood, cycle, labs, illness episodes, allergies, family history, nutrients, workouts, health profile and custom metrics in one JSON file. Same shape as the weekly auto-backup. Some record types are not carried yet — the file's manifest names what is in it.",
"description": "Your whole record in one JSON file.",
"scopeNote": "Document metadata and the AI-generated summary, if one exists, are included as plain text; original document files and workout GPS/sample data are not — download those separately from the vault or the workout export.",
"encryptToggle": "Encrypt with a passphrase",
"passphrasePlaceholder": "Passphrase (min. 12 characters)",
"passphraseHelp": "Derives the archive key with Argon2id and seals it with AES-256-GCM. Save the file with a .hlx extension.",
"noRecoveryWarning": "There is no recovery — if you lose this passphrase, the archive cannot be opened. Keep it somewhere safe.",
"stepUpNeeded": "Confirm your second factor, then try again."
"stepUpNeeded": "Confirm your second factor, then try again.",
"detail": "Same shape as the weekly auto-backup. Some record types are not carried yet — the file's manifest names what is in it.",
"contents": "Your measurements, medications, mood, cycle, labs, illness episodes, allergies, family history, nutrients, workouts, health profile and custom metrics in one JSON file."
}
},
"import": {
@@ -4841,7 +4847,7 @@
},
"csv": {
"title": "CSV file",
"description": "Import measurements from a spreadsheet or meter export. One row per reading.",
"description": "Import measurements from a spreadsheet or meter export.",
"pasteLabel": "Paste CSV",
"fileInputLabel": "Choose a CSV file to import",
"schemaHint": "Columns: type, value, unit, measuredAt (with timezone offset), and optional glucoseContext, notes, externalId.",
@@ -4881,11 +4887,12 @@
"reasonExternalIdTooLong": "external ID is too long",
"reasonUnstableExternalId": "external ID is not stable across exports",
"reasonDuplicate": "already imported",
"reasonUnknown": "skipped"
"reasonUnknown": "skipped",
"detail": "One row per reading."
},
"doseHistory": {
"title": "Medication dose history",
"description": "A dose history exported from another medication tracker, read as it comes. One file covers every medication in it.",
"description": "A dose history exported from another medication tracker, read as it comes.",
"pasteLabel": "Paste the export",
"fileInputLabel": "Choose a dose-history export file",
"uploadFile": "Choose file",
@@ -4935,7 +4942,8 @@
"jsonNotAnArray": "That JSON holds no list of doses.",
"jsonNoIntakeTime": "The JSON export records when a dose was due but not when it was taken, and a scheduled time will not be written as if it were the take. Export the CSV instead, which carries both.",
"tooManyRows": "That file holds more rows than one import can take. Split it and import the parts."
}
},
"detail": "One file covers every medication in it."
}
},
"subtitle": "Export your data or import from a backup."
@@ -4986,7 +4994,7 @@
"error": "Couldn't save. Please try again.",
"toggleable": {
"title": "What you track",
"description": "Optional areas. Disabled modules drop out of the navigation, tiles, and insights — your data stays untouched."
"description": "Disabled modules drop out of the navigation, tiles, and insights — your data stays untouched."
},
"manageIn": "Manage in {section}",
"operatorDisabled": "Disabled server-wide",
@@ -5374,7 +5382,7 @@
"testReasonUnreachable": "Could not reach the AI provider.",
"coachMemory": {
"title": "What the Coach remembers",
"description": "Durable facts you have told the Coach. It uses these to keep its replies relevant. Remove anything you would rather it forgot.",
"description": "Durable facts you have told the Coach.",
"empty": "The Coach has not stored any facts yet. As you chat, it remembers durable details you share — your goals, conditions, preferences — and you can clear them here at any time.",
"learnedPrefix": "learned {when}",
"categoryPreference": "Preferences",
@@ -5397,11 +5405,12 @@
"forgotToast": "Fact forgotten.",
"forgotError": "Could not forget that fact. Please try again.",
"clearedToast": "Forgot {count} stored facts.",
"clearError": "Could not clear the stored facts. Please try again."
"clearError": "Could not clear the stored facts. Please try again.",
"detail": "It uses these to keep its replies relevant. Remove anything you would rather it forgot."
},
"coachReminders": {
"title": "Your reminders",
"description": "Things you asked the Coach to bring back later. Due ones show up here and on the Coach.",
"description": "Things you asked the Coach to bring back later.",
"empty": "No reminders yet. Ask the Coach to remind you about something and it will appear here.",
"loadError": "Could not load your reminders.",
"dueHeading": "Due now",
@@ -5422,7 +5431,8 @@
},
"deleteConfirmTitle": "Delete this reminder?",
"deleteConfirmBody": "It stops being raised. Anything you have already recorded is untouched.",
"deleteConfirmAction": "Delete"
"deleteConfirmAction": "Delete",
"detail": "Due ones show up here and on the Coach."
},
"aboutMe": {
"title": "About me",
@@ -5596,7 +5606,6 @@
"googleHealthTag": "Fitbit & Pixel",
"googleHealthBetaBadge": "Beta",
"googleHealthDescription": "Connect Fitbit, Pixel Watch and Fitbit Air through your Google account — weight, heart rate, sleep and activity. This replaces the classic Fitbit connection, which Google retires in September 2026.",
"googleHealthDescriptionSecondary": "You register your own Google Cloud client and bring the credentials here. Stress and readiness are not offered by the API, and Google may ask you to reconnect from time to time. New to the setup? Follow the",
"googleHealthCredentials": "API Credentials",
"googleHealthClientId": "Client ID",
"googleHealthClientSecret": "Client Secret",
@@ -5696,7 +5705,7 @@
"workouts": "Workouts"
},
"integrationFallback": {
"description": "Configured on this account without a settings card of its own. This row exists so a connection cannot stop working unnoticed.",
"description": "Configured on this account without a settings card of its own.",
"lastAttempt": "Last attempt {relative}",
"noAttempt": "No sync attempt recorded"
},
@@ -5705,7 +5714,7 @@
"fitbitViewData": "View your sleep",
"googleHealthViewData": "View your steps",
"apiTokens": "API Tokens",
"apiTokensDescription": "Review and revoke API tokens for external medication intake. Tokens are issued per medication from the medication's API endpoint toggle.",
"apiTokensDescription": "Review and revoke API tokens for external medication intake.",
"tokenMintMovedDescription": "Tokens are issued per medication. Open a medication and enable its API endpoint to get a token scoped to that medication alone.",
"tokenNamePlaceholder": "Token name (e.g., iPhone Shortcut)",
"tokenCreated": "Token created — copy it now! It won't be shown again.",
@@ -5736,14 +5745,14 @@
"apiEndpointAuth": "Authentication",
"apiEndpointExample": "Body example",
"dangerZone": "Delete All Data",
"dangerZoneDescription": "Erases your whole health record and every integration connection. Your account and the credentials you sign in with are kept.",
"dangerZoneDescription": "Erases your whole health record and every integration connection.",
"dangerZoneConfirm": "Delete all data?",
"dangerZoneConfirmDescription": "This permanently deletes everything in your record: measurements, medications, mood entries, lab results, cycle entries, documents, AI conversations and every integration connection, together with everything derived from them and anything you deleted earlier. Share links you gave to a doctor stop working immediately. Your account stays, along with the credentials you sign in with and display settings such as language, timezone and units. This cannot be undone.",
"dangerZoneSuccess": "Your health record has been deleted. Your account and sign-in are unchanged.",
"dangerZoneDeleteFailed": "Deletion failed",
"finalDelete": "Delete permanently",
"deleteAccountCardTitle": "Delete account entirely",
"deleteAccountCardDescription": "Deletes your account along with passkeys, audit log, and sessions. This cannot be undone.",
"deleteAccountCardDescription": "Deletes your account along with passkeys, audit log, and sessions.",
"deleteAccountCta": "Delete account",
"deleteAccountConfirmTitle": "Delete account permanently?",
"deleteAccountConfirmDescription": "Your account, all health data, passkeys, sessions, and audit entries will be permanently deleted. You will be signed out immediately after.",
@@ -5761,8 +5770,6 @@
"changelog": "Changelog",
"docs": "Documentation",
"linksHeading": "Sources & docs",
"identityDescription": "Version, license, and build details for this instance.",
"linksDescription": "Source code, changelog, and documentation.",
"newerAvailable": "Newer version: {tag}"
},
"testConnection": {
@@ -5790,7 +5797,7 @@
"notificationStatus": {
"title": "Delivery status",
"description": "Per-channel state; \"Auto-disabled\" stops retries after permanent errors.",
"emptyDescription": "No channels configured yet. Set one up under Delivery channels to start tracking delivery.",
"emptyDescription": "Set up a channel under Settings › Integrations › Delivery channels.",
"stateActive": "Active",
"stateAutoDisabled": "Auto-disabled",
"stateSendingPaused": "Sending paused",
@@ -5801,7 +5808,8 @@
"disabledReason": "Reason",
"nextRetry": "Next retry",
"reEnable": "Re-enable",
"sendTest": "Send test"
"sendTest": "Send test",
"emptyTitle": "No channels configured yet"
},
"identity": {
"description": "Optional details for the health-record export (PDF cover + FHIR export). All fields are optional.",
@@ -5814,7 +5822,7 @@
},
"healthRecord": {
"title": "Export health record",
"description": "Generate a complete health record for your doctor — as a PDF, structured (FHIR), or a combined package.",
"description": "A complete record for your doctor — as a PDF, as FHIR, or both.",
"format": "Format",
"format_pdf": "PDF",
"format_fhir": "Structured (FHIR)",
@@ -5839,10 +5847,10 @@
"errorModuleDisabled": "The health record export is switched off for this account."
},
"globalExcludedInjectionSitesLabel": "Globally excluded injection sites",
"globalExcludedInjectionSitesHint": "Sites listed here are never offered for any medication and rejected when logging a dose, even if a medication prefers them.",
"globalExcludedInjectionSitesHint": "Never offered and rejected when logging a dose, even if a medication prefers them.",
"sharing": {
"createTitle": "New share link",
"createDescription": "The link is read-only and time-boxed. The token is shown once on creation — store it then; it cannot be recovered.",
"createDescription": "The link is read-only and time-boxed.",
"label": "Label",
"labelPlaceholder": "e.g. Dr. Smith — annual check-up",
"range": "Days of history",
@@ -5892,15 +5900,17 @@
"scopeInsuranceExcluded": "Insurance details are never shared through a link.",
"statusNeedsReselect": "Pick contents again",
"reselectReason": "This link served a scope that was never chosen, so it was closed. Create a new one and pick what it should contain.",
"reselectAction": "Create a replacement"
"reselectAction": "Create a replacement",
"createDetail": "The token is shown once on creation — store it then; it cannot be recovered."
},
"genderOther": "Other",
"cycleTracking": {
"title": "Cycle tracking",
"description": "Track your menstrual cycle, predictions, symptoms, and phase insights. On by default for accounts set to female; available to everyone.",
"description": "Track your menstrual cycle, predictions, symptoms, and phase insights.",
"enable": "Enable cycle tracking",
"manage": "Open cycle tracking",
"error": "Couldn't update cycle tracking."
"error": "Couldn't update cycle tracking.",
"detail": "On by default for accounts set to female; available to everyone."
},
"nightscout": "Nightscout",
"nightscoutTag": "CGM",
@@ -5986,20 +5996,13 @@
"coach": {
"activate": {
"title": "Activate Coach",
"description": "Show the Coach button and drawer. On by default — turn it off to hide the Coach everywhere.",
"description": "Show the Coach button and drawer.",
"toggleAria": "Activate the Coach button and drawer",
"savedOn": "Coach activated.",
"savedOff": "Coach hidden.",
"saveError": "Could not save the change. Please try again."
}
},
"withingsDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the",
"whoopDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the",
"fitbitDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the",
"polarDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the",
"ouraDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the",
"nightscoutDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the",
"integrationCredentialsHint": "Where to find these credentials and how to register your own app is covered in the setup guide above.",
"integrationRedirectGuide": {
"intro": "This connects through your own {provider} developer app — about 5 minutes to set up:",
"step1": "Create an app in the {provider} developer portal.",
@@ -6113,19 +6116,13 @@
"signOutEverywhereConfirmBody": "Every device except this one will need to sign in again. This device stays signed in. Nothing you have recorded is affected."
},
"privacy": {
"intro": {
"title": "Your data stays yours",
"body": "HealthLog is self-hosted. This page brings together how your data is protected, how long it is kept, how to get it out, and how to delete it."
},
"encryption": {
"title": "Encrypted at rest",
"description": "Sensitive fields are encrypted in the database.",
"summary": "Your sensitive data is encrypted at rest with {algorithm} across {columns} fields in {models} record types.",
"statement": "Your sensitive data is encrypted at rest with AES-256-GCM."
},
"stored": {
"title": "What's stored",
"description": "The categories of data this account holds.",
"measurements": "Measurements (weight, blood pressure, pulse, glucose, sleep, body composition)",
"medications": "Medications and intake history",
"moodLabs": "Mood entries and lab results",
@@ -6143,7 +6140,7 @@
},
"export": {
"title": "Download my data",
"description": "Export your records as CSV or JSON, optionally passphrase-encrypted, or generate a full health record.",
"description": "Your records as CSV or JSON, or a complete health record for your doctor.",
"openExport": "Export & backup",
"openHealthRecord": "Health record"
},
@@ -6162,30 +6159,31 @@
},
"mcp": {
"enableTitle": "Remote MCP endpoint",
"enableDescription": "Let an MCP-capable assistant (Claude.ai, ChatGPT) read your health data over a scoped token. Off by default.",
"enableDescription": "Let an MCP-capable assistant (Claude.ai, ChatGPT) read your health data over a scoped token.",
"enabled": "Enabled",
"disabled": "Disabled",
"enableToggleLabel": "Enable the MCP endpoint",
"endpointLabel": "Connector URL",
"connectDescription": "Paste this URL into your assistant's custom-connector setup, then authorize with the token below or sign in when prompted.",
"tokensTitle": "Connector tokens",
"tokensDescription": "Mint a read-only token to connect an assistant. The token is shown once.",
"tokensDescription": "Mint a read-only token to connect an assistant.",
"scopeNote": "Tokens are scoped to health:read — read-only access to your own records. They can never reach admin functions.",
"writeScopeToggleLabel": "Allow logging (health:write)",
"writeScopeNote": "Adds the health:write scope so the assistant can log new measurements and mood entries to your account. Every write is confirmed first. The token still can't delete or edit existing entries, change medications, or reach REST or admin functions. Leave off for read-only access.",
"connectionsTitle": "Connected assistants",
"connectionsDescription": "Assistants you authorized through the sign-in flow. Revoking a connection ends its access immediately, including its ability to refresh.",
"connectionsDescription": "Assistants you authorized through the sign-in flow.",
"noConnections": "No connected assistants.",
"connectionConnected": "Connected",
"connectionLastUsed": "Last used",
"connectionNeverUsed": "Never used",
"connectionRevoke": "Disconnect",
"connectionRevokeDescription": "This ends the assistant's access immediately and stops it from renewing. It will need to be re-authorized to reconnect.",
"connectionRevokeFailed": "Could not disconnect the assistant."
"connectionRevokeFailed": "Could not disconnect the assistant.",
"connectionsDetail": "Revoking a connection ends its access immediately, including its ability to refresh.",
"tokensDetail": "The token is shown once."
},
"strava": "Strava",
"stravaDescription": "Connect your Strava account to sync your activities into your workout history.",
"stravaDescriptionSecondary": "Activities flow into your dashboard in the background. New to the setup? Follow the",
"stravaViewData": "View your workouts",
"stravaCredentials": "API Credentials",
"stravaClientId": "Client ID",
@@ -6215,7 +6213,7 @@
},
"appleHealth": {
"title": "Apple Health",
"description": "Live sync comes from the HealthLog iOS app. It is not a web or OAuth connection.",
"description": "Live sync comes from the HealthLog iOS app, not from a web or OAuth connection.",
"status": {
"unavailable": "Apple Health status unavailable"
},
@@ -6251,7 +6249,9 @@
"explainer": "Off, readings are judged against the general non-diabetic reference band. On, they are judged against the tighter glycaemic goal band from the ADA Standards of Care, and the Coach grounds its glucose remarks on that band instead. Your own target overrides below still take precedence.",
"disclaimer": "This selects a reference band. It is not a diagnosis, it is never inferred from a reading, and the app takes no clinical action on it. Which targets apply to you is a question for your clinician.",
"error": "Could not save the setting."
}
},
"dangerZoneDetail": "Your account and the credentials you sign in with are kept.",
"deleteAccountCardDetail": "This cannot be undone."
},
"admin": {
"title": "Administration",
@@ -6282,7 +6282,7 @@
},
"aiServerKey": {
"title": "AI server key",
"description": "An operator-wide provider key every account can fall back to when no personal key is configured.",
"description": "An operator key for accounts that have not configured one of their own.",
"byokHint": "A user's own key (BYOK, ChatGPT login or local provider) always takes precedence — the server key is the last step in the provider chain.",
"consentHint": "Requests over the server key require the user's active AI consent; without a consent receipt nothing is sent.",
"statusConfigured": "Configured",
@@ -6388,7 +6388,7 @@
"monitoringTestFailed": "Monitoring test failed",
"assistant": {
"title": "Assistant Surfaces",
"description": "Each toggle hides a model-driven surface app-wide without removing the provider configuration. The master kills every sub-flag at once.",
"description": "Each toggle hides a model-driven surface app-wide without removing the provider configuration.",
"master": {
"title": "Assistant enabled",
"description": "Master switch for every model-driven surface. When off, every sub-flag below is forced off."
@@ -6412,13 +6412,13 @@
},
"modules": {
"title": "Module availability",
"description": "Turn a module off for the whole server. A module disabled here is off for every account regardless of personal preference. Core domains (weight, blood pressure, pulse, medications) are always on and cannot be disabled.",
"saveError": "Could not update module availability."
"description": "Turn a module off for the whole server.",
"saveError": "Could not update module availability.",
"detail": "A module disabled here is off for every account regardless of personal preference. Core domains (weight, blood pressure, pulse, medications) are always on and cannot be disabled."
},
"invites": {
"title": "Invites",
"description": "Registration invite links — they work even while registration is closed. The link is shown only once.",
"adminOnlyHint": "Only admins can create and revoke invites.",
"description": "Registration invite links — they work even while registration is closed.",
"create": "Create invite",
"createTitle": "Create invite",
"createDescription": "The link admits a signup even while registration is closed.",
@@ -6524,7 +6524,7 @@
"outcomeFailed": "Failed",
"auditDetails": "Details",
"deleteAllData": "Delete all data globally",
"deleteAllDescription": "Deletes every record every account holds: measurements, medications, laboratory results, documents, cycle logs, mood, workouts, sleep, Coach conversations, integrations and the audit history. The accounts themselves and the credentials that sign into them are kept.",
"deleteAllDescription": "Deletes every record every account holds; the accounts and their sign-in credentials are kept.",
"deleteAllConfirm": "Really delete all data?",
"deleteAllConfirmDescription": "This permanently deletes every account's records across the whole database, not a selection of tables. Kept: the user accounts and the credentials that sign into them, the instance configuration, and the delegation grants that keep managed profiles reachable. A receipt for this wipe is written to the audit log afterwards; the audit history before it goes with everything else.",
"deleteButton": "Delete all data",
@@ -6573,15 +6573,10 @@
"sectionsNav": "Admin sections"
},
"overview": {
"welcomeTitle": "Welcome, {name}",
"welcomeSubtitle": "You are signed in as administrator. Pick a section to manage HealthLog.",
"snapshotTitle": "System snapshot",
"snapshotVersion": "Version",
"snapshotDatabase": "Database",
"snapshotWorker": "Worker",
"snapshotStarted": "Started",
"snapshotBuildSha": "Build SHA",
"snapshotBuiltAt": "Built at",
"snapshotOfflineGeo": "Geo data",
"snapshotOfflineGeoOn": "Offline (GeoLite2)",
"snapshotOfflineGeoOff": "Online ({host})",
@@ -6618,7 +6613,7 @@
},
"coach": {
"title": "Coach",
"subtitle": "Assistant surfaces, server key, and the Coach and Insights feedback-quality tables in one place."
"subtitle": "Assistant surfaces, the server key, and feedback quality in one place."
},
"reminders": {
"title": "Reminders",
@@ -6674,7 +6669,7 @@
"app-logs": {
"title": "App Logs",
"subtitle": "Tail the most recent structured wide-events emitted by this process.",
"processNote": "Showing app-process logs only — worker-process logs go to Loki when that integration is configured. Buffer is volatile and capped at 500 entries.",
"processNote": "Showing app-process logs only — worker-process logs go to Loki when that integration is configured.",
"filterTraceId": "Trace ID",
"filterAction": "Action contains…",
"filterLevel": "Level",
@@ -6695,7 +6690,8 @@
"emptyDescription": "No events match the current filters yet. Hit refresh after triggering some traffic.",
"refresh": "Refresh",
"eventDetails": "Event details",
"closeDetails": "Close details"
"closeDetails": "Close details",
"processDetail": "Buffer is volatile and capped at 500 entries."
},
"backups": {
"title": "Backups",
@@ -6763,7 +6759,7 @@
"loading": "Loading encryption status…",
"loadError": "Failed to load encryption status.",
"coverageTitle": "Coverage",
"coverageDescription": "How many encrypted rows sit under each key id. Only key ids and counts are shown — never key material.",
"coverageDescription": "How many encrypted rows sit under each key id.",
"activeKeyId": "Active key id",
"configuredKeys": "Configured keys",
"totalRows": "Encrypted rows",
@@ -6771,7 +6767,7 @@
"safeToDropLegacy": "Safe to drop the legacy key",
"rotationIncomplete": "{count} rows still need rotation",
"rotationTitle": "Key rotation",
"rotationDescription": "Re-encrypt every encrypted column to the active key. The command-line tool stays the canonical path; this trigger is a convenience.",
"rotationDescription": "Re-encrypt every encrypted column to the active key.",
"rotationState": "State",
"lastRun": "Last run: scanned {scanned}, rotated {rotated}, errors {errors}.",
"rotateNow": "Rotate now",
@@ -6794,7 +6790,8 @@
"running": "Running",
"completed": "Completed",
"failed": "Failed"
}
},
"coverageDetail": "Only key ids and counts are shown — never key material."
}
},
"carrier": "Carrier",
@@ -6840,7 +6837,8 @@
"countFew": "{count} failures",
"countOther": "{count} failures",
"lastFailed": "Last failure {when}"
}
},
"deleteAllDetail": "User accounts will be preserved."
},
"achievements": {
"title": "Achievements",
@@ -7166,7 +7164,7 @@
},
"moodReminder": {
"title": "Daily mood reminder",
"description": "A gentle nudge at your chosen time when you haven't logged a mood for the day. Routes only through channels you've already configured (Telegram, ntfy, Web Push, Apple Push) and stays silent once you've logged today.",
"description": "A gentle nudge at your chosen time when you haven't logged a mood for the day.",
"toggleAria": "Toggle daily mood reminder",
"statusOn": "Active at your chosen time",
"statusOff": "Off",
@@ -7175,11 +7173,12 @@
"saveError": "Save failed. Please try again.",
"hourLabel": "Reminder time",
"hourAria": "Mood reminder hour",
"hourSavedToast": "Reminder time updated."
"hourSavedToast": "Reminder time updated.",
"detail": "Routes only through channels you've already configured (Telegram, ntfy, Web Push, Apple Push) and stays silent once you've logged today."
},
"coachNudge": {
"title": "Proactive Coach",
"description": "Reaches out when your data suggests a look at the Coach — for example low medication adherence or an elevated weekly blood-pressure average. Topics and frequency are adjustable.",
"description": "Reaches out when your data suggests a look at the Coach.",
"toggleAria": "Toggle proactive Coach nudges",
"statusOn": "Active",
"statusOff": "Off",
@@ -7206,7 +7205,7 @@
},
"lowStock": {
"title": "Low-stock alert",
"description": "Checks your tracked medication supplies once a day and notifies you when one covers fewer days than this threshold. One notification per shortage; it re-arms after a refill.",
"description": "Reports daily when a supply covers fewer days than this threshold.",
"toggleAria": "Toggle low-stock alerts",
"statusOn": "Active",
"statusOff": "Off",
@@ -7220,7 +7219,8 @@
"enabledToast": "Low-stock alerts enabled.",
"disabledToast": "Low-stock alerts disabled.",
"saveError": "Could not save. Please try again.",
"medicationsLink": "Go to your medications"
"medicationsLink": "Go to your medications",
"detail": "One notification per shortage; it re-arms after a refill."
},
"eventMedicationReminder": "Medication reminder",
"eventMedicationReminderDesc": "Sent at each scheduled dose time.",
@@ -8049,7 +8049,8 @@
"customize": "Customize labs",
"localOcr": {
"heading": "Read scans on this device",
"description": "Lets you scan a lab report even when your AI provider can't read images: the photo is read on your device and only the extracted text is sent to your provider. Less accurate — review every value before saving."
"description": "Reads the photo on your device and sends only the extracted text to your provider.",
"detail": "Useful when your AI provider cannot read images. Less accurate — review every value before saving."
},
"sort": {
"heading": "Sort order",
@@ -9623,7 +9624,7 @@
},
"invite": {
"title": "Invite someone",
"description": "Nothing is shared until they accept. Until then the invitation sits in their settings and gives them nothing.",
"description": "Nothing is shared until they accept.",
"identifierLabel": "Username or e-mail",
"identifierPlaceholder": "The name they sign in with",
"identifierHint": "They need an account on this instance. Invitations cannot be sent to an e-mail address that has not registered.",
@@ -9677,7 +9678,7 @@
},
"received": {
"title": "Records I can open",
"description": "An invitation gives you nothing until you accept it. You can hand access back whenever you want.",
"description": "An invitation gives you nothing until you accept it.",
"emptyTitle": "Nobody has shared a record with you",
"emptyDescription": "When someone invites you, the invitation appears here.",
"loadError": "The list could not be loaded.",
@@ -9689,7 +9690,8 @@
"renounce": "Hand back",
"renounceTitle": "Hand {name}'s record back?",
"renounceBody": "You lose access immediately. They can invite you again at any time.",
"renounceConfirm": "Hand back"
"renounceConfirm": "Hand back",
"detail": "You can hand access back whenever you want."
},
"activity": {
"title": "When my record was opened",
@@ -9707,13 +9709,8 @@
"addOnlyNote": "You can add to this record. Changing or removing entries is something only {name} can do."
},
"lookingAfter": {
"title": "Looking after",
"description": "Records other people have shared with you.",
"levelRead": "Can view",
"levelWrite": "Can view and add",
"kindShared": "Shared record",
"kindManaged": "Managed profile",
"open": "Open {name}'s record"
"kindManaged": "Managed profile"
},
"managed": {
"title": "Records you look after",
+90 -93
View File
@@ -2984,7 +2984,8 @@
"valuesBack": "Volver a Insights",
"valuesBackToMetric": "Volver a {metric}",
"scoresBack": "Volver a Insights",
"ecgDescription": "Registros de ECG de una derivación de tu dispositivo, cada uno con el resultado del propio dispositivo. HealthLog muestra el trazado y el registro, pero no interpreta la onda."
"ecgDescription": "Registros de ECG de una derivación de tu dispositivo, cada uno con el resultado del propio dispositivo.",
"ecgDisclaimer": "HealthLog muestra el trazado y el registro, pero no interpreta la onda."
},
"units": {
"steps": "pasos",
@@ -3971,12 +3972,13 @@
},
"pillOrder": {
"title": "Ordenar las pestañas de navegación",
"description": "Arrastra las pestañas al orden que quieras y muéstralas u ocúltalas con el icono del ojo. Las páginas de detalle ocultas también se quitan de la navegación superior.",
"description": "Arrastra las pestañas al orden que quieras y muéstralas u ocúltalas con el icono del ojo.",
"dragHandle": "Reordenar",
"saveSuccess": "Orden de las pestañas guardado.",
"saveError": "No se pudo guardar el orden de las pestañas.",
"save": "Guardar",
"empty": "Aún no hay pestañas de navegación disponibles."
"empty": "Aún no hay pestañas de navegación disponibles.",
"detail": "Las páginas de detalle ocultas también se quitan de la navegación superior."
},
"customize": "Personalizar Análisis",
"regenerateRateLimited": "Límite de actualizaciones alcanzado: inténtalo de nuevo más tarde.",
@@ -4458,7 +4460,7 @@
"settings": {
"researchMode": {
"sectionTitle": "Modo Investigación",
"subtitle": "Visualización opcional de una curva estimada del nivel de fármaco GLP-1, derivada de la farmacocinética poblacional publicada por la EMA. Solo lectura: nunca una medición ni un consejo médico.",
"subtitle": "Curva opcional del nivel estimado del fármaco GLP-1, según datos de la EMA.",
"toggleLabel": "Mostrar la curva estimada del nivel de fármaco",
"disabledStatus": "Desactivado. La curva está oculta.",
"enabledStaleStatus": "El disclaimer se actualizó. Reconfirma debajo para volver a mostrar la curva.",
@@ -4466,7 +4468,8 @@
"rePromptTitle": "Disclaimer actualizado",
"rePromptBody": "El disclaimer del modo Investigación se actualizó desde tu última confirmación. Relelo y reconfirma para mantener la curva visible.",
"rePromptCta": "Reconfirmar disclaimer",
"disableError": "No se pudo desactivar el modo Investigación. Inténtalo de nuevo."
"disableError": "No se pudo desactivar el modo Investigación. Inténtalo de nuevo.",
"subtitleDetail": "Solo lectura: nunca una medición ni un consejo médico."
},
"shell": {
"sectionsNav": "Secciones de ajustes",
@@ -4593,7 +4596,8 @@
},
"unavailableTitle": "No disponible en este registro compartido",
"unavailableDescription": "Esta sección de Ajustes no está disponible intencionadamente mientras consultas el registro de otra persona.",
"integrationStatusDescription": "Estado de conexión de este registro gestionado. Los controles de conexión no están disponibles."
"integrationStatusDescription": "Estado de conexión de este registro gestionado.",
"integrationStatusDetail": "Los controles de conexión no están disponibles."
},
"sections": {
"account": {
@@ -4606,7 +4610,6 @@
"description": "Servicios conectados, canales de entrega y prioridad de fuentes en un solo lugar.",
"subtitle": "Conecta y gestiona tus fuentes de datos y dispositivos.",
"connectionsHeading": "Conexiones",
"connectionsDescription": "Conecta y gestiona tus fuentes de datos y dispositivos.",
"channelsHeading": "Canales de entrega",
"channelsDescription": "Dónde se entregan los recordatorios y avisos.",
"matrixLink": "Notificaciones por canal"
@@ -4616,7 +4619,6 @@
"description": "Todos los recordatorios en un solo lugar: qué se recuerda y cuándo, y dónde se entrega.",
"subtitle": "Elige qué recordatorios recibes y dónde se entregan.",
"remindersHeading": "Recordatorios",
"remindersDescription": "Lo que recibes.",
"matrixLink": "Notificaciones por canal",
"channelsLink": "Canales de entrega"
},
@@ -4670,7 +4672,7 @@
"integrationsHint": "Conecta o desconecta fuentes de datos en la página de integraciones.",
"integrationsHintLink": "Abrir integraciones",
"cardTitle": "Prioridad de fuentes por métrica",
"help": "Si varias fuentes entregan el mismo valor el mismo día, la fuente superior cuenta como canónica. Las demás se conservan para la traza de auditoría.",
"help": "Si varias fuentes entregan el mismo valor el mismo día, la fuente superior cuenta como canónica.",
"resetDefaults": "Restablecer valores predeterminados",
"resetDefaultsTitle": "¿Restablecer el orden de fuentes?",
"resetDefaultsBody": "Cada orden que configuraste vuelve al predeterminado, para todas las métricas y tipos de dispositivo a la vez. Se guarda de inmediato y no se puede recuperar.",
@@ -4730,13 +4732,15 @@
"subtitle": "Decide qué fuente prevalece cuando dos informan la misma métrica.",
"nutrients": {
"title": "Ingesta de nutrientes sincronizada",
"description": "Totales diarios sincronizados desde Apple Health en los últimos {days} días. Solo lectura: la app de origen es la dueña de los valores.",
"description": "Totales diarios sincronizados desde Apple Health en los últimos {days} días.",
"empty": "Todavía no se han sincronizado datos de nutrientes.",
"latest": "{amount} {unit} el {date}",
"daysWithData": "{count} de {days} días",
"moduleHint": "La sincronización se controla con el módulo de ingesta de nutrientes.",
"moduleHintLink": "Gestionar módulos"
}
"moduleHintLink": "Gestionar módulos",
"detail": "Solo lectura: la app de origen es la dueña de los valores."
},
"helpDetail": "Las demás se conservan para la traza de auditoría."
},
"ai": {
"title": "Proveedor de IA",
@@ -4781,13 +4785,15 @@
},
"fullBackup": {
"title": "Copia completa",
"description": "Tus mediciones, medicamentos, estado de ánimo, ciclo, analíticas, episodios de enfermedad, alergias, antecedentes familiares, nutrientes, entrenamientos, tu perfil de salud y tus métricas personalizadas en un archivo JSON. El mismo formato que la copia automática semanal. Algunos tipos de registro aún no se incluyen: el manifiesto del archivo indica qué contiene.",
"description": "Todo tu historial en un archivo JSON.",
"scopeNote": "Los metadatos de los documentos y el resumen de IA, si existe, se incluyen en texto plano; los archivos originales de los documentos y los datos de rutas GPS/muestras de los entrenamientos no están incluidos — descárgalos por separado desde el archivo de documentos o la exportación de entrenamientos.",
"encryptToggle": "Cifrar con una contraseña",
"passphrasePlaceholder": "Contraseña (mín. 12 caracteres)",
"passphraseHelp": "Deriva la clave del archivo con Argon2id y lo sella con AES-256-GCM. Guarda el archivo con la extensión .hlx.",
"noRecoveryWarning": "No hay recuperación: si pierdes esta contraseña, el archivo no se podrá abrir. Guárdala en un lugar seguro.",
"stepUpNeeded": "Confirma tu segundo factor y vuelve a intentarlo."
"stepUpNeeded": "Confirma tu segundo factor y vuelve a intentarlo.",
"detail": "El mismo formato que la copia automática semanal. Algunos tipos de registro aún no se incluyen: el manifiesto del archivo indica qué contiene.",
"contents": "Tus mediciones, medicamentos, estado de ánimo, ciclo, analíticas, episodios de enfermedad, alergias, antecedentes familiares, nutrientes, entrenamientos, tu perfil de salud y tus métricas personalizadas en un archivo JSON."
}
},
"import": {
@@ -4841,7 +4847,7 @@
},
"csv": {
"title": "Archivo CSV",
"description": "Importa mediciones desde una hoja de cálculo o la exportación de un medidor. Una fila por lectura.",
"description": "Importa mediciones desde una hoja de cálculo o la exportación de un medidor.",
"pasteLabel": "Pegar CSV",
"fileInputLabel": "Elegir un archivo CSV para importar",
"schemaHint": "Columnas: type, value, unit, measuredAt (con desfase de zona horaria) y opcionales glucoseContext, notes, externalId.",
@@ -4881,11 +4887,12 @@
"reasonExternalIdTooLong": "el ID externo es demasiado largo",
"reasonUnstableExternalId": "el ID externo no es estable entre exportaciones",
"reasonDuplicate": "ya importada",
"reasonUnknown": "omitida"
"reasonUnknown": "omitida",
"detail": "Una fila por lectura."
},
"doseHistory": {
"title": "Historial de dosis",
"description": "Un historial de dosis exportado desde otra aplicación de seguimiento, leído tal cual. Un archivo abarca todos los medicamentos que contiene.",
"description": "Un historial de dosis exportado desde otra aplicación de seguimiento, leído tal cual.",
"pasteLabel": "Pega la exportación",
"fileInputLabel": "Elegir un archivo de historial de dosis",
"uploadFile": "Elegir archivo",
@@ -4935,7 +4942,8 @@
"jsonNotAnArray": "Ese JSON no contiene ninguna lista de dosis.",
"jsonNoIntakeTime": "La exportación JSON registra cuándo tocaba una dosis pero no cuándo se tomó, y una hora prevista no se escribirá como si fuera la toma. Exporta el CSV, que lleva ambas.",
"tooManyRows": "Ese archivo tiene más filas de las que admite una importación. Divídelo e importa las partes."
}
},
"detail": "Un archivo abarca todos los medicamentos que contiene."
}
},
"subtitle": "Exporta tus datos o importa desde una copia de seguridad."
@@ -4986,7 +4994,7 @@
"error": "No se pudo guardar. Inténtalo de nuevo.",
"toggleable": {
"title": "Qué registras",
"description": "Áreas opcionales. Los módulos desactivados desaparecen de la navegación, los paneles y los análisis; tus datos se conservan."
"description": "Los módulos desactivados desaparecen de la navegación, los paneles y los análisis; tus datos se conservan."
},
"manageIn": "Gestionar en {section}",
"operatorDisabled": "Desactivado en todo el servidor",
@@ -5374,7 +5382,7 @@
"testReasonUnreachable": "No se pudo contactar con el proveedor de IA.",
"coachMemory": {
"title": "Lo que el Coach recuerda",
"description": "Datos duraderos que le has contado al Coach. Los usa para que sus respuestas sigan siendo relevantes. Elimina lo que prefieras que olvide.",
"description": "Datos duraderos que le has contado al Coach.",
"empty": "El Coach aún no ha guardado ningún dato. A medida que conversas, recuerda detalles duraderos que compartes: tus objetivos, afecciones y preferencias, y puedes borrarlos aquí cuando quieras.",
"learnedPrefix": "aprendido {when}",
"categoryPreference": "Preferencias",
@@ -5397,11 +5405,12 @@
"forgotToast": "Dato olvidado.",
"forgotError": "No se pudo olvidar ese dato. Inténtalo de nuevo.",
"clearedToast": "Se olvidaron {count} datos guardados.",
"clearError": "No se pudieron borrar los datos guardados. Inténtalo de nuevo."
"clearError": "No se pudieron borrar los datos guardados. Inténtalo de nuevo.",
"detail": "Los usa para que sus respuestas sigan siendo relevantes. Elimina lo que prefieras que olvide."
},
"coachReminders": {
"title": "Tus recordatorios",
"description": "Cosas que le pediste al Coach que retomara más tarde. Las que vencen aparecen aquí y en el Coach.",
"description": "Cosas que le pediste al Coach que retomara más tarde.",
"empty": "Aún no hay recordatorios. Pídele al Coach que te recuerde algo y aparecerá aquí.",
"loadError": "No se pudieron cargar tus recordatorios.",
"dueHeading": "Vencen ahora",
@@ -5422,7 +5431,8 @@
},
"deleteConfirmTitle": "¿Eliminar este recordatorio?",
"deleteConfirmBody": "Deja de aparecer. Lo que ya has registrado no cambia.",
"deleteConfirmAction": "Eliminar"
"deleteConfirmAction": "Eliminar",
"detail": "Las que vencen aparecen aquí y en el Coach."
},
"aboutMe": {
"title": "Sobre mí",
@@ -5596,7 +5606,6 @@
"googleHealthTag": "Fitbit y Pixel",
"googleHealthBetaBadge": "Beta",
"googleHealthDescription": "Conecta Fitbit, Pixel Watch y Fitbit Air a través de tu cuenta de Google: peso, frecuencia cardíaca, sueño y actividad. Sustituye a la conexión clásica de Fitbit, que Google retira en septiembre de 2026.",
"googleHealthDescriptionSecondary": "Registras tu propio cliente de Google Cloud y traes aquí las credenciales. La API no ofrece estrés ni preparación, y Google puede pedirte que vuelvas a conectar de vez en cuando. ¿Es tu primera vez? Sigue la",
"googleHealthCredentials": "Credenciales de API",
"googleHealthClientId": "ID de cliente",
"googleHealthClientSecret": "Secreto de cliente",
@@ -5696,7 +5705,7 @@
"workouts": "Entrenamientos"
},
"integrationFallback": {
"description": "Configurado en esta cuenta pero sin tarjeta de ajustes propia. Esta fila existe para que una conexión no deje de funcionar sin que nadie lo note.",
"description": "Configurado en esta cuenta pero sin tarjeta de ajustes propia.",
"lastAttempt": "Último intento {relative}",
"noAttempt": "Ningún intento de sincronización registrado"
},
@@ -5705,7 +5714,7 @@
"fitbitViewData": "Ver tu sueño",
"googleHealthViewData": "Ver tus pasos",
"apiTokens": "Tokens de API",
"apiTokensDescription": "Revisa y revoca tokens de API para el registro externo de tomas de medicación. Los tokens se emiten por medicamento desde su interruptor de endpoint de API.",
"apiTokensDescription": "Revisa y revoca tokens de API para el registro externo de tomas de medicación.",
"tokenMintMovedDescription": "Los tokens se emiten por medicamento. Abre un medicamento y activa su endpoint de API para obtener un token limitado únicamente a ese medicamento.",
"tokenNamePlaceholder": "Nombre del token (p. ej., Atajo de iPhone)",
"tokenCreated": "Token creado — ¡cópialo ahora! No se mostrará de nuevo.",
@@ -5736,14 +5745,14 @@
"apiEndpointAuth": "Autenticación",
"apiEndpointExample": "Ejemplo de cuerpo",
"dangerZone": "Eliminar todos los datos",
"dangerZoneDescription": "Borra todo tu historial de salud y todas las conexiones de integración. Tu cuenta y las credenciales con las que inicias sesión se conservan.",
"dangerZoneDescription": "Borra todo tu historial de salud y todas las conexiones de integración.",
"dangerZoneConfirm": "¿Eliminar todos los datos?",
"dangerZoneConfirmDescription": "Esto elimina de forma permanente todo lo que hay en tu historial: mediciones, medicamentos, entradas de ánimo, resultados de laboratorio, entradas de ciclo, documentos, conversaciones con la IA y todas las conexiones de integración, junto con todo lo derivado de ello y todo lo que eliminaste antes. Los enlaces compartidos que diste a una consulta dejan de funcionar de inmediato. Tu cuenta se mantiene, junto con las credenciales con las que inicias sesión y los ajustes de presentación como el idioma, la zona horaria y las unidades. Esto no se puede deshacer.",
"dangerZoneSuccess": "Se ha eliminado tu historial de salud. Tu cuenta y tu inicio de sesión no han cambiado.",
"dangerZoneDeleteFailed": "Falló la eliminación",
"finalDelete": "Eliminar permanentemente",
"deleteAccountCardTitle": "Eliminar la cuenta por completo",
"deleteAccountCardDescription": "Elimina tu cuenta junto con las claves de acceso, el registro de auditoría y las sesiones. Esto no se puede deshacer.",
"deleteAccountCardDescription": "Elimina tu cuenta junto con las claves de acceso, el registro de auditoría y las sesiones.",
"deleteAccountCta": "Eliminar cuenta",
"deleteAccountConfirmTitle": "¿Eliminar la cuenta de forma permanente?",
"deleteAccountConfirmDescription": "Tu cuenta, todos los datos de salud, las claves de acceso, las sesiones y las entradas de auditoría se eliminarán de forma permanente. Se cerrará tu sesión inmediatamente después.",
@@ -5761,8 +5770,6 @@
"changelog": "Changelog",
"docs": "Documentación",
"linksHeading": "Fuentes y documentación",
"identityDescription": "Versión, licencia y detalles de compilación de esta instancia.",
"linksDescription": "Código fuente, registro de cambios y documentación.",
"newerAvailable": "Nueva versión: {tag}"
},
"testConnection": {
@@ -5790,7 +5797,7 @@
"notificationStatus": {
"title": "Fiabilidad de los canales",
"description": "Estado por canal; «Desactivado automáticamente» detiene los reintentos tras errores permanentes.",
"emptyDescription": "Aún no hay canales configurados. Configura uno en Canales de entrega para seguir la entrega.",
"emptyDescription": "Configura un canal en Ajustes › Integraciones › Canales de entrega.",
"stateActive": "Activo",
"stateAutoDisabled": "Desactivado automáticamente",
"stateSendingPaused": "Envío en pausa",
@@ -5801,7 +5808,8 @@
"disabledReason": "Motivo",
"nextRetry": "Próximo intento",
"reEnable": "Reactivar",
"sendTest": "Enviar prueba"
"sendTest": "Enviar prueba",
"emptyTitle": "Aún no hay canales configurados"
},
"identity": {
"description": "Datos opcionales para la exportación del historial de salud (portada PDF + exportación FHIR). Todos los campos son opcionales.",
@@ -5814,7 +5822,7 @@
},
"healthRecord": {
"title": "Exportar historial de salud",
"description": "Genere un historial de salud completo para su médico — como PDF, estructurado (FHIR) o un paquete combinado.",
"description": "Un historial completo para tu médico: en PDF, en FHIR o ambos.",
"format": "Formato",
"format_pdf": "PDF",
"format_fhir": "Estructurado (FHIR)",
@@ -5839,10 +5847,10 @@
"errorModuleDisabled": "La exportación del expediente está desactivada para esta cuenta."
},
"globalExcludedInjectionSitesLabel": "Sitios de inyección excluidos globalmente",
"globalExcludedInjectionSitesHint": "Los sitios listados aquí no se ofrecen para ningún medicamento y se rechazan al registrar una dosis, aunque un medicamento los prefiera.",
"globalExcludedInjectionSitesHint": "Nunca se ofrecen y se rechazan al registrar una dosis, aunque un medicamento los prefiera.",
"sharing": {
"createTitle": "Nuevo enlace para compartir",
"createDescription": "El enlace es de solo lectura y limitado en el tiempo. El token se muestra una sola vez al crearlo — guárdalo entonces; no se puede recuperar.",
"createDescription": "El enlace es de solo lectura y limitado en el tiempo.",
"label": "Etiqueta",
"labelPlaceholder": "p. ej. Dra. García — revisión anual",
"range": "Días de historial",
@@ -5892,15 +5900,17 @@
"scopeInsuranceExcluded": "Los datos del seguro nunca se comparten por enlace.",
"statusNeedsReselect": "Elegir contenido de nuevo",
"reselectReason": "Este enlace mostraba un alcance que nadie eligió, por eso se cerró. Cree uno nuevo y elija su contenido.",
"reselectAction": "Crear un reemplazo"
"reselectAction": "Crear un reemplazo",
"createDetail": "El token se muestra una sola vez al crearlo — guárdalo entonces; no se puede recuperar."
},
"genderOther": "Otro",
"cycleTracking": {
"title": "Seguimiento del ciclo",
"description": "Registra tu ciclo menstrual, predicciones, síntomas e información de fases. Activado por defecto en cuentas configuradas como femeninas; disponible para todos.",
"description": "Registra tu ciclo menstrual, predicciones, síntomas e información de fases.",
"enable": "Activar el seguimiento del ciclo",
"manage": "Abrir seguimiento del ciclo",
"error": "No se pudo actualizar el seguimiento del ciclo."
"error": "No se pudo actualizar el seguimiento del ciclo.",
"detail": "Activado por defecto en cuentas configuradas como femeninas; disponible para todos."
},
"nightscout": "Nightscout",
"nightscoutTag": "CGM",
@@ -5986,20 +5996,13 @@
"coach": {
"activate": {
"title": "Activar el Coach",
"description": "Muestra el botón y el panel del Coach. Activado por defecto: desactívalo para ocultar el Coach en todas partes.",
"description": "Muestra el botón y el panel del Coach.",
"toggleAria": "Activar el botón y el panel del Coach",
"savedOn": "Coach activado.",
"savedOff": "Coach oculto.",
"saveError": "No se pudo guardar el cambio. Inténtalo de nuevo."
}
},
"withingsDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la",
"whoopDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la",
"fitbitDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la",
"polarDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la",
"ouraDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la",
"nightscoutDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la",
"integrationCredentialsHint": "Dónde encontrar estas credenciales y cómo registrar tu propia aplicación se explica en la guía de configuración de arriba.",
"integrationRedirectGuide": {
"intro": "Esto se conecta mediante tu propia app de desarrollador de {provider} — unos 5 minutos de configuración:",
"step1": "Crea una app en el portal de desarrolladores de {provider}.",
@@ -6113,19 +6116,13 @@
"signOutEverywhereConfirmBody": "Todos los dispositivos salvo este tendrán que iniciar sesión de nuevo. Este seguirá con la sesión abierta. Nada de lo que has registrado se ve afectado."
},
"privacy": {
"intro": {
"title": "Tus datos siguen siendo tuyos",
"body": "HealthLog es autoalojado. Esta página reúne cómo se protegen tus datos, cuánto tiempo se conservan, cómo exportarlos y cómo eliminarlos."
},
"encryption": {
"title": "Cifrado en reposo",
"description": "Los campos sensibles se cifran en la base de datos.",
"summary": "Tus datos sensibles se cifran en reposo con {algorithm} en {columns} campos de {models} tipos de registro.",
"statement": "Tus datos sensibles se cifran en reposo con AES-256-GCM."
},
"stored": {
"title": "Qué se almacena",
"description": "Las categorías de datos que contiene esta cuenta.",
"measurements": "Mediciones (peso, presión arterial, pulso, glucosa, sueño, composición corporal)",
"medications": "Medicamentos e historial de tomas",
"moodLabs": "Entradas de estado de ánimo y resultados de laboratorio",
@@ -6143,7 +6140,7 @@
},
"export": {
"title": "Descargar mis datos",
"description": "Exporta tus registros como CSV o JSON, opcionalmente cifrados con frase de contraseña, o genera un historial médico completo.",
"description": "Tus registros en CSV o JSON, o un historial completo para tu médico.",
"openExport": "Exportar y copia de seguridad",
"openHealthRecord": "Historial médico"
},
@@ -6162,30 +6159,31 @@
},
"mcp": {
"enableTitle": "Endpoint MCP remoto",
"enableDescription": "Permite que un asistente compatible con MCP (Claude.ai, ChatGPT) lea tus datos de salud mediante un token limitado. Desactivado por defecto.",
"enableDescription": "Permite que un asistente compatible con MCP (Claude.ai, ChatGPT) lea tus datos de salud mediante un token limitado.",
"enabled": "Activado",
"disabled": "Desactivado",
"enableToggleLabel": "Activar el endpoint MCP",
"endpointLabel": "URL del conector",
"connectDescription": "Pega esta URL en la configuración del conector personalizado de tu asistente y autoriza con el token de abajo o inicia sesión cuando se te solicite.",
"tokensTitle": "Tokens del conector",
"tokensDescription": "Genera un token de solo lectura para conectar un asistente. El token se muestra una sola vez.",
"tokensDescription": "Genera un token de solo lectura para conectar un asistente.",
"scopeNote": "Los tokens se limitan a health:read: acceso de solo lectura a tus propios registros. Nunca pueden acceder a funciones de administración.",
"writeScopeToggleLabel": "Permitir registro (health:write)",
"writeScopeNote": "Añade el ámbito health:write para que el asistente pueda registrar nuevas mediciones y entradas de estado de ánimo en tu cuenta. Cada escritura se confirma antes. El token sigue sin poder eliminar ni editar entradas existentes, cambiar medicamentos ni acceder a funciones REST o de administración. Déjalo desactivado para acceso de solo lectura.",
"connectionsTitle": "Asistentes conectados",
"connectionsDescription": "Asistentes que autorizaste mediante el inicio de sesión. Revocar una conexión finaliza su acceso de inmediato, incluida su capacidad de renovación.",
"connectionsDescription": "Asistentes que autorizaste mediante el inicio de sesión.",
"noConnections": "No hay asistentes conectados.",
"connectionConnected": "Conectado",
"connectionLastUsed": "Último uso",
"connectionNeverUsed": "Nunca usado",
"connectionRevoke": "Desconectar",
"connectionRevokeDescription": "Esto finaliza el acceso del asistente de inmediato e impide su renovación. Será necesario autorizarlo de nuevo para volver a conectarlo.",
"connectionRevokeFailed": "No se pudo desconectar el asistente."
"connectionRevokeFailed": "No se pudo desconectar el asistente.",
"tokensDetail": "El token se muestra una sola vez.",
"connectionsDetail": "Revocar una conexión finaliza su acceso de inmediato, incluida su capacidad de renovación."
},
"strava": "Strava",
"stravaDescription": "Conecta tu cuenta de Strava para sincronizar tus actividades con tu historial de entrenamientos.",
"stravaDescriptionSecondary": "Las actividades llegan a tu panel en segundo plano. ¿Nuevo en la configuración? Sigue la",
"stravaViewData": "Ver tus entrenamientos",
"stravaCredentials": "Credenciales de API",
"stravaClientId": "ID de cliente",
@@ -6251,7 +6249,9 @@
"explainer": "Desactivado, los valores se evalúan con la banda de referencia general no diabética. Activado, se evalúan con la banda objetivo glucémica más estrecha de los ADA Standards of Care, y el Coach fundamenta sus comentarios sobre glucosa en esa banda. Tus propios valores objetivo personalizados de abajo siguen teniendo prioridad.",
"disclaimer": "Esto selecciona una banda de referencia. No es un diagnóstico, nunca se deduce de una medición y la aplicación no actúa clínicamente en consecuencia. Qué objetivos se aplican a ti es una pregunta para tu profesional sanitario.",
"error": "No se pudo guardar el ajuste."
}
},
"dangerZoneDetail": "Tu cuenta y las credenciales con las que inicias sesión se conservan.",
"deleteAccountCardDetail": "Esto no se puede deshacer."
},
"admin": {
"title": "Administration",
@@ -6282,7 +6282,7 @@
},
"aiServerKey": {
"title": "Clave de IA del servidor",
"description": "Una clave de proveedor a nivel de operador a la que puede recurrir cualquier cuenta cuando no hay una clave personal configurada.",
"description": "Una clave del operador para las cuentas que no tienen una propia.",
"byokHint": "La clave propia del usuario (BYOK, inicio de sesión de ChatGPT o proveedor local) siempre tiene prioridad — la clave del servidor es el último paso de la cadena de proveedores.",
"consentHint": "Las solicitudes con la clave del servidor requieren el consentimiento de IA activo del usuario; sin un recibo de consentimiento no se envía nada.",
"statusConfigured": "Configurada",
@@ -6388,7 +6388,7 @@
"monitoringTestFailed": "La prueba de monitorización falló",
"assistant": {
"title": "Superficies del asistente",
"description": "Cada conmutador oculta una superficie impulsada por el modelo en toda la aplicación sin retirar la configuración del proveedor. El interruptor maestro fuerza cada opción secundaria a off.",
"description": "Cada conmutador oculta una superficie impulsada por el modelo en toda la aplicación sin retirar la configuración del proveedor.",
"master": {
"title": "Asistente activado",
"description": "Interruptor maestro para cada superficie impulsada por el modelo. Cuando está desactivado, todas las opciones secundarias se fuerzan a off."
@@ -6412,13 +6412,13 @@
},
"modules": {
"title": "Disponibilidad de módulos",
"description": "Desactiva un módulo para todo el servidor. Un módulo desactivado aquí queda apagado para todas las cuentas, sin importar la preferencia personal. Los dominios principales (peso, presión arterial, pulso, medicación) están siempre activos y no se pueden desactivar.",
"saveError": "No se pudo actualizar la disponibilidad de módulos."
"description": "Desactiva un módulo para todo el servidor.",
"saveError": "No se pudo actualizar la disponibilidad de módulos.",
"detail": "Un módulo desactivado aquí queda apagado para todas las cuentas, sin importar la preferencia personal. Los dominios principales (peso, presión arterial, pulso, medicación) están siempre activos y no se pueden desactivar."
},
"invites": {
"title": "Invitaciones",
"description": "Enlaces de invitación para el registro: funcionan incluso con el registro cerrado. El enlace se muestra solo una vez.",
"adminOnlyHint": "Solo los administradores pueden crear y revocar invitaciones.",
"description": "Enlaces de invitación para el registro: funcionan incluso con el registro cerrado.",
"create": "Crear invitación",
"createTitle": "Crear invitación",
"createDescription": "El enlace permite registrarse incluso con el registro cerrado.",
@@ -6524,7 +6524,7 @@
"outcomeFailed": "Fallido",
"auditDetails": "Detalles",
"deleteAllData": "Eliminar todos los datos globalmente",
"deleteAllDescription": "Elimina todos los registros de todas las cuentas: mediciones, medicamentos, resultados de laboratorio, documentos, registros de ciclo, estado de ánimo, entrenamientos, sueño, conversaciones del Coach, integraciones y el historial de auditoría. Las cuentas y las credenciales de inicio de sesión se conservan.",
"deleteAllDescription": "Elimina todos los registros de todas las cuentas; las cuentas y sus credenciales de inicio de sesión se conservan.",
"deleteAllConfirm": "¿Eliminar realmente todos los datos?",
"deleteAllConfirmDescription": "Esto elimina permanentemente todos los registros de todas las cuentas en toda la base de datos, no una selección de tablas. Se conservan: las cuentas de usuario y sus credenciales de inicio de sesión, la configuración de la instancia y las concesiones de acceso que mantienen accesibles los perfiles gestionados. Después se escribe un comprobante de este borrado en el registro de auditoría; el historial anterior se va con todo lo demás.",
"deleteButton": "Eliminar todos los datos",
@@ -6573,15 +6573,10 @@
"sectionsNav": "Secciones de administración"
},
"overview": {
"welcomeTitle": "Bienvenido,{name}",
"welcomeSubtitle": "Has iniciado sesión como administrador. Elige una sección para gestionar HealthLog.",
"snapshotTitle": "Instantánea del sistema",
"snapshotVersion": "Versión",
"snapshotDatabase": "Base de datos",
"snapshotWorker": "Worker",
"snapshotStarted": "Iniciado",
"snapshotBuildSha": "Build SHA",
"snapshotBuiltAt": "Compilado el",
"snapshotOfflineGeo": "Datos geográficos",
"snapshotOfflineGeoOn": "Sin conexión (GeoLite2)",
"snapshotOfflineGeoOff": "En línea ({host})",
@@ -6618,7 +6613,7 @@
},
"coach": {
"title": "Coach",
"subtitle": "Superficies del asistente, clave del servidor y las tablas de calidad de comentarios del Coach y de los análisis, en un solo lugar."
"subtitle": "Superficies del asistente, clave del servidor y calidad del feedback."
},
"reminders": {
"title": "Recordatorios",
@@ -6674,7 +6669,7 @@
"app-logs": {
"title": "Registros de la app",
"subtitle": "Muestra los wide-events estructurados más recientes emitidos por este proceso.",
"processNote": "Solo se muestran los registros del proceso de la app; los registros del proceso del worker van a Loki cuando esa integración está configurada. El búfer es volátil y está limitado a 500 entradas.",
"processNote": "Solo se muestran los registros del proceso de la app; los registros del proceso del worker van a Loki cuando esa integración está configurada.",
"filterTraceId": "ID de traza",
"filterAction": "La acción contiene…",
"filterLevel": "Nivel",
@@ -6695,7 +6690,8 @@
"emptyDescription": "Aún no hay eventos que coincidan con los filtros actuales. Pulsa actualizar tras generar algo de tráfico.",
"refresh": "Actualizar",
"eventDetails": "Detalles del evento",
"closeDetails": "Cerrar detalles"
"closeDetails": "Cerrar detalles",
"processDetail": "El búfer es volátil y está limitado a 500 entradas."
},
"backups": {
"title": "Copias de seguridad",
@@ -6763,7 +6759,7 @@
"loading": "Cargando el estado de cifrado...",
"loadError": "No se pudo cargar el estado de cifrado.",
"coverageTitle": "Cobertura",
"coverageDescription": "Cuántas filas cifradas hay bajo cada id de clave. Solo se muestran los id de clave y los recuentos, nunca el material de la clave.",
"coverageDescription": "Cuántas filas cifradas hay bajo cada id de clave.",
"activeKeyId": "Id de clave activa",
"configuredKeys": "Claves configuradas",
"totalRows": "Filas cifradas",
@@ -6771,7 +6767,7 @@
"safeToDropLegacy": "Es seguro eliminar la clave antigua",
"rotationIncomplete": "Aún quedan {count} filas por rotar",
"rotationTitle": "Rotación de claves",
"rotationDescription": "Vuelve a cifrar cada columna cifrada con la clave activa. La herramienta de línea de comandos sigue siendo la vía canónica; este disparador es una comodidad.",
"rotationDescription": "Vuelve a cifrar cada columna cifrada con la clave activa.",
"rotationState": "Estado",
"lastRun": "Última ejecución: {scanned} revisadas, {rotated} rotadas, {errors} errores.",
"rotateNow": "Rotar ahora",
@@ -6794,7 +6790,8 @@
"running": "En curso",
"completed": "Completado",
"failed": "Fallido"
}
},
"coverageDetail": "Solo se muestran los id de clave y los recuentos, nunca el material de la clave."
}
},
"carrier": "Operador",
@@ -6840,7 +6837,8 @@
"countFew": "{count} fallos",
"countOther": "{count} fallos",
"lastFailed": "Último fallo {when}"
}
},
"deleteAllDetail": "Las cuentas de usuario se conservarán."
},
"achievements": {
"title": "Logros",
@@ -7166,7 +7164,7 @@
},
"moodReminder": {
"title": "Recordatorio diario de ánimo",
"description": "Aviso suave a la hora que elijas si aún no has registrado el ánimo del día. Se envía solo por canales ya configurados (Telegram, ntfy, Web Push, Apple Push) y permanece en silencio una vez que has registrado el ánimo hoy.",
"description": "Aviso suave a la hora que elijas si aún no has registrado el ánimo del día.",
"toggleAria": "Alternar recordatorio diario de ánimo",
"statusOn": "Activo a la hora que elijas",
"statusOff": "Desactivado",
@@ -7175,11 +7173,12 @@
"saveError": "No se pudo guardar. Inténtalo de nuevo.",
"hourLabel": "Hora del recordatorio",
"hourAria": "Hora del recordatorio de ánimo",
"hourSavedToast": "Hora del recordatorio actualizada."
"hourSavedToast": "Hora del recordatorio actualizada.",
"detail": "Se envía solo por canales ya configurados (Telegram, ntfy, Web Push, Apple Push) y permanece en silencio una vez que has registrado el ánimo hoy."
},
"coachNudge": {
"title": "Coach proactivo",
"description": "Se pone en contacto cuando tus datos sugieren consultar al Coach, por ejemplo baja adherencia a la medicación o una media semanal de tensión elevada. Los temas y la frecuencia son ajustables.",
"description": "Se pone en contacto cuando tus datos sugieren consultar al Coach.",
"toggleAria": "Activar o desactivar los avisos proactivos del Coach",
"statusOn": "Activo",
"groupsLabel": "Temas",
@@ -7206,7 +7205,7 @@
},
"lowStock": {
"title": "Aviso de existencias bajas",
"description": "Comprueba una vez al día las existencias registradas de tus medicamentos y te avisa cuando alguna cubre menos días que este umbral. Un aviso por escasez; se reactiva tras reponer.",
"description": "Avisa a diario cuando unas existencias cubren menos días que este umbral.",
"toggleAria": "Activar o desactivar los avisos de existencias bajas",
"statusOn": "Activo",
"statusOff": "Desactivado",
@@ -7220,7 +7219,8 @@
"enabledToast": "Avisos de existencias bajas activados.",
"disabledToast": "Avisos de existencias bajas desactivados.",
"saveError": "No se pudo guardar. Inténtalo de nuevo.",
"medicationsLink": "Ir a tus medicamentos"
"medicationsLink": "Ir a tus medicamentos",
"detail": "Un aviso por escasez; se reactiva tras reponer."
},
"eventMedicationReminder": "Recordatorio de medicación",
"eventMedicationReminderDesc": "Se envía a cada hora de dosis programada.",
@@ -8049,7 +8049,8 @@
"customize": "Personalizar laboratorio",
"localOcr": {
"heading": "Leer escaneos en este dispositivo",
"description": "Permite escanear un informe de laboratorio aunque tu proveedor de IA no pueda leer imágenes: la foto se lee en tu dispositivo y solo se envía el texto extraído a tu proveedor. Menos preciso: revisa cada valor antes de guardar."
"description": "Lee la foto en tu dispositivo y envía solo el texto extraído a tu proveedor.",
"detail": "Útil cuando tu proveedor de IA no puede leer imágenes. Menos preciso: revisa cada valor antes de guardar."
},
"sort": {
"heading": "Orden",
@@ -9623,7 +9624,7 @@
},
"invite": {
"title": "Invitar a alguien",
"description": "No se comparte nada hasta que acepte. Hasta entonces la invitación está en sus ajustes y no le da nada.",
"description": "No se comparte nada hasta que acepte.",
"identifierLabel": "Usuario o correo electrónico",
"identifierPlaceholder": "El nombre con el que inicia sesión",
"identifierHint": "Necesita una cuenta en esta instancia. No se pueden enviar invitaciones a una dirección que no se haya registrado.",
@@ -9677,7 +9678,7 @@
},
"received": {
"title": "Historiales que puedo abrir",
"description": "Una invitación no te da nada hasta que la aceptas. Puedes devolver el acceso cuando quieras.",
"description": "Una invitación no te da nada hasta que la aceptas.",
"emptyTitle": "Nadie ha compartido un historial contigo",
"emptyDescription": "Cuando alguien te invite, la invitación aparecerá aquí.",
"loadError": "No se pudo cargar la lista.",
@@ -9689,7 +9690,8 @@
"renounce": "Devolver",
"renounceTitle": "¿Devolver el historial de {name}?",
"renounceBody": "Pierdes el acceso de inmediato. Esa persona puede volver a invitarte cuando quiera.",
"renounceConfirm": "Devolver"
"renounceConfirm": "Devolver",
"detail": "Puedes devolver el acceso cuando quieras."
},
"activity": {
"title": "Cuándo se abrió mi historial",
@@ -9707,13 +9709,8 @@
"addOnlyNote": "Puedes añadir cosas a este historial. Modificar o borrar entradas solo lo puede hacer {name}."
},
"lookingAfter": {
"title": "Estoy cuidando de",
"description": "Historiales que otras personas han compartido contigo.",
"levelRead": "Puede ver",
"levelWrite": "Puede ver y añadir",
"kindShared": "Registro compartido",
"kindManaged": "Perfil administrado",
"open": "Abrir el historial de {name}"
"kindManaged": "Perfil administrado"
},
"managed": {
"title": "Perfiles de los que te ocupas",
+90 -93
View File
@@ -2984,7 +2984,8 @@
"valuesBack": "Retour aux Insights",
"valuesBackToMetric": "Retour à {metric}",
"scoresBack": "Retour aux Insights",
"ecgDescription": "Enregistrements ECG à dérivation unique de votre appareil, chacun accompagné du résultat de l'appareil. HealthLog affiche le tracé et l'enregistrement, mais n'interprète pas la courbe."
"ecgDescription": "Enregistrements ECG à dérivation unique de votre appareil, chacun accompagné du résultat de l'appareil.",
"ecgDisclaimer": "HealthLog affiche le tracé et l'enregistrement, mais n'interprète pas la courbe."
},
"units": {
"steps": "pas",
@@ -3971,12 +3972,13 @@
},
"pillOrder": {
"title": "Trier les onglets de navigation",
"description": "Faites glisser les onglets dans l'ordre souhaité et affichez-les ou masquez-les via l'icône en forme d'œil. Les pages de détail masquées disparaissent aussi de la navigation supérieure.",
"description": "Faites glisser les onglets dans l'ordre souhaité et affichez-les ou masquez-les via l'icône en forme d'œil.",
"dragHandle": "Réorganiser",
"saveSuccess": "Ordre des onglets enregistré.",
"saveError": "L'enregistrement de l'ordre des onglets a échoué.",
"save": "Enregistrer",
"empty": "Aucun onglet de navigation disponible pour le moment."
"empty": "Aucun onglet de navigation disponible pour le moment.",
"detail": "Les pages de détail masquées disparaissent aussi de la navigation supérieure."
},
"customize": "Personnaliser les Analyses",
"regenerateRateLimited": "Limite d'actualisation atteinte — réessayez plus tard.",
@@ -4458,7 +4460,7 @@
"settings": {
"researchMode": {
"sectionTitle": "Mode Recherche",
"subtitle": "Affichage optionnel d'une courbe de niveau estimé de médicament GLP-1, dérivée de la pharmacocinétique de population publiée par l'EMA. Lecture seule — jamais une mesure ni un conseil médical.",
"subtitle": "Courbe optionnelle du niveau estimé de GLP-1, d'après les données de l'EMA.",
"toggleLabel": "Afficher la courbe estimée du niveau de médicament",
"disabledStatus": "Désactivé. La courbe est masquée.",
"enabledStaleStatus": "Le disclaimer a été mis à jour. Reconfirmez ci-dessous pour réafficher la courbe.",
@@ -4466,7 +4468,8 @@
"rePromptTitle": "Disclaimer mis à jour",
"rePromptBody": "Le disclaimer du mode Recherche a été mis à jour depuis votre dernière confirmation. Relisez-le et reconfirmez pour garder la courbe visible.",
"rePromptCta": "Reconfirmer le disclaimer",
"disableError": "Impossible de désactiver le mode Recherche. Veuillez réessayer."
"disableError": "Impossible de désactiver le mode Recherche. Veuillez réessayer.",
"subtitleDetail": "Lecture seule — jamais une mesure ni un conseil médical."
},
"shell": {
"sectionsNav": "Sections des réglages",
@@ -4593,7 +4596,8 @@
},
"unavailableTitle": "Indisponible dans ce dossier partagé",
"unavailableDescription": "Cette section des réglages est volontairement indisponible lorsque vous consultez le dossier d’une autre personne.",
"integrationStatusDescription": "État de connexion pour ce dossier géré. Les commandes de connexion ne sont pas disponibles."
"integrationStatusDescription": "État de connexion pour ce dossier géré.",
"integrationStatusDetail": "Les commandes de connexion ne sont pas disponibles."
},
"sections": {
"account": {
@@ -4606,7 +4610,6 @@
"description": "Services connectés, canaux de diffusion et priorité des sources au même endroit.",
"subtitle": "Connectez et gérez vos sources de données et appareils.",
"connectionsHeading": "Connexions",
"connectionsDescription": "Connectez et gérez vos sources de données et appareils.",
"channelsHeading": "Canaux de diffusion",
"channelsDescription": "Où sont envoyés les rappels et alertes.",
"matrixLink": "Notifications par canal"
@@ -4616,7 +4619,6 @@
"description": "Tous les rappels au même endroit — quoi et quand, et où c'est envoyé.",
"subtitle": "Choisissez les rappels que vous recevez et où ils sont envoyés.",
"remindersHeading": "Rappels",
"remindersDescription": "Ce que vous recevez.",
"matrixLink": "Notifications par canal",
"channelsLink": "Canaux de diffusion"
},
@@ -4670,7 +4672,7 @@
"integrationsHint": "Connectez ou déconnectez des sources de données sur la page Intégrations.",
"integrationsHintLink": "Ouvrir les intégrations",
"cardTitle": "Priorité des sources par métrique",
"help": "Si plusieurs sources fournissent la même valeur le même jour, la source en tête compte comme canonique. Les autres restent enregistrées pour la piste d’audit.",
"help": "Si plusieurs sources fournissent la même valeur le même jour, la source en tête compte comme canonique.",
"resetDefaults": "Rétablir les valeurs par défaut",
"resetDefaultsTitle": "Réinitialiser l’ordre des sources ?",
"resetDefaultsBody": "Chaque classement que vous avez défini revient à l’ordre par défaut, pour toutes les mesures et tous les types d’appareil à la fois. L’enregistrement est immédiat et irréversible.",
@@ -4730,13 +4732,15 @@
"subtitle": "Choisissez la source prioritaire en cas de doublon de mesure.",
"nutrients": {
"title": "Apports en nutriments synchronisés",
"description": "Totaux quotidiens synchronisés depuis Apple Health sur les {days} derniers jours. Lecture seule : l'app source reste propriétaire des valeurs.",
"description": "Totaux quotidiens synchronisés depuis Apple Health sur les {days} derniers jours.",
"empty": "Aucune donnée de nutriments synchronisée pour l'instant.",
"latest": "{amount} {unit} le {date}",
"daysWithData": "{count} jours sur {days}",
"moduleHint": "La synchronisation est contrôlée par le module Apports en nutriments.",
"moduleHintLink": "Gérer les modules"
}
"moduleHintLink": "Gérer les modules",
"detail": "Lecture seule : l'app source reste propriétaire des valeurs."
},
"helpDetail": "Les autres restent enregistrées pour la piste d’audit."
},
"ai": {
"title": "Fournisseur d'IA",
@@ -4781,13 +4785,15 @@
},
"fullBackup": {
"title": "Sauvegarde complète",
"description": "Vos mesures, médicaments, humeur, cycle, analyses, épisodes de maladie, allergies, antécédents familiaux, nutriments, séances, votre profil de santé et vos mesures personnalisées dans un seul fichier JSON. Même format que la sauvegarde automatique hebdomadaire. Certains types d'enregistrement ne sont pas encore inclus : le manifeste du fichier indique ce qu'il contient.",
"description": "L'ensemble de votre dossier dans un fichier JSON.",
"scopeNote": "Les métadonnées des documents et le résumé IA, s'il existe, sont inclus en texte clair ; les fichiers originaux des documents ainsi que les tracés GPS/données d'échantillons des entraînements ne le sont pas — téléchargez-les séparément depuis le coffre de documents ou l'export des entraînements.",
"encryptToggle": "Chiffrer avec une phrase secrète",
"passphrasePlaceholder": "Phrase secrète (12 caractères min.)",
"passphraseHelp": "Dérive la clé de l'archive avec Argon2id et la scelle avec AES-256-GCM. Enregistrez le fichier avec l'extension .hlx.",
"noRecoveryWarning": "Aucune récupération possible : si vous perdez cette phrase secrète, l'archive ne pourra pas être ouverte. Conservez-la en lieu sûr.",
"stepUpNeeded": "Confirmez votre second facteur, puis réessayez."
"stepUpNeeded": "Confirmez votre second facteur, puis réessayez.",
"detail": "Même format que la sauvegarde automatique hebdomadaire. Certains types d'enregistrement ne sont pas encore inclus : le manifeste du fichier indique ce qu'il contient.",
"contents": "Vos mesures, médicaments, humeur, cycle, analyses, épisodes de maladie, allergies, antécédents familiaux, nutriments, séances, votre profil de santé et vos mesures personnalisées dans un seul fichier JSON."
}
},
"import": {
@@ -4841,7 +4847,7 @@
},
"csv": {
"title": "Fichier CSV",
"description": "Importez des mesures depuis un tableur ou l'export d'un appareil. Une ligne par relevé.",
"description": "Importez des mesures depuis un tableur ou l'export d'un appareil.",
"pasteLabel": "Coller le CSV",
"fileInputLabel": "Choisir un fichier CSV à importer",
"schemaHint": "Colonnes : type, value, unit, measuredAt (avec décalage horaire) et, en option, glucoseContext, notes, externalId.",
@@ -4881,11 +4887,12 @@
"reasonExternalIdTooLong": "l'identifiant externe est trop long",
"reasonUnstableExternalId": "l'identifiant externe n'est pas stable d'un export à l'autre",
"reasonDuplicate": "déjà importée",
"reasonUnknown": "ignorée"
"reasonUnknown": "ignorée",
"detail": "Une ligne par relevé."
},
"doseHistory": {
"title": "Historique des prises",
"description": "Un historique de prises exporté depuis une autre application de suivi, lu tel quel. Un fichier couvre tous les médicaments qu'il contient.",
"description": "Un historique de prises exporté depuis une autre application de suivi, lu tel quel.",
"pasteLabel": "Coller l'export",
"fileInputLabel": "Choisir un fichier d'historique des prises",
"uploadFile": "Choisir un fichier",
@@ -4935,7 +4942,8 @@
"jsonNotAnArray": "Ce JSON ne contient aucune liste de prises.",
"jsonNoIntakeTime": "L'export JSON consigne le moment où une dose était due, pas celui où elle a été prise, et une heure prévue ne sera pas écrite comme si c'était la prise. Exportez le CSV, qui porte les deux.",
"tooManyRows": "Ce fichier contient plus de lignes qu'un import peut en prendre. Découpez-le et importez les parties."
}
},
"detail": "Un fichier couvre tous les médicaments qu'il contient."
}
},
"subtitle": "Exportez vos données ou importez depuis une sauvegarde."
@@ -4986,7 +4994,7 @@
"error": "Échec de l'enregistrement. Veuillez réessayer.",
"toggleable": {
"title": "Ce que vous suivez",
"description": "Domaines optionnels. Les modules désactivés disparaissent de la navigation, des tuiles et des analyses ; vos données sont conservées."
"description": "Les modules désactivés disparaissent de la navigation, des tuiles et des analyses ; vos données sont conservées."
},
"manageIn": "Gérer dans {section}",
"operatorDisabled": "Désactivé sur tout le serveur",
@@ -5374,7 +5382,7 @@
"testReasonUnreachable": "Impossible de joindre le fournisseur d’IA.",
"coachMemory": {
"title": "Ce dont le Coach se souvient",
"description": "Les informations durables que vous avez confiées au Coach. Il s'en sert pour garder ses réponses pertinentes. Supprimez tout ce qu'il vaut mieux qu'il oublie.",
"description": "Les informations durables que vous avez confiées au Coach.",
"empty": "Le Coach n'a encore enregistré aucune information. Au fil de vos échanges, il retient les détails durables que vous partagez — vos objectifs, vos affections, vos préférences — et vous pouvez les effacer ici à tout moment.",
"learnedPrefix": "appris {when}",
"categoryPreference": "Préférences",
@@ -5397,11 +5405,12 @@
"forgotToast": "Information oubliée.",
"forgotError": "Impossible d'oublier cette information. Veuillez réessayer.",
"clearedToast": "{count} informations enregistrées oubliées.",
"clearError": "Impossible d'effacer les informations enregistrées. Veuillez réessayer."
"clearError": "Impossible d'effacer les informations enregistrées. Veuillez réessayer.",
"detail": "Il s'en sert pour garder ses réponses pertinentes. Supprimez tout ce qu'il vaut mieux qu'il oublie."
},
"coachReminders": {
"title": "Tes rappels",
"description": "Les choses que tu as demandé au Coach de te rappeler plus tard. Celles qui arrivent à échéance apparaissent ici et chez le Coach.",
"description": "Les choses que tu as demandé au Coach de te rappeler plus tard.",
"empty": "Aucun rappel pour l'instant. Demande au Coach de te rappeler quelque chose et cela apparaîtra ici.",
"loadError": "Impossible de charger tes rappels.",
"dueHeading": "À échéance",
@@ -5422,7 +5431,8 @@
},
"deleteConfirmTitle": "Supprimer ce rappel ?",
"deleteConfirmBody": "Il ne se déclenchera plus. Ce que tu as déjà enregistré reste intact.",
"deleteConfirmAction": "Supprimer"
"deleteConfirmAction": "Supprimer",
"detail": "Celles qui arrivent à échéance apparaissent ici et chez le Coach."
},
"aboutMe": {
"title": "À propos de moi",
@@ -5596,7 +5606,6 @@
"googleHealthTag": "Fitbit et Pixel",
"googleHealthBetaBadge": "Bêta",
"googleHealthDescription": "Connecte Fitbit, Pixel Watch et Fitbit Air via ton compte Google — poids, fréquence cardiaque, sommeil et activité. Cela remplace la connexion Fitbit classique, que Google arrête en septembre 2026.",
"googleHealthDescriptionSecondary": "Tu enregistres ton propre client Google Cloud et saisis les identifiants ici. L'API ne fournit ni stress ni disponibilité, et Google peut te demander de te reconnecter de temps en temps. Première configuration ? Suis le",
"googleHealthCredentials": "Identifiants d'API",
"googleHealthClientId": "ID client",
"googleHealthClientSecret": "Secret client",
@@ -5696,7 +5705,7 @@
"workouts": "Séances"
},
"integrationFallback": {
"description": "Configuré sur ce compte sans carte de réglages dédiée. Cette ligne existe pour qu'une connexion ne puisse pas cesser de fonctionner sans que personne ne le voie.",
"description": "Configuré sur ce compte sans carte de réglages dédiée.",
"lastAttempt": "Dernière tentative {relative}",
"noAttempt": "Aucune tentative de synchronisation enregistrée"
},
@@ -5705,7 +5714,7 @@
"fitbitViewData": "Voir votre sommeil",
"googleHealthViewData": "Voir vos pas",
"apiTokens": "Jetons API",
"apiTokensDescription": "Consultez et révoquez les jetons API pour l’enregistrement externe des prises de médicaments. Les jetons sont émis par médicament depuis son interrupteur de point de terminaison API.",
"apiTokensDescription": "Consultez et révoquez les jetons API pour l’enregistrement externe des prises de médicaments.",
"tokenMintMovedDescription": "Les jetons sont émis par médicament. Ouvrez un médicament et activez son point de terminaison API pour obtenir un jeton limité à ce seul médicament.",
"tokenNamePlaceholder": "Nom du jeton (par exemple Raccourci iPhone)",
"tokenCreated": "Jeton créé — copiez-le maintenant ! Il ne sera plus affiché.",
@@ -5736,14 +5745,14 @@
"apiEndpointAuth": "Authentification",
"apiEndpointExample": "Exemple de corps",
"dangerZone": "Supprimer toutes les données",
"dangerZoneDescription": "Efface l’intégralité de votre dossier de santé et toutes les connexions d’intégration. Votre compte et vos identifiants de connexion sont conservés.",
"dangerZoneDescription": "Efface l’intégralité de votre dossier de santé et toutes les connexions d’intégration.",
"dangerZoneConfirm": "Supprimer toutes les données ?",
"dangerZoneConfirmDescription": "Cette action supprime définitivement tout ce que contient votre dossier : mesures, médicaments, entrées d’humeur, résultats de laboratoire, entrées de cycle, documents, conversations avec l’IA et toutes les connexions d’intégration, ainsi que tout ce qui en découle et tout ce que vous aviez supprimé auparavant. Les liens de partage remis à un cabinet cessent aussitôt de fonctionner. Votre compte reste en place, de même que vos identifiants de connexion et vos réglages d’affichage comme la langue, le fuseau horaire et les unités. Cette action est irréversible.",
"dangerZoneSuccess": "Votre dossier de santé a été supprimé. Votre compte et votre connexion sont inchangés.",
"dangerZoneDeleteFailed": "Échec de la suppression",
"finalDelete": "Supprimer définitivement",
"deleteAccountCardTitle": "Supprimer entièrement le compte",
"deleteAccountCardDescription": "Supprime votre compte ainsi que les clés d’accès, le journal d’audit et les sessions. Cette action est irréversible.",
"deleteAccountCardDescription": "Supprime votre compte ainsi que les clés d’accès, le journal d’audit et les sessions.",
"deleteAccountCta": "Supprimer le compte",
"deleteAccountConfirmTitle": "Supprimer définitivement le compte ?",
"deleteAccountConfirmDescription": "Votre compte, toutes vos données de santé, vos clés d’accès, vos sessions et vos entrées d’audit seront définitivement supprimés. Vous serez déconnecté immédiatement après.",
@@ -5761,8 +5770,6 @@
"changelog": "Changelog",
"docs": "Documentation",
"linksHeading": "Sources et documentation",
"identityDescription": "Version, licence et détails de build de cette instance.",
"linksDescription": "Code source, journal des modifications et documentation.",
"newerAvailable": "Nouvelle version : {tag}"
},
"testConnection": {
@@ -5790,7 +5797,7 @@
"notificationStatus": {
"title": "Fiabilité des canaux",
"description": "État par canal ; « Désactivé automatiquement » arrête les nouvelles tentatives après des erreurs permanentes.",
"emptyDescription": "Aucun canal configuré pour l’instant. Ajoutez-en un dans Canaux de diffusion pour suivre la distribution.",
"emptyDescription": "Ajoutez un canal dans Paramètres › Intégrations › Canaux de diffusion.",
"stateActive": "Actif",
"stateAutoDisabled": "Désactivé automatiquement",
"stateSendingPaused": "Envoi en pause",
@@ -5801,7 +5808,8 @@
"disabledReason": "Raison",
"nextRetry": "Prochaine tentative",
"reEnable": "Réactiver",
"sendTest": "Envoyer un test"
"sendTest": "Envoyer un test",
"emptyTitle": "Aucun canal configuré pour l’instant"
},
"identity": {
"description": "Informations facultatives pour l’export du dossier de santé (couverture PDF + export FHIR). Tous les champs sont facultatifs.",
@@ -5814,7 +5822,7 @@
},
"healthRecord": {
"title": "Exporter le dossier de santé",
"description": "Générez un dossier de santé complet pour votre médecin — en PDF, structuré (FHIR) ou en paquet combiné.",
"description": "Un dossier complet pour votre médecin — en PDF, en FHIR ou les deux.",
"format": "Format",
"format_pdf": "PDF",
"format_fhir": "Structuré (FHIR)",
@@ -5839,10 +5847,10 @@
"errorModuleDisabled": "L'export du dossier est désactivé pour ce compte."
},
"globalExcludedInjectionSitesLabel": "Sites d'injection exclus globalement",
"globalExcludedInjectionSitesHint": "Les sites listés ici ne sont proposés pour aucun médicament et sont refusés lors de l'enregistrement d'une dose, même si un médicament les privilégie.",
"globalExcludedInjectionSitesHint": "Jamais proposés et refusés à l'enregistrement, même si un médicament les privilégie.",
"sharing": {
"createTitle": "Nouveau lien de partage",
"createDescription": "Le lien est en lecture seule et limité dans le temps. Le jeton n'est affiché qu'une fois à la création — conservez-le alors ; il ne peut pas être récupéré.",
"createDescription": "Le lien est en lecture seule et limité dans le temps.",
"label": "Libellé",
"labelPlaceholder": "ex. Dr Martin — bilan annuel",
"range": "Jours d'historique",
@@ -5892,15 +5900,17 @@
"scopeInsuranceExcluded": "Les données d'assurance ne sont jamais partagées via un lien.",
"statusNeedsReselect": "Choisir à nouveau le contenu",
"reselectReason": "Ce lien diffusait un périmètre que personne n'avait choisi ; il a donc été fermé. Créez-en un nouveau et choisissez son contenu.",
"reselectAction": "Créer un remplacement"
"reselectAction": "Créer un remplacement",
"createDetail": "Le jeton n'est affiché qu'une fois à la création — conservez-le alors ; il ne peut pas être récupéré."
},
"genderOther": "Autre",
"cycleTracking": {
"title": "Suivi du cycle",
"description": "Suivez votre cycle menstruel, les prédictions, les symptômes et les analyses de phases. Activé par défaut pour les comptes définis comme féminins ; disponible pour tous.",
"description": "Suivez votre cycle menstruel, les prédictions, les symptômes et les analyses de phases.",
"enable": "Activer le suivi du cycle",
"manage": "Ouvrir le suivi du cycle",
"error": "Impossible de mettre à jour le suivi du cycle."
"error": "Impossible de mettre à jour le suivi du cycle.",
"detail": "Activé par défaut pour les comptes définis comme féminins ; disponible pour tous."
},
"nightscout": "Nightscout",
"nightscoutTag": "CGM",
@@ -5986,20 +5996,13 @@
"coach": {
"activate": {
"title": "Activer le Coach",
"description": "Affiche le bouton et le panneau du Coach. Activé par défaut — désactivez-le pour masquer le Coach partout.",
"description": "Affiche le bouton et le panneau du Coach.",
"toggleAria": "Activer le bouton et le panneau du Coach",
"savedOn": "Coach activé.",
"savedOff": "Coach masqué.",
"saveError": "Impossible d enregistrer la modification. Veuillez réessayer."
}
},
"withingsDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le",
"whoopDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le",
"fitbitDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le",
"polarDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le",
"ouraDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le",
"nightscoutDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le",
"integrationCredentialsHint": "Où trouver ces identifiants et comment enregistrer ta propre application est expliqué dans le guide de configuration ci-dessus.",
"integrationRedirectGuide": {
"intro": "Cette connexion passe par votre propre application développeur {provider} — environ 5 minutes de configuration :",
"step1": "Créez une application dans le portail développeur {provider}.",
@@ -6113,19 +6116,13 @@
"signOutEverywhereConfirmBody": "Chaque appareil sauf celui-ci devra se reconnecter. Celui-ci reste connecté. Rien de ce que tu as enregistré n'est affecté."
},
"privacy": {
"intro": {
"title": "Vos données vous appartiennent",
"body": "HealthLog est auto-hébergé. Cette page réunit la façon dont vos données sont protégées, leur durée de conservation, comment les exporter et comment les supprimer."
},
"encryption": {
"title": "Chiffré au repos",
"description": "Les champs sensibles sont chiffrés dans la base de données.",
"summary": "Vos données sensibles sont chiffrées au repos avec {algorithm} sur {columns} champs dans {models} types d'enregistrement.",
"statement": "Vos données sensibles sont chiffrées au repos avec AES-256-GCM."
},
"stored": {
"title": "Ce qui est stocké",
"description": "Les catégories de données que contient ce compte.",
"measurements": "Mesures (poids, tension, pouls, glycémie, sommeil, composition corporelle)",
"medications": "Médicaments et historique des prises",
"moodLabs": "Entrées d'humeur et résultats de laboratoire",
@@ -6143,7 +6140,7 @@
},
"export": {
"title": "Télécharger mes données",
"description": "Exportez vos enregistrements en CSV ou JSON, éventuellement chiffrés par phrase secrète, ou générez un dossier médical complet.",
"description": "Vos enregistrements en CSV ou JSON, ou un dossier complet pour votre médecin.",
"openExport": "Export et sauvegarde",
"openHealthRecord": "Dossier médical"
},
@@ -6162,30 +6159,31 @@
},
"mcp": {
"enableTitle": "Point de terminaison MCP distant",
"enableDescription": "Autorisez un assistant compatible MCP (Claude.ai, ChatGPT) à lire vos données de santé via un jeton restreint. Désactivé par défaut.",
"enableDescription": "Autorisez un assistant compatible MCP (Claude.ai, ChatGPT) à lire vos données de santé via un jeton restreint.",
"enabled": "Activé",
"disabled": "Désactivé",
"enableToggleLabel": "Activer le point de terminaison MCP",
"endpointLabel": "URL du connecteur",
"connectDescription": "Collez cette URL dans la configuration du connecteur personnalisé de votre assistant, puis autorisez avec le jeton ci-dessous ou connectez-vous lorsque vous y êtes invité.",
"tokensTitle": "Jetons du connecteur",
"tokensDescription": "Générez un jeton en lecture seule pour connecter un assistant. Le jeton n'est affiché qu'une seule fois.",
"tokensDescription": "Générez un jeton en lecture seule pour connecter un assistant.",
"scopeNote": "Les jetons sont limités à health:read : accès en lecture seule à vos propres données. Ils ne peuvent jamais atteindre les fonctions d'administration.",
"writeScopeToggleLabel": "Autoriser l'enregistrement (health:write)",
"writeScopeNote": "Ajoute la portée health:write pour que l'assistant puisse enregistrer de nouvelles mesures et entrées d'humeur dans votre compte. Chaque écriture est d'abord confirmée. Le jeton ne peut toujours pas supprimer ni modifier des entrées existantes, changer les médicaments, ni atteindre les fonctions REST ou d'administration. Laissez désactivé pour un accès en lecture seule.",
"connectionsTitle": "Assistants connectés",
"connectionsDescription": "Assistants que vous avez autorisés via la connexion. Révoquer une connexion met fin à son accès immédiatement, y compris sa capacité à se renouveler.",
"connectionsDescription": "Assistants que vous avez autorisés via la connexion.",
"noConnections": "Aucun assistant connecté.",
"connectionConnected": "Connecté",
"connectionLastUsed": "Dernière utilisation",
"connectionNeverUsed": "Jamais utilisé",
"connectionRevoke": "Déconnecter",
"connectionRevokeDescription": "Cela met fin immédiatement à l'accès de l'assistant et l'empêche de se renouveler. Une nouvelle autorisation sera nécessaire pour le reconnecter.",
"connectionRevokeFailed": "Impossible de déconnecter l'assistant."
"connectionRevokeFailed": "Impossible de déconnecter l'assistant.",
"tokensDetail": "Le jeton n'est affiché qu'une seule fois.",
"connectionsDetail": "Révoquer une connexion met fin à son accès immédiatement, y compris sa capacité à se renouveler."
},
"strava": "Strava",
"stravaDescription": "Connecte ton compte Strava pour synchroniser tes activités avec ton historique d'entraînement.",
"stravaDescriptionSecondary": "Les activités arrivent sur ton tableau de bord en arrière-plan. Nouveau dans la configuration ? Suis le",
"stravaViewData": "Voir tes entraînements",
"stravaCredentials": "Identifiants API",
"stravaClientId": "ID client",
@@ -6251,7 +6249,9 @@
"explainer": "Désactivé, les valeurs sont comparées à la plage de référence générale non diabétique. Activé, elles sont comparées à la plage cible glycémique plus stricte des ADA Standards of Care, et le Coach fonde ses remarques sur le glucose sur cette plage. Vos propres valeurs cibles personnalisées ci-dessous restent prioritaires.",
"disclaimer": "Ceci sélectionne une plage de référence. Ce n'est pas un diagnostic, ce n'est jamais déduit d'une mesure, et l'application n'en tire aucune conséquence clinique. Les cibles qui s'appliquent à vous relèvent de votre médecin.",
"error": "Le réglage n'a pas pu être enregistré."
}
},
"dangerZoneDetail": "Votre compte et vos identifiants de connexion sont conservés.",
"deleteAccountCardDetail": "Cette action est irréversible."
},
"admin": {
"title": "Administration",
@@ -6282,7 +6282,7 @@
},
"aiServerKey": {
"title": "Clé IA du serveur",
"description": "Une clé de fournisseur à l'échelle de l'opérateur sur laquelle chaque compte peut se rabattre lorsqu'aucune clé personnelle n'est configurée.",
"description": "Une clé de l'opérateur pour les comptes qui n'en ont pas configuré une.",
"byokHint": "La clé personnelle de l'utilisateur (BYOK, connexion ChatGPT ou fournisseur local) a toujours la priorité — la clé du serveur est la dernière étape de la chaîne de fournisseurs.",
"consentHint": "Les requêtes via la clé du serveur nécessitent le consentement IA actif de l'utilisateur ; sans justificatif de consentement, rien n'est envoyé.",
"statusConfigured": "Configurée",
@@ -6388,7 +6388,7 @@
"monitoringTestFailed": "Échec du test de surveillance",
"assistant": {
"title": "Surfaces d'assistance",
"description": "Chaque bascule masque une surface pilotée par le modèle dans toute l'application sans retirer la configuration du fournisseur. Le commutateur principal force chaque option secondaire à off.",
"description": "Chaque bascule masque une surface pilotée par le modèle dans toute l'application sans retirer la configuration du fournisseur.",
"master": {
"title": "Assistant activé",
"description": "Commutateur principal pour chaque surface pilotée par le modèle. Lorsqu'il est désactivé, toutes les options secondaires ci-dessous sont forcées à off."
@@ -6412,13 +6412,13 @@
},
"modules": {
"title": "Disponibilité des modules",
"description": "Désactivez un module pour tout le serveur. Un module désactivé ici est coupé pour chaque compte, quelle que soit la préférence personnelle. Les domaines essentiels (poids, tension, pouls, médicaments) restent toujours actifs et ne peuvent pas être désactivés.",
"saveError": "Impossible de mettre à jour la disponibilité des modules."
"description": "Désactivez un module pour tout le serveur.",
"saveError": "Impossible de mettre à jour la disponibilité des modules.",
"detail": "Un module désactivé ici est coupé pour chaque compte, quelle que soit la préférence personnelle. Les domaines essentiels (poids, tension, pouls, médicaments) restent toujours actifs et ne peuvent pas être désactivés."
},
"invites": {
"title": "Invitations",
"description": "Liens d'invitation pour l'inscription — ils fonctionnent même lorsque les inscriptions sont fermées. Le lien n'est affiché qu'une seule fois.",
"adminOnlyHint": "Seuls les administrateurs peuvent créer et révoquer des invitations.",
"description": "Liens d'invitation pour l'inscription — ils fonctionnent même lorsque les inscriptions sont fermées.",
"create": "Créer une invitation",
"createTitle": "Créer une invitation",
"createDescription": "Le lien permet une inscription même lorsque les inscriptions sont fermées.",
@@ -6524,7 +6524,7 @@
"outcomeFailed": "Échec",
"auditDetails": "Détails",
"deleteAllData": "Supprimer toutes les données globalement",
"deleteAllDescription": "Supprime tous les enregistrements de tous les comptes : mesures, médicaments, résultats de laboratoire, documents, journaux de cycle, humeur, entraînements, sommeil, conversations du Coach, intégrations et historique d'audit. Les comptes et les identifiants de connexion sont conservés.",
"deleteAllDescription": "Supprime tous les enregistrements de tous les comptes ; les comptes et leurs identifiants de connexion sont conservés.",
"deleteAllConfirm": "Vraiment supprimer toutes les données ?",
"deleteAllConfirmDescription": "Cette action supprime définitivement tous les enregistrements de tous les comptes dans l'ensemble de la base de données, et non une sélection de tables. Sont conservés : les comptes utilisateurs et leurs identifiants de connexion, la configuration de l'instance et les délégations qui gardent les profils gérés accessibles. Un reçu de cet effacement est ensuite écrit dans le journal d'audit ; l'historique antérieur part avec le reste.",
"deleteButton": "Supprimer toutes les données",
@@ -6573,15 +6573,10 @@
"sectionsNav": "Sections d'administration"
},
"overview": {
"welcomeTitle": "Bienvenue,{name}",
"welcomeSubtitle": "Vous êtes connecté en tant qu'administrateur. Choisissez une section pour gérer HealthLog.",
"snapshotTitle": "Instantané du système",
"snapshotVersion": "Version",
"snapshotDatabase": "Base de données",
"snapshotWorker": "Worker",
"snapshotStarted": "Démarré",
"snapshotBuildSha": "Build SHA",
"snapshotBuiltAt": "Compilé le",
"snapshotOfflineGeo": "Données géographiques",
"snapshotOfflineGeoOn": "Hors ligne (GeoLite2)",
"snapshotOfflineGeoOff": "En ligne ({host})",
@@ -6618,7 +6613,7 @@
},
"coach": {
"title": "Coach",
"subtitle": "Surfaces d'assistance, clé du serveur et les tableaux de qualité des retours du Coach et des analyses, au même endroit."
"subtitle": "Surfaces d'assistance, clé du serveur et qualité des retours."
},
"reminders": {
"title": "Rappels",
@@ -6674,7 +6669,7 @@
"app-logs": {
"title": "Journaux de l'app",
"subtitle": "Affichez les wide-events structurés les plus récents émis par ce processus.",
"processNote": "Seuls les journaux du processus de l'app sont affichés — ceux du processus worker vont vers Loki lorsque cette intégration est configurée. Le tampon est volatil et limité à 500 entrées.",
"processNote": "Seuls les journaux du processus de l'app sont affichés — ceux du processus worker vont vers Loki lorsque cette intégration est configurée.",
"filterTraceId": "ID de trace",
"filterAction": "L'action contient…",
"filterLevel": "Niveau",
@@ -6695,7 +6690,8 @@
"emptyDescription": "Aucun événement ne correspond encore aux filtres actuels. Cliquez sur Actualiser après avoir généré du trafic.",
"refresh": "Actualiser",
"eventDetails": "Détails de l'événement",
"closeDetails": "Fermer les détails"
"closeDetails": "Fermer les détails",
"processDetail": "Le tampon est volatil et limité à 500 entrées."
},
"backups": {
"title": "Sauvegardes",
@@ -6763,7 +6759,7 @@
"loading": "Chargement de l'état du chiffrement...",
"loadError": "Échec du chargement de l'état du chiffrement.",
"coverageTitle": "Couverture",
"coverageDescription": "Combien de lignes chiffrées relèvent de chaque id de clé. Seuls les id de clé et les décomptes sont affichés, jamais le matériel de clé.",
"coverageDescription": "Combien de lignes chiffrées relèvent de chaque id de clé.",
"activeKeyId": "Id de clé active",
"configuredKeys": "Clés configurées",
"totalRows": "Lignes chiffrées",
@@ -6771,7 +6767,7 @@
"safeToDropLegacy": "La clé ancienne peut être retirée",
"rotationIncomplete": "{count} lignes restent à faire tourner",
"rotationTitle": "Rotation des clés",
"rotationDescription": "Re-chiffre chaque colonne chiffrée avec la clé active. L'outil en ligne de commande reste la voie canonique ; ce déclencheur est un confort.",
"rotationDescription": "Re-chiffre chaque colonne chiffrée avec la clé active.",
"rotationState": "État",
"lastRun": "Dernière exécution : {scanned} analysées, {rotated} tournées, {errors} erreurs.",
"rotateNow": "Faire tourner maintenant",
@@ -6794,7 +6790,8 @@
"running": "En cours",
"completed": "Terminé",
"failed": "Échoué"
}
},
"coverageDetail": "Seuls les id de clé et les décomptes sont affichés, jamais le matériel de clé."
}
},
"carrier": "Opérateur",
@@ -6840,7 +6837,8 @@
"countFew": "{count} échecs",
"countOther": "{count} échecs",
"lastFailed": "Dernier échec {when}"
}
},
"deleteAllDetail": "Les comptes utilisateurs sont conservés."
},
"achievements": {
"title": "Réussites",
@@ -7166,7 +7164,7 @@
},
"moodReminder": {
"title": "Rappel quotidien d’humeur",
"description": "Petit rappel à l’heure que tu as choisie si tu n’as pas encore noté ton humeur du jour. Utilise uniquement les canaux déjà configurés (Telegram, ntfy, Web Push, Apple Push) et reste silencieux dès que tu as noté ton humeur aujourd’hui.",
"description": "Petit rappel à l’heure que tu as choisie si tu n’as pas encore noté ton humeur du jour.",
"toggleAria": "Activer / désactiver le rappel quotidien d’humeur",
"statusOn": "Actif à l’heure que tu as choisie",
"statusOff": "Désactivé",
@@ -7175,11 +7173,12 @@
"saveError": "Échec de l’enregistrement. Réessayez.",
"hourLabel": "Heure du rappel",
"hourAria": "Heure du rappel d’humeur",
"hourSavedToast": "Heure du rappel mise à jour."
"hourSavedToast": "Heure du rappel mise à jour.",
"detail": "Utilise uniquement les canaux déjà configurés (Telegram, ntfy, Web Push, Apple Push) et reste silencieux dès que tu as noté ton humeur aujourd’hui."
},
"coachNudge": {
"title": "Coach proactif",
"description": "Vous contacte lorsque vos données suggèrent de consulter le Coach — par exemple une faible observance ou une moyenne hebdomadaire de tension élevée. Les thèmes et la fréquence sont réglables.",
"description": "Vous contacte lorsque vos données suggèrent de consulter le Coach.",
"toggleAria": "Activer ou désactiver les rappels proactifs du Coach",
"statusOn": "Actif",
"groupsLabel": "Thèmes",
@@ -7206,7 +7205,7 @@
},
"lowStock": {
"title": "Alerte de stock faible",
"description": "Vérifie une fois par jour les stocks suivis de vos médicaments et vous prévient quand l’un d’eux couvre moins de jours que ce seuil. Une notification par pénurie ; elle se réarme après un réapprovisionnement.",
"description": "Signale chaque jour un stock couvrant moins de jours que ce seuil.",
"toggleAria": "Activer ou désactiver les alertes de stock faible",
"statusOn": "Actif",
"statusOff": "Désactivé",
@@ -7220,7 +7219,8 @@
"enabledToast": "Alertes de stock faible activées.",
"disabledToast": "Alertes de stock faible désactivées.",
"saveError": "Échec de l’enregistrement. Réessayez.",
"medicationsLink": "Voir tes médicaments"
"medicationsLink": "Voir tes médicaments",
"detail": "Une notification par pénurie ; elle se réarme après un réapprovisionnement."
},
"eventMedicationReminder": "Rappel de médicament",
"eventMedicationReminderDesc": "Envoyé à chaque heure de prise prévue.",
@@ -8049,7 +8049,8 @@
"customize": "Personnaliser les analyses",
"localOcr": {
"heading": "Lire les scans sur cet appareil",
"description": "Permet de scanner un compte rendu de laboratoire même si votre fournisseur d'IA ne peut pas lire les images : la photo est lue sur votre appareil et seul le texte extrait est envoyé à votre fournisseur. Moins précis — vérifiez chaque valeur avant d'enregistrer."
"description": "Lit la photo sur votre appareil et n'envoie que le texte extrait à votre fournisseur.",
"detail": "Utile quand votre fournisseur d'IA ne lit pas les images. Moins précis — vérifiez chaque valeur avant d'enregistrer."
},
"sort": {
"heading": "Tri",
@@ -9623,7 +9624,7 @@
},
"invite": {
"title": "Inviter quelqu'un",
"description": "Rien n'est partagé avant son acceptation. D'ici là, l'invitation reste dans ses réglages et ne lui donne rien.",
"description": "Rien n'est partagé avant son acceptation.",
"identifierLabel": "Nom d'utilisateur ou e-mail",
"identifierPlaceholder": "Le nom avec lequel elle se connecte",
"identifierHint": "Cette personne a besoin d'un compte sur cette instance. Une invitation ne peut pas être envoyée à une adresse non inscrite.",
@@ -9677,7 +9678,7 @@
},
"received": {
"title": "Dossiers que je peux ouvrir",
"description": "Une invitation ne vous donne rien avant que vous l'acceptiez. Vous pouvez rendre l'accès quand vous voulez.",
"description": "Une invitation ne vous donne rien avant que vous l'acceptiez.",
"emptyTitle": "Personne n'a partagé de dossier avec vous",
"emptyDescription": "Quand quelqu'un vous invite, l'invitation apparaît ici.",
"loadError": "La liste n'a pas pu être chargée.",
@@ -9689,7 +9690,8 @@
"renounce": "Rendre",
"renounceTitle": "Rendre le dossier de {name} ?",
"renounceBody": "Vous perdez l'accès immédiatement. Cette personne peut vous inviter de nouveau à tout moment.",
"renounceConfirm": "Rendre"
"renounceConfirm": "Rendre",
"detail": "Vous pouvez rendre l'accès quand vous voulez."
},
"activity": {
"title": "Quand mon dossier a été ouvert",
@@ -9707,13 +9709,8 @@
"addOnlyNote": "Vous pouvez ajouter des éléments à ce dossier. Seul {name} peut modifier ou supprimer des entrées."
},
"lookingAfter": {
"title": "Je m'occupe de",
"description": "Les dossiers que d'autres personnes partagent avec vous.",
"levelRead": "Peut consulter",
"levelWrite": "Peut consulter et ajouter",
"kindShared": "Dossier partagé",
"kindManaged": "Profil géré",
"open": "Ouvrir le dossier de {name}"
"kindManaged": "Profil géré"
},
"managed": {
"title": "Profils dont vous vous occupez",
+90 -93
View File
@@ -2984,7 +2984,8 @@
"valuesBack": "Torna a Insights",
"valuesBackToMetric": "Torna a {metric}",
"scoresBack": "Torna a Insights",
"ecgDescription": "Registrazioni ECG a singola derivazione dal tuo dispositivo, ciascuna con il risultato del dispositivo. HealthLog mostra il tracciato e la registrazione, ma non interpreta l'onda."
"ecgDescription": "Registrazioni ECG a singola derivazione dal tuo dispositivo, ciascuna con il risultato del dispositivo.",
"ecgDisclaimer": "HealthLog mostra il tracciato e la registrazione, ma non interpreta l'onda."
},
"units": {
"steps": "passi",
@@ -3971,12 +3972,13 @@
},
"pillOrder": {
"title": "Ordina le schede di navigazione",
"description": "Trascina le schede nell'ordine desiderato e mostrale o nascondile con l'icona dell'occhio. Le pagine di dettaglio nascoste vengono rimosse anche dalla navigazione in alto.",
"description": "Trascina le schede nell'ordine desiderato e mostrale o nascondile con l'icona dell'occhio.",
"dragHandle": "Riordina",
"saveSuccess": "Ordine delle schede salvato.",
"saveError": "Salvataggio dell'ordine delle schede non riuscito.",
"save": "Salva",
"empty": "Nessuna scheda di navigazione ancora disponibile."
"empty": "Nessuna scheda di navigazione ancora disponibile.",
"detail": "Le pagine di dettaglio nascoste vengono rimosse anche dalla navigazione in alto."
},
"customize": "Personalizza gli Approfondimenti",
"regenerateRateLimited": "Limite di aggiornamento raggiunto — riprova più tardi.",
@@ -4458,7 +4460,7 @@
"settings": {
"researchMode": {
"sectionTitle": "Modalità Ricerca",
"subtitle": "Visualizzazione opzionale di una curva stimata del livello del farmaco GLP-1, derivata dalla farmacocinetica di popolazione pubblicata dall'EMA. Solo lettura: mai una misurazione né un consiglio medico.",
"subtitle": "Curva opzionale del livello stimato di GLP-1, da dati pubblicati dall'EMA.",
"toggleLabel": "Mostra la curva stimata del livello del farmaco",
"disabledStatus": "Disattivata. La curva è nascosta.",
"enabledStaleStatus": "Il disclaimer è stato aggiornato. Riconferma sotto per rivedere la curva.",
@@ -4466,7 +4468,8 @@
"rePromptTitle": "Disclaimer aggiornato",
"rePromptBody": "Il disclaimer della modalità Ricerca è stato aggiornato dall'ultima tua conferma. Rileggilo e riconferma per mantenere visibile la curva.",
"rePromptCta": "Riconferma disclaimer",
"disableError": "Impossibile disattivare la modalità Ricerca. Riprova."
"disableError": "Impossibile disattivare la modalità Ricerca. Riprova.",
"subtitleDetail": "Solo lettura: mai una misurazione né un consiglio medico."
},
"shell": {
"sectionsNav": "Sezioni delle impostazioni",
@@ -4593,7 +4596,8 @@
},
"unavailableTitle": "Non disponibile in questo record condiviso",
"unavailableDescription": "Questa area delle Impostazioni non è disponibile intenzionalmente mentre visualizzi il record di un’altra persona.",
"integrationStatusDescription": "Stato di connessione per questo record gestito. I controlli di connessione non sono disponibili."
"integrationStatusDescription": "Stato di connessione per questo record gestito.",
"integrationStatusDetail": "I controlli di connessione non sono disponibili."
},
"sections": {
"account": {
@@ -4606,7 +4610,6 @@
"description": "Servizi collegati, canali di consegna e priorità delle fonti in un unico posto.",
"subtitle": "Collega e gestisci le tue fonti di dati e i dispositivi.",
"connectionsHeading": "Connessioni",
"connectionsDescription": "Collega e gestisci le tue fonti di dati e i dispositivi.",
"channelsHeading": "Canali di recapito",
"channelsDescription": "Dove vengono recapitati promemoria e avvisi.",
"matrixLink": "Notifiche per canale"
@@ -4616,7 +4619,6 @@
"description": "Tutti i promemoria in un unico posto — cosa e quando, e dove vengono recapitati.",
"subtitle": "Scegli quali promemoria ricevere e dove vengono recapitati.",
"remindersHeading": "Promemoria",
"remindersDescription": "Ciò che ricevi.",
"matrixLink": "Notifiche per canale",
"channelsLink": "Canali di recapito"
},
@@ -4670,7 +4672,7 @@
"integrationsHint": "Collega o scollega le fonti dati nella pagina Integrazioni.",
"integrationsHintLink": "Apri le integrazioni",
"cardTitle": "Priorità delle fonti per metrica",
"help": "Se più fonti forniscono lo stesso valore nello stesso giorno, la fonte in cima conta come canonica. Le altre restano memorizzate per la traccia di controllo.",
"help": "Se più fonti forniscono lo stesso valore nello stesso giorno, la fonte in cima conta come canonica.",
"resetDefaults": "Ripristina i valori predefiniti",
"resetDefaultsTitle": "Reimpostare l’ordine delle fonti?",
"resetDefaultsBody": "Ogni gerarchia che hai impostato torna all’ordine predefinito, per tutte le metriche e tutti i tipi di dispositivo insieme. Viene salvato subito e non può essere recuperato.",
@@ -4730,13 +4732,15 @@
"subtitle": "Decidi quale fonte prevale quando due riportano la stessa metrica.",
"nutrients": {
"title": "Assunzione di nutrienti sincronizzata",
"description": "Totali giornalieri sincronizzati da Apple Health negli ultimi {days} giorni. Sola lettura: i valori appartengono all'app di origine.",
"description": "Totali giornalieri sincronizzati da Apple Health negli ultimi {days} giorni.",
"empty": "Nessun dato sui nutrienti ancora sincronizzato.",
"latest": "{amount} {unit} il {date}",
"daysWithData": "{count} giorni su {days}",
"moduleHint": "La sincronizzazione è controllata dal modulo Assunzione di nutrienti.",
"moduleHintLink": "Gestisci moduli"
}
"moduleHintLink": "Gestisci moduli",
"detail": "Sola lettura: i valori appartengono all'app di origine."
},
"helpDetail": "Le altre restano memorizzate per la traccia di controllo."
},
"ai": {
"title": "Provider IA",
@@ -4781,13 +4785,15 @@
},
"fullBackup": {
"title": "Backup completo",
"description": "Le tue misurazioni, i farmaci, l'umore, il ciclo, le analisi, gli episodi di malattia, le allergie, l'anamnesi familiare, i nutrienti, gli allenamenti, il tuo profilo di salute e le tue metriche personalizzate in un unico file JSON. Stesso formato del backup automatico settimanale. Alcuni tipi di dato non sono ancora inclusi: il manifest del file indica cosa contiene.",
"description": "L'intera cartella in un unico file JSON.",
"scopeNote": "I metadati dei documenti e il riepilogo IA, se presente, sono inclusi in chiaro; i file originali dei documenti e i dati di percorso GPS/campionamento degli allenamenti non lo sono — scaricali separatamente dall'archivio documenti o dall'esportazione degli allenamenti.",
"encryptToggle": "Cifra con una passphrase",
"passphrasePlaceholder": "Passphrase (min. 12 caratteri)",
"passphraseHelp": "Deriva la chiave dell'archivio con Argon2id e lo sigilla con AES-256-GCM. Salva il file con estensione .hlx.",
"noRecoveryWarning": "Non c'è recupero: se perdi questa passphrase, l'archivio non potrà essere aperto. Conservala in un luogo sicuro.",
"stepUpNeeded": "Conferma il secondo fattore e riprova."
"stepUpNeeded": "Conferma il secondo fattore e riprova.",
"detail": "Stesso formato del backup automatico settimanale. Alcuni tipi di dato non sono ancora inclusi: il manifest del file indica cosa contiene.",
"contents": "Le tue misurazioni, i farmaci, l'umore, il ciclo, le analisi, gli episodi di malattia, le allergie, l'anamnesi familiare, i nutrienti, gli allenamenti, il tuo profilo di salute e le tue metriche personalizzate in un unico file JSON."
}
},
"import": {
@@ -4841,7 +4847,7 @@
},
"csv": {
"title": "File CSV",
"description": "Importa misurazioni da un foglio di calcolo o dall'esportazione di un dispositivo. Una riga per lettura.",
"description": "Importa misurazioni da un foglio di calcolo o dall'esportazione di un dispositivo.",
"pasteLabel": "Incolla CSV",
"fileInputLabel": "Scegli un file CSV da importare",
"schemaHint": "Colonne: type, value, unit, measuredAt (con offset del fuso orario) e, facoltative, glucoseContext, notes, externalId.",
@@ -4881,11 +4887,12 @@
"reasonExternalIdTooLong": "l'ID esterno è troppo lungo",
"reasonUnstableExternalId": "l'ID esterno non è stabile tra un export e l'altro",
"reasonDuplicate": "già importata",
"reasonUnknown": "saltata"
"reasonUnknown": "saltata",
"detail": "Una riga per lettura."
},
"doseHistory": {
"title": "Storico delle assunzioni",
"description": "Uno storico di assunzioni esportato da un'altra app di monitoraggio, letto così com'è. Un file copre tutti i farmaci che contiene.",
"description": "Uno storico di assunzioni esportato da un'altra app di monitoraggio, letto così com'è.",
"pasteLabel": "Incolla l'esportazione",
"fileInputLabel": "Scegli un file con lo storico delle assunzioni",
"uploadFile": "Scegli file",
@@ -4935,7 +4942,8 @@
"jsonNotAnArray": "Quel JSON non contiene alcun elenco di dosi.",
"jsonNoIntakeTime": "L'esportazione JSON registra quando una dose era prevista, non quando è stata assunta, e un orario previsto non verrà scritto come se fosse l'assunzione. Esporta il CSV, che porta entrambi.",
"tooManyRows": "Quel file contiene più righe di quante un'importazione possa accoglierne. Dividilo e importa le parti."
}
},
"detail": "Un file copre tutti i farmaci che contiene."
}
},
"subtitle": "Esporta i tuoi dati o importa da un backup."
@@ -4986,7 +4994,7 @@
"error": "Salvataggio non riuscito. Riprova.",
"toggleable": {
"title": "Cosa monitori",
"description": "Ambiti opzionali. I moduli disattivati spariscono da navigazione, riquadri e analisi; i tuoi dati restano intatti."
"description": "I moduli disattivati spariscono da navigazione, riquadri e analisi; i tuoi dati restano intatti."
},
"manageIn": "Gestisci in {section}",
"operatorDisabled": "Disattivato a livello di server",
@@ -5374,7 +5382,7 @@
"testReasonUnreachable": "Impossibile raggiungere il provider IA.",
"coachMemory": {
"title": "Ciò che il Coach ricorda",
"description": "Informazioni durature che hai comunicato al Coach. Le usa per mantenere pertinenti le sue risposte. Rimuovi tutto ciò che preferisci dimentichi.",
"description": "Informazioni durature che hai comunicato al Coach.",
"empty": "Il Coach non ha ancora memorizzato alcuna informazione. Mentre chatti, ricorda i dettagli duraturi che condividi — i tuoi obiettivi, le condizioni, le preferenze — e puoi cancellarli qui in qualsiasi momento.",
"learnedPrefix": "appreso {when}",
"categoryPreference": "Preferenze",
@@ -5397,11 +5405,12 @@
"forgotToast": "Informazione dimenticata.",
"forgotError": "Impossibile dimenticare questa informazione. Riprova.",
"clearedToast": "Dimenticate {count} informazioni memorizzate.",
"clearError": "Impossibile cancellare le informazioni memorizzate. Riprova."
"clearError": "Impossibile cancellare le informazioni memorizzate. Riprova.",
"detail": "Le usa per mantenere pertinenti le sue risposte. Rimuovi tutto ciò che preferisci dimentichi."
},
"coachReminders": {
"title": "I tuoi promemoria",
"description": "Cose che hai chiesto al Coach di riprendere più tardi. Quelle in scadenza compaiono qui e nel Coach.",
"description": "Cose che hai chiesto al Coach di riprendere più tardi.",
"empty": "Ancora nessun promemoria. Chiedi al Coach di ricordarti qualcosa e comparirà qui.",
"loadError": "Impossibile caricare i tuoi promemoria.",
"dueHeading": "In scadenza",
@@ -5422,7 +5431,8 @@
},
"deleteConfirmTitle": "Eliminare questo promemoria?",
"deleteConfirmBody": "Non verrà più mostrato. Quanto hai già registrato resta invariato.",
"deleteConfirmAction": "Elimina"
"deleteConfirmAction": "Elimina",
"detail": "Quelle in scadenza compaiono qui e nel Coach."
},
"aboutMe": {
"title": "Su di me",
@@ -5596,7 +5606,6 @@
"googleHealthTag": "Fitbit e Pixel",
"googleHealthBetaBadge": "Beta",
"googleHealthDescription": "Collega Fitbit, Pixel Watch e Fitbit Air tramite il tuo account Google — peso, frequenza cardiaca, sonno e attività. Sostituisce la connessione Fitbit classica, che Google ritira a settembre 2026.",
"googleHealthDescriptionSecondary": "Registri il tuo client Google Cloud e inserisci qui le credenziali. L'API non offre stress né prontezza, e Google può chiederti di riconnetterti di tanto in tanto. Prima configurazione? Segui la",
"googleHealthCredentials": "Credenziali API",
"googleHealthClientId": "ID client",
"googleHealthClientSecret": "Client secret",
@@ -5696,7 +5705,7 @@
"workouts": "Allenamenti"
},
"integrationFallback": {
"description": "Configurato su questo account ma senza una scheda di impostazioni propria. Questa riga esiste perché una connessione non possa smettere di funzionare senza che nessuno se ne accorga.",
"description": "Configurato su questo account ma senza una scheda di impostazioni propria.",
"lastAttempt": "Ultimo tentativo {relative}",
"noAttempt": "Nessun tentativo di sincronizzazione registrato"
},
@@ -5705,7 +5714,7 @@
"fitbitViewData": "Vedi il tuo sonno",
"googleHealthViewData": "Vedi i tuoi passi",
"apiTokens": "Token API",
"apiTokensDescription": "Rivedi e revoca i token API per la registrazione esterna delle assunzioni di farmaci. I token vengono emessi per singolo farmaco dal relativo interruttore dell’endpoint API.",
"apiTokensDescription": "Rivedi e revoca i token API per la registrazione esterna delle assunzioni di farmaci.",
"tokenMintMovedDescription": "I token vengono emessi per singolo farmaco. Apri un farmaco e attiva il suo endpoint API per ottenere un token valido solo per quel farmaco.",
"tokenNamePlaceholder": "Nome del token (ad es. Comando rapido iPhone)",
"tokenCreated": "Token creato — copialo ora! Non verrà mostrato di nuovo.",
@@ -5736,14 +5745,14 @@
"apiEndpointAuth": "Autenticazione",
"apiEndpointExample": "Esempio di body",
"dangerZone": "Elimina tutti i dati",
"dangerZoneDescription": "Cancella l’intera cartella sanitaria e ogni connessione di integrazione. Il tuo account e le credenziali con cui accedi restano.",
"dangerZoneDescription": "Cancella l’intera cartella sanitaria e ogni connessione di integrazione.",
"dangerZoneConfirm": "Eliminare tutti i dati?",
"dangerZoneConfirmDescription": "Questa azione elimina definitivamente tutto ciò che contiene la tua cartella: misurazioni, farmaci, voci dell’umore, risultati di laboratorio, voci del ciclo, documenti, conversazioni con l’IA e ogni connessione di integrazione, insieme a tutto ciò che ne deriva e a tutto quello che avevi eliminato prima. I link di condivisione consegnati a uno studio medico smettono subito di funzionare. Il tuo account resta, insieme alle credenziali con cui accedi e alle impostazioni di visualizzazione come lingua, fuso orario e unità. Non può essere annullato.",
"dangerZoneSuccess": "La tua cartella sanitaria è stata eliminata. Account e accesso restano invariati.",
"dangerZoneDeleteFailed": "Eliminazione non riuscita",
"finalDelete": "Elimina definitivamente",
"deleteAccountCardTitle": "Elimina completamente l’account",
"deleteAccountCardDescription": "Elimina il tuo account insieme a passkey, registro di controllo e sessioni. Questa azione non può essere annullata.",
"deleteAccountCardDescription": "Elimina il tuo account insieme a passkey, registro di controllo e sessioni.",
"deleteAccountCta": "Elimina account",
"deleteAccountConfirmTitle": "Eliminare definitivamente l’account?",
"deleteAccountConfirmDescription": "Il tuo account, tutti i dati sanitari, le passkey, le sessioni e le voci di audit verranno eliminati definitivamente. Verrai disconnesso subito dopo.",
@@ -5761,8 +5770,6 @@
"changelog": "Changelog",
"docs": "Documentazione",
"linksHeading": "Fonti e documentazione",
"identityDescription": "Versione, licenza e dettagli di build di questa istanza.",
"linksDescription": "Codice sorgente, registro delle modifiche e documentazione.",
"newerAvailable": "Nuova versione: {tag}"
},
"testConnection": {
@@ -5790,7 +5797,7 @@
"notificationStatus": {
"title": "Affidabilità dei canali",
"description": "Stato per canale; «Disattivato automaticamente» interrompe i tentativi dopo errori permanenti.",
"emptyDescription": "Nessun canale ancora configurato. Aggiungine uno in Canali di recapito per monitorarne la consegna.",
"emptyDescription": "Aggiungi un canale in Impostazioni › Integrazioni › Canali di recapito.",
"stateActive": "Attivo",
"stateAutoDisabled": "Disattivato automaticamente",
"stateSendingPaused": "Invio in pausa",
@@ -5801,7 +5808,8 @@
"disabledReason": "Motivo",
"nextRetry": "Prossimo tentativo",
"reEnable": "Riattiva",
"sendTest": "Invia prova"
"sendTest": "Invia prova",
"emptyTitle": "Nessun canale ancora configurato"
},
"identity": {
"description": "Dati facoltativi per l’esportazione della cartella clinica (copertina PDF + esportazione FHIR). Tutti i campi sono facoltativi.",
@@ -5814,7 +5822,7 @@
},
"healthRecord": {
"title": "Esporta cartella clinica",
"description": "Genera una cartella clinica completa per il medico — come PDF, strutturata (FHIR) o pacchetto combinato.",
"description": "Una cartella completa per il medico — in PDF, in FHIR o entrambi.",
"format": "Formato",
"format_pdf": "PDF",
"format_fhir": "Strutturato (FHIR)",
@@ -5839,10 +5847,10 @@
"errorModuleDisabled": "L'esportazione della cartella è disattivata per questo account."
},
"globalExcludedInjectionSitesLabel": "Siti di iniezione esclusi globalmente",
"globalExcludedInjectionSitesHint": "I siti elencati qui non vengono proposti per alcun farmaco e vengono rifiutati al momento della registrazione di una dose, anche se un farmaco li preferisce.",
"globalExcludedInjectionSitesHint": "Mai proposti e rifiutati alla registrazione, anche se un farmaco li preferisce.",
"sharing": {
"createTitle": "Nuovo link di condivisione",
"createDescription": "Il link è di sola lettura e a tempo. Il token viene mostrato una sola volta alla creazione — salvalo subito; non può essere recuperato.",
"createDescription": "Il link è di sola lettura e a tempo.",
"label": "Etichetta",
"labelPlaceholder": "es. Dott. Rossi — controllo annuale",
"range": "Giorni di cronologia",
@@ -5892,15 +5900,17 @@
"scopeInsuranceExcluded": "I dati assicurativi non vengono mai condivisi tramite link.",
"statusNeedsReselect": "Scegli di nuovo il contenuto",
"reselectReason": "Questo link mostrava un ambito che nessuno aveva scelto, perciò è stato chiuso. Creane uno nuovo e scegli cosa deve contenere.",
"reselectAction": "Crea un sostituto"
"reselectAction": "Crea un sostituto",
"createDetail": "Il token viene mostrato una sola volta alla creazione — salvalo subito; non può essere recuperato."
},
"genderOther": "Altro",
"cycleTracking": {
"title": "Monitoraggio del ciclo",
"description": "Monitora il ciclo mestruale, le previsioni, i sintomi e gli approfondimenti sulle fasi. Attivo per impostazione predefinita per gli account impostati come femminili; disponibile per tutti.",
"description": "Monitora il ciclo mestruale, le previsioni, i sintomi e gli approfondimenti sulle fasi.",
"enable": "Attiva il monitoraggio del ciclo",
"manage": "Apri il monitoraggio del ciclo",
"error": "Impossibile aggiornare il monitoraggio del ciclo."
"error": "Impossibile aggiornare il monitoraggio del ciclo.",
"detail": "Attivo per impostazione predefinita per gli account impostati come femminili; disponibile per tutti."
},
"nightscout": "Nightscout",
"nightscoutTag": "CGM",
@@ -5986,20 +5996,13 @@
"coach": {
"activate": {
"title": "Attiva il Coach",
"description": "Mostra il pulsante e il pannello del Coach. Attivo per impostazione predefinita: disattivalo per nascondere il Coach ovunque.",
"description": "Mostra il pulsante e il pannello del Coach.",
"toggleAria": "Attiva il pulsante e il pannello del Coach",
"savedOn": "Coach attivato.",
"savedOff": "Coach nascosto.",
"saveError": "Impossibile salvare la modifica. Riprova."
}
},
"withingsDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la",
"whoopDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la",
"fitbitDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la",
"polarDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la",
"ouraDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la",
"nightscoutDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la",
"integrationCredentialsHint": "Dove trovare queste credenziali e come registrare la tua app è spiegato nella guida alla configurazione qui sopra.",
"integrationRedirectGuide": {
"intro": "Questa connessione passa dalla tua app sviluppatore {provider} — circa 5 minuti di configurazione:",
"step1": "Crea un'app nel portale sviluppatori {provider}.",
@@ -6113,19 +6116,13 @@
"signOutEverywhereConfirmBody": "Ogni dispositivo tranne questo dovrà accedere di nuovo. Questo resta connesso. Nulla di ciò che hai registrato viene toccato."
},
"privacy": {
"intro": {
"title": "I tuoi dati restano tuoi",
"body": "HealthLog è self-hosted. Questa pagina riunisce come sono protetti i tuoi dati, per quanto tempo vengono conservati, come esportarli e come eliminarli."
},
"encryption": {
"title": "Cifrati a riposo",
"description": "I campi sensibili sono cifrati nel database.",
"summary": "I tuoi dati sensibili sono cifrati a riposo con {algorithm} su {columns} campi in {models} tipi di record.",
"statement": "I tuoi dati sensibili sono cifrati a riposo con AES-256-GCM."
},
"stored": {
"title": "Cosa viene memorizzato",
"description": "Le categorie di dati contenute in questo account.",
"measurements": "Misurazioni (peso, pressione, polso, glicemia, sonno, composizione corporea)",
"medications": "Farmaci e cronologia delle assunzioni",
"moodLabs": "Voci dell'umore e referti di laboratorio",
@@ -6143,7 +6140,7 @@
},
"export": {
"title": "Scarica i miei dati",
"description": "Esporta i tuoi record come CSV o JSON, eventualmente cifrati con passphrase, oppure genera un fascicolo sanitario completo.",
"description": "I tuoi record in CSV o JSON, o una cartella completa per il medico.",
"openExport": "Esportazione e backup",
"openHealthRecord": "Fascicolo sanitario"
},
@@ -6162,30 +6159,31 @@
},
"mcp": {
"enableTitle": "Endpoint MCP remoto",
"enableDescription": "Consenti a un assistente compatibile con MCP (Claude.ai, ChatGPT) di leggere i tuoi dati sulla salute tramite un token limitato. Disattivato per impostazione predefinita.",
"enableDescription": "Consenti a un assistente compatibile con MCP (Claude.ai, ChatGPT) di leggere i tuoi dati sulla salute tramite un token limitato.",
"enabled": "Attivo",
"disabled": "Inattivo",
"enableToggleLabel": "Attiva l'endpoint MCP",
"endpointLabel": "URL del connettore",
"connectDescription": "Incolla questo URL nella configurazione del connettore personalizzato del tuo assistente e autorizza con il token qui sotto o accedi quando richiesto.",
"tokensTitle": "Token del connettore",
"tokensDescription": "Genera un token in sola lettura per collegare un assistente. Il token viene mostrato una sola volta.",
"tokensDescription": "Genera un token in sola lettura per collegare un assistente.",
"scopeNote": "I token sono limitati a health:read: accesso in sola lettura ai tuoi dati. Non possono mai raggiungere funzioni di amministrazione.",
"writeScopeToggleLabel": "Consenti la registrazione (health:write)",
"writeScopeNote": "Aggiunge l'ambito health:write così l'assistente può registrare nuove misurazioni e voci dell'umore nel tuo account. Ogni scrittura viene prima confermata. Il token non può comunque eliminare o modificare voci esistenti, cambiare i farmaci o raggiungere funzioni REST o di amministrazione. Lascia disattivato per l'accesso in sola lettura.",
"connectionsTitle": "Assistenti collegati",
"connectionsDescription": "Assistenti che hai autorizzato tramite l'accesso. Revocare una connessione ne termina subito l'accesso, inclusa la capacità di rinnovarsi.",
"connectionsDescription": "Assistenti che hai autorizzato tramite l'accesso.",
"noConnections": "Nessun assistente collegato.",
"connectionConnected": "Collegato",
"connectionLastUsed": "Ultimo utilizzo",
"connectionNeverUsed": "Mai usato",
"connectionRevoke": "Disconnetti",
"connectionRevokeDescription": "Questo termina immediatamente l'accesso dell'assistente e ne impedisce il rinnovo. Per riconnetterlo sarà necessaria una nuova autorizzazione.",
"connectionRevokeFailed": "Impossibile disconnettere l'assistente."
"connectionRevokeFailed": "Impossibile disconnettere l'assistente.",
"tokensDetail": "Il token viene mostrato una sola volta.",
"connectionsDetail": "Revocare una connessione ne termina subito l'accesso, inclusa la capacità di rinnovarsi."
},
"strava": "Strava",
"stravaDescription": "Collega il tuo account Strava per sincronizzare le tue attività con la cronologia degli allenamenti.",
"stravaDescriptionSecondary": "Le attività arrivano nella tua dashboard in background. Nuovo alla configurazione? Segui la",
"stravaViewData": "Vedi i tuoi allenamenti",
"stravaCredentials": "Credenziali API",
"stravaClientId": "ID client",
@@ -6251,7 +6249,9 @@
"explainer": "Disattivato, i valori vengono valutati con la banda di riferimento generale non diabetica. Attivato, vengono valutati con la banda obiettivo glicemica più stretta degli ADA Standards of Care, e il Coach fonda su quella banda le sue osservazioni sulla glicemia. I tuoi valori obiettivo personalizzati qui sotto hanno comunque la precedenza.",
"disclaimer": "Questo seleziona una banda di riferimento. Non è una diagnosi, non viene mai dedotto da una misurazione e l'app non agisce clinicamente di conseguenza. Quali obiettivi valgano per te è una domanda per il tuo medico.",
"error": "Non è stato possibile salvare l'impostazione."
}
},
"dangerZoneDetail": "Il tuo account e le credenziali con cui accedi restano.",
"deleteAccountCardDetail": "Questa azione non può essere annullata."
},
"admin": {
"title": "Administration",
@@ -6282,7 +6282,7 @@
},
"aiServerKey": {
"title": "Chiave IA del server",
"description": "Una chiave del provider a livello di operatore su cui ogni account può ripiegare quando non è configurata una chiave personale.",
"description": "Una chiave dell'operatore per gli account che non ne hanno una propria.",
"byokHint": "La chiave personale dell'utente (BYOK, accesso ChatGPT o provider locale) ha sempre la precedenza — la chiave del server è l'ultimo passo della catena dei provider.",
"consentHint": "Le richieste tramite la chiave del server richiedono il consenso IA attivo dell'utente; senza una ricevuta di consenso non viene inviato nulla.",
"statusConfigured": "Configurata",
@@ -6388,7 +6388,7 @@
"monitoringTestFailed": "Test di monitoraggio non riuscito",
"assistant": {
"title": "Superfici dell'assistente",
"description": "Ogni interruttore nasconde una superficie guidata dal modello in tutta l'app senza rimuovere la configurazione del provider. Il master forza ogni opzione secondaria su off.",
"description": "Ogni interruttore nasconde una superficie guidata dal modello in tutta l'app senza rimuovere la configurazione del provider.",
"master": {
"title": "Assistente attivo",
"description": "Interruttore master per ogni superficie guidata dal modello. Quando spento, ogni opzione secondaria qui sotto è forzata su off."
@@ -6412,13 +6412,13 @@
},
"modules": {
"title": "Disponibilità dei moduli",
"description": "Disattiva un modulo per l'intero server. Un modulo disattivato qui è spento per ogni account, indipendentemente dalle preferenze personali. I domini principali (peso, pressione, polso, farmaci) sono sempre attivi e non possono essere disattivati.",
"saveError": "Impossibile aggiornare la disponibilità dei moduli."
"description": "Disattiva un modulo per l'intero server.",
"saveError": "Impossibile aggiornare la disponibilità dei moduli.",
"detail": "Un modulo disattivato qui è spento per ogni account, indipendentemente dalle preferenze personali. I domini principali (peso, pressione, polso, farmaci) sono sempre attivi e non possono essere disattivati."
},
"invites": {
"title": "Inviti",
"description": "Link di invito per la registrazione: funzionano anche quando la registrazione è chiusa. Il link viene mostrato una sola volta.",
"adminOnlyHint": "Solo gli amministratori possono creare e revocare gli inviti.",
"description": "Link di invito per la registrazione: funzionano anche quando la registrazione è chiusa.",
"create": "Crea invito",
"createTitle": "Crea invito",
"createDescription": "Il link consente una registrazione anche quando la registrazione è chiusa.",
@@ -6524,7 +6524,7 @@
"outcomeFailed": "Non riuscito",
"auditDetails": "Dettagli",
"deleteAllData": "Elimina tutti i dati globalmente",
"deleteAllDescription": "Elimina tutti i record di tutti gli account: misurazioni, farmaci, risultati di laboratorio, documenti, registri del ciclo, umore, allenamenti, sonno, conversazioni del Coach, integrazioni e cronologia di audit. Gli account e le credenziali di accesso vengono conservati.",
"deleteAllDescription": "Elimina tutti i record di tutti gli account; gli account e le credenziali di accesso vengono conservati.",
"deleteAllConfirm": "Eliminare davvero tutti i dati?",
"deleteAllConfirmDescription": "Questa operazione elimina definitivamente tutti i record di tutti gli account nell'intero database, non una selezione di tabelle. Vengono conservati: gli account utente e le loro credenziali di accesso, la configurazione dell'istanza e le deleghe che mantengono raggiungibili i profili gestiti. Al termine nel log di audit viene scritta una ricevuta di questa cancellazione; la cronologia precedente se ne va con tutto il resto.",
"deleteButton": "Elimina tutti i dati",
@@ -6573,15 +6573,10 @@
"sectionsNav": "Sezioni di amministrazione"
},
"overview": {
"welcomeTitle": "Benvenuto,{name}",
"welcomeSubtitle": "Hai effettuato l'accesso come amministratore. Scegli una sezione per gestire HealthLog.",
"snapshotTitle": "Istantanea del sistema",
"snapshotVersion": "Versione",
"snapshotDatabase": "Database",
"snapshotWorker": "Worker",
"snapshotStarted": "Avviato",
"snapshotBuildSha": "Build SHA",
"snapshotBuiltAt": "Compilato il",
"snapshotOfflineGeo": "Dati geografici",
"snapshotOfflineGeoOn": "Offline (GeoLite2)",
"snapshotOfflineGeoOff": "Online ({host})",
@@ -6618,7 +6613,7 @@
},
"coach": {
"title": "Coach",
"subtitle": "Superfici dell'assistente, chiave del server e le tabelle di qualità del feedback di Coach e analisi, in un unico posto."
"subtitle": "Superfici dell'assistente, chiave del server e qualità del feedback."
},
"reminders": {
"title": "Promemoria",
@@ -6674,7 +6669,7 @@
"app-logs": {
"title": "Log dell'app",
"subtitle": "Visualizza i wide-event strutturati più recenti emessi da questo processo.",
"processNote": "Vengono mostrati solo i log del processo dell'app: i log del processo worker vanno su Loki quando l'integrazione è configurata. Il buffer è volatile e limitato a 500 voci.",
"processNote": "Vengono mostrati solo i log del processo dell'app: i log del processo worker vanno su Loki quando l'integrazione è configurata.",
"filterTraceId": "ID traccia",
"filterAction": "L'azione contiene…",
"filterLevel": "Livello",
@@ -6695,7 +6690,8 @@
"emptyDescription": "Nessun evento corrisponde ancora ai filtri attuali. Premi Aggiorna dopo aver generato un po' di traffico.",
"refresh": "Aggiorna",
"eventDetails": "Dettagli dell'evento",
"closeDetails": "Chiudi dettagli"
"closeDetails": "Chiudi dettagli",
"processDetail": "Il buffer è volatile e limitato a 500 voci."
},
"backups": {
"title": "Backup",
@@ -6763,7 +6759,7 @@
"loading": "Caricamento dello stato della crittografia...",
"loadError": "Impossibile caricare lo stato della crittografia.",
"coverageTitle": "Copertura",
"coverageDescription": "Quante righe cifrate stanno sotto ogni id di chiave. Vengono mostrati solo gli id di chiave e i conteggi, mai il materiale della chiave.",
"coverageDescription": "Quante righe cifrate stanno sotto ogni id di chiave.",
"activeKeyId": "Id chiave attiva",
"configuredKeys": "Chiavi configurate",
"totalRows": "Righe cifrate",
@@ -6771,7 +6767,7 @@
"safeToDropLegacy": "È sicuro rimuovere la vecchia chiave",
"rotationIncomplete": "Restano {count} righe da ruotare",
"rotationTitle": "Rotazione delle chiavi",
"rotationDescription": "Ricifra ogni colonna cifrata con la chiave attiva. Lo strumento da riga di comando resta la via canonica; questo comando è una comodità.",
"rotationDescription": "Ricifra ogni colonna cifrata con la chiave attiva.",
"rotationState": "Stato",
"lastRun": "Ultima esecuzione: {scanned} esaminate, {rotated} ruotate, {errors} errori.",
"rotateNow": "Ruota ora",
@@ -6794,7 +6790,8 @@
"running": "In corso",
"completed": "Completato",
"failed": "Fallito"
}
},
"coverageDetail": "Vengono mostrati solo gli id di chiave e i conteggi, mai il materiale della chiave."
}
},
"carrier": "Operatore",
@@ -6840,7 +6837,8 @@
"countFew": "{count} errori",
"countOther": "{count} errori",
"lastFailed": "Ultimo errore {when}"
}
},
"deleteAllDetail": "Gli account utente vengono conservati."
},
"achievements": {
"title": "Obiettivi",
@@ -7166,7 +7164,7 @@
},
"moodReminder": {
"title": "Promemoria quotidiano dell’umore",
"description": "Promemoria leggero all’orario che scegli se non hai ancora registrato l’umore di oggi. Usa solo i canali già configurati (Telegram, ntfy, Web Push, Apple Push) e tace una volta registrato l’umore di oggi.",
"description": "Promemoria leggero all’orario che scegli se non hai ancora registrato l’umore di oggi.",
"toggleAria": "Attiva o disattiva il promemoria quotidiano dell’umore",
"statusOn": "Attivo all’orario che scegli",
"statusOff": "Disattivato",
@@ -7175,11 +7173,12 @@
"saveError": "Salvataggio non riuscito. Riprova.",
"hourLabel": "Ora del promemoria",
"hourAria": "Ora del promemoria dell’umore",
"hourSavedToast": "Ora del promemoria aggiornata."
"hourSavedToast": "Ora del promemoria aggiornata.",
"detail": "Usa solo i canali già configurati (Telegram, ntfy, Web Push, Apple Push) e tace una volta registrato l’umore di oggi."
},
"coachNudge": {
"title": "Coach proattivo",
"description": "Si fa sentire quando i tuoi dati suggeriscono di consultare il Coach — ad esempio bassa aderenza terapeutica o una media settimanale della pressione elevata. Temi e frequenza sono regolabili.",
"description": "Si fa sentire quando i tuoi dati suggeriscono di consultare il Coach.",
"toggleAria": "Attiva o disattiva gli avvisi proattivi del Coach",
"statusOn": "Attivo",
"groupsLabel": "Temi",
@@ -7206,7 +7205,7 @@
},
"lowStock": {
"title": "Avviso scorte basse",
"description": "Controlla una volta al giorno le scorte tracciate dei tuoi farmaci e ti avvisa quando una copre meno giorni di questa soglia. Un avviso per carenza; si riattiva dopo il rifornimento.",
"description": "Segnala ogni giorno una scorta che copre meno giorni di questa soglia.",
"toggleAria": "Attiva o disattiva gli avvisi di scorte basse",
"statusOn": "Attivo",
"statusOff": "Disattivato",
@@ -7220,7 +7219,8 @@
"enabledToast": "Avvisi di scorte basse attivati.",
"disabledToast": "Avvisi di scorte basse disattivati.",
"saveError": "Salvataggio non riuscito. Riprova.",
"medicationsLink": "Vai ai tuoi farmaci"
"medicationsLink": "Vai ai tuoi farmaci",
"detail": "Un avviso per carenza; si riattiva dopo il rifornimento."
},
"eventMedicationReminder": "Promemoria farmaco",
"eventMedicationReminderDesc": "Inviato a ogni orario di dose programmato.",
@@ -8049,7 +8049,8 @@
"customize": "Personalizza esami",
"localOcr": {
"heading": "Leggi le scansioni su questo dispositivo",
"description": "Consente di scansionare un referto di laboratorio anche se il tuo provider di IA non può leggere le immagini: la foto viene letta sul tuo dispositivo e al provider viene inviato solo il testo estratto. Meno preciso: controlla ogni valore prima di salvare."
"description": "Legge la foto sul tuo dispositivo e invia al provider solo il testo estratto.",
"detail": "Utile quando il tuo provider IA non legge le immagini. Meno preciso — controlla ogni valore prima di salvare."
},
"sort": {
"heading": "Ordinamento",
@@ -9623,7 +9624,7 @@
},
"invite": {
"title": "Invita qualcuno",
"description": "Nulla viene condiviso finché non accetta. Fino ad allora l'invito resta nelle sue impostazioni e non le dà nulla.",
"description": "Nulla viene condiviso finché non accetta.",
"identifierLabel": "Nome utente o e-mail",
"identifierPlaceholder": "Il nome con cui accede",
"identifierHint": "Serve un account su questa istanza. Non è possibile inviare inviti a un indirizzo non registrato.",
@@ -9677,7 +9678,7 @@
},
"received": {
"title": "Cartelle che posso aprire",
"description": "Un invito non ti dà nulla finché non lo accetti. Puoi restituire l'accesso quando vuoi.",
"description": "Un invito non ti dà nulla finché non lo accetti.",
"emptyTitle": "Nessuno ha condiviso una cartella con te",
"emptyDescription": "Quando qualcuno ti invita, l'invito compare qui.",
"loadError": "Non è stato possibile caricare l'elenco.",
@@ -9689,7 +9690,8 @@
"renounce": "Restituisci",
"renounceTitle": "Restituire la cartella di {name}?",
"renounceBody": "Perdi l'accesso subito. Quella persona può invitarti di nuovo quando vuole.",
"renounceConfirm": "Restituisci"
"renounceConfirm": "Restituisci",
"detail": "Puoi restituire l'accesso quando vuoi."
},
"activity": {
"title": "Quando è stata aperta la mia cartella",
@@ -9707,13 +9709,8 @@
"addOnlyNote": "Puoi aggiungere voci a questa cartella. Solo {name} può modificarle o eliminarle."
},
"lookingAfter": {
"title": "Mi prendo cura di",
"description": "Cartelle che altre persone hanno condiviso con te.",
"levelRead": "Può consultare",
"levelWrite": "Può consultare e aggiungere",
"kindShared": "Cartella condivisa",
"kindManaged": "Profilo gestito",
"open": "Apri la cartella di {name}"
"kindManaged": "Profilo gestito"
},
"managed": {
"title": "Profili di cui ti occupi",
+90 -93
View File
@@ -2984,7 +2984,8 @@
"valuesBack": "Powrót do Insights",
"valuesBackToMetric": "Powrót do {metric}",
"scoresBack": "Powrót do Insights",
"ecgDescription": "Zapisy EKG jednoodprowadzeniowe z Twojego urządzenia, każdy z wynikiem samego urządzenia. HealthLog pokazuje przebieg i zapis — nie interpretuje krzywej."
"ecgDescription": "Zapisy EKG jednoodprowadzeniowe z Twojego urządzenia, każdy z wynikiem samego urządzenia.",
"ecgDisclaimer": "HealthLog pokazuje przebieg i zapis — nie interpretuje krzywej."
},
"units": {
"steps": "kroki",
@@ -3971,12 +3972,13 @@
},
"pillOrder": {
"title": "Sortuj pigułki nawigacyjne",
"description": "Przeciągnij pigułki w wybranej kolejności i pokaż lub ukryj je ikoną oka. Ukryte strony szczegółów znikają również z górnej nawigacji.",
"description": "Przeciągnij pigułki w wybranej kolejności i pokaż lub ukryj je ikoną oka.",
"dragHandle": "Zmień kolejność",
"saveSuccess": "Zapisano kolejność pigułek.",
"saveError": "Zapisanie kolejności pigułek nie powiodło się.",
"save": "Zapisz",
"empty": "Brak dostępnych pigułek nawigacyjnych."
"empty": "Brak dostępnych pigułek nawigacyjnych.",
"detail": "Ukryte strony szczegółów znikają również z górnej nawigacji."
},
"customize": "Dostosuj Analizy",
"regenerateRateLimited": "Osiągnięto limit odświeżeń — spróbuj ponownie później.",
@@ -4458,7 +4460,7 @@
"settings": {
"researchMode": {
"sectionTitle": "Tryb Badawczy",
"subtitle": "Opcjonalne wyświetlanie szacowanej krzywej poziomu leku GLP-1, wyprowadzonej z farmakokinetyki populacyjnej opublikowanej przez EMA. Tylko do odczytu — nigdy pomiar ani porada medyczna.",
"subtitle": "Opcjonalna krzywa szacowanego poziomu leku GLP-1 wg danych EMA.",
"toggleLabel": "Pokaż szacowaną krzywą poziomu leku",
"disabledStatus": "Wyłączone. Krzywa jest ukryta.",
"enabledStaleStatus": "Disclaimer został zaktualizowany. Potwierdź ponownie poniżej, aby przywrócić krzywą.",
@@ -4466,7 +4468,8 @@
"rePromptTitle": "Zaktualizowano disclaimer",
"rePromptBody": "Disclaimer trybu Badawczego został zaktualizowany od czasu twojego ostatniego potwierdzenia. Przeczytaj go ponownie i potwierdź, aby krzywa pozostała widoczna.",
"rePromptCta": "Potwierdź ponownie disclaimer",
"disableError": "Nie udało się wyłączyć trybu Badawczego. Spróbuj ponownie."
"disableError": "Nie udało się wyłączyć trybu Badawczego. Spróbuj ponownie.",
"subtitleDetail": "Tylko do odczytu — nigdy pomiar ani porada medyczna."
},
"shell": {
"sectionsNav": "Sekcje ustawień",
@@ -4593,7 +4596,8 @@
},
"unavailableTitle": "Niedostępne w tym udostępnionym rekordzie",
"unavailableDescription": "Ten obszar Ustawień jest celowo niedostępny podczas przeglądania rekordu innej osoby.",
"integrationStatusDescription": "Stan połączenia dla tego zarządzanego rekordu. Elementy sterujące połączeniem są niedostępne."
"integrationStatusDescription": "Stan połączenia dla tego zarządzanego rekordu.",
"integrationStatusDetail": "Elementy sterujące połączeniem są niedostępne."
},
"sections": {
"account": {
@@ -4606,7 +4610,6 @@
"description": "Połączone usługi, kanały dostarczania i priorytet źródeł w jednym miejscu.",
"subtitle": "Połącz i zarządzaj źródłami danych oraz urządzeniami.",
"connectionsHeading": "Połączenia",
"connectionsDescription": "Połącz i zarządzaj źródłami danych oraz urządzeniami.",
"channelsHeading": "Kanały dostarczania",
"channelsDescription": "Gdzie dostarczane są przypomnienia i powiadomienia.",
"matrixLink": "Powiadomienia wg kanału"
@@ -4616,7 +4619,6 @@
"description": "Wszystkie przypomnienia w jednym miejscu — co i kiedy oraz gdzie są dostarczane.",
"subtitle": "Wybierz, które przypomnienia otrzymujesz i gdzie są dostarczane.",
"remindersHeading": "Przypomnienia",
"remindersDescription": "Co otrzymujesz.",
"matrixLink": "Powiadomienia wg kanału",
"channelsLink": "Kanały dostarczania"
},
@@ -4670,7 +4672,7 @@
"integrationsHint": "Źródła danych podłączysz lub odłączysz na stronie integracji.",
"integrationsHintLink": "Otwórz integracje",
"cardTitle": "Priorytet źródeł według metryki",
"help": "Gdy kilka źródeł dostarcza tę samą wartość tego samego dnia, źródło na górze liczy się jako kanoniczne. Pozostałe pozostają zapisane na potrzeby audytu.",
"help": "Gdy kilka źródeł dostarcza tę samą wartość tego samego dnia, źródło na górze liczy się jako kanoniczne.",
"resetDefaults": "Przywróć wartości domyślne",
"resetDefaultsTitle": "Zresetować kolejność źródeł?",
"resetDefaultsBody": "Każda ustawiona przez ciebie kolejność wraca do domyślnej, dla wszystkich metryk i typów urządzeń naraz. Zapis następuje natychmiast i nie da się go cofnąć.",
@@ -4730,13 +4732,15 @@
"subtitle": "Zdecyduj, które źródło wygrywa, gdy dwa podają tę samą metrykę.",
"nutrients": {
"title": "Zsynchronizowane spożycie składników",
"description": "Dzienne sumy z Apple Health z ostatnich {days} dni. Tylko do odczytu — wartości należą do aplikacji źródłowej.",
"description": "Dzienne sumy z Apple Health z ostatnich {days} dni.",
"empty": "Nie zsynchronizowano jeszcze danych o składnikach odżywczych.",
"latest": "{amount} {unit} dnia {date}",
"daysWithData": "{count} z {days} dni",
"moduleHint": "Synchronizacją steruje moduł spożycia składników odżywczych.",
"moduleHintLink": "Zarządzaj modułami"
}
"moduleHintLink": "Zarządzaj modułami",
"detail": "Tylko do odczytu — wartości należą do aplikacji źródłowej."
},
"helpDetail": "Pozostałe pozostają zapisane na potrzeby audytu."
},
"ai": {
"title": "Dostawca AI",
@@ -4781,13 +4785,15 @@
},
"fullBackup": {
"title": "Pełna kopia zapasowa",
"description": "Twoje pomiary, leki, nastrój, cykl, wyniki badań, epizody chorobowe, alergie, wywiad rodzinny, składniki odżywcze, treningi, Twój profil zdrowia i własne wskaźniki w jednym pliku JSON. Ten sam kształt co cotygodniowa kopia automatyczna. Niektóre rodzaje wpisów nie są jeszcze przenoszone — manifest pliku wskazuje, co zawiera.",
"description": "Cała dokumentacja w jednym pliku JSON.",
"scopeNote": "Metadane dokumentów oraz podsumowanie AI, jeśli istnieje, są uwzględnione jako czysty tekst; oryginalne pliki dokumentów oraz dane tras GPS/próbek treningów nie są uwzględnione — pobierz je osobno z archiwum dokumentów lub eksportu treningów.",
"encryptToggle": "Zaszyfruj hasłem-frazą",
"passphrasePlaceholder": "Hasło-fraza (min. 12 znaków)",
"passphraseHelp": "Wyprowadza klucz archiwum za pomocą Argon2id i pieczętuje je AES-256-GCM. Zapisz plik z rozszerzeniem .hlx.",
"noRecoveryWarning": "Nie ma odzyskiwania — jeśli zgubisz tę frazę, archiwum nie da się otworzyć. Przechowuj ją bezpiecznie.",
"stepUpNeeded": "Potwierdź drugi składnik i spróbuj ponownie."
"stepUpNeeded": "Potwierdź drugi składnik i spróbuj ponownie.",
"detail": "Ten sam kształt co cotygodniowa kopia automatyczna. Niektóre rodzaje wpisów nie są jeszcze przenoszone — manifest pliku wskazuje, co zawiera.",
"contents": "Twoje pomiary, leki, nastrój, cykl, wyniki badań, epizody chorobowe, alergie, wywiad rodzinny, składniki odżywcze, treningi, Twój profil zdrowia i własne wskaźniki w jednym pliku JSON."
}
},
"import": {
@@ -4841,7 +4847,7 @@
},
"csv": {
"title": "Plik CSV",
"description": "Importuj pomiary z arkusza lub eksportu z urządzenia. Jeden wiersz na odczyt.",
"description": "Importuj pomiary z arkusza lub eksportu z urządzenia.",
"pasteLabel": "Wklej CSV",
"fileInputLabel": "Wybierz plik CSV do zaimportowania",
"schemaHint": "Kolumny: type, value, unit, measuredAt (z przesunięciem strefy czasowej) oraz opcjonalnie glucoseContext, notes, externalId.",
@@ -4881,11 +4887,12 @@
"reasonExternalIdTooLong": "identyfikator zewnętrzny jest zbyt długi",
"reasonUnstableExternalId": "identyfikator zewnętrzny nie jest stabilny między eksportami",
"reasonDuplicate": "już zaimportowano",
"reasonUnknown": "pominięto"
"reasonUnknown": "pominięto",
"detail": "Jeden wiersz na odczyt."
},
"doseHistory": {
"title": "Historia dawek",
"description": "Historia dawek wyeksportowana z innej aplikacji, czytana wprost. Jeden plik obejmuje wszystkie leki, które zawiera.",
"description": "Historia dawek wyeksportowana z innej aplikacji, czytana wprost.",
"pasteLabel": "Wklej eksport",
"fileInputLabel": "Wybierz plik z historią dawek",
"uploadFile": "Wybierz plik",
@@ -4935,7 +4942,8 @@
"jsonNotAnArray": "Ten JSON nie zawiera listy dawek.",
"jsonNoIntakeTime": "Eksport JSON zapisuje, kiedy dawka była zaplanowana, a nie kiedy została przyjęta, i zaplanowana godzina nie zostanie zapisana jako godzina przyjęcia. Wyeksportuj CSV, który zawiera obie.",
"tooManyRows": "Ten plik ma więcej wierszy, niż może przyjąć jeden import. Podziel go i zaimportuj części."
}
},
"detail": "Jeden plik obejmuje wszystkie leki, które zawiera."
}
},
"subtitle": "Wyeksportuj dane lub zaimportuj z kopii zapasowej."
@@ -4986,7 +4994,7 @@
"error": "Nie udało się zapisać. Spróbuj ponownie.",
"toggleable": {
"title": "Co śledzisz",
"description": "Obszary opcjonalne. Wyłączone moduły znikają z nawigacji, kafelków i analiz — Twoje dane pozostają nienaruszone."
"description": "Wyłączone moduły znikają z nawigacji, kafelków i analiz — Twoje dane pozostają nienaruszone."
},
"manageIn": "Zarządzaj w {section}",
"operatorDisabled": "Wyłączone na całym serwerze",
@@ -5374,7 +5382,7 @@
"testReasonUnreachable": "Nie udało się połączyć z dostawcą AI.",
"coachMemory": {
"title": "Co Coach pamięta",
"description": "Trwałe informacje, które przekazałeś Coachowi. Wykorzystuje je, aby jego odpowiedzi pozostawały trafne. Usuń wszystko, o czym wolisz, by zapomniał.",
"description": "Trwałe informacje, które przekazałeś Coachowi.",
"empty": "Coach nie zapisał jeszcze żadnych informacji. Podczas rozmowy zapamiętuje trwałe szczegóły, którymi się dzielisz — Twoje cele, dolegliwości, preferencje — i możesz je tutaj w każdej chwili usunąć.",
"learnedPrefix": "zapamiętano {when}",
"categoryPreference": "Preferencje",
@@ -5397,11 +5405,12 @@
"forgotToast": "Informacja zapomniana.",
"forgotError": "Nie udało się zapomnieć tej informacji. Spróbuj ponownie.",
"clearedToast": "Zapomniano {count} zapisanych informacji.",
"clearError": "Nie udało się usunąć zapisanych informacji. Spróbuj ponownie."
"clearError": "Nie udało się usunąć zapisanych informacji. Spróbuj ponownie.",
"detail": "Wykorzystuje je, aby jego odpowiedzi pozostawały trafne. Usuń wszystko, o czym wolisz, by zapomniał."
},
"coachReminders": {
"title": "Twoje przypomnienia",
"description": "Rzeczy, o których przypomnienie poprosiłeś Coacha. Te, których termin nadszedł, pojawiają się tutaj i w Coachu.",
"description": "Rzeczy, o których przypomnienie poprosiłeś Coacha.",
"empty": "Brak przypomnień. Poproś Coacha, aby przypomniał Ci o czymś, a pojawi się tutaj.",
"loadError": "Nie udało się wczytać Twoich przypomnień.",
"dueHeading": "Termin teraz",
@@ -5422,7 +5431,8 @@
},
"deleteConfirmTitle": "Usunąć to przypomnienie?",
"deleteConfirmBody": "Przestanie się pojawiać. Zapisane wcześniej dane pozostają bez zmian.",
"deleteConfirmAction": "Usuń"
"deleteConfirmAction": "Usuń",
"detail": "Te, których termin nadszedł, pojawiają się tutaj i w Coachu."
},
"aboutMe": {
"title": "O mnie",
@@ -5596,7 +5606,6 @@
"googleHealthTag": "Fitbit i Pixel",
"googleHealthBetaBadge": "Beta",
"googleHealthDescription": "Połącz Fitbit, Pixel Watch i Fitbit Air przez konto Google — waga, tętno, sen i aktywność. Zastępuje to klasyczne połączenie Fitbit, które Google wyłącza we wrześniu 2026.",
"googleHealthDescriptionSecondary": "Rejestrujesz własnego klienta Google Cloud i wprowadzasz tutaj dane logowania. API nie udostępnia stresu ani gotowości, a Google może od czasu do czasu poprosić o ponowne połączenie. Pierwsza konfiguracja? Skorzystaj z",
"googleHealthCredentials": "Dane API",
"googleHealthClientId": "Identyfikator klienta",
"googleHealthClientSecret": "Sekret klienta",
@@ -5696,7 +5705,7 @@
"workouts": "Treningi"
},
"integrationFallback": {
"description": "Skonfigurowane na tym koncie, ale bez własnej karty ustawień. Ten wiersz istnieje po to, by przerwane połączenie nie pozostało niezauważone.",
"description": "Skonfigurowane na tym koncie, ale bez własnej karty ustawień.",
"lastAttempt": "Ostatnia próba {relative}",
"noAttempt": "Brak zapisanej próby synchronizacji"
},
@@ -5705,7 +5714,7 @@
"fitbitViewData": "Zobacz swój sen",
"googleHealthViewData": "Zobacz swoje kroki",
"apiTokens": "Tokeny API",
"apiTokensDescription": "Przeglądaj i unieważniaj tokeny API do zewnętrznego rejestrowania przyjęć leków. Tokeny są wydawane dla pojedynczego leku z jego przełącznika punktu końcowego API.",
"apiTokensDescription": "Przeglądaj i unieważniaj tokeny API do zewnętrznego rejestrowania przyjęć leków.",
"tokenMintMovedDescription": "Tokeny są wydawane dla pojedynczego leku. Otwórz lek i włącz jego punkt końcowy API, aby otrzymać token obejmujący wyłącznie ten lek.",
"tokenNamePlaceholder": "Nazwa tokena (np. Skrót iPhone)",
"tokenCreated": "Token utworzony — skopiuj go teraz! Nie zostanie pokazany ponownie.",
@@ -5736,14 +5745,14 @@
"apiEndpointAuth": "Uwierzytelnianie",
"apiEndpointExample": "Przykład treści",
"dangerZone": "Usuń wszystkie dane",
"dangerZoneDescription": "Kasuje całą Twoją dokumentację zdrowotną i wszystkie połączenia integracji. Konto i dane logowania zostają zachowane.",
"dangerZoneDescription": "Kasuje całą Twoją dokumentację zdrowotną i wszystkie połączenia integracji.",
"dangerZoneConfirm": "Usunąć wszystkie dane?",
"dangerZoneConfirmDescription": "Ta czynność trwale usuwa wszystko z Twojej dokumentacji: pomiary, leki, wpisy nastroju, wyniki badań, wpisy cyklu, dokumenty, rozmowy z SI oraz wszystkie połączenia integracji, a wraz z nimi wszystko, co z nich wyliczono, i wszystko, co usunięto wcześniej. Linki udostępnione poradni natychmiast przestają działać. Konto zostaje, razem z danymi logowania i ustawieniami wyświetlania, takimi jak język, strefa czasowa i jednostki. Nie można tego cofnąć.",
"dangerZoneSuccess": "Twoja dokumentacja zdrowotna została usunięta. Konto i logowanie pozostają bez zmian.",
"dangerZoneDeleteFailed": "Usuwanie nie powiodło się",
"finalDelete": "Usuń trwale",
"deleteAccountCardTitle": "Usuń całe konto",
"deleteAccountCardDescription": "Usuwa Twoje konto wraz z kluczami dostępu, dziennikiem audytu i sesjami. Tej operacji nie można cofnąć.",
"deleteAccountCardDescription": "Usuwa Twoje konto wraz z kluczami dostępu, dziennikiem audytu i sesjami.",
"deleteAccountCta": "Usuń konto",
"deleteAccountConfirmTitle": "Trwale usunąć konto?",
"deleteAccountConfirmDescription": "Twoje konto, wszystkie dane zdrowotne, klucze dostępu, sesje i wpisy audytu zostaną trwale usunięte. Zostaniesz wylogowany natychmiast po tym.",
@@ -5761,8 +5770,6 @@
"changelog": "Changelog",
"docs": "Dokumentacja",
"linksHeading": "Źródła i dokumentacja",
"identityDescription": "Wersja, licencja i szczegóły kompilacji tej instancji.",
"linksDescription": "Kod źródłowy, dziennik zmian i dokumentacja.",
"newerAvailable": "Nowa wersja: {tag}"
},
"testConnection": {
@@ -5790,7 +5797,7 @@
"notificationStatus": {
"title": "Niezawodność kanałów",
"description": "Stan każdego kanału; „Automatycznie wyłączone” przerywa ponawianie po trwałych błędach.",
"emptyDescription": "Nie skonfigurowano jeszcze żadnych kanałów. Dodaj kanał w sekcji Kanały dostarczania, aby śledzić dostarczalność.",
"emptyDescription": "Dodaj kanał w Ustawienia › Integracje › Kanały dostarczania.",
"stateActive": "Aktywne",
"stateAutoDisabled": "Automatycznie wyłączone",
"stateSendingPaused": "Wysyłanie wstrzymane",
@@ -5801,7 +5808,8 @@
"disabledReason": "Powód",
"nextRetry": "Następna próba",
"reEnable": "Włącz ponownie",
"sendTest": "Wyślij test"
"sendTest": "Wyślij test",
"emptyTitle": "Nie skonfigurowano jeszcze kanałów"
},
"identity": {
"description": "Opcjonalne dane do eksportu dokumentacji zdrowotnej (okładka PDF + eksport FHIR). Wszystkie pola są opcjonalne.",
@@ -5814,7 +5822,7 @@
},
"healthRecord": {
"title": "Eksportuj dokumentację zdrowotną",
"description": "Wygeneruj pełną dokumentację zdrowotną dla lekarza — jako PDF, ustrukturyzowaną (FHIR) lub łączny pakiet.",
"description": "Pełna dokumentacja dla lekarza — jako PDF, jako FHIR lub oba.",
"format": "Format",
"format_pdf": "PDF",
"format_fhir": "Ustrukturyzowany (FHIR)",
@@ -5839,10 +5847,10 @@
"errorModuleDisabled": "Eksport dokumentacji jest wyłączony dla tego konta."
},
"globalExcludedInjectionSitesLabel": "Globalnie wykluczone miejsca iniekcji",
"globalExcludedInjectionSitesHint": "Miejsca wymienione tutaj nie są proponowane dla żadnego leku i są odrzucane przy rejestrowaniu dawki, nawet jeśli lek je preferuje.",
"globalExcludedInjectionSitesHint": "Nigdy nieproponowane i odrzucane przy rejestracji, nawet jeśli lek je preferuje.",
"sharing": {
"createTitle": "Nowy link udostępniania",
"createDescription": "Link jest tylko do odczytu i ograniczony czasowo. Token jest pokazywany tylko raz przy tworzeniu — zapisz go wtedy; nie można go odzyskać.",
"createDescription": "Link jest tylko do odczytu i ograniczony czasowo.",
"label": "Etykieta",
"labelPlaceholder": "np. dr Kowalski — kontrola roczna",
"range": "Dni historii",
@@ -5892,15 +5900,17 @@
"scopeInsuranceExcluded": "Dane ubezpieczenia nigdy nie są udostępniane przez link.",
"statusNeedsReselect": "Wybierz zawartość ponownie",
"reselectReason": "Ten link udostępniał zakres, którego nikt nie wybrał, więc został zamknięty. Utwórz nowy i wybierz jego zawartość.",
"reselectAction": "Utwórz zamiennik"
"reselectAction": "Utwórz zamiennik",
"createDetail": "Token jest pokazywany tylko raz przy tworzeniu — zapisz go wtedy; nie można go odzyskać."
},
"genderOther": "Inna",
"cycleTracking": {
"title": "Śledzenie cyklu",
"description": "Śledź swój cykl menstruacyjny, prognozy, objawy i analizy faz. Domyślnie włączone dla kont ustawionych jako kobiece; dostępne dla wszystkich.",
"description": "Śledź swój cykl menstruacyjny, prognozy, objawy i analizy faz.",
"enable": "Włącz śledzenie cyklu",
"manage": "Otwórz śledzenie cyklu",
"error": "Nie udało się zaktualizować śledzenia cyklu."
"error": "Nie udało się zaktualizować śledzenia cyklu.",
"detail": "Domyślnie włączone dla kont ustawionych jako kobiece; dostępne dla wszystkich."
},
"nightscout": "Nightscout",
"nightscoutTag": "CGM",
@@ -5986,20 +5996,13 @@
"coach": {
"activate": {
"title": "Włącz Coacha",
"description": "Pokazuje przycisk i panel Coacha. Domyślnie włączony — wyłącz, aby ukryć Coacha wszędzie.",
"description": "Pokazuje przycisk i panel Coacha.",
"toggleAria": "Włącz przycisk i panel Coacha",
"savedOn": "Coach włączony.",
"savedOff": "Coach ukryty.",
"saveError": "Nie udało się zapisać zmiany. Spróbuj ponownie."
}
},
"withingsDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z",
"whoopDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z",
"fitbitDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z",
"polarDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z",
"ouraDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z",
"nightscoutDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z",
"integrationCredentialsHint": "Gdzie znaleźć te dane logowania i jak zarejestrować własną aplikację, opisano w przewodniku konfiguracji powyżej.",
"integrationRedirectGuide": {
"intro": "To połączenie działa przez Twoją własną aplikację deweloperską {provider} — około 5 minut konfiguracji:",
"step1": "Utwórz aplikację w portalu deweloperskim {provider}.",
@@ -6113,19 +6116,13 @@
"signOutEverywhereConfirmBody": "Każde urządzenie poza tym będzie musiało zalogować się ponownie. To urządzenie pozostaje zalogowane. Zapisane dane pozostają bez zmian."
},
"privacy": {
"intro": {
"title": "Twoje dane pozostają Twoje",
"body": "HealthLog jest hostowany samodzielnie. Ta strona zbiera w jednym miejscu informacje o tym, jak chronione są Twoje dane, jak długo są przechowywane, jak je wyeksportować i jak je usunąć."
},
"encryption": {
"title": "Szyfrowane w spoczynku",
"description": "Wrażliwe pola są szyfrowane w bazie danych.",
"summary": "Twoje wrażliwe dane są szyfrowane w spoczynku algorytmem {algorithm} w {columns} polach w {models} typach rekordów.",
"statement": "Twoje wrażliwe dane są szyfrowane w spoczynku algorytmem AES-256-GCM."
},
"stored": {
"title": "Co jest przechowywane",
"description": "Kategorie danych przechowywanych na tym koncie.",
"measurements": "Pomiary (waga, ciśnienie, tętno, glukoza, sen, skład ciała)",
"medications": "Leki i historia przyjmowania",
"moodLabs": "Wpisy nastroju i wyniki badań laboratoryjnych",
@@ -6143,7 +6140,7 @@
},
"export": {
"title": "Pobierz moje dane",
"description": "Wyeksportuj swoje rekordy jako CSV lub JSON, opcjonalnie zaszyfrowane hasłem, albo wygeneruj pełną dokumentację medyczną.",
"description": "Twoje rekordy jako CSV lub JSON albo pełna dokumentacja dla lekarza.",
"openExport": "Eksport i kopia zapasowa",
"openHealthRecord": "Dokumentacja medyczna"
},
@@ -6162,30 +6159,31 @@
},
"mcp": {
"enableTitle": "Zdalny punkt końcowy MCP",
"enableDescription": "Zezwól asystentowi obsługującemu MCP (Claude.ai, ChatGPT) na odczyt danych zdrowotnych za pomocą ograniczonego tokenu. Domyślnie wyłączone.",
"enableDescription": "Zezwól asystentowi obsługującemu MCP (Claude.ai, ChatGPT) na odczyt danych zdrowotnych za pomocą ograniczonego tokenu.",
"enabled": "Włączony",
"disabled": "Wyłączony",
"enableToggleLabel": "Włącz punkt końcowy MCP",
"endpointLabel": "Adres URL łącznika",
"connectDescription": "Wklej ten adres URL w konfiguracji własnego łącznika asystenta, a następnie autoryzuj za pomocą poniższego tokenu lub zaloguj się po wyświetleniu monitu.",
"tokensTitle": "Tokeny łącznika",
"tokensDescription": "Wygeneruj token tylko do odczytu, aby połączyć asystenta. Token jest wyświetlany tylko raz.",
"tokensDescription": "Wygeneruj token tylko do odczytu, aby połączyć asystenta.",
"scopeNote": "Tokeny są ograniczone do health:read — dostęp tylko do odczytu własnych danych. Nigdy nie mogą uzyskać dostępu do funkcji administracyjnych.",
"writeScopeToggleLabel": "Zezwól na zapis (health:write)",
"writeScopeNote": "Dodaje zakres health:write, aby asystent mógł zapisywać nowe pomiary i wpisy nastroju na Twoim koncie. Każdy zapis jest najpierw potwierdzany. Token nadal nie może usuwać ani edytować istniejących wpisów, zmieniać leków ani uzyskiwać dostępu do funkcji REST lub administracyjnych. Pozostaw wyłączone, aby uzyskać dostęp tylko do odczytu.",
"connectionsTitle": "Połączeni asystenci",
"connectionsDescription": "Asystenci autoryzowani podczas logowania. Cofnięcie połączenia natychmiast kończy dostęp, w tym możliwość odnawiania.",
"connectionsDescription": "Asystenci autoryzowani podczas logowania.",
"noConnections": "Brak połączonych asystentów.",
"connectionConnected": "Połączono",
"connectionLastUsed": "Ostatnio użyto",
"connectionNeverUsed": "Nigdy nie użyto",
"connectionRevoke": "Rozłącz",
"connectionRevokeDescription": "To natychmiast kończy dostęp asystenta i uniemożliwia odnawianie. Ponowne połączenie będzie wymagać nowej autoryzacji.",
"connectionRevokeFailed": "Nie udało się rozłączyć asystenta."
"connectionRevokeFailed": "Nie udało się rozłączyć asystenta.",
"tokensDetail": "Token jest wyświetlany tylko raz.",
"connectionsDetail": "Cofnięcie połączenia natychmiast kończy dostęp, w tym możliwość odnawiania."
},
"strava": "Strava",
"stravaDescription": "Połącz konto Strava, aby synchronizować aktywności z historią treningów.",
"stravaDescriptionSecondary": "Aktywności trafiają na pulpit w tle. Nowy w konfiguracji? Skorzystaj z",
"stravaViewData": "Zobacz treningi",
"stravaCredentials": "Dane API",
"stravaClientId": "Identyfikator klienta",
@@ -6251,7 +6249,9 @@
"explainer": "Wyłączone — wyniki są oceniane względem ogólnego zakresu referencyjnego dla osób bez cukrzycy. Włączone — są oceniane względem węższego zakresu docelowego glikemii z ADA Standards of Care, a Coach opiera na nim swoje uwagi o glukozie. Twoje własne wartości docelowe poniżej nadal mają pierwszeństwo.",
"disclaimer": "To wybiera zakres referencyjny. Nie jest to diagnoza, nigdy nie jest wywnioskowane z pomiaru, a aplikacja nie podejmuje na tej podstawie działań klinicznych. O to, jakie cele dotyczą ciebie, zapytaj swojego lekarza.",
"error": "Nie udało się zapisać ustawienia."
}
},
"dangerZoneDetail": "Konto i dane logowania zostają zachowane.",
"deleteAccountCardDetail": "Tej operacji nie można cofnąć."
},
"admin": {
"title": "Administracja",
@@ -6282,7 +6282,7 @@
},
"aiServerKey": {
"title": "Serwerowy klucz AI",
"description": "Klucz dostawcy na poziomie operatora, z którego może skorzystać każde konto, gdy nie skonfigurowano klucza osobistego.",
"description": "Klucz operatora dla kont, które nie mają własnego.",
"byokHint": "Własny klucz użytkownika (BYOK, logowanie ChatGPT lub dostawca lokalny) zawsze ma pierwszeństwo — klucz serwera jest ostatnim ogniwem łańcucha dostawców.",
"consentHint": "Żądania przez klucz serwera wymagają aktywnej zgody użytkownika na AI; bez potwierdzenia zgody nic nie jest wysyłane.",
"statusConfigured": "Skonfigurowany",
@@ -6388,7 +6388,7 @@
"monitoringTestFailed": "Test monitorowania nie powiódł się",
"assistant": {
"title": "Powierzchnie asystenta",
"description": "Każdy przełącznik ukrywa powierzchnię opartą na modelu w całej aplikacji bez usuwania konfiguracji dostawcy. Główny wyłącznik wymusza wyłączenie każdej opcji podrzędnej.",
"description": "Każdy przełącznik ukrywa powierzchnię opartą na modelu w całej aplikacji bez usuwania konfiguracji dostawcy.",
"master": {
"title": "Asystent włączony",
"description": "Główny wyłącznik dla każdej powierzchni opartej na modelu. Gdy wyłączony, każda opcja podrzędna jest wymuszana na off."
@@ -6412,13 +6412,13 @@
},
"modules": {
"title": "Dostępność modułów",
"description": "Wyłącz moduł dla całego serwera. Moduł wyłączony tutaj jest nieaktywny dla każdego konta, niezależnie od preferencji użytkownika. Domeny podstawowe (waga, ciśnienie, tętno, leki) są zawsze włączone i nie można ich wyłączyć.",
"saveError": "Nie udało się zaktualizować dostępności modułów."
"description": "Wyłącz moduł dla całego serwera.",
"saveError": "Nie udało się zaktualizować dostępności modułów.",
"detail": "Moduł wyłączony tutaj jest nieaktywny dla każdego konta, niezależnie od preferencji użytkownika. Domeny podstawowe (waga, ciśnienie, tętno, leki) są zawsze włączone i nie można ich wyłączyć."
},
"invites": {
"title": "Zaproszenia",
"description": "Linki z zaproszeniem do rejestracji — działają nawet przy zamkniętej rejestracji. Link jest wyświetlany tylko raz.",
"adminOnlyHint": "Tylko administratorzy mogą tworzyć i unieważniać zaproszenia.",
"description": "Linki z zaproszeniem do rejestracji — działają nawet przy zamkniętej rejestracji.",
"create": "Utwórz zaproszenie",
"createTitle": "Utwórz zaproszenie",
"createDescription": "Link umożliwia rejestrację nawet przy zamkniętej rejestracji.",
@@ -6524,7 +6524,7 @@
"outcomeFailed": "Nieudane",
"auditDetails": "Szczegóły",
"deleteAllData": "Usuń wszystkie dane globalnie",
"deleteAllDescription": "Usuwa wszystkie rekordy wszystkich kont: pomiary, leki, wyniki laboratoryjne, dokumenty, wpisy cyklu, nastrój, treningi, sen, rozmowy z Coachem, integracje i historię audytu. Konta i dane logowania zostają zachowane.",
"deleteAllDescription": "Usuwa wszystkie rekordy wszystkich kont; konta i dane logowania zostają zachowane.",
"deleteAllConfirm": "Na pewno usunąć wszystkie dane?",
"deleteAllConfirmDescription": "Ta operacja trwale usuwa wszystkie rekordy wszystkich kont w całej bazie danych, a nie wybrane tabele. Zachowane zostają: konta użytkowników i ich dane logowania, konfiguracja instancji oraz uprawnienia, dzięki którym profile zarządzane pozostają dostępne. Po zakończeniu do dziennika audytu trafia potwierdzenie tego usunięcia; wcześniejsza historia znika razem z resztą.",
"deleteButton": "Usuń wszystkie dane",
@@ -6573,15 +6573,10 @@
"sectionsNav": "Sekcje administracyjne"
},
"overview": {
"welcomeTitle": "Witamy,{name}",
"welcomeSubtitle": "Zalogowano jako administrator. Wybierz sekcję, aby zarządzać HealthLog.",
"snapshotTitle": "Migawka systemu",
"snapshotVersion": "Wersja",
"snapshotDatabase": "Baza danych",
"snapshotWorker": "Worker",
"snapshotStarted": "Uruchomiono",
"snapshotBuildSha": "Build SHA",
"snapshotBuiltAt": "Skompilowano",
"snapshotOfflineGeo": "Dane geograficzne",
"snapshotOfflineGeoOn": "Offline (GeoLite2)",
"snapshotOfflineGeoOff": "Online ({host})",
@@ -6618,7 +6613,7 @@
},
"coach": {
"title": "Coach",
"subtitle": "Powierzchnie asystenta, klucz serwera oraz tabele jakości opinii Coacha i analiz w jednym miejscu."
"subtitle": "Powierzchnie asystenta, klucz serwera i jakość opinii."
},
"reminders": {
"title": "Przypomnienia",
@@ -6674,7 +6669,7 @@
"app-logs": {
"title": "Logi aplikacji",
"subtitle": "Wyświetl najnowsze ustrukturyzowane wide-events emitowane przez ten proces.",
"processNote": "Wyświetlane są tylko logi procesu aplikacji — logi procesu workera trafiają do Loki, gdy ta integracja jest skonfigurowana. Bufor jest ulotny i ograniczony do 500 wpisów.",
"processNote": "Wyświetlane są tylko logi procesu aplikacji — logi procesu workera trafiają do Loki, gdy ta integracja jest skonfigurowana.",
"filterTraceId": "Identyfikator śladu",
"filterAction": "Akcja zawiera…",
"filterLevel": "Poziom",
@@ -6695,7 +6690,8 @@
"emptyDescription": "Żadne zdarzenie nie pasuje jeszcze do bieżących filtrów. Kliknij Odśwież po wygenerowaniu ruchu.",
"refresh": "Odśwież",
"eventDetails": "Szczegóły zdarzenia",
"closeDetails": "Zamknij szczegóły"
"closeDetails": "Zamknij szczegóły",
"processDetail": "Bufor jest ulotny i ograniczony do 500 wpisów."
},
"backups": {
"title": "Kopie zapasowe",
@@ -6763,7 +6759,7 @@
"loading": "Ładowanie statusu szyfrowania...",
"loadError": "Nie udało się wczytać statusu szyfrowania.",
"coverageTitle": "Pokrycie",
"coverageDescription": "Ile zaszyfrowanych wierszy przypada na każdy identyfikator klucza. Pokazywane są tylko identyfikatory kluczy i liczby — nigdy materiał klucza.",
"coverageDescription": "Ile zaszyfrowanych wierszy przypada na każdy identyfikator klucza.",
"activeKeyId": "Identyfikator aktywnego klucza",
"configuredKeys": "Skonfigurowane klucze",
"totalRows": "Zaszyfrowane wiersze",
@@ -6771,7 +6767,7 @@
"safeToDropLegacy": "Można bezpiecznie usunąć stary klucz",
"rotationIncomplete": "Pozostało {count} wierszy do rotacji",
"rotationTitle": "Rotacja kluczy",
"rotationDescription": "Ponownie szyfruje każdą zaszyfrowaną kolumnę aktywnym kluczem. Narzędzie wiersza poleceń pozostaje kanoniczną drogą; ten przycisk to udogodnienie.",
"rotationDescription": "Ponownie szyfruje każdą zaszyfrowaną kolumnę aktywnym kluczem.",
"rotationState": "Status",
"lastRun": "Ostatni przebieg: sprawdzono {scanned}, zrotowano {rotated}, błędy {errors}.",
"rotateNow": "Rotuj teraz",
@@ -6794,7 +6790,8 @@
"running": "W toku",
"completed": "Zakończono",
"failed": "Niepowodzenie"
}
},
"coverageDetail": "Pokazywane są tylko identyfikatory kluczy i liczby — nigdy materiał klucza."
}
},
"carrier": "Operator",
@@ -6840,7 +6837,8 @@
"countFew": "{count} niepowodzenia",
"countOther": "{count} niepowodzeń",
"lastFailed": "Ostatnie niepowodzenie {when}"
}
},
"deleteAllDetail": "Konta użytkowników zostaną zachowane."
},
"achievements": {
"title": "Osiągnięcia",
@@ -7166,7 +7164,7 @@
},
"moodReminder": {
"title": "Codzienne przypomnienie o nastroju",
"description": "Delikatne przypomnienie o wybranej przez Ciebie godzinie, jeśli dziś jeszcze nie zapisano nastroju. Wysyłane tylko skonfigurowanymi już kanałami (Telegram, ntfy, Web Push, Apple Push) i milknie, gdy dziś zapisano nastrój.",
"description": "Delikatne przypomnienie o wybranej przez Ciebie godzinie, jeśli dziś jeszcze nie zapisano nastroju.",
"toggleAria": "Włącz lub wyłącz codzienne przypomnienie o nastroju",
"statusOn": "Aktywne o wybranej godzinie",
"statusOff": "Wyłączone",
@@ -7175,11 +7173,12 @@
"saveError": "Zapis nieudany. Spróbuj ponownie.",
"hourLabel": "Godzina przypomnienia",
"hourAria": "Godzina przypomnienia o nastroju",
"hourSavedToast": "Godzina przypomnienia zaktualizowana."
"hourSavedToast": "Godzina przypomnienia zaktualizowana.",
"detail": "Wysyłane tylko skonfigurowanymi już kanałami (Telegram, ntfy, Web Push, Apple Push) i milknie, gdy dziś zapisano nastrój."
},
"coachNudge": {
"title": "Proaktywny Coach",
"description": "Odzywa się, gdy Twoje dane sugerują zajrzenie do Coacha — np. niska systematyczność przyjmowania leków lub podwyższona tygodniowa średnia ciśnienia. Tematy i częstotliwość można dostosować.",
"description": "Odzywa się, gdy Twoje dane sugerują zajrzenie do Coacha.",
"toggleAria": "Przełącz proaktywne podpowiedzi Coacha",
"statusOn": "Aktywne",
"groupsLabel": "Tematy",
@@ -7206,7 +7205,7 @@
},
"lowStock": {
"title": "Alert niskiego zapasu",
"description": "Raz dziennie sprawdza śledzone zapasy leków i powiadamia, gdy któryś wystarcza na mniej dni niż ten próg. Jedno powiadomienie na niedobór; po uzupełnieniu uzbraja się ponownie.",
"description": "Codziennie zgłasza zapas wystarczający na mniej dni niż ten próg.",
"toggleAria": "Przełącz alerty niskiego zapasu",
"statusOn": "Aktywne",
"statusOff": "Wyłączone",
@@ -7220,7 +7219,8 @@
"enabledToast": "Alerty niskiego zapasu włączone.",
"disabledToast": "Alerty niskiego zapasu wyłączone.",
"saveError": "Zapis nieudany. Spróbuj ponownie.",
"medicationsLink": "Przejdź do swoich leków"
"medicationsLink": "Przejdź do swoich leków",
"detail": "Jedno powiadomienie na niedobór; po uzupełnieniu uzbraja się ponownie."
},
"eventMedicationReminder": "Przypomnienie o leku",
"eventMedicationReminderDesc": "Wysyłane o każdej zaplanowanej godzinie dawki.",
@@ -8049,7 +8049,8 @@
"customize": "Dostosuj wyniki badań",
"localOcr": {
"heading": "Odczytuj skany na tym urządzeniu",
"description": "Pozwala zeskanować wynik laboratoryjny nawet wtedy, gdy Twój dostawca AI nie potrafi odczytywać obrazów: zdjęcie jest odczytywane na Twoim urządzeniu, a do dostawcy wysyłany jest tylko wyodrębniony tekst. Mniej dokładne — sprawdź każdą wartość przed zapisaniem."
"description": "Odczytuje zdjęcie na Twoim urządzeniu i wysyła do dostawcy tylko rozpoznany tekst.",
"detail": "Przydatne, gdy dostawca AI nie odczytuje obrazów. Mniej dokładne — sprawdź każdą wartość przed zapisem."
},
"sort": {
"heading": "Sortowanie",
@@ -9623,7 +9624,7 @@
},
"invite": {
"title": "Zaproś kogoś",
"description": "Nic nie jest udostępniane, dopóki ta osoba nie zaakceptuje. Do tego czasu zaproszenie leży w jej ustawieniach i nic jej nie daje.",
"description": "Nic nie jest udostępniane, dopóki ta osoba nie zaakceptuje.",
"identifierLabel": "Nazwa użytkownika lub e-mail",
"identifierPlaceholder": "Nazwa, którą się loguje",
"identifierHint": "Ta osoba potrzebuje konta na tej instancji. Nie można wysłać zaproszenia na niezarejestrowany adres.",
@@ -9677,7 +9678,7 @@
},
"received": {
"title": "Karty, które mogę otworzyć",
"description": "Zaproszenie nic ci nie daje, dopóki go nie zaakceptujesz. Dostęp możesz zwrócić w dowolnej chwili.",
"description": "Zaproszenie nic ci nie daje, dopóki go nie zaakceptujesz.",
"emptyTitle": "Nikt nie udostępnił ci karty",
"emptyDescription": "Kiedy ktoś cię zaprosi, zaproszenie pojawi się tutaj.",
"loadError": "Nie udało się wczytać listy.",
@@ -9689,7 +9690,8 @@
"renounce": "Zwróć",
"renounceTitle": "Zwrócić kartę użytkownika {name}?",
"renounceBody": "Tracisz dostęp natychmiast. Ta osoba może zaprosić cię ponownie w dowolnej chwili.",
"renounceConfirm": "Zwróć"
"renounceConfirm": "Zwróć",
"detail": "Dostęp możesz zwrócić w dowolnej chwili."
},
"activity": {
"title": "Kiedy otwierano moją kartę",
@@ -9707,13 +9709,8 @@
"addOnlyNote": "Możesz dodawać wpisy do tej karty. Zmieniać je i usuwać może tylko {name}."
},
"lookingAfter": {
"title": "Opiekuję się",
"description": "Karty, które udostępniły ci inne osoby.",
"levelRead": "Może przeglądać",
"levelWrite": "Może przeglądać i dodawać",
"kindShared": "Udostępniona karta",
"kindManaged": "Zarządzany profil",
"open": "Otwórz kartę użytkownika {name}"
"kindManaged": "Zarządzany profil"
},
"managed": {
"title": "Profile, którymi się opiekujesz",
+83
View File
@@ -0,0 +1,83 @@
/**
* One-shot helper for a text sweep across the six locale bundles.
*
* node scripts/i18n-key-tool.mjs drop <dotted.key> [...]
* node scripts/i18n-key-tool.mjs set <locale> <dotted.key> <value>
* node scripts/i18n-key-tool.mjs show <dotted.key>
*
* Keys are dotted paths into the bundle; a segment containing a literal dot
* is not supported (no such key exists). `drop` removes the key from every
* locale and prunes an object left empty behind it.
*/
import { readFileSync, writeFileSync } from "node:fs";
const LOCALES = ["de", "en", "es", "fr", "it", "pl"];
const file = (loc) => `messages/${loc}.json`;
function read(loc) {
return JSON.parse(readFileSync(file(loc), "utf8"));
}
function write(loc, data) {
writeFileSync(file(loc), `${JSON.stringify(data, null, 2)}\n`, "utf8");
}
function drop(bundle, path) {
const parts = path.split(".");
const last = parts.pop();
const chain = [];
let node = bundle;
for (const part of parts) {
if (node == null || typeof node !== "object") return false;
chain.push([node, part]);
node = node[part];
}
if (node == null || !(last in node)) return false;
delete node[last];
for (let i = chain.length - 1; i >= 0; i -= 1) {
const [parent, key] = chain[i];
if (parent[key] && Object.keys(parent[key]).length === 0)
delete parent[key];
else break;
}
return true;
}
function set(bundle, path, value) {
const parts = path.split(".");
const last = parts.pop();
let node = bundle;
for (const part of parts) {
if (typeof node[part] !== "object" || node[part] === null) node[part] = {};
node = node[part];
}
node[last] = value;
}
function get(bundle, path) {
return path.split(".").reduce((n, p) => (n == null ? n : n[p]), bundle);
}
const [cmd, ...rest] = process.argv.slice(2);
if (cmd === "drop") {
for (const loc of LOCALES) {
const bundle = read(loc);
let n = 0;
for (const key of rest) if (drop(bundle, key)) n += 1;
write(loc, bundle);
console.log(`${loc}: dropped ${n}/${rest.length}`);
}
} else if (cmd === "set") {
const [loc, key, ...valueParts] = rest;
const bundle = read(loc);
set(bundle, key, valueParts.join(" "));
write(loc, bundle);
console.log(`${loc}: ${key} set`);
} else if (cmd === "show") {
for (const loc of LOCALES) {
console.log(`${loc}\t${JSON.stringify(get(read(loc), rest[0]))}`);
}
} else {
console.error("usage: drop|set|show");
process.exit(1);
}
+71
View File
@@ -0,0 +1,71 @@
/**
* Measures the description / hint strings under the `settings` and `admin`
* namespaces against the design standards' §3 rule: one sentence, guideline
* ≤ 120 characters.
*
* node scripts/measure-description-lengths.mjs [locale=de] [--top N]
*
* A review tool, not a gate — the gate is
* `src/components/settings/__tests__/card-description-length.test.ts`, which
* checks the strings that actually reach a card header's description slot.
* This one sweeps wider so a long hint in a body still shows up in a sweep.
*/
import { readFileSync } from "node:fs";
const locale = process.argv[2]?.startsWith("--")
? "de"
: (process.argv[2] ?? "de");
const topIdx = process.argv.indexOf("--top");
const top = topIdx === -1 ? 40 : Number(process.argv[topIdx + 1]);
const NAME = /(description|subtitle|hint|note|explainer|help|body)$/i;
function walk(node, path, out) {
for (const [key, value] of Object.entries(node)) {
const next = path ? `${path}.${key}` : key;
if (typeof value === "string") {
if (NAME.test(key)) out.push([next, value]);
} else if (value && typeof value === "object") {
walk(value, next, out);
}
}
}
/** Sentence count: terminators followed by a space + capital, or at the end. */
function sentences(text) {
const trimmed = text.trim();
if (!trimmed) return 0;
const matches = trimmed.match(/[.!?](\s+[^\s]|$)/g);
return Math.max(1, matches ? matches.length : 1);
}
const bundle = JSON.parse(readFileSync(`messages/${locale}.json`, "utf8"));
const entries = [];
for (const ns of ["settings", "admin"]) {
if (bundle[ns]) walk(bundle[ns], ns, entries);
}
const lengths = entries.map(([, v]) => v.length).sort((a, b) => a - b);
const at = (q) =>
lengths[Math.min(lengths.length - 1, Math.floor(lengths.length * q))];
const multi = entries.filter(([, v]) => sentences(v) >= 2);
console.log(`locale ${locale} — ${entries.length} description/hint strings`);
console.log(
`length: min ${lengths[0]}, median ${at(0.5)}, p75 ${at(0.75)}, p90 ${at(0.9)}, max ${lengths.at(-1)}`,
);
console.log(
`> 120 chars: ${lengths.filter((n) => n > 120).length}, > 200: ${lengths.filter((n) => n > 200).length}`,
);
console.log(
`>= 2 sentences: ${multi.length}, >= 3: ${entries.filter(([, v]) => sentences(v) >= 3).length}`,
);
console.log(`\nLONGEST ${top}:`);
for (const [key, value] of [...entries]
.sort((a, b) => b[1].length - a[1].length)
.slice(0, top)) {
console.log(
`${String(value.length).padStart(4)}ch ${sentences(value)}S ${key}`,
);
console.log(` ${value.slice(0, 130)}${value.length > 130 ? "…" : ""}`);
}
+137
View File
@@ -0,0 +1,137 @@
/**
* Splits an over-long description at its first sentence boundary, in every
* locale, WITHOUT retranslating anything: the tail is already written in each
* language, it is just in the wrong slot.
*
* node scripts/split-description.mjs --dry <key> [<key> …]
* node scripts/split-description.mjs drop <key> [<key> …]
* node scripts/split-description.mjs move <key> <tailKey>
*
* drop — the description keeps sentence 1; the tail is deleted.
* move — the description keeps sentence 1; the tail lands under `tailKey`,
* to be rendered in the card body as foreground `text-sm`.
* --dry — print what each locale would become, change nothing.
*
* A terminator followed by whitespace and an upper-case letter (or a digit,
* or an opening quote) is a sentence boundary. `Claude.ai` and `z. B.` do not
* match: the first has no whitespace, the second continues lower-case.
*/
import { readFileSync, writeFileSync } from "node:fs";
const LOCALES = ["de", "en", "es", "fr", "it", "pl"];
const BOUNDARY = /(?<=[.!?])\s+(?=[A-ZÄÖÜÀÂÉÈÊÎÔÙÛÇŁŚŹŻĄĆĘŃÓ0-9„"„«])/u;
/**
* Abbreviations whose full stop is not a sentence end.
*
* The boundary above demands an upper-case letter after the space, which
* already rejects `z. B.` mid-sentence in German (`B.` continues lower-case
* more often than not) — but not reliably: "… z. B. Blutdruck." splits after
* `z.`, because `B` IS upper-case. The same hole exists for `d. h.`, `u. a.`,
* `e.g.` / `i.e.` before a capitalised noun, and French `p. ex.`. A split
* there produces a head that ends mid-abbreviation and a tail that starts
* with a fragment, in a file nobody re-reads afterwards.
*/
const ABBREVIATIONS = [
"bzw.",
"ca.",
"vgl.",
"usw.",
"e.g.",
"i.e.",
"cf.",
"etc.",
"ej.",
"es.",
"np.",
"tzn.",
"ecc.",
"ex.",
];
/**
* A single letter followed by a full stop is an abbreviation part, never a
* sentence: `z. B.`, `d. h.`, `u. a.`, `p. ex.`. Both halves have to be
* caught, which is why this is a shape rather than a list — catching only
* `z.` still splits after the `B.`.
*/
const SINGLE_LETTER = /(^|\s)\p{L}\.$/u;
/** Split on sentence boundaries, skipping the ones an abbreviation created. */
function splitSentences(text) {
const rough = text.split(BOUNDARY);
const out = [];
for (const part of rough) {
const previous = out[out.length - 1];
const lower = previous?.toLowerCase() ?? "";
const endsInAbbreviation =
previous !== undefined &&
(SINGLE_LETTER.test(previous) ||
ABBREVIATIONS.some(
(abbr) => lower.endsWith(` ${abbr}`) || lower === abbr,
));
// Merge, then let the NEXT iteration re-test the merged tail: `z. B.`
// needs two merges, and a single pass would stop after the first.
if (endsInAbbreviation) out[out.length - 1] = `${previous} ${part}`;
else out.push(part);
}
return out;
}
function get(bundle, path) {
return path
.split(".")
.reduce((node, part) => (node == null ? node : node[part]), bundle);
}
function set(bundle, path, value) {
const parts = path.split(".");
const last = parts.pop();
let node = bundle;
for (const part of parts) {
if (typeof node[part] !== "object" || node[part] === null) node[part] = {};
node = node[part];
}
node[last] = value;
}
const [mode, ...rest] = process.argv.slice(2);
const dry = mode === "--dry";
const keys = dry ? rest : mode === "move" ? [rest[0]] : rest;
const tailKey = mode === "move" ? rest[1] : null;
if (!mode || keys.length === 0) {
console.error("usage: --dry|drop|move <key> [tailKey]");
process.exit(1);
}
for (const locale of LOCALES) {
const file = `messages/${locale}.json`;
const bundle = JSON.parse(readFileSync(file, "utf8"));
let changed = false;
for (const key of keys) {
const value = get(bundle, key);
if (typeof value !== "string") {
console.log(`${locale} ${key}: MISSING`);
continue;
}
const parts = splitSentences(value);
if (parts.length < 2) {
console.log(`${locale} ${key}: single sentence, untouched`);
continue;
}
const head = parts[0].trim();
const tail = parts.slice(1).join(" ").trim();
if (dry) {
console.log(
`${locale} ${key}\n head(${head.length}): ${head}\n tail: ${tail}`,
);
continue;
}
set(bundle, key, head);
if (tailKey) set(bundle, tailKey, tail);
changed = true;
}
if (changed) {
writeFileSync(file, `${JSON.stringify(bundle, null, 2)}\n`, "utf8");
console.log(`${locale}: written`);
}
}
@@ -80,13 +80,26 @@ const NOT_A_CONSUMER = new Set([
join(SRC, "app", "api", "auth", "me", "route.ts"),
]);
const ACCOUNT_ENTRY_CONSUMER = join(
SRC,
"components",
"dashboard",
"looking-after-card.tsx",
);
/**
* The surfaces that read a per-account access ENTRY. This was one file (the
* dashboard's caring-for card) until that card came off the dashboard —
* records other people share are reached from the account switcher instead.
* A single named file was the fragile part: the guarantee is "every additive
* field has a reader", not "this one component still exists".
*/
const ACCOUNT_ENTRY_CONSUMERS = [
join(SRC, "lib", "navigation", "record-presentation.ts"),
join(SRC, "components", "layout", "account-switcher-menu.tsx"),
];
const ACTIVE_RECORD_CONSUMER = join(SRC, "hooks", "use-record-capabilities.ts");
/**
* The names an access ENTRY is bound to at the two consumer sites. Pinning
* these is what stops the matcher from accepting `.level` on an unrelated
* object; a new consumer that binds it under a third name adds that name here,
* which is a deliberate edit rather than a silent widening.
*/
const ENTRY_BINDINGS = ["entry", "account"];
const ACCOUNT_ACCESS_ADDITIVE_FIELDS = [
"level",
"sections",
@@ -231,8 +244,8 @@ describe("account payload consumer guard", () => {
});
it("has non-zero entry and active-record readers for additive access fields", () => {
const entrySource = stripComments(
readFileSync(ACCOUNT_ENTRY_CONSUMER, "utf8"),
const entrySources = ACCOUNT_ENTRY_CONSUMERS.map((file) =>
stripComments(readFileSync(file, "utf8")),
);
const activeSource = stripComments(
readFileSync(ACTIVE_RECORD_CONSUMER, "utf8"),
@@ -240,9 +253,21 @@ describe("account payload consumer guard", () => {
const readerCount = ACCOUNT_ACCESS_ADDITIVE_FIELDS.reduce(
(count, field) => {
expect(entrySource).toMatch(new RegExp(`entry\\.${field}(?![\\w$])`));
// The read has to be a read OF THE PAYLOAD ENTRY, so the matcher names
// the bindings the entry actually arrives under. `\w\.<field>` would
// have accepted `.level` on any object in the file — a chart's zoom
// level, a log record's level — and reported a consumer that does not
// exist. An empty match set fails rather than passing quietly.
const read = new RegExp(
`\\b(?:${ENTRY_BINDINGS.join("|")})\\.${field}(?![\\w$])`,
);
const readers = entrySources.filter((source) => read.test(source));
expect(
readers.length,
`no client reader for account access field \`${field}\` bound as ${ENTRY_BINDINGS.join(" / ")}`,
).toBeGreaterThan(0);
expect(activeSource).toMatch(new RegExp(`active\\.${field}(?![\\w$])`));
return count + 2;
return count + readers.length + 1;
},
0,
);
+17 -2
View File
@@ -102,7 +102,6 @@ const LEGIT_IDENTICAL = new Set<string>([
"admin.overview.snapshotOfflineGeoOff",
"admin.overview.snapshotOfflineGeoOn",
"admin.webPushVapidTitle",
"insights.healthScore.provenance.sources.appleHealth",
"measurements.sourceAppleHealth",
"onboarding.source.more.appleHealth",
"settings.ai.providerChain.types.anthropic",
@@ -114,7 +113,6 @@ const LEGIT_IDENTICAL = new Set<string>([
"mood.tag.fastFood",
// Technical / config labels conventionally kept verbatim.
"admin.hostMetrics.diskBusyPercent",
"admin.overview.snapshotBuildSha",
"admin.servicesGlobal",
"admin.webPushVapidPublicKeyPlaceholder",
"settings.ai.baseUrlLabel",
@@ -231,4 +229,21 @@ describe("i18n English-leak guard", () => {
).toEqual([]);
},
);
it("carries no stale LEGIT_IDENTICAL entry", () => {
// An allowlist that outlives the key it excused is an allowlist nobody
// can reason about: the next reader cannot tell which entries are load-
// bearing. Two were already dead when this assertion was written — one
// from a key removed long before, one from the duplicated version fields
// that came off `/admin`. The sibling allowlists in
// `card-description-length.test.ts` and `account-payload-consumer-guard`
// carry the same check; this one did not.
const stale = [...LEGIT_IDENTICAL].filter(
(key) => typeof enMap.get(key) !== "string",
);
expect(
stale,
"these keys no longer exist in en.json — drop them from LEGIT_IDENTICAL",
).toEqual([]);
});
});
+24 -78
View File
@@ -15,7 +15,6 @@
import type { JSX } from "react";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { AboutSection } from "@/components/settings/about-section";
import { AiQualitySection } from "@/components/admin/ai-quality-section";
import { AiServerKeySection } from "@/components/admin/ai-server-key-section";
@@ -46,7 +45,6 @@ export function AdminSectionRenderer({
slug,
}: RendererProps): JSX.Element | null {
const { user } = useAuth();
const { t } = useTranslations();
// The admin shell layout already gates non-admins via `<AuthShell>`,
// but the auth hook is async — return nothing on the first render so
@@ -57,37 +55,25 @@ export function AdminSectionRenderer({
switch (slug) {
case "system-status":
return (
<SectionFrame
title={t("admin.section.system-status.title")}
subtitle={t("admin.section.system-status.subtitle")}
>
<SectionFrame>
<SystemStatusSection />
</SectionFrame>
);
case "general":
return (
<SectionFrame
title={t("admin.section.general.title")}
subtitle={t("admin.section.general.subtitle")}
>
<SectionFrame>
<GeneralSettingsSection />
</SectionFrame>
);
case "services":
return (
<SectionFrame
title={t("admin.section.services.title")}
subtitle={t("admin.section.services.subtitle")}
>
<SectionFrame>
<ServicesSection />
</SectionFrame>
);
case "integrations":
return (
<SectionFrame
title={t("admin.section.integrations.title")}
subtitle={t("admin.section.integrations.subtitle")}
>
<SectionFrame>
<IntegrationsGroupSection />
</SectionFrame>
);
@@ -99,10 +85,7 @@ export function AdminSectionRenderer({
// `module-availability` section: it gates EVERY module, not just the
// coach, so it no longer belongs stacked under Coach.
return (
<SectionFrame
title={t("admin.section.coach.title")}
subtitle={t("admin.section.coach.subtitle")}
>
<SectionFrame>
<AssistantSection />
<AiServerKeySection />
<CentralCodexSection />
@@ -115,28 +98,19 @@ export function AdminSectionRenderer({
// admin section (was stacked under Coach). Reconciled name: the
// user-facing "Module" settings pick from what is available here.
return (
<SectionFrame
title={t("admin.section.module-availability.title")}
subtitle={t("admin.section.module-availability.subtitle")}
>
<SectionFrame>
<ModuleAvailabilitySection />
</SectionFrame>
);
case "reminders":
return (
<SectionFrame
title={t("admin.section.reminders.title")}
subtitle={t("admin.section.reminders.subtitle")}
>
<SectionFrame>
<RemindersSection />
</SectionFrame>
);
case "users":
return (
<SectionFrame
title={t("admin.section.users.title")}
subtitle={t("admin.section.users.subtitle")}
>
<SectionFrame>
<UserManagementSection />
</SectionFrame>
);
@@ -145,64 +119,43 @@ export function AdminSectionRenderer({
// the full table (status, redemptions, revocation) is a workflow
// of its own and was drowning under the account list.
return (
<SectionFrame
title={t("admin.section.invites.title")}
subtitle={t("admin.section.invites.subtitle")}
>
<SectionFrame>
<InviteTokensSection />
</SectionFrame>
);
case "api-tokens":
return (
<SectionFrame
title={t("admin.section.api-tokens.title")}
subtitle={t("admin.section.api-tokens.subtitle")}
>
<SectionFrame>
<ApiTokenOverviewSection />
</SectionFrame>
);
case "login-overview":
return (
<SectionFrame
title={t("admin.section.login-overview.title")}
subtitle={t("admin.section.login-overview.subtitle")}
>
<SectionFrame>
<LoginOverviewSection />
</SectionFrame>
);
case "app-logs":
return (
<SectionFrame
title={t("admin.section.app-logs.title")}
subtitle={t("admin.section.app-logs.subtitle")}
>
<SectionFrame>
<AppLogPreviewSection />
</SectionFrame>
);
case "backups":
return (
<SectionFrame
title={t("admin.section.backups.title")}
subtitle={t("admin.section.backups.subtitle")}
>
<SectionFrame>
<BackupsSection />
</SectionFrame>
);
case "encryption":
return (
<SectionFrame
title={t("admin.section.encryption.title")}
subtitle={t("admin.section.encryption.subtitle")}
>
<SectionFrame>
<EncryptionSection />
</SectionFrame>
);
case "danger-zone":
return (
<SectionFrame
title={t("admin.section.danger-zone.title")}
subtitle={t("admin.section.danger-zone.subtitle")}
>
<SectionFrame>
<DangerZoneSection />
</SectionFrame>
);
@@ -212,10 +165,7 @@ export function AdminSectionRenderer({
// section so it gets the same visible heading + subtitle (the one admin
// page that was bypassing the frame and showing no heading).
return (
<SectionFrame
title={t("admin.section.about.title")}
subtitle={t("admin.section.about.subtitle")}
>
<SectionFrame>
<AboutSection />
</SectionFrame>
);
@@ -225,17 +175,13 @@ export function AdminSectionRenderer({
}
}
interface SectionFrameProps {
// v1.18.6.1 — the visible heading + subtitle moved to `<AdminShell>`, which
// places them in their own grid row so the left nav lines up with the first
// card. `title` / `subtitle` are still passed by every call site (and remain
// the i18n source of truth the shell reads), but the frame no longer paints
// them — it is now just the `space-y-6` card-stack wrapper.
title?: string;
subtitle?: string;
children: React.ReactNode;
}
function SectionFrame({ children }: SectionFrameProps) {
/**
* The card-stack wrapper for a section. It took a `title` and a `subtitle`
* from all seventeen call sites and rendered neither — the visible heading
* moved to `<AdminShell>` in v1.18.6.1, which reads the same i18n keys
* itself. A prop contract nothing honours is a promise a later reader
* believes, so it is gone.
*/
function SectionFrame({ children }: { children: React.ReactNode }) {
return <div className="space-y-6">{children}</div>;
}
-16
View File
@@ -41,22 +41,6 @@ export default function AdminOverviewPage() {
</div>
)}
{/* Welcome card — greeting with admin context indicator. */}
<section
aria-labelledby="admin-overview-welcome-heading"
className="bg-card border-border rounded-xl border p-6"
>
<h2
id="admin-overview-welcome-heading"
className="text-lg font-semibold"
>
{t("admin.overview.welcomeTitle", { name: user.username })}
</h2>
<p className="text-muted-foreground mt-1 text-sm">
{t("admin.overview.welcomeSubtitle")}
</p>
</section>
<VersionTileSection />
<SystemStatusSummary />
+5
View File
@@ -76,6 +76,11 @@ export default function InsightsEcgPage() {
title={t("insights.ecg.sectionTitle")}
description={t("insights.subPage.ecgDescription")}
>
{/* What the app does and does not claim about a trace. Its own
paragraph, so the page explainer above stays one sentence. */}
<p className="text-muted-foreground text-sm">
{t("insights.subPage.ecgDisclaimer")}
</p>
<EcgSection enabled={isAuthenticated} hideHeading />
</SubPageShell>
);
+66 -76
View File
@@ -33,6 +33,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { EmptyState } from "@/components/ui/empty-state";
import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Skeleton } from "@/components/ui/skeleton";
import {
@@ -359,14 +360,10 @@ export default function MedicationsPageClient() {
if (!isAuthenticated) {
return (
<div className="space-y-6">
<div className="space-y-1">
<h1 className="text-2xl font-bold tracking-tight">
{t("medications.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("medications.loginRequired")}
</p>
</div>
<PageHeader
title={t("medications.title")}
description={t("medications.loginRequired")}
/>
</div>
);
}
@@ -407,90 +404,83 @@ export default function MedicationsPageClient() {
return (
<div className="space-y-6">
<PullToRefreshIndicator {...pull} />
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<h1
data-tour-id="medications-hero"
className="text-2xl font-bold tracking-tight"
>
{t("medications.title")}
</h1>
{/* v1.4.34 IW-G — subtitle stays visible on mobile so the
H1 isn't an unframed label. */}
<p className="text-muted-foreground truncate text-xs sm:text-sm">
{t("medications.subtitle")}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
{/* v1.16.11 (#316) — "Alle fälligen einnehmen". Contextual, not a
<PageHeader
title={
<span data-tour-id="medications-hero">{t("medications.title")}</span>
}
description={t("medications.subtitle")}
actions={
<>
{/* v1.16.11 (#316) — "Alle fälligen einnehmen". Contextual, not a
permanent fixture: it renders only while at least TWO
medications are currently due (a single due dose is the
card's own one-tap job). Calm outline variant so the primary
Add button keeps the visual lead; same responsive 44-px
mobile tap floor as its neighbours. */}
{canAdd && dueMeds.length >= 2 && (
<Button
variant="outline"
onClick={() => setTakeAllOpen(true)}
className="min-h-11 sm:min-h-9"
>
<CheckCheck className="h-4 w-4" aria-hidden="true" />
{t("medications.takeAllDue.button")}
</Button>
)}
{/* v1.16.11 — the wrench is the one customize entry point:
{canAdd && dueMeds.length >= 2 && (
<Button
variant="outline"
onClick={() => setTakeAllOpen(true)}
className="min-h-11 sm:min-h-9"
>
<CheckCheck className="h-4 w-4" aria-hidden="true" />
{t("medications.takeAllDue.button")}
</Button>
)}
{/* v1.16.11 — the wrench is the one customize entry point:
it links to /settings/medications, which owns the view
preference (cards ⇄ table) and the manual-order editor.
Same glyph, slot (left of the add button) and responsive
44-px mobile tap floor as the dashboard and insights
headers. */}
{canManage && (
<Button
asChild
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
>
<Link
href="/settings/layout/medications"
aria-label={t("medications.customize")}
title={t("medications.customize")}
{canManage && (
<Button
asChild
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
>
<Wrench className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
)}
{/* v1.14.0 — the "Add" button is now a choice: log an intake
<Link
href="/settings/layout/medications"
aria-label={t("medications.customize")}
title={t("medications.customize")}
>
<Wrench className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
)}
{/* v1.14.0 — the "Add" button is now a choice: log an intake
(incl. a backdated one) against an existing medication, or
create a new medication. v1.12.2 — match the dashboard Add
button's responsive tap-target floor (`min-h-11 sm:min-h-9`) so
both primary "add" entry points clear the WCAG 2.5.5 44px mobile
minimum identically. */}
{canAdd && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="min-h-11 sm:min-h-9">
<Plus className="h-4 w-4" />
{t("medications.addMedication")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={() => setLogIntakeOpen(true)}
disabled={activeMeds.length === 0}
>
<CheckCircle2 className="mr-2 h-4 w-4" />
{t("medications.addChoice.logIntake")}
</DropdownMenuItem>
<DropdownMenuItem onSelect={openCreate}>
<Pill className="mr-2 h-4 w-4" />
{t("medications.addChoice.newMedication")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
{canAdd && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="min-h-11 sm:min-h-9">
<Plus className="h-4 w-4" />
{t("medications.addMedication")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={() => setLogIntakeOpen(true)}
disabled={activeMeds.length === 0}
>
<CheckCircle2 className="mr-2 h-4 w-4" />
{t("medications.addChoice.logIntake")}
</DropdownMenuItem>
<DropdownMenuItem onSelect={openCreate}>
<Pill className="mr-2 h-4 w-4" />
{t("medications.addChoice.newMedication")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</>
}
/>
{isLoading || isLayoutLoading ? (
// v1.11.3 C4 — skeletons instead of a bare centred spinner so the
+4 -7
View File
@@ -1,6 +1,5 @@
"use client";
import { LookingAfterCard } from "@/components/dashboard/looking-after-card";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import React, { Suspense, useCallback, useMemo, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
@@ -931,12 +930,10 @@ export default function DashboardPageClient({
<PullToRefreshIndicator {...pull} />
<DashboardHeader onQuickEntry={setQuickEntryDialog} />
{/* v1.36.x — the caregiver's front door, directly under the greeting
and above this account's own day. Somebody who opens the app to
check on a parent should not have to go through settings to get
there. Renders nothing for an account nobody has shared with, and
nothing while a switch is already on. */}
<LookingAfterCard />
{/* Records other people share with you are reached from the account
switcher in the top bar and the sidebar, not from a card here. The
dashboard is this account's own day; a permanent block about other
people's records sat above it and pushed it down. */}
{/* S2 — the Today hero, promoted above the tile strip (MARC sign-off
decision 1). Renders the S1 daily digest: the day's read, the
+12
View File
@@ -8,6 +8,7 @@ import {
isLayoutGroupId,
} from "@/components/settings/layout-groups";
import { SettingsShell } from "@/components/settings/settings-shell";
import { SettingsHubBackLink } from "@/components/settings/settings-hub-back-link";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { getServerTranslator } from "@/lib/i18n/server-translator";
@@ -67,6 +68,17 @@ export default async function SettingsLayoutModulePage({ params }: PageProps) {
title: t(group.titleKey),
subtitle: t(group.descriptionKey),
headingId,
// The way back to the hub. `SettingsHubBackLink` and its string have
// existed since the sub-pages were split out, but the only thing that
// mounted them was the shell's slug FALLBACK — and these pages pass an
// explicit `heading`, so the fallback never ran and no child route
// ever painted a back link. Both ends existed; nothing connected them.
topSlot: (
<SettingsHubBackLink
href="/settings/layout"
labelKey="settings.sections.layout.backToHub"
/>
),
}}
>
<RecordSettingsSectionGate section="layout">
@@ -6,7 +6,10 @@ import { renderToStaticMarkup } from "react-dom/server";
* `StatusCardGrid` (the section-card grid the maintainer described as redundant
* with the sidebar nav). The status-card component itself was removed
* from the codebase. This test guards the *new* overview composition:
* a welcome card, the system snapshot, and the recent-audit preview.
* the version tile, the system snapshot, and the recent-audit preview. The
* welcome card went with the grid: it greeted the reader and told them they
* were signed in as an administrator, on a route only an administrator can
* reach.
*
* The file kept its old name so git history follows it. Nothing else
* imports `status-card-grid`.
@@ -71,12 +74,10 @@ describe("/admin overview composition", () => {
expect(html).not.toContain("Open backups");
});
it("renders the welcome card with the admin's username", () => {
it("renders no welcome card", () => {
const html = render(<AdminOverviewPage />);
// `welcomeTitle` interpolates the username; the mocked auth hook
// returns "testuser".
expect(html).toContain("Welcome, testuser");
expect(html).toContain("admin-overview-welcome-heading");
expect(html).not.toContain("Welcome, testuser");
expect(html).not.toContain("admin-overview-welcome-heading");
});
it("renders the system snapshot section heading", () => {
+14 -8
View File
@@ -1,6 +1,7 @@
"use client";
import { useMemo } from "react";
import { StatTile } from "@/components/admin/_stat-tile";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Cpu, Fingerprint, Globe, KeyRound } from "lucide-react";
@@ -149,8 +150,14 @@ export function ConfiguredBadge() {
);
}
/**
* The console's label/value tile. Four hand-rolled variants of this box used
* to exist side by side; `StatTile` is the one, and this is the thin adapter
* the status grids call (the `className` they pass tints the VALUE, which is
* why it cannot just be `StatTile` under a different name).
*/
export function StatusItem({
icon: Icon,
icon,
label,
value,
className,
@@ -161,13 +168,12 @@ export function StatusItem({
className?: string;
}) {
return (
<div className="bg-muted/50 rounded-lg p-3">
<div className="text-muted-foreground flex items-center gap-1.5 text-xs">
<Icon className="h-3.5 w-3.5" />
{label}
</div>
<p className={`mt-1 text-sm font-semibold ${className ?? ""}`}>{value}</p>
</div>
<StatTile
icon={icon}
label={label}
value={value}
valueClassName={className}
/>
);
}
+43
View File
@@ -0,0 +1,43 @@
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* `<StatTile>` — the one label/value mini tile in the Admin console.
*
* Four hand-rolled variants of this box existed side by side: `StatusItem`
* (`bg-muted/50 p-3`), the encryption section's local `Stat`
* (`bg-muted/40 px-3 py-2`), the user-management inline panels
* (`bg-muted/80 p-4`), and the backups upload box (`bg-muted/30 p-3`). Four
* fills, three paddings, one role. This is the role.
*
* `icon` is optional — a tile with a glyph and one without still share the
* frame, so a grid can mix them without a step in tile height.
*/
export function StatTile({
icon: Icon,
label,
value,
valueClassName,
className,
}: {
icon?: React.ComponentType<{ className?: string }>;
label: React.ReactNode;
value: React.ReactNode;
/** Extra classes on the value line — a status colour, or `font-mono`. */
valueClassName?: string;
className?: string;
}) {
return (
<div
data-slot="stat-tile"
className={cn("bg-muted/50 space-y-1 rounded-lg p-3", className)}
>
<div className="text-muted-foreground flex items-center gap-1.5 text-xs">
{Icon ? <Icon className="size-3.5 shrink-0" /> : null}
{label}
</div>
<p className={cn("text-sm font-semibold", valueClassName)}>{value}</p>
</div>
);
}
+9 -14
View File
@@ -37,6 +37,7 @@ import {
} from "lucide-react";
import { cn } from "@/lib/utils";
import { PageHeader } from "@/components/ui/page-header";
import { SUB_SHELL_GRID_FLOOR } from "@/components/layout/shell-metrics";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
@@ -253,21 +254,15 @@ export function AdminShell({ active, children }: AdminShellProps) {
? t("admin.subtitle")
: t(`admin.section.${activeSlug}.subtitle`);
// Same two-copy heading as the Settings shell (mobile above the chip strip,
// desktop inside the grid row) and the same primitive, so the two consoles
// cannot drift a step apart again.
const headingBlock = (mobile = false) => (
<div>
{mobile ? (
<div
role="heading"
aria-level={1}
className="text-2xl font-bold tracking-tight"
>
{headingTitle}
</div>
) : (
<h1 className="text-2xl font-bold tracking-tight">{headingTitle}</h1>
)}
<p className="text-muted-foreground text-sm">{headingSubtitle}</p>
</div>
<PageHeader
headingAs={mobile ? "div" : "h1"}
title={headingTitle}
description={headingSubtitle}
/>
);
// v1.4.25 W8 — AuthShell wraps the page in `px-4 py-6 md:px-6`
+1 -1
View File
@@ -76,7 +76,7 @@ export function AiQualitySection() {
// resolves. Mirrors <CoachFeedbackSection> (header outside the
// fetch-state branch).
return (
<SettingsCard className="space-y-4">
<SettingsCard>
<SettingsCardHeader
icon={Sparkles}
title={t("admin.aiQuality.title")}
+25 -22
View File
@@ -6,6 +6,7 @@ import { KeyRound, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Button } from "@/components/ui/button";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { Input } from "@/components/ui/input";
@@ -98,13 +99,7 @@ export function AiServerKeySection() {
<SettingsCardHeader
icon={KeyRound}
title={t("admin.aiServerKey.title")}
description={
<>
<p>{t("admin.aiServerKey.description")}</p>
<p>{t("admin.aiServerKey.byokHint")}</p>
<p>{t("admin.aiServerKey.consentHint")}</p>
</>
}
description={t("admin.aiServerKey.description")}
status={
<Badge variant={configured ? "default" : "outline"}>
{configured
@@ -114,7 +109,12 @@ export function AiServerKeySection() {
}
/>
<div className="mt-4 space-y-4 pl-7">
<div className="space-y-4">
<div className="space-y-2 text-sm">
<p>{t("admin.aiServerKey.byokHint")}</p>
<p>{t("admin.aiServerKey.consentHint")}</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="admin-ai-server-key-input">
{t("admin.aiServerKey.keyLabel")}
@@ -159,8 +159,23 @@ export function AiServerKeySection() {
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<SettingsCardActions>
{configured && (
<ConfirmButton
slot="admin-ai-server-key-remove"
variant="outline"
size="sm"
label={t("admin.aiServerKey.remove")}
title={t("admin.aiServerKey.removeTitle")}
body={t("admin.aiServerKey.removeBody")}
confirmLabel={t("admin.aiServerKey.removeConfirm")}
pending={save.isPending}
onConfirm={handleRemove}
/>
)}
<Button
size="sm"
className="min-h-11 sm:min-h-9"
onClick={handleSave}
disabled={save.isPending}
data-slot="admin-ai-server-key-save"
@@ -170,19 +185,7 @@ export function AiServerKeySection() {
)}
{t("admin.aiServerKey.save")}
</Button>
{configured && (
<ConfirmButton
slot="admin-ai-server-key-remove"
variant="outline"
label={t("admin.aiServerKey.remove")}
title={t("admin.aiServerKey.removeTitle")}
body={t("admin.aiServerKey.removeBody")}
confirmLabel={t("admin.aiServerKey.removeConfirm")}
pending={save.isPending}
onConfirm={handleRemove}
/>
)}
</div>
</SettingsCardActions>
</div>
</SettingsCard>
);
@@ -64,7 +64,7 @@ function TruncatedCell({
/**
* v1.4.22 W5 reconcile (S-03) — the revoked / isExpired / active
* 3-way ternary is identical between the desktop table and the
* mobile card list; the only difference is `text-xs` vs `text-[10px]`.
* mobile card list; the only difference is `text-xs` vs `text-2xs`.
* Centralise so the two surfaces stay in lock-step.
*/
function TokenStatusBadge({
@@ -77,7 +77,7 @@ function TokenStatusBadge({
const { t } = useTranslations();
const expired =
token.expiresAt != null && new Date(token.expiresAt) < new Date();
const sizeClass = size === "xs" ? "text-[10px]" : "text-xs";
const sizeClass = size === "xs" ? "text-2xs" : "text-xs";
if (token.revoked) {
return (
<Badge variant="destructive" className={`shrink-0 ${sizeClass}`}>
@@ -160,7 +160,7 @@ export function ApiTokenOverviewSection() {
description={t("admin.apiTokensDescription")}
/>
<div className="mt-4">
<div>
{isLoading ? (
<div className="flex justify-center py-4">
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin motion-reduce:animate-none" />
@@ -317,7 +317,7 @@ export function ApiTokenOverviewSection() {
<Badge
key={p}
variant="secondary"
className="max-w-full truncate text-[10px]"
className="text-2xs max-w-full truncate"
title={
p === "*"
? t("admin.tokenPermissionAllTooltip")
@@ -116,15 +116,16 @@ export function AppLogPreviewSection() {
const events = data?.events ?? [];
return (
<SettingsCard className="space-y-4">
<SettingsCard>
<SettingsCardHeader
icon={FileText}
title={t("admin.section.app-logs.title")}
description={t("admin.section.app-logs.processNote")}
status={
<Button
variant="ghost"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => refetch()}
disabled={isFetching}
aria-label={t("admin.section.app-logs.refresh")}
@@ -132,6 +133,9 @@ export function AppLogPreviewSection() {
<RefreshCw
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""} motion-reduce:animate-none`}
/>
<p className="text-sm">
{t("admin.section.app-logs.processDetail")}
</p>
</Button>
}
/>
@@ -280,7 +284,7 @@ export function AppLogPreviewSection() {
</table>
</div>
<div
className="text-muted-foreground mt-3 flex items-center justify-between text-xs"
className="text-muted-foreground flex items-center justify-between text-xs"
data-testid="app-log-preview-summary"
>
<span>
+1 -1
View File
@@ -103,7 +103,7 @@ export function AssistantSection() {
title={t("admin.assistant.title")}
description={t("admin.assistant.description")}
/>
<div className="mt-4 space-y-4 pl-7">
<div className="space-y-4">
<SettingsToggle
label={t("admin.assistant.master.title")}
description={t("admin.assistant.master.description")}
+25 -28
View File
@@ -36,6 +36,7 @@ import {
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { Input } from "@/components/ui/input";
@@ -225,7 +226,7 @@ export function RestoreSkipReport({
<div
role="status"
data-slot="restore-skip-report"
className="border-warning/40 bg-warning/10 mt-4 rounded-md border px-3 py-2 text-sm"
className="border-warning/40 bg-warning/10 rounded-md border px-3 py-2 text-sm"
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-2">
@@ -496,13 +497,8 @@ export function BackupsSection() {
return (
<SettingsCard>
{/* v1.18.1 E3 — the snapshot count leads (numbers first), the
"Run now" button follows, and the explainer + docs link move
into the header's description slot. Previously the description
sat in a separate `mt-1 pl-7` paragraph that crowded the button
directly above it; routing it through the header's `space-y-1`
stack restores breathing room and left-aligns it under the
title. */}
{/* v1.18.1 E3 — the snapshot count leads (numbers first) and the
"Run now" button follows. */}
<SettingsCardHeader
icon={Database}
title={t("admin.section.backups.title")}
@@ -513,21 +509,6 @@ export function BackupsSection() {
</Badge>
) : null
}
status={
<Button
size="sm"
disabled={runBackup.isPending}
onClick={() => runBackup.mutate()}
className="min-h-11"
>
{runBackup.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<PlayCircle className="h-3.5 w-3.5" />
)}
{t("admin.section.backups.runNow")}
</Button>
}
description={
<p>
{t("admin.section.backups.description")}{" "}
@@ -550,7 +531,7 @@ export function BackupsSection() {
backup file independently of any existing rows. The visible
button proxies a hidden file input so the layout stays clean
while keyboard / screen-reader users keep a labelled control. */}
<div className="bg-muted/30 border-border mt-4 flex flex-col gap-2 rounded-md border p-3 sm:flex-row sm:items-center sm:justify-between">
<div className="bg-muted/30 border-border flex flex-col gap-2 rounded-md border p-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="text-sm font-medium">
{t("admin.section.backups.uploadTitle")}
@@ -599,7 +580,7 @@ export function BackupsSection() {
) : null}
{isLoading ? (
<div className="mt-4 flex items-center gap-2">
<div className="flex items-center gap-2">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
<span className="text-muted-foreground text-sm">
{t("admin.section.backups.loading")}
@@ -608,7 +589,7 @@ export function BackupsSection() {
) : isError ? (
<div
role="alert"
className="text-destructive bg-destructive/10 border-destructive/30 mt-4 rounded-md border px-3 py-2 text-sm"
className="text-destructive bg-destructive/10 border-destructive/30 rounded-md border px-3 py-2 text-sm"
>
{t("admin.section.backups.loadError")}
</div>
@@ -617,7 +598,7 @@ export function BackupsSection() {
// primitive. The header already exposes "Backup now" but a
// brand-new admin lands inside the card and benefits from a
// duplicate CTA right next to the explanation.
<div className="mt-4">
<div>
<EmptyState
icon={<Database className="size-6" />}
title={t("admin.section.backups.emptyTitle")}
@@ -640,7 +621,7 @@ export function BackupsSection() {
/>
</div>
) : (
<div className="mt-4">
<div>
{/* Wide layout: the canonical table. Hidden below `md`, where the
five columns push Download / Restore off-screen behind a
horizontal scroll; the card list below takes over there. */}
@@ -784,6 +765,22 @@ export function BackupsSection() {
</p>
</div>
)}
<SettingsCardActions>
<Button
size="sm"
disabled={runBackup.isPending}
onClick={() => runBackup.mutate()}
className="min-h-11"
>
{runBackup.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<PlayCircle className="h-3.5 w-3.5" />
)}
{t("admin.section.backups.runNow")}
</Button>
</SettingsCardActions>
</SettingsCard>
);
}
+38 -33
View File
@@ -21,6 +21,8 @@ import { Loader2, ShieldAlert, Sparkles, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { SettingsInfoTile } from "@/components/settings/_info-tile";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Button } from "@/components/ui/button";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { SettingsCard } from "@/components/settings/settings-card";
@@ -166,11 +168,10 @@ export function CentralCodexSection() {
}
/>
<div className="mt-4 space-y-4 pl-7">
<div className="text-muted-foreground flex items-start gap-2 text-xs">
<ShieldAlert className="mt-0.5 size-3.5 shrink-0" aria-hidden />
<p className="min-w-0">{t("admin.centralCodex.honesty")}</p>
</div>
<div className="space-y-4">
<SettingsInfoTile icon={ShieldAlert} tone="warning">
<p className="text-foreground">{t("admin.centralCodex.honesty")}</p>
</SettingsInfoTile>
{data?.connectedAt && isConnected && (
<p className="text-muted-foreground text-xs">
@@ -181,25 +182,27 @@ export function CentralCodexSection() {
)}
{isConnected ? (
<ConfirmButton
slot="admin-central-codex-disconnect"
variant="outline"
size="sm"
className="text-destructive min-h-11 shrink-0 sm:min-h-9"
icon={<Trash2 className="h-4 w-4" />}
label={t("admin.centralCodex.disconnectButton")}
title={t("admin.centralCodex.disconnectTitle")}
body={t("admin.centralCodex.disconnectBody")}
confirmLabel={t("admin.centralCodex.disconnectConfirm")}
pending={disconnecting}
onConfirm={handleDisconnect}
/>
<SettingsCardActions>
<ConfirmButton
slot="admin-central-codex-disconnect"
variant="destructive"
size="sm"
className="min-h-11 shrink-0 sm:min-h-9"
icon={<Trash2 className="h-4 w-4" />}
label={t("admin.centralCodex.disconnectButton")}
title={t("admin.centralCodex.disconnectTitle")}
body={t("admin.centralCodex.disconnectBody")}
confirmLabel={t("admin.centralCodex.disconnectConfirm")}
pending={disconnecting}
onConfirm={handleDisconnect}
/>
</SettingsCardActions>
) : deviceCode ? (
<div className="border-primary bg-primary/5 space-y-3 rounded-lg border-l-4 p-4">
<p className="text-sm font-medium">
{t("settings.ai.deviceCodeHeading")}
</p>
<ol className="text-muted-foreground list-decimal space-y-2 pl-5 text-sm">
<ol className="text-muted-foreground list-decimal space-y-2 pl-6 text-sm">
<li>
{t("settings.ai.deviceCodeStep1")}{" "}
<a
@@ -248,20 +251,22 @@ export function CentralCodexSection() {
</div>
</div>
) : (
<Button
variant="outline"
onClick={handleConnect}
disabled={devicePolling}
className="min-h-11 w-full sm:min-h-9 sm:w-auto"
data-slot="admin-central-codex-connect"
>
{devicePolling ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Sparkles className="h-4 w-4" />
)}
{t("admin.centralCodex.connectButton")}
</Button>
<SettingsCardActions>
<Button
size="sm"
onClick={handleConnect}
disabled={devicePolling}
className="min-h-11 w-full sm:min-h-9 sm:w-auto"
data-slot="admin-central-codex-connect"
>
{devicePolling ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Sparkles className="h-4 w-4" />
)}
{t("admin.centralCodex.connectButton")}
</Button>
</SettingsCardActions>
)}
</div>
</SettingsCard>
@@ -71,7 +71,7 @@ export function CoachFeedbackSection() {
// /admin/coach-feedback. Mirrors the canonical structure used by
// <SystemStatusSection> (header outside the fetch-state branch).
return (
<SettingsCard className="space-y-4">
<SettingsCard>
<SettingsCardHeader
icon={Sparkles}
title={t("admin.coachFeedback.title")}
+74 -73
View File
@@ -15,6 +15,7 @@ import {
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { SettingsCard } from "@/components/settings/settings-card";
@@ -86,88 +87,88 @@ export function DangerZoneSection() {
icon={AlertTriangle}
title={t("admin.deleteAllData")}
description={t("admin.deleteAllDescription")}
status={
<AlertDialog
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) setTyped("");
}}
>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
size="sm"
disabled={wipeAllData.isPending}
className="min-h-11"
>
{wipeAllData.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
{t("admin.deleteButton")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("admin.deleteAllConfirm")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("admin.deleteAllConfirmDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-2">
<Label htmlFor="wipe-confirm-prompt">
{t("admin.deleteAllPromptLabel", {
token: WIPE_CONFIRM_TOKEN,
})}
</Label>
<Input
id="wipe-confirm-prompt"
value={typed}
onChange={(e) => setTyped(e.target.value)}
autoComplete="off"
spellCheck={false}
placeholder={WIPE_CONFIRM_TOKEN}
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={!matched || wipeAllData.isPending}
aria-busy={wipeAllData.isPending || undefined}
onClick={(e) => {
if (!matched) {
e.preventDefault();
return;
}
setOpen(false);
setTyped("");
wipeAllData.mutate();
}}
>
{wipeAllData.isPending && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("admin.finalDelete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
}
/>
<p className="text-sm">{t("admin.deleteAllDetail")}</p>
{wipeMsg && (
<p
className={`mt-3 pl-7 text-sm ${
className={`mt-3 text-sm ${
wipeAllData.isError ? "text-destructive" : "text-success"
}`}
>
{wipeMsg}
</p>
)}
<SettingsCardActions>
<AlertDialog
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) setTyped("");
}}
>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
size="sm"
disabled={wipeAllData.isPending}
className="min-h-11"
>
{wipeAllData.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
{t("admin.deleteButton")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("admin.deleteAllConfirm")}</AlertDialogTitle>
<AlertDialogDescription>
{t("admin.deleteAllConfirmDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-2">
<Label htmlFor="wipe-confirm-prompt">
{t("admin.deleteAllPromptLabel", {
token: WIPE_CONFIRM_TOKEN,
})}
</Label>
<Input
id="wipe-confirm-prompt"
value={typed}
onChange={(e) => setTyped(e.target.value)}
autoComplete="off"
spellCheck={false}
placeholder={WIPE_CONFIRM_TOKEN}
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={!matched || wipeAllData.isPending}
aria-busy={wipeAllData.isPending || undefined}
onClick={(e) => {
if (!matched) {
e.preventDefault();
return;
}
setOpen(false);
setTyped("");
wipeAllData.mutate();
}}
>
{wipeAllData.isPending && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("admin.finalDelete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</SettingsCardActions>
</SettingsCard>
);
}
+72 -58
View File
@@ -27,6 +27,8 @@ import {
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { StatTile } from "@/components/admin/_stat-tile";
import { Button } from "@/components/ui/button";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
@@ -99,20 +101,35 @@ export function EncryptionSection() {
},
});
if (statusQuery.isLoading) {
// The card + header stay rendered across every query state so the heading
// keeps a constant Y-offset — the loading branch used to return a bare
// spinner row and the error branch a bare paragraph, so the section jumped
// downward the moment the status resolved. Same shape as
// `coach-feedback-section.tsx`, which fixed this for itself and never had
// the fix carried across.
if (statusQuery.isLoading || statusQuery.isError || !statusQuery.data) {
return (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
{t("admin.section.encryption.loading")}
</div>
);
}
<SettingsCard>
<SettingsCardHeader
icon={ShieldCheck}
title={t("admin.section.encryption.coverageTitle")}
description={t("admin.section.encryption.coverageDescription")}
/>
<p className="text-sm">
{t("admin.section.encryption.coverageDetail")}
</p>
if (statusQuery.isError || !statusQuery.data) {
return (
<p role="alert" className="text-destructive text-sm">
{t("admin.section.encryption.loadError")}
</p>
{statusQuery.isLoading ? (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
{t("admin.section.encryption.loading")}
</div>
) : (
<p role="alert" className="text-destructive text-sm">
{t("admin.section.encryption.loadError")}
</p>
)}
</SettingsCard>
);
}
@@ -125,7 +142,7 @@ export function EncryptionSection() {
return (
<div className="space-y-6">
{/* ── Coverage summary ───────────────────────────────────────── */}
<SettingsCard className="space-y-4">
<SettingsCard>
<SettingsCardHeader
icon={ShieldCheck}
title={t("admin.section.encryption.coverageTitle")}
@@ -163,7 +180,7 @@ export function EncryptionSection() {
</SettingsCard>
{/* ── Rotation status + trigger ──────────────────────────────── */}
<SettingsCard className="space-y-4">
<SettingsCard>
<SettingsCardHeader
icon={KeyRound}
title={t("admin.section.encryption.rotationTitle")}
@@ -187,50 +204,52 @@ export function EncryptionSection() {
)}
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={running}
data-testid="admin-encryption-rotate"
>
{running ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RotateCw className="h-3.5 w-3.5" />
)}
{t("admin.section.encryption.rotateNow")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("admin.section.encryption.rotateConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("admin.section.encryption.rotateConfirmDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("admin.section.encryption.cancel")}
</AlertDialogCancel>
<AlertDialogAction onClick={() => rotate.mutate()}>
{t("admin.section.encryption.rotateConfirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<p className="text-muted-foreground text-xs">
{t("admin.section.encryption.cliNote")}
</p>
<SettingsCardActions>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={running}
data-testid="admin-encryption-rotate"
>
{running ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RotateCw className="h-3.5 w-3.5" />
)}
{t("admin.section.encryption.rotateNow")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("admin.section.encryption.rotateConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("admin.section.encryption.rotateConfirmDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("admin.section.encryption.cancel")}
</AlertDialogCancel>
<AlertDialogAction onClick={() => rotate.mutate()}>
{t("admin.section.encryption.rotateConfirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</SettingsCardActions>
</SettingsCard>
{/* ── Per-column coverage table ──────────────────────────────── */}
<SettingsCard className="space-y-3">
<SettingsCard>
<SettingsCardHeader
icon={ShieldCheck}
title={t("admin.section.encryption.columnsTitle")}
@@ -286,10 +305,5 @@ export function EncryptionSection() {
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div className="bg-muted/40 rounded-lg px-3 py-2">
<p className="text-muted-foreground text-xs">{label}</p>
<p className="text-foreground font-mono text-sm font-medium">{value}</p>
</div>
);
return <StatTile label={label} value={value} valueClassName="font-mono" />;
}
@@ -85,7 +85,7 @@ export function GeneralSettingsSection() {
title={t("admin.appSettings")}
description={t("admin.appSettingsDescription")}
/>
<div className="mt-4 space-y-4 pl-7">
<div className="space-y-4">
<SettingsToggle
label={t("admin.registrationEnabled")}
description={t("admin.registrationEnabledDescription")}
@@ -103,7 +103,7 @@ export function GeneralSettingsSection() {
{(settings?.registrationEnabled ?? true) && (
<p
data-slot="registration-open-hint"
className="text-muted-foreground -mt-2 text-xs"
className="text-muted-foreground text-xs"
>
{t("admin.registrationOpenHint")}
</p>
+4 -3
View File
@@ -5,6 +5,7 @@ import { useMutation } from "@tanstack/react-query";
import { AlertTriangle, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -78,7 +79,7 @@ export function GlitchtipSection() {
status={configured ? <ConfiguredBadge /> : null}
/>
<div className="mt-4 space-y-3 pl-7">
<div className="space-y-3">
<SettingsToggle
label={t("admin.glitchtipEnabled")}
icon={AlertTriangle}
@@ -130,7 +131,7 @@ export function GlitchtipSection() {
</div>
</div>
<div className="mt-4 flex justify-end gap-2">
<SettingsCardActions>
<Button
size="sm"
variant="outline"
@@ -152,7 +153,7 @@ export function GlitchtipSection() {
)}
{t("common.save")}
</Button>
</div>
</SettingsCardActions>
</SettingsCard>
);
}
+44 -28
View File
@@ -39,7 +39,10 @@ import { useMemo } from "react";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { Skeleton } from "@/components/ui/skeleton";
import { Activity } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { apiGet } from "@/lib/api/api-fetch";
import { readStoredTimezone } from "@/lib/timezone-mirror";
@@ -164,14 +167,16 @@ export function HostMetricsChart() {
if (isLoading) {
return (
<SettingsCard as="section" aria-labelledby="admin-host-metrics-heading">
<div className="mb-3 flex items-center justify-between">
<h2 id="admin-host-metrics-heading" className="text-sm font-semibold">
{t("admin.hostMetrics.title")}
</h2>
<span className="text-muted-foreground text-xs">
{t("admin.hostMetrics.last2hours")}
</span>
</div>
<SettingsCardHeader
icon={Activity}
titleId="admin-host-metrics-heading"
title={t("admin.hostMetrics.title")}
status={
<span className="text-muted-foreground text-xs">
{t("admin.hostMetrics.last2hours")}
</span>
}
/>
<Skeleton className="bg-muted/40 h-[180px] w-full" />
</SettingsCard>
);
@@ -180,12 +185,19 @@ export function HostMetricsChart() {
if (isError) {
return (
<SettingsCard as="section" aria-labelledby="admin-host-metrics-heading">
<h2 id="admin-host-metrics-heading" className="text-sm font-semibold">
{t("admin.hostMetrics.title")}
</h2>
<SettingsCardHeader
icon={Activity}
titleId="admin-host-metrics-heading"
title={t("admin.hostMetrics.title")}
status={
<span className="text-muted-foreground text-xs">
{t("admin.hostMetrics.last2hours")}
</span>
}
/>
<p
role="alert"
className="text-destructive bg-destructive/10 border-destructive/30 mt-3 rounded-md border px-3 py-2 text-xs"
className="text-destructive bg-destructive/10 border-destructive/30 rounded-md border px-3 py-2 text-xs"
>
{t("admin.hostMetrics.loadError")}
</p>
@@ -199,14 +211,16 @@ export function HostMetricsChart() {
if (rows.length === 0) {
return (
<SettingsCard as="section" aria-labelledby="admin-host-metrics-heading">
<div className="mb-3 flex items-center justify-between">
<h2 id="admin-host-metrics-heading" className="text-sm font-semibold">
{t("admin.hostMetrics.title")}
</h2>
<span className="text-muted-foreground text-xs">
{t("admin.hostMetrics.last2hours")}
</span>
</div>
<SettingsCardHeader
icon={Activity}
titleId="admin-host-metrics-heading"
title={t("admin.hostMetrics.title")}
status={
<span className="text-muted-foreground text-xs">
{t("admin.hostMetrics.last2hours")}
</span>
}
/>
<p className="text-muted-foreground text-xs">
{t("admin.hostMetrics.empty")}
</p>
@@ -216,14 +230,16 @@ export function HostMetricsChart() {
return (
<SettingsCard as="section" aria-labelledby="admin-host-metrics-heading">
<div className="mb-3 flex items-center justify-between">
<h2 id="admin-host-metrics-heading" className="text-sm font-semibold">
{t("admin.hostMetrics.title")}
</h2>
<span className="text-muted-foreground text-xs">
{t("admin.hostMetrics.last2hours")}
</span>
</div>
<SettingsCardHeader
icon={Activity}
titleId="admin-host-metrics-heading"
title={t("admin.hostMetrics.title")}
status={
<span className="text-muted-foreground text-xs">
{t("admin.hostMetrics.last2hours")}
</span>
}
/>
<div className="h-[200px] w-full touch-pan-y">
<ResponsiveContainer width="100%" height="100%">
@@ -6,6 +6,10 @@
* Wraps the standalone integration sub-sections (Umami, GlitchTip, Web Push)
* under one route so each existing component stays the source of truth for its
* own settings while admins still get a single landing page.
*
* No wrapper element: `SectionFrame` already stacks its children at
* `space-y-6`, so a second identical stack here was one nesting level with
* no effect.
*/
import { GlitchtipSection } from "./glitchtip-section";
@@ -14,10 +18,10 @@ import { WebPushVapidSection } from "./web-push-vapid-section";
export function IntegrationsGroupSection() {
return (
<div className="space-y-6">
<>
<UmamiSection />
<GlitchtipSection />
<WebPushVapidSection />
</div>
</>
);
}
+30 -57
View File
@@ -28,16 +28,7 @@
*/
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Ban,
Check,
Copy,
Loader2,
Plus,
ShieldCheck,
Ticket,
Users,
} from "lucide-react";
import { Ban, Check, Copy, Loader2, Plus, Ticket, Users } from "lucide-react";
import { toast } from "sonner";
import {
@@ -51,6 +42,9 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { SettingsCard } from "@/components/settings/settings-card";
import {
Dialog,
DialogContent,
@@ -305,64 +299,30 @@ export function InviteTokensSection() {
}
return (
<section
<SettingsCard
as="section"
aria-labelledby="admin-invites-title"
className="bg-card border-border rounded-xl border"
data-testid="admin-invites-card"
>
{/* Header — copy left, the one action right. */}
<div className="flex flex-col gap-4 p-4 sm:flex-row sm:items-start sm:justify-between sm:p-6">
<div className="min-w-0">
<div className="flex items-center gap-2">
<Ticket
className="text-muted-foreground h-5 w-5"
aria-hidden="true"
/>
<h2 id="admin-invites-title" className="text-lg font-semibold">
{t("admin.invites.title")}
</h2>
</div>
<p className="text-muted-foreground mt-1 pl-7 text-xs">
{t("admin.invites.description")}
</p>
<p className="text-muted-foreground mt-1 flex items-center gap-1.5 pl-7 text-xs">
<ShieldCheck className="size-3.5 shrink-0" aria-hidden="true" />
{t("admin.invites.adminOnlyHint")}
</p>
</div>
<Button
type="button"
onClick={openCreateDialog}
data-testid="admin-invites-open-create"
className="shrink-0"
>
<Plus className="size-4" aria-hidden="true" />
{t("admin.invites.create")}
</Button>
</div>
<SettingsCardHeader
icon={Ticket}
titleId="admin-invites-title"
title={t("admin.invites.title")}
description={t("admin.invites.description")}
/>
{isError && (
<p className="text-destructive px-4 pb-4 text-sm sm:px-6">
<p className="text-destructive text-sm">
{t("admin.invites.loadError")}
</p>
)}
{invites && invites.length === 0 && (
<div className="px-4 pb-4 sm:px-6 sm:pb-6">
<div>
<EmptyState
icon={<Ticket className="size-6" aria-hidden="true" />}
title={t("admin.invites.emptyTitle")}
description={t("admin.invites.emptyDescription")}
action={
<Button
type="button"
variant="outline"
onClick={openCreateDialog}
>
<Plus className="size-4" aria-hidden="true" />
{t("admin.invites.create")}
</Button>
}
/>
</div>
)}
@@ -370,7 +330,7 @@ export function InviteTokensSection() {
{invites && invites.length > 0 && (
<>
{/* Desktop table (md+) */}
<div className="hidden px-2 pb-2 md:block">
<div className="hidden md:block">
<Table data-testid="admin-invites-table">
<TableHeader>
<TableRow className="hover:bg-transparent">
@@ -470,7 +430,7 @@ export function InviteTokensSection() {
</div>
{/* Mobile cards (<md) */}
<ul className="space-y-2 px-4 pb-4 md:hidden">
<ul className="space-y-2 md:hidden">
{invites.map((invite) => {
const status = deriveInviteStatus(invite, now);
const relative = expiryRelative(invite, status);
@@ -718,7 +678,20 @@ export function InviteTokensSection() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</section>
<SettingsCardActions>
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={openCreateDialog}
data-testid="admin-invites-open-create"
>
<Plus className="size-4" aria-hidden="true" />
{t("admin.invites.create")}
</Button>
</SettingsCardActions>
</SettingsCard>
);
}
@@ -227,7 +227,7 @@ export function LoginOverviewSection() {
description={t("admin.loginOverviewDescription")}
/>
<div className="mt-4 space-y-3">
<div className="space-y-3">
{/* Quick-filter pills. Failed-only is the most common
admin "is something wrong" question — keep the one-tap shortcut. */}
<div className="flex gap-1">
@@ -94,7 +94,9 @@ export function ModuleAvailabilitySection() {
title={t("admin.modules.title")}
description={t("admin.modules.description")}
/>
<div className="mt-4 space-y-4 pl-7">
<p className="text-sm">{t("admin.modules.detail")}</p>
<div className="space-y-4">
{MODULE_KEYS
// A module switched off in code (pending a rebuild) is hard-off
// server-wide; drop its row so the operator toggle can't mislead.
@@ -62,7 +62,7 @@ export function RecentAuditPreview() {
status={
<Link
href="/admin/login-overview"
className="text-primary hover:text-primary/80 inline-flex items-center gap-1 text-sm"
className="text-primary inline-flex items-center gap-1 text-sm underline-offset-2 hover:underline"
>
{t("admin.overview.auditViewAll")}
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
@@ -70,7 +70,7 @@ export function RecentAuditPreview() {
}
/>
<div className="mt-4">
<div>
{isLoading ? (
<div className="flex items-center gap-2 py-2">
<Loader2
+63 -62
View File
@@ -12,6 +12,7 @@ import {
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -54,14 +55,14 @@ function NotificationHealthPanel() {
});
return (
<SettingsCard className="mt-6">
<SettingsCard>
<SettingsCardHeader
icon={HeartPulse}
title={t("admin.notificationHealth.title")}
description={t("admin.notificationHealth.description")}
/>
<div className="mt-4 pl-7">
<div>
{isLoading && (
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
@@ -205,7 +206,7 @@ export function RemindersSection() {
description={t("admin.medicationRemindersDescription")}
/>
<div className="mt-4 space-y-3 pl-7">
<div className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="admin-reminder-late" className="text-xs">
@@ -260,66 +261,9 @@ export function RemindersSection() {
</div>
</div>
<div className="mt-4 flex justify-end gap-2">
<Button
size="sm"
variant="outline"
onClick={() => testNotification.mutate()}
disabled={testNotification.isPending}
>
{testNotification.isPending && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
<Bell className="h-3.5 w-3.5" />
{t("admin.notificationTestSend")}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => reminderCheck.mutate()}
disabled={reminderCheck.isPending}
>
{reminderCheck.isPending && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
<Activity className="h-3.5 w-3.5" />
{t("admin.reminderCheckRun")}
</Button>
<Button
size="sm"
onClick={() => {
updateSettings.mutate(
{
...(reminderLateDraft != null && {
reminderLateMinutes: reminderLateDraft,
}),
...(reminderMissedDraft != null && {
reminderMissedMinutes: reminderMissedDraft,
}),
},
{
onSuccess: () => {
setReminderLateDraft(null);
setReminderMissedDraft(null);
},
},
);
}}
disabled={
updateSettings.isPending ||
(reminderLateDraft == null && reminderMissedDraft == null)
}
>
{updateSettings.isPending && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>
</div>
{testNotification.data?.results &&
testNotification.data.results.length > 0 && (
<div className="mt-4 space-y-1">
<div className="space-y-1">
{testNotification.data.results.map((r, i) => (
<div key={i} className="flex items-center gap-1.5 text-xs">
{r.success ? (
@@ -338,7 +282,7 @@ export function RemindersSection() {
{reminderCheck.data?.medications &&
reminderCheck.data.medications.length > 0 && (
<div className="mt-4 space-y-2">
<div className="space-y-2">
{/* v1.4.33 IW9 — h4 -> h2 so the page outline stays
sequential (parent admin SectionFrame ships <h1>). */}
<h2 className="text-sm font-medium">
@@ -405,6 +349,63 @@ export function RemindersSection() {
</div>
</div>
)}
<SettingsCardActions>
<Button
size="sm"
variant="outline"
onClick={() => testNotification.mutate()}
disabled={testNotification.isPending}
>
{testNotification.isPending && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
<Bell className="h-3.5 w-3.5" />
{t("admin.notificationTestSend")}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => reminderCheck.mutate()}
disabled={reminderCheck.isPending}
>
{reminderCheck.isPending && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
<Activity className="h-3.5 w-3.5" />
{t("admin.reminderCheckRun")}
</Button>
<Button
size="sm"
onClick={() => {
updateSettings.mutate(
{
...(reminderLateDraft != null && {
reminderLateMinutes: reminderLateDraft,
}),
...(reminderMissedDraft != null && {
reminderMissedMinutes: reminderMissedDraft,
}),
},
{
onSuccess: () => {
setReminderLateDraft(null);
setReminderMissedDraft(null);
},
},
);
}}
disabled={
updateSettings.isPending ||
(reminderLateDraft == null && reminderMissedDraft == null)
}
>
{updateSettings.isPending && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>
</SettingsCardActions>
</SettingsCard>
<NotificationHealthPanel />
</>
+1 -1
View File
@@ -18,7 +18,7 @@ export function ServicesSection() {
title={t("admin.servicesGlobal")}
description={t("admin.servicesGlobalDescription")}
/>
<div className="mt-4 space-y-3 pl-7">
<div className="space-y-3">
<SettingsToggle
label="Telegram"
description={t("admin.telegramGlobal")}
@@ -74,7 +74,7 @@ export function FailingJobsCard({
title={t("admin.failingJobs.title")}
description={t("admin.failingJobs.description")}
/>
<p className="text-muted-foreground mt-4 pl-7 text-sm">
<p className="text-muted-foreground text-sm">
{t("admin.failingJobs.unavailable")}
</p>
</SettingsCard>
@@ -91,12 +91,12 @@ export function FailingJobsCard({
{failingJobs.queues.length === 0 ? (
<p
data-testid="failing-jobs-none"
className="text-muted-foreground mt-4 pl-7 text-sm"
className="text-muted-foreground text-sm"
>
{t("admin.failingJobs.none", { hours: failingJobs.windowHours })}
</p>
) : (
<ul data-testid="failing-jobs-list" className="mt-4 space-y-3 pl-7">
<ul data-testid="failing-jobs-list" className="space-y-3">
{failingJobs.queues.map((queue) => (
<li key={queue.queue} className="text-sm">
<p className="text-foreground font-medium">
@@ -146,7 +146,7 @@ export function SystemStatusSection() {
// branches below it are a load error and a spinner, so the
// marker cannot stand for a section still waiting on the read.
data-slot="system-status-grid"
className="mt-4 grid gap-4 pl-7 sm:grid-cols-2 lg:grid-cols-4"
className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"
>
<StatusItem
icon={Database}
@@ -275,7 +275,7 @@ export function SystemStatusSection() {
// full page reload to recover.
<div
role="alert"
className="text-destructive bg-destructive/10 border-destructive/30 mt-4 flex flex-col items-start gap-2 rounded-md border px-3 py-2 text-sm sm:flex-row sm:items-center sm:justify-between"
className="text-destructive bg-destructive/10 border-destructive/30 flex flex-col items-start gap-2 rounded-md border px-3 py-2 text-sm sm:flex-row sm:items-center sm:justify-between"
>
<span>{t("admin.systemStatusLoadError")}</span>
<Button
@@ -296,7 +296,7 @@ export function SystemStatusSection() {
</Button>
</div>
) : (
<div className="mt-4 flex items-center gap-2">
<div className="flex items-center gap-2">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
<span className="text-muted-foreground text-sm">
{t("admin.loadingStatus")}
+4 -32
View File
@@ -12,16 +12,7 @@
* don't re-fetch.
*/
import {
Clock,
Cog,
Database,
GitCommit,
Globe,
Loader2,
Server,
Tag,
} from "lucide-react";
import { Clock, Cog, Database, Globe, Loader2, Server } from "lucide-react";
import { formatDateTime } from "@/lib/format";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
@@ -45,12 +36,7 @@ export function SystemStatusSummary() {
/>
{status ? (
<div className="mt-4 grid gap-3 pl-7 sm:grid-cols-2 lg:grid-cols-3">
<StatusItem
icon={Tag}
label={t("admin.overview.snapshotVersion")}
value={version?.version ?? status.version}
/>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<StatusItem
icon={Database}
label={t("admin.overview.snapshotDatabase")}
@@ -82,20 +68,6 @@ export function SystemStatusSummary() {
label={t("admin.overview.snapshotStarted")}
value={formatDateTime(status.startTime)}
/>
{(version?.buildSha ?? status.gitCommit) && (
<StatusItem
icon={GitCommit}
label={t("admin.overview.snapshotBuildSha")}
value={version?.buildSha ?? status.gitCommit}
/>
)}
{version?.builtAt && (
<StatusItem
icon={Clock}
label={t("admin.overview.snapshotBuiltAt")}
value={formatDateTime(version.builtAt)}
/>
)}
{/* v1.4.27 R5 — surface the offline-geo state so the maintainer
spots the missing MAXMIND_LICENSE_KEY without crawling logs.
The field is undefined on legacy responses; the row only
@@ -120,12 +92,12 @@ export function SystemStatusSummary() {
) : isError ? (
<div
role="alert"
className="text-destructive bg-destructive/10 border-destructive/30 mt-4 rounded-md border px-3 py-2 text-sm"
className="text-destructive bg-destructive/10 border-destructive/30 rounded-md border px-3 py-2 text-sm"
>
{t("admin.overview.snapshotLoadError")}
</div>
) : (
<div className="mt-4 flex items-center gap-2">
<div className="flex items-center gap-2">
<Loader2
className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none"
aria-hidden="true"
+4 -3
View File
@@ -5,6 +5,7 @@ import { useMutation } from "@tanstack/react-query";
import { Activity, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -81,7 +82,7 @@ export function UmamiSection() {
status={configured ? <ConfiguredBadge /> : null}
/>
<div className="mt-4 space-y-3 pl-7">
<div className="space-y-3">
<SettingsToggle
label={t("admin.umamiEnabled")}
icon={Activity}
@@ -131,7 +132,7 @@ export function UmamiSection() {
</div>
</div>
<div className="mt-4 flex justify-end gap-2">
<SettingsCardActions>
<Button
size="sm"
variant="outline"
@@ -153,7 +154,7 @@ export function UmamiSection() {
)}
{t("common.save")}
</Button>
</div>
</SettingsCardActions>
</SettingsCard>
);
}
@@ -13,6 +13,7 @@ import {
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { Input } from "@/components/ui/input";
@@ -282,31 +283,36 @@ export function UserManagementSection() {
</Badge>
) : null
}
status={
<div className="flex flex-wrap items-center gap-1.5">
{(["all", "admin", "user"] as const).map((value) => (
<Button
key={value}
variant={filter === value ? "default" : "ghost"}
size="sm"
className="min-h-11 min-w-11 px-3 text-xs"
onClick={() => setFilter(value)}
aria-pressed={filter === value}
>
{t(`admin.section.users.filter.${value}`)}
</Button>
))}
</div>
}
/>
{/* Filter toolbar. It used to sit in the header's status slot, which is
where the neighbouring sections put a primary, a destructive, and a
badge — three different meanings for one position. */}
<div
className="flex flex-wrap items-center gap-1.5"
data-slot="admin-users-filter"
>
{(["all", "admin", "user"] as const).map((value) => (
<Button
key={value}
variant={filter === value ? "default" : "outline"}
size="sm"
className="min-h-11 min-w-11 px-3 text-xs sm:min-h-9"
onClick={() => setFilter(value)}
aria-pressed={filter === value}
>
{t(`admin.section.users.filter.${value}`)}
</Button>
))}
</div>
{filteredUsers ? (
filteredUsers.length === 0 ? (
// v1.4.15 phase-C5: dedicated empty state. The previous build
// rendered an empty `<tbody>` so an admin filter that returned
// no rows produced a blank rectangle; the icon + filter-aware
// copy + "Show all users" CTA make the state explicit.
<div className="mt-4">
<div>
<EmptyState
icon={<Users className="size-6" />}
title={t("admin.section.users.emptyTitle")}
@@ -330,7 +336,7 @@ export function UserManagementSection() {
`md:block hidden` swaps it out for the card-list below at
`< md`. Hiding the table-only wrapper (instead of just
the cells) saves DOM weight on mobile too. */}
<div className="mt-4 hidden overflow-x-auto md:block">
<div className="hidden overflow-x-auto md:block">
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground border-b text-xs">
@@ -399,7 +405,7 @@ export function UserManagementSection() {
All four actions stay visible and tap-targetable; nothing
is hidden behind a horizontal scroll. */}
<ul
className="mt-4 space-y-2 md:hidden"
className="space-y-2 md:hidden"
data-slot="admin-user-rows"
data-testid="admin-users-mobile-list"
>
@@ -444,7 +450,7 @@ export function UserManagementSection() {
</>
)
) : (
<div className="mt-4 flex items-center gap-2">
<div className="flex items-center gap-2">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
<span className="text-muted-foreground text-sm">
{t("admin.loadingUsers")}
@@ -454,8 +460,8 @@ export function UserManagementSection() {
{/* Edit Dialog */}
{editingUser && (
<div className="bg-muted/80 mt-4 rounded-lg p-4">
<h3 className="mb-3 text-sm font-semibold">
<div className="bg-muted/50 space-y-3 rounded-lg p-3">
<h3 className="text-sm font-semibold">
{t("admin.editUserTitle", { name: editingUser.username })}
</h3>
{/* v1.16.4 — a real form so Enter in the username / email
@@ -535,10 +541,25 @@ export function UserManagementSection() {
{t("admin.userDocumentQuotaHint")}
</p>
</div>
<div className="flex gap-2">
{updateUser.isError && (
<p className="text-destructive text-sm">
{(updateUser.error as Error).message}
</p>
)}
<SettingsCardActions>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => setEditingUser(null)}
>
{t("common.cancel")}
</Button>
<Button
type="submit"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={updateUser.isPending}
aria-busy={updateUser.isPending || undefined}
>
@@ -547,28 +568,15 @@ export function UserManagementSection() {
)}
{t("common.save")}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setEditingUser(null)}
>
{t("common.cancel")}
</Button>
{updateUser.isError && (
<span className="text-destructive self-center text-sm">
{(updateUser.error as Error).message}
</span>
)}
</div>
</SettingsCardActions>
</form>
</div>
)}
{/* Password Reset Dialog */}
{resetUser && (
<div className="bg-muted/80 mt-4 rounded-lg p-4">
<h3 className="mb-3 text-sm font-semibold">
<div className="bg-muted/50 space-y-3 rounded-lg p-3">
<h3 className="text-sm font-semibold">
{t("admin.resetPasswordTitle", { name: resetUser.username })}
</h3>
<div className="space-y-3">
@@ -582,9 +590,25 @@ export function UserManagementSection() {
/>
<PasswordStrength password={resetPassword} />
</div>
<div className="flex gap-2">
{resetMsg && (
<p
className={`text-sm ${resetMsg === t("admin.passwordReset") ? "text-success" : "text-destructive"}`}
>
{resetMsg}
</p>
)}
<SettingsCardActions>
<Button
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => setResetUser(null)}
>
{t("common.cancel")}
</Button>
<Button
size="sm"
className="min-h-11 sm:min-h-9"
disabled={resetPw.isPending || !resetPassword}
onClick={() =>
resetPw.mutate({
@@ -598,21 +622,7 @@ export function UserManagementSection() {
)}
{t("admin.reset")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setResetUser(null)}
>
{t("common.cancel")}
</Button>
</div>
{resetMsg && (
<p
className={`text-sm ${resetMsg === t("admin.passwordReset") ? "text-success" : "text-destructive"}`}
>
{resetMsg}
</p>
)}
</SettingsCardActions>
</div>
</div>
)}
@@ -97,7 +97,7 @@ export function VersionTileSection() {
}
target="_blank"
rel="noopener noreferrer"
className="bg-warning/15 text-warning border-warning/30 inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-opacity hover:opacity-80"
className="bg-warning/15 text-warning border-warning/30 inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium underline-offset-2 hover:underline"
>
<ArrowUpCircle className="h-3.5 w-3.5" aria-hidden="true" />
{t("admin.overview.versionTileUpdateAvailable", {
@@ -114,7 +114,7 @@ export function VersionTileSection() {
}
/>
<div className="mt-4 grid gap-3 pl-7 sm:grid-cols-2 lg:grid-cols-3">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<StatusItem
icon={Tag}
label={t("admin.overview.versionTileVersion")}
@@ -5,6 +5,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { BellRing, KeyRound, Loader2 } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { Button } from "@/components/ui/button";
import {
@@ -128,7 +129,7 @@ export function WebPushVapidSection() {
status={configured ? <ConfiguredBadge /> : null}
/>
<div className="mt-4 grid gap-3 pl-7 sm:grid-cols-2">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="admin-web-push-public-key" className="text-xs">
{t("admin.webPushVapidPublicKey")}
@@ -191,7 +192,7 @@ export function WebPushVapidSection() {
</div>
</div>
<div className="mt-4 flex justify-end gap-2">
<SettingsCardActions>
<Button
variant="outline"
size="sm"
@@ -217,7 +218,7 @@ export function WebPushVapidSection() {
)}
{t("common.save")}
</Button>
</div>
</SettingsCardActions>
<AlertDialog
open={overwriteConfirmOpen}
+3 -7
View File
@@ -19,6 +19,7 @@ import { makeFormatters } from "@/lib/format-locale";
import type { Locale } from "@/lib/i18n/config";
import type { ShareViewDocument } from "@/lib/clinician-share/share-view-data";
import type { ReportSelection } from "@/lib/report-selection/selection";
import { PageHeader } from "@/components/ui/page-header";
import { DocumentEntry } from "./documents-list";
import { ShareDownloadActions } from "./download-actions";
import {
@@ -98,13 +99,8 @@ export function ClinicianView({
className="mx-auto min-h-dvh w-full max-w-3xl px-4 py-8"
>
{/* ── Provenance header ───────────────────────────────────────── */}
<header className="mb-6">
<h1 className="text-2xl font-bold tracking-tight">
{t("clinicianView.title")}
</h1>
{label ? (
<p className="text-muted-foreground mt-1 text-sm">{label}</p>
) : null}
<header className="mb-6 space-y-1.5">
<PageHeader title={t("clinicianView.title")} description={label} />
{report && !documentOnly ? (
<p className="text-muted-foreground mt-3 text-sm">
{t("clinicianView.period", {
@@ -18,6 +18,7 @@ import { Card, CardContent } from "@/components/ui/card";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { EmptyState } from "@/components/ui/empty-state";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { PageHeader } from "@/components/ui/page-header";
import { Skeleton } from "@/components/ui/skeleton";
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
@@ -171,14 +172,10 @@ export function CustomMetricDetail({
if (metricError || listError) {
return (
<div className="space-y-6">
<header className="space-y-1.5">
<h1 className="text-2xl font-bold tracking-tight">
{metric?.name ?? t("customMetrics.detail.title")}
</h1>
<p className="text-foreground text-sm leading-relaxed">
{description}
</p>
</header>
<PageHeader
title={metric?.name ?? t("customMetrics.detail.title")}
description={description}
/>
<QueryErrorCard
description={t("customMetrics.loadError")}
onRetry={() => {
@@ -192,70 +189,69 @@ export function CustomMetricDetail({
return (
<div className="space-y-6">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl font-bold tracking-tight">
{metric?.name ?? <Skeleton className="h-7 w-32" />}
</h1>
{metric?.unit ? (
<p className="text-muted-foreground text-xs sm:text-sm">
{metric.unit}
</p>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2">
{/* Controls, left → right: Delete · Edit · Show-all-values · Add. */}
<DeleteButton
onConfirm={() => deleteMetric.mutate()}
title={t("customMetrics.deleteConfirmTitle")}
description={t("customMetrics.deleteConfirmDescription")}
confirmLabel={t("customMetrics.delete")}
triggerTitle={t("customMetrics.delete")}
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
iconClassName="h-4 w-4"
/>
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
onClick={() => setEditOpen(true)}
disabled={!metric}
aria-label={t("customMetrics.edit")}
title={t("customMetrics.edit")}
>
<Pencil className="h-4 w-4" />
</Button>
{entries.length > 0 ? (
<PageHeader
title={metric?.name ?? <Skeleton className="h-7 w-32" />}
description={description}
actions={
<>
{/* Controls, left → right: Delete · Edit · Show-all-values · Add. */}
<DeleteButton
onConfirm={() => deleteMetric.mutate()}
title={t("customMetrics.deleteConfirmTitle")}
description={t("customMetrics.deleteConfirmDescription")}
confirmLabel={t("customMetrics.delete")}
triggerTitle={t("customMetrics.delete")}
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
iconClassName="h-4 w-4"
/>
<Button
asChild
variant="ghost"
size="icon"
data-slot="custom-metric-show-all-values"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
onClick={() => setEditOpen(true)}
disabled={!metric}
aria-label={t("customMetrics.edit")}
title={t("customMetrics.edit")}
>
<Link
href={`/custom-metrics/${customMetricId}/values`}
aria-label={t("customMetrics.showAllValues")}
title={t("customMetrics.showAllValues")}
>
<ListOrdered className="h-4 w-4" aria-hidden="true" />
</Link>
<Pencil className="h-4 w-4" />
</Button>
) : null}
<Button
onClick={() => setAddOpen(true)}
className="min-h-11 min-w-11 shrink-0 sm:min-h-9 sm:min-w-0"
aria-label={t("customMetrics.addValue")}
>
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">
{t("customMetrics.addValue")}
</span>
</Button>
</div>
</div>
{entries.length > 0 ? (
<Button
asChild
variant="ghost"
size="icon"
data-slot="custom-metric-show-all-values"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
>
<Link
href={`/custom-metrics/${customMetricId}/values`}
aria-label={t("customMetrics.showAllValues")}
title={t("customMetrics.showAllValues")}
>
<ListOrdered className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
) : null}
<Button
onClick={() => setAddOpen(true)}
className="min-h-11 min-w-11 shrink-0 sm:min-h-9 sm:min-w-0"
aria-label={t("customMetrics.addValue")}
>
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">
{t("customMetrics.addValue")}
</span>
</Button>
</>
}
/>
<p className="text-foreground text-sm leading-relaxed">{description}</p>
{/* The unit is meta about the metric, not its explainer, so it reads
below the header rather than competing for the one description line
every page header carries. */}
{metric?.unit ? (
<p className="text-muted-foreground text-sm">{metric.unit}</p>
) : null}
{isLoading ? (
<Card>
@@ -5,7 +5,7 @@ import Link from "next/link";
import { ArrowRight, Droplets } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { SectionHeading } from "@/components/insights/section-heading";
import { SectionHeading } from "@/components/ui/section-heading";
import { CyclePhaseHeadline } from "./cycle-phase-crosstab";
import { PHASE_HUE } from "./phase-tokens";
import { localYmd, useCycleCalendar, useCycleInsights } from "./use-cycle";
@@ -1,189 +0,0 @@
/**
* The caregiver's front door, rendered.
*
* SSR-only, like the rest of the component suite: what is proven is the
* paint — who the card lists and when it exists at all. The tap that switches
* records is the switcher's existing mutation and is driven end to end in
* `e2e/account-sharing.spec.ts`.
*
* Mutation checks, run:
* - drop the `accounts.length === 0` bail → "an account nobody shares with
* sees nothing" goes red.
* - drop the `active != null` bail → "no doorway into the room you are in"
* goes red.
* - render the level from `entry.access` instead of `entry.canWrite` → "the
* level is the server's resolved boolean" goes red.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import type { AccountAccess } from "@/lib/sharing/account-access-view";
const GRANDMOTHER = {
accountId: "acct-gran",
username: "gran",
displayName: "Margarethe",
access: "read" as const,
level: "read" as const,
sections: null,
recordKind: "shared" as const,
canWrite: false,
};
const FATHER = {
accountId: "acct-dad",
username: "dad",
displayName: null,
access: "write" as const,
level: "write" as const,
sections: null,
recordKind: "shared" as const,
canWrite: true,
};
const MANAGED_PROFILE = {
accountId: "managed-profile",
username: "managed-profile",
displayName: "Managed profile",
access: "write" as const,
level: "manage" as const,
sections: null,
recordKind: "managed" as const,
canWrite: true,
};
const mockAccessRef: { value: AccountAccess } = {
value: { accounts: [], active: null, canSwitch: false },
};
const mockAccount = () => ({
id: "delegate",
username: "delegate",
email: null,
role: "USER",
avatarUrl: null,
modules: {},
accountAccess: mockAccessRef.value,
});
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: mockAccount(),
isAuthenticated: true,
isLoading: false,
refetch: vi.fn(),
}),
// The card reads the mount-pinned accessor: it paints inside the dashboard's
// streamed boundary, where reading the grants on the hydration render would
// disagree with the server and cost the whole streamed tree. These cases are
// about the MOUNTED render, so the accessor answers the account.
useAccountOnceMounted: () => mockAccount(),
}));
vi.mock("@/hooks/use-account-switch", () => ({
useAccountSwitch: () => ({ mutate: vi.fn(), isPending: false }),
}));
import { LookingAfterCard } from "../looking-after-card";
function render(access: AccountAccess): string {
mockAccessRef.value = access;
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<LookingAfterCard />
</I18nProvider>,
);
}
describe("<LookingAfterCard>", () => {
it("an account nobody shares with sees nothing", () => {
// The v1.36.0 posture: the feature is invisible until it applies to you.
// Not an empty state, not a prompt to ask somebody for access.
expect(render({ accounts: [], active: null, canSwitch: false })).toBe("");
});
it("lists every record shared with this account", () => {
const html = render({
accounts: [GRANDMOTHER, FATHER],
active: null,
canSwitch: true,
});
expect(html).toContain('data-slot="looking-after-card"');
expect(html).toContain('data-account-id="acct-gran"');
expect(html).toContain('data-account-id="acct-dad"');
// The display name where there is one, the username where there is not.
expect(html).toContain("Margarethe");
expect(html).toContain("dad");
});
it("the level is the server's resolved boolean", () => {
const html = render({
accounts: [GRANDMOTHER, FATHER],
active: null,
canSwitch: true,
});
expect(html).toContain("Can view and add");
expect(html).toContain("Can view");
});
it("a grant labelled write but resolved read reads as read", () => {
// `access` describes the row; `canWrite` is the decision the server made
// about it this second. A grant that lapsed, or a level this build does
// not honour, must not promise an add the next request would refuse.
const html = render({
accounts: [
{ ...FATHER, access: "write", level: "write", canWrite: false },
],
active: null,
canSwitch: true,
});
expect(html).toContain("Can view");
expect(html).not.toContain("Can view and add");
});
it("keeps MANAGE and managed profile labels separate", () => {
const html = render({
accounts: [MANAGED_PROFILE],
active: null,
canSwitch: true,
});
expect(html).toContain('data-access-level="manage"');
expect(html).toContain('data-record-kind="managed"');
expect(html).toContain("Can read the record, add to it, and change");
expect(html).toContain("Managed profile");
});
it("names the record button with its action, access, and kind", () => {
const html = render({
accounts: [MANAGED_PROFILE],
active: null,
canSwitch: true,
});
expect(html).toMatch(
/aria-label="Open Managed profile&#x27;s record; Can read the record, add to it, and change or remove what is in it; Managed profile;/,
);
});
it("shows no health data, only a doorway", () => {
const html = render({
accounts: [GRANDMOTHER],
active: null,
canSwitch: true,
});
// A preview would put somebody else's readings on this dashboard, outside
// the banner frame that says which record you are looking at. The card
// carries names and levels and nothing else.
expect(html).not.toMatch(/\d+(\.\d+)?\s*(kg|mmHg|bpm)/);
});
it("no doorway into the room you are already in", () => {
expect(
render({
accounts: [GRANDMOTHER],
active: GRANDMOTHER,
canSwitch: true,
}),
).toBe("");
});
});
@@ -1,192 +0,0 @@
"use client";
import { HeartHandshake, Loader2 } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { useAccountSwitch } from "@/hooks/use-account-switch";
import { useAccountOnceMounted } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import {
resolveRecordPresentation,
type RecordPresentation,
} from "@/lib/navigation/record-presentation";
import { sharedRecordLandingHref } from "@/lib/navigation/shared-record";
import {
accountLabel,
type AccountAccessEntry,
} from "@/lib/sharing/account-access-view";
/**
* v1.36.x — the caregiver's front door.
*
* ## Why this is on the dashboard and not in settings
*
* Sharing shipped as a settings panel and a menu three levels deep: user menu
* → switcher → the record. That is the right place to MANAGE a grant, and the
* wrong place to use one. Somebody who opens this app every morning to check
* on a parent does not go to settings to do it, and UI-STANDARDS §10 draws
* exactly that line: settings are account utilities, not feature
* destinations. Looking after somebody is a feature destination.
*
* ## What it is not
*
* It is a doorway, not a preview. No numbers, no last reading, no status.
* Putting a slice of somebody else's health record on a delegate's own
* dashboard would place it outside the one frame the whole design leans on —
* the banner that says which record you are inside. Everything about the
* other person is read after the switch, under that banner, or not at all.
*
* ## Where the content comes from
*
* `accountAccess.accounts`, the same resolved list the switcher renders, and
* the switch is the same mutation the switcher fires. No second mechanism, no
* second source of truth about who may open what. An account nobody has
* shared with sees nothing at all — no empty state, no invitation to ask
* somebody for access (the v1.36.0 posture: the feature is invisible until it
* applies to you).
*
* Design: `Card` + `TileHeader` per UI-STANDARDS §1 and §5 (icon and title in
* the foreground colour, title `text-base`), the person's name as content in
* `text-foreground` and the grant level as meta in `text-muted-foreground`
* per §3, and the §11 row shape — label block `min-w-0 flex-1`, the whole row
* a single tap target at the `min-h-11` mobile floor.
*/
export function LookingAfterCard() {
const { t } = useTranslations();
// Withheld until mounted: this card sits inside the dashboard's streamed
// boundary and renders nothing without grants. The shell has usually answered
// `/api/auth/me` before the boundary hydrates, so reading the grants on the
// hydration render paints a card where the server painted none — a mismatch
// that discards the streamed dashboard. See `useAccountOnceMounted`.
const user = useAccountOnceMounted();
const switchAccount = useAccountSwitch();
const access = user?.accountAccess;
const accounts = access?.accounts ?? [];
// Inside somebody else's record the card would be a doorway to the room you
// are already in; the banner owns that context and carries the way out.
if (access?.active != null) return null;
if (accounts.length === 0) return null;
return (
<Card data-slot="looking-after-card">
<CardContent className="space-y-3">
<TileHeader
icon={HeartHandshake}
title={t("recordSharing.lookingAfter.title")}
titleAs="h2"
/>
<p className="text-muted-foreground text-sm">
{t("recordSharing.lookingAfter.description")}
</p>
<ul className="divide-y">
{accounts.map((entry) => {
const presentation = resolveRecordPresentation(entry);
const level = levelLabel(entry, presentation, t);
const kind = recordKindLabel(entry, t);
const scope = scopeLabel(entry, t);
return (
<li key={entry.accountId}>
<button
type="button"
data-slot="looking-after-row"
data-account-id={entry.accountId}
data-access-level={presentation.access}
data-record-kind={presentation.recordKind}
disabled={switchAccount.isPending}
onClick={() =>
switchAccount.switchTo(
entry.accountId,
sharedRecordLandingHref(entry.sections),
)
}
aria-label={[
t("recordSharing.lookingAfter.open", {
name: accountLabel(entry),
}),
level,
kind,
scope,
].join("; ")}
className="hover:bg-accent/40 focus-visible:ring-ring/50 flex min-h-11 w-full items-center gap-3 rounded-md px-2 py-2 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
>
<span className="min-w-0 flex-1">
<span className="text-foreground block truncate text-sm font-medium">
{accountLabel(entry)}
</span>
<span className="text-muted-foreground block truncate text-xs">
{level}
</span>
<span className="text-muted-foreground block truncate text-xs">
{kind}
</span>
<span className="text-muted-foreground block truncate text-xs">
{scope}
</span>
</span>
{switchAccount.isPending && (
<Loader2
className="text-muted-foreground size-4 shrink-0 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
)}
</button>
</li>
);
})}
</ul>
</CardContent>
</Card>
);
}
/**
* The grant level, in the words the person reading it would use.
*
* Bound from the server's resolved `canWrite`, never from the `access` label
* beside it: the label describes the row, the boolean is the decision, and a
* lapsed WRITE grant must read as view-only here exactly as it behaves.
*
* No "last opened" line: `lastUsedAt` is not on this payload, and a relative
* time invented from something else would be a placeholder wearing a fact's
* clothes.
*/
function levelLabel(
entry: AccountAccessEntry,
presentation: RecordPresentation,
t: (key: string) => string,
): string {
if (entry.level === "manage") {
return t("recordSharing.row.canManage");
}
return presentation.access === "view-and-add"
? t("recordSharing.lookingAfter.levelWrite")
: t("recordSharing.lookingAfter.levelRead");
}
function recordKindLabel(
entry: AccountAccessEntry,
t: (key: string) => string,
): string {
return entry.recordKind === "managed"
? t("recordSharing.lookingAfter.kindManaged")
: t("recordSharing.lookingAfter.kindShared");
}
function scopeLabel(
entry: AccountAccessEntry,
t: (key: string) => string,
): string {
if (entry.sections === null) {
return t("recordSharing.row.entireRecord");
}
if (entry.sections.length === 0) {
return t("recordSharing.row.noSections");
}
return entry.sections
.map((domain) => t(`recordSharing.section.${domain}.label`))
.join(", ");
}
@@ -16,6 +16,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { PageHeader } from "@/components/ui/page-header";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
@@ -67,13 +68,11 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
return (
<div className="space-y-6">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="truncate text-2xl font-bold tracking-tight">
{episode?.label ?? <Skeleton className="h-7 w-40" />}
</h1>
{episode ? (
<div className="mt-1.5 flex flex-wrap items-center gap-2">
<PageHeader
title={episode?.label ?? <Skeleton className="h-7 w-40" />}
description={
episode ? (
<span className="flex flex-wrap items-center gap-2">
<Badge variant="secondary">
{t(`illness.type.${episode.type}`)}
</Badge>
@@ -94,32 +93,34 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
})}`
: ""}
</span>
</div>
) : null}
</div>
{canManage && (
<div className="flex shrink-0 items-center gap-1">
<Button
onClick={() => setLogOpen(true)}
className="min-h-11 sm:min-h-9"
>
{t("illness.logDay")}
</Button>
{episode ? (
<EpisodeMenu
episode={episode}
onEdit={() => setEditOpen(true)}
onResolve={
active && !isChronic
? () => resolve.mutate(episode.id)
: undefined
}
resolving={resolve.isPending}
/>
) : null}
</div>
)}
</div>
</span>
) : undefined
}
actions={
canManage ? (
<>
<Button
onClick={() => setLogOpen(true)}
className="min-h-11 sm:min-h-9"
>
{t("illness.logDay")}
</Button>
{episode ? (
<EpisodeMenu
episode={episode}
onEdit={() => setEditOpen(true)}
onResolve={
active && !isChronic
? () => resolve.mutate(episode.id)
: undefined
}
resolving={resolve.isPending}
/>
) : null}
</>
) : undefined
}
/>
{episode?.note ? (
<Card>
+2 -2
View File
@@ -48,7 +48,7 @@ export function IllnessSettings() {
return (
<div className="space-y-6">
<SettingsCard id="illness-view" className="scroll-mt-28 space-y-4">
<SettingsCard id="illness-view" className="scroll-mt-28">
<SettingsCardHeader
icon={Eye}
title={t("moduleList.viewHeading")}
@@ -57,7 +57,7 @@ export function IllnessSettings() {
/>
</SettingsCard>
<SettingsCard id="illness-order" className="scroll-mt-28 space-y-4">
<SettingsCard id="illness-order" className="scroll-mt-28">
<SettingsCardHeader
icon={ListOrdered}
title={t("moduleList.reorder.heading")}
@@ -8,7 +8,7 @@ import { useAuth } from "@/hooks/use-auth";
import { cn } from "@/lib/utils";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import { SectionHeading } from "@/components/insights/section-heading";
import { SectionHeading } from "@/components/ui/section-heading";
import type {
BreathingClassification,
BreathingTrend,
@@ -4,7 +4,7 @@ import { AlertTriangle, Activity } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { SectionHeading } from "@/components/insights/section-heading";
import { SectionHeading } from "@/components/ui/section-heading";
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta";
import { CoverageMeter } from "@/components/insights/derived/coverage-meter";
import { ProvenanceExplainer } from "@/components/insights/derived/provenance-explainer";
+1 -1
View File
@@ -23,7 +23,7 @@ import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { ListRow } from "@/components/ui/list-row";
import { Skeleton } from "@/components/ui/skeleton";
import { SectionHeading } from "@/components/insights/section-heading";
import { SectionHeading } from "@/components/ui/section-heading";
import {
useTranslations,
useFormatters,
@@ -13,7 +13,7 @@ import {
MEASUREMENT_TYPE_LABEL_KEYS,
} from "@/components/measurements/measurement-list-meta";
import { TileHeader } from "@/components/insights/tile-header";
import { SectionHeading } from "@/components/insights/section-heading";
import { SectionHeading } from "@/components/ui/section-heading";
import { SparklineDeltaTile } from "./sparkline-delta-tile";
import { LearnMoreLink } from "@/components/ui/learn-more-link";
import { CoverageMeter } from "./coverage-meter";
@@ -14,7 +14,7 @@ import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "@/lib/i18n/context";
import { SectionHeading } from "@/components/insights/section-heading";
import { SectionHeading } from "@/components/ui/section-heading";
import { ScoreRing } from "./score-ring";
import { TILE_HUE, type RingHue } from "./ring-hues";
import type { DerivedBatchRead } from "./use-derived-metric";
@@ -11,7 +11,7 @@ import type { DataSummary } from "@/lib/analytics/trends";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { LearningGate } from "@/components/ui/learning-gate";
import { HealthChartDynamicMini } from "@/components/charts/health-chart-dynamic";
import { SectionHeading } from "@/components/insights/section-heading";
import { SectionHeading } from "@/components/ui/section-heading";
import { TileHeader } from "@/components/insights/tile-header";
/**
+1 -1
View File
@@ -9,7 +9,7 @@ import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { SectionHeading } from "@/components/insights/section-heading";
import { SectionHeading } from "@/components/ui/section-heading";
import { EcgWaveform } from "@/components/insights/ecg-waveform";
import { InsightSectionCard } from "./insight-section-card";
@@ -9,7 +9,7 @@ import { metricFractionDigits } from "@/lib/measurements/value-domain";
import { cn } from "@/lib/utils";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import { SectionHeading } from "@/components/insights/section-heading";
import { SectionHeading } from "@/components/ui/section-heading";
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta";
import type {
HealthStatusDeviation,
@@ -23,6 +23,7 @@ import {
import { CSS } from "@dnd-kit/utilities";
import { Button } from "@/components/ui/button";
import { SettingsCard } from "@/components/settings/settings-card";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { cn } from "@/lib/utils";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
@@ -213,10 +214,7 @@ export function InsightsEditMode({
const allHidden = draft.sections.every((s) => !s.visible);
return (
<div
data-slot="insights-edit-mode"
className="bg-card border-border space-y-4 rounded-xl border p-4 sm:p-6"
>
<SettingsCard data-slot="insights-edit-mode">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<div>
<h2
@@ -312,7 +310,7 @@ export function InsightsEditMode({
{t("insights.editMode.manageInSettings")}
</Link>
</p>
</div>
</SettingsCard>
);
}
@@ -8,7 +8,7 @@ import { useAuth } from "@/hooks/use-auth";
import { cn } from "@/lib/utils";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import { SectionHeading } from "@/components/insights/section-heading";
import { SectionHeading } from "@/components/ui/section-heading";
import type { LabChange } from "@/lib/insights/labs-changes";
import { InsightSectionCard } from "./insight-section-card";
@@ -13,7 +13,7 @@ import { formatUpdatedLabel } from "@/lib/i18n/relative-time";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import { Skeleton } from "@/components/ui/skeleton";
import { SectionHeading } from "@/components/insights/section-heading";
import { SectionHeading } from "@/components/ui/section-heading";
import { AskCoachIconButton } from "@/components/insights/ask-coach-action";
import { ProseBlocks } from "@/components/insights/prose-blocks";
import { apiGet } from "@/lib/api/api-fetch";
@@ -9,7 +9,7 @@ import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { SectionHeading } from "@/components/insights/section-heading";
import { SectionHeading } from "@/components/ui/section-heading";
import { InsightSectionCard } from "./insight-section-card";
/**
@@ -5,7 +5,7 @@ import { Activity, Gauge, Moon, Repeat, Target, Waves } from "lucide-react";
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { SectionHeading } from "@/components/insights/section-heading";
import { SectionHeading } from "@/components/ui/section-heading";
import { DeviceScoreTile } from "@/components/insights/device-score-tile";
import { DeviceScoreGridSkeleton } from "@/components/insights/device-score-tile-skeleton";
+1 -1
View File
@@ -6,7 +6,7 @@ import { ArrowRight, TrendingUp } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { SectionHeading } from "@/components/insights/section-heading";
import { SectionHeading } from "@/components/ui/section-heading";
import { HealthChartDynamicMini } from "@/components/charts/health-chart-dynamic";
import type { DailyBriefing } from "@/lib/ai/schema";
import {
@@ -5,7 +5,7 @@ import Link from "next/link";
import { ArrowRight, CalendarDays, Moon, Smile } from "lucide-react";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { SectionHeading } from "@/components/insights/section-heading";
import { SectionHeading } from "@/components/ui/section-heading";
import { Card, CardContent } from "@/components/ui/card";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Skeleton } from "@/components/ui/skeleton";
+76 -82
View File
@@ -27,6 +27,7 @@ import { Card, CardContent } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { PageHeader } from "@/components/ui/page-header";
import { Skeleton } from "@/components/ui/skeleton";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
@@ -303,14 +304,10 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
if (markerError || listError) {
return (
<div className="space-y-6">
<header className="space-y-1.5">
<h1 className="text-2xl font-bold tracking-tight">
{marker?.name ?? t("labs.detail.title")}
</h1>
<p className="text-foreground text-sm leading-relaxed">
{description}
</p>
</header>
<PageHeader
title={marker?.name ?? t("labs.detail.title")}
description={description}
/>
<QueryErrorCard
title={t("labs.loadError")}
onRetry={() => {
@@ -324,91 +321,88 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
return (
<div className="space-y-6">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl font-bold tracking-tight">
{marker?.name ?? <Skeleton className="h-7 w-32" />}
</h1>
{marker?.unit ? (
<p className="text-muted-foreground text-xs sm:text-sm">
{marker.unit}
{marker.panel ? ` · ${marker.panel}` : ""}
</p>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2">
{/* v1.25.3 — header control cluster, left → right:
<PageHeader
title={marker?.name ?? <Skeleton className="h-7 w-32" />}
description={description}
actions={
<>
{/* v1.25.3 — header control cluster, left → right:
Delete · Edit · Show-all-values · Add. The icon controls share one
ghost-icon treatment (the target-range editing lives inside the
full marker editor behind the pencil, so the separate range
control was dropped). */}
<DeleteButton
onConfirm={() => deleteMarker.mutate()}
title={t("labs.biomarker.deleteConfirmTitle")}
description={t("labs.biomarker.deleteConfirmDescription")}
confirmLabel={t("labs.biomarker.delete")}
triggerTitle={t("labs.biomarker.delete")}
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
iconClassName="h-4 w-4"
/>
{canManage && (
<Button
variant="ghost"
size="icon"
<DeleteButton
onConfirm={() => deleteMarker.mutate()}
title={t("labs.biomarker.deleteConfirmTitle")}
description={t("labs.biomarker.deleteConfirmDescription")}
confirmLabel={t("labs.biomarker.delete")}
triggerTitle={t("labs.biomarker.delete")}
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
onClick={() => setEditOpen(true)}
disabled={!marker}
aria-label={t("labs.biomarker.edit")}
title={t("labs.biomarker.edit")}
>
<Pencil className="h-4 w-4" />
</Button>
)}
{/* "Show all readings" mirrors the metric sub-pages' `<SubPageShell>`
iconClassName="h-4 w-4"
/>
{canManage && (
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
onClick={() => setEditOpen(true)}
disabled={!marker}
aria-label={t("labs.biomarker.edit")}
title={t("labs.biomarker.edit")}
>
<Pencil className="h-4 w-4" />
</Button>
)}
{/* "Show all readings" mirrors the metric sub-pages' `<SubPageShell>`
control. The full reading feed lives on
`/labs/[biomarkerId]/values`; the detail page keeps the
numbers-first spine. */}
{readings.length > 0 ? (
<Button
asChild
variant="ghost"
size="icon"
data-slot="lab-show-all-values"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
>
<Link
href={`/labs/${biomarkerId}/values`}
aria-label={t("insights.subPage.showAllValues")}
title={t("insights.subPage.showAllValues")}
{readings.length > 0 ? (
<Button
asChild
variant="ghost"
size="icon"
data-slot="lab-show-all-values"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
>
<ListOrdered className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
) : null}
{/* v1.36.x — entering a result is an admitted delegated write;
<Link
href={`/labs/${biomarkerId}/values`}
aria-label={t("insights.subPage.showAllValues")}
title={t("insights.subPage.showAllValues")}
>
<ListOrdered className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
) : null}
{/* v1.36.x — entering a result is an admitted delegated write;
renaming or removing the marker is not. */}
{canAdd && (
<Button
onClick={() => setAddOpen(true)}
// v1.18.10 (W10) — on the narrowest phones the h1 + Edit + Delete +
// text "Add" button crowd the row and truncate the title hard.
// Drop the Add button to icon-only under `sm` (label kept for
// screen readers via `aria-label`); the full text returns at `sm+`.
className="min-h-11 min-w-11 shrink-0 sm:min-h-9 sm:min-w-0"
aria-label={t("labs.addResult")}
>
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">{t("labs.addResult")}</span>
</Button>
)}
</div>
</div>
{canAdd && (
<Button
onClick={() => setAddOpen(true)}
// v1.18.10 (W10) — on the narrowest phones the h1 + Edit + Delete +
// text "Add" button crowd the row and truncate the title hard.
// Drop the Add button to icon-only under `sm` (label kept for
// screen readers via `aria-label`); the full text returns at `sm+`.
className="min-h-11 min-w-11 shrink-0 sm:min-h-9 sm:min-w-0"
aria-label={t("labs.addResult")}
>
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">{t("labs.addResult")}</span>
</Button>
)}
</>
}
/>
{/* v1.25.1 — the static description leads the page, directly beneath the
heading, mirroring the metric sub-pages' explainer caption. Computed
once above (catalog desc → user `context` → generic fallback) so a
catalog-less marker still carries a description. */}
<p className="text-foreground text-sm leading-relaxed">{description}</p>
{/* Unit + panel are meta about the marker, not its explainer, so they
read below the header block rather than competing with it for the
one description line every page header carries. */}
{marker?.unit ? (
<p className="text-muted-foreground text-sm">
{marker.unit}
{marker.panel ? ` · ${marker.panel}` : ""}
</p>
) : null}
{/* Bridge to the source report PDFs. There is no per-value document
relation in the data model yet, so this links to the vault filtered
+8 -5
View File
@@ -79,7 +79,7 @@ export function LabsSettings() {
return (
<div className="space-y-6">
{/* View — card vs compact list. */}
<SettingsCard id="labs-view" className="scroll-mt-28 space-y-4">
<SettingsCard id="labs-view" className="scroll-mt-28">
<SettingsCardHeader
icon={LayoutGrid}
title={t("moduleList.viewHeading")}
@@ -89,7 +89,7 @@ export function LabsSettings() {
</SettingsCard>
{/* Sort order — recency, alphabetical, or manual. */}
<SettingsCard id="labs-sort-card" className="scroll-mt-28 space-y-4">
<SettingsCard id="labs-sort-card" className="scroll-mt-28">
<SettingsCardHeader
icon={ArrowDownUp}
title={t("labs.sort.heading")}
@@ -112,7 +112,7 @@ export function LabsSettings() {
</SettingsCard>
{manual ? (
<SettingsCard id="labs-order" className="scroll-mt-28 space-y-4">
<SettingsCard id="labs-order" className="scroll-mt-28">
<SettingsCardHeader
icon={ListOrdered}
title={t("moduleList.reorder.heading")}
@@ -130,7 +130,7 @@ export function LabsSettings() {
) : null}
{/* v1.18.10 — local (in-browser) OCR opt-in for lab scans. */}
<SettingsCard id="labs-local-ocr" className="scroll-mt-28 space-y-4">
<SettingsCard id="labs-local-ocr" className="scroll-mt-28">
<SettingsCardHeader
icon={ScanText}
title={t("labs.localOcr.heading")}
@@ -144,10 +144,13 @@ export function LabsSettings() {
/>
}
/>
{/* "Less accurate — review every value" is safety copy, so it reads
foreground in the body rather than muted beside the switch. */}
<p className="text-sm">{t("labs.localOcr.detail")}</p>
</SettingsCard>
{/* Biomarker catalog — define / edit / delete. */}
<SettingsCard id="labs-biomarkers" className="scroll-mt-28 space-y-4">
<SettingsCard id="labs-biomarkers" className="scroll-mt-28">
<SettingsCardHeader
icon={FlaskConical}
title={t("labs.biomarker.manageTitle")}
@@ -62,6 +62,7 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { PageHeader } from "@/components/ui/page-header";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
@@ -400,24 +401,21 @@ export function VorsorgeSection({
return (
<section aria-labelledby="vorsorge-section-title" className="space-y-4">
{variant === "page" ? (
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h1
id="vorsorge-section-title"
data-tour-id="vorsorge-hero"
className="text-2xl font-bold tracking-tight"
>
<PageHeader
titleId="vorsorge-section-title"
title={
<span data-tour-id="vorsorge-hero">
{t("measurementReminders.sectionTitle")}
</h1>
<p className="text-muted-foreground text-xs sm:text-sm">
{t("measurementReminders.sectionDescription")}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
{wrenchButton}
{addButton}
</div>
</div>
</span>
}
description={t("measurementReminders.sectionDescription")}
actions={
<>
{wrenchButton}
{addButton}
</>
}
/>
) : (
<SettingsCardHeader
icon={CalendarClock}
@@ -81,7 +81,7 @@ export function VorsorgeSettings() {
title={t("moduleList.reorder.heading")}
description={t("moduleList.reorder.description")}
/>
<div className="mt-4 pl-7">
<div>
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 3 }, (_, i) => (
@@ -100,7 +100,7 @@ export function VorsorgeSettings() {
title={t("measurementReminders.manage.title")}
description={t("measurementReminders.manage.description")}
/>
<div className="mt-4 pl-7">
<div>
{isLoading ? (
<Skeleton className="h-24 w-full" />
) : (reminders?.length ?? 0) === 0 ? (
@@ -0,0 +1,188 @@
import { describe, it, expect } from "vitest";
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
/**
* The description slot holds ONE sentence, guideline ≤ 120 characters
* (design standards §3).
*
* Scope is deliberately the strings that actually reach a card header's
* description slot, whether the prop sits on the header or on a shell that
* forwards it. That is the slot the rule is about. A hint in a card BODY is
* prose and is allowed to be prose; `scripts/measure-description-lengths.mjs`
* sweeps those for review without gating them.
*
* Both EN and DE are checked. DE runs 15-25% longer than EN, and a rule that
* only holds in English produces a slot that wraps to two lines in half the
* locales — which is the same defect, moved.
*/
const ROOT = process.cwd();
const MAX_CHARS = 120;
/**
* Strings that stay longer than the guideline, each with the reason. The
* guideline is not a hard cut: a sentence that needs 130 characters to stay
* true is fine. Two sentences never are, and that half of the rule has no
* exceptions.
*/
const LENGTH_EXCEPTIONS: Record<string, string> = {
// Empty on purpose: the sweep that introduced this guard left nothing over
// the guideline in EN or DE. An entry here needs a sentence saying what the
// extra characters buy, and `carries no stale exception` deletes it again
// the moment the string fits or stops being a description.
};
function walk(dir: string, out: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
if (entry === "__tests__" || entry === "node_modules") continue;
walk(full, out);
} else if (entry.endsWith(".tsx")) {
out.push(full);
}
}
return out;
}
/**
* Elements whose `description` prop reaches `SettingsCardHeader`'s slot.
*
* The first cut of this guard matched `<SettingsCardHeader` only, and only
* under three component folders. That left five real card descriptions
* invisible to it — one of them the 351-character, three-sentence full-backup
* text that the audit's own shortlist had named. Two ways in were missing:
* a card header rendered from a folder the walk never entered, and a
* description FORWARDED to the header by a shell (`ExportCardShell`,
* `ImportCardShell`), where the prop is on the shell's tag rather than the
* header's.
*
* `SubPageShell` is deliberately NOT here. Its description renders as
* `<p className="text-foreground text-sm">` — the body tier, where the
* standards allow prose. Gating it would enforce a rule §3 does not make for
* that tier.
*/
const DESCRIPTION_HOSTS = [
"SettingsCardHeader",
"ExportCardShell",
"ImportCardShell",
];
/** Component roots that can host a card header, directly or via a shell. */
const ROOTS = [
join("src", "components", "settings"),
join("src", "components", "admin"),
join("src", "components", "measurement-reminders"),
join("src", "components", "labs"),
join("src", "components", "insights"),
join("src", "components", "medications"),
join("src", "components", "records"),
];
/** Every `description={t("…")}` on an element that owns the header's slot. */
function describedKeys(): Map<string, string[]> {
const keys = new Map<string, string[]>();
for (const root of ROOTS) {
for (const file of walk(join(ROOT, root))) {
const source = readFileSync(file, "utf8");
const host = new RegExp(`<(?:${DESCRIPTION_HOSTS.join("|")})\\b`, "g");
let match: RegExpExecArray | null;
while ((match = host.exec(source))) {
// The element ends at the first `/>` or `>` that closes the opening
// tag; a `description={t("x")}` after that belongs to something else.
const selfClose = source.indexOf("/>", match.index);
const childrenOpen = source.indexOf("\n >", match.index);
const end = [selfClose, childrenOpen]
.filter((i) => i !== -1)
.sort((a, b) => a - b)[0];
const slice = source.slice(match.index, end ?? undefined);
const desc = /description=\{t\(\s*"([^"]+)"/.exec(slice);
if (!desc) continue;
const list = keys.get(desc[1]) ?? [];
list.push(file.slice(ROOT.length + 1));
keys.set(desc[1], list);
}
}
}
return keys;
}
function resolve(bundle: unknown, key: string): string | undefined {
const value = key
.split(".")
.reduce<unknown>(
(node, part) =>
node && typeof node === "object"
? (node as Record<string, unknown>)[part]
: undefined,
bundle,
);
return typeof value === "string" ? value : undefined;
}
/** Sentence terminators followed by a space or the end of the string. */
function sentenceCount(text: string): number {
const trimmed = text.trim();
if (!trimmed) return 0;
const matches = trimmed.match(/[.!?](\s+\S|$)/g);
return Math.max(1, matches ? matches.length : 1);
}
describe("card header descriptions — one sentence, ≤120 characters", () => {
const keys = describedKeys();
it("finds the description slots it is meant to check", () => {
// A matcher that quietly matches nothing passes every assertion below it.
expect(keys.size).toBeGreaterThan(35);
});
for (const locale of ["en", "de"] as const) {
const bundle = JSON.parse(
readFileSync(join(ROOT, "messages", `${locale}.json`), "utf8"),
);
it(`${locale}: every description is a single sentence`, () => {
const offenders: string[] = [];
for (const [key, files] of keys) {
const value = resolve(bundle, key);
if (value === undefined) continue; // the i18n coverage guard owns this
if (sentenceCount(value) > 1) {
offenders.push(
`${key} (${sentenceCount(value)} sentences) — ${files[0]}`,
);
}
}
expect(offenders).toEqual([]);
});
it(`${locale}: every description is within the length guideline`, () => {
const offenders: string[] = [];
for (const [key, files] of keys) {
const value = resolve(bundle, key);
if (value === undefined) continue;
if (value.length > MAX_CHARS && !(key in LENGTH_EXCEPTIONS)) {
offenders.push(`${key} (${value.length} chars) — ${files[0]}`);
}
}
expect(offenders).toEqual([]);
});
}
it("carries no stale exception", () => {
const bundle = JSON.parse(
readFileSync(join(ROOT, "messages", "en.json"), "utf8"),
);
for (const [key, reason] of Object.entries(LENGTH_EXCEPTIONS)) {
expect(
keys.has(key),
`${key} is allowlisted but no longer a description`,
).toBe(true);
expect(
resolve(bundle, key)!.length,
`${key} is allowlisted but now fits the guideline`,
).toBeGreaterThan(MAX_CHARS);
expect(reason.length).toBeGreaterThan(20);
}
});
});
@@ -55,7 +55,7 @@ describe("<CoachSection> — SSR smoke", () => {
expect(html).toContain('data-testid="settings-disable-coach-switch"');
// v1.18.1 (D7) — polarity flipped to activate/default-on.
expect(html).toContain("Activate Coach");
expect(html).toContain("Show the Coach button and drawer. On by default");
expect(html).toContain("Show the Coach button and drawer");
// v1.18.6 (W9) — the proactive Coach nudge card moved here from
// Benachrichtigungen; its anchor renders under the enabled-coach gate.
expect(html).toContain('id="coach-nudge"');
@@ -68,7 +68,6 @@ describe("<CoachSection> — SSR smoke", () => {
authState.disableCoach = false;
const html = render("de");
expect(html).toContain("Coach aktivieren");
expect(html).toContain("Standardmäßig an");
expect(html).not.toContain("settings.sections.coach.");
expect(html).not.toContain("settings.coach.activate.");
});
@@ -150,6 +150,6 @@ describe("Settings — DisableCoachCard", () => {
const html = render();
// v1.18.1 (D7) — polarity flipped to activate/default-on.
expect(html).toContain("Activate Coach");
expect(html).toContain("Show the Coach button and drawer. On by default");
expect(html).toContain("Show the Coach button and drawer");
});
});
@@ -21,7 +21,7 @@
* 3. Withings reauth: pill carries the "Error — reconnect" label.
* 4. Mood Log disconnected: pill says "Not connected", no other
* status display surfaces.
* 5. Mood Log card has the visual divider that Withings always had
* 5. no card paints a divider under its header
* — consistency the maintainer explicitly called out.
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
@@ -254,7 +254,7 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
expect(html).toContain("Withings activity error: 293");
});
it("every integration card carries the visual divider (consistency)", () => {
it("no integration card paints a divider under its header", () => {
setIntegrationStatus({
threshold: 3,
integrations: [
@@ -274,10 +274,10 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
});
const html = render();
// Every integration card includes the section divider data-testid so the
// header → body separation is visually consistent: the four legacy cards,
// Apple Health, the three OAuth cards, and Nightscout.
expect(count(html, 'data-testid="integration-card-divider"')).toBe(9);
// The `<hr>` under the header existed only in `integrations/` — nine
// cards carrying a rule no other Settings card has. The card's own gap
// does the separating now, identically here and everywhere else.
expect(count(html, 'data-testid="integration-card-divider"')).toBe(0);
});
// v1.17.1 — every integration card carries the same discreet "Setup guide"
@@ -0,0 +1,191 @@
import { describe, it, expect } from "vitest";
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { renderToStaticMarkup } from "react-dom/server";
import { Bell } from "lucide-react";
import { I18nProvider } from "@/lib/i18n/context";
import { PageHeader } from "@/components/ui/page-header";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { SettingsCardActions } from "@/components/settings/_card-actions";
/**
* Settings + Admin read as one surface. These pin the parts of that claim
* that can be checked exactly:
*
* 1. the header→body step is owned by `SettingsCard`, so a call site can
* neither re-declare it nor drift from it;
* 2. the card header's description slot carries no vertical rhythm — the
* licence call sites used to stack explainer paragraphs into a muted
* `text-xs` slot;
* 3. the action row has one shape, and its one documented exception
* (a card whose only actions are links out) is spelled as a prop;
* 4. `pl-7` — the off-scale body gutter that broke wherever two
* neighbouring cards disagreed about it — stays gone;
* 5. both consoles render their page heading through `PageHeader`, so
* Settings and Admin cannot drift a step apart from the module pages
* or from each other.
*/
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
const ROOT = process.cwd();
function walk(dir: string, out: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
if (entry === "__tests__") continue;
walk(full, out);
} else if (entry.endsWith(".tsx")) {
out.push(full);
}
}
return out;
}
describe("SettingsCard — the container owns the rhythm", () => {
it("paints the gap-based shell, not a call-site margin", () => {
const html = render(<SettingsCard>body</SettingsCard>);
expect(html).toContain("flex flex-col gap-4");
expect(html).toContain("p-4");
expect(html).toContain("md:p-6");
});
it("gives the semantic-landmark variant the same rhythm", () => {
const html = render(<SettingsCard as="section">body</SettingsCard>);
expect(html).toContain("<section");
expect(html).toContain("flex flex-col gap-4");
});
it("still lets a dense card override the gap on the card itself", () => {
const html = render(<SettingsCard className="gap-2">body</SettingsCard>);
expect(html).toContain("gap-2");
});
});
describe("SettingsCardHeader — one description sentence, one text run", () => {
it("renders the description without any vertical rhythm of its own", () => {
const html = render(
<SettingsCardHeader icon={Bell} title="Channels" description="One." />,
);
// The slot is muted meta at the size floor…
expect(html).toContain("text-muted-foreground text-xs");
// …and carries no `space-y-*`, so a stack of <p>s cannot read as prose.
const slot = html.slice(html.indexOf("text-muted-foreground text-xs"));
expect(slot.slice(0, 60)).not.toMatch(/space-y-/);
});
it("keeps the title in the foreground tier at one size", () => {
const html = render(<SettingsCardHeader icon={Bell} title="Channels" />);
expect(html).toMatch(/<h2[^>]*class="text-lg font-semibold"/);
});
it("names the description slot so a browser test can target it", () => {
// A spec that asserted the sentence's bytes broke on the text diet while
// the thing it was actually about — the card rendering its explainer —
// never changed. This attribute is the stable target that replaced it.
const html = render(
<SettingsCardHeader icon={Bell} title="Channels" description="One." />,
);
expect(html).toContain('data-slot="settings-card-description"');
});
});
describe("SettingsCardActions — one row, one shape", () => {
it("puts the row at the trailing edge by default", () => {
const html = render(
<SettingsCardActions>
<button>Save</button>
</SettingsCardActions>,
);
expect(html).toContain('data-slot="settings-card-actions"');
expect(html).toContain("justify-end");
});
it("spells the link-row exception as a prop rather than a local class", () => {
const html = render(
<SettingsCardActions align="start">
<a href="/x">Open</a>
</SettingsCardActions>,
);
expect(html).toContain("justify-start");
expect(html).not.toContain("justify-end");
});
});
describe("PageHeader — the one page heading, including the shells", () => {
it("renders the explainer in the muted tier", () => {
const html = render(<PageHeader title="Settings" description="Yours." />);
expect(html).toMatch(/<p class="text-muted-foreground text-sm">Yours\./);
});
it("renders a second copy as a heading role, never a second <h1>", () => {
const html = render(
<PageHeader headingAs="div" title="Settings" description="Yours." />,
);
expect(html).not.toContain("<h1");
expect(html).toContain('role="heading"');
expect(html).toContain('aria-level="1"');
});
it("never truncates the explainer", () => {
const html = render(
<PageHeader title="Medications" description="Schedules and reminders." />,
);
expect(html).not.toContain("truncate");
});
});
describe("the Settings and Admin shells route their heading through PageHeader", () => {
for (const shell of [
join("src", "components", "settings", "settings-shell.tsx"),
join("src", "components", "admin", "admin-shell.tsx"),
]) {
it(`${shell} has no hand-rolled H1 block`, () => {
const source = readFileSync(join(ROOT, shell), "utf8");
expect(source).toContain("<PageHeader");
// The hand-rolled copies were a literal `<h1 className="text-2xl …">`
// plus a `role="heading"` div beside it. Both belong to the primitive.
const code = source
.split("\n")
.filter((line) => !line.trimStart().startsWith("//"))
.join("\n");
expect(code).not.toMatch(/<h1\b/);
expect(code).not.toMatch(/role="heading"/);
});
}
});
describe("the abolished body gutter stays abolished", () => {
it("has no `pl-7` left under Settings or Admin", () => {
const offenders: string[] = [];
for (const dir of ["settings", "admin"]) {
for (const file of walk(join(ROOT, "src", "components", dir))) {
const source = readFileSync(file, "utf8");
for (const [i, line] of source.split("\n").entries()) {
// Comments may name the class they retired; class strings may not.
// Skipping any line that CONTAINS `//` was too coarse — a class
// string on the same line as a URL (`https://…`) would have been
// waved through. Only the part of the line after a line-comment
// marker is exempt, and a marker inside a string is not one.
if (line.trimStart().startsWith("*")) continue;
const marker = line.indexOf("//");
const code =
marker === -1 || /["'`]/.test(line.slice(0, marker))
? line
: line.slice(0, marker);
if (/\bpl-7\b/.test(code)) {
offenders.push(`${file.slice(ROOT.length + 1)}:${i + 1}`);
}
}
}
}
expect(offenders).toEqual([]);
});
});
+46
View File
@@ -0,0 +1,46 @@
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* `<SettingsCardActions>` — the action row of a Settings or Admin card.
*
* The audit found four positions (bottom-right, bottom-left, the header's
* status slot, inline mid-card) and five variants carrying the SAME role —
* "the primary action of this card" — sometimes two of them in one file. The
* two sections that already read right (`score-section.tsx`,
* `coach-prefs-section.tsx`) agreed on one shape, and this is it.
*
* The contract (design standards §12):
* - The row is the LAST thing in the card. Status lines and results sit
* above it; a result set big enough to need a frame becomes its own card.
* - Primary action last (rightmost), `variant` default, `size="sm"` with
* the `min-h-11` mobile tap floor. One per card.
* - Secondary action `outline`, before the primary in the same row.
* - Destructive card-wide action: same row, `variant="destructive"` —
* never an `outline` hand-tinted with `text-destructive`.
*
* `align="start"` is the one documented exception: a card whose only actions
* are deep links out of it (privacy, about, export) has no primary action, so
* its links read with the text on the left rather than against it.
*/
export function SettingsCardActions({
align = "end",
className,
children,
...props
}: React.ComponentProps<"div"> & { align?: "start" | "end" }) {
return (
<div
data-slot="settings-card-actions"
className={cn(
"flex flex-wrap items-center gap-2",
align === "end" ? "justify-end" : "justify-start",
className,
)}
{...props}
>
{children}
</div>
);
}
+17 -4
View File
@@ -47,9 +47,13 @@ export interface SettingsCardHeaderProps {
* after the title (e.g. a tag chip + experimental badge). The title
* row wraps so the accessories reflow below on a narrow viewport. */
titleAccessory?: React.ReactNode;
/** Optional short description rendered as muted text below the
* title row. Either a string (paragraph) or arbitrary node
* (links, badges, extra sub-notes, etc.). */
/** Optional description rendered as muted text below the title row.
* Exactly ONE sentence, guideline ≤ 120 characters (design standards
* §3). A node is accepted so the sentence can carry an inline link,
* not so it can carry a second paragraph — the slot owns no vertical
* rhythm, so a stack of `<p>`s collapses into one run and reads
* broken. Multi-sentence prose goes in the body as
* `<p className="text-sm">` (foreground). */
description?: React.ReactNode;
/** Optional right-aligned status surface — typically an
* `<IntegrationStatusPill>` or a wrapper around badges. */
@@ -88,8 +92,17 @@ export function SettingsCardHeader({
<div className="flex shrink-0 items-center gap-2">{status}</div>
) : null}
</div>
{/* One sentence, one text run. This slot deliberately carries no
`space-y-*`: the vertical rhythm was the licence call sites used
to stack explainer paragraphs into a muted `text-xs` slot.
The `data-slot` is what a browser test targets — asserting the
sentence's bytes couples the suite to wording, and a text diet
then breaks specs that were never about the words. */}
{description ? (
<div className="text-muted-foreground space-y-1 text-xs">
<div
data-slot="settings-card-description"
className="text-muted-foreground text-xs"
>
{description}
</div>
) : null}
+6 -1
View File
@@ -43,7 +43,10 @@ const TONE_ICON: Record<SettingsInfoTileTone, string> = {
neutral: "text-muted-foreground",
};
export interface SettingsInfoTileProps {
export interface SettingsInfoTileProps extends Omit<
React.ComponentProps<"div">,
"title"
> {
/** Lucide icon component; rendered in the tone colour. */
icon: LucideIcon;
/** Visual tone — routes through the semantic colour tokens. */
@@ -62,6 +65,7 @@ export function SettingsInfoTile({
title,
children,
className,
...props
}: SettingsInfoTileProps) {
return (
<div
@@ -70,6 +74,7 @@ export function SettingsInfoTile({
TONE_SURFACE[tone],
className,
)}
{...props}
>
<Icon
className={cn("mt-0.5 h-4 w-4 shrink-0", TONE_ICON[tone])}
+12 -17
View File
@@ -49,6 +49,7 @@ import {
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { SettingsInfoTile } from "@/components/settings/_info-tile";
import { Label } from "@/components/ui/label";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { SettingsCard } from "@/components/settings/settings-card";
@@ -195,7 +196,6 @@ export function AboutMeSection({
as="section"
aria-labelledby="settings-ai-about-me-title"
data-testid="settings-about-me-card"
className="space-y-4"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<SettingsCardHeader
@@ -229,7 +229,7 @@ export function AboutMeSection({
</div>
</div>
<div className="pl-7">
<div>
{structuredField(
"allergies",
t("settings.ai.aboutMe.allergiesLabel"),
@@ -237,7 +237,7 @@ export function AboutMeSection({
)}
</div>
<div className="space-y-1.5 pl-7">
<div className="space-y-1.5">
<Label htmlFor="settings-about-me-freetext">
{t("settings.ai.aboutMe.freeTextLabel")}
</Label>
@@ -255,7 +255,7 @@ export function AboutMeSection({
/>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 pl-7">
<div className="flex flex-wrap items-center justify-between gap-3">
<p
data-testid="settings-about-me-count"
className="text-muted-foreground text-xs tabular-nums"
@@ -302,18 +302,13 @@ export function AboutMeSection({
</div>
{pendingQuestions.length > 0 && (
<div
<SettingsInfoTile
icon={MessageCircleQuestion}
tone="primary"
data-testid="settings-about-me-questions"
className="border-primary/30 bg-primary/5 rounded-lg border p-4"
title={t("settings.ai.aboutMe.questionsTitle")}
>
<p className="flex items-center gap-2 text-sm font-medium">
<MessageCircleQuestion
className="text-primary size-4 shrink-0"
aria-hidden="true"
/>
{t("settings.ai.aboutMe.questionsTitle")}
</p>
<ul className="text-muted-foreground mt-2 list-disc space-y-1 pl-5 text-sm">
<ul className="list-disc space-y-1 pl-6">
{pendingQuestions.map((q) => (
<li key={q}>{q}</li>
))}
@@ -326,7 +321,7 @@ export function AboutMeSection({
{t("settings.ai.aboutMe.questionsOpenCoach")}
</Link>
</p>
</div>
</SettingsInfoTile>
)}
{query.isError && (
@@ -339,14 +334,14 @@ export function AboutMeSection({
</p>
)}
<p className="text-muted-foreground border-border border-t pt-3 pl-7 text-xs">
<p className="text-muted-foreground border-border border-t pt-3 text-xs">
{t("settings.ai.aboutMe.hint")} {t("settings.ai.aboutMe.profileHint")}
</p>
{/* v1.25.12 — cross-link to the Anamnese (medical-history) home, where the
chronic conditions the Coach watches now live alongside allergies +
family history as one coherent medical history. */}
<p className="text-muted-foreground pl-7 text-xs">
<p className="text-muted-foreground text-xs">
{t("settings.ai.aboutMe.recordsLink")}{" "}
<Link
href="/settings/anamnesis"
+6 -12
View File
@@ -13,6 +13,7 @@ import {
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
@@ -193,22 +194,17 @@ export function AboutSection() {
which read as a separate field even though the two values belong
on the same row. */}
<SettingsCard>
<SettingsCardHeader
icon={Info}
title="HealthLog"
description={t("settings.about.identityDescription")}
className="mb-4"
/>
<SettingsCardHeader icon={Info} title="HealthLog" />
{isLoading || !version ? (
<div className="flex items-center gap-2 pl-7">
<div className="flex items-center gap-2">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
<span className="text-muted-foreground text-sm">
{t("common.loading")}
</span>
</div>
) : (
<dl className="flex flex-wrap items-baseline gap-x-6 gap-y-3 pl-7 text-sm">
<dl className="flex flex-wrap items-baseline gap-x-6 gap-y-3 text-sm">
<div className="flex items-baseline gap-2">
<dt className="text-muted-foreground text-xs tracking-wide uppercase">
{t("settings.about.version")}
@@ -271,10 +267,8 @@ export function AboutSection() {
<SettingsCardHeader
icon={BookOpen}
title={t("settings.about.linksHeading")}
description={t("settings.about.linksDescription")}
className="mb-4"
/>
<div className="flex flex-col gap-2 pl-7 sm:flex-row sm:flex-wrap">
<SettingsCardActions align="start">
<Button
asChild
variant="outline"
@@ -319,7 +313,7 @@ export function AboutSection() {
<ExternalLink className="h-3.5 w-3.5" />
</a>
</Button>
</div>
</SettingsCardActions>
</SettingsCard>
)}
@@ -82,7 +82,10 @@ describe("grant invitation lapse date", () => {
expect(onError).not.toContain("setIdentifier");
expect(onError).not.toContain("setExpiresOn");
expect(source).toContain('role="alert"');
expect(source).toContain(
// The submit stays inert while a name is missing or a request is in
// flight. Matched whitespace-tolerantly: the predicate is the contract,
// its line wrapping is not.
expect(source.replace(/\s+/g, " ")).toContain(
"invite.isPending || identifier.trim().length === 0",
);
});
@@ -4,6 +4,7 @@ import { useState } from "react";
import { UserPlus } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
@@ -148,7 +149,7 @@ export function GrantInviteCard() {
title={t("recordSharing.invite.title")}
description={t("recordSharing.invite.description")}
/>
<form onSubmit={submit} className="mt-4 space-y-3">
<form onSubmit={submit} className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="grant-invite-identifier">
{t("recordSharing.invite.identifierLabel")}
@@ -318,21 +319,6 @@ export function GrantInviteCard() {
{t("recordSharing.invite.expiresHint")}
</p>
</div>
<Button
type="submit"
data-slot="grant-invite-submit"
// A narrowed invitation with nothing ticked would be a grant that
// opens nothing, which the server refuses. The button is inert and
// the sentence above says why, rather than sending a request whose
// only purpose is to come back as an error.
disabled={
invite.isPending || identifier.trim().length === 0 || choice.blocked
}
>
{invite.isPending
? t("recordSharing.invite.submitting")
: t("recordSharing.invite.submit")}
</Button>
{error && (
<p role="alert" className="text-destructive text-sm">
{error}
@@ -343,6 +329,27 @@ export function GrantInviteCard() {
{t("recordSharing.invite.sent", { name: invited })}
</p>
)}
<SettingsCardActions>
<Button
type="submit"
size="sm"
className="min-h-11 sm:min-h-9"
data-slot="grant-invite-submit"
// A narrowed invitation with nothing ticked would be a grant that
// opens nothing, which the server refuses. The button is inert and
// the sentence above says why, rather than sending a request whose
// only purpose is to come back as an error.
disabled={
invite.isPending ||
identifier.trim().length === 0 ||
choice.blocked
}
>
{invite.isPending
? t("recordSharing.invite.submitting")
: t("recordSharing.invite.submit")}
</Button>
</SettingsCardActions>
</form>
</SettingsCard>
);
@@ -52,7 +52,7 @@ export function GrantsGivenCard() {
title={t("recordSharing.given.title")}
description={t("recordSharing.given.description")}
/>
<div className="mt-4">
<div>
{isLoading && (
<Loader2
className="text-muted-foreground size-5 animate-spin motion-reduce:animate-none"
@@ -68,7 +68,9 @@ export function GrantsReceivedCard() {
title={t("recordSharing.received.title")}
description={t("recordSharing.received.description")}
/>
<div className="mt-4">
<p className="text-sm">{t("recordSharing.received.detail")}</p>
<div>
{isLoading && (
<Loader2
className="text-muted-foreground size-5 animate-spin motion-reduce:animate-none"
@@ -75,7 +75,7 @@ export function ManagedProfileCard() {
title={t("recordSharing.managed.title")}
description={t("recordSharing.managed.description")}
/>
<div className="mt-4 space-y-4">
<div className="space-y-4">
{/* Content, not meta: this is what somebody is consenting to take on,
and UI-STANDARDS §3 reserves muted for the incidental. */}
<p data-slot="managed-profile-explainer" className="text-sm">
@@ -50,7 +50,7 @@ export function RecordActivityCard() {
title={t("recordSharing.activity.title")}
description={t("recordSharing.activity.description")}
/>
<div className="mt-4 space-y-3">
<div className="space-y-3">
{/* The window, stated. Rendered only once the server has said what it
is — a placeholder number here would be exactly the invented value
the sentence exists to avoid. */}
@@ -108,20 +108,16 @@ export function AvatarSection() {
return (
<SettingsCard>
<SettingsCardHeader
icon={ImageUp}
title={t("settings.avatar.title")}
className="mb-4"
/>
<SettingsCardHeader icon={ImageUp} title={t("settings.avatar.title")} />
{/* The accepted formats and limits belong to the sentence that explains
what this card does, not to a caption of their own under the buttons.
Two strings rather than one merged key: the same wording is the
upload's own constraint copy, and a translator keeps the shorter unit
either way. */}
<p className="text-muted-foreground mb-4 pl-7 text-sm">
<p className="text-muted-foreground text-sm">
{t("settings.avatar.description")} {t("settings.avatar.hint")}
</p>
<div className="flex items-center gap-4 pl-7">
<div className="flex items-center gap-4">
<Avatar className="size-16">
{avatarUrl && <AvatarImage src={avatarUrl} alt={user.username} />}
<AvatarFallback className="text-base">
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import { Loader2, Save, Shield, User } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
SeededFormDiscardDialog,
@@ -362,12 +363,8 @@ export function AccountSection() {
<AvatarSection />
<SettingsCard>
<SettingsCardHeader
icon={User}
title={t("settings.profile")}
className="mb-4"
/>
<form onSubmit={handleSaveProfile} className="space-y-4 pl-7">
<SettingsCardHeader icon={User} title={t("settings.profile")} />
<form onSubmit={handleSaveProfile} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="username">{t("settings.username")}</Label>
@@ -559,7 +556,7 @@ export function AccountSection() {
</p>
)}
<div className="flex justify-end">
<SettingsCardActions>
<Button
type="submit"
className="min-h-11 sm:min-h-9"
@@ -572,7 +569,7 @@ export function AccountSection() {
)}
{t("common.save")}
</Button>
</div>
</SettingsCardActions>
</form>
</SettingsCard>
@@ -592,31 +589,26 @@ export function AccountSection() {
reachable. */}
<CycleTrackingCard isAuthenticated={isAuthenticated} />
{/* Password card. v1.4.19 A6: action-button placement contract —
on mobile (`<sm`) the action button stacks below the title +
description and renders full-width; on desktop (`>=sm`) it
right-aligns next to the title. The previous
`flex items-center justify-between` pattern overflowed the
card edge on Pixel 5 once button copy got longer than ~24 ch
(the German "Passwort ändern" already pushes it; the tour
card's "Restart onboarding tour" actually broke through the
right border by ~48 px). */}
{/* Password card. The action is this card's only one, so it sits in
the card's action row like every other primary. It stays full-width
under `sm` — the German "Passwort ändern" is long enough that a
right-hugging button used to break through the card's right border
on a narrow phone. */}
<SettingsCard>
<SettingsCardHeader
icon={Shield}
title={t("settings.passwordReset")}
description={t("settings.changePasswordDescription")}
status={
<Button
type="button"
variant="outline"
onClick={() => setPasswordDialogOpen(true)}
className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
>
{t("settings.changePassword")}
</Button>
}
/>
<SettingsCardActions>
<Button
type="button"
onClick={() => setPasswordDialogOpen(true)}
className="min-h-11 w-full sm:min-h-9 sm:w-auto"
>
{t("settings.changePassword")}
</Button>
</SettingsCardActions>
</SettingsCard>
{/* v1.25.7 — active-session management + the security-activity feed
+131 -129
View File
@@ -2,7 +2,7 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { BookOpenCheck, Loader2, Trash2 } from "lucide-react";
import { AlertTriangle, BookOpenCheck, Loader2, Trash2 } from "lucide-react";
import {
AlertDialog,
@@ -16,6 +16,8 @@ import {
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { SettingsInfoTile } from "@/components/settings/_info-tile";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Switch } from "@/components/ui/switch";
import { ResearchModeAcknowledgmentDialog } from "@/components/medications/research-mode-acknowledgment-dialog";
import { formatDateTime } from "@/lib/format";
@@ -146,19 +148,17 @@ function ResearchModeCard() {
title={t("settings.researchMode.sectionTitle")}
description={t("settings.researchMode.subtitle")}
/>
<p className="text-sm">{t("settings.researchMode.subtitleDetail")}</p>
{showRePrompt && (
<div
<SettingsInfoTile
icon={AlertTriangle}
tone="warning"
role="alert"
data-slot="settings-research-mode-reprompt"
className="border-warning/40 bg-warning/10 mt-4 rounded-md border-l-4 px-3 py-2 text-sm"
title={t("settings.researchMode.rePromptTitle")}
>
<p className="text-foreground font-medium">
{t("settings.researchMode.rePromptTitle")}
</p>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.researchMode.rePromptBody")}
</p>
<p>{t("settings.researchMode.rePromptBody")}</p>
<Button
size="sm"
variant="outline"
@@ -168,10 +168,10 @@ function ResearchModeCard() {
>
{t("settings.researchMode.rePromptCta")}
</Button>
</div>
</SettingsInfoTile>
)}
<div className="mt-4 flex items-start justify-between gap-3">
<div className="flex items-start justify-between gap-3">
<div className="space-y-0.5">
<p className="text-sm font-medium">
{t("settings.researchMode.toggleLabel")}
@@ -197,7 +197,7 @@ function ResearchModeCard() {
{errorMessage && (
<p
role="alert"
className="text-destructive mt-3 text-sm"
className="text-destructive text-sm"
data-slot="settings-research-mode-error"
>
{errorMessage}
@@ -262,65 +262,8 @@ function DataResetCard() {
icon={Trash2}
title={t("settings.dangerZone")}
description={t("settings.dangerZoneDescription")}
status={
<AlertDialog
open={confirmOpen}
onOpenChange={(open) => {
// Hold the dialog open while the destructive mutation is in
// flight so the in-dialog pending state stays visible and a
// stray backdrop tap can't dismiss it mid-request.
if (deleting) return;
setConfirmOpen(open);
}}
>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
size="sm"
disabled={deleting}
className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
>
{deleting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
{t("settings.dangerZone")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.dangerZoneConfirm")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.dangerZoneConfirmDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={(e) => {
e.preventDefault();
void handleDeleteAllData();
}}
disabled={deleting}
aria-busy={deleting || undefined}
data-slot="settings-data-reset-confirm"
>
{deleting && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.finalDelete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
}
/>
<p className="text-sm">{t("settings.dangerZoneDetail")}</p>
{msg && (
<p
@@ -330,6 +273,64 @@ function DataResetCard() {
{msg}
</p>
)}
<SettingsCardActions>
<AlertDialog
open={confirmOpen}
onOpenChange={(open) => {
// Hold the dialog open while the destructive mutation is in
// flight so the in-dialog pending state stays visible and a
// stray backdrop tap can't dismiss it mid-request.
if (deleting) return;
setConfirmOpen(open);
}}
>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
size="sm"
disabled={deleting}
className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
>
{deleting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
{t("settings.dangerZone")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.dangerZoneConfirm")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.dangerZoneConfirmDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={(e) => {
e.preventDefault();
void handleDeleteAllData();
}}
disabled={deleting}
aria-busy={deleting || undefined}
data-slot="settings-data-reset-confirm"
>
{deleting && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.finalDelete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</SettingsCardActions>
</SettingsCard>
);
}
@@ -399,66 +400,8 @@ function AccountDeleteCard() {
icon={Trash2}
title={t("settings.deleteAccountCardTitle")}
description={t("settings.deleteAccountCardDescription")}
status={
<AlertDialog
open={confirmOpen}
onOpenChange={(open) => {
// Hold the dialog open while the irreversible delete runs so
// the pending state stays on screen and a stray dismissal
// can't fire a second request.
if (deleting) return;
setConfirmOpen(open);
}}
>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
size="sm"
disabled={deleting}
className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
data-slot="settings-account-delete-trigger"
>
{deleting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
{t("settings.deleteAccountCta")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.deleteAccountConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.deleteAccountConfirmDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={(e) => {
e.preventDefault();
void handleDeleteAccount();
}}
disabled={deleting}
aria-busy={deleting || undefined}
data-slot="settings-account-delete-confirm"
>
{deleting && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.deleteAccountFinal")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
}
/>
<p className="text-sm">{t("settings.deleteAccountCardDetail")}</p>
{msg && (
<p
@@ -468,6 +411,65 @@ function AccountDeleteCard() {
{msg}
</p>
)}
<SettingsCardActions>
<AlertDialog
open={confirmOpen}
onOpenChange={(open) => {
// Hold the dialog open while the irreversible delete runs so
// the pending state stays on screen and a stray dismissal
// can't fire a second request.
if (deleting) return;
setConfirmOpen(open);
}}
>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
size="sm"
disabled={deleting}
className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
data-slot="settings-account-delete-trigger"
>
{deleting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
{t("settings.deleteAccountCta")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.deleteAccountConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.deleteAccountConfirmDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={(e) => {
e.preventDefault();
void handleDeleteAccount();
}}
disabled={deleting}
aria-busy={deleting || undefined}
data-slot="settings-account-delete-confirm"
>
{deleting && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.deleteAccountFinal")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</SettingsCardActions>
</SettingsCard>
);
}
@@ -140,13 +140,13 @@ export function AiInsightsCard({
}
return (
<SettingsCard className="space-y-4">
<SettingsCard>
{/* The card used to render an icon-only header row (Sparkles +
status badges, no title), which left the tile unanchored next
to its titled siblings. It now follows the shared
`SettingsCardHeader` contract: icon column, title + description
in the content column, status badges top-right — and the body
below indents to the title column (`pl-7`). */}
starts on the card edge like every other Settings card. */}
<SettingsCardHeader
icon={Sparkles}
title={t("settings.ai.providerCardTitle")}
@@ -159,7 +159,7 @@ export function AiInsightsCard({
}
/>
<div className="space-y-4 pl-7">
<div className="space-y-4">
<ActiveProviderSelect
value={selectedProvider}
onChange={pickProvider}
@@ -169,8 +169,8 @@ export function DisableCoachCard({
aria-live="polite"
className={
msgType === "error"
? "text-destructive mt-3 pl-7 text-xs"
: "text-muted-foreground mt-3 pl-7 text-xs"
? "text-destructive mt-3 text-xs"
: "text-muted-foreground mt-3 text-xs"
}
>
{msg}
@@ -44,7 +44,7 @@ export function AnamnesisSection() {
return (
<div className="space-y-6">
{(coachEnabled || insightsEnabled) && (
<SettingsCard className="space-y-4">
<SettingsCard>
<SettingsCardHeader
icon={EyeOff}
title={t("records.aiInclusion.cardTitle")}
@@ -55,7 +55,7 @@ export function AnamnesisSection() {
)}
{coachEnabled && (
<SettingsCard className="space-y-4">
<SettingsCard>
<SettingsCardHeader
icon={HeartPulse}
title={t("records.conditions.cardTitle")}
@@ -64,7 +64,7 @@ export function AnamnesisSection() {
<ConditionsManager />
</SettingsCard>
)}
<SettingsCard className="space-y-4">
<SettingsCard>
<SettingsCardHeader
icon={ClipboardList}
title={t("records.profileFacts.cardTitle")}
@@ -73,7 +73,7 @@ export function AnamnesisSection() {
<HealthProfileFactsManager />
</SettingsCard>
<SettingsCard className="space-y-4">
<SettingsCard>
<SettingsCardHeader
icon={ShieldAlert}
title={t("records.allergies.cardTitle")}
@@ -82,7 +82,7 @@ export function AnamnesisSection() {
<AllergyManager />
</SettingsCard>
<SettingsCard className="space-y-4">
<SettingsCard>
<SettingsCardHeader
icon={Users}
title={t("records.family.cardTitle")}
+4 -6
View File
@@ -75,7 +75,7 @@ function ApiEndpointsCard() {
/>
{/* Desktop table — verbatim layout for md+. */}
<div className="border-border mt-4 hidden overflow-x-auto rounded-lg border md:block">
<div className="border-border hidden overflow-x-auto rounded-lg border md:block">
<table className="w-full min-w-[760px] text-xs md:min-w-0">
<thead>
<tr className="bg-muted/40 text-muted-foreground border-b">
@@ -109,7 +109,7 @@ function ApiEndpointsCard() {
{/* Mobile card list — each endpoint stacks its columns vertically
with a labelled meta row. No horizontal scroll. */}
<ul
className="mt-4 space-y-2 md:hidden"
className="space-y-2 md:hidden"
data-testid="settings-api-endpoints-mobile-list"
>
{endpoints.map((endpoint) => (
@@ -206,16 +206,14 @@ function ApiTokensCard() {
}
/>
<div className="mt-4 space-y-4 pl-7">
<div className="space-y-4">
{/* Tokens are no longer minted here. The generic mint issued a
`medication:ingest` token that the ingest endpoint refused (it
gates on the per-medication grant too) and that every other
authenticated route accepted. The per-medication API-endpoint
toggle issues the pair that actually works, scoped to one
medication. This card lists and revokes. */}
<p className="text-muted-foreground text-sm">
{t("settings.tokenMintMovedDescription")}
</p>
<p className="text-sm">{t("settings.tokenMintMovedDescription")}</p>
<div>
<p className="mb-2 text-sm font-medium">
@@ -34,6 +34,7 @@ import {
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { EmptyState } from "@/components/ui/empty-state";
import { formatDateOrRelative } from "@/lib/format";
@@ -141,66 +142,14 @@ export function CoachMemorySection({
as="section"
aria-labelledby="settings-ai-coach-memory-title"
data-testid="settings-coach-memory-card"
className="space-y-4"
>
<SettingsCardHeader
icon={Brain}
titleId="settings-ai-coach-memory-title"
title={t("settings.ai.coachMemory.title")}
description={t("settings.ai.coachMemory.description")}
status={
facts.length > 0 ? (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
data-testid="settings-coach-memory-forget-all"
disabled={!isAuthenticated || forgetAll.isPending}
>
{forgetAll.isPending ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
) : (
<Trash2 className="size-4" aria-hidden />
)}
{t("settings.ai.coachMemory.forgetAll")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.ai.coachMemory.forgetAllConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.ai.coachMemory.forgetAllConfirmBody")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("settings.ai.coachMemory.cancel")}
</AlertDialogCancel>
<AlertDialogAction
data-testid="settings-coach-memory-forget-all-confirm"
disabled={forgetAll.isPending}
aria-busy={forgetAll.isPending || undefined}
onClick={() => forgetAll.mutate()}
>
{forgetAll.isPending && (
<Loader2 className="mr-1 size-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.ai.coachMemory.forgetAllConfirmAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : null
}
/>
<p className="text-sm">{t("settings.ai.coachMemory.detail")}</p>
{query.isError && (
<p
@@ -277,6 +226,58 @@ export function CoachMemorySection({
<p className="text-muted-foreground border-border border-t pt-3 text-xs">
{t("settings.ai.coachMemory.summaryNote")}
</p>
<SettingsCardActions>
{facts.length > 0 ? (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
data-testid="settings-coach-memory-forget-all"
disabled={!isAuthenticated || forgetAll.isPending}
>
{forgetAll.isPending ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
) : (
<Trash2 className="size-4" aria-hidden />
)}
{t("settings.ai.coachMemory.forgetAll")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.ai.coachMemory.forgetAllConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.ai.coachMemory.forgetAllConfirmBody")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("settings.ai.coachMemory.cancel")}
</AlertDialogCancel>
<AlertDialogAction
data-testid="settings-coach-memory-forget-all-confirm"
disabled={forgetAll.isPending}
aria-busy={forgetAll.isPending || undefined}
onClick={() => forgetAll.mutate()}
>
{forgetAll.isPending && (
<Loader2 className="mr-1 size-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.ai.coachMemory.forgetAllConfirmAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : null}
</SettingsCardActions>
</SettingsCard>
);
}
+4 -4
View File
@@ -225,7 +225,7 @@ export function CoachNudgeCard({
}
/>
{enabled && (
<div className="mt-4 space-y-3 pl-7">
<div className="space-y-3">
<p className="text-sm font-medium">
{t("notifications.coachNudge.groupsLabel")}
</p>
@@ -307,7 +307,7 @@ export function CoachNudgeCard({
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 mt-4 flex min-h-11 items-center justify-between gap-3 border-t pt-4">
<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")}
@@ -331,8 +331,8 @@ export function CoachNudgeCard({
aria-live="polite"
className={
msgType === "error"
? "text-destructive mt-3 pl-7 text-sm"
: "text-muted-foreground mt-3 pl-7 text-sm"
? "text-destructive mt-3 text-sm"
: "text-muted-foreground mt-3 text-sm"
}
>
{msg}
Loaded 100 of 167 files, more files were not shown because too many files have changed in this diff. Show more