From c8e8499472185d97f557f6b7dac7fc9fac3822c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 15:26:49 +0200 Subject: [PATCH 01/27] fix(whoop): degrade a per-resource 403 gracefully instead of parking the connection A 403 on a single WHOOP collection endpoint was classified reauth_required, which parked the whole connection at error_reauth and short-circuited every future sync. A lower-tier user missing one gated data class would brick the entire integration. Treat a per-resource collection 403 as a soft skip of that data class: log, return 0, and continue so sibling resources still sync. Reserve connection-wide reauth for a 401 (token rejected) and for a 403 on the token-refresh / profile path, which run outside the per-resource catch blocks and remain unchanged. Add isCollectionForbidden as the shared gate and cover both branches in tests. --- src/lib/whoop/__tests__/sync.test.ts | 81 ++++++++++++++++++++++++++++ src/lib/whoop/sync-cycle.ts | 10 ++++ src/lib/whoop/sync-recovery.ts | 10 ++++ src/lib/whoop/sync-sleep.ts | 10 ++++ src/lib/whoop/sync-workout.ts | 9 ++++ src/lib/whoop/sync.ts | 12 +++++ 6 files changed, 132 insertions(+) diff --git a/src/lib/whoop/__tests__/sync.test.ts b/src/lib/whoop/__tests__/sync.test.ts index 2ce3dcf4..e22b21f8 100644 --- a/src/lib/whoop/__tests__/sync.test.ts +++ b/src/lib/whoop/__tests__/sync.test.ts @@ -87,10 +87,13 @@ vi.mock("@/lib/logging/context", () => ({ import { getValidToken, incrementalStart, + isCollectionForbidden, upsertWhoopMeasurements, WHOOP_DEFAULT_OVERLAP_MS, WHOOP_RECOVERY_SLEEP_OVERLAP_MS, } from "../sync"; +import { WhoopApiError } from "../response-classifier"; +import { syncUserRecovery } from "../sync-recovery"; beforeEach(() => { vi.clearAllMocks(); @@ -247,3 +250,81 @@ describe("upsertWhoopMeasurements — idempotent upsert", () => { expect(a).toEqual(b); }); }); + +describe("isCollectionForbidden — tier degradation gate", () => { + it("is true only for a WhoopApiError carrying HTTP 403", () => { + const forbidden = new WhoopApiError({ + verb: "fetchRecoveries", + classification: "reauth_required", + httpStatus: 403, + reason: "http_403", + }); + expect(isCollectionForbidden(forbidden)).toBe(true); + }); + + it("is false for a 401 (genuine token reject → connection-wide reauth)", () => { + const unauthorized = new WhoopApiError({ + verb: "fetchRecoveries", + classification: "reauth_required", + httpStatus: 401, + reason: "http_401", + }); + expect(isCollectionForbidden(unauthorized)).toBe(false); + }); + + it("is false for a non-WhoopApiError", () => { + expect(isCollectionForbidden(new Error("network down"))).toBe(false); + expect(isCollectionForbidden("boom")).toBe(false); + }); +}); + +describe("per-resource 403 soft-skip vs reauth", () => { + beforeEach(() => { + prismaMock.whoopConnection.findUnique.mockResolvedValue({ + id: "conn1", + whoopUserId: "42", + accessToken: "enc(live-access)", + refreshToken: "enc(live-refresh)", + tokenExpiresAt: new Date(Date.now() + 60 * 60 * 1000), + lastSyncedAt: new Date("2026-06-01T00:00:00Z"), + }); + prismaMock.whoopConnection.update.mockResolvedValue({}); + }); + + it("a collection 403 soft-skips: returns 0, records NO failure (connection stays connected)", async () => { + fetchRecoveriesMock.mockRejectedValue( + new WhoopApiError({ + verb: "fetchRecoveries", + classification: "reauth_required", + httpStatus: 403, + reason: "http_403", + }), + ); + + const imported = await syncUserRecovery("user1"); + + expect(imported).toBe(0); + // No failure recorded → recordSyncFailure never parks the row at + // error_reauth, so the next syncUserWhoop does not short-circuit. + expect(recordSyncFailure).not.toHaveBeenCalled(); + }); + + it("a collection 401 still records a reauth failure and rethrows", async () => { + fetchRecoveriesMock.mockRejectedValue( + new WhoopApiError({ + verb: "fetchRecoveries", + classification: "reauth_required", + httpStatus: 401, + reason: "http_401", + }), + ); + + await expect(syncUserRecovery("user1")).rejects.toThrow(); + expect(recordSyncFailure).toHaveBeenCalledWith( + expect.objectContaining({ + integration: "whoop", + kind: "reauth_required", + }), + ); + }); +}); diff --git a/src/lib/whoop/sync-cycle.ts b/src/lib/whoop/sync-cycle.ts index 3eb585f1..5adc8df6 100644 --- a/src/lib/whoop/sync-cycle.ts +++ b/src/lib/whoop/sync-cycle.ts @@ -9,12 +9,14 @@ import { fetchCycles, mapCycle } from "./client"; import { getValidToken, incrementalStart, + isCollectionForbidden, markSynced, recordWhoopSyncFailure, upsertWhoopMeasurements, type WhoopMeasurementUpsert, } from "./sync"; import { prisma } from "@/lib/db"; +import { getEvent } from "@/lib/logging/context"; export async function syncUserCycle( userId: string, @@ -37,6 +39,14 @@ export async function syncUserCycle( try { records = await fetchCycles(tokenInfo.accessToken, { start }); } catch (err) { + // A per-resource 403 soft-skips this data class rather than parking the + // whole connection — sibling resources still sync. + if (isCollectionForbidden(err)) { + getEvent()?.addWarning( + `whoop cycle sync skipped for ${userId}: collection 403 (soft-skip)`, + ); + return 0; + } await recordWhoopSyncFailure(userId, err); throw err; } diff --git a/src/lib/whoop/sync-recovery.ts b/src/lib/whoop/sync-recovery.ts index 7c70cf37..f6fb4bd6 100644 --- a/src/lib/whoop/sync-recovery.ts +++ b/src/lib/whoop/sync-recovery.ts @@ -13,6 +13,7 @@ import { fetchRecoveries, mapRecovery } from "./client"; import { getValidToken, incrementalStart, + isCollectionForbidden, markSynced, recordWhoopSyncFailure, upsertWhoopMeasurements, @@ -20,6 +21,7 @@ import { type WhoopMeasurementUpsert, } from "./sync"; import { prisma } from "@/lib/db"; +import { getEvent } from "@/lib/logging/context"; export async function syncUserRecovery( userId: string, @@ -43,6 +45,14 @@ export async function syncUserRecovery( try { records = await fetchRecoveries(tokenInfo.accessToken, { start }); } catch (err) { + // A per-resource 403 soft-skips this data class rather than parking the + // whole connection — sibling resources still sync. + if (isCollectionForbidden(err)) { + getEvent()?.addWarning( + `whoop recovery sync skipped for ${userId}: collection 403 (soft-skip)`, + ); + return 0; + } await recordWhoopSyncFailure(userId, err); throw err; } diff --git a/src/lib/whoop/sync-sleep.ts b/src/lib/whoop/sync-sleep.ts index d3d1676b..deebe9d7 100644 --- a/src/lib/whoop/sync-sleep.ts +++ b/src/lib/whoop/sync-sleep.ts @@ -11,6 +11,7 @@ import { fetchSleeps, mapSleep } from "./client"; import { getValidToken, incrementalStart, + isCollectionForbidden, markSynced, recordWhoopSyncFailure, upsertWhoopMeasurements, @@ -18,6 +19,7 @@ import { type WhoopMeasurementUpsert, } from "./sync"; import { prisma } from "@/lib/db"; +import { getEvent } from "@/lib/logging/context"; export async function syncUserSleep( userId: string, @@ -41,6 +43,14 @@ export async function syncUserSleep( try { records = await fetchSleeps(tokenInfo.accessToken, { start }); } catch (err) { + // A per-resource 403 soft-skips this data class rather than parking the + // whole connection — sibling resources still sync. + if (isCollectionForbidden(err)) { + getEvent()?.addWarning( + `whoop sleep sync skipped for ${userId}: collection 403 (soft-skip)`, + ); + return 0; + } await recordWhoopSyncFailure(userId, err); throw err; } diff --git a/src/lib/whoop/sync-workout.ts b/src/lib/whoop/sync-workout.ts index 9c044160..296b3185 100644 --- a/src/lib/whoop/sync-workout.ts +++ b/src/lib/whoop/sync-workout.ts @@ -17,6 +17,7 @@ import { fetchWorkouts, KJ_TO_KCAL } from "./client"; import { getValidToken, incrementalStart, + isCollectionForbidden, markSynced, recordWhoopSyncFailure, } from "./sync"; @@ -52,6 +53,14 @@ export async function syncUserWorkout( try { records = await fetchWorkouts(tokenInfo.accessToken, { start }); } catch (err) { + // A per-resource 403 soft-skips this data class rather than parking the + // whole connection — sibling resources still sync. + if (isCollectionForbidden(err)) { + getEvent()?.addWarning( + `whoop workout sync skipped for ${userId}: collection 403 (soft-skip)`, + ); + return 0; + } await recordWhoopSyncFailure(userId, err); throw err; } diff --git a/src/lib/whoop/sync.ts b/src/lib/whoop/sync.ts index bd23b9c4..0ccbebb6 100644 --- a/src/lib/whoop/sync.ts +++ b/src/lib/whoop/sync.ts @@ -43,6 +43,18 @@ import { type WhoopClassification, } from "./response-classifier"; +/** + * True when a caught error is a per-resource collection 403 (forbidden). A 403 + * on a single data class is a tier/scope gate on THAT class — the right + * response is to soft-skip the class and keep the connection connected, NOT to + * park the whole integration at `error_reauth`. Reserve connection-wide reauth + * for a 401 (token rejected) and for a 403 on the token-refresh / profile path + * (a genuine grant revoke), which run outside the per-resource catch blocks. + */ +export function isCollectionForbidden(err: unknown): boolean { + return err instanceof WhoopApiError && err.httpStatus === 403; +} + /** Refresh the access token this many ms before `tokenExpiresAt`. */ const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000; From 0458c9aea838a87d3afc79c6c2f0fc275bb8c69a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 15:28:50 +0200 Subject: [PATCH 02/27] =?UTF-8?q?feat(whoop):=20ingest=20body=20measuremen?= =?UTF-8?q?t=20=E2=80=94=20weight,=20max-HR,=20profile=20height?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The body-measurement endpoint was fetched by a function nobody called, so WHOOP weight, max heart rate, and height never reached the database despite being documented in the mapping table. Add sync-body.ts and wire it into the per-user sync loop (and, through it, the backfill): - weight_kilogram → a WEIGHT Measurement (source = WHOOP) on the stable externalId whoop:body:weight with overwrite semantics. A body measurement is a single self-reported profile value, not a time series, so a re-sync updates the same row in place rather than accumulating one row per poll. measuredAt is the fetch time; the source-priority picker ranks a real scale above WHOOP. - max_heart_rate → WhoopConnection.maxHeartRate (a profile constant). - height_meter → User.heightCm (m→cm), written ONLY when the user has no height yet — a user-set height is never overwritten and height is never minted as a Measurement. Every write is field-by-field and idempotent across reruns. Document the body mapping and the watch-only blood-pressure stance in mapping.md. --- src/lib/whoop/__tests__/sync-body.test.ts | 247 ++++++++++++++++++++++ src/lib/whoop/client.ts | 48 +++++ src/lib/whoop/mapping.md | 16 +- src/lib/whoop/sync-body.ts | 124 +++++++++++ src/lib/whoop/sync.ts | 2 + 5 files changed, 436 insertions(+), 1 deletion(-) create mode 100644 src/lib/whoop/__tests__/sync-body.test.ts create mode 100644 src/lib/whoop/sync-body.ts diff --git a/src/lib/whoop/__tests__/sync-body.test.ts b/src/lib/whoop/__tests__/sync-body.test.ts new file mode 100644 index 00000000..0b213280 --- /dev/null +++ b/src/lib/whoop/__tests__/sync-body.test.ts @@ -0,0 +1,247 @@ +/** + * v1.11.3 — WHOOP body-measurement sync tests (mocked). Covers: + * - `mapBody` maps weight/max-HR/height (m→cm) and nulls absent fields; + * - weight lands as a single overwrite-in-place WEIGHT row (stable externalId); + * - `max_heart_rate` is persisted to WhoopConnection.maxHeartRate; + * - height seeds User.heightCm ONLY when currently null (never overwrites); + * - a collection 403 soft-skips (returns 0, records no failure). + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { WhoopApiError } from "../response-classifier"; +import type { WhoopMeasurementUpsert } from "../sync"; + +// ── Module mocks ──────────────────────────────────────────────── +const { + prismaMock, + getValidTokenMock, + fetchBodyMeasurementMock, + upsertWhoopMeasurementsMock, + markSyncedMock, + recordWhoopSyncFailureMock, +} = vi.hoisted(() => ({ + prismaMock: { + whoopConnection: { + update: vi.fn<(...a: unknown[]) => Promise>(async () => ({})), + }, + user: { + findUnique: vi.fn(), + update: vi.fn<(...a: unknown[]) => Promise>(async () => ({})), + }, + }, + getValidTokenMock: vi.fn(), + fetchBodyMeasurementMock: vi.fn(), + upsertWhoopMeasurementsMock: vi.fn<(...a: unknown[]) => Promise>( + async () => 1, + ), + markSyncedMock: vi.fn<(...a: unknown[]) => Promise>(async () => {}), + recordWhoopSyncFailureMock: vi.fn<(...a: unknown[]) => Promise>( + async () => {}, + ), +})); + +vi.mock("@/lib/db", () => ({ prisma: prismaMock })); + +vi.mock("@/lib/logging/context", () => ({ + getEvent: () => null, + annotate: () => {}, +})); + +vi.mock("../client", async (orig) => { + const actual = await orig(); + return { + ...actual, + fetchBodyMeasurement: (...a: unknown[]) => fetchBodyMeasurementMock(...a), + }; +}); + +vi.mock("../sync", async (orig) => { + const actual = await orig(); + return { + ...actual, + getValidToken: (...a: unknown[]) => getValidTokenMock(...a), + upsertWhoopMeasurements: (...a: unknown[]) => + upsertWhoopMeasurementsMock(...a), + markSynced: (...a: unknown[]) => markSyncedMock(...a), + recordWhoopSyncFailure: (...a: unknown[]) => + recordWhoopSyncFailureMock(...a), + }; +}); + +import { mapBody } from "../client"; +import { syncUserBody, WHOOP_BODY_WEIGHT_EXTERNAL_ID } from "../sync-body"; + +const TOKEN = { accessToken: "acc", connection: { id: "c1", whoopUserId: "9" } }; + +beforeEach(() => { + vi.clearAllMocks(); + getValidTokenMock.mockResolvedValue(TOKEN); + upsertWhoopMeasurementsMock.mockResolvedValue(1); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("mapBody", () => { + it("maps weight, max-HR, and height (m→cm)", () => { + const out = mapBody({ + weight_kilogram: 81.234, + max_heart_rate: 191.6, + height_meter: 1.83, + }); + expect(out.weightKg).toBe(81.23); + // max heart rate rounds to an integer (column is Int). + expect(out.maxHeartRate).toBe(192); + expect(out.heightCm).toBe(183); + }); + + it("nulls every field that WHOOP omits", () => { + expect(mapBody({})).toEqual({ + weightKg: null, + maxHeartRate: null, + heightCm: null, + }); + }); + + it("nulls weight when absent but keeps the present fields", () => { + const out = mapBody({ max_heart_rate: 180 }); + expect(out.weightKg).toBeNull(); + expect(out.maxHeartRate).toBe(180); + expect(out.heightCm).toBeNull(); + }); +}); + +describe("syncUserBody — weight overwrite", () => { + it("upserts weight against the stable externalId (no duplicate per sync)", async () => { + fetchBodyMeasurementMock.mockResolvedValue({ weight_kilogram: 80 }); + prismaMock.user.findUnique.mockResolvedValue({ heightCm: 170 }); + + const imported = await syncUserBody("user1"); + + expect(imported).toBe(1); + expect(upsertWhoopMeasurementsMock).toHaveBeenCalledTimes(1); + const readings = upsertWhoopMeasurementsMock.mock + .calls[0]![1] as WhoopMeasurementUpsert[]; + expect(readings).toHaveLength(1); + expect(readings[0]).toMatchObject({ + type: "WEIGHT", + value: 80, + unit: "kg", + externalId: WHOOP_BODY_WEIGHT_EXTERNAL_ID, + }); + // A second sync re-uses the SAME externalId — the upsert collapses it. + await syncUserBody("user1"); + const readings2 = upsertWhoopMeasurementsMock.mock + .calls[1]![1] as WhoopMeasurementUpsert[]; + expect(readings2[0]!.externalId).toBe(readings[0]!.externalId); + }); + + it("does not upsert a weight row when WHOOP omits weight", async () => { + fetchBodyMeasurementMock.mockResolvedValue({ max_heart_rate: 185 }); + prismaMock.user.findUnique.mockResolvedValue({ heightCm: 170 }); + + const imported = await syncUserBody("user1"); + + expect(imported).toBe(0); + expect(upsertWhoopMeasurementsMock).not.toHaveBeenCalled(); + }); +}); + +describe("syncUserBody — max heart rate", () => { + it("persists max_heart_rate to WhoopConnection.maxHeartRate", async () => { + fetchBodyMeasurementMock.mockResolvedValue({ max_heart_rate: 186 }); + prismaMock.user.findUnique.mockResolvedValue({ heightCm: 170 }); + + await syncUserBody("user1"); + + expect(prismaMock.whoopConnection.update).toHaveBeenCalledWith({ + where: { userId: "user1" }, + data: { maxHeartRate: 186 }, + }); + }); + + it("skips the connection write when max_heart_rate is absent", async () => { + fetchBodyMeasurementMock.mockResolvedValue({ weight_kilogram: 80 }); + prismaMock.user.findUnique.mockResolvedValue({ heightCm: 170 }); + + await syncUserBody("user1"); + + expect(prismaMock.whoopConnection.update).not.toHaveBeenCalled(); + }); +}); + +describe("syncUserBody — height seed (only when null)", () => { + it("writes User.heightCm when it is currently null", async () => { + fetchBodyMeasurementMock.mockResolvedValue({ height_meter: 1.8 }); + prismaMock.user.findUnique.mockResolvedValue({ heightCm: null }); + + await syncUserBody("user1"); + + expect(prismaMock.user.update).toHaveBeenCalledWith({ + where: { id: "user1" }, + data: { heightCm: 180 }, + }); + }); + + it("does NOT overwrite a user-set height", async () => { + fetchBodyMeasurementMock.mockResolvedValue({ height_meter: 1.9 }); + prismaMock.user.findUnique.mockResolvedValue({ heightCm: 175 }); + + await syncUserBody("user1"); + + expect(prismaMock.user.update).not.toHaveBeenCalled(); + }); + + it("never mints a Measurement for height", async () => { + fetchBodyMeasurementMock.mockResolvedValue({ height_meter: 1.8 }); + prismaMock.user.findUnique.mockResolvedValue({ heightCm: null }); + + const imported = await syncUserBody("user1"); + + // No weight present → nothing imported as a Measurement. + expect(imported).toBe(0); + expect(upsertWhoopMeasurementsMock).not.toHaveBeenCalled(); + }); +}); + +describe("syncUserBody — tier degradation", () => { + it("soft-skips a collection 403 (returns 0, records no failure)", async () => { + fetchBodyMeasurementMock.mockRejectedValue( + new WhoopApiError({ + verb: "fetchBodyMeasurement", + classification: "reauth_required", + httpStatus: 403, + reason: "http_403", + }), + ); + + const imported = await syncUserBody("user1"); + + expect(imported).toBe(0); + expect(recordWhoopSyncFailureMock).not.toHaveBeenCalled(); + expect(markSyncedMock).not.toHaveBeenCalled(); + }); + + it("records + rethrows a 401 (genuine reauth)", async () => { + fetchBodyMeasurementMock.mockRejectedValue( + new WhoopApiError({ + verb: "fetchBodyMeasurement", + classification: "reauth_required", + httpStatus: 401, + reason: "http_401", + }), + ); + + await expect(syncUserBody("user1")).rejects.toThrow(); + expect(recordWhoopSyncFailureMock).toHaveBeenCalledTimes(1); + }); + + it("returns 0 without fetching when there is no valid token", async () => { + getValidTokenMock.mockResolvedValue(null); + + const imported = await syncUserBody("user1"); + + expect(imported).toBe(0); + expect(fetchBodyMeasurementMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/whoop/client.ts b/src/lib/whoop/client.ts index 23519bde..a9e8fc68 100644 --- a/src/lib/whoop/client.ts +++ b/src/lib/whoop/client.ts @@ -610,6 +610,48 @@ export function mapCycle(c: WhoopCycle): MappedMeasurement[] { ]; } +/** Metres → centimetres (WHOOP `height_meter` → `User.heightCm`). */ +const M_TO_CM = 100; + +/** + * The three destinations a WHOOP body-measurement object fans out to. Unlike + * the collection mappers it does NOT emit `MappedMeasurement[]` directly, + * because only `weight` lands in `Measurement` — `maxHeartRate` is a profile + * constant on `WhoopConnection` and `heightCm` is a one-time `User` profile + * seed (written only when the user has no height yet). The sync layer routes + * each piece to its own table. + */ +export interface MappedBodyMeasurement { + /** Self-reported profile weight in kg, or null when WHOOP omits it. */ + weightKg: number | null; + /** Profile max heart rate in bpm, or null when WHOOP omits it. */ + maxHeartRate: number | null; + /** Profile height converted m→cm, or null when WHOOP omits it. */ + heightCm: number | null; +} + +/** + * Map a WHOOP body-measurement object onto its three destinations. The body + * measurement is a single self-reported profile value, not a timestamped + * reading, so the sync layer stamps the weight row's `measuredAt` with the + * fetch time. Every field is optional on the wire — an absent field maps to + * null and the sync layer skips it. + */ +export function mapBody(b: WhoopBodyMeasurement): MappedBodyMeasurement { + return { + weightKg: + typeof b.weight_kilogram === "number" ? round2(b.weight_kilogram) : null, + maxHeartRate: + typeof b.max_heart_rate === "number" + ? Math.round(b.max_heart_rate) + : null, + heightCm: + typeof b.height_meter === "number" + ? round2(b.height_meter * M_TO_CM) + : null, + }; +} + /** * Field→Measurement mapping table (mirror of `mapping.md`). Documents which * WHOOP source field becomes which MeasurementType + unit. Used as the @@ -685,4 +727,10 @@ export const WHOOP_FIELD_MAP: Record< unit: "bpm", note: "profile constant — stored on the connection, not a Measurement", }, + "body.height_meter": { + type: "User.heightCm", + unit: "cm", + factor: M_TO_CM, + note: "profile seed — written to User.heightCm only when it is null, never as a Measurement", + }, }; diff --git a/src/lib/whoop/mapping.md b/src/lib/whoop/mapping.md index 2660b288..0e402674 100644 --- a/src/lib/whoop/mapping.md +++ b/src/lib/whoop/mapping.md @@ -59,7 +59,21 @@ fetch + the score→energy conversion factor (`KJ_TO_KCAL`). ## Body / profile (single objects, no pagination) +Ingested by `sync-body.ts` (`syncUserBody`), wired into the `syncUserWhoop` +loop + the backfill. The body endpoint is a single object, not a paginated +collection. + | Source field | Destination | Unit | Note | |---|---|---|---| -| `body.weight_kilogram` | `WEIGHT` | kg | Picker ranks a real scale above WHOOP. | +| `body.weight_kilogram` | `WEIGHT` | kg | Source = WHOOP, STABLE externalId `whoop:body:weight` with overwrite semantics — a single self-reported profile value, not a time series, so a re-sync updates the same row rather than accumulating duplicates. `measuredAt` = the fetch time. Picker ranks a real scale above WHOOP. | | `body.max_heart_rate` | `WhoopConnection.maxHeartRate` | bpm | Profile constant — stored on the connection, not a `Measurement`. | +| `body.height_meter` | `User.heightCm` | cm | Profile seed (m→cm). Written ONLY when `User.heightCm` is currently null — never overwrites a user-set height, never minted as a `Measurement`. | + +## Blood pressure — watch-only + +Blood pressure is API-invisible: the WHOOP public developer API (v2) exposes no +BP field, endpoint, or scope. WHOOP's Blood Pressure Insights are WHOOP MG +hardware + WHOOP Life membership + cuff-calibrated and live only in the WHOOP +app. No server-side code. If WHOOP ever ships an API surface it reuses the +existing `BLOOD_PRESSURE_SYS` / `BLOOD_PRESSURE_DIA` types — no new enum, no +migration. Re-check developer.whoop.com/docs/api-changelog periodically. diff --git a/src/lib/whoop/sync-body.ts b/src/lib/whoop/sync-body.ts new file mode 100644 index 00000000..80b7ebb9 --- /dev/null +++ b/src/lib/whoop/sync-body.ts @@ -0,0 +1,124 @@ +/** + * WHOOP body-measurement sync. The body endpoint is a single object (not a + * paginated collection): one self-reported profile snapshot carrying weight, + * max heart rate, and height. It fans out to three destinations: + * + * - `weight_kilogram` → a `WEIGHT` `Measurement` (source = WHOOP) keyed on a + * STABLE externalId (`whoop:body:weight`). Because the profile weight is a + * single value rather than a time series, the externalId never carries the + * fetch time — a re-sync overwrites the same row in place rather than + * accumulating a duplicate per poll. `measuredAt` is the fetch time so the + * read-time source-priority picker (a real scale outranks WHOOP) and the + * trend view treat it as "as of now". + * - `max_heart_rate` → `WhoopConnection.maxHeartRate` (a profile constant, + * not a time series — lives on the connection row, not a `Measurement`). + * - `height_meter` → `User.heightCm`, converted m→cm, written ONLY when the + * user has no height yet. A user-set height is never overwritten, and + * height is never minted as a `Measurement`. + * + * Every write is field-by-field and idempotent across reruns. A per-resource + * 403 soft-skips this data class (returns 0, leaves the connection connected) + * rather than parking the whole integration — see `isCollectionForbidden`. + */ +import { fetchBodyMeasurement, mapBody } from "./client"; +import { + getValidToken, + isCollectionForbidden, + markSynced, + recordWhoopSyncFailure, + upsertWhoopMeasurements, + type WhoopMeasurementUpsert, +} from "./sync"; +import { prisma } from "@/lib/db"; +import { getEvent } from "@/lib/logging/context"; + +/** Stable externalId for the single WHOOP profile weight row (overwrite). */ +export const WHOOP_BODY_WEIGHT_EXTERNAL_ID = "whoop:body:weight"; + +/** + * The body measurement is a single profile snapshot — there is no incremental + * window to seek, so this sync ignores `fullSync` (the incremental vs backfill + * distinction the collection syncs honour). The `opts` parameter is accepted to + * keep the `syncUserWhoop` loop signature uniform. + */ +export async function syncUserBody( + userId: string, + opts: { fullSync?: boolean } = {}, +): Promise { + void opts; + + const tokenInfo = await getValidToken(userId); + if (!tokenInfo) return 0; + + let body: Awaited>; + try { + body = await fetchBodyMeasurement(tokenInfo.accessToken); + } catch (err) { + // A per-resource collection 403 soft-skips this data class: log + skip + + // return 0, leaving the connection connected so sibling resources still + // sync. A 401 (or any other failure) still records + rethrows so a genuine + // grant revoke parks the connection. + if (isCollectionForbidden(err)) { + getEvent()?.addWarning( + `whoop body sync skipped for ${userId}: collection 403 (soft-skip)`, + ); + return 0; + } + await recordWhoopSyncFailure(userId, err); + throw err; + } + + const mapped = mapBody(body); + const measuredAt = new Date(); + + // Weight → a single overwrite-in-place WEIGHT Measurement. + let imported = 0; + if (mapped.weightKg !== null) { + const reading: WhoopMeasurementUpsert = { + type: "WEIGHT", + value: mapped.weightKg, + unit: "kg", + measuredAt, + externalId: WHOOP_BODY_WEIGHT_EXTERNAL_ID, + }; + imported += await upsertWhoopMeasurements(userId, [reading]); + } + + // Max heart rate → WhoopConnection.maxHeartRate (profile constant). + if (mapped.maxHeartRate !== null) { + try { + await prisma.whoopConnection.update({ + where: { userId }, + data: { maxHeartRate: mapped.maxHeartRate }, + }); + } catch (err) { + getEvent()?.addWarning( + `whoop: failed to persist maxHeartRate for ${userId}: ${err}`, + ); + } + } + + // Height → User.heightCm, only when the user has no height yet. Never + // overwrite a user-set value; never mint a Measurement. + if (mapped.heightCm !== null) { + try { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { heightCm: true }, + }); + if (user && user.heightCm === null) { + await prisma.user.update({ + where: { id: userId }, + data: { heightCm: mapped.heightCm }, + }); + } + } catch (err) { + getEvent()?.addWarning( + `whoop: failed to seed heightCm for ${userId}: ${err}`, + ); + } + } + + await markSynced(userId); + return imported; +} diff --git a/src/lib/whoop/sync.ts b/src/lib/whoop/sync.ts index 0ccbebb6..51968251 100644 --- a/src/lib/whoop/sync.ts +++ b/src/lib/whoop/sync.ts @@ -290,6 +290,7 @@ export async function syncUserWhoop( const { syncUserSleep } = await import("./sync-sleep"); const { syncUserCycle } = await import("./sync-cycle"); const { syncUserWorkout } = await import("./sync-workout"); + const { syncUserBody } = await import("./sync-body"); let total = 0; let anyFailed = false; @@ -298,6 +299,7 @@ export async function syncUserWhoop( syncUserSleep, syncUserCycle, syncUserWorkout, + syncUserBody, ]) { try { total += await fn(userId, opts); From 35264eaf94c0184246c087a0a1479e2e32fa8dea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 15:18:32 +0200 Subject: [PATCH 03/27] feat(i18n): translate rhythm-event copy for es/fr/it/pl The insights.rhythmEvents event labels and verdicts shipped verbatim English in the four non-reference locales. Translate the 11 affected keys so the device-health-notifications surface reads in the user's language. --- messages/es.json | 22 +++++++++++----------- messages/fr.json | 22 +++++++++++----------- messages/it.json | 22 +++++++++++----------- messages/pl.json | 22 +++++++++++----------- 4 files changed, 44 insertions(+), 44 deletions(-) diff --git a/messages/es.json b/messages/es.json index a45cc621..45ff7b76 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2784,19 +2784,19 @@ "loadError": "No se pudieron cargar las notificaciones de tu dispositivo.", "disclaimer": "Estas son alertas que tu dispositivo generó con su propia detección integrada. Se muestran aquí solo para tu información. No es una evaluación médica de HealthLog, y HealthLog no diagnostica ninguna afección. Si una notificación te preocupa, habla con tu médico.", "event": { - "irregularRhythm": "Irregular rhythm notification", - "highHeartRate": "High heart rate notification", - "lowHeartRate": "Low heart rate notification", - "walkingSteadiness": "Walking steadiness alert", - "breathingDisturbance": "Breathing disturbance during sleep" + "irregularRhythm": "Notificación de ritmo irregular", + "highHeartRate": "Notificación de frecuencia cardíaca alta", + "lowHeartRate": "Notificación de frecuencia cardíaca baja", + "walkingSteadiness": "Alerta de estabilidad al caminar", + "breathingDisturbance": "Alteración respiratoria durante el sueño" }, "verdict": { - "irregular": "Your device flagged a possible irregular rhythm.", - "notDetected": "Your device did not flag an irregular rhythm.", - "inconclusive": "Your device could not make a reading.", - "low": "Your device flagged low walking steadiness.", - "veryLow": "Your device flagged very low walking steadiness.", - "fired": "Your device flagged this event." + "irregular": "Tu dispositivo detectó un posible ritmo irregular.", + "notDetected": "Tu dispositivo no detectó un ritmo irregular.", + "inconclusive": "Tu dispositivo no pudo realizar una medición.", + "low": "Tu dispositivo detectó una estabilidad al caminar baja.", + "veryLow": "Tu dispositivo detectó una estabilidad al caminar muy baja.", + "fired": "Tu dispositivo señaló este evento." } } }, diff --git a/messages/fr.json b/messages/fr.json index d79ae8d4..8b2b2dfb 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2784,19 +2784,19 @@ "loadError": "Impossible de charger les notifications de votre appareil.", "disclaimer": "Ce sont des alertes que votre appareil a produites avec sa propre détection intégrée. Elles sont affichées ici uniquement à titre informatif. Il ne s’agit pas d’une évaluation médicale de HealthLog, et HealthLog ne pose aucun diagnostic. Si une notification vous inquiète, parlez-en à votre médecin.", "event": { - "irregularRhythm": "Irregular rhythm notification", - "highHeartRate": "High heart rate notification", - "lowHeartRate": "Low heart rate notification", - "walkingSteadiness": "Walking steadiness alert", - "breathingDisturbance": "Breathing disturbance during sleep" + "irregularRhythm": "Notification de rythme irrégulier", + "highHeartRate": "Notification de fréquence cardiaque élevée", + "lowHeartRate": "Notification de fréquence cardiaque basse", + "walkingSteadiness": "Alerte de stabilité à la marche", + "breathingDisturbance": "Trouble respiratoire pendant le sommeil" }, "verdict": { - "irregular": "Your device flagged a possible irregular rhythm.", - "notDetected": "Your device did not flag an irregular rhythm.", - "inconclusive": "Your device could not make a reading.", - "low": "Your device flagged low walking steadiness.", - "veryLow": "Your device flagged very low walking steadiness.", - "fired": "Your device flagged this event." + "irregular": "Votre appareil a détecté un possible rythme irrégulier.", + "notDetected": "Votre appareil n’a pas détecté de rythme irrégulier.", + "inconclusive": "Votre appareil n’a pas pu effectuer de mesure.", + "low": "Votre appareil a détecté une faible stabilité à la marche.", + "veryLow": "Votre appareil a détecté une très faible stabilité à la marche.", + "fired": "Votre appareil a signalé cet événement." } } }, diff --git a/messages/it.json b/messages/it.json index e25ee136..0024fc75 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2784,19 +2784,19 @@ "loadError": "Impossibile caricare le notifiche del dispositivo.", "disclaimer": "Questi sono avvisi generati dal tuo dispositivo con il proprio rilevamento integrato. Sono mostrati qui solo a scopo informativo. Non è una valutazione medica da parte di HealthLog, e HealthLog non diagnostica alcuna condizione. Se una notifica ti preoccupa, parla con il tuo medico.", "event": { - "irregularRhythm": "Irregular rhythm notification", - "highHeartRate": "High heart rate notification", - "lowHeartRate": "Low heart rate notification", - "walkingSteadiness": "Walking steadiness alert", - "breathingDisturbance": "Breathing disturbance during sleep" + "irregularRhythm": "Notifica di ritmo irregolare", + "highHeartRate": "Notifica di frequenza cardiaca alta", + "lowHeartRate": "Notifica di frequenza cardiaca bassa", + "walkingSteadiness": "Avviso di stabilità nella camminata", + "breathingDisturbance": "Disturbo respiratorio durante il sonno" }, "verdict": { - "irregular": "Your device flagged a possible irregular rhythm.", - "notDetected": "Your device did not flag an irregular rhythm.", - "inconclusive": "Your device could not make a reading.", - "low": "Your device flagged low walking steadiness.", - "veryLow": "Your device flagged very low walking steadiness.", - "fired": "Your device flagged this event." + "irregular": "Il tuo dispositivo ha rilevato un possibile ritmo irregolare.", + "notDetected": "Il tuo dispositivo non ha rilevato un ritmo irregolare.", + "inconclusive": "Il tuo dispositivo non è riuscito a effettuare una misurazione.", + "low": "Il tuo dispositivo ha rilevato una bassa stabilità nella camminata.", + "veryLow": "Il tuo dispositivo ha rilevato una stabilità nella camminata molto bassa.", + "fired": "Il tuo dispositivo ha segnalato questo evento." } } }, diff --git a/messages/pl.json b/messages/pl.json index 39097727..ada1a0cc 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2784,19 +2784,19 @@ "loadError": "Nie udało się wczytać powiadomień urządzenia.", "disclaimer": "To alerty wygenerowane przez Twoje urządzenie za pomocą jego wbudowanego wykrywania. Są tu pokazywane wyłącznie dla Twojej informacji. To nie jest ocena medyczna wykonana przez HealthLog, a HealthLog nie diagnozuje żadnych schorzeń. Jeśli powiadomienie Cię niepokoi, skontaktuj się z lekarzem.", "event": { - "irregularRhythm": "Irregular rhythm notification", - "highHeartRate": "High heart rate notification", - "lowHeartRate": "Low heart rate notification", - "walkingSteadiness": "Walking steadiness alert", - "breathingDisturbance": "Breathing disturbance during sleep" + "irregularRhythm": "Powiadomienie o nieregularnym rytmie", + "highHeartRate": "Powiadomienie o wysokim tętnie", + "lowHeartRate": "Powiadomienie o niskim tętnie", + "walkingSteadiness": "Alert o stabilności chodu", + "breathingDisturbance": "Zaburzenie oddychania podczas snu" }, "verdict": { - "irregular": "Your device flagged a possible irregular rhythm.", - "notDetected": "Your device did not flag an irregular rhythm.", - "inconclusive": "Your device could not make a reading.", - "low": "Your device flagged low walking steadiness.", - "veryLow": "Your device flagged very low walking steadiness.", - "fired": "Your device flagged this event." + "irregular": "Twoje urządzenie wykryło możliwy nieregularny rytm.", + "notDetected": "Twoje urządzenie nie wykryło nieregularnego rytmu.", + "inconclusive": "Twoje urządzenie nie mogło wykonać pomiaru.", + "low": "Twoje urządzenie wykryło niską stabilność chodu.", + "veryLow": "Twoje urządzenie wykryło bardzo niską stabilność chodu.", + "fired": "Twoje urządzenie zgłosiło to zdarzenie." } } }, From e8793c82128daac964fa8631c5c0efb61fa9a49c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 15:25:37 +0200 Subject: [PATCH 04/27] feat(i18n): translate Settings copy for es/fr/it/pl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Large stretches of the Settings surface — account, passkeys, notification channels, AI provider configuration, Withings, API tokens, export, and the danger zone — rendered verbatim English in the four non-reference locales. Translate the genuine prose and labels, leaving brand names, units, and protocol tokens as-is. --- messages/es.json | 568 +++++++++++++++++++++++----------------------- messages/fr.json | 566 +++++++++++++++++++++++----------------------- messages/it.json | 570 +++++++++++++++++++++++------------------------ messages/pl.json | 564 +++++++++++++++++++++++----------------------- 4 files changed, 1134 insertions(+), 1134 deletions(-) diff --git a/messages/es.json b/messages/es.json index 45ff7b76..045203dc 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2998,24 +2998,24 @@ "disableError": "No se pudo desactivar el modo Investigación. Inténtalo de nuevo." }, "shell": { - "sectionsNav": "Settings sections" + "sectionsNav": "Secciones de ajustes" }, "sections": { "account": { "title": "Cuenta", - "description": "Profile, password, passkeys, and your onboarding tour." + "description": "Perfil, contraseña, claves de acceso y tu recorrido de incorporación." }, "integrations": { "title": "Integraciones", - "description": "Withings, moodLog, and other connected services." + "description": "Withings, moodLog y otros servicios conectados." }, "notifications": { "title": "Notificaciones", - "description": "Live overview of every configured channel." + "description": "Vista en vivo de cada canal configurado." }, "dashboard": { "title": "Panel", - "description": "Tile layout and order." + "description": "Diseño y orden de los mosaicos." }, "thresholds": { "title": "Objetivos", @@ -3078,12 +3078,12 @@ } }, "ai": { - "title": "AI Insights", - "description": "Provider, model, key." + "title": "Análisis con IA", + "description": "Proveedor, modelo, clave." }, "api": { "title": "API & Tokens", - "description": "Bearer tokens for your own scripts and apps — log measurements or medication intake from anywhere." + "description": "Tokens Bearer para tus propios scripts y apps — registra mediciones o tomas de medicación desde cualquier lugar." }, "advanced": { "title": "Avanzado", @@ -3091,7 +3091,7 @@ }, "export": { "title": "Exportar", - "description": "Download your health data as PDF, CSV, or a full JSON backup.", + "description": "Descarga tus datos de salud como PDF, CSV o una copia de seguridad JSON completa.", "otherOptionsHeading": "Otras opciones de exportación", "hero": { "eyebrow": "Cita médica", @@ -3100,39 +3100,39 @@ "formatHint": "PDF · listo para imprimir" }, "actions": { - "download": "Download" + "download": "Descargar" }, "filters": { "since": "Desde", - "until": "Until" + "until": "Hasta" }, "cards": { "doctorReport": { - "title": "Doctor Report", - "description": "Printable PDF for the doctor's appointment — vitals, BMI, BP classification, medication compliance, mood." + "title": "Informe médico", + "description": "PDF imprimible para la cita médica — constantes vitales, IMC, clasificación de la presión arterial, cumplimiento de la medicación, estado de ánimo." }, "measurementsCsv": { - "title": "Measurements", - "description": "Weight, blood pressure, pulse, glucose, and every other measurement as comma-separated values." + "title": "Mediciones", + "description": "Peso, presión arterial, pulso, glucosa y todas las demás mediciones como valores separados por comas." }, "medicationsCsv": { "title": "Medicamentos", - "description": "Your medication list with dosage, schedules, and (optionally) the full intake history.", - "includeIntake": "Include intake history" + "description": "Tu lista de medicación con dosis, horarios y (opcionalmente) el historial completo de tomas.", + "includeIntake": "Incluir historial de tomas" }, "moodCsv": { "title": "Estado de ánimo", - "description": "Daily mood entries with score, tags, and timestamps." + "description": "Entradas de estado de ánimo diarias con puntuación, etiquetas y marcas de tiempo." }, "fullBackup": { - "title": "Full backup", - "description": "Single JSON file with everything — same shape as the weekly auto-backup. Useful for self-restore via admin upload." + "title": "Copia completa", + "description": "Un único archivo JSON con todo — el mismo formato que la copia automática semanal. Útil para restaurar tú mismo mediante la carga de administrador." } } }, "about": { - "title": "About", - "description": "Version, license, links." + "title": "Acerca de", + "description": "Versión, licencia, enlaces." }, "sharing": { "title": "Compartir", @@ -3141,20 +3141,20 @@ }, "profile": "Perfil", "language": "Idioma", - "languageDescription": "Choose the display language of the application.", + "languageDescription": "Elige el idioma de visualización de la aplicación.", "username": "Nombre de usuario", - "height": "Height (cm)", + "height": "Altura (cm)", "dateOfBirth": "Fecha de nacimiento", - "dateOfBirthHint": "Used for automatic blood pressure target calculations.", + "dateOfBirthHint": "Se usa para el cálculo automático de los objetivos de presión arterial.", "gender": "Sexo", - "genderNone": "Not specified", + "genderNone": "Sin especificar", "genderMale": "Hombre", "genderFemale": "Mujer", - "genderHint": "Used for gender-specific target values.", + "genderHint": "Se usa para valores objetivo específicos por sexo.", "timezone": "Zona horaria", - "timezoneHint": "Used for chart axis labels, reminder times, export timestamps, and Coach context. Stored data is unchanged.", - "timezoneInvalid": "Not a valid IANA timezone.", - "profileSaved": "Profile saved", + "timezoneHint": "Se usa para las etiquetas de los ejes de los gráficos, las horas de recordatorio, las marcas de tiempo de exportación y el contexto del Coach. Los datos almacenados no cambian.", + "timezoneInvalid": "No es una zona horaria IANA válida.", + "profileSaved": "Perfil guardado", "avatar": { "title": "Foto de perfil", "description": "Sube una foto que se almacena en tu propio servidor. Aparece en toda la aplicación en lugar de un servicio de avatares externo.", @@ -3168,174 +3168,174 @@ "tooLarge": "La imagen supera el límite de 2 MB.", "error": "No se pudo actualizar la foto de perfil. Inténtalo de nuevo." }, - "changePassword": "Change password", - "changePasswordDescription": "Replace your current password with a new one.", - "passwordReset": "Password reset", - "currentPassword": "Current password", - "newPassword": "New password", - "confirmNewPassword": "Confirm new password", - "passwordMismatch": "New passwords do not match", - "passwordUpdated": "Password updated successfully", + "changePassword": "Cambiar contraseña", + "changePasswordDescription": "Sustituye tu contraseña actual por una nueva.", + "passwordReset": "Restablecer contraseña", + "currentPassword": "Contraseña actual", + "newPassword": "Nueva contraseña", + "confirmNewPassword": "Confirmar nueva contraseña", + "passwordMismatch": "Las nuevas contraseñas no coinciden", + "passwordUpdated": "Contraseña actualizada correctamente", "passkeys": "Passkeys", - "registeredPasskeys": "Registered Passkeys", - "passkeysDescription": "Passkeys allow secure login without a password.", + "registeredPasskeys": "Claves de acceso registradas", + "passkeysDescription": "Las claves de acceso permiten iniciar sesión de forma segura sin contraseña.", "passkeyName": "Nombre", - "passkeyDevice": "Device", + "passkeyDevice": "Dispositivo", "passkeyBackup": "Backup", - "passkeyCreated": "Created", + "passkeyCreated": "Creada", "passkeyActions": "Acciones", - "noPasskeys": "No passkeys registered.", - "addPasskey": "Add passkey", - "passkeyAdded": "Passkey added successfully!", - "passkeyOptionsError": "Could not load passkey options", - "passkeyRegistrationFailed": "Passkey registration failed", - "passkeyRegistrationCancelled": "Passkey registration cancelled", - "passkeyNotSupported": "Your browser or device doesn't support passkeys yet. Try a different browser or device.", - "passkeyAlreadyRegistered": "This device is already registered as a passkey for your account.", - "passkeySecurityBlocked": "Your browser blocked the passkey request for security reasons. Make sure you're on a trusted HTTPS origin and try again.", - "passkeyTimeout": "The passkey prompt timed out. Please try again.", - "passkeyUnknownError": "Passkey registration failed: {message}", - "deletePasskey": "Delete passkey?", - "deletePasskeyDescription": "The passkey will be permanently deleted.", - "singleDevice": "Single device", - "multiDevice": "Multi-device", - "backedUp": "backed up", + "noPasskeys": "No hay claves de acceso registradas.", + "addPasskey": "Añadir clave de acceso", + "passkeyAdded": "¡Clave de acceso añadida correctamente!", + "passkeyOptionsError": "No se pudieron cargar las opciones de la clave de acceso", + "passkeyRegistrationFailed": "Falló el registro de la clave de acceso", + "passkeyRegistrationCancelled": "Registro de la clave de acceso cancelado", + "passkeyNotSupported": "Tu navegador o dispositivo aún no admite claves de acceso. Prueba con otro navegador o dispositivo.", + "passkeyAlreadyRegistered": "Este dispositivo ya está registrado como clave de acceso de tu cuenta.", + "passkeySecurityBlocked": "Tu navegador bloqueó la solicitud de clave de acceso por motivos de seguridad. Asegúrate de estar en un origen HTTPS de confianza e inténtalo de nuevo.", + "passkeyTimeout": "La solicitud de la clave de acceso expiró. Inténtalo de nuevo.", + "passkeyUnknownError": "Falló el registro de la clave de acceso: {message}", + "deletePasskey": "¿Eliminar clave de acceso?", + "deletePasskeyDescription": "La clave de acceso se eliminará de forma permanente.", + "singleDevice": "Un solo dispositivo", + "multiDevice": "Varios dispositivos", + "backedUp": "con copia de seguridad", "telegram": "Telegram Notifications", "telegramDescription": "Get reminders for missed medication intake via Telegram.", - "telegramSaved": "Telegram settings saved", - "botToken": "Bot Token", - "chatId": "Chat ID", - "enableNotifications": "Enable notifications", - "testMessage": "Test message", - "testSent": "Test message sent!", - "telegramStep1": "1. Create a bot via @BotFather in Telegram and copy the token.", - "telegramStep2": "2. Send /start to your bot to activate the chat.", - "telegramStep3": "3. Find your Chat ID via @userinfobot or the Bot API.", + "telegramSaved": "Ajustes de Telegram guardados", + "botToken": "Token del bot", + "chatId": "ID del chat", + "enableNotifications": "Activar notificaciones", + "testMessage": "Mensaje de prueba", + "testSent": "¡Mensaje de prueba enviado!", + "telegramStep1": "1. Crea un bot con @BotFather en Telegram y copia el token.", + "telegramStep2": "2. Envía /start a tu bot para activar el chat.", + "telegramStep3": "3. Obtén tu ID del chat con @userinfobot o la API del bot.", "ntfy": "ntfy", - "ntfyDescription": "Notifications via ntfy (self-hosted or ntfy.sh).", - "ntfyEnable": "Enable ntfy", - "ntfyServer": "Server URL", + "ntfyDescription": "Notificaciones a través de ntfy (autoalojado o ntfy.sh).", + "ntfyEnable": "Activar ntfy", + "ntfyServer": "URL del servidor", "ntfyTopic": "Topic", - "ntfyAuthToken": "Auth Token (optional)", - "ntfyAuthTokenHint": "Only needed for private topics with access control.", - "webPush": "Browser Push", - "webPushDescription": "Receive notifications directly in your browser, even when HealthLog is not open.", - "webPushNotSupported": "Your browser does not support push notifications.", - "webPushDenied": "Push notifications are blocked. Allow them in your browser settings.", - "webPushNotConfigured": "Web Push is not configured on the server.", - "webPushSubscribe": "Enable push", - "webPushUnsubscribe": "Disable push", - "webPushSubscribed": "Push notifications enabled!", - "webPushUnsubscribed": "Push notifications disabled.", - "webPushSubscribeFailed": "Activation failed", + "ntfyAuthToken": "Token de autenticación (opcional)", + "ntfyAuthTokenHint": "Solo necesario para temas privados con control de acceso.", + "webPush": "Push del navegador", + "webPushDescription": "Recibe notificaciones directamente en tu navegador, incluso cuando HealthLog no está abierto.", + "webPushNotSupported": "Tu navegador no admite notificaciones push.", + "webPushDenied": "Las notificaciones push están bloqueadas. Permítelas en los ajustes de tu navegador.", + "webPushNotConfigured": "Web Push no está configurado en el servidor.", + "webPushSubscribe": "Activar push", + "webPushUnsubscribe": "Desactivar push", + "webPushSubscribed": "¡Notificaciones push activadas!", + "webPushUnsubscribed": "Notificaciones push desactivadas.", + "webPushSubscribeFailed": "Falló la activación", "webPushActive": "Activo", - "kiInsightsDescription": "Optional: Save your OpenAI key for daily, automatic evaluations in Insights.", - "codexConnected": "ChatGPT connected successfully! Insights are now active.", - "codexDisconnected": "ChatGPT connection removed.", - "codexConnectionFailed": "ChatGPT connection failed. Please try again.", - "rawData": "Send raw data", - "rawDataOnDescription": "Aggregated metrics plus anonymized raw points from the last 30 days are sent. This includes per-metric measurements (for example weight, blood pressure, pulse) with time context so the provider can detect patterns, outliers, and correlations more reliably. Name, email, and direct account identifiers are not transmitted.", - "rawDataOffDescription": "Only summarized metrics are sent (for example averages, trends, minimum/maximum). No individual points and no exact time context. This is more privacy-preserving, but the provider can be less precise for short-term patterns and fluctuations.", - "rawDataWarning": "Raw mode enabled: the provider receives additional anonymized points from the last 30 days for higher analysis accuracy.", - "regenerateInsights": "Regenerate reports", - "regenerateSuccess": "Reports regenerated successfully", - "regenerateRateLimit": "Please wait — you've hit the hourly limit for analyses.", - "lastGeneratedAt": "Last generated", + "kiInsightsDescription": "Opcional: guarda tu clave de OpenAI para análisis diarios y automáticos en Insights.", + "codexConnected": "¡ChatGPT conectado correctamente! Insights ya está activo.", + "codexDisconnected": "Conexión con ChatGPT eliminada.", + "codexConnectionFailed": "Falló la conexión con ChatGPT. Inténtalo de nuevo.", + "rawData": "Enviar datos en bruto", + "rawDataOnDescription": "Se envían métricas agregadas más puntos en bruto anonimizados de los últimos 30 días. Esto incluye mediciones por métrica (por ejemplo, peso, presión arterial, pulso) con contexto temporal para que el proveedor pueda detectar patrones, valores atípicos y correlaciones de forma más fiable. No se transmiten el nombre, el correo electrónico ni los identificadores directos de la cuenta.", + "rawDataOffDescription": "Solo se envían métricas resumidas (por ejemplo, promedios, tendencias, mínimo/máximo). Sin puntos individuales ni contexto temporal exacto. Esto preserva mejor la privacidad, pero el proveedor puede ser menos preciso con los patrones y las fluctuaciones a corto plazo.", + "rawDataWarning": "Modo en bruto activado: el proveedor recibe puntos anonimizados adicionales de los últimos 30 días para un análisis más preciso.", + "regenerateInsights": "Regenerar informes", + "regenerateSuccess": "Informes regenerados correctamente", + "regenerateRateLimit": "Espera un momento — has alcanzado el límite por hora de análisis.", + "lastGeneratedAt": "Generado por última vez", "ai": { - "chatgptConnectedBadge": "ChatGPT connected", - "adminAiActiveBadge": "Admin provider active", - "connectionExpiredBadge": "Connection expired", - "connectedSince": "Connected since {when}.", - "deviceCodeHeading": "Finish connecting on chatgpt.com", - "deviceCodeStep1": "Open this link on any device:", - "deviceCodeStep2": "Enter this one-time code:", - "deviceCodeStep3": "Approve the connection — this page updates automatically.", + "chatgptConnectedBadge": "ChatGPT conectado", + "adminAiActiveBadge": "Proveedor de administrador activo", + "connectionExpiredBadge": "Conexión caducada", + "connectedSince": "Conectado desde {when}.", + "deviceCodeHeading": "Termina de conectar en chatgpt.com", + "deviceCodeStep1": "Abre este enlace en cualquier dispositivo:", + "deviceCodeStep2": "Introduce este código de un solo uso:", + "deviceCodeStep3": "Aprueba la conexión — esta página se actualiza automáticamente.", "deviceCodeCopy": "Copiar", - "deviceCodeWaiting": "Waiting for approval…", + "deviceCodeWaiting": "Esperando aprobación…", "deviceCodeCancel": "Cancelar", - "oauthNotConfigured": "ChatGPT OAuth is not configured on this instance — use your own API key below instead.", - "modelLabel": "Model", - "modelOptionDefault": "— Default —", - "modelOptionCustom": "Custom…", - "customModelLabel": "Custom model name", - "anthropicKeyLabel": "Anthropic API key", + "oauthNotConfigured": "El OAuth de ChatGPT no está configurado en esta instancia — usa tu propia clave de API a continuación.", + "modelLabel": "Modelo", + "modelOptionDefault": "— Predeterminado —", + "modelOptionCustom": "Personalizado…", + "customModelLabel": "Nombre del modelo personalizado", + "anthropicKeyLabel": "Clave de API de Anthropic", "baseUrlLabel": "Base URL", - "localKeyLabel": "API key (optional)", - "savedPreview": "(saved {preview})", - "savedShort": "(saved)", + "localKeyLabel": "Clave de API (opcional)", + "savedPreview": "(guardada {preview})", + "savedShort": "(guardada)", "saveCta": "Guardar", "saved": "Guardado", - "saveFailed": "Save failed", + "saveFailed": "Falló el guardado", "errorGeneric": "Error", "providerChain": { "types": { "codex": "ChatGPT (Codex)", - "openai": "OpenAI (your key)", + "openai": "OpenAI (tu clave)", "anthropic": "Anthropic (Claude)", - "local": "Local model", - "admin-openai": "Admin OpenAI" + "local": "Modelo local", + "admin-openai": "OpenAI de administrador" }, - "title": "Fallback chain", - "description": "If the primary provider fails, HealthLog walks the chain in order. Drag the rows to reorder, toggle the switch to disable a provider without removing it.", - "moveUp": "Move up", - "moveDown": "Move down", - "removeFromChain": "Remove from chain", - "addProvider": "Add provider", - "addNoneAvailable": "All providers already in chain", - "saveOrder": "Save chain order", - "saved": "Chain saved", - "saveFailed": "Saving the chain failed", - "resetDefaults": "Reset to defaults", - "resetConfirmTitle": "Reset chain to defaults?", - "resetConfirmBody": "The chain reverts to Codex → OpenAI → Anthropic → Local → Admin OpenAI. Your saved credentials are not touched." - }, - "activeProviderHeading": "Active provider", - "activeProviderBody": "Pick the provider you want to use first. The form below configures only the selected provider; the fallback chain at the bottom decides what happens if it fails.", - "activeProviderLabel": "Primary provider", - "providerConfigTitle": "Provider configuration", + "title": "Cadena de respaldo", + "description": "Si el proveedor principal falla, HealthLog recorre la cadena en orden. Arrastra las filas para reordenarlas; usa el interruptor para desactivar un proveedor sin eliminarlo.", + "moveUp": "Subir", + "moveDown": "Bajar", + "removeFromChain": "Quitar de la cadena", + "addProvider": "Añadir proveedor", + "addNoneAvailable": "Todos los proveedores ya están en la cadena", + "saveOrder": "Guardar el orden de la cadena", + "saved": "Cadena guardada", + "saveFailed": "Falló el guardado de la cadena", + "resetDefaults": "Restablecer", + "resetConfirmTitle": "¿Restablecer la cadena a los valores predeterminados?", + "resetConfirmBody": "La cadena vuelve a Codex → OpenAI → Anthropic → Local → OpenAI de administrador. Tus credenciales guardadas no se modifican." + }, + "activeProviderHeading": "Proveedor activo", + "activeProviderBody": "Elige el proveedor que quieres usar primero. El formulario de abajo configura únicamente el proveedor seleccionado; la cadena de respaldo del final decide qué ocurre si falla.", + "activeProviderLabel": "Proveedor principal", + "providerConfigTitle": "Configuración del proveedor", "providerSelect": { - "codex": "ChatGPT account (Codex)", - "openai": "OpenAI (your API key)", + "codex": "Cuenta de ChatGPT (Codex)", + "openai": "OpenAI (tu clave de API)", "anthropic": "Anthropic (Claude)", - "local": "Local model (OpenAI-compatible)", - "admin-openai": "Admin-provided OpenAI" + "local": "Modelo local (compatible con OpenAI)", + "admin-openai": "OpenAI proporcionado por el administrador" }, "openai": { - "modelSelect": "Model", - "modelOptionCustom": "Custom slug…", - "modelCustomLabel": "Custom model slug", + "modelSelect": "Modelo", + "modelOptionCustom": "Slug personalizado…", + "modelCustomLabel": "Slug del modelo personalizado", "modelCustomPlaceholder": "gpt-5", - "baseUrlLabel": "Base URL (advanced)", + "baseUrlLabel": "URL base (avanzado)", "baseUrlPlaceholder": "https://api.openai.com/v1", - "baseUrlHelp": "Override only when using an OpenAI-compatible gateway. Leave blank for OpenAI itself.", - "showAdvanced": "Show advanced", - "hideAdvanced": "Hide advanced", - "apiKey": "API key", + "baseUrlHelp": "Sobrescribe solo cuando uses una pasarela compatible con OpenAI. Déjalo en blanco para OpenAI directamente.", + "showAdvanced": "Mostrar opciones avanzadas", + "hideAdvanced": "Ocultar opciones avanzadas", + "apiKey": "Clave de API", "apiKeyPlaceholder": "sk-…" }, "codex": { - "statusConnected": "Connected", - "statusDisconnected": "Not connected", - "statusExpired": "Connection expired", - "connectButton": "Connect with ChatGPT", - "disconnectButton": "Disconnect", - "modelSlugLabel": "Model slug", - "modelSlugBody": "Codex uses the model your ChatGPT subscription routes to. The CODEX_MODEL environment variable lets ops override this on the instance.", - "lastInsight": "Last insight: {when}" + "statusConnected": "Conectado", + "statusDisconnected": "No conectado", + "statusExpired": "Conexión caducada", + "connectButton": "Conectar con ChatGPT", + "disconnectButton": "Desconectar", + "modelSlugLabel": "Slug del modelo", + "modelSlugBody": "Codex usa el modelo al que enruta tu suscripción de ChatGPT. La variable de entorno CODEX_MODEL permite al operador sobrescribirlo en la instancia.", + "lastInsight": "Último análisis: {when}" }, "adminOpenai": { - "title": "Admin OpenAI", - "body": "Operator-provided OpenAI key. Used as a last-ditch fallback when no personal provider is configured. There is nothing to configure on this row — visibility means the operator has set it up.", - "notConfigured": "The operator has not configured a shared OpenAI key on this instance." + "title": "OpenAI de administrador", + "body": "Clave de OpenAI proporcionada por el operador. Se usa como último recurso cuando no hay ningún proveedor personal configurado. No hay nada que configurar en esta fila — su visibilidad significa que el operador la ha habilitado.", + "notConfigured": "El operador no ha configurado una clave de OpenAI compartida en esta instancia." }, - "testProvider": "Test active provider", + "testProvider": "Probar el proveedor activo", "testSuccess": "OK — {provider} ({model})", - "testFailedShort": "Test failed: {message}", - "testUnexpectedResponse": "AI provider connection failed — unexpected response from the server.", - "testReasonCredentials": "Provider rejected the credentials — re-authenticate in AI settings.", - "testReasonRateLimited": "Provider rate-limited the request — try again shortly.", - "testReasonServerError": "The AI provider returned a server error.", - "testReasonUnreachable": "Could not reach the AI provider.", + "testFailedShort": "Prueba fallida: {message}", + "testUnexpectedResponse": "Falló la conexión con el proveedor de IA — respuesta inesperada del servidor.", + "testReasonCredentials": "El proveedor rechazó las credenciales — vuelve a autenticarte en los ajustes de IA.", + "testReasonRateLimited": "El proveedor limitó la solicitud — inténtalo de nuevo en breve.", + "testReasonServerError": "El proveedor de IA devolvió un error del servidor.", + "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.", @@ -3380,28 +3380,28 @@ } }, "withings": "Withings", - "withingsDescription": "Connect your Withings scale and blood pressure monitors.", - "withingsCredentials": "API Credentials", - "withingsClientId": "Client ID", - "withingsClientSecret": "Client Secret", - "withingsCredentialsSaved": "Credentials saved", - "withingsCredentialsSavedPlaceholder": "Saved — enter new to replace", - "withingsCredentialsSavedPlaceholderSecret": "Saved — enter new to replace", - "withingsSaveCredentials": "Save credentials", - "configured": "Configured", - "withingsSync": "Sync now", - "withingsFullSync": "Sync all data", - "withingsFullSyncTitle": "Full synchronization?", - "withingsFullSyncDescription": "All available Withings data will be fully synchronized. This may take some time depending on your history.", - "withingsSyncResult": "{count} measurements synchronized", - "withingsFullSyncResult": "{count} measurements fully synchronized", - "withingsSyncFailed": "Sync failed", - "withingsSynchronize": "Synchronize", - "withingsDisconnect": "Disconnect", - "withingsDisconnectTitle": "Disconnect Withings?", - "withingsDisconnectDescription": "The connection to Withings will be disconnected. Previously synced data will be preserved.", - "withingsConnect": "Connect with Withings", - "withingsNoCredentials": "Please enter your API credentials above to connect Withings.", + "withingsDescription": "Conecta tu báscula y tensiómetros Withings.", + "withingsCredentials": "Credenciales de API", + "withingsClientId": "ID de cliente", + "withingsClientSecret": "Secreto de cliente", + "withingsCredentialsSaved": "Credenciales guardadas", + "withingsCredentialsSavedPlaceholder": "Guardado — introduce uno nuevo para reemplazarlo", + "withingsCredentialsSavedPlaceholderSecret": "Guardado — introduce uno nuevo para reemplazarlo", + "withingsSaveCredentials": "Guardar credenciales", + "configured": "Configurado", + "withingsSync": "Sincronizar ahora", + "withingsFullSync": "Sincronizar todos los datos", + "withingsFullSyncTitle": "¿Sincronización completa?", + "withingsFullSyncDescription": "Se sincronizarán por completo todos los datos disponibles de Withings. Esto puede tardar un poco según tu historial.", + "withingsSyncResult": "{count} mediciones sincronizadas", + "withingsFullSyncResult": "{count} mediciones sincronizadas por completo", + "withingsSyncFailed": "Falló la sincronización", + "withingsSynchronize": "Sincronizar", + "withingsDisconnect": "Desconectar", + "withingsDisconnectTitle": "¿Desconectar Withings?", + "withingsDisconnectDescription": "Se desconectará la conexión con Withings. Los datos ya sincronizados se conservarán.", + "withingsConnect": "Conectar con Withings", + "withingsNoCredentials": "Introduce primero tus credenciales de API arriba para conectar Withings.", "whoop": "WHOOP", "whoopDescription": "Conecta tu banda WHOOP para sincronizar recuperación, sueño, esfuerzo y entrenamientos.", "whoopOverlapNote": "Si WHOOP y otra fuente proporcionan el mismo valor vital —frecuencia cardíaca en reposo, oxígeno en sangre, temperatura corporal, frecuencia respiratoria o fases del sueño—, es posible que veas ambos valores hasta que una futura actualización elija una única fuente preferida.", @@ -3430,9 +3430,9 @@ "withings": { "reconnect": { "banner": { - "title": "Activity sync available", - "body": "Your Withings connection was authorised before activity sync was added. Reconnect once to enable steps, active energy, walking + running distance, and floors-climbed ingest from your Withings devices.", - "action": "Reconnect Withings" + "title": "Sincronización de actividad disponible", + "body": "Tu conexión con Withings se autorizó antes de añadir la sincronización de actividad. Vuelve a conectar una vez para habilitar la importación de pasos, energía activa, distancia caminando y corriendo, y pisos subidos desde tus dispositivos Withings.", + "action": "Reconectar Withings" } } } @@ -3443,132 +3443,132 @@ "warningServerError": "Conectado, error del servidor", "parkedReconnect": "Pausado — reconectar manualmente", "notConnected": "No conectado", - "justNow": "just now", - "minutesAgo": "{count} min ago", - "hoursAgo": "{count} h ago", - "daysAgo": "{count} d ago", - "ariaLabel": "Integration status", + "justNow": "ahora mismo", + "minutesAgo": "hace {count} min", + "hoursAgo": "hace {count} h", + "daysAgo": "hace {count} d", + "ariaLabel": "Estado de la integración", "resumeCta": "Reconectar", "resumeSuccess": "Integración reanudada", "resumeError": "Reconexión fallida" }, - "apiTokens": "API Tokens", - "apiTokensDescription": "Create API tokens for external medication intake (e.g., Shortcuts, automations).", - "tokenNamePlaceholder": "Token name (e.g., iPhone Shortcut)", - "tokenCreated": "Token created — copy it now! It won't be shown again.", - "tokenRevoke": "Revoke token?", - "tokenRevokeDescription": "The token will be permanently deactivated. Applications using it will lose access.", - "tokenRevoked": "Revoked", - "tokenExpired": "Expired", + "apiTokens": "Tokens de API", + "apiTokensDescription": "Crea tokens de API para registrar tomas de medicación externas (p. ej., Atajos, automatizaciones).", + "tokenNamePlaceholder": "Nombre del token (p. ej., Atajo de iPhone)", + "tokenCreated": "Token creado — ¡cópialo ahora! No se mostrará de nuevo.", + "tokenRevoke": "¿Revocar token?", + "tokenRevokeDescription": "El token se desactivará de forma permanente. Las aplicaciones que lo usen perderán el acceso.", + "tokenRevoked": "Revocado", + "tokenExpired": "Caducado", "tokenActive": "Activo", - "activeTokensTitle": "Active tokens", - "revokedTokensTitle": "Revoked tokens ({count})", + "activeTokensTitle": "Tokens activos", + "revokedTokensTitle": "Tokens revocados ({count})", "tokenTableName": "Nombre", - "tokenTablePermissions": "Permissions", + "tokenTablePermissions": "Permisos", "tokenTableStatus": "Estado", - "tokenTableCreated": "Created", - "tokenTableLastUsed": "Last used", + "tokenTableCreated": "Creado", + "tokenTableLastUsed": "Último uso", "tokenTableActions": "Acciones", - "noActiveTokens": "No active tokens available.", - "tokenNeverUsed": "Never", - "apiEndpointsTitle": "API endpoints", - "apiEndpointsDescription": "Overview of currently available external endpoint(s) for token-based ingestion.", - "apiEndpointMethod": "Method", - "apiEndpointPath": "Path", - "apiEndpointAuth": "Authentication", - "apiEndpointExample": "Body example", - "collapse": "Collapse", - "expand": "Expand", - "dangerZone": "Delete All Data", - "dangerZoneTitle": "Danger zone", - "dangerZoneDescription": "Deletes all your health data and integrations. Your user account will be preserved.", - "dangerZoneConfirm": "Delete all data?", - "dangerZoneConfirmDescription": "This will permanently delete all your health data and integrations. Your user account will be preserved.", - "dangerZoneSuccess": "All personal data has been deleted", - "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.", - "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.", - "deleteAccountSuccess": "Account deleted — signing you out.", - "deleteAccountFailed": "Account could not be deleted", - "deleteAccountFinal": "Delete permanently", + "noActiveTokens": "No hay tokens activos.", + "tokenNeverUsed": "Nunca", + "apiEndpointsTitle": "Endpoints de API", + "apiEndpointsDescription": "Resumen de los endpoints externos disponibles actualmente para la ingesta basada en tokens.", + "apiEndpointMethod": "Método", + "apiEndpointPath": "Ruta", + "apiEndpointAuth": "Autenticación", + "apiEndpointExample": "Ejemplo de cuerpo", + "collapse": "Contraer", + "expand": "Expandir", + "dangerZone": "Eliminar todos los datos", + "dangerZoneTitle": "Zona de peligro", + "dangerZoneDescription": "Elimina todos tus datos de salud e integraciones. Tu cuenta de usuario se conservará.", + "dangerZoneConfirm": "¿Eliminar todos los datos?", + "dangerZoneConfirmDescription": "Esto eliminará de forma permanente todos tus datos de salud e integraciones. Tu cuenta de usuario se conservará.", + "dangerZoneSuccess": "Se han eliminado todos los datos personales", + "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.", + "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.", + "deleteAccountSuccess": "Cuenta eliminada — cerrando tu sesión.", + "deleteAccountFailed": "No se pudo eliminar la cuenta", + "deleteAccountFinal": "Eliminar permanentemente", "saved": "Guardado", - "savingError": "Error saving", + "savingError": "Error al guardar", "moodLogTitle": "moodLog", "moodLogDescription": "Import mood data from moodLog", "moodLogUrl": "moodLog URL", "moodLogUrlPlaceholder": "https://mood.example.com", - "moodLogApiKey": "API Key", + "moodLogApiKey": "Clave de API", "moodLogApiKeyPlaceholder": "ml_...", - "moodLogWebhookSecret": "Webhook Secret", - "moodLogWebhookSecretHelp": "Enter this secret in moodLog as webhook secret", - "moodLogSync": "Start sync", - "moodLogFullSync": "Full sync", - "moodLogFullSyncConfirm": "Synchronize fully", - "moodLogFullSyncTitle": "Full sync?", - "moodLogFullSyncDescription": "All available mood data will be fully synchronized.", - "moodLogDisconnect": "Disconnect", - "moodLogDisconnectTitle": "Disconnect moodLog?", - "moodLogDisconnectDescription": "The connection will be removed and all imported mood data will be deleted.", - "moodLogEntries": "Entries", - "moodLogSyncResult": "{count} entries synchronized", - "moodLogSyncFailed": "Sync failed", - "moodLogSaved": "moodLog connection saved", - "moodLogDisconnected": "moodLog disconnected", + "moodLogWebhookSecret": "Secreto del webhook", + "moodLogWebhookSecretHelp": "Introduce este secreto en moodLog como secreto del webhook", + "moodLogSync": "Iniciar sincronización", + "moodLogFullSync": "Sincronización completa", + "moodLogFullSyncConfirm": "Sincronizar por completo", + "moodLogFullSyncTitle": "¿Sincronización completa?", + "moodLogFullSyncDescription": "Se sincronizarán por completo todos los datos de estado de ánimo disponibles.", + "moodLogDisconnect": "Desconectar", + "moodLogDisconnectTitle": "¿Desconectar moodLog?", + "moodLogDisconnectDescription": "Se eliminará la conexión y se borrarán todos los datos de estado de ánimo importados.", + "moodLogEntries": "Entradas", + "moodLogSyncResult": "{count} entradas sincronizadas", + "moodLogSyncFailed": "Falló la sincronización", + "moodLogSaved": "Conexión con moodLog guardada", + "moodLogDisconnected": "moodLog desconectado", "about": { - "version": "App version", + "version": "Versión de la app", "gitSha": "Build", - "builtAt": "Built {time}", - "builtAtLabel": "Built", - "license": "License", - "repository": "Source code", + "builtAt": "Compilado {time}", + "builtAtLabel": "Compilado", + "license": "Licencia", + "repository": "Código fuente", "changelog": "Changelog", - "docs": "Documentation", + "docs": "Documentación", "linksHeading": "Fuentes y documentación", "newerAvailable": "Nueva versión: {tag}", "tourReplay": "Volver a ver el recorrido", "tourReplayHint": "Mira el recorrido del panel — útil si lo saltaste en la primera visita." }, "testConnection": { - "test": "Test connection", - "testing": "Testing…", - "ok": "Connected (latency {latency} ms)", + "test": "Probar conexión", + "testing": "Probando…", + "ok": "Conectado (latencia {latency} ms)", "errors": { - "credentials_rejected": "Credentials rejected — check your token", - "rate_limited": "Rate-limited by the upstream", - "timeout": "Request timed out", - "upstream_error": "Upstream returned an error", - "connection_failed": "Connection failed", - "not_configured": "Not configured", - "url_not_public": "URL is not a public endpoint", - "url_invalid": "URL is invalid", - "redirected": "Endpoint redirects — check the URL", - "endpoint_not_found": "Endpoint not found at the URL", - "credentials_unreadable": "Credentials cannot be decrypted", - "upstream_invalid_json": "Upstream sent invalid JSON", - "vapid_not_configured": "Web Push not configured (VAPID keys missing)", - "rate_limited_self": "Too many test requests", - "generic": "Test failed" + "credentials_rejected": "Credenciales rechazadas — comprueba tu token", + "rate_limited": "Limitado por el servidor remoto", + "timeout": "La solicitud agotó el tiempo de espera", + "upstream_error": "El servidor remoto devolvió un error", + "connection_failed": "Falló la conexión", + "not_configured": "No configurado", + "url_not_public": "La URL no es un endpoint público", + "url_invalid": "La URL no es válida", + "redirected": "El endpoint redirige — comprueba la URL", + "endpoint_not_found": "No se encontró el endpoint en la URL", + "credentials_unreadable": "Las credenciales no se pueden descifrar", + "upstream_invalid_json": "El servidor remoto envió un JSON no válido", + "vapid_not_configured": "Web Push no configurado (faltan las claves VAPID)", + "rate_limited_self": "Demasiadas solicitudes de prueba", + "generic": "Prueba fallida" } }, "notificationStatus": { "title": "Channel reliability", "description": "Live status of every notification channel — Auto-disabled means HealthLog stopped retrying after a permanent error.", - "emptyDescription": "No channels configured yet. Add a channel below to start tracking its delivery health.", + "emptyDescription": "Aún no hay canales configurados. Añade un canal abajo para empezar a controlar su estado de entrega.", "stateActive": "Activo", - "stateAutoDisabled": "Auto-disabled", - "stateSendingPaused": "Sending paused", + "stateAutoDisabled": "Desactivado automáticamente", + "stateSendingPaused": "Envío en pausa", "stateManuallyDisabled": "Desactivado", - "lastSuccess": "Last successful send", - "lastFailure": "Last failure", - "consecutiveFailures": "Consecutive failures", - "disabledReason": "Reason", - "nextRetry": "Next retry", - "reEnable": "Re-enable", - "sendTest": "Send test" + "lastSuccess": "Último envío correcto", + "lastFailure": "Último fallo", + "consecutiveFailures": "Fallos consecutivos", + "disabledReason": "Motivo", + "nextRetry": "Próximo intento", + "reEnable": "Reactivar", + "sendTest": "Enviar prueba" }, "identity": { "description": "Datos opcionales para la exportación del historial de salud (portada PDF + exportación FHIR). Todos los campos son opcionales.", diff --git a/messages/fr.json b/messages/fr.json index 8b2b2dfb..26988c90 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2998,24 +2998,24 @@ "disableError": "Impossible de désactiver le mode Recherche. Veuillez réessayer." }, "shell": { - "sectionsNav": "Settings sections" + "sectionsNav": "Sections des réglages" }, "sections": { "account": { "title": "Compte", - "description": "Profile, password, passkeys, and your onboarding tour." + "description": "Profil, mot de passe, clés d’accès et votre visite guidée." }, "integrations": { "title": "Intégrations", - "description": "Withings, moodLog, and other connected services." + "description": "Withings, moodLog et autres services connectés." }, "notifications": { "title": "Notifications", - "description": "Live overview of every configured channel." + "description": "Aperçu en direct de chaque canal configuré." }, "dashboard": { "title": "Tableau de bord", - "description": "Tile layout and order." + "description": "Disposition et ordre des tuiles." }, "thresholds": { "title": "Objectifs", @@ -3078,12 +3078,12 @@ } }, "ai": { - "title": "AI Insights", - "description": "Provider, model, key." + "title": "Analyses IA", + "description": "Fournisseur, modèle, clé." }, "api": { "title": "API & Tokens", - "description": "Bearer tokens for your own scripts and apps — log measurements or medication intake from anywhere." + "description": "Jetons Bearer pour vos propres scripts et applis — enregistrez des mesures ou des prises de médicaments depuis n’importe où." }, "advanced": { "title": "Avancé", @@ -3091,7 +3091,7 @@ }, "export": { "title": "Exporter", - "description": "Download your health data as PDF, CSV, or a full JSON backup.", + "description": "Téléchargez vos données de santé en PDF, CSV ou sauvegarde JSON complète.", "otherOptionsHeading": "Autres options d'export", "hero": { "eyebrow": "Rendez-vous médical", @@ -3100,39 +3100,39 @@ "formatHint": "PDF · prêt à imprimer" }, "actions": { - "download": "Download" + "download": "Télécharger" }, "filters": { "since": "De", - "until": "Until" + "until": "Jusqu’au" }, "cards": { "doctorReport": { - "title": "Doctor Report", - "description": "Printable PDF for the doctor's appointment — vitals, BMI, BP classification, medication compliance, mood." + "title": "Rapport médical", + "description": "PDF imprimable pour le rendez-vous médical — constantes, IMC, classification de la tension, observance des traitements, humeur." }, "measurementsCsv": { - "title": "Measurements", - "description": "Weight, blood pressure, pulse, glucose, and every other measurement as comma-separated values." + "title": "Mesures", + "description": "Poids, tension artérielle, pouls, glycémie et toutes les autres mesures en valeurs séparées par des virgules." }, "medicationsCsv": { "title": "Médicaments", - "description": "Your medication list with dosage, schedules, and (optionally) the full intake history.", - "includeIntake": "Include intake history" + "description": "Votre liste de médicaments avec posologie, horaires et (en option) l’historique complet des prises.", + "includeIntake": "Inclure l’historique des prises" }, "moodCsv": { "title": "Humeur", - "description": "Daily mood entries with score, tags, and timestamps." + "description": "Entrées d’humeur quotidiennes avec score, étiquettes et horodatages." }, "fullBackup": { - "title": "Full backup", - "description": "Single JSON file with everything — same shape as the weekly auto-backup. Useful for self-restore via admin upload." + "title": "Sauvegarde complète", + "description": "Un seul fichier JSON contenant tout — même format que la sauvegarde automatique hebdomadaire. Utile pour une restauration personnelle via le téléversement administrateur." } } }, "about": { - "title": "About", - "description": "Version, license, links." + "title": "À propos", + "description": "Version, licence, liens." }, "sharing": { "title": "Partage", @@ -3141,20 +3141,20 @@ }, "profile": "Profil", "language": "Langue", - "languageDescription": "Choose the display language of the application.", + "languageDescription": "Choisissez la langue d’affichage de l’application.", "username": "Nom d'utilisateur", - "height": "Height (cm)", + "height": "Taille (cm)", "dateOfBirth": "Date de naissance", - "dateOfBirthHint": "Used for automatic blood pressure target calculations.", + "dateOfBirthHint": "Utilisé pour le calcul automatique des objectifs de tension artérielle.", "gender": "Sexe", - "genderNone": "Not specified", + "genderNone": "Non précisé", "genderMale": "Homme", "genderFemale": "Femme", - "genderHint": "Used for gender-specific target values.", + "genderHint": "Utilisé pour des valeurs cibles spécifiques au sexe.", "timezone": "Fuseau horaire", - "timezoneHint": "Used for chart axis labels, reminder times, export timestamps, and Coach context. Stored data is unchanged.", - "timezoneInvalid": "Not a valid IANA timezone.", - "profileSaved": "Profile saved", + "timezoneHint": "Utilisé pour les libellés des axes des graphiques, les heures de rappel, les horodatages d’export et le contexte du Coach. Les données stockées restent inchangées.", + "timezoneInvalid": "Fuseau horaire IANA non valide.", + "profileSaved": "Profil enregistré", "avatar": { "title": "Photo de profil", "description": "Téléverse une photo stockée sur ton propre serveur. Elle apparaît dans toute l'application à la place d'un service d'avatar externe.", @@ -3168,174 +3168,174 @@ "tooLarge": "L'image dépasse la limite de 2 Mo.", "error": "Impossible de mettre à jour la photo de profil. Réessaie." }, - "changePassword": "Change password", - "changePasswordDescription": "Replace your current password with a new one.", - "passwordReset": "Password reset", - "currentPassword": "Current password", - "newPassword": "New password", - "confirmNewPassword": "Confirm new password", - "passwordMismatch": "New passwords do not match", - "passwordUpdated": "Password updated successfully", + "changePassword": "Changer le mot de passe", + "changePasswordDescription": "Remplacez votre mot de passe actuel par un nouveau.", + "passwordReset": "Réinitialisation du mot de passe", + "currentPassword": "Mot de passe actuel", + "newPassword": "Nouveau mot de passe", + "confirmNewPassword": "Confirmer le nouveau mot de passe", + "passwordMismatch": "Les nouveaux mots de passe ne correspondent pas", + "passwordUpdated": "Mot de passe mis à jour", "passkeys": "Passkeys", - "registeredPasskeys": "Registered Passkeys", - "passkeysDescription": "Passkeys allow secure login without a password.", + "registeredPasskeys": "Clés d’accès enregistrées", + "passkeysDescription": "Les clés d’accès permettent une connexion sécurisée sans mot de passe.", "passkeyName": "Nom", - "passkeyDevice": "Device", + "passkeyDevice": "Appareil", "passkeyBackup": "Backup", - "passkeyCreated": "Created", + "passkeyCreated": "Créée", "passkeyActions": "Actions", - "noPasskeys": "No passkeys registered.", - "addPasskey": "Add passkey", - "passkeyAdded": "Passkey added successfully!", - "passkeyOptionsError": "Could not load passkey options", - "passkeyRegistrationFailed": "Passkey registration failed", - "passkeyRegistrationCancelled": "Passkey registration cancelled", - "passkeyNotSupported": "Your browser or device doesn't support passkeys yet. Try a different browser or device.", - "passkeyAlreadyRegistered": "This device is already registered as a passkey for your account.", - "passkeySecurityBlocked": "Your browser blocked the passkey request for security reasons. Make sure you're on a trusted HTTPS origin and try again.", - "passkeyTimeout": "The passkey prompt timed out. Please try again.", - "passkeyUnknownError": "Passkey registration failed: {message}", - "deletePasskey": "Delete passkey?", - "deletePasskeyDescription": "The passkey will be permanently deleted.", - "singleDevice": "Single device", - "multiDevice": "Multi-device", - "backedUp": "backed up", + "noPasskeys": "Aucune clé d’accès enregistrée.", + "addPasskey": "Ajouter une clé d’accès", + "passkeyAdded": "Clé d’accès ajoutée !", + "passkeyOptionsError": "Impossible de charger les options de clé d’accès", + "passkeyRegistrationFailed": "Échec de l’enregistrement de la clé d’accès", + "passkeyRegistrationCancelled": "Enregistrement de la clé d’accès annulé", + "passkeyNotSupported": "Votre navigateur ou appareil ne prend pas encore en charge les clés d’accès. Essayez un autre navigateur ou appareil.", + "passkeyAlreadyRegistered": "Cet appareil est déjà enregistré comme clé d’accès de votre compte.", + "passkeySecurityBlocked": "Votre navigateur a bloqué la demande de clé d’accès pour des raisons de sécurité. Assurez-vous d’être sur une origine HTTPS de confiance et réessayez.", + "passkeyTimeout": "La demande de clé d’accès a expiré. Veuillez réessayer.", + "passkeyUnknownError": "Échec de l’enregistrement de la clé d’accès : {message}", + "deletePasskey": "Supprimer la clé d’accès ?", + "deletePasskeyDescription": "La clé d’accès sera définitivement supprimée.", + "singleDevice": "Appareil unique", + "multiDevice": "Multi-appareils", + "backedUp": "sauvegardée", "telegram": "Telegram Notifications", "telegramDescription": "Get reminders for missed medication intake via Telegram.", - "telegramSaved": "Telegram settings saved", - "botToken": "Bot Token", - "chatId": "Chat ID", - "enableNotifications": "Enable notifications", - "testMessage": "Test message", - "testSent": "Test message sent!", - "telegramStep1": "1. Create a bot via @BotFather in Telegram and copy the token.", - "telegramStep2": "2. Send /start to your bot to activate the chat.", - "telegramStep3": "3. Find your Chat ID via @userinfobot or the Bot API.", + "telegramSaved": "Réglages Telegram enregistrés", + "botToken": "Jeton du bot", + "chatId": "ID du chat", + "enableNotifications": "Activer les notifications", + "testMessage": "Message de test", + "testSent": "Message de test envoyé !", + "telegramStep1": "1. Créez un bot via @BotFather dans Telegram et copiez le jeton.", + "telegramStep2": "2. Envoyez /start à votre bot pour activer le chat.", + "telegramStep3": "3. Trouvez votre ID de chat via @userinfobot ou l’API du bot.", "ntfy": "ntfy", - "ntfyDescription": "Notifications via ntfy (self-hosted or ntfy.sh).", - "ntfyEnable": "Enable ntfy", - "ntfyServer": "Server URL", + "ntfyDescription": "Notifications via ntfy (auto-hébergé ou ntfy.sh).", + "ntfyEnable": "Activer ntfy", + "ntfyServer": "URL du serveur", "ntfyTopic": "Topic", - "ntfyAuthToken": "Auth Token (optional)", - "ntfyAuthTokenHint": "Only needed for private topics with access control.", - "webPush": "Browser Push", - "webPushDescription": "Receive notifications directly in your browser, even when HealthLog is not open.", - "webPushNotSupported": "Your browser does not support push notifications.", - "webPushDenied": "Push notifications are blocked. Allow them in your browser settings.", - "webPushNotConfigured": "Web Push is not configured on the server.", - "webPushSubscribe": "Enable push", - "webPushUnsubscribe": "Disable push", - "webPushSubscribed": "Push notifications enabled!", - "webPushUnsubscribed": "Push notifications disabled.", - "webPushSubscribeFailed": "Activation failed", + "ntfyAuthToken": "Jeton d’authentification (facultatif)", + "ntfyAuthTokenHint": "Nécessaire uniquement pour les sujets privés avec contrôle d’accès.", + "webPush": "Push navigateur", + "webPushDescription": "Recevez des notifications directement dans votre navigateur, même lorsque HealthLog n’est pas ouvert.", + "webPushNotSupported": "Votre navigateur ne prend pas en charge les notifications push.", + "webPushDenied": "Les notifications push sont bloquées. Autorisez-les dans les réglages de votre navigateur.", + "webPushNotConfigured": "Web Push n’est pas configuré sur le serveur.", + "webPushSubscribe": "Activer le push", + "webPushUnsubscribe": "Désactiver le push", + "webPushSubscribed": "Notifications push activées !", + "webPushUnsubscribed": "Notifications push désactivées.", + "webPushSubscribeFailed": "Échec de l’activation", "webPushActive": "Actif", - "kiInsightsDescription": "Optional: Save your OpenAI key for daily, automatic evaluations in Insights.", - "codexConnected": "ChatGPT connected successfully! Insights are now active.", - "codexDisconnected": "ChatGPT connection removed.", - "codexConnectionFailed": "ChatGPT connection failed. Please try again.", - "rawData": "Send raw data", - "rawDataOnDescription": "Aggregated metrics plus anonymized raw points from the last 30 days are sent. This includes per-metric measurements (for example weight, blood pressure, pulse) with time context so the provider can detect patterns, outliers, and correlations more reliably. Name, email, and direct account identifiers are not transmitted.", - "rawDataOffDescription": "Only summarized metrics are sent (for example averages, trends, minimum/maximum). No individual points and no exact time context. This is more privacy-preserving, but the provider can be less precise for short-term patterns and fluctuations.", - "rawDataWarning": "Raw mode enabled: the provider receives additional anonymized points from the last 30 days for higher analysis accuracy.", - "regenerateInsights": "Regenerate reports", - "regenerateSuccess": "Reports regenerated successfully", - "regenerateRateLimit": "Please wait — you've hit the hourly limit for analyses.", - "lastGeneratedAt": "Last generated", + "kiInsightsDescription": "Facultatif : enregistrez votre clé OpenAI pour des analyses quotidiennes et automatiques dans Insights.", + "codexConnected": "ChatGPT connecté ! Insights est maintenant actif.", + "codexDisconnected": "Connexion à ChatGPT supprimée.", + "codexConnectionFailed": "Échec de la connexion à ChatGPT. Veuillez réessayer.", + "rawData": "Envoyer les données brutes", + "rawDataOnDescription": "Des métriques agrégées sont envoyées, ainsi que des points bruts anonymisés des 30 derniers jours. Cela inclut des mesures par métrique (par exemple poids, tension, pouls) avec un contexte temporel, afin que le fournisseur puisse détecter plus fiablement les tendances, les valeurs aberrantes et les corrélations. Le nom, l’e-mail et les identifiants de compte directs ne sont pas transmis.", + "rawDataOffDescription": "Seules des métriques résumées sont envoyées (par exemple moyennes, tendances, minimum/maximum). Aucun point individuel ni contexte temporel exact. Cela préserve mieux la vie privée, mais le fournisseur peut être moins précis pour les tendances et fluctuations à court terme.", + "rawDataWarning": "Mode brut activé : le fournisseur reçoit des points anonymisés supplémentaires des 30 derniers jours pour une analyse plus précise.", + "regenerateInsights": "Régénérer les rapports", + "regenerateSuccess": "Rapports régénérés", + "regenerateRateLimit": "Veuillez patienter — vous avez atteint la limite horaire d’analyses.", + "lastGeneratedAt": "Dernière génération", "ai": { - "chatgptConnectedBadge": "ChatGPT connected", - "adminAiActiveBadge": "Admin provider active", - "connectionExpiredBadge": "Connection expired", - "connectedSince": "Connected since {when}.", - "deviceCodeHeading": "Finish connecting on chatgpt.com", - "deviceCodeStep1": "Open this link on any device:", - "deviceCodeStep2": "Enter this one-time code:", - "deviceCodeStep3": "Approve the connection — this page updates automatically.", + "chatgptConnectedBadge": "ChatGPT connecté", + "adminAiActiveBadge": "Fournisseur administrateur actif", + "connectionExpiredBadge": "Connexion expirée", + "connectedSince": "Connecté depuis {when}.", + "deviceCodeHeading": "Terminez la connexion sur chatgpt.com", + "deviceCodeStep1": "Ouvrez ce lien sur n’importe quel appareil :", + "deviceCodeStep2": "Saisissez ce code à usage unique :", + "deviceCodeStep3": "Approuvez la connexion — cette page se met à jour automatiquement.", "deviceCodeCopy": "Copier", - "deviceCodeWaiting": "Waiting for approval…", + "deviceCodeWaiting": "En attente d’approbation…", "deviceCodeCancel": "Annuler", - "oauthNotConfigured": "ChatGPT OAuth is not configured on this instance — use your own API key below instead.", - "modelLabel": "Model", - "modelOptionDefault": "— Default —", - "modelOptionCustom": "Custom…", - "customModelLabel": "Custom model name", - "anthropicKeyLabel": "Anthropic API key", + "oauthNotConfigured": "L’OAuth ChatGPT n’est pas configuré sur cette instance — utilisez plutôt votre propre clé API ci-dessous.", + "modelLabel": "Modèle", + "modelOptionDefault": "— Par défaut —", + "modelOptionCustom": "Personnalisé…", + "customModelLabel": "Nom du modèle personnalisé", + "anthropicKeyLabel": "Clé API Anthropic", "baseUrlLabel": "Base URL", - "localKeyLabel": "API key (optional)", - "savedPreview": "(saved {preview})", - "savedShort": "(saved)", + "localKeyLabel": "Clé API (facultative)", + "savedPreview": "(enregistrée {preview})", + "savedShort": "(enregistrée)", "saveCta": "Enregistrer", "saved": "Enregistré", - "saveFailed": "Save failed", + "saveFailed": "Échec de l’enregistrement", "errorGeneric": "Erreur", "providerChain": { "types": { "codex": "ChatGPT (Codex)", - "openai": "OpenAI (your key)", + "openai": "OpenAI (votre clé)", "anthropic": "Anthropic (Claude)", - "local": "Local model", - "admin-openai": "Admin OpenAI" + "local": "Modèle local", + "admin-openai": "OpenAI administrateur" }, - "title": "Fallback chain", - "description": "If the primary provider fails, HealthLog walks the chain in order. Drag the rows to reorder, toggle the switch to disable a provider without removing it.", - "moveUp": "Move up", - "moveDown": "Move down", - "removeFromChain": "Remove from chain", - "addProvider": "Add provider", - "addNoneAvailable": "All providers already in chain", - "saveOrder": "Save chain order", - "saved": "Chain saved", - "saveFailed": "Saving the chain failed", - "resetDefaults": "Reset to defaults", - "resetConfirmTitle": "Reset chain to defaults?", - "resetConfirmBody": "The chain reverts to Codex → OpenAI → Anthropic → Local → Admin OpenAI. Your saved credentials are not touched." - }, - "activeProviderHeading": "Active provider", - "activeProviderBody": "Pick the provider you want to use first. The form below configures only the selected provider; the fallback chain at the bottom decides what happens if it fails.", - "activeProviderLabel": "Primary provider", - "providerConfigTitle": "Provider configuration", + "title": "Chaîne de secours", + "description": "Si le fournisseur principal échoue, HealthLog parcourt la chaîne dans l’ordre. Faites glisser les lignes pour les réorganiser ; utilisez l’interrupteur pour désactiver un fournisseur sans le supprimer.", + "moveUp": "Monter", + "moveDown": "Descendre", + "removeFromChain": "Retirer de la chaîne", + "addProvider": "Ajouter un fournisseur", + "addNoneAvailable": "Tous les fournisseurs sont déjà dans la chaîne", + "saveOrder": "Enregistrer l’ordre de la chaîne", + "saved": "Chaîne enregistrée", + "saveFailed": "Échec de l’enregistrement de la chaîne", + "resetDefaults": "Réinitialiser", + "resetConfirmTitle": "Réinitialiser la chaîne aux valeurs par défaut ?", + "resetConfirmBody": "La chaîne revient à Codex → OpenAI → Anthropic → Local → OpenAI administrateur. Vos identifiants enregistrés ne sont pas affectés." + }, + "activeProviderHeading": "Fournisseur actif", + "activeProviderBody": "Choisissez le fournisseur à utiliser en premier. Le formulaire ci-dessous configure uniquement le fournisseur sélectionné ; la chaîne de secours en bas détermine ce qui se passe en cas d’échec.", + "activeProviderLabel": "Fournisseur principal", + "providerConfigTitle": "Configuration du fournisseur", "providerSelect": { - "codex": "ChatGPT account (Codex)", - "openai": "OpenAI (your API key)", + "codex": "Compte ChatGPT (Codex)", + "openai": "OpenAI (votre clé API)", "anthropic": "Anthropic (Claude)", - "local": "Local model (OpenAI-compatible)", - "admin-openai": "Admin-provided OpenAI" + "local": "Modèle local (compatible OpenAI)", + "admin-openai": "OpenAI fourni par l’administrateur" }, "openai": { - "modelSelect": "Model", - "modelOptionCustom": "Custom slug…", - "modelCustomLabel": "Custom model slug", + "modelSelect": "Modèle", + "modelOptionCustom": "Slug personnalisé…", + "modelCustomLabel": "Slug du modèle personnalisé", "modelCustomPlaceholder": "gpt-5", - "baseUrlLabel": "Base URL (advanced)", + "baseUrlLabel": "URL de base (avancé)", "baseUrlPlaceholder": "https://api.openai.com/v1", - "baseUrlHelp": "Override only when using an OpenAI-compatible gateway. Leave blank for OpenAI itself.", - "showAdvanced": "Show advanced", - "hideAdvanced": "Hide advanced", - "apiKey": "API key", + "baseUrlHelp": "À remplacer uniquement si vous utilisez une passerelle compatible OpenAI. Laissez vide pour OpenAI lui-même.", + "showAdvanced": "Afficher les options avancées", + "hideAdvanced": "Masquer les options avancées", + "apiKey": "Clé API", "apiKeyPlaceholder": "sk-…" }, "codex": { - "statusConnected": "Connected", - "statusDisconnected": "Not connected", - "statusExpired": "Connection expired", - "connectButton": "Connect with ChatGPT", - "disconnectButton": "Disconnect", - "modelSlugLabel": "Model slug", - "modelSlugBody": "Codex uses the model your ChatGPT subscription routes to. The CODEX_MODEL environment variable lets ops override this on the instance.", - "lastInsight": "Last insight: {when}" + "statusConnected": "Connecté", + "statusDisconnected": "Non connecté", + "statusExpired": "Connexion expirée", + "connectButton": "Se connecter avec ChatGPT", + "disconnectButton": "Déconnecter", + "modelSlugLabel": "Slug du modèle", + "modelSlugBody": "Codex utilise le modèle vers lequel votre abonnement ChatGPT route. La variable d’environnement CODEX_MODEL permet à l’opérateur de le remplacer sur l’instance.", + "lastInsight": "Dernière analyse : {when}" }, "adminOpenai": { - "title": "Admin OpenAI", - "body": "Operator-provided OpenAI key. Used as a last-ditch fallback when no personal provider is configured. There is nothing to configure on this row — visibility means the operator has set it up.", - "notConfigured": "The operator has not configured a shared OpenAI key on this instance." + "title": "OpenAI administrateur", + "body": "Clé OpenAI fournie par l’opérateur. Utilisée en dernier recours lorsqu’aucun fournisseur personnel n’est configuré. Rien à configurer sur cette ligne — sa visibilité signifie que l’opérateur l’a mise en place.", + "notConfigured": "L’opérateur n’a pas configuré de clé OpenAI partagée sur cette instance." }, - "testProvider": "Test active provider", + "testProvider": "Tester le fournisseur actif", "testSuccess": "OK — {provider} ({model})", - "testFailedShort": "Test failed: {message}", - "testUnexpectedResponse": "AI provider connection failed — unexpected response from the server.", - "testReasonCredentials": "Provider rejected the credentials — re-authenticate in AI settings.", - "testReasonRateLimited": "Provider rate-limited the request — try again shortly.", - "testReasonServerError": "The AI provider returned a server error.", - "testReasonUnreachable": "Could not reach the AI provider.", + "testFailedShort": "Échec du test : {message}", + "testUnexpectedResponse": "Échec de la connexion au fournisseur d’IA — réponse inattendue du serveur.", + "testReasonCredentials": "Le fournisseur a rejeté les identifiants — réauthentifiez-vous dans les réglages IA.", + "testReasonRateLimited": "Le fournisseur a limité la requête — réessayez sous peu.", + "testReasonServerError": "Le fournisseur d’IA a renvoyé une erreur serveur.", + "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.", @@ -3380,28 +3380,28 @@ } }, "withings": "Withings", - "withingsDescription": "Connect your Withings scale and blood pressure monitors.", - "withingsCredentials": "API Credentials", - "withingsClientId": "Client ID", - "withingsClientSecret": "Client Secret", - "withingsCredentialsSaved": "Credentials saved", - "withingsCredentialsSavedPlaceholder": "Saved — enter new to replace", - "withingsCredentialsSavedPlaceholderSecret": "Saved — enter new to replace", - "withingsSaveCredentials": "Save credentials", - "configured": "Configured", - "withingsSync": "Sync now", - "withingsFullSync": "Sync all data", - "withingsFullSyncTitle": "Full synchronization?", - "withingsFullSyncDescription": "All available Withings data will be fully synchronized. This may take some time depending on your history.", - "withingsSyncResult": "{count} measurements synchronized", - "withingsFullSyncResult": "{count} measurements fully synchronized", - "withingsSyncFailed": "Sync failed", - "withingsSynchronize": "Synchronize", - "withingsDisconnect": "Disconnect", - "withingsDisconnectTitle": "Disconnect Withings?", - "withingsDisconnectDescription": "The connection to Withings will be disconnected. Previously synced data will be preserved.", - "withingsConnect": "Connect with Withings", - "withingsNoCredentials": "Please enter your API credentials above to connect Withings.", + "withingsDescription": "Connectez votre balance et vos tensiomètres Withings.", + "withingsCredentials": "Identifiants API", + "withingsClientId": "ID client", + "withingsClientSecret": "Secret client", + "withingsCredentialsSaved": "Identifiants enregistrés", + "withingsCredentialsSavedPlaceholder": "Enregistré — saisissez-en un nouveau pour remplacer", + "withingsCredentialsSavedPlaceholderSecret": "Enregistré — saisissez-en un nouveau pour remplacer", + "withingsSaveCredentials": "Enregistrer les identifiants", + "configured": "Configuré", + "withingsSync": "Synchroniser maintenant", + "withingsFullSync": "Synchroniser toutes les données", + "withingsFullSyncTitle": "Synchronisation complète ?", + "withingsFullSyncDescription": "Toutes les données Withings disponibles seront entièrement synchronisées. Cela peut prendre un certain temps selon votre historique.", + "withingsSyncResult": "{count} mesures synchronisées", + "withingsFullSyncResult": "{count} mesures entièrement synchronisées", + "withingsSyncFailed": "Échec de la synchronisation", + "withingsSynchronize": "Synchroniser", + "withingsDisconnect": "Déconnecter", + "withingsDisconnectTitle": "Déconnecter Withings ?", + "withingsDisconnectDescription": "La connexion à Withings sera déconnectée. Les données déjà synchronisées seront conservées.", + "withingsConnect": "Se connecter avec Withings", + "withingsNoCredentials": "Saisissez d’abord vos identifiants API ci-dessus pour connecter Withings.", "whoop": "WHOOP", "whoopDescription": "Connectez votre bracelet WHOOP pour synchroniser récupération, sommeil, effort et entraînements.", "whoopOverlapNote": "Si WHOOP et une autre source fournissent la même donnée vitale — fréquence cardiaque au repos, oxygène sanguin, température corporelle, fréquence respiratoire ou phases de sommeil —, les deux valeurs peuvent apparaître jusqu'à ce qu'une future mise à jour retienne une seule source préférée.", @@ -3430,9 +3430,9 @@ "withings": { "reconnect": { "banner": { - "title": "Activity sync available", - "body": "Your Withings connection was authorised before activity sync was added. Reconnect once to enable steps, active energy, walking + running distance, and floors-climbed ingest from your Withings devices.", - "action": "Reconnect Withings" + "title": "Synchronisation d’activité disponible", + "body": "Votre connexion Withings a été autorisée avant l’ajout de la synchronisation d’activité. Reconnectez-vous une fois pour activer l’import des pas, de l’énergie active, de la distance à pied et en course, et des étages gravis depuis vos appareils Withings.", + "action": "Reconnecter Withings" } } } @@ -3443,88 +3443,88 @@ "warningServerError": "Connecté, erreur serveur", "parkedReconnect": "En pause — reconnecter manuellement", "notConnected": "Non connecté", - "justNow": "just now", - "minutesAgo": "{count} min ago", - "hoursAgo": "{count} h ago", - "daysAgo": "{count} d ago", - "ariaLabel": "Integration status", + "justNow": "à l’instant", + "minutesAgo": "il y a {count} min", + "hoursAgo": "il y a {count} h", + "daysAgo": "il y a {count} j", + "ariaLabel": "Statut de l’intégration", "resumeCta": "Reconnecter", "resumeSuccess": "Intégration reprise", "resumeError": "Échec de la reconnexion" }, - "apiTokens": "API Tokens", - "apiTokensDescription": "Create API tokens for external medication intake (e.g., Shortcuts, automations).", - "tokenNamePlaceholder": "Token name (e.g., iPhone Shortcut)", - "tokenCreated": "Token created — copy it now! It won't be shown again.", - "tokenRevoke": "Revoke token?", - "tokenRevokeDescription": "The token will be permanently deactivated. Applications using it will lose access.", - "tokenRevoked": "Revoked", - "tokenExpired": "Expired", + "apiTokens": "Jetons API", + "apiTokensDescription": "Créez des jetons API pour enregistrer des prises de médicaments externes (par exemple Raccourcis, automatisations).", + "tokenNamePlaceholder": "Nom du jeton (par exemple Raccourci iPhone)", + "tokenCreated": "Jeton créé — copiez-le maintenant ! Il ne sera plus affiché.", + "tokenRevoke": "Révoquer le jeton ?", + "tokenRevokeDescription": "Le jeton sera désactivé définitivement. Les applications qui l’utilisent perdront l’accès.", + "tokenRevoked": "Révoqué", + "tokenExpired": "Expiré", "tokenActive": "Actif", - "activeTokensTitle": "Active tokens", - "revokedTokensTitle": "Revoked tokens ({count})", + "activeTokensTitle": "Jetons actifs", + "revokedTokensTitle": "Jetons révoqués ({count})", "tokenTableName": "Nom", - "tokenTablePermissions": "Permissions", + "tokenTablePermissions": "Autorisations", "tokenTableStatus": "Statut", - "tokenTableCreated": "Created", - "tokenTableLastUsed": "Last used", + "tokenTableCreated": "Créé", + "tokenTableLastUsed": "Dernière utilisation", "tokenTableActions": "Actions", - "noActiveTokens": "No active tokens available.", - "tokenNeverUsed": "Never", - "apiEndpointsTitle": "API endpoints", - "apiEndpointsDescription": "Overview of currently available external endpoint(s) for token-based ingestion.", - "apiEndpointMethod": "Method", - "apiEndpointPath": "Path", - "apiEndpointAuth": "Authentication", - "apiEndpointExample": "Body example", - "collapse": "Collapse", - "expand": "Expand", - "dangerZone": "Delete All Data", - "dangerZoneTitle": "Danger zone", - "dangerZoneDescription": "Deletes all your health data and integrations. Your user account will be preserved.", - "dangerZoneConfirm": "Delete all data?", - "dangerZoneConfirmDescription": "This will permanently delete all your health data and integrations. Your user account will be preserved.", - "dangerZoneSuccess": "All personal data has been deleted", - "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.", - "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.", - "deleteAccountSuccess": "Account deleted — signing you out.", - "deleteAccountFailed": "Account could not be deleted", - "deleteAccountFinal": "Delete permanently", + "noActiveTokens": "Aucun jeton actif disponible.", + "tokenNeverUsed": "Jamais", + "apiEndpointsTitle": "Points de terminaison API", + "apiEndpointsDescription": "Aperçu des points de terminaison externes actuellement disponibles pour l’ingestion par jeton.", + "apiEndpointMethod": "Méthode", + "apiEndpointPath": "Chemin", + "apiEndpointAuth": "Authentification", + "apiEndpointExample": "Exemple de corps", + "collapse": "Réduire", + "expand": "Développer", + "dangerZone": "Supprimer toutes les données", + "dangerZoneTitle": "Zone à risque", + "dangerZoneDescription": "Supprime toutes vos données de santé et intégrations. Votre compte utilisateur sera conservé.", + "dangerZoneConfirm": "Supprimer toutes les données ?", + "dangerZoneConfirmDescription": "Cette action supprimera définitivement toutes vos données de santé et intégrations. Votre compte utilisateur sera conservé.", + "dangerZoneSuccess": "Toutes les données personnelles ont été supprimées", + "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.", + "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.", + "deleteAccountSuccess": "Compte supprimé — déconnexion en cours.", + "deleteAccountFailed": "Le compte n’a pas pu être supprimé", + "deleteAccountFinal": "Supprimer définitivement", "saved": "Enregistré", - "savingError": "Error saving", + "savingError": "Erreur lors de l’enregistrement", "moodLogTitle": "moodLog", "moodLogDescription": "Import mood data from moodLog", "moodLogUrl": "moodLog URL", "moodLogUrlPlaceholder": "https://mood.example.com", - "moodLogApiKey": "API Key", + "moodLogApiKey": "Clé API", "moodLogApiKeyPlaceholder": "ml_...", - "moodLogWebhookSecret": "Webhook Secret", - "moodLogWebhookSecretHelp": "Enter this secret in moodLog as webhook secret", - "moodLogSync": "Start sync", - "moodLogFullSync": "Full sync", - "moodLogFullSyncConfirm": "Synchronize fully", - "moodLogFullSyncTitle": "Full sync?", - "moodLogFullSyncDescription": "All available mood data will be fully synchronized.", - "moodLogDisconnect": "Disconnect", - "moodLogDisconnectTitle": "Disconnect moodLog?", - "moodLogDisconnectDescription": "The connection will be removed and all imported mood data will be deleted.", - "moodLogEntries": "Entries", - "moodLogSyncResult": "{count} entries synchronized", - "moodLogSyncFailed": "Sync failed", - "moodLogSaved": "moodLog connection saved", - "moodLogDisconnected": "moodLog disconnected", + "moodLogWebhookSecret": "Secret du webhook", + "moodLogWebhookSecretHelp": "Saisissez ce secret dans moodLog comme secret de webhook", + "moodLogSync": "Démarrer la synchronisation", + "moodLogFullSync": "Synchronisation complète", + "moodLogFullSyncConfirm": "Synchroniser entièrement", + "moodLogFullSyncTitle": "Synchronisation complète ?", + "moodLogFullSyncDescription": "Toutes les données d’humeur disponibles seront entièrement synchronisées.", + "moodLogDisconnect": "Déconnecter", + "moodLogDisconnectTitle": "Déconnecter moodLog ?", + "moodLogDisconnectDescription": "La connexion sera supprimée et toutes les données d’humeur importées seront supprimées.", + "moodLogEntries": "Entrées", + "moodLogSyncResult": "{count} entrées synchronisées", + "moodLogSyncFailed": "Échec de la synchronisation", + "moodLogSaved": "Connexion moodLog enregistrée", + "moodLogDisconnected": "moodLog déconnecté", "about": { - "version": "App version", + "version": "Version de l’app", "gitSha": "Build", - "builtAt": "Built {time}", - "builtAtLabel": "Built", - "license": "License", - "repository": "Source code", + "builtAt": "Compilé {time}", + "builtAtLabel": "Compilé", + "license": "Licence", + "repository": "Code source", "changelog": "Changelog", "docs": "Documentation", "linksHeading": "Sources et documentation", @@ -3533,42 +3533,42 @@ "tourReplayHint": "Voir la visite guidée du tableau de bord — pratique si tu l'as sautée à la première visite." }, "testConnection": { - "test": "Test connection", - "testing": "Testing…", - "ok": "Connected (latency {latency} ms)", + "test": "Tester la connexion", + "testing": "Test en cours…", + "ok": "Connecté (latence {latency} ms)", "errors": { - "credentials_rejected": "Credentials rejected — check your token", - "rate_limited": "Rate-limited by the upstream", - "timeout": "Request timed out", - "upstream_error": "Upstream returned an error", - "connection_failed": "Connection failed", - "not_configured": "Not configured", - "url_not_public": "URL is not a public endpoint", - "url_invalid": "URL is invalid", - "redirected": "Endpoint redirects — check the URL", - "endpoint_not_found": "Endpoint not found at the URL", - "credentials_unreadable": "Credentials cannot be decrypted", - "upstream_invalid_json": "Upstream sent invalid JSON", - "vapid_not_configured": "Web Push not configured (VAPID keys missing)", - "rate_limited_self": "Too many test requests", - "generic": "Test failed" + "credentials_rejected": "Identifiants rejetés — vérifiez votre jeton", + "rate_limited": "Limité par le service en amont", + "timeout": "Délai de la requête dépassé", + "upstream_error": "Le service en amont a renvoyé une erreur", + "connection_failed": "Échec de la connexion", + "not_configured": "Non configuré", + "url_not_public": "L’URL n’est pas un point de terminaison public", + "url_invalid": "L’URL n’est pas valide", + "redirected": "Le point de terminaison redirige — vérifiez l’URL", + "endpoint_not_found": "Point de terminaison introuvable à l’URL", + "credentials_unreadable": "Les identifiants ne peuvent pas être déchiffrés", + "upstream_invalid_json": "Le service en amont a envoyé un JSON non valide", + "vapid_not_configured": "Web Push non configuré (clés VAPID manquantes)", + "rate_limited_self": "Trop de requêtes de test", + "generic": "Échec du test" } }, "notificationStatus": { "title": "Channel reliability", "description": "Live status of every notification channel — Auto-disabled means HealthLog stopped retrying after a permanent error.", - "emptyDescription": "No channels configured yet. Add a channel below to start tracking its delivery health.", + "emptyDescription": "Aucun canal configuré pour l’instant. Ajoutez un canal ci-dessous pour commencer à suivre l’état de sa livraison.", "stateActive": "Actif", - "stateAutoDisabled": "Auto-disabled", - "stateSendingPaused": "Sending paused", + "stateAutoDisabled": "Désactivé automatiquement", + "stateSendingPaused": "Envoi en pause", "stateManuallyDisabled": "Désactivé", - "lastSuccess": "Last successful send", - "lastFailure": "Last failure", - "consecutiveFailures": "Consecutive failures", - "disabledReason": "Reason", - "nextRetry": "Next retry", - "reEnable": "Re-enable", - "sendTest": "Send test" + "lastSuccess": "Dernier envoi réussi", + "lastFailure": "Dernier échec", + "consecutiveFailures": "Échecs consécutifs", + "disabledReason": "Raison", + "nextRetry": "Prochaine tentative", + "reEnable": "Réactiver", + "sendTest": "Envoyer un test" }, "identity": { "description": "Informations facultatives pour l’export du dossier de santé (couverture PDF + export FHIR). Tous les champs sont facultatifs.", diff --git a/messages/it.json b/messages/it.json index 0024fc75..55152c79 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2998,31 +2998,31 @@ "disableError": "Impossibile disattivare la modalità Ricerca. Riprova." }, "shell": { - "sectionsNav": "Settings sections" + "sectionsNav": "Sezioni delle impostazioni" }, "sections": { "account": { "title": "Account", - "description": "Profile, password, passkeys, and your onboarding tour." + "description": "Profilo, password, passkey e il tour di benvenuto." }, "integrations": { "title": "Integrazioni", - "description": "Withings, moodLog, and other connected services." + "description": "Withings, moodLog e altri servizi collegati." }, "notifications": { "title": "Notifiche", - "description": "Live overview of every configured channel." + "description": "Panoramica in tempo reale di ogni canale configurato." }, "dashboard": { "title": "Cruscotto", - "description": "Tile layout and order." + "description": "Layout e ordine dei riquadri." }, "thresholds": { "title": "Obiettivi", "description": "Per metrica: il tuo intervallo target per valori sani." }, "sources": { - "title": "Fonti", + "title": "Sorgenti", "description": "Scegli quale fonte prevale quando più di una registra la stessa metrica lo stesso giorno.", "cardTitle": "Priorità delle fonti per metrica", "help": "Quando più fonti forniscono lo stesso valore nello stesso giorno (ad es. Withings e Apple Health riportano entrambe 1.200 passi), la fonte in cima conta come lettura canonica. Le altre fonti restano memorizzate per la traccia di controllo.", @@ -3078,12 +3078,12 @@ } }, "ai": { - "title": "AI Insights", - "description": "Provider, model, key." + "title": "Analisi IA", + "description": "Provider, modello, chiave." }, "api": { "title": "API & Tokens", - "description": "Bearer tokens for your own scripts and apps — log measurements or medication intake from anywhere." + "description": "Token Bearer per i tuoi script e le tue app — registra misurazioni o assunzioni di farmaci da ovunque." }, "advanced": { "title": "Avanzate", @@ -3091,7 +3091,7 @@ }, "export": { "title": "Esporta", - "description": "Download your health data as PDF, CSV, or a full JSON backup.", + "description": "Scarica i tuoi dati sanitari come PDF, CSV o backup JSON completo.", "otherOptionsHeading": "Altre opzioni di esportazione", "hero": { "eyebrow": "Visita medica", @@ -3100,39 +3100,39 @@ "formatHint": "PDF · pronto per la stampa" }, "actions": { - "download": "Download" + "download": "Scarica" }, "filters": { "since": "Da", - "until": "Until" + "until": "Fino al" }, "cards": { "doctorReport": { - "title": "Doctor Report", - "description": "Printable PDF for the doctor's appointment — vitals, BMI, BP classification, medication compliance, mood." + "title": "Referto medico", + "description": "PDF stampabile per la visita medica — parametri vitali, IMC, classificazione della pressione, aderenza ai farmaci, umore." }, "measurementsCsv": { - "title": "Measurements", - "description": "Weight, blood pressure, pulse, glucose, and every other measurement as comma-separated values." + "title": "Misurazioni", + "description": "Peso, pressione, polso, glicemia e tutte le altre misurazioni in valori separati da virgola." }, "medicationsCsv": { "title": "Farmaci", - "description": "Your medication list with dosage, schedules, and (optionally) the full intake history.", - "includeIntake": "Include intake history" + "description": "Il tuo elenco di farmaci con dosaggio, orari e (facoltativamente) lo storico completo delle assunzioni.", + "includeIntake": "Includi lo storico delle assunzioni" }, "moodCsv": { "title": "Umore", - "description": "Daily mood entries with score, tags, and timestamps." + "description": "Voci di umore giornaliere con punteggio, tag e orari." }, "fullBackup": { - "title": "Full backup", - "description": "Single JSON file with everything — same shape as the weekly auto-backup. Useful for self-restore via admin upload." + "title": "Backup completo", + "description": "Un unico file JSON con tutto — stesso formato del backup automatico settimanale. Utile per il ripristino autonomo tramite il caricamento da amministratore." } } }, "about": { - "title": "About", - "description": "Version, license, links." + "title": "Informazioni", + "description": "Versione, licenza, link." }, "sharing": { "title": "Condivisione", @@ -3141,20 +3141,20 @@ }, "profile": "Profilo", "language": "Lingua", - "languageDescription": "Choose the display language of the application.", + "languageDescription": "Scegli la lingua di visualizzazione dell’applicazione.", "username": "Nome utente", - "height": "Height (cm)", + "height": "Altezza (cm)", "dateOfBirth": "Data di nascita", - "dateOfBirthHint": "Used for automatic blood pressure target calculations.", + "dateOfBirthHint": "Usato per il calcolo automatico degli obiettivi di pressione arteriosa.", "gender": "Sesso", - "genderNone": "Not specified", + "genderNone": "Non specificato", "genderMale": "Uomo", "genderFemale": "Donna", - "genderHint": "Used for gender-specific target values.", + "genderHint": "Usato per valori obiettivo specifici per sesso.", "timezone": "Fuso orario", - "timezoneHint": "Used for chart axis labels, reminder times, export timestamps, and Coach context. Stored data is unchanged.", - "timezoneInvalid": "Not a valid IANA timezone.", - "profileSaved": "Profile saved", + "timezoneHint": "Usato per le etichette degli assi dei grafici, gli orari dei promemoria, gli orari di esportazione e il contesto del Coach. I dati memorizzati restano invariati.", + "timezoneInvalid": "Non è un fuso orario IANA valido.", + "profileSaved": "Profilo salvato", "avatar": { "title": "Foto del profilo", "description": "Carica una foto archiviata sul tuo server. Appare in tutta l'app al posto di un servizio di avatar esterno.", @@ -3168,174 +3168,174 @@ "tooLarge": "L'immagine supera il limite di 2 MB.", "error": "Impossibile aggiornare la foto del profilo. Riprova." }, - "changePassword": "Change password", - "changePasswordDescription": "Replace your current password with a new one.", - "passwordReset": "Password reset", - "currentPassword": "Current password", - "newPassword": "New password", - "confirmNewPassword": "Confirm new password", - "passwordMismatch": "New passwords do not match", - "passwordUpdated": "Password updated successfully", + "changePassword": "Cambia password", + "changePasswordDescription": "Sostituisci la password attuale con una nuova.", + "passwordReset": "Reimposta password", + "currentPassword": "Password attuale", + "newPassword": "Nuova password", + "confirmNewPassword": "Conferma nuova password", + "passwordMismatch": "Le nuove password non coincidono", + "passwordUpdated": "Password aggiornata correttamente", "passkeys": "Passkeys", - "registeredPasskeys": "Registered Passkeys", - "passkeysDescription": "Passkeys allow secure login without a password.", + "registeredPasskeys": "Passkey registrate", + "passkeysDescription": "Le passkey consentono un accesso sicuro senza password.", "passkeyName": "Nome", - "passkeyDevice": "Device", + "passkeyDevice": "Dispositivo", "passkeyBackup": "Backup", - "passkeyCreated": "Created", + "passkeyCreated": "Creata", "passkeyActions": "Azioni", - "noPasskeys": "No passkeys registered.", - "addPasskey": "Add passkey", - "passkeyAdded": "Passkey added successfully!", - "passkeyOptionsError": "Could not load passkey options", - "passkeyRegistrationFailed": "Passkey registration failed", - "passkeyRegistrationCancelled": "Passkey registration cancelled", - "passkeyNotSupported": "Your browser or device doesn't support passkeys yet. Try a different browser or device.", - "passkeyAlreadyRegistered": "This device is already registered as a passkey for your account.", - "passkeySecurityBlocked": "Your browser blocked the passkey request for security reasons. Make sure you're on a trusted HTTPS origin and try again.", - "passkeyTimeout": "The passkey prompt timed out. Please try again.", - "passkeyUnknownError": "Passkey registration failed: {message}", - "deletePasskey": "Delete passkey?", - "deletePasskeyDescription": "The passkey will be permanently deleted.", - "singleDevice": "Single device", - "multiDevice": "Multi-device", - "backedUp": "backed up", + "noPasskeys": "Nessuna passkey registrata.", + "addPasskey": "Aggiungi passkey", + "passkeyAdded": "Passkey aggiunta correttamente!", + "passkeyOptionsError": "Impossibile caricare le opzioni della passkey", + "passkeyRegistrationFailed": "Registrazione della passkey non riuscita", + "passkeyRegistrationCancelled": "Registrazione della passkey annullata", + "passkeyNotSupported": "Il tuo browser o dispositivo non supporta ancora le passkey. Prova un altro browser o dispositivo.", + "passkeyAlreadyRegistered": "Questo dispositivo è già registrato come passkey del tuo account.", + "passkeySecurityBlocked": "Il tuo browser ha bloccato la richiesta di passkey per motivi di sicurezza. Assicurati di essere su un’origine HTTPS attendibile e riprova.", + "passkeyTimeout": "La richiesta della passkey è scaduta. Riprova.", + "passkeyUnknownError": "Registrazione della passkey non riuscita: {message}", + "deletePasskey": "Eliminare la passkey?", + "deletePasskeyDescription": "La passkey verrà eliminata definitivamente.", + "singleDevice": "Dispositivo singolo", + "multiDevice": "Multi-dispositivo", + "backedUp": "con backup", "telegram": "Telegram Notifications", "telegramDescription": "Get reminders for missed medication intake via Telegram.", - "telegramSaved": "Telegram settings saved", - "botToken": "Bot Token", - "chatId": "Chat ID", - "enableNotifications": "Enable notifications", - "testMessage": "Test message", - "testSent": "Test message sent!", - "telegramStep1": "1. Create a bot via @BotFather in Telegram and copy the token.", - "telegramStep2": "2. Send /start to your bot to activate the chat.", - "telegramStep3": "3. Find your Chat ID via @userinfobot or the Bot API.", + "telegramSaved": "Impostazioni Telegram salvate", + "botToken": "Token del bot", + "chatId": "ID chat", + "enableNotifications": "Attiva le notifiche", + "testMessage": "Messaggio di prova", + "testSent": "Messaggio di prova inviato!", + "telegramStep1": "1. Crea un bot tramite @BotFather su Telegram e copia il token.", + "telegramStep2": "2. Invia /start al tuo bot per attivare la chat.", + "telegramStep3": "3. Trova il tuo ID chat tramite @userinfobot o l’API del bot.", "ntfy": "ntfy", - "ntfyDescription": "Notifications via ntfy (self-hosted or ntfy.sh).", - "ntfyEnable": "Enable ntfy", - "ntfyServer": "Server URL", + "ntfyDescription": "Notifiche tramite ntfy (self-hosted o ntfy.sh).", + "ntfyEnable": "Attiva ntfy", + "ntfyServer": "URL del server", "ntfyTopic": "Topic", - "ntfyAuthToken": "Auth Token (optional)", - "ntfyAuthTokenHint": "Only needed for private topics with access control.", - "webPush": "Browser Push", - "webPushDescription": "Receive notifications directly in your browser, even when HealthLog is not open.", - "webPushNotSupported": "Your browser does not support push notifications.", - "webPushDenied": "Push notifications are blocked. Allow them in your browser settings.", - "webPushNotConfigured": "Web Push is not configured on the server.", - "webPushSubscribe": "Enable push", - "webPushUnsubscribe": "Disable push", - "webPushSubscribed": "Push notifications enabled!", - "webPushUnsubscribed": "Push notifications disabled.", - "webPushSubscribeFailed": "Activation failed", + "ntfyAuthToken": "Token di autenticazione (facoltativo)", + "ntfyAuthTokenHint": "Necessario solo per topic privati con controllo degli accessi.", + "webPush": "Push del browser", + "webPushDescription": "Ricevi notifiche direttamente nel browser, anche quando HealthLog non è aperto.", + "webPushNotSupported": "Il tuo browser non supporta le notifiche push.", + "webPushDenied": "Le notifiche push sono bloccate. Consentile nelle impostazioni del browser.", + "webPushNotConfigured": "Web Push non è configurato sul server.", + "webPushSubscribe": "Attiva push", + "webPushUnsubscribe": "Disattiva push", + "webPushSubscribed": "Notifiche push attivate!", + "webPushUnsubscribed": "Notifiche push disattivate.", + "webPushSubscribeFailed": "Attivazione non riuscita", "webPushActive": "Attivo", - "kiInsightsDescription": "Optional: Save your OpenAI key for daily, automatic evaluations in Insights.", - "codexConnected": "ChatGPT connected successfully! Insights are now active.", - "codexDisconnected": "ChatGPT connection removed.", - "codexConnectionFailed": "ChatGPT connection failed. Please try again.", - "rawData": "Send raw data", - "rawDataOnDescription": "Aggregated metrics plus anonymized raw points from the last 30 days are sent. This includes per-metric measurements (for example weight, blood pressure, pulse) with time context so the provider can detect patterns, outliers, and correlations more reliably. Name, email, and direct account identifiers are not transmitted.", - "rawDataOffDescription": "Only summarized metrics are sent (for example averages, trends, minimum/maximum). No individual points and no exact time context. This is more privacy-preserving, but the provider can be less precise for short-term patterns and fluctuations.", - "rawDataWarning": "Raw mode enabled: the provider receives additional anonymized points from the last 30 days for higher analysis accuracy.", - "regenerateInsights": "Regenerate reports", - "regenerateSuccess": "Reports regenerated successfully", - "regenerateRateLimit": "Please wait — you've hit the hourly limit for analyses.", - "lastGeneratedAt": "Last generated", + "kiInsightsDescription": "Facoltativo: salva la tua chiave OpenAI per analisi giornaliere e automatiche in Insights.", + "codexConnected": "ChatGPT connesso correttamente! Insights è ora attivo.", + "codexDisconnected": "Connessione a ChatGPT rimossa.", + "codexConnectionFailed": "Connessione a ChatGPT non riuscita. Riprova.", + "rawData": "Invia dati grezzi", + "rawDataOnDescription": "Vengono inviate metriche aggregate più punti grezzi anonimizzati degli ultimi 30 giorni. Ciò include misurazioni per singola metrica (ad esempio peso, pressione, polso) con contesto temporale, in modo che il provider possa rilevare in modo più affidabile schemi, valori anomali e correlazioni. Nome, e-mail e identificatori diretti dell’account non vengono trasmessi.", + "rawDataOffDescription": "Vengono inviate solo metriche riassuntive (ad esempio medie, tendenze, minimo/massimo). Nessun punto individuale e nessun contesto temporale preciso. Questo tutela meglio la privacy, ma il provider può essere meno preciso su schemi e oscillazioni a breve termine.", + "rawDataWarning": "Modalità grezza attiva: il provider riceve ulteriori punti anonimizzati degli ultimi 30 giorni per un’analisi più precisa.", + "regenerateInsights": "Rigenera i report", + "regenerateSuccess": "Report rigenerati correttamente", + "regenerateRateLimit": "Attendi — hai raggiunto il limite orario di analisi.", + "lastGeneratedAt": "Ultima generazione", "ai": { - "chatgptConnectedBadge": "ChatGPT connected", - "adminAiActiveBadge": "Admin provider active", - "connectionExpiredBadge": "Connection expired", - "connectedSince": "Connected since {when}.", - "deviceCodeHeading": "Finish connecting on chatgpt.com", - "deviceCodeStep1": "Open this link on any device:", - "deviceCodeStep2": "Enter this one-time code:", - "deviceCodeStep3": "Approve the connection — this page updates automatically.", + "chatgptConnectedBadge": "ChatGPT connesso", + "adminAiActiveBadge": "Provider amministratore attivo", + "connectionExpiredBadge": "Connessione scaduta", + "connectedSince": "Connesso da {when}.", + "deviceCodeHeading": "Completa la connessione su chatgpt.com", + "deviceCodeStep1": "Apri questo link su qualsiasi dispositivo:", + "deviceCodeStep2": "Inserisci questo codice monouso:", + "deviceCodeStep3": "Approva la connessione — questa pagina si aggiorna automaticamente.", "deviceCodeCopy": "Copia", - "deviceCodeWaiting": "Waiting for approval…", + "deviceCodeWaiting": "In attesa di approvazione…", "deviceCodeCancel": "Annulla", - "oauthNotConfigured": "ChatGPT OAuth is not configured on this instance — use your own API key below instead.", - "modelLabel": "Model", - "modelOptionDefault": "— Default —", - "modelOptionCustom": "Custom…", - "customModelLabel": "Custom model name", - "anthropicKeyLabel": "Anthropic API key", + "oauthNotConfigured": "L’OAuth di ChatGPT non è configurato su questa istanza — usa invece la tua chiave API qui sotto.", + "modelLabel": "Modello", + "modelOptionDefault": "— Predefinito —", + "modelOptionCustom": "Personalizzato…", + "customModelLabel": "Nome del modello personalizzato", + "anthropicKeyLabel": "Chiave API Anthropic", "baseUrlLabel": "Base URL", - "localKeyLabel": "API key (optional)", - "savedPreview": "(saved {preview})", - "savedShort": "(saved)", + "localKeyLabel": "Chiave API (facoltativa)", + "savedPreview": "(salvata {preview})", + "savedShort": "(salvata)", "saveCta": "Salva", "saved": "Salvato", - "saveFailed": "Save failed", + "saveFailed": "Salvataggio non riuscito", "errorGeneric": "Errore", "providerChain": { "types": { "codex": "ChatGPT (Codex)", - "openai": "OpenAI (your key)", + "openai": "OpenAI (la tua chiave)", "anthropic": "Anthropic (Claude)", - "local": "Local model", - "admin-openai": "Admin OpenAI" + "local": "Modello locale", + "admin-openai": "OpenAI amministratore" }, - "title": "Fallback chain", - "description": "If the primary provider fails, HealthLog walks the chain in order. Drag the rows to reorder, toggle the switch to disable a provider without removing it.", - "moveUp": "Move up", - "moveDown": "Move down", - "removeFromChain": "Remove from chain", - "addProvider": "Add provider", - "addNoneAvailable": "All providers already in chain", - "saveOrder": "Save chain order", - "saved": "Chain saved", - "saveFailed": "Saving the chain failed", - "resetDefaults": "Reset to defaults", - "resetConfirmTitle": "Reset chain to defaults?", - "resetConfirmBody": "The chain reverts to Codex → OpenAI → Anthropic → Local → Admin OpenAI. Your saved credentials are not touched." - }, - "activeProviderHeading": "Active provider", - "activeProviderBody": "Pick the provider you want to use first. The form below configures only the selected provider; the fallback chain at the bottom decides what happens if it fails.", - "activeProviderLabel": "Primary provider", - "providerConfigTitle": "Provider configuration", + "title": "Catena di fallback", + "description": "Se il provider principale fallisce, HealthLog percorre la catena in ordine. Trascina le righe per riordinarle; usa l’interruttore per disattivare un provider senza rimuoverlo.", + "moveUp": "Sposta su", + "moveDown": "Sposta giù", + "removeFromChain": "Rimuovi dalla catena", + "addProvider": "Aggiungi provider", + "addNoneAvailable": "Tutti i provider sono già nella catena", + "saveOrder": "Salva l’ordine della catena", + "saved": "Catena salvata", + "saveFailed": "Salvataggio della catena non riuscito", + "resetDefaults": "Ripristina", + "resetConfirmTitle": "Ripristinare la catena ai valori predefiniti?", + "resetConfirmBody": "La catena torna a Codex → OpenAI → Anthropic → Locale → OpenAI amministratore. Le credenziali salvate non vengono toccate." + }, + "activeProviderHeading": "Provider attivo", + "activeProviderBody": "Scegli il provider da usare per primo. Il modulo qui sotto configura solo il provider selezionato; la catena di fallback in fondo decide cosa accade se fallisce.", + "activeProviderLabel": "Provider principale", + "providerConfigTitle": "Configurazione del provider", "providerSelect": { - "codex": "ChatGPT account (Codex)", - "openai": "OpenAI (your API key)", + "codex": "Account ChatGPT (Codex)", + "openai": "OpenAI (la tua chiave API)", "anthropic": "Anthropic (Claude)", - "local": "Local model (OpenAI-compatible)", - "admin-openai": "Admin-provided OpenAI" + "local": "Modello locale (compatibile con OpenAI)", + "admin-openai": "OpenAI fornito dall’amministratore" }, "openai": { - "modelSelect": "Model", - "modelOptionCustom": "Custom slug…", - "modelCustomLabel": "Custom model slug", + "modelSelect": "Modello", + "modelOptionCustom": "Slug personalizzato…", + "modelCustomLabel": "Slug del modello personalizzato", "modelCustomPlaceholder": "gpt-5", - "baseUrlLabel": "Base URL (advanced)", + "baseUrlLabel": "URL base (avanzato)", "baseUrlPlaceholder": "https://api.openai.com/v1", - "baseUrlHelp": "Override only when using an OpenAI-compatible gateway. Leave blank for OpenAI itself.", - "showAdvanced": "Show advanced", - "hideAdvanced": "Hide advanced", - "apiKey": "API key", + "baseUrlHelp": "Sovrascrivi solo se usi un gateway compatibile con OpenAI. Lascia vuoto per OpenAI stesso.", + "showAdvanced": "Mostra avanzate", + "hideAdvanced": "Nascondi avanzate", + "apiKey": "Chiave API", "apiKeyPlaceholder": "sk-…" }, "codex": { - "statusConnected": "Connected", - "statusDisconnected": "Not connected", - "statusExpired": "Connection expired", - "connectButton": "Connect with ChatGPT", - "disconnectButton": "Disconnect", - "modelSlugLabel": "Model slug", - "modelSlugBody": "Codex uses the model your ChatGPT subscription routes to. The CODEX_MODEL environment variable lets ops override this on the instance.", - "lastInsight": "Last insight: {when}" + "statusConnected": "Connesso", + "statusDisconnected": "Non connesso", + "statusExpired": "Connessione scaduta", + "connectButton": "Connetti con ChatGPT", + "disconnectButton": "Disconnetti", + "modelSlugLabel": "Slug del modello", + "modelSlugBody": "Codex usa il modello a cui instrada il tuo abbonamento ChatGPT. La variabile d’ambiente CODEX_MODEL consente all’operatore di sovrascriverlo sull’istanza.", + "lastInsight": "Ultima analisi: {when}" }, "adminOpenai": { - "title": "Admin OpenAI", - "body": "Operator-provided OpenAI key. Used as a last-ditch fallback when no personal provider is configured. There is nothing to configure on this row — visibility means the operator has set it up.", - "notConfigured": "The operator has not configured a shared OpenAI key on this instance." + "title": "OpenAI amministratore", + "body": "Chiave OpenAI fornita dall’operatore. Usata come ultima risorsa quando nessun provider personale è configurato. Non c’è nulla da configurare in questa riga — la sua visibilità indica che l’operatore l’ha attivata.", + "notConfigured": "L’operatore non ha configurato una chiave OpenAI condivisa su questa istanza." }, - "testProvider": "Test active provider", + "testProvider": "Prova il provider attivo", "testSuccess": "OK — {provider} ({model})", - "testFailedShort": "Test failed: {message}", - "testUnexpectedResponse": "AI provider connection failed — unexpected response from the server.", - "testReasonCredentials": "Provider rejected the credentials — re-authenticate in AI settings.", - "testReasonRateLimited": "Provider rate-limited the request — try again shortly.", - "testReasonServerError": "The AI provider returned a server error.", - "testReasonUnreachable": "Could not reach the AI provider.", + "testFailedShort": "Prova non riuscita: {message}", + "testUnexpectedResponse": "Connessione al provider IA non riuscita — risposta inattesa dal server.", + "testReasonCredentials": "Il provider ha rifiutato le credenziali — autenticati di nuovo nelle impostazioni IA.", + "testReasonRateLimited": "Il provider ha limitato la richiesta — riprova a breve.", + "testReasonServerError": "Il provider IA ha restituito un errore del server.", + "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.", @@ -3380,28 +3380,28 @@ } }, "withings": "Withings", - "withingsDescription": "Connect your Withings scale and blood pressure monitors.", - "withingsCredentials": "API Credentials", - "withingsClientId": "Client ID", - "withingsClientSecret": "Client Secret", - "withingsCredentialsSaved": "Credentials saved", - "withingsCredentialsSavedPlaceholder": "Saved — enter new to replace", - "withingsCredentialsSavedPlaceholderSecret": "Saved — enter new to replace", - "withingsSaveCredentials": "Save credentials", - "configured": "Configured", - "withingsSync": "Sync now", - "withingsFullSync": "Sync all data", - "withingsFullSyncTitle": "Full synchronization?", - "withingsFullSyncDescription": "All available Withings data will be fully synchronized. This may take some time depending on your history.", - "withingsSyncResult": "{count} measurements synchronized", - "withingsFullSyncResult": "{count} measurements fully synchronized", - "withingsSyncFailed": "Sync failed", - "withingsSynchronize": "Synchronize", - "withingsDisconnect": "Disconnect", - "withingsDisconnectTitle": "Disconnect Withings?", - "withingsDisconnectDescription": "The connection to Withings will be disconnected. Previously synced data will be preserved.", - "withingsConnect": "Connect with Withings", - "withingsNoCredentials": "Please enter your API credentials above to connect Withings.", + "withingsDescription": "Collega la tua bilancia e i misuratori di pressione Withings.", + "withingsCredentials": "Credenziali API", + "withingsClientId": "ID client", + "withingsClientSecret": "Secret client", + "withingsCredentialsSaved": "Credenziali salvate", + "withingsCredentialsSavedPlaceholder": "Salvato — inseriscine uno nuovo per sostituirlo", + "withingsCredentialsSavedPlaceholderSecret": "Salvato — inseriscine uno nuovo per sostituirlo", + "withingsSaveCredentials": "Salva credenziali", + "configured": "Configurato", + "withingsSync": "Sincronizza ora", + "withingsFullSync": "Sincronizza tutti i dati", + "withingsFullSyncTitle": "Sincronizzazione completa?", + "withingsFullSyncDescription": "Tutti i dati Withings disponibili verranno sincronizzati completamente. Potrebbe richiedere del tempo a seconda del tuo storico.", + "withingsSyncResult": "{count} misurazioni sincronizzate", + "withingsFullSyncResult": "{count} misurazioni sincronizzate completamente", + "withingsSyncFailed": "Sincronizzazione non riuscita", + "withingsSynchronize": "Sincronizza", + "withingsDisconnect": "Disconnetti", + "withingsDisconnectTitle": "Disconnettere Withings?", + "withingsDisconnectDescription": "La connessione a Withings verrà interrotta. I dati già sincronizzati saranno conservati.", + "withingsConnect": "Connetti con Withings", + "withingsNoCredentials": "Inserisci prima le tue credenziali API qui sopra per collegare Withings.", "whoop": "WHOOP", "whoopDescription": "Collega la tua fascia WHOOP per sincronizzare recupero, sonno, sforzo e allenamenti.", "whoopOverlapNote": "Se WHOOP e un'altra fonte forniscono lo stesso valore vitale — frequenza cardiaca a riposo, ossigeno nel sangue, temperatura corporea, frequenza respiratoria o fasi del sonno —, potresti vedere entrambi i valori finché un futuro aggiornamento non sceglierà un'unica fonte preferita.", @@ -3430,9 +3430,9 @@ "withings": { "reconnect": { "banner": { - "title": "Activity sync available", - "body": "Your Withings connection was authorised before activity sync was added. Reconnect once to enable steps, active energy, walking + running distance, and floors-climbed ingest from your Withings devices.", - "action": "Reconnect Withings" + "title": "Sincronizzazione attività disponibile", + "body": "La tua connessione Withings è stata autorizzata prima dell’aggiunta della sincronizzazione attività. Riconnettiti una volta per abilitare l’importazione di passi, energia attiva, distanza a piedi e di corsa, e piani saliti dai tuoi dispositivi Withings.", + "action": "Riconnetti Withings" } } } @@ -3443,132 +3443,132 @@ "warningServerError": "Connesso, errore del server", "parkedReconnect": "In pausa — riconnettere manualmente", "notConnected": "Non connesso", - "justNow": "just now", - "minutesAgo": "{count} min ago", - "hoursAgo": "{count} h ago", - "daysAgo": "{count} d ago", - "ariaLabel": "Integration status", + "justNow": "proprio ora", + "minutesAgo": "{count} min fa", + "hoursAgo": "{count} h fa", + "daysAgo": "{count} g fa", + "ariaLabel": "Stato dell’integrazione", "resumeCta": "Riconnetti", "resumeSuccess": "Integrazione ripresa", "resumeError": "Riconnessione fallita" }, - "apiTokens": "API Tokens", - "apiTokensDescription": "Create API tokens for external medication intake (e.g., Shortcuts, automations).", - "tokenNamePlaceholder": "Token name (e.g., iPhone Shortcut)", - "tokenCreated": "Token created — copy it now! It won't be shown again.", - "tokenRevoke": "Revoke token?", - "tokenRevokeDescription": "The token will be permanently deactivated. Applications using it will lose access.", - "tokenRevoked": "Revoked", - "tokenExpired": "Expired", + "apiTokens": "Token API", + "apiTokensDescription": "Crea token API per registrare assunzioni di farmaci esterne (ad es. Comandi rapidi, automazioni).", + "tokenNamePlaceholder": "Nome del token (ad es. Comando rapido iPhone)", + "tokenCreated": "Token creato — copialo ora! Non verrà mostrato di nuovo.", + "tokenRevoke": "Revocare il token?", + "tokenRevokeDescription": "Il token verrà disattivato definitivamente. Le applicazioni che lo usano perderanno l’accesso.", + "tokenRevoked": "Revocato", + "tokenExpired": "Scaduto", "tokenActive": "Attivo", - "activeTokensTitle": "Active tokens", - "revokedTokensTitle": "Revoked tokens ({count})", + "activeTokensTitle": "Token attivi", + "revokedTokensTitle": "Token revocati ({count})", "tokenTableName": "Nome", - "tokenTablePermissions": "Permissions", + "tokenTablePermissions": "Autorizzazioni", "tokenTableStatus": "Stato", - "tokenTableCreated": "Created", - "tokenTableLastUsed": "Last used", + "tokenTableCreated": "Creato", + "tokenTableLastUsed": "Ultimo utilizzo", "tokenTableActions": "Azioni", - "noActiveTokens": "No active tokens available.", - "tokenNeverUsed": "Never", - "apiEndpointsTitle": "API endpoints", - "apiEndpointsDescription": "Overview of currently available external endpoint(s) for token-based ingestion.", - "apiEndpointMethod": "Method", - "apiEndpointPath": "Path", - "apiEndpointAuth": "Authentication", - "apiEndpointExample": "Body example", - "collapse": "Collapse", - "expand": "Expand", - "dangerZone": "Delete All Data", - "dangerZoneTitle": "Danger zone", - "dangerZoneDescription": "Deletes all your health data and integrations. Your user account will be preserved.", - "dangerZoneConfirm": "Delete all data?", - "dangerZoneConfirmDescription": "This will permanently delete all your health data and integrations. Your user account will be preserved.", - "dangerZoneSuccess": "All personal data has been deleted", - "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.", - "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.", - "deleteAccountSuccess": "Account deleted — signing you out.", - "deleteAccountFailed": "Account could not be deleted", - "deleteAccountFinal": "Delete permanently", + "noActiveTokens": "Nessun token attivo disponibile.", + "tokenNeverUsed": "Mai", + "apiEndpointsTitle": "Endpoint API", + "apiEndpointsDescription": "Panoramica degli endpoint esterni attualmente disponibili per l’inserimento basato su token.", + "apiEndpointMethod": "Metodo", + "apiEndpointPath": "Percorso", + "apiEndpointAuth": "Autenticazione", + "apiEndpointExample": "Esempio di body", + "collapse": "Comprimi", + "expand": "Espandi", + "dangerZone": "Elimina tutti i dati", + "dangerZoneTitle": "Zona pericolosa", + "dangerZoneDescription": "Elimina tutti i tuoi dati sanitari e le integrazioni. Il tuo account utente verrà conservato.", + "dangerZoneConfirm": "Eliminare tutti i dati?", + "dangerZoneConfirmDescription": "Questa azione eliminerà definitivamente tutti i tuoi dati sanitari e le integrazioni. Il tuo account utente verrà conservato.", + "dangerZoneSuccess": "Tutti i dati personali sono stati eliminati", + "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.", + "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.", + "deleteAccountSuccess": "Account eliminato — disconnessione in corso.", + "deleteAccountFailed": "Impossibile eliminare l’account", + "deleteAccountFinal": "Elimina definitivamente", "saved": "Salvato", - "savingError": "Error saving", + "savingError": "Errore durante il salvataggio", "moodLogTitle": "moodLog", "moodLogDescription": "Import mood data from moodLog", "moodLogUrl": "moodLog URL", "moodLogUrlPlaceholder": "https://mood.example.com", - "moodLogApiKey": "API Key", + "moodLogApiKey": "Chiave API", "moodLogApiKeyPlaceholder": "ml_...", - "moodLogWebhookSecret": "Webhook Secret", - "moodLogWebhookSecretHelp": "Enter this secret in moodLog as webhook secret", - "moodLogSync": "Start sync", - "moodLogFullSync": "Full sync", - "moodLogFullSyncConfirm": "Synchronize fully", - "moodLogFullSyncTitle": "Full sync?", - "moodLogFullSyncDescription": "All available mood data will be fully synchronized.", - "moodLogDisconnect": "Disconnect", - "moodLogDisconnectTitle": "Disconnect moodLog?", - "moodLogDisconnectDescription": "The connection will be removed and all imported mood data will be deleted.", - "moodLogEntries": "Entries", - "moodLogSyncResult": "{count} entries synchronized", - "moodLogSyncFailed": "Sync failed", - "moodLogSaved": "moodLog connection saved", - "moodLogDisconnected": "moodLog disconnected", + "moodLogWebhookSecret": "Secret del webhook", + "moodLogWebhookSecretHelp": "Inserisci questo secret in moodLog come secret del webhook", + "moodLogSync": "Avvia sincronizzazione", + "moodLogFullSync": "Sincronizzazione completa", + "moodLogFullSyncConfirm": "Sincronizza completamente", + "moodLogFullSyncTitle": "Sincronizzazione completa?", + "moodLogFullSyncDescription": "Tutti i dati sull’umore disponibili verranno sincronizzati completamente.", + "moodLogDisconnect": "Disconnetti", + "moodLogDisconnectTitle": "Disconnettere moodLog?", + "moodLogDisconnectDescription": "La connessione verrà rimossa e tutti i dati sull’umore importati verranno eliminati.", + "moodLogEntries": "Voci", + "moodLogSyncResult": "{count} voci sincronizzate", + "moodLogSyncFailed": "Sincronizzazione non riuscita", + "moodLogSaved": "Connessione moodLog salvata", + "moodLogDisconnected": "moodLog disconnesso", "about": { - "version": "App version", + "version": "Versione dell’app", "gitSha": "Build", - "builtAt": "Built {time}", - "builtAtLabel": "Built", - "license": "License", - "repository": "Source code", + "builtAt": "Compilato {time}", + "builtAtLabel": "Compilato", + "license": "Licenza", + "repository": "Codice sorgente", "changelog": "Changelog", - "docs": "Documentation", + "docs": "Documentazione", "linksHeading": "Fonti e documentazione", "newerAvailable": "Nuova versione: {tag}", "tourReplay": "Rivedi il tour", "tourReplayHint": "Guarda il tour della dashboard — utile se l'hai saltato alla prima visita." }, "testConnection": { - "test": "Test connection", - "testing": "Testing…", - "ok": "Connected (latency {latency} ms)", + "test": "Prova connessione", + "testing": "Test in corso…", + "ok": "Connesso (latenza {latency} ms)", "errors": { - "credentials_rejected": "Credentials rejected — check your token", - "rate_limited": "Rate-limited by the upstream", - "timeout": "Request timed out", - "upstream_error": "Upstream returned an error", - "connection_failed": "Connection failed", - "not_configured": "Not configured", - "url_not_public": "URL is not a public endpoint", - "url_invalid": "URL is invalid", - "redirected": "Endpoint redirects — check the URL", - "endpoint_not_found": "Endpoint not found at the URL", - "credentials_unreadable": "Credentials cannot be decrypted", - "upstream_invalid_json": "Upstream sent invalid JSON", - "vapid_not_configured": "Web Push not configured (VAPID keys missing)", - "rate_limited_self": "Too many test requests", - "generic": "Test failed" + "credentials_rejected": "Credenziali rifiutate — controlla il token", + "rate_limited": "Limitato dal servizio upstream", + "timeout": "Richiesta scaduta", + "upstream_error": "Il servizio upstream ha restituito un errore", + "connection_failed": "Connessione non riuscita", + "not_configured": "Non configurato", + "url_not_public": "L’URL non è un endpoint pubblico", + "url_invalid": "L’URL non è valido", + "redirected": "L’endpoint reindirizza — controlla l’URL", + "endpoint_not_found": "Endpoint non trovato all’URL", + "credentials_unreadable": "Impossibile decifrare le credenziali", + "upstream_invalid_json": "Il servizio upstream ha inviato un JSON non valido", + "vapid_not_configured": "Web Push non configurato (chiavi VAPID mancanti)", + "rate_limited_self": "Troppe richieste di prova", + "generic": "Prova non riuscita" } }, "notificationStatus": { "title": "Channel reliability", "description": "Live status of every notification channel — Auto-disabled means HealthLog stopped retrying after a permanent error.", - "emptyDescription": "No channels configured yet. Add a channel below to start tracking its delivery health.", + "emptyDescription": "Nessun canale ancora configurato. Aggiungi un canale qui sotto per iniziare a monitorarne la consegna.", "stateActive": "Attivo", - "stateAutoDisabled": "Auto-disabled", - "stateSendingPaused": "Sending paused", + "stateAutoDisabled": "Disattivato automaticamente", + "stateSendingPaused": "Invio in pausa", "stateManuallyDisabled": "Disabilitato", - "lastSuccess": "Last successful send", - "lastFailure": "Last failure", - "consecutiveFailures": "Consecutive failures", - "disabledReason": "Reason", - "nextRetry": "Next retry", - "reEnable": "Re-enable", - "sendTest": "Send test" + "lastSuccess": "Ultimo invio riuscito", + "lastFailure": "Ultimo errore", + "consecutiveFailures": "Errori consecutivi", + "disabledReason": "Motivo", + "nextRetry": "Prossimo tentativo", + "reEnable": "Riattiva", + "sendTest": "Invia prova" }, "identity": { "description": "Dati facoltativi per l’esportazione della cartella clinica (copertina PDF + esportazione FHIR). Tutti i campi sono facoltativi.", diff --git a/messages/pl.json b/messages/pl.json index ada1a0cc..c68e1d16 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2998,24 +2998,24 @@ "disableError": "Nie udało się wyłączyć trybu Badawczego. Spróbuj ponownie." }, "shell": { - "sectionsNav": "Settings sections" + "sectionsNav": "Sekcje ustawień" }, "sections": { "account": { "title": "Konto", - "description": "Profile, password, passkeys, and your onboarding tour." + "description": "Profil, hasło, klucze dostępu i przewodnik wprowadzający." }, "integrations": { "title": "Integracje", - "description": "Withings, moodLog, and other connected services." + "description": "Withings, moodLog i inne połączone usługi." }, "notifications": { "title": "Powiadomienia", - "description": "Live overview of every configured channel." + "description": "Podgląd na żywo każdego skonfigurowanego kanału." }, "dashboard": { "title": "Pulpit", - "description": "Tile layout and order." + "description": "Układ i kolejność kafelków." }, "thresholds": { "title": "Cele", @@ -3078,12 +3078,12 @@ } }, "ai": { - "title": "AI Insights", - "description": "Provider, model, key." + "title": "Analizy AI", + "description": "Dostawca, model, klucz." }, "api": { "title": "API & Tokens", - "description": "Bearer tokens for your own scripts and apps — log measurements or medication intake from anywhere." + "description": "Tokeny Bearer dla własnych skryptów i aplikacji — zapisuj pomiary lub przyjęcia leków z dowolnego miejsca." }, "advanced": { "title": "Zaawansowane", @@ -3091,7 +3091,7 @@ }, "export": { "title": "Eksportuj", - "description": "Download your health data as PDF, CSV, or a full JSON backup.", + "description": "Pobierz swoje dane zdrowotne jako PDF, CSV lub pełną kopię zapasową JSON.", "otherOptionsHeading": "Inne opcje eksportu", "hero": { "eyebrow": "Wizyta u lekarza", @@ -3100,39 +3100,39 @@ "formatHint": "PDF · gotowe do druku" }, "actions": { - "download": "Download" + "download": "Pobierz" }, "filters": { "since": "Od", - "until": "Until" + "until": "Do" }, "cards": { "doctorReport": { - "title": "Doctor Report", - "description": "Printable PDF for the doctor's appointment — vitals, BMI, BP classification, medication compliance, mood." + "title": "Raport dla lekarza", + "description": "PDF do druku na wizytę lekarską — parametry życiowe, BMI, klasyfikacja ciśnienia, przestrzeganie leczenia, nastrój." }, "measurementsCsv": { - "title": "Measurements", - "description": "Weight, blood pressure, pulse, glucose, and every other measurement as comma-separated values." + "title": "Pomiary", + "description": "Waga, ciśnienie, tętno, glukoza i wszystkie inne pomiary jako wartości rozdzielone przecinkami." }, "medicationsCsv": { "title": "Leki", - "description": "Your medication list with dosage, schedules, and (optionally) the full intake history.", - "includeIntake": "Include intake history" + "description": "Lista leków z dawkowaniem, harmonogramem i (opcjonalnie) pełną historią przyjęć.", + "includeIntake": "Dołącz historię przyjęć" }, "moodCsv": { "title": "Nastrój", - "description": "Daily mood entries with score, tags, and timestamps." + "description": "Codzienne wpisy nastroju z oceną, tagami i znacznikami czasu." }, "fullBackup": { - "title": "Full backup", - "description": "Single JSON file with everything — same shape as the weekly auto-backup. Useful for self-restore via admin upload." + "title": "Pełna kopia zapasowa", + "description": "Pojedynczy plik JSON ze wszystkim — taki sam format jak cotygodniowa automatyczna kopia. Przydatny do samodzielnego przywracania przez przesłanie w panelu administratora." } } }, "about": { - "title": "About", - "description": "Version, license, links." + "title": "O aplikacji", + "description": "Wersja, licencja, linki." }, "sharing": { "title": "Udostępnianie", @@ -3141,20 +3141,20 @@ }, "profile": "Profil", "language": "Język", - "languageDescription": "Choose the display language of the application.", + "languageDescription": "Wybierz język wyświetlania aplikacji.", "username": "Nazwa użytkownika", - "height": "Height (cm)", + "height": "Wzrost (cm)", "dateOfBirth": "Data urodzenia", - "dateOfBirthHint": "Used for automatic blood pressure target calculations.", + "dateOfBirthHint": "Używane do automatycznego obliczania docelowych wartości ciśnienia.", "gender": "Płeć", - "genderNone": "Not specified", + "genderNone": "Nie podano", "genderMale": "Mężczyzna", "genderFemale": "Kobieta", - "genderHint": "Used for gender-specific target values.", + "genderHint": "Używane do wartości docelowych zależnych od płci.", "timezone": "Strefa czasowa", - "timezoneHint": "Used for chart axis labels, reminder times, export timestamps, and Coach context. Stored data is unchanged.", - "timezoneInvalid": "Not a valid IANA timezone.", - "profileSaved": "Profile saved", + "timezoneHint": "Używane do etykiet osi wykresów, godzin przypomnień, znaczników czasu eksportu i kontekstu Coacha. Zapisane dane pozostają niezmienione.", + "timezoneInvalid": "To nie jest prawidłowa strefa czasowa IANA.", + "profileSaved": "Profil zapisany", "avatar": { "title": "Zdjęcie profilowe", "description": "Prześlij zdjęcie przechowywane na własnym serwerze. Pojawia się w całej aplikacji zamiast zewnętrznej usługi awatarów.", @@ -3168,174 +3168,174 @@ "tooLarge": "Obraz przekracza limit 2 MB.", "error": "Nie udało się zaktualizować zdjęcia profilowego. Spróbuj ponownie." }, - "changePassword": "Change password", - "changePasswordDescription": "Replace your current password with a new one.", - "passwordReset": "Password reset", - "currentPassword": "Current password", - "newPassword": "New password", - "confirmNewPassword": "Confirm new password", - "passwordMismatch": "New passwords do not match", - "passwordUpdated": "Password updated successfully", + "changePassword": "Zmień hasło", + "changePasswordDescription": "Zastąp obecne hasło nowym.", + "passwordReset": "Resetowanie hasła", + "currentPassword": "Obecne hasło", + "newPassword": "Nowe hasło", + "confirmNewPassword": "Potwierdź nowe hasło", + "passwordMismatch": "Nowe hasła nie są zgodne", + "passwordUpdated": "Hasło zaktualizowane pomyślnie", "passkeys": "Passkeys", - "registeredPasskeys": "Registered Passkeys", - "passkeysDescription": "Passkeys allow secure login without a password.", + "registeredPasskeys": "Zarejestrowane klucze dostępu", + "passkeysDescription": "Klucze dostępu umożliwiają bezpieczne logowanie bez hasła.", "passkeyName": "Nazwa", - "passkeyDevice": "Device", + "passkeyDevice": "Urządzenie", "passkeyBackup": "Backup", - "passkeyCreated": "Created", + "passkeyCreated": "Utworzono", "passkeyActions": "Akcje", - "noPasskeys": "No passkeys registered.", - "addPasskey": "Add passkey", - "passkeyAdded": "Passkey added successfully!", - "passkeyOptionsError": "Could not load passkey options", - "passkeyRegistrationFailed": "Passkey registration failed", - "passkeyRegistrationCancelled": "Passkey registration cancelled", - "passkeyNotSupported": "Your browser or device doesn't support passkeys yet. Try a different browser or device.", - "passkeyAlreadyRegistered": "This device is already registered as a passkey for your account.", - "passkeySecurityBlocked": "Your browser blocked the passkey request for security reasons. Make sure you're on a trusted HTTPS origin and try again.", - "passkeyTimeout": "The passkey prompt timed out. Please try again.", - "passkeyUnknownError": "Passkey registration failed: {message}", - "deletePasskey": "Delete passkey?", - "deletePasskeyDescription": "The passkey will be permanently deleted.", - "singleDevice": "Single device", - "multiDevice": "Multi-device", - "backedUp": "backed up", + "noPasskeys": "Brak zarejestrowanych kluczy dostępu.", + "addPasskey": "Dodaj klucz dostępu", + "passkeyAdded": "Klucz dostępu dodany pomyślnie!", + "passkeyOptionsError": "Nie udało się wczytać opcji klucza dostępu", + "passkeyRegistrationFailed": "Rejestracja klucza dostępu nie powiodła się", + "passkeyRegistrationCancelled": "Rejestracja klucza dostępu anulowana", + "passkeyNotSupported": "Twoja przeglądarka lub urządzenie nie obsługuje jeszcze kluczy dostępu. Wypróbuj inną przeglądarkę lub urządzenie.", + "passkeyAlreadyRegistered": "To urządzenie jest już zarejestrowane jako klucz dostępu Twojego konta.", + "passkeySecurityBlocked": "Twoja przeglądarka zablokowała żądanie klucza dostępu ze względów bezpieczeństwa. Upewnij się, że jesteś w zaufanym źródle HTTPS i spróbuj ponownie.", + "passkeyTimeout": "Żądanie klucza dostępu wygasło. Spróbuj ponownie.", + "passkeyUnknownError": "Rejestracja klucza dostępu nie powiodła się: {message}", + "deletePasskey": "Usunąć klucz dostępu?", + "deletePasskeyDescription": "Klucz dostępu zostanie trwale usunięty.", + "singleDevice": "Pojedyncze urządzenie", + "multiDevice": "Wiele urządzeń", + "backedUp": "z kopią zapasową", "telegram": "Telegram Notifications", "telegramDescription": "Get reminders for missed medication intake via Telegram.", - "telegramSaved": "Telegram settings saved", - "botToken": "Bot Token", - "chatId": "Chat ID", - "enableNotifications": "Enable notifications", - "testMessage": "Test message", - "testSent": "Test message sent!", - "telegramStep1": "1. Create a bot via @BotFather in Telegram and copy the token.", - "telegramStep2": "2. Send /start to your bot to activate the chat.", - "telegramStep3": "3. Find your Chat ID via @userinfobot or the Bot API.", + "telegramSaved": "Ustawienia Telegrama zapisane", + "botToken": "Token bota", + "chatId": "ID czatu", + "enableNotifications": "Włącz powiadomienia", + "testMessage": "Wiadomość testowa", + "testSent": "Wiadomość testowa wysłana!", + "telegramStep1": "1. Utwórz bota przez @BotFather w Telegramie i skopiuj token.", + "telegramStep2": "2. Wyślij /start do swojego bota, aby aktywować czat.", + "telegramStep3": "3. Znajdź swoje ID czatu przez @userinfobot lub API bota.", "ntfy": "ntfy", - "ntfyDescription": "Notifications via ntfy (self-hosted or ntfy.sh).", - "ntfyEnable": "Enable ntfy", - "ntfyServer": "Server URL", + "ntfyDescription": "Powiadomienia przez ntfy (własny serwer lub ntfy.sh).", + "ntfyEnable": "Włącz ntfy", + "ntfyServer": "Adres URL serwera", "ntfyTopic": "Topic", - "ntfyAuthToken": "Auth Token (optional)", - "ntfyAuthTokenHint": "Only needed for private topics with access control.", - "webPush": "Browser Push", - "webPushDescription": "Receive notifications directly in your browser, even when HealthLog is not open.", - "webPushNotSupported": "Your browser does not support push notifications.", - "webPushDenied": "Push notifications are blocked. Allow them in your browser settings.", - "webPushNotConfigured": "Web Push is not configured on the server.", - "webPushSubscribe": "Enable push", - "webPushUnsubscribe": "Disable push", - "webPushSubscribed": "Push notifications enabled!", - "webPushUnsubscribed": "Push notifications disabled.", - "webPushSubscribeFailed": "Activation failed", + "ntfyAuthToken": "Token uwierzytelniania (opcjonalny)", + "ntfyAuthTokenHint": "Wymagany tylko dla prywatnych tematów z kontrolą dostępu.", + "webPush": "Push przeglądarki", + "webPushDescription": "Otrzymuj powiadomienia bezpośrednio w przeglądarce, nawet gdy HealthLog nie jest otwarty.", + "webPushNotSupported": "Twoja przeglądarka nie obsługuje powiadomień push.", + "webPushDenied": "Powiadomienia push są zablokowane. Zezwól na nie w ustawieniach przeglądarki.", + "webPushNotConfigured": "Web Push nie jest skonfigurowany na serwerze.", + "webPushSubscribe": "Włącz push", + "webPushUnsubscribe": "Wyłącz push", + "webPushSubscribed": "Powiadomienia push włączone!", + "webPushUnsubscribed": "Powiadomienia push wyłączone.", + "webPushSubscribeFailed": "Aktywacja nie powiodła się", "webPushActive": "Aktywne", - "kiInsightsDescription": "Optional: Save your OpenAI key for daily, automatic evaluations in Insights.", - "codexConnected": "ChatGPT connected successfully! Insights are now active.", - "codexDisconnected": "ChatGPT connection removed.", - "codexConnectionFailed": "ChatGPT connection failed. Please try again.", - "rawData": "Send raw data", - "rawDataOnDescription": "Aggregated metrics plus anonymized raw points from the last 30 days are sent. This includes per-metric measurements (for example weight, blood pressure, pulse) with time context so the provider can detect patterns, outliers, and correlations more reliably. Name, email, and direct account identifiers are not transmitted.", - "rawDataOffDescription": "Only summarized metrics are sent (for example averages, trends, minimum/maximum). No individual points and no exact time context. This is more privacy-preserving, but the provider can be less precise for short-term patterns and fluctuations.", - "rawDataWarning": "Raw mode enabled: the provider receives additional anonymized points from the last 30 days for higher analysis accuracy.", - "regenerateInsights": "Regenerate reports", - "regenerateSuccess": "Reports regenerated successfully", - "regenerateRateLimit": "Please wait — you've hit the hourly limit for analyses.", - "lastGeneratedAt": "Last generated", + "kiInsightsDescription": "Opcjonalnie: zapisz swój klucz OpenAI, aby uzyskać codzienne, automatyczne analizy w Insights.", + "codexConnected": "ChatGPT połączony pomyślnie! Insights jest teraz aktywny.", + "codexDisconnected": "Połączenie z ChatGPT usunięte.", + "codexConnectionFailed": "Połączenie z ChatGPT nie powiodło się. Spróbuj ponownie.", + "rawData": "Wysyłaj surowe dane", + "rawDataOnDescription": "Wysyłane są zagregowane metryki oraz zanonimizowane surowe punkty z ostatnich 30 dni. Obejmuje to pomiary dla poszczególnych metryk (np. waga, ciśnienie, tętno) z kontekstem czasowym, aby dostawca mógł rzetelniej wykrywać wzorce, wartości odstające i korelacje. Imię, adres e-mail i bezpośrednie identyfikatory konta nie są przesyłane.", + "rawDataOffDescription": "Wysyłane są tylko podsumowane metryki (np. średnie, trendy, minimum/maksimum). Bez pojedynczych punktów i bez dokładnego kontekstu czasowego. Lepiej chroni to prywatność, ale dostawca może być mniej precyzyjny w przypadku wzorców i krótkoterminowych wahań.", + "rawDataWarning": "Tryb surowych danych włączony: dostawca otrzymuje dodatkowe zanonimizowane punkty z ostatnich 30 dni dla dokładniejszej analizy.", + "regenerateInsights": "Wygeneruj raporty ponownie", + "regenerateSuccess": "Raporty wygenerowane ponownie", + "regenerateRateLimit": "Poczekaj — osiągnięto godzinny limit analiz.", + "lastGeneratedAt": "Ostatnio wygenerowano", "ai": { - "chatgptConnectedBadge": "ChatGPT connected", - "adminAiActiveBadge": "Admin provider active", - "connectionExpiredBadge": "Connection expired", - "connectedSince": "Connected since {when}.", - "deviceCodeHeading": "Finish connecting on chatgpt.com", - "deviceCodeStep1": "Open this link on any device:", - "deviceCodeStep2": "Enter this one-time code:", - "deviceCodeStep3": "Approve the connection — this page updates automatically.", + "chatgptConnectedBadge": "ChatGPT połączony", + "adminAiActiveBadge": "Aktywny dostawca administratora", + "connectionExpiredBadge": "Połączenie wygasło", + "connectedSince": "Połączono od {when}.", + "deviceCodeHeading": "Dokończ łączenie na chatgpt.com", + "deviceCodeStep1": "Otwórz ten link na dowolnym urządzeniu:", + "deviceCodeStep2": "Wprowadź ten jednorazowy kod:", + "deviceCodeStep3": "Zatwierdź połączenie — ta strona zaktualizuje się automatycznie.", "deviceCodeCopy": "Kopiuj", - "deviceCodeWaiting": "Waiting for approval…", + "deviceCodeWaiting": "Oczekiwanie na zatwierdzenie…", "deviceCodeCancel": "Anuluj", - "oauthNotConfigured": "ChatGPT OAuth is not configured on this instance — use your own API key below instead.", + "oauthNotConfigured": "OAuth ChatGPT nie jest skonfigurowany w tej instancji — użyj zamiast tego własnego klucza API poniżej.", "modelLabel": "Model", - "modelOptionDefault": "— Default —", - "modelOptionCustom": "Custom…", - "customModelLabel": "Custom model name", - "anthropicKeyLabel": "Anthropic API key", + "modelOptionDefault": "— Domyślny —", + "modelOptionCustom": "Niestandardowy…", + "customModelLabel": "Nazwa niestandardowego modelu", + "anthropicKeyLabel": "Klucz API Anthropic", "baseUrlLabel": "Base URL", - "localKeyLabel": "API key (optional)", - "savedPreview": "(saved {preview})", - "savedShort": "(saved)", + "localKeyLabel": "Klucz API (opcjonalny)", + "savedPreview": "(zapisano {preview})", + "savedShort": "(zapisano)", "saveCta": "Zapisz", "saved": "Zapisano", - "saveFailed": "Save failed", + "saveFailed": "Zapis nie powiódł się", "errorGeneric": "Błąd", "providerChain": { "types": { "codex": "ChatGPT (Codex)", - "openai": "OpenAI (your key)", + "openai": "OpenAI (Twój klucz)", "anthropic": "Anthropic (Claude)", - "local": "Local model", - "admin-openai": "Admin OpenAI" + "local": "Model lokalny", + "admin-openai": "OpenAI administratora" }, - "title": "Fallback chain", - "description": "If the primary provider fails, HealthLog walks the chain in order. Drag the rows to reorder, toggle the switch to disable a provider without removing it.", - "moveUp": "Move up", - "moveDown": "Move down", - "removeFromChain": "Remove from chain", - "addProvider": "Add provider", - "addNoneAvailable": "All providers already in chain", - "saveOrder": "Save chain order", - "saved": "Chain saved", - "saveFailed": "Saving the chain failed", - "resetDefaults": "Reset to defaults", - "resetConfirmTitle": "Reset chain to defaults?", - "resetConfirmBody": "The chain reverts to Codex → OpenAI → Anthropic → Local → Admin OpenAI. Your saved credentials are not touched." - }, - "activeProviderHeading": "Active provider", - "activeProviderBody": "Pick the provider you want to use first. The form below configures only the selected provider; the fallback chain at the bottom decides what happens if it fails.", - "activeProviderLabel": "Primary provider", - "providerConfigTitle": "Provider configuration", + "title": "Łańcuch zapasowy", + "description": "Jeśli główny dostawca zawiedzie, HealthLog przechodzi przez łańcuch po kolei. Przeciągaj wiersze, aby zmienić kolejność; użyj przełącznika, aby wyłączyć dostawcę bez usuwania go.", + "moveUp": "Przesuń w górę", + "moveDown": "Przesuń w dół", + "removeFromChain": "Usuń z łańcucha", + "addProvider": "Dodaj dostawcę", + "addNoneAvailable": "Wszyscy dostawcy są już w łańcuchu", + "saveOrder": "Zapisz kolejność łańcucha", + "saved": "Łańcuch zapisany", + "saveFailed": "Zapis łańcucha nie powiódł się", + "resetDefaults": "Przywróć domyślne", + "resetConfirmTitle": "Przywrócić domyślny łańcuch?", + "resetConfirmBody": "Łańcuch wraca do Codex → OpenAI → Anthropic → Lokalny → OpenAI administratora. Zapisane dane logowania pozostają nienaruszone." + }, + "activeProviderHeading": "Aktywny dostawca", + "activeProviderBody": "Wybierz dostawcę, którego chcesz użyć jako pierwszego. Formularz poniżej konfiguruje tylko wybranego dostawcę; łańcuch zapasowy na dole decyduje, co się stanie w razie awarii.", + "activeProviderLabel": "Główny dostawca", + "providerConfigTitle": "Konfiguracja dostawcy", "providerSelect": { - "codex": "ChatGPT account (Codex)", - "openai": "OpenAI (your API key)", + "codex": "Konto ChatGPT (Codex)", + "openai": "OpenAI (Twój klucz API)", "anthropic": "Anthropic (Claude)", - "local": "Local model (OpenAI-compatible)", - "admin-openai": "Admin-provided OpenAI" + "local": "Model lokalny (zgodny z OpenAI)", + "admin-openai": "OpenAI udostępniony przez administratora" }, "openai": { "modelSelect": "Model", - "modelOptionCustom": "Custom slug…", - "modelCustomLabel": "Custom model slug", + "modelOptionCustom": "Niestandardowy slug…", + "modelCustomLabel": "Slug niestandardowego modelu", "modelCustomPlaceholder": "gpt-5", - "baseUrlLabel": "Base URL (advanced)", + "baseUrlLabel": "Adres URL bazowy (zaawansowane)", "baseUrlPlaceholder": "https://api.openai.com/v1", - "baseUrlHelp": "Override only when using an OpenAI-compatible gateway. Leave blank for OpenAI itself.", - "showAdvanced": "Show advanced", - "hideAdvanced": "Hide advanced", - "apiKey": "API key", + "baseUrlHelp": "Nadpisuj tylko przy korzystaniu z bramy zgodnej z OpenAI. Pozostaw puste dla samego OpenAI.", + "showAdvanced": "Pokaż zaawansowane", + "hideAdvanced": "Ukryj zaawansowane", + "apiKey": "Klucz API", "apiKeyPlaceholder": "sk-…" }, "codex": { - "statusConnected": "Connected", - "statusDisconnected": "Not connected", - "statusExpired": "Connection expired", - "connectButton": "Connect with ChatGPT", - "disconnectButton": "Disconnect", - "modelSlugLabel": "Model slug", - "modelSlugBody": "Codex uses the model your ChatGPT subscription routes to. The CODEX_MODEL environment variable lets ops override this on the instance.", - "lastInsight": "Last insight: {when}" + "statusConnected": "Połączono", + "statusDisconnected": "Niepołączono", + "statusExpired": "Połączenie wygasło", + "connectButton": "Połącz z ChatGPT", + "disconnectButton": "Rozłącz", + "modelSlugLabel": "Slug modelu", + "modelSlugBody": "Codex używa modelu, do którego kieruje Twoja subskrypcja ChatGPT. Zmienna środowiskowa CODEX_MODEL pozwala operatorowi nadpisać to w instancji.", + "lastInsight": "Ostatnia analiza: {when}" }, "adminOpenai": { - "title": "Admin OpenAI", - "body": "Operator-provided OpenAI key. Used as a last-ditch fallback when no personal provider is configured. There is nothing to configure on this row — visibility means the operator has set it up.", - "notConfigured": "The operator has not configured a shared OpenAI key on this instance." + "title": "OpenAI administratora", + "body": "Klucz OpenAI udostępniony przez operatora. Używany jako ostateczność, gdy nie skonfigurowano żadnego osobistego dostawcy. W tym wierszu nie ma nic do skonfigurowania — jego widoczność oznacza, że operator go skonfigurował.", + "notConfigured": "Operator nie skonfigurował współdzielonego klucza OpenAI w tej instancji." }, - "testProvider": "Test active provider", + "testProvider": "Przetestuj aktywnego dostawcę", "testSuccess": "OK — {provider} ({model})", - "testFailedShort": "Test failed: {message}", - "testUnexpectedResponse": "AI provider connection failed — unexpected response from the server.", - "testReasonCredentials": "Provider rejected the credentials — re-authenticate in AI settings.", - "testReasonRateLimited": "Provider rate-limited the request — try again shortly.", - "testReasonServerError": "The AI provider returned a server error.", - "testReasonUnreachable": "Could not reach the AI provider.", + "testFailedShort": "Test nie powiódł się: {message}", + "testUnexpectedResponse": "Połączenie z dostawcą AI nie powiodło się — nieoczekiwana odpowiedź serwera.", + "testReasonCredentials": "Dostawca odrzucił dane logowania — uwierzytelnij się ponownie w ustawieniach AI.", + "testReasonRateLimited": "Dostawca ograniczył żądanie — spróbuj ponownie za chwilę.", + "testReasonServerError": "Dostawca AI zwrócił błąd serwera.", + "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ł.", @@ -3380,28 +3380,28 @@ } }, "withings": "Withings", - "withingsDescription": "Connect your Withings scale and blood pressure monitors.", - "withingsCredentials": "API Credentials", - "withingsClientId": "Client ID", - "withingsClientSecret": "Client Secret", - "withingsCredentialsSaved": "Credentials saved", - "withingsCredentialsSavedPlaceholder": "Saved — enter new to replace", - "withingsCredentialsSavedPlaceholderSecret": "Saved — enter new to replace", - "withingsSaveCredentials": "Save credentials", - "configured": "Configured", - "withingsSync": "Sync now", - "withingsFullSync": "Sync all data", - "withingsFullSyncTitle": "Full synchronization?", - "withingsFullSyncDescription": "All available Withings data will be fully synchronized. This may take some time depending on your history.", - "withingsSyncResult": "{count} measurements synchronized", - "withingsFullSyncResult": "{count} measurements fully synchronized", - "withingsSyncFailed": "Sync failed", - "withingsSynchronize": "Synchronize", - "withingsDisconnect": "Disconnect", - "withingsDisconnectTitle": "Disconnect Withings?", - "withingsDisconnectDescription": "The connection to Withings will be disconnected. Previously synced data will be preserved.", - "withingsConnect": "Connect with Withings", - "withingsNoCredentials": "Please enter your API credentials above to connect Withings.", + "withingsDescription": "Połącz swoją wagę i ciśnieniomierze Withings.", + "withingsCredentials": "Dane logowania API", + "withingsClientId": "ID klienta", + "withingsClientSecret": "Sekret klienta", + "withingsCredentialsSaved": "Dane logowania zapisane", + "withingsCredentialsSavedPlaceholder": "Zapisano — wprowadź nowy, aby zastąpić", + "withingsCredentialsSavedPlaceholderSecret": "Zapisano — wprowadź nowy, aby zastąpić", + "withingsSaveCredentials": "Zapisz dane logowania", + "configured": "Skonfigurowano", + "withingsSync": "Synchronizuj teraz", + "withingsFullSync": "Synchronizuj wszystkie dane", + "withingsFullSyncTitle": "Pełna synchronizacja?", + "withingsFullSyncDescription": "Wszystkie dostępne dane Withings zostaną w pełni zsynchronizowane. Może to chwilę potrwać w zależności od historii.", + "withingsSyncResult": "{count} pomiarów zsynchronizowano", + "withingsFullSyncResult": "{count} pomiarów w pełni zsynchronizowano", + "withingsSyncFailed": "Synchronizacja nie powiodła się", + "withingsSynchronize": "Synchronizuj", + "withingsDisconnect": "Rozłącz", + "withingsDisconnectTitle": "Rozłączyć Withings?", + "withingsDisconnectDescription": "Połączenie z Withings zostanie rozłączone. Wcześniej zsynchronizowane dane zostaną zachowane.", + "withingsConnect": "Połącz z Withings", + "withingsNoCredentials": "Najpierw wprowadź powyżej dane logowania API, aby połączyć Withings.", "whoop": "WHOOP", "whoopDescription": "Połącz opaskę WHOOP, aby synchronizować regenerację, sen, obciążenie i treningi.", "whoopOverlapNote": "Jeśli WHOOP i inne źródło dostarczają tę samą wartość życiową — tętno spoczynkowe, saturację, temperaturę ciała, częstość oddechów lub fazy snu — możesz widzieć obie wartości, dopóki przyszła aktualizacja nie wybierze jednego preferowanego źródła.", @@ -3430,9 +3430,9 @@ "withings": { "reconnect": { "banner": { - "title": "Activity sync available", - "body": "Your Withings connection was authorised before activity sync was added. Reconnect once to enable steps, active energy, walking + running distance, and floors-climbed ingest from your Withings devices.", - "action": "Reconnect Withings" + "title": "Dostępna synchronizacja aktywności", + "body": "Twoje połączenie z Withings zostało autoryzowane przed dodaniem synchronizacji aktywności. Połącz ponownie jeden raz, aby włączyć import kroków, energii aktywnej, dystansu marszu i biegu oraz pokonanych pięter z urządzeń Withings.", + "action": "Połącz ponownie Withings" } } } @@ -3443,132 +3443,132 @@ "warningServerError": "Połączono, błąd serwera", "parkedReconnect": "Wstrzymane — połącz ręcznie", "notConnected": "Nie połączono", - "justNow": "just now", - "minutesAgo": "{count} min ago", - "hoursAgo": "{count} h ago", - "daysAgo": "{count} d ago", - "ariaLabel": "Integration status", + "justNow": "przed chwilą", + "minutesAgo": "{count} min temu", + "hoursAgo": "{count} godz. temu", + "daysAgo": "{count} dni temu", + "ariaLabel": "Status integracji", "resumeCta": "Połącz ponownie", "resumeSuccess": "Integracja wznowiona", "resumeError": "Ponowne połączenie nieudane" }, - "apiTokens": "API Tokens", - "apiTokensDescription": "Create API tokens for external medication intake (e.g., Shortcuts, automations).", - "tokenNamePlaceholder": "Token name (e.g., iPhone Shortcut)", - "tokenCreated": "Token created — copy it now! It won't be shown again.", - "tokenRevoke": "Revoke token?", - "tokenRevokeDescription": "The token will be permanently deactivated. Applications using it will lose access.", - "tokenRevoked": "Revoked", - "tokenExpired": "Expired", + "apiTokens": "Tokeny API", + "apiTokensDescription": "Twórz tokeny API do zewnętrznego rejestrowania przyjęć leków (np. Skróty, automatyzacje).", + "tokenNamePlaceholder": "Nazwa tokena (np. Skrót iPhone)", + "tokenCreated": "Token utworzony — skopiuj go teraz! Nie zostanie pokazany ponownie.", + "tokenRevoke": "Cofnąć token?", + "tokenRevokeDescription": "Token zostanie trwale dezaktywowany. Aplikacje, które go używają, stracą dostęp.", + "tokenRevoked": "Cofnięto", + "tokenExpired": "Wygasł", "tokenActive": "Aktywne", - "activeTokensTitle": "Active tokens", - "revokedTokensTitle": "Revoked tokens ({count})", + "activeTokensTitle": "Aktywne tokeny", + "revokedTokensTitle": "Cofnięte tokeny ({count})", "tokenTableName": "Nazwa", - "tokenTablePermissions": "Permissions", + "tokenTablePermissions": "Uprawnienia", "tokenTableStatus": "Status", - "tokenTableCreated": "Created", - "tokenTableLastUsed": "Last used", + "tokenTableCreated": "Utworzono", + "tokenTableLastUsed": "Ostatnio użyto", "tokenTableActions": "Akcje", - "noActiveTokens": "No active tokens available.", - "tokenNeverUsed": "Never", - "apiEndpointsTitle": "API endpoints", - "apiEndpointsDescription": "Overview of currently available external endpoint(s) for token-based ingestion.", - "apiEndpointMethod": "Method", - "apiEndpointPath": "Path", - "apiEndpointAuth": "Authentication", - "apiEndpointExample": "Body example", - "collapse": "Collapse", - "expand": "Expand", - "dangerZone": "Delete All Data", - "dangerZoneTitle": "Danger zone", - "dangerZoneDescription": "Deletes all your health data and integrations. Your user account will be preserved.", - "dangerZoneConfirm": "Delete all data?", - "dangerZoneConfirmDescription": "This will permanently delete all your health data and integrations. Your user account will be preserved.", - "dangerZoneSuccess": "All personal data has been deleted", - "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.", - "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.", - "deleteAccountSuccess": "Account deleted — signing you out.", - "deleteAccountFailed": "Account could not be deleted", - "deleteAccountFinal": "Delete permanently", + "noActiveTokens": "Brak aktywnych tokenów.", + "tokenNeverUsed": "Nigdy", + "apiEndpointsTitle": "Punkty końcowe API", + "apiEndpointsDescription": "Przegląd dostępnych obecnie zewnętrznych punktów końcowych do wprowadzania danych za pomocą tokenów.", + "apiEndpointMethod": "Metoda", + "apiEndpointPath": "Ścieżka", + "apiEndpointAuth": "Uwierzytelnianie", + "apiEndpointExample": "Przykład treści", + "collapse": "Zwiń", + "expand": "Rozwiń", + "dangerZone": "Usuń wszystkie dane", + "dangerZoneTitle": "Strefa zagrożenia", + "dangerZoneDescription": "Usuwa wszystkie Twoje dane zdrowotne i integracje. Twoje konto użytkownika zostanie zachowane.", + "dangerZoneConfirm": "Usunąć wszystkie dane?", + "dangerZoneConfirmDescription": "Spowoduje to trwałe usunięcie wszystkich Twoich danych zdrowotnych i integracji. Twoje konto użytkownika zostanie zachowane.", + "dangerZoneSuccess": "Wszystkie dane osobowe zostały usunięte", + "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ąć.", + "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.", + "deleteAccountSuccess": "Konto usunięte — trwa wylogowywanie.", + "deleteAccountFailed": "Nie udało się usunąć konta", + "deleteAccountFinal": "Usuń trwale", "saved": "Zapisano", - "savingError": "Error saving", + "savingError": "Błąd podczas zapisywania", "moodLogTitle": "moodLog", "moodLogDescription": "Import mood data from moodLog", "moodLogUrl": "moodLog URL", "moodLogUrlPlaceholder": "https://mood.example.com", - "moodLogApiKey": "API Key", + "moodLogApiKey": "Klucz API", "moodLogApiKeyPlaceholder": "ml_...", - "moodLogWebhookSecret": "Webhook Secret", - "moodLogWebhookSecretHelp": "Enter this secret in moodLog as webhook secret", - "moodLogSync": "Start sync", - "moodLogFullSync": "Full sync", - "moodLogFullSyncConfirm": "Synchronize fully", - "moodLogFullSyncTitle": "Full sync?", - "moodLogFullSyncDescription": "All available mood data will be fully synchronized.", - "moodLogDisconnect": "Disconnect", - "moodLogDisconnectTitle": "Disconnect moodLog?", - "moodLogDisconnectDescription": "The connection will be removed and all imported mood data will be deleted.", - "moodLogEntries": "Entries", - "moodLogSyncResult": "{count} entries synchronized", - "moodLogSyncFailed": "Sync failed", - "moodLogSaved": "moodLog connection saved", - "moodLogDisconnected": "moodLog disconnected", + "moodLogWebhookSecret": "Sekret webhooka", + "moodLogWebhookSecretHelp": "Wprowadź ten sekret w moodLog jako sekret webhooka", + "moodLogSync": "Rozpocznij synchronizację", + "moodLogFullSync": "Pełna synchronizacja", + "moodLogFullSyncConfirm": "Synchronizuj w pełni", + "moodLogFullSyncTitle": "Pełna synchronizacja?", + "moodLogFullSyncDescription": "Wszystkie dostępne dane o nastroju zostaną w pełni zsynchronizowane.", + "moodLogDisconnect": "Rozłącz", + "moodLogDisconnectTitle": "Rozłączyć moodLog?", + "moodLogDisconnectDescription": "Połączenie zostanie usunięte, a wszystkie zaimportowane dane o nastroju zostaną skasowane.", + "moodLogEntries": "Wpisy", + "moodLogSyncResult": "{count} wpisów zsynchronizowano", + "moodLogSyncFailed": "Synchronizacja nie powiodła się", + "moodLogSaved": "Połączenie moodLog zapisane", + "moodLogDisconnected": "moodLog rozłączony", "about": { - "version": "App version", + "version": "Wersja aplikacji", "gitSha": "Build", - "builtAt": "Built {time}", - "builtAtLabel": "Built", - "license": "License", - "repository": "Source code", + "builtAt": "Skompilowano {time}", + "builtAtLabel": "Skompilowano", + "license": "Licencja", + "repository": "Kod źródłowy", "changelog": "Changelog", - "docs": "Documentation", + "docs": "Dokumentacja", "linksHeading": "Źródła i dokumentacja", "newerAvailable": "Nowa wersja: {tag}", "tourReplay": "Obejrzyj samouczek", "tourReplayHint": "Zobacz przewodnik po panelu — przydatne, jeśli pominąłeś go przy pierwszej wizycie." }, "testConnection": { - "test": "Test connection", - "testing": "Testing…", - "ok": "Connected (latency {latency} ms)", + "test": "Testuj połączenie", + "testing": "Testowanie…", + "ok": "Połączono (opóźnienie {latency} ms)", "errors": { - "credentials_rejected": "Credentials rejected — check your token", - "rate_limited": "Rate-limited by the upstream", - "timeout": "Request timed out", - "upstream_error": "Upstream returned an error", - "connection_failed": "Connection failed", - "not_configured": "Not configured", - "url_not_public": "URL is not a public endpoint", - "url_invalid": "URL is invalid", - "redirected": "Endpoint redirects — check the URL", - "endpoint_not_found": "Endpoint not found at the URL", - "credentials_unreadable": "Credentials cannot be decrypted", - "upstream_invalid_json": "Upstream sent invalid JSON", - "vapid_not_configured": "Web Push not configured (VAPID keys missing)", - "rate_limited_self": "Too many test requests", - "generic": "Test failed" + "credentials_rejected": "Dane logowania odrzucone — sprawdź token", + "rate_limited": "Ograniczone przez usługę nadrzędną", + "timeout": "Przekroczono limit czasu żądania", + "upstream_error": "Usługa nadrzędna zwróciła błąd", + "connection_failed": "Połączenie nie powiodło się", + "not_configured": "Nieskonfigurowano", + "url_not_public": "Adres URL nie jest publicznym punktem końcowym", + "url_invalid": "Adres URL jest nieprawidłowy", + "redirected": "Punkt końcowy przekierowuje — sprawdź adres URL", + "endpoint_not_found": "Nie znaleziono punktu końcowego pod adresem URL", + "credentials_unreadable": "Nie można odszyfrować danych logowania", + "upstream_invalid_json": "Usługa nadrzędna wysłała nieprawidłowy JSON", + "vapid_not_configured": "Web Push nieskonfigurowany (brak kluczy VAPID)", + "rate_limited_self": "Zbyt wiele żądań testowych", + "generic": "Test nie powiódł się" } }, "notificationStatus": { "title": "Channel reliability", "description": "Live status of every notification channel — Auto-disabled means HealthLog stopped retrying after a permanent error.", - "emptyDescription": "No channels configured yet. Add a channel below to start tracking its delivery health.", + "emptyDescription": "Nie skonfigurowano jeszcze żadnych kanałów. Dodaj kanał poniżej, aby zacząć śledzić jego dostarczalność.", "stateActive": "Aktywne", - "stateAutoDisabled": "Auto-disabled", - "stateSendingPaused": "Sending paused", + "stateAutoDisabled": "Automatycznie wyłączone", + "stateSendingPaused": "Wysyłanie wstrzymane", "stateManuallyDisabled": "Wyłączone", - "lastSuccess": "Last successful send", - "lastFailure": "Last failure", - "consecutiveFailures": "Consecutive failures", - "disabledReason": "Reason", - "nextRetry": "Next retry", - "reEnable": "Re-enable", - "sendTest": "Send test" + "lastSuccess": "Ostatnia udana wysyłka", + "lastFailure": "Ostatni błąd", + "consecutiveFailures": "Kolejne błędy", + "disabledReason": "Powód", + "nextRetry": "Następna próba", + "reEnable": "Włącz ponownie", + "sendTest": "Wyślij test" }, "identity": { "description": "Opcjonalne dane do eksportu dokumentacji zdrowotnej (okładka PDF + eksport FHIR). Wszystkie pola są opcjonalne.", From 06da3c44ee1ca33d6a2f6d42864b4dc197ee014e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 15:31:20 +0200 Subject: [PATCH 05/27] feat(i18n): translate Medications copy for es/fr/it/pl Schedule, wizard, intake, and detail-page copy in the medication surface rendered verbatim English for the four non-reference locales. Translate the wizard steps, cadence options, intake history, reminder controls, and danger-zone prose, leaving units, ATC/RxNorm codes, and GLP-1 INN drug names in their language-appropriate form. --- messages/es.json | 534 +++++++++++++++++++++++------------------------ messages/fr.json | 524 +++++++++++++++++++++++----------------------- messages/it.json | 532 +++++++++++++++++++++++----------------------- messages/pl.json | 534 +++++++++++++++++++++++------------------------ 4 files changed, 1062 insertions(+), 1062 deletions(-) diff --git a/messages/es.json b/messages/es.json index 045203dc..36568a1f 100644 --- a/messages/es.json +++ b/messages/es.json @@ -554,35 +554,35 @@ "scheduleDaily": "A diario", "inactive": "En pausa", "intakeHistory": "Historial de tomas", - "openDetailPage": "Open medication detail page", + "openDetailPage": "Abrir la página de detalle del medicamento", "deleteConfirm": "¿Eliminar medicamento?", "compliance": "Cumplimiento", - "importIntakes": "Import intakes", - "importDescription": "Paste a JSON array with intake data. Expected format:", - "importUploadFile": "Upload JSON file", - "importPaste": "Paste JSON here...", - "importSelected": "Selected: {name}", - "importFileLoaded": "File \"{name}\" loaded", - "importInvalidJson": "File does not contain valid JSON", - "importNoArray": "No array found", - "importResult": "{imported} intakes imported", - "importDuplicatesSkipped": "{count} duplicates skipped", - "importInvalidSkipped": "{count} invalid entries skipped", - "importFailed": "Import failed", - "importInvalidFormat": "Invalid JSON format", - "apiEndpointTitle": "API Endpoint: {name}", - "apiEndpointDescription": "Use this endpoint to record intakes via API (e.g., iPhone Shortcut or cron job).", - "apiEndpointActive": "API endpoint active", - "apiEndpointActivated": "API endpoint activated", - "apiEndpointDeactivated": "API endpoint deactivated", - "apiTokenCount": "Active ({count} tokens)", - "apiToken": "API Token", - "apiTokenActiveHint": "Endpoint is active. Existing tokens remain valid but cannot be shown in plain text again.", - "apiTokenActivateHint": "Activate the endpoint to create a new token.", - "apiTokenOnceHint": "The token is only shown in plain text once at creation.", - "requestExample": "Request example", - "statusLoadFailed": "Could not load status", - "changeFailed": "Change failed", + "importIntakes": "Importar tomas", + "importDescription": "Pega un array JSON con los datos de tomas. Formato esperado:", + "importUploadFile": "Subir archivo JSON", + "importPaste": "Pega el JSON aquí...", + "importSelected": "Seleccionado: {name}", + "importFileLoaded": "Archivo \"{name}\" cargado", + "importInvalidJson": "El archivo no contiene un JSON válido", + "importNoArray": "No se encontró ningún array", + "importResult": "{imported} tomas importadas", + "importDuplicatesSkipped": "{count} duplicados omitidos", + "importInvalidSkipped": "{count} entradas no válidas omitidas", + "importFailed": "Falló la importación", + "importInvalidFormat": "Formato JSON no válido", + "apiEndpointTitle": "Endpoint de API: {name}", + "apiEndpointDescription": "Usa este endpoint para registrar tomas a través de la API (p. ej., con un Atajo de iPhone o un cron job).", + "apiEndpointActive": "Endpoint de API activo", + "apiEndpointActivated": "Endpoint de API activado", + "apiEndpointDeactivated": "Endpoint de API desactivado", + "apiTokenCount": "Activo ({count} tokens)", + "apiToken": "Token de API", + "apiTokenActiveHint": "El endpoint está activo. Los tokens existentes siguen siendo válidos, pero no se pueden mostrar de nuevo en texto plano.", + "apiTokenActivateHint": "Activa el endpoint para crear un nuevo token.", + "apiTokenOnceHint": "El token solo se muestra en texto plano una vez, al crearlo.", + "requestExample": "Ejemplo de solicitud", + "statusLoadFailed": "No se pudo cargar el estado", + "changeFailed": "Falló el cambio", "categoryBloodPressure": "Presión arterial", "categoryVitamin": "Vitaminas", "categoryThyroid": "Tiroides", @@ -594,7 +594,7 @@ "categorySkin": "Cuidado de la piel", "categorySleepAid": "Para dormir", "categoryDiabetes": "Diabetes", - "categoryAntibiotic": "Antibiotic", + "categoryAntibiotic": "Antibiótico", "categoryOther": "Otros", "daysSun": "Do", "daysMon": "Lu", @@ -691,7 +691,7 @@ "glp1Specific": "Específico de GLP-1" }, "entries": { - "nausea": "Náusea", + "nausea": "Náuseas", "vomiting": "Vómito", "diarrhea": "Diarrea", "constipation": "Estreñimiento", @@ -738,141 +738,141 @@ } }, "cadence": { - "section": "Cadence", - "label": "How often", + "section": "Frecuencia", + "label": "Con qué frecuencia", "kind": { - "daily": "Every day", - "weekdays": "Certain days of the week", - "everyNWeeks": "Every N weeks on certain days", - "monthly": "Monthly on a specific day", - "everyNMonths": "Every N months on a specific day", - "yearly": "Once a year", - "rolling": "Every N days from when I last took it (flexible)", - "rollingExplainer": "Counts from your last logged intake — pauses if you skip a dose.", - "oneShot": "One-time dose" + "daily": "Cada día", + "weekdays": "Determinados días de la semana", + "everyNWeeks": "Cada N semanas en determinados días", + "monthly": "Mensualmente un día concreto", + "everyNMonths": "Cada N meses un día concreto", + "yearly": "Una vez al año", + "rolling": "Cada N días desde la última toma (flexible)", + "rollingExplainer": "Cuenta desde tu última toma registrada — se pausa si te saltas una dosis.", + "oneShot": "Dosis única" }, "weekdays": { - "label": "Days of the week", + "label": "Días de la semana", "short": { "mo": "Mo", - "tu": "Tu", - "we": "We", - "th": "Th", + "tu": "Ma", + "we": "Mi", + "th": "Ju", "fr": "Fr", "sa": "Sa", - "su": "Su" + "su": "Do" }, "long": { - "mo": "Monday", - "tu": "Tuesday", - "we": "Wednesday", - "th": "Thursday", - "fr": "Friday", - "sa": "Saturday", - "su": "Sunday" + "mo": "Lunes", + "tu": "Martes", + "we": "Miércoles", + "th": "Jueves", + "fr": "Viernes", + "sa": "Sábado", + "su": "Domingo" } }, "intervalWeeks": { - "suffix": "weeks" + "suffix": "semanas" }, "dayOfMonth": { - "label": "Day" + "label": "Día" }, "intervalMonths": { - "suffix": "months", - "dayOnLabel": "on day" + "suffix": "meses", + "dayOnLabel": "el día" }, "yearly": { "date": { - "label": "Date" + "label": "Fecha" } }, "rollingDays": { - "suffix": "days" + "suffix": "días" } }, "timesOfDay": { - "section": "Times of day", - "label": "Times of day", + "section": "Horas del día", + "label": "Horas del día", "empty": { - "cta": "Add the first time." + "cta": "Añade la primera hora." }, - "add": "Add time", - "remove": "Remove", + "add": "Añadir hora", + "remove": "Quitar", "presets": { - "morning": "Morning", - "noon": "Noon", - "evening": "Evening", - "night": "Night" + "morning": "Mañana", + "noon": "Mediodía", + "evening": "Tarde", + "night": "Noche" }, "max": { - "reached": "Maximum reached — remove a time before adding another." + "reached": "Máximo alcanzado — quita una hora antes de añadir otra." } }, "courseWindow": { - "section": "Course window", + "section": "Periodo de tratamiento", "startsOn": { - "label": "Starts on" + "label": "Empieza el" }, "endsOn": { - "label": "Ends on" + "label": "Termina el" }, - "noEndDate": "No end date", - "oneShotCaption": "(one-time dose)", - "invalidRange": "End date must be on or after the start date." + "noEndDate": "Sin fecha de fin", + "oneShotCaption": "(dosis única)", + "invalidRange": "La fecha de fin debe ser igual o posterior a la de inicio." } }, "wizard": { "header": { - "stepOf": "Step {current} of {total}", - "createTitle": "New medication", - "editTitle": "Edit {name}" + "stepOf": "Paso {current} de {total}", + "createTitle": "Nuevo medicamento", + "editTitle": "Editar {name}" }, "nav": { - "back": "Back", - "next": "Next", - "save": "Save", - "saveEdit": "Save changes", + "back": "Atrás", + "next": "Siguiente", + "save": "Guardar", + "saveEdit": "Guardar cambios", "nextHint": "Siguiente: {step}", "reviewHint": "Siguiente: Guardar", "jumpFirst": "Ir al primer paso", "jumpLast": "Ir a revisar y guardar" }, "nl": { - "button": "Describe" + "button": "Describir" }, "steps": { "step1": { - "title": "What's the medication called?", - "subline": "Exactly as it appears on the box.", + "title": "¿Cómo se llama el medicamento?", + "subline": "Exactamente como aparece en la caja.", "label": "Name", - "placeholder": "e.g. Ramipril", + "placeholder": "p. ej. Ramipril", "short": "Nombre" }, "step2": { - "title": "What kind of medication is it?", - "subline": "We use this for the right templates and analytics.", + "title": "¿Qué tipo de medicamento es?", + "subline": "Lo usamos para las plantillas y los análisis adecuados.", "short": "Tipo" }, "step3": { - "title": "What's the dose?", - "subline": "A tablet, a puff, a drop — depending on the form.", - "amountLabel": "Amount", - "amountPlaceholder": "e.g. 5", - "unitLabel": "Unit", + "title": "¿Cuál es la dosis?", + "subline": "Una pastilla, una inhalación, una gota — según la forma.", + "amountLabel": "Cantidad", + "amountPlaceholder": "p. ej. 5", + "unitLabel": "Unidad", "unit": { "mg": "mg", "ml": "ml", - "iu": "IU", + "iu": "UI", "mcg": "µg", "g": "g", - "tablets": "tablet(s)", - "capsules": "capsule(s)", - "drops": "drops", - "puffs": "puff", - "sprays": "spray", - "pieces": "pieces", - "other": "other" + "tablets": "comprimido(s)", + "capsules": "cápsula(s)", + "drops": "gotas", + "puffs": "inhalación", + "sprays": "pulverización", + "pieces": "unidades", + "other": "otro" }, "deliveryFormLabel": "Vía", "deliveryForm": { @@ -888,124 +888,124 @@ "short": "Dosis" }, "step4": { - "title": "Over what period are you taking it?", - "subline": "A start date is enough for today. You can add an end date later.", + "title": "¿Durante qué periodo lo tomas?", + "subline": "Por hoy basta con una fecha de inicio. Puedes añadir una fecha de fin más tarde.", "short": "Cuándo" }, "step5": { - "title": "How often do you take it?", - "subline": "Pick the cadence — the details follow on the next step.", + "title": "¿Con qué frecuencia lo tomas?", + "subline": "Elige la frecuencia — los detalles vienen en el siguiente paso.", "short": "Frecuencia" }, "step6": { - "title": "What does that look like in detail?", - "subline": "Set weekdays, intervals, or the day of the month.", + "title": "¿Cómo es exactamente?", + "subline": "Indica los días de la semana, los intervalos o el día del mes.", "intervalWeeks": { - "label": "Every", - "suffix": "weeks" + "label": "Cada", + "suffix": "semanas" }, "dayOfMonth": { - "label": "On", - "suffix": "day of the month" + "label": "El", + "suffix": "día del mes" }, "rollingDays": { - "label": "Every", - "suffix": "days from the last intake" + "label": "Cada", + "suffix": "días desde la última toma" }, "short": "Detalle" }, "step7": { - "title": "When do you take it?", - "subline": "One or more times of day — reminders follow this list.", - "presetsLabel": "Time-of-day presets", + "title": "¿Cuándo lo tomas?", + "subline": "Una o varias horas del día — los recordatorios siguen esta lista.", + "presetsLabel": "Preajustes por hora del día", "presets": { - "morning": "Morning", - "noon": "Noon", - "evening": "Evening", - "night": "Night" + "morning": "Mañana", + "noon": "Mediodía", + "evening": "Tarde", + "night": "Noche" }, "short": "Horas" }, "step8": { - "title": "All done — should HealthLog remind you?", - "subline": "Review everything below before you save.", - "remindersLabel": "Reminders on", - "remindersDescription": "Push, Telegram or ntfy — based on your settings.", - "multiScheduleNote": "This medication has multiple schedules. The detailed editor returns in a later release.", + "title": "Listo — ¿quieres que HealthLog te lo recuerde?", + "subline": "Revisa todo abajo antes de guardar.", + "remindersLabel": "Recordatorios activados", + "remindersDescription": "Push, Telegram o ntfy — según tus ajustes.", + "multiScheduleNote": "Este medicamento tiene varios horarios. El editor detallado volverá en una versión posterior.", "short": "Listo" } }, "classRow": { - "bloodPressure": "Blood pressure", + "bloodPressure": "Presión arterial", "diabetes": "Diabetes", - "hormone": "Hormones", - "glp1": "GLP-1 injection", - "painRelief": "Pain", - "allergy": "Allergy", - "vitamin": "Vitamins", - "supplement": "Supplement", - "antibiotic": "Antibiotic", - "other": "Other" + "hormone": "Hormonas", + "glp1": "Inyección de GLP-1", + "painRelief": "Dolor", + "allergy": "Alergia", + "vitamin": "Vitaminas", + "supplement": "Suplemento", + "antibiotic": "Antibiótico", + "other": "Otro" }, "cadence": { "daily": { - "label": "Daily", - "description": "Every day at the same time." + "label": "Diario", + "description": "Cada día a la misma hora." }, "weekdays": { - "label": "Specific weekdays", - "description": "E.g. Monday, Wednesday, Friday." + "label": "Días concretos de la semana", + "description": "P. ej. lunes, miércoles, viernes." }, "everyNWeeks": { - "label": "Every few weeks", - "description": "E.g. every 2 weeks on Thursdays (GLP-1, biologics)." + "label": "Cada pocas semanas", + "description": "P. ej. cada 2 semanas los jueves (GLP-1, biológicos)." }, "monthly": { - "label": "Monthly", - "description": "On a fixed day each month (e.g. always the 1st)." + "label": "Mensual", + "description": "Un día fijo cada mes (p. ej. siempre el 1)." }, "rolling": { - "label": "Flexible from last dose", - "description": "Leave the next dose open. When you tap 'taken', the counter starts again from that moment." + "label": "Flexible desde la última dosis", + "description": "Deja abierta la próxima dosis. Cuando pulsas «tomada», el contador empieza de nuevo desde ese momento." }, "oneShot": { - "label": "Single dose", - "description": "One administration, e.g. a vaccine or an antibiotic course." + "label": "Dosis única", + "description": "Una sola administración, p. ej. una vacuna o un ciclo de antibiótico." } }, "summary": { - "title": "Summary", + "title": "Resumen", "cadence": { - "daily": "Every day", - "weekdays": "On selected days of the week", - "biweekly": "Every two weeks", - "monthly": "Monthly", - "quarterly": "Every three months", - "yearly": "Once a year", - "rolling": "Every {n} days from your last intake", - "oneShot": "A single dose", - "everyNWeeks": "Every {n} weeks", - "everyNMonths": "Every {n} months" + "daily": "Cada día", + "weekdays": "En los días de la semana seleccionados", + "biweekly": "Cada dos semanas", + "monthly": "Mensual", + "quarterly": "Cada tres meses", + "yearly": "Una vez al año", + "rolling": "Cada {n} días desde tu última toma", + "oneShot": "Una dosis única", + "everyNWeeks": "Cada {n} semanas", + "everyNMonths": "Cada {n} meses" }, - "weekdaysDetail": ", on {days}", - "dayOfMonthDetail": ", on day {day}.", - "times": "at {times}", - "startsOn": "Starts: {date}", - "endsOn": "Ends: {date}", - "noEndDate": "No end date" + "weekdaysDetail": ", los {days}", + "dayOfMonthDetail": ", el día {day}.", + "times": "a las {times}", + "startsOn": "Empieza: {date}", + "endsOn": "Termina: {date}", + "noEndDate": "Sin fecha de fin" }, "compose": { - "scheduleIndex": "Schedule {n} of {total}", + "scheduleIndex": "Horario {n} de {total}", "list": { - "add": "Add another schedule", - "edit": "Edit", - "remove": "Remove", - "removeDisabled": "At least one schedule is required.", - "empty": "No schedule yet." + "add": "Añadir otro horario", + "edit": "Editar", + "remove": "Quitar", + "removeDisabled": "Se requiere al menos un horario.", + "empty": "Aún no hay horario." } }, "errors": { - "submitFailed": "Could not save the medication — please retry." + "submitFailed": "No se pudo guardar el medicamento — inténtalo de nuevo." } }, "doseStrength": { @@ -1160,88 +1160,88 @@ }, "detail": { "status": { - "active": "Active", - "paused": "Paused", - "ended": "Ended" + "active": "Activo", + "paused": "Pausado", + "ended": "Finalizado" }, "today": { - "groupLabel": "Today's dose", - "taken": "Taken", - "skipped": "Skipped", - "toastTaken": "Today's dose logged", - "toastSkipped": "Today's dose skipped", - "recordedTaken": "Taken today at {time}", - "recordedSkipped": "Skipped today at {time}", - "recordedOneShot": "One-shot dose taken at {time}", - "noneScheduled": "No dose scheduled for today.", - "pausedHint": "Paused — no reminder today.", - "error": "Could not log the dose." + "groupLabel": "Dosis de hoy", + "taken": "Tomada", + "skipped": "Omitida", + "toastTaken": "Dosis de hoy registrada", + "toastSkipped": "Dosis de hoy omitida", + "recordedTaken": "Tomada hoy a las {time}", + "recordedSkipped": "Omitida hoy a las {time}", + "recordedOneShot": "Dosis única tomada a las {time}", + "noneScheduled": "No hay ninguna dosis programada para hoy.", + "pausedHint": "En pausa — hoy no hay recordatorio.", + "error": "No se pudo registrar la dosis." }, "cadence": { - "oneShotOn": "One-time dose on {date}.", - "oneShotPending": "One-time dose pending." + "oneShotOn": "Dosis única el {date}.", + "oneShotPending": "Dosis única pendiente." }, "intake": { - "title": "Intake history", - "importButton": "Import", + "title": "Historial de tomas", + "importButton": "Importar", "rowActions": { - "openMenu": "Open row actions", - "edit": "Edit", - "delete": "Delete" + "openMenu": "Abrir acciones de la fila", + "edit": "Editar", + "delete": "Eliminar" }, "selection": { - "rowToggleLabel": "Select row" + "rowToggleLabel": "Seleccionar fila" }, "bulkDelete": { - "selectionCount": "{count} entries selected", - "deleteButton": "Delete selection", - "cancelButton": "Cancel", - "confirmTitle": "Delete the selected entries?", - "confirmBody": "{count} entries will be removed permanently. The medication itself stays.", - "confirmAction": "Delete", - "toast": "Selection deleted", - "failed": "Could not delete the selection." + "selectionCount": "{count} entradas seleccionadas", + "deleteButton": "Eliminar selección", + "cancelButton": "Cancelar", + "confirmTitle": "¿Eliminar las entradas seleccionadas?", + "confirmBody": "Se eliminarán {count} entradas de forma permanente. El medicamento se conserva.", + "confirmAction": "Eliminar", + "toast": "Selección eliminada", + "failed": "No se pudo eliminar la selección." }, "edit": { - "dialogTitle": "Edit intake", - "takenAtLabel": "Taken at", - "skippedLabel": "Skipped", - "noteLabel": "Note", - "save": "Save", - "cancel": "Cancel", - "savedToast": "Intake updated", - "failed": "Could not update the intake." + "dialogTitle": "Editar toma", + "takenAtLabel": "Tomada a las", + "skippedLabel": "Omitida", + "noteLabel": "Nota", + "save": "Guardar", + "cancel": "Cancelar", + "savedToast": "Toma actualizada", + "failed": "No se pudo actualizar la toma." }, "deleteRow": { - "confirmTitle": "Delete this intake?", - "confirmBody": "The entry is removed permanently.", - "confirmAction": "Delete", - "toast": "Intake deleted", - "failed": "Could not delete the intake." + "confirmTitle": "¿Eliminar esta toma?", + "confirmBody": "La entrada se elimina de forma permanente.", + "confirmAction": "Eliminar", + "toast": "Toma eliminada", + "failed": "No se pudo eliminar la toma." } }, "notifications": { - "title": "Notifications", - "switchLabel": "Send a reminder when this dose is due", - "helperOn": "Reminders are on.", - "helperOff": "Reminders are off.", - "enabledToast": "Reminders on", - "disabledToast": "Reminders off", - "toggleFailed": "Could not change the reminder setting.", - "clientManagedChip": "Your iPhone manages reminders for this medication." + "title": "Recordatorios", + "switchLabel": "Enviar un recordatorio cuando toque esta dosis", + "helperOn": "Los recordatorios están activados.", + "helperOff": "Los recordatorios están desactivados.", + "enabledToast": "Recordatorios activados", + "disabledToast": "Recordatorios desactivados", + "toggleFailed": "No se pudo cambiar el ajuste de recordatorios.", + "clientManagedChip": "Tu iPhone gestiona los recordatorios de este medicamento." }, "settings": { - "title": "Settings", + "title": "Ajustes", "phases": { - "openButton": "Configure phases", - "requiresCourseWindow": "Phases are available once a course window is set." + "openButton": "Configurar fases", + "requiresCourseWindow": "Las fases están disponibles una vez fijado un periodo de tratamiento." }, "grace": { - "label": "Reminder window — applies to your primary schedule", - "primaryScheduleNote": "Multi-schedule medications keep their other schedules' default window.", - "unit": "minutes", - "saved": "Reminder window saved", - "failed": "Could not save the reminder window." + "label": "Ventana de recordatorio — se aplica a tu horario principal", + "primaryScheduleNote": "Los medicamentos con varios horarios mantienen la ventana predeterminada de sus demás horarios.", + "unit": "minutos", + "saved": "Ventana de recordatorio guardada", + "failed": "No se pudo guardar la ventana de recordatorio." }, "codes": { "label": "Códigos clínicos (ATC / RxNorm)", @@ -1253,57 +1253,57 @@ } }, "zone": { - "title": "Manage & danger zone", + "title": "Gestión y zona de peligro", "pause": { - "title": "Pause", - "helper": "Pause reminders until you turn them back on. History stays intact.", - "pausedToast": "Reminders paused", - "resumedToast": "Reminders resumed", - "failed": "Could not change the paused state." + "title": "Pausar", + "helper": "Pausa los recordatorios hasta que los reactives. El historial se conserva.", + "pausedToast": "Recordatorios pausados", + "resumedToast": "Recordatorios reanudados", + "failed": "No se pudo cambiar el estado de pausa." }, "end": { - "title": "End medication", - "helper": "Stop reminders and mark the medication as ended. History stays visible.", - "button": "End", - "dialogTitle": "End {name} — stop reminders, history stays visible", - "dialogBody": "No more reminders. Old entries remain in the timeline.", - "toast": "Medication ended", - "failed": "Could not end the medication." + "title": "Finalizar medicamento", + "helper": "Detiene los recordatorios y marca el medicamento como finalizado. El historial sigue siendo visible.", + "button": "Finalizar", + "dialogTitle": "Finalizar {name} — detiene los recordatorios, el historial sigue visible", + "dialogBody": "No habrá más recordatorios. Las entradas antiguas permanecen en la línea de tiempo.", + "toast": "Medicamento finalizado", + "failed": "No se pudo finalizar el medicamento." }, "purge": { - "title": "Delete history", - "helper": "{count} intake events are stored for this medication.", - "button": "Delete history", - "dialogTitle": "Really delete the history?", - "dialogBody": "The {count} intake events will be removed permanently. The medication itself stays.", - "toast": "History deleted", - "failed": "Could not delete the history." + "title": "Eliminar historial", + "helper": "Hay {count} tomas registradas para este medicamento.", + "button": "Eliminar historial", + "dialogTitle": "¿Eliminar realmente el historial?", + "dialogBody": "Las {count} tomas se eliminarán de forma permanente. El medicamento se conserva.", + "toast": "Historial eliminado", + "failed": "No se pudo eliminar el historial." }, "delete": { - "title": "Delete medication", - "helper": "Removes the medication and every related record.", - "button": "Delete", - "dialogTitle": "Delete {name}?", - "dialogBody": "The medication, its schedules, intake history, API tokens, phase config and reminders will be removed permanently.", - "toast": "Medication deleted", - "failed": "Could not delete the medication." + "title": "Eliminar medicamento", + "helper": "Elimina el medicamento y todos los registros relacionados.", + "button": "Eliminar", + "dialogTitle": "¿Eliminar {name}?", + "dialogBody": "El medicamento, sus horarios, el historial de tomas, los tokens de API, la configuración de fases y los recordatorios se eliminarán de forma permanente.", + "toast": "Medicamento eliminado", + "failed": "No se pudo eliminar el medicamento." } }, "phases": { - "modeLabel": "{phase} mode", - "saveFailed": "Could not save the reminder phases." + "modeLabel": "Modo de {phase}", + "saveFailed": "No se pudieron guardar las fases de recordatorio." }, "api": { - "caption": "Endpoint for \"{name}\"", - "copyUrl": "Copy URL", - "urlCopied": "URL copied", - "mintToken": "Mint token", - "mintAnotherToken": "Mint another token", - "mintFailed": "Could not mint a token.", - "copyFailed": "Could not copy.", - "tokenCopied": "Token copied", - "copyToken": "Copy token", - "mintedHint": "Copy this token now — it is shown only once." + "caption": "Endpoint para «{name}»", + "copyUrl": "Copiar URL", + "urlCopied": "URL copiada", + "mintToken": "Generar token", + "mintAnotherToken": "Generar otro token", + "mintFailed": "No se pudo generar un token.", + "copyFailed": "No se pudo copiar.", + "tokenCopied": "Token copiado", + "copyToken": "Copiar token", + "mintedHint": "Copia este token ahora — solo se muestra una vez." }, "edit": { "planOption": "Editar plan", diff --git a/messages/fr.json b/messages/fr.json index 26988c90..5d63ec44 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -554,35 +554,35 @@ "scheduleDaily": "Quotidien", "inactive": "En pause", "intakeHistory": "Historique des prises", - "openDetailPage": "Open medication detail page", + "openDetailPage": "Ouvrir la page de détail du médicament", "deleteConfirm": "Supprimer le médicament ?", "compliance": "Observance", - "importIntakes": "Import intakes", - "importDescription": "Paste a JSON array with intake data. Expected format:", - "importUploadFile": "Upload JSON file", - "importPaste": "Paste JSON here...", - "importSelected": "Selected: {name}", - "importFileLoaded": "File \"{name}\" loaded", - "importInvalidJson": "File does not contain valid JSON", - "importNoArray": "No array found", - "importResult": "{imported} intakes imported", - "importDuplicatesSkipped": "{count} duplicates skipped", - "importInvalidSkipped": "{count} invalid entries skipped", - "importFailed": "Import failed", - "importInvalidFormat": "Invalid JSON format", - "apiEndpointTitle": "API Endpoint: {name}", - "apiEndpointDescription": "Use this endpoint to record intakes via API (e.g., iPhone Shortcut or cron job).", - "apiEndpointActive": "API endpoint active", - "apiEndpointActivated": "API endpoint activated", - "apiEndpointDeactivated": "API endpoint deactivated", - "apiTokenCount": "Active ({count} tokens)", - "apiToken": "API Token", - "apiTokenActiveHint": "Endpoint is active. Existing tokens remain valid but cannot be shown in plain text again.", - "apiTokenActivateHint": "Activate the endpoint to create a new token.", - "apiTokenOnceHint": "The token is only shown in plain text once at creation.", - "requestExample": "Request example", - "statusLoadFailed": "Could not load status", - "changeFailed": "Change failed", + "importIntakes": "Importer les prises", + "importDescription": "Collez un tableau JSON contenant les données de prises. Format attendu :", + "importUploadFile": "Téléverser un fichier JSON", + "importPaste": "Collez le JSON ici...", + "importSelected": "Sélectionné : {name}", + "importFileLoaded": "Fichier « {name} » chargé", + "importInvalidJson": "Le fichier ne contient pas de JSON valide", + "importNoArray": "Aucun tableau trouvé", + "importResult": "{imported} prises importées", + "importDuplicatesSkipped": "{count} doublons ignorés", + "importInvalidSkipped": "{count} entrées non valides ignorées", + "importFailed": "Échec de l’importation", + "importInvalidFormat": "Format JSON non valide", + "apiEndpointTitle": "Point de terminaison API : {name}", + "apiEndpointDescription": "Utilisez ce point de terminaison pour enregistrer des prises via l’API (par exemple un Raccourci iPhone ou une tâche cron).", + "apiEndpointActive": "Point de terminaison API actif", + "apiEndpointActivated": "Point de terminaison API activé", + "apiEndpointDeactivated": "Point de terminaison API désactivé", + "apiTokenCount": "Actif ({count} jetons)", + "apiToken": "Jeton API", + "apiTokenActiveHint": "Le point de terminaison est actif. Les jetons existants restent valides mais ne peuvent plus être affichés en texte clair.", + "apiTokenActivateHint": "Activez le point de terminaison pour créer un nouveau jeton.", + "apiTokenOnceHint": "Le jeton n’est affiché en texte clair qu’une seule fois, lors de sa création.", + "requestExample": "Exemple de requête", + "statusLoadFailed": "Impossible de charger le statut", + "changeFailed": "Échec de la modification", "categoryBloodPressure": "Tension artérielle", "categoryVitamin": "Vitamines", "categoryThyroid": "Thyroïde", @@ -594,7 +594,7 @@ "categorySkin": "Soin de la peau", "categorySleepAid": "Aide au sommeil", "categoryDiabetes": "Diabetes", - "categoryAntibiotic": "Antibiotic", + "categoryAntibiotic": "Antibiotique", "categoryOther": "Autres", "daysSun": "Dim", "daysMon": "Lun", @@ -691,7 +691,7 @@ "glp1Specific": "Spécifique GLP-1" }, "entries": { - "nausea": "Nausée", + "nausea": "Nausées", "vomiting": "Vomissement", "diarrhea": "Diarrhée", "constipation": "Constipation", @@ -739,48 +739,48 @@ }, "cadence": { "section": "Cadence", - "label": "How often", + "label": "À quelle fréquence", "kind": { - "daily": "Every day", - "weekdays": "Certain days of the week", - "everyNWeeks": "Every N weeks on certain days", - "monthly": "Monthly on a specific day", - "everyNMonths": "Every N months on a specific day", - "yearly": "Once a year", - "rolling": "Every N days from when I last took it (flexible)", - "rollingExplainer": "Counts from your last logged intake — pauses if you skip a dose.", - "oneShot": "One-time dose" + "daily": "Chaque jour", + "weekdays": "Certains jours de la semaine", + "everyNWeeks": "Toutes les N semaines certains jours", + "monthly": "Chaque mois un jour précis", + "everyNMonths": "Tous les N mois un jour précis", + "yearly": "Une fois par an", + "rolling": "Tous les N jours depuis la dernière prise (flexible)", + "rollingExplainer": "Compte à partir de votre dernière prise enregistrée — se met en pause si vous sautez une dose.", + "oneShot": "Dose unique" }, "weekdays": { - "label": "Days of the week", + "label": "Jours de la semaine", "short": { "mo": "Mo", - "tu": "Tu", - "we": "We", - "th": "Th", + "tu": "Ma", + "we": "Me", + "th": "Je", "fr": "Fr", "sa": "Sa", - "su": "Su" + "su": "Di" }, "long": { - "mo": "Monday", - "tu": "Tuesday", - "we": "Wednesday", - "th": "Thursday", - "fr": "Friday", - "sa": "Saturday", - "su": "Sunday" + "mo": "Lundi", + "tu": "Mardi", + "we": "Mercredi", + "th": "Jeudi", + "fr": "Vendredi", + "sa": "Samedi", + "su": "Dimanche" } }, "intervalWeeks": { - "suffix": "weeks" + "suffix": "semaines" }, "dayOfMonth": { - "label": "Day" + "label": "Jour" }, "intervalMonths": { - "suffix": "months", - "dayOnLabel": "on day" + "suffix": "mois", + "dayOnLabel": "le jour" }, "yearly": { "date": { @@ -788,91 +788,91 @@ } }, "rollingDays": { - "suffix": "days" + "suffix": "jours" } }, "timesOfDay": { - "section": "Times of day", - "label": "Times of day", + "section": "Heures de la journée", + "label": "Heures de la journée", "empty": { - "cta": "Add the first time." + "cta": "Ajoutez la première heure." }, - "add": "Add time", - "remove": "Remove", + "add": "Ajouter une heure", + "remove": "Retirer", "presets": { - "morning": "Morning", - "noon": "Noon", - "evening": "Evening", - "night": "Night" + "morning": "Matin", + "noon": "Midi", + "evening": "Soir", + "night": "Nuit" }, "max": { - "reached": "Maximum reached — remove a time before adding another." + "reached": "Maximum atteint — retirez une heure avant d’en ajouter une autre." } }, "courseWindow": { - "section": "Course window", + "section": "Période de traitement", "startsOn": { - "label": "Starts on" + "label": "Commence le" }, "endsOn": { - "label": "Ends on" + "label": "Se termine le" }, - "noEndDate": "No end date", - "oneShotCaption": "(one-time dose)", - "invalidRange": "End date must be on or after the start date." + "noEndDate": "Pas de date de fin", + "oneShotCaption": "(dose unique)", + "invalidRange": "La date de fin doit être égale ou postérieure à la date de début." } }, "wizard": { "header": { - "stepOf": "Step {current} of {total}", - "createTitle": "New medication", - "editTitle": "Edit {name}" + "stepOf": "Étape {current} sur {total}", + "createTitle": "Nouveau médicament", + "editTitle": "Modifier {name}" }, "nav": { - "back": "Back", - "next": "Next", - "save": "Save", - "saveEdit": "Save changes", + "back": "Retour", + "next": "Suivant", + "save": "Enregistrer", + "saveEdit": "Enregistrer les modifications", "nextHint": "Suivant : {step}", "reviewHint": "Suivant : Enregistrer", "jumpFirst": "Aller à la première étape", "jumpLast": "Aller à la vérification et à l’enregistrement" }, "nl": { - "button": "Describe" + "button": "Décrire" }, "steps": { "step1": { - "title": "What's the medication called?", - "subline": "Exactly as it appears on the box.", + "title": "Comment s’appelle le médicament ?", + "subline": "Exactement comme indiqué sur la boîte.", "label": "Name", - "placeholder": "e.g. Ramipril", + "placeholder": "par exemple Ramipril", "short": "Nom" }, "step2": { - "title": "What kind of medication is it?", - "subline": "We use this for the right templates and analytics.", + "title": "De quel type de médicament s’agit-il ?", + "subline": "Nous l’utilisons pour les bons modèles et analyses.", "short": "Type" }, "step3": { - "title": "What's the dose?", - "subline": "A tablet, a puff, a drop — depending on the form.", - "amountLabel": "Amount", - "amountPlaceholder": "e.g. 5", - "unitLabel": "Unit", + "title": "Quelle est la dose ?", + "subline": "Un comprimé, une bouffée, une goutte — selon la forme.", + "amountLabel": "Quantité", + "amountPlaceholder": "par exemple 5", + "unitLabel": "Unité", "unit": { "mg": "mg", "ml": "ml", - "iu": "IU", + "iu": "UI", "mcg": "µg", "g": "g", - "tablets": "tablet(s)", - "capsules": "capsule(s)", - "drops": "drops", - "puffs": "puff", - "sprays": "spray", - "pieces": "pieces", - "other": "other" + "tablets": "comprimé(s)", + "capsules": "gélule(s)", + "drops": "gouttes", + "puffs": "bouffée", + "sprays": "pulvérisation", + "pieces": "pièces", + "other": "autre" }, "deliveryFormLabel": "Voie", "deliveryForm": { @@ -888,124 +888,124 @@ "short": "Dose" }, "step4": { - "title": "Over what period are you taking it?", - "subline": "A start date is enough for today. You can add an end date later.", + "title": "Sur quelle période le prenez-vous ?", + "subline": "Une date de début suffit pour aujourd’hui. Vous pourrez ajouter une date de fin plus tard.", "short": "Quand" }, "step5": { - "title": "How often do you take it?", - "subline": "Pick the cadence — the details follow on the next step.", + "title": "À quelle fréquence le prenez-vous ?", + "subline": "Choisissez la cadence — les détails suivent à l’étape suivante.", "short": "Fréquence" }, "step6": { - "title": "What does that look like in detail?", - "subline": "Set weekdays, intervals, or the day of the month.", + "title": "À quoi cela ressemble-t-il en détail ?", + "subline": "Indiquez les jours de la semaine, les intervalles ou le jour du mois.", "intervalWeeks": { - "label": "Every", - "suffix": "weeks" + "label": "Toutes les", + "suffix": "semaines" }, "dayOfMonth": { - "label": "On", - "suffix": "day of the month" + "label": "Le", + "suffix": "jour du mois" }, "rollingDays": { - "label": "Every", - "suffix": "days from the last intake" + "label": "Tous les", + "suffix": "jours depuis la dernière prise" }, "short": "Détail" }, "step7": { - "title": "When do you take it?", - "subline": "One or more times of day — reminders follow this list.", - "presetsLabel": "Time-of-day presets", + "title": "Quand le prenez-vous ?", + "subline": "Une ou plusieurs heures de la journée — les rappels suivent cette liste.", + "presetsLabel": "Préréglages par moment de la journée", "presets": { - "morning": "Morning", - "noon": "Noon", - "evening": "Evening", - "night": "Night" + "morning": "Matin", + "noon": "Midi", + "evening": "Soir", + "night": "Nuit" }, "short": "Heures" }, "step8": { - "title": "All done — should HealthLog remind you?", - "subline": "Review everything below before you save.", - "remindersLabel": "Reminders on", - "remindersDescription": "Push, Telegram or ntfy — based on your settings.", - "multiScheduleNote": "This medication has multiple schedules. The detailed editor returns in a later release.", + "title": "Terminé — HealthLog doit-il vous le rappeler ?", + "subline": "Vérifiez tout ci-dessous avant d’enregistrer.", + "remindersLabel": "Rappels activés", + "remindersDescription": "Push, Telegram ou ntfy — selon vos réglages.", + "multiScheduleNote": "Ce médicament a plusieurs horaires. L’éditeur détaillé reviendra dans une version ultérieure.", "short": "Fini" } }, "classRow": { - "bloodPressure": "Blood pressure", + "bloodPressure": "Tension artérielle", "diabetes": "Diabetes", "hormone": "Hormones", - "glp1": "GLP-1 injection", - "painRelief": "Pain", - "allergy": "Allergy", - "vitamin": "Vitamins", - "supplement": "Supplement", - "antibiotic": "Antibiotic", - "other": "Other" + "glp1": "Injection de GLP-1", + "painRelief": "Douleur", + "allergy": "Allergie", + "vitamin": "Vitamines", + "supplement": "Complément", + "antibiotic": "Antibiotique", + "other": "Autre" }, "cadence": { "daily": { - "label": "Daily", - "description": "Every day at the same time." + "label": "Quotidien", + "description": "Chaque jour à la même heure." }, "weekdays": { - "label": "Specific weekdays", - "description": "E.g. Monday, Wednesday, Friday." + "label": "Jours précis de la semaine", + "description": "Par exemple lundi, mercredi, vendredi." }, "everyNWeeks": { - "label": "Every few weeks", - "description": "E.g. every 2 weeks on Thursdays (GLP-1, biologics)." + "label": "Toutes les quelques semaines", + "description": "Par exemple toutes les 2 semaines le jeudi (GLP-1, biologiques)." }, "monthly": { - "label": "Monthly", - "description": "On a fixed day each month (e.g. always the 1st)." + "label": "Mensuel", + "description": "Un jour fixe chaque mois (par exemple toujours le 1er)." }, "rolling": { - "label": "Flexible from last dose", - "description": "Leave the next dose open. When you tap 'taken', the counter starts again from that moment." + "label": "Flexible depuis la dernière dose", + "description": "Laissez la prochaine dose ouverte. Quand vous appuyez sur « pris », le compteur repart à partir de ce moment." }, "oneShot": { - "label": "Single dose", - "description": "One administration, e.g. a vaccine or an antibiotic course." + "label": "Dose unique", + "description": "Une seule administration, par exemple un vaccin ou une cure d’antibiotique." } }, "summary": { - "title": "Summary", + "title": "Résumé", "cadence": { - "daily": "Every day", - "weekdays": "On selected days of the week", - "biweekly": "Every two weeks", - "monthly": "Monthly", - "quarterly": "Every three months", - "yearly": "Once a year", - "rolling": "Every {n} days from your last intake", - "oneShot": "A single dose", - "everyNWeeks": "Every {n} weeks", - "everyNMonths": "Every {n} months" + "daily": "Chaque jour", + "weekdays": "Les jours de la semaine sélectionnés", + "biweekly": "Toutes les deux semaines", + "monthly": "Mensuel", + "quarterly": "Tous les trois mois", + "yearly": "Une fois par an", + "rolling": "Tous les {n} jours depuis votre dernière prise", + "oneShot": "Une dose unique", + "everyNWeeks": "Toutes les {n} semaines", + "everyNMonths": "Tous les {n} mois" }, - "weekdaysDetail": ", on {days}", - "dayOfMonthDetail": ", on day {day}.", - "times": "at {times}", - "startsOn": "Starts: {date}", - "endsOn": "Ends: {date}", - "noEndDate": "No end date" + "weekdaysDetail": ", le {days}", + "dayOfMonthDetail": ", le {day}.", + "times": "à {times}", + "startsOn": "Début : {date}", + "endsOn": "Fin : {date}", + "noEndDate": "Pas de date de fin" }, "compose": { - "scheduleIndex": "Schedule {n} of {total}", + "scheduleIndex": "Horaire {n} sur {total}", "list": { - "add": "Add another schedule", - "edit": "Edit", - "remove": "Remove", - "removeDisabled": "At least one schedule is required.", - "empty": "No schedule yet." + "add": "Ajouter un autre horaire", + "edit": "Modifier", + "remove": "Retirer", + "removeDisabled": "Au moins un horaire est requis.", + "empty": "Pas encore d’horaire." } }, "errors": { - "submitFailed": "Could not save the medication — please retry." + "submitFailed": "Impossible d’enregistrer le médicament — veuillez réessayer." } }, "doseStrength": { @@ -1160,88 +1160,88 @@ }, "detail": { "status": { - "active": "Active", - "paused": "Paused", - "ended": "Ended" + "active": "Actif", + "paused": "En pause", + "ended": "Terminé" }, "today": { - "groupLabel": "Today's dose", - "taken": "Taken", - "skipped": "Skipped", - "toastTaken": "Today's dose logged", - "toastSkipped": "Today's dose skipped", - "recordedTaken": "Taken today at {time}", - "recordedSkipped": "Skipped today at {time}", - "recordedOneShot": "One-shot dose taken at {time}", - "noneScheduled": "No dose scheduled for today.", - "pausedHint": "Paused — no reminder today.", - "error": "Could not log the dose." + "groupLabel": "Dose du jour", + "taken": "Pris", + "skipped": "Sauté", + "toastTaken": "Dose du jour enregistrée", + "toastSkipped": "Dose du jour sautée", + "recordedTaken": "Prise aujourd’hui à {time}", + "recordedSkipped": "Sautée aujourd’hui à {time}", + "recordedOneShot": "Dose unique prise à {time}", + "noneScheduled": "Aucune dose prévue aujourd’hui.", + "pausedHint": "En pause — pas de rappel aujourd’hui.", + "error": "Impossible d’enregistrer la dose." }, "cadence": { - "oneShotOn": "One-time dose on {date}.", - "oneShotPending": "One-time dose pending." + "oneShotOn": "Dose unique le {date}.", + "oneShotPending": "Dose unique en attente." }, "intake": { - "title": "Intake history", - "importButton": "Import", + "title": "Historique des prises", + "importButton": "Importer", "rowActions": { - "openMenu": "Open row actions", - "edit": "Edit", - "delete": "Delete" + "openMenu": "Ouvrir les actions de la ligne", + "edit": "Modifier", + "delete": "Supprimer" }, "selection": { - "rowToggleLabel": "Select row" + "rowToggleLabel": "Sélectionner la ligne" }, "bulkDelete": { - "selectionCount": "{count} entries selected", - "deleteButton": "Delete selection", - "cancelButton": "Cancel", - "confirmTitle": "Delete the selected entries?", - "confirmBody": "{count} entries will be removed permanently. The medication itself stays.", - "confirmAction": "Delete", - "toast": "Selection deleted", - "failed": "Could not delete the selection." + "selectionCount": "{count} entrées sélectionnées", + "deleteButton": "Supprimer la sélection", + "cancelButton": "Annuler", + "confirmTitle": "Supprimer les entrées sélectionnées ?", + "confirmBody": "{count} entrées seront supprimées définitivement. Le médicament est conservé.", + "confirmAction": "Supprimer", + "toast": "Sélection supprimée", + "failed": "Impossible de supprimer la sélection." }, "edit": { - "dialogTitle": "Edit intake", - "takenAtLabel": "Taken at", - "skippedLabel": "Skipped", + "dialogTitle": "Modifier la prise", + "takenAtLabel": "Prise à", + "skippedLabel": "Sautée", "noteLabel": "Note", - "save": "Save", - "cancel": "Cancel", - "savedToast": "Intake updated", - "failed": "Could not update the intake." + "save": "Enregistrer", + "cancel": "Annuler", + "savedToast": "Prise mise à jour", + "failed": "Impossible de mettre à jour la prise." }, "deleteRow": { - "confirmTitle": "Delete this intake?", - "confirmBody": "The entry is removed permanently.", - "confirmAction": "Delete", - "toast": "Intake deleted", - "failed": "Could not delete the intake." + "confirmTitle": "Supprimer cette prise ?", + "confirmBody": "L’entrée est supprimée définitivement.", + "confirmAction": "Supprimer", + "toast": "Prise supprimée", + "failed": "Impossible de supprimer la prise." } }, "notifications": { - "title": "Notifications", - "switchLabel": "Send a reminder when this dose is due", - "helperOn": "Reminders are on.", - "helperOff": "Reminders are off.", - "enabledToast": "Reminders on", - "disabledToast": "Reminders off", - "toggleFailed": "Could not change the reminder setting.", - "clientManagedChip": "Your iPhone manages reminders for this medication." + "title": "Rappels", + "switchLabel": "Envoyer un rappel lorsque cette dose est due", + "helperOn": "Les rappels sont activés.", + "helperOff": "Les rappels sont désactivés.", + "enabledToast": "Rappels activés", + "disabledToast": "Rappels désactivés", + "toggleFailed": "Impossible de modifier le réglage des rappels.", + "clientManagedChip": "Votre iPhone gère les rappels de ce médicament." }, "settings": { - "title": "Settings", + "title": "Réglages", "phases": { - "openButton": "Configure phases", - "requiresCourseWindow": "Phases are available once a course window is set." + "openButton": "Configurer les phases", + "requiresCourseWindow": "Les phases sont disponibles une fois une période de traitement définie." }, "grace": { - "label": "Reminder window — applies to your primary schedule", - "primaryScheduleNote": "Multi-schedule medications keep their other schedules' default window.", + "label": "Fenêtre de rappel — s’applique à votre horaire principal", + "primaryScheduleNote": "Les médicaments à horaires multiples conservent la fenêtre par défaut de leurs autres horaires.", "unit": "minutes", - "saved": "Reminder window saved", - "failed": "Could not save the reminder window." + "saved": "Fenêtre de rappel enregistrée", + "failed": "Impossible d’enregistrer la fenêtre de rappel." }, "codes": { "label": "Codes cliniques (ATC / RxNorm)", @@ -1253,57 +1253,57 @@ } }, "zone": { - "title": "Manage & danger zone", + "title": "Gestion et zone à risque", "pause": { - "title": "Pause", - "helper": "Pause reminders until you turn them back on. History stays intact.", - "pausedToast": "Reminders paused", - "resumedToast": "Reminders resumed", - "failed": "Could not change the paused state." + "title": "Mettre en pause", + "helper": "Mettez les rappels en pause jusqu’à ce que vous les réactiviez. L’historique est conservé.", + "pausedToast": "Rappels mis en pause", + "resumedToast": "Rappels réactivés", + "failed": "Impossible de modifier l’état de pause." }, "end": { - "title": "End medication", - "helper": "Stop reminders and mark the medication as ended. History stays visible.", - "button": "End", - "dialogTitle": "End {name} — stop reminders, history stays visible", - "dialogBody": "No more reminders. Old entries remain in the timeline.", - "toast": "Medication ended", - "failed": "Could not end the medication." + "title": "Terminer le médicament", + "helper": "Arrête les rappels et marque le médicament comme terminé. L’historique reste visible.", + "button": "Terminer", + "dialogTitle": "Terminer {name} — arrête les rappels, l’historique reste visible", + "dialogBody": "Plus de rappels. Les anciennes entrées restent dans la chronologie.", + "toast": "Médicament terminé", + "failed": "Impossible de terminer le médicament." }, "purge": { - "title": "Delete history", - "helper": "{count} intake events are stored for this medication.", - "button": "Delete history", - "dialogTitle": "Really delete the history?", - "dialogBody": "The {count} intake events will be removed permanently. The medication itself stays.", - "toast": "History deleted", - "failed": "Could not delete the history." + "title": "Supprimer l’historique", + "helper": "{count} prises sont enregistrées pour ce médicament.", + "button": "Supprimer l’historique", + "dialogTitle": "Supprimer vraiment l’historique ?", + "dialogBody": "Les {count} prises seront supprimées définitivement. Le médicament est conservé.", + "toast": "Historique supprimé", + "failed": "Impossible de supprimer l’historique." }, "delete": { - "title": "Delete medication", - "helper": "Removes the medication and every related record.", - "button": "Delete", - "dialogTitle": "Delete {name}?", - "dialogBody": "The medication, its schedules, intake history, API tokens, phase config and reminders will be removed permanently.", - "toast": "Medication deleted", - "failed": "Could not delete the medication." + "title": "Supprimer le médicament", + "helper": "Supprime le médicament et tous les enregistrements associés.", + "button": "Supprimer", + "dialogTitle": "Supprimer {name} ?", + "dialogBody": "Le médicament, ses horaires, l’historique des prises, les jetons API, la configuration des phases et les rappels seront supprimés définitivement.", + "toast": "Médicament supprimé", + "failed": "Impossible de supprimer le médicament." } }, "phases": { - "modeLabel": "{phase} mode", - "saveFailed": "Could not save the reminder phases." + "modeLabel": "Mode {phase}", + "saveFailed": "Impossible d’enregistrer les phases de rappel." }, "api": { - "caption": "Endpoint for \"{name}\"", - "copyUrl": "Copy URL", - "urlCopied": "URL copied", - "mintToken": "Mint token", - "mintAnotherToken": "Mint another token", - "mintFailed": "Could not mint a token.", - "copyFailed": "Could not copy.", - "tokenCopied": "Token copied", - "copyToken": "Copy token", - "mintedHint": "Copy this token now — it is shown only once." + "caption": "Point de terminaison pour « {name} »", + "copyUrl": "Copier l’URL", + "urlCopied": "URL copiée", + "mintToken": "Générer un jeton", + "mintAnotherToken": "Générer un autre jeton", + "mintFailed": "Impossible de générer un jeton.", + "copyFailed": "Impossible de copier.", + "tokenCopied": "Jeton copié", + "copyToken": "Copier le jeton", + "mintedHint": "Copiez ce jeton maintenant — il n’est affiché qu’une seule fois." }, "edit": { "planOption": "Modifier le plan", diff --git a/messages/it.json b/messages/it.json index 55152c79..c303acfc 100644 --- a/messages/it.json +++ b/messages/it.json @@ -554,35 +554,35 @@ "scheduleDaily": "Quotidiano", "inactive": "In pausa", "intakeHistory": "Cronologia assunzioni", - "openDetailPage": "Open medication detail page", + "openDetailPage": "Apri la pagina di dettaglio del farmaco", "deleteConfirm": "Eliminare il farmaco?", "compliance": "Aderenza", - "importIntakes": "Import intakes", - "importDescription": "Paste a JSON array with intake data. Expected format:", - "importUploadFile": "Upload JSON file", - "importPaste": "Paste JSON here...", - "importSelected": "Selected: {name}", - "importFileLoaded": "File \"{name}\" loaded", - "importInvalidJson": "File does not contain valid JSON", - "importNoArray": "No array found", - "importResult": "{imported} intakes imported", - "importDuplicatesSkipped": "{count} duplicates skipped", - "importInvalidSkipped": "{count} invalid entries skipped", - "importFailed": "Import failed", - "importInvalidFormat": "Invalid JSON format", - "apiEndpointTitle": "API Endpoint: {name}", - "apiEndpointDescription": "Use this endpoint to record intakes via API (e.g., iPhone Shortcut or cron job).", - "apiEndpointActive": "API endpoint active", - "apiEndpointActivated": "API endpoint activated", - "apiEndpointDeactivated": "API endpoint deactivated", - "apiTokenCount": "Active ({count} tokens)", - "apiToken": "API Token", - "apiTokenActiveHint": "Endpoint is active. Existing tokens remain valid but cannot be shown in plain text again.", - "apiTokenActivateHint": "Activate the endpoint to create a new token.", - "apiTokenOnceHint": "The token is only shown in plain text once at creation.", - "requestExample": "Request example", - "statusLoadFailed": "Could not load status", - "changeFailed": "Change failed", + "importIntakes": "Importa assunzioni", + "importDescription": "Incolla un array JSON con i dati delle assunzioni. Formato previsto:", + "importUploadFile": "Carica file JSON", + "importPaste": "Incolla il JSON qui...", + "importSelected": "Selezionato: {name}", + "importFileLoaded": "File \"{name}\" caricato", + "importInvalidJson": "Il file non contiene un JSON valido", + "importNoArray": "Nessun array trovato", + "importResult": "{imported} assunzioni importate", + "importDuplicatesSkipped": "{count} duplicati ignorati", + "importInvalidSkipped": "{count} voci non valide ignorate", + "importFailed": "Importazione non riuscita", + "importInvalidFormat": "Formato JSON non valido", + "apiEndpointTitle": "Endpoint API: {name}", + "apiEndpointDescription": "Usa questo endpoint per registrare assunzioni tramite API (ad es. con un Comando rapido iPhone o un cron job).", + "apiEndpointActive": "Endpoint API attivo", + "apiEndpointActivated": "Endpoint API attivato", + "apiEndpointDeactivated": "Endpoint API disattivato", + "apiTokenCount": "Attivo ({count} token)", + "apiToken": "Token API", + "apiTokenActiveHint": "L’endpoint è attivo. I token esistenti restano validi ma non possono essere mostrati di nuovo in chiaro.", + "apiTokenActivateHint": "Attiva l’endpoint per creare un nuovo token.", + "apiTokenOnceHint": "Il token viene mostrato in chiaro solo una volta, alla creazione.", + "requestExample": "Esempio di richiesta", + "statusLoadFailed": "Impossibile caricare lo stato", + "changeFailed": "Modifica non riuscita", "categoryBloodPressure": "Pressione arteriosa", "categoryVitamin": "Vitamine", "categoryThyroid": "Tiroide", @@ -594,7 +594,7 @@ "categorySkin": "Cura della pelle", "categorySleepAid": "Per dormire", "categoryDiabetes": "Diabetes", - "categoryAntibiotic": "Antibiotic", + "categoryAntibiotic": "Antibiotico", "categoryOther": "Altro", "daysSun": "Dom", "daysMon": "Lun", @@ -738,141 +738,141 @@ } }, "cadence": { - "section": "Cadence", - "label": "How often", + "section": "Cadenza", + "label": "Con quale frequenza", "kind": { - "daily": "Every day", - "weekdays": "Certain days of the week", - "everyNWeeks": "Every N weeks on certain days", - "monthly": "Monthly on a specific day", - "everyNMonths": "Every N months on a specific day", - "yearly": "Once a year", - "rolling": "Every N days from when I last took it (flexible)", - "rollingExplainer": "Counts from your last logged intake — pauses if you skip a dose.", - "oneShot": "One-time dose" + "daily": "Ogni giorno", + "weekdays": "In determinati giorni della settimana", + "everyNWeeks": "Ogni N settimane in determinati giorni", + "monthly": "Mensilmente in un giorno specifico", + "everyNMonths": "Ogni N mesi in un giorno specifico", + "yearly": "Una volta all’anno", + "rolling": "Ogni N giorni dall’ultima assunzione (flessibile)", + "rollingExplainer": "Conta dall’ultima assunzione registrata — si mette in pausa se salti una dose.", + "oneShot": "Dose singola" }, "weekdays": { - "label": "Days of the week", + "label": "Giorni della settimana", "short": { "mo": "Mo", - "tu": "Tu", - "we": "We", - "th": "Th", + "tu": "Ma", + "we": "Me", + "th": "Gi", "fr": "Fr", "sa": "Sa", - "su": "Su" + "su": "Do" }, "long": { - "mo": "Monday", - "tu": "Tuesday", - "we": "Wednesday", - "th": "Thursday", - "fr": "Friday", - "sa": "Saturday", - "su": "Sunday" + "mo": "Lunedì", + "tu": "Martedì", + "we": "Mercoledì", + "th": "Giovedì", + "fr": "Venerdì", + "sa": "Sabato", + "su": "Domenica" } }, "intervalWeeks": { - "suffix": "weeks" + "suffix": "settimane" }, "dayOfMonth": { - "label": "Day" + "label": "Giorno" }, "intervalMonths": { - "suffix": "months", - "dayOnLabel": "on day" + "suffix": "mesi", + "dayOnLabel": "il giorno" }, "yearly": { "date": { - "label": "Date" + "label": "Data" } }, "rollingDays": { - "suffix": "days" + "suffix": "giorni" } }, "timesOfDay": { - "section": "Times of day", - "label": "Times of day", + "section": "Orari del giorno", + "label": "Orari del giorno", "empty": { - "cta": "Add the first time." + "cta": "Aggiungi il primo orario." }, - "add": "Add time", - "remove": "Remove", + "add": "Aggiungi orario", + "remove": "Rimuovi", "presets": { - "morning": "Morning", - "noon": "Noon", - "evening": "Evening", - "night": "Night" + "morning": "Mattina", + "noon": "Mezzogiorno", + "evening": "Sera", + "night": "Notte" }, "max": { - "reached": "Maximum reached — remove a time before adding another." + "reached": "Massimo raggiunto — rimuovi un orario prima di aggiungerne un altro." } }, "courseWindow": { - "section": "Course window", + "section": "Periodo di trattamento", "startsOn": { - "label": "Starts on" + "label": "Inizia il" }, "endsOn": { - "label": "Ends on" + "label": "Termina il" }, - "noEndDate": "No end date", - "oneShotCaption": "(one-time dose)", - "invalidRange": "End date must be on or after the start date." + "noEndDate": "Nessuna data di fine", + "oneShotCaption": "(dose singola)", + "invalidRange": "La data di fine deve essere uguale o successiva alla data di inizio." } }, "wizard": { "header": { - "stepOf": "Step {current} of {total}", - "createTitle": "New medication", - "editTitle": "Edit {name}" + "stepOf": "Passo {current} di {total}", + "createTitle": "Nuovo farmaco", + "editTitle": "Modifica {name}" }, "nav": { - "back": "Back", - "next": "Next", - "save": "Save", - "saveEdit": "Save changes", + "back": "Indietro", + "next": "Avanti", + "save": "Salva", + "saveEdit": "Salva modifiche", "nextHint": "Avanti: {step}", "reviewHint": "Avanti: Salva", "jumpFirst": "Vai al primo passaggio", "jumpLast": "Vai a revisione e salvataggio" }, "nl": { - "button": "Describe" + "button": "Descrivi" }, "steps": { "step1": { - "title": "What's the medication called?", - "subline": "Exactly as it appears on the box.", + "title": "Come si chiama il farmaco?", + "subline": "Esattamente come riportato sulla confezione.", "label": "Name", - "placeholder": "e.g. Ramipril", + "placeholder": "ad es. Ramipril", "short": "Nome" }, "step2": { - "title": "What kind of medication is it?", - "subline": "We use this for the right templates and analytics.", + "title": "Che tipo di farmaco è?", + "subline": "Lo usiamo per i modelli e le analisi corretti.", "short": "Tipo" }, "step3": { - "title": "What's the dose?", - "subline": "A tablet, a puff, a drop — depending on the form.", - "amountLabel": "Amount", - "amountPlaceholder": "e.g. 5", - "unitLabel": "Unit", + "title": "Qual è la dose?", + "subline": "Una compressa, uno spruzzo, una goccia — a seconda della forma.", + "amountLabel": "Quantità", + "amountPlaceholder": "ad es. 5", + "unitLabel": "Unità", "unit": { "mg": "mg", "ml": "ml", - "iu": "IU", + "iu": "UI", "mcg": "µg", "g": "g", - "tablets": "tablet(s)", - "capsules": "capsule(s)", - "drops": "drops", - "puffs": "puff", - "sprays": "spray", - "pieces": "pieces", - "other": "other" + "tablets": "compressa/e", + "capsules": "capsula/e", + "drops": "gocce", + "puffs": "spruzzo", + "sprays": "spruzzo", + "pieces": "pezzi", + "other": "altro" }, "deliveryFormLabel": "Via", "deliveryForm": { @@ -888,124 +888,124 @@ "short": "Dose" }, "step4": { - "title": "Over what period are you taking it?", - "subline": "A start date is enough for today. You can add an end date later.", + "title": "Per quanto tempo lo assumi?", + "subline": "Per oggi basta una data di inizio. Potrai aggiungere una data di fine in seguito.", "short": "Quando" }, "step5": { - "title": "How often do you take it?", - "subline": "Pick the cadence — the details follow on the next step.", + "title": "Con quale frequenza lo assumi?", + "subline": "Scegli la cadenza — i dettagli arrivano al passo successivo.", "short": "Frequenza" }, "step6": { - "title": "What does that look like in detail?", - "subline": "Set weekdays, intervals, or the day of the month.", + "title": "Come si presenta nel dettaglio?", + "subline": "Imposta i giorni della settimana, gli intervalli o il giorno del mese.", "intervalWeeks": { - "label": "Every", - "suffix": "weeks" + "label": "Ogni", + "suffix": "settimane" }, "dayOfMonth": { - "label": "On", - "suffix": "day of the month" + "label": "Il", + "suffix": "giorno del mese" }, "rollingDays": { - "label": "Every", - "suffix": "days from the last intake" + "label": "Ogni", + "suffix": "giorni dall’ultima assunzione" }, "short": "Dettaglio" }, "step7": { - "title": "When do you take it?", - "subline": "One or more times of day — reminders follow this list.", - "presetsLabel": "Time-of-day presets", + "title": "Quando lo assumi?", + "subline": "Uno o più orari del giorno — i promemoria seguono questo elenco.", + "presetsLabel": "Preimpostazioni per orario del giorno", "presets": { - "morning": "Morning", - "noon": "Noon", - "evening": "Evening", - "night": "Night" + "morning": "Mattina", + "noon": "Mezzogiorno", + "evening": "Sera", + "night": "Notte" }, "short": "Orari" }, "step8": { - "title": "All done — should HealthLog remind you?", - "subline": "Review everything below before you save.", - "remindersLabel": "Reminders on", - "remindersDescription": "Push, Telegram or ntfy — based on your settings.", - "multiScheduleNote": "This medication has multiple schedules. The detailed editor returns in a later release.", + "title": "Fatto — vuoi che HealthLog te lo ricordi?", + "subline": "Controlla tutto qui sotto prima di salvare.", + "remindersLabel": "Promemoria attivi", + "remindersDescription": "Push, Telegram o ntfy — in base alle tue impostazioni.", + "multiScheduleNote": "Questo farmaco ha più orari. L’editor dettagliato tornerà in una versione successiva.", "short": "Fatto" } }, "classRow": { - "bloodPressure": "Blood pressure", + "bloodPressure": "Pressione arteriosa", "diabetes": "Diabetes", - "hormone": "Hormones", - "glp1": "GLP-1 injection", - "painRelief": "Pain", - "allergy": "Allergy", - "vitamin": "Vitamins", - "supplement": "Supplement", - "antibiotic": "Antibiotic", - "other": "Other" + "hormone": "Ormoni", + "glp1": "Iniezione di GLP-1", + "painRelief": "Dolore", + "allergy": "Allergia", + "vitamin": "Vitamine", + "supplement": "Integratore", + "antibiotic": "Antibiotico", + "other": "Altro" }, "cadence": { "daily": { - "label": "Daily", - "description": "Every day at the same time." + "label": "Giornaliero", + "description": "Ogni giorno alla stessa ora." }, "weekdays": { - "label": "Specific weekdays", - "description": "E.g. Monday, Wednesday, Friday." + "label": "Giorni specifici della settimana", + "description": "Ad es. lunedì, mercoledì, venerdì." }, "everyNWeeks": { - "label": "Every few weeks", - "description": "E.g. every 2 weeks on Thursdays (GLP-1, biologics)." + "label": "Ogni poche settimane", + "description": "Ad es. ogni 2 settimane di giovedì (GLP-1, biologici)." }, "monthly": { - "label": "Monthly", - "description": "On a fixed day each month (e.g. always the 1st)." + "label": "Mensile", + "description": "Un giorno fisso ogni mese (ad es. sempre il 1°)." }, "rolling": { - "label": "Flexible from last dose", - "description": "Leave the next dose open. When you tap 'taken', the counter starts again from that moment." + "label": "Flessibile dall’ultima dose", + "description": "Lascia aperta la prossima dose. Quando tocchi «assunta», il conteggio riparte da quel momento." }, "oneShot": { - "label": "Single dose", - "description": "One administration, e.g. a vaccine or an antibiotic course." + "label": "Dose singola", + "description": "Una singola somministrazione, ad es. un vaccino o un ciclo di antibiotico." } }, "summary": { - "title": "Summary", + "title": "Riepilogo", "cadence": { - "daily": "Every day", - "weekdays": "On selected days of the week", - "biweekly": "Every two weeks", - "monthly": "Monthly", - "quarterly": "Every three months", - "yearly": "Once a year", - "rolling": "Every {n} days from your last intake", - "oneShot": "A single dose", - "everyNWeeks": "Every {n} weeks", - "everyNMonths": "Every {n} months" + "daily": "Ogni giorno", + "weekdays": "Nei giorni della settimana selezionati", + "biweekly": "Ogni due settimane", + "monthly": "Mensile", + "quarterly": "Ogni tre mesi", + "yearly": "Una volta all’anno", + "rolling": "Ogni {n} giorni dall’ultima assunzione", + "oneShot": "Una dose singola", + "everyNWeeks": "Ogni {n} settimane", + "everyNMonths": "Ogni {n} mesi" }, - "weekdaysDetail": ", on {days}", - "dayOfMonthDetail": ", on day {day}.", - "times": "at {times}", - "startsOn": "Starts: {date}", - "endsOn": "Ends: {date}", - "noEndDate": "No end date" + "weekdaysDetail": ", il {days}", + "dayOfMonthDetail": ", il giorno {day}.", + "times": "alle {times}", + "startsOn": "Inizio: {date}", + "endsOn": "Fine: {date}", + "noEndDate": "Nessuna data di fine" }, "compose": { - "scheduleIndex": "Schedule {n} of {total}", + "scheduleIndex": "Programma {n} di {total}", "list": { - "add": "Add another schedule", - "edit": "Edit", - "remove": "Remove", - "removeDisabled": "At least one schedule is required.", - "empty": "No schedule yet." + "add": "Aggiungi un altro programma", + "edit": "Modifica", + "remove": "Rimuovi", + "removeDisabled": "È richiesto almeno un programma.", + "empty": "Ancora nessun programma." } }, "errors": { - "submitFailed": "Could not save the medication — please retry." + "submitFailed": "Impossibile salvare il farmaco — riprova." } }, "doseStrength": { @@ -1160,88 +1160,88 @@ }, "detail": { "status": { - "active": "Active", - "paused": "Paused", - "ended": "Ended" + "active": "Attivo", + "paused": "In pausa", + "ended": "Terminato" }, "today": { - "groupLabel": "Today's dose", - "taken": "Taken", - "skipped": "Skipped", - "toastTaken": "Today's dose logged", - "toastSkipped": "Today's dose skipped", - "recordedTaken": "Taken today at {time}", - "recordedSkipped": "Skipped today at {time}", - "recordedOneShot": "One-shot dose taken at {time}", - "noneScheduled": "No dose scheduled for today.", - "pausedHint": "Paused — no reminder today.", - "error": "Could not log the dose." + "groupLabel": "Dose di oggi", + "taken": "Assunta", + "skipped": "Saltata", + "toastTaken": "Dose di oggi registrata", + "toastSkipped": "Dose di oggi saltata", + "recordedTaken": "Assunta oggi alle {time}", + "recordedSkipped": "Saltata oggi alle {time}", + "recordedOneShot": "Dose singola assunta alle {time}", + "noneScheduled": "Nessuna dose prevista per oggi.", + "pausedHint": "In pausa — nessun promemoria oggi.", + "error": "Impossibile registrare la dose." }, "cadence": { - "oneShotOn": "One-time dose on {date}.", - "oneShotPending": "One-time dose pending." + "oneShotOn": "Dose singola il {date}.", + "oneShotPending": "Dose singola in sospeso." }, "intake": { - "title": "Intake history", - "importButton": "Import", + "title": "Storico delle assunzioni", + "importButton": "Importa", "rowActions": { - "openMenu": "Open row actions", - "edit": "Edit", - "delete": "Delete" + "openMenu": "Apri azioni della riga", + "edit": "Modifica", + "delete": "Elimina" }, "selection": { - "rowToggleLabel": "Select row" + "rowToggleLabel": "Seleziona riga" }, "bulkDelete": { - "selectionCount": "{count} entries selected", - "deleteButton": "Delete selection", - "cancelButton": "Cancel", - "confirmTitle": "Delete the selected entries?", - "confirmBody": "{count} entries will be removed permanently. The medication itself stays.", - "confirmAction": "Delete", - "toast": "Selection deleted", - "failed": "Could not delete the selection." + "selectionCount": "{count} voci selezionate", + "deleteButton": "Elimina selezione", + "cancelButton": "Annulla", + "confirmTitle": "Eliminare le voci selezionate?", + "confirmBody": "{count} voci verranno rimosse definitivamente. Il farmaco resta.", + "confirmAction": "Elimina", + "toast": "Selezione eliminata", + "failed": "Impossibile eliminare la selezione." }, "edit": { - "dialogTitle": "Edit intake", - "takenAtLabel": "Taken at", - "skippedLabel": "Skipped", - "noteLabel": "Note", - "save": "Save", - "cancel": "Cancel", - "savedToast": "Intake updated", - "failed": "Could not update the intake." + "dialogTitle": "Modifica assunzione", + "takenAtLabel": "Assunta alle", + "skippedLabel": "Saltata", + "noteLabel": "Nota", + "save": "Salva", + "cancel": "Annulla", + "savedToast": "Assunzione aggiornata", + "failed": "Impossibile aggiornare l’assunzione." }, "deleteRow": { - "confirmTitle": "Delete this intake?", - "confirmBody": "The entry is removed permanently.", - "confirmAction": "Delete", - "toast": "Intake deleted", - "failed": "Could not delete the intake." + "confirmTitle": "Eliminare questa assunzione?", + "confirmBody": "La voce viene rimossa definitivamente.", + "confirmAction": "Elimina", + "toast": "Assunzione eliminata", + "failed": "Impossibile eliminare l’assunzione." } }, "notifications": { - "title": "Notifications", - "switchLabel": "Send a reminder when this dose is due", - "helperOn": "Reminders are on.", - "helperOff": "Reminders are off.", - "enabledToast": "Reminders on", - "disabledToast": "Reminders off", - "toggleFailed": "Could not change the reminder setting.", - "clientManagedChip": "Your iPhone manages reminders for this medication." + "title": "Promemoria", + "switchLabel": "Invia un promemoria quando questa dose è dovuta", + "helperOn": "I promemoria sono attivi.", + "helperOff": "I promemoria sono disattivati.", + "enabledToast": "Promemoria attivi", + "disabledToast": "Promemoria disattivati", + "toggleFailed": "Impossibile modificare l’impostazione dei promemoria.", + "clientManagedChip": "Il tuo iPhone gestisce i promemoria di questo farmaco." }, "settings": { - "title": "Settings", + "title": "Impostazioni", "phases": { - "openButton": "Configure phases", - "requiresCourseWindow": "Phases are available once a course window is set." + "openButton": "Configura le fasi", + "requiresCourseWindow": "Le fasi sono disponibili dopo aver impostato un periodo di trattamento." }, "grace": { - "label": "Reminder window — applies to your primary schedule", - "primaryScheduleNote": "Multi-schedule medications keep their other schedules' default window.", - "unit": "minutes", - "saved": "Reminder window saved", - "failed": "Could not save the reminder window." + "label": "Finestra di promemoria — si applica al tuo programma principale", + "primaryScheduleNote": "I farmaci con più programmi mantengono la finestra predefinita degli altri programmi.", + "unit": "minuti", + "saved": "Finestra di promemoria salvata", + "failed": "Impossibile salvare la finestra di promemoria." }, "codes": { "label": "Codici clinici (ATC / RxNorm)", @@ -1253,57 +1253,57 @@ } }, "zone": { - "title": "Manage & danger zone", + "title": "Gestione e zona pericolosa", "pause": { - "title": "Pause", - "helper": "Pause reminders until you turn them back on. History stays intact.", - "pausedToast": "Reminders paused", - "resumedToast": "Reminders resumed", - "failed": "Could not change the paused state." + "title": "Metti in pausa", + "helper": "Metti in pausa i promemoria finché non li riattivi. Lo storico resta intatto.", + "pausedToast": "Promemoria in pausa", + "resumedToast": "Promemoria ripresi", + "failed": "Impossibile modificare lo stato di pausa." }, "end": { - "title": "End medication", - "helper": "Stop reminders and mark the medication as ended. History stays visible.", - "button": "End", - "dialogTitle": "End {name} — stop reminders, history stays visible", - "dialogBody": "No more reminders. Old entries remain in the timeline.", - "toast": "Medication ended", - "failed": "Could not end the medication." + "title": "Termina il farmaco", + "helper": "Interrompe i promemoria e segna il farmaco come terminato. Lo storico resta visibile.", + "button": "Termina", + "dialogTitle": "Termina {name} — interrompe i promemoria, lo storico resta visibile", + "dialogBody": "Nessun altro promemoria. Le voci precedenti restano nella cronologia.", + "toast": "Farmaco terminato", + "failed": "Impossibile terminare il farmaco." }, "purge": { - "title": "Delete history", - "helper": "{count} intake events are stored for this medication.", - "button": "Delete history", - "dialogTitle": "Really delete the history?", - "dialogBody": "The {count} intake events will be removed permanently. The medication itself stays.", - "toast": "History deleted", - "failed": "Could not delete the history." + "title": "Elimina lo storico", + "helper": "Per questo farmaco sono memorizzate {count} assunzioni.", + "button": "Elimina lo storico", + "dialogTitle": "Eliminare davvero lo storico?", + "dialogBody": "Le {count} assunzioni verranno rimosse definitivamente. Il farmaco resta.", + "toast": "Storico eliminato", + "failed": "Impossibile eliminare lo storico." }, "delete": { - "title": "Delete medication", - "helper": "Removes the medication and every related record.", - "button": "Delete", - "dialogTitle": "Delete {name}?", - "dialogBody": "The medication, its schedules, intake history, API tokens, phase config and reminders will be removed permanently.", - "toast": "Medication deleted", - "failed": "Could not delete the medication." + "title": "Elimina il farmaco", + "helper": "Rimuove il farmaco e tutti i record correlati.", + "button": "Elimina", + "dialogTitle": "Eliminare {name}?", + "dialogBody": "Il farmaco, i suoi programmi, lo storico delle assunzioni, i token API, la configurazione delle fasi e i promemoria verranno rimossi definitivamente.", + "toast": "Farmaco eliminato", + "failed": "Impossibile eliminare il farmaco." } }, "phases": { - "modeLabel": "{phase} mode", - "saveFailed": "Could not save the reminder phases." + "modeLabel": "Modalità {phase}", + "saveFailed": "Impossibile salvare le fasi dei promemoria." }, "api": { - "caption": "Endpoint for \"{name}\"", - "copyUrl": "Copy URL", - "urlCopied": "URL copied", - "mintToken": "Mint token", - "mintAnotherToken": "Mint another token", - "mintFailed": "Could not mint a token.", - "copyFailed": "Could not copy.", - "tokenCopied": "Token copied", - "copyToken": "Copy token", - "mintedHint": "Copy this token now — it is shown only once." + "caption": "Endpoint per \"{name}\"", + "copyUrl": "Copia URL", + "urlCopied": "URL copiato", + "mintToken": "Genera token", + "mintAnotherToken": "Genera un altro token", + "mintFailed": "Impossibile generare un token.", + "copyFailed": "Impossibile copiare.", + "tokenCopied": "Token copiato", + "copyToken": "Copia token", + "mintedHint": "Copia questo token ora — viene mostrato una sola volta." }, "edit": { "planOption": "Modifica piano", diff --git a/messages/pl.json b/messages/pl.json index c68e1d16..8e1cb86f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -554,35 +554,35 @@ "scheduleDaily": "Codziennie", "inactive": "Wstrzymany", "intakeHistory": "Historia przyjęć", - "openDetailPage": "Open medication detail page", + "openDetailPage": "Otwórz stronę szczegółów leku", "deleteConfirm": "Usunąć lek?", "compliance": "Przestrzeganie zaleceń", - "importIntakes": "Import intakes", - "importDescription": "Paste a JSON array with intake data. Expected format:", - "importUploadFile": "Upload JSON file", - "importPaste": "Paste JSON here...", - "importSelected": "Selected: {name}", - "importFileLoaded": "File \"{name}\" loaded", - "importInvalidJson": "File does not contain valid JSON", - "importNoArray": "No array found", - "importResult": "{imported} intakes imported", - "importDuplicatesSkipped": "{count} duplicates skipped", - "importInvalidSkipped": "{count} invalid entries skipped", - "importFailed": "Import failed", - "importInvalidFormat": "Invalid JSON format", - "apiEndpointTitle": "API Endpoint: {name}", - "apiEndpointDescription": "Use this endpoint to record intakes via API (e.g., iPhone Shortcut or cron job).", - "apiEndpointActive": "API endpoint active", - "apiEndpointActivated": "API endpoint activated", - "apiEndpointDeactivated": "API endpoint deactivated", - "apiTokenCount": "Active ({count} tokens)", - "apiToken": "API Token", - "apiTokenActiveHint": "Endpoint is active. Existing tokens remain valid but cannot be shown in plain text again.", - "apiTokenActivateHint": "Activate the endpoint to create a new token.", - "apiTokenOnceHint": "The token is only shown in plain text once at creation.", - "requestExample": "Request example", - "statusLoadFailed": "Could not load status", - "changeFailed": "Change failed", + "importIntakes": "Importuj przyjęcia", + "importDescription": "Wklej tablicę JSON z danymi przyjęć. Oczekiwany format:", + "importUploadFile": "Prześlij plik JSON", + "importPaste": "Wklej tutaj JSON...", + "importSelected": "Wybrano: {name}", + "importFileLoaded": "Plik „{name}” wczytany", + "importInvalidJson": "Plik nie zawiera prawidłowego JSON", + "importNoArray": "Nie znaleziono tablicy", + "importResult": "{imported} przyjęć zaimportowano", + "importDuplicatesSkipped": "{count} duplikatów pominięto", + "importInvalidSkipped": "{count} nieprawidłowych wpisów pominięto", + "importFailed": "Import nie powiódł się", + "importInvalidFormat": "Nieprawidłowy format JSON", + "apiEndpointTitle": "Punkt końcowy API: {name}", + "apiEndpointDescription": "Użyj tego punktu końcowego do rejestrowania przyjęć przez API (np. Skrót iPhone lub zadanie cron).", + "apiEndpointActive": "Punkt końcowy API aktywny", + "apiEndpointActivated": "Punkt końcowy API aktywowany", + "apiEndpointDeactivated": "Punkt końcowy API dezaktywowany", + "apiTokenCount": "Aktywny ({count} tokenów)", + "apiToken": "Token API", + "apiTokenActiveHint": "Punkt końcowy jest aktywny. Istniejące tokeny pozostają ważne, ale nie można ich ponownie pokazać jako zwykły tekst.", + "apiTokenActivateHint": "Aktywuj punkt końcowy, aby utworzyć nowy token.", + "apiTokenOnceHint": "Token jest pokazywany jako zwykły tekst tylko raz, przy tworzeniu.", + "requestExample": "Przykład żądania", + "statusLoadFailed": "Nie udało się wczytać statusu", + "changeFailed": "Zmiana nie powiodła się", "categoryBloodPressure": "Ciśnienie krwi", "categoryVitamin": "Witaminy", "categoryThyroid": "Tarczyca", @@ -594,7 +594,7 @@ "categorySkin": "Pielęgnacja skóry", "categorySleepAid": "Na sen", "categoryDiabetes": "Diabetes", - "categoryAntibiotic": "Antibiotic", + "categoryAntibiotic": "Antybiotyk", "categoryOther": "Inne", "daysSun": "Nd", "daysMon": "Pn", @@ -738,141 +738,141 @@ } }, "cadence": { - "section": "Cadence", - "label": "How often", + "section": "Częstotliwość", + "label": "Jak często", "kind": { - "daily": "Every day", - "weekdays": "Certain days of the week", - "everyNWeeks": "Every N weeks on certain days", - "monthly": "Monthly on a specific day", - "everyNMonths": "Every N months on a specific day", - "yearly": "Once a year", - "rolling": "Every N days from when I last took it (flexible)", - "rollingExplainer": "Counts from your last logged intake — pauses if you skip a dose.", - "oneShot": "One-time dose" + "daily": "Codziennie", + "weekdays": "W określone dni tygodnia", + "everyNWeeks": "Co N tygodni w określone dni", + "monthly": "Co miesiąc w określonym dniu", + "everyNMonths": "Co N miesięcy w określonym dniu", + "yearly": "Raz w roku", + "rolling": "Co N dni od ostatniego przyjęcia (elastycznie)", + "rollingExplainer": "Liczy od ostatniego zarejestrowanego przyjęcia — wstrzymuje się, jeśli pominiesz dawkę.", + "oneShot": "Dawka jednorazowa" }, "weekdays": { - "label": "Days of the week", + "label": "Dni tygodnia", "short": { "mo": "Mo", - "tu": "Tu", - "we": "We", - "th": "Th", + "tu": "Wt", + "we": "Śr", + "th": "Cz", "fr": "Fr", "sa": "Sa", - "su": "Su" + "su": "Nd" }, "long": { - "mo": "Monday", - "tu": "Tuesday", - "we": "Wednesday", - "th": "Thursday", - "fr": "Friday", - "sa": "Saturday", - "su": "Sunday" + "mo": "Poniedziałek", + "tu": "Wtorek", + "we": "Środa", + "th": "Czwartek", + "fr": "Piątek", + "sa": "Sobota", + "su": "Niedziela" } }, "intervalWeeks": { - "suffix": "weeks" + "suffix": "tygodni" }, "dayOfMonth": { - "label": "Day" + "label": "Dzień" }, "intervalMonths": { - "suffix": "months", - "dayOnLabel": "on day" + "suffix": "miesięcy", + "dayOnLabel": "w dniu" }, "yearly": { "date": { - "label": "Date" + "label": "Data" } }, "rollingDays": { - "suffix": "days" + "suffix": "dni" } }, "timesOfDay": { - "section": "Times of day", - "label": "Times of day", + "section": "Pory dnia", + "label": "Pory dnia", "empty": { - "cta": "Add the first time." + "cta": "Dodaj pierwszą godzinę." }, - "add": "Add time", - "remove": "Remove", + "add": "Dodaj godzinę", + "remove": "Usuń", "presets": { - "morning": "Morning", - "noon": "Noon", - "evening": "Evening", - "night": "Night" + "morning": "Rano", + "noon": "Południe", + "evening": "Wieczór", + "night": "Noc" }, "max": { - "reached": "Maximum reached — remove a time before adding another." + "reached": "Osiągnięto maksimum — usuń jedną godzinę przed dodaniem kolejnej." } }, "courseWindow": { - "section": "Course window", + "section": "Okres leczenia", "startsOn": { - "label": "Starts on" + "label": "Rozpoczyna się" }, "endsOn": { - "label": "Ends on" + "label": "Kończy się" }, - "noEndDate": "No end date", - "oneShotCaption": "(one-time dose)", - "invalidRange": "End date must be on or after the start date." + "noEndDate": "Brak daty zakończenia", + "oneShotCaption": "(dawka jednorazowa)", + "invalidRange": "Data zakończenia musi być taka sama jak data rozpoczęcia lub późniejsza." } }, "wizard": { "header": { - "stepOf": "Step {current} of {total}", - "createTitle": "New medication", - "editTitle": "Edit {name}" + "stepOf": "Krok {current} z {total}", + "createTitle": "Nowy lek", + "editTitle": "Edytuj {name}" }, "nav": { - "back": "Back", - "next": "Next", - "save": "Save", - "saveEdit": "Save changes", + "back": "Wstecz", + "next": "Dalej", + "save": "Zapisz", + "saveEdit": "Zapisz zmiany", "nextHint": "Dalej: {step}", "reviewHint": "Dalej: Zapisz", "jumpFirst": "Przejdź do pierwszego kroku", "jumpLast": "Przejdź do podsumowania i zapisu" }, "nl": { - "button": "Describe" + "button": "Opisz" }, "steps": { "step1": { - "title": "What's the medication called?", - "subline": "Exactly as it appears on the box.", + "title": "Jak nazywa się lek?", + "subline": "Dokładnie tak, jak na opakowaniu.", "label": "Name", - "placeholder": "e.g. Ramipril", + "placeholder": "np. Ramipryl", "short": "Nazwa" }, "step2": { - "title": "What kind of medication is it?", - "subline": "We use this for the right templates and analytics.", - "short": "Typ" + "title": "Jaki to rodzaj leku?", + "subline": "Wykorzystujemy to do odpowiednich szablonów i analiz.", + "short": "Rodzaj" }, "step3": { - "title": "What's the dose?", - "subline": "A tablet, a puff, a drop — depending on the form.", - "amountLabel": "Amount", - "amountPlaceholder": "e.g. 5", - "unitLabel": "Unit", + "title": "Jaka jest dawka?", + "subline": "Tabletka, wziew, kropla — w zależności od formy.", + "amountLabel": "Ilość", + "amountPlaceholder": "np. 5", + "unitLabel": "Jednostka", "unit": { "mg": "mg", "ml": "ml", - "iu": "IU", + "iu": "j.m.", "mcg": "µg", "g": "g", - "tablets": "tablet(s)", - "capsules": "capsule(s)", - "drops": "drops", - "puffs": "puff", - "sprays": "spray", - "pieces": "pieces", - "other": "other" + "tablets": "tabletka/i", + "capsules": "kapsułka/i", + "drops": "krople", + "puffs": "wziew", + "sprays": "rozpylenie", + "pieces": "sztuki", + "other": "inne" }, "deliveryFormLabel": "Droga podania", "deliveryForm": { @@ -888,124 +888,124 @@ "short": "Dawka" }, "step4": { - "title": "Over what period are you taking it?", - "subline": "A start date is enough for today. You can add an end date later.", + "title": "Przez jaki okres go przyjmujesz?", + "subline": "Na dziś wystarczy data rozpoczęcia. Datę zakończenia możesz dodać później.", "short": "Kiedy" }, "step5": { - "title": "How often do you take it?", - "subline": "Pick the cadence — the details follow on the next step.", + "title": "Jak często go przyjmujesz?", + "subline": "Wybierz częstotliwość — szczegóły w następnym kroku.", "short": "Częstotliwość" }, "step6": { - "title": "What does that look like in detail?", - "subline": "Set weekdays, intervals, or the day of the month.", + "title": "Jak to wygląda szczegółowo?", + "subline": "Ustaw dni tygodnia, interwały lub dzień miesiąca.", "intervalWeeks": { - "label": "Every", - "suffix": "weeks" + "label": "Co", + "suffix": "tygodni" }, "dayOfMonth": { - "label": "On", - "suffix": "day of the month" + "label": "W dniu", + "suffix": "dzień miesiąca" }, "rollingDays": { - "label": "Every", - "suffix": "days from the last intake" + "label": "Co", + "suffix": "dni od ostatniego przyjęcia" }, "short": "Szczegóły" }, "step7": { - "title": "When do you take it?", - "subline": "One or more times of day — reminders follow this list.", - "presetsLabel": "Time-of-day presets", + "title": "Kiedy go przyjmujesz?", + "subline": "Jedna lub więcej pór dnia — przypomnienia opierają się na tej liście.", + "presetsLabel": "Ustawienia wstępne pory dnia", "presets": { - "morning": "Morning", - "noon": "Noon", - "evening": "Evening", - "night": "Night" + "morning": "Rano", + "noon": "Południe", + "evening": "Wieczór", + "night": "Noc" }, "short": "Godziny" }, "step8": { - "title": "All done — should HealthLog remind you?", - "subline": "Review everything below before you save.", - "remindersLabel": "Reminders on", - "remindersDescription": "Push, Telegram or ntfy — based on your settings.", - "multiScheduleNote": "This medication has multiple schedules. The detailed editor returns in a later release.", + "title": "Gotowe — czy HealthLog ma Ci przypominać?", + "subline": "Sprawdź wszystko poniżej przed zapisaniem.", + "remindersLabel": "Przypomnienia włączone", + "remindersDescription": "Push, Telegram lub ntfy — w zależności od ustawień.", + "multiScheduleNote": "Ten lek ma wiele harmonogramów. Szczegółowy edytor wróci w kolejnej wersji.", "short": "Gotowe" } }, "classRow": { - "bloodPressure": "Blood pressure", + "bloodPressure": "Ciśnienie krwi", "diabetes": "Diabetes", - "hormone": "Hormones", - "glp1": "GLP-1 injection", - "painRelief": "Pain", - "allergy": "Allergy", - "vitamin": "Vitamins", - "supplement": "Supplement", - "antibiotic": "Antibiotic", - "other": "Other" + "hormone": "Hormony", + "glp1": "Iniekcja GLP-1", + "painRelief": "Ból", + "allergy": "Alergia", + "vitamin": "Witaminy", + "supplement": "Suplement", + "antibiotic": "Antybiotyk", + "other": "Inne" }, "cadence": { "daily": { - "label": "Daily", - "description": "Every day at the same time." + "label": "Codziennie", + "description": "Codziennie o tej samej porze." }, "weekdays": { - "label": "Specific weekdays", - "description": "E.g. Monday, Wednesday, Friday." + "label": "Określone dni tygodnia", + "description": "Np. poniedziałek, środa, piątek." }, "everyNWeeks": { - "label": "Every few weeks", - "description": "E.g. every 2 weeks on Thursdays (GLP-1, biologics)." + "label": "Co kilka tygodni", + "description": "Np. co 2 tygodnie w czwartki (GLP-1, leki biologiczne)." }, "monthly": { - "label": "Monthly", - "description": "On a fixed day each month (e.g. always the 1st)." + "label": "Co miesiąc", + "description": "W stały dzień każdego miesiąca (np. zawsze 1.)." }, "rolling": { - "label": "Flexible from last dose", - "description": "Leave the next dose open. When you tap 'taken', the counter starts again from that moment." + "label": "Elastycznie od ostatniej dawki", + "description": "Pozostaw kolejną dawkę otwartą. Po naciśnięciu „przyjęto” licznik startuje od nowa od tego momentu." }, "oneShot": { - "label": "Single dose", - "description": "One administration, e.g. a vaccine or an antibiotic course." + "label": "Jednorazowo", + "description": "Jednorazowe podanie, np. szczepionka lub kuracja antybiotykowa." } }, "summary": { - "title": "Summary", + "title": "Podsumowanie", "cadence": { - "daily": "Every day", - "weekdays": "On selected days of the week", - "biweekly": "Every two weeks", - "monthly": "Monthly", - "quarterly": "Every three months", - "yearly": "Once a year", - "rolling": "Every {n} days from your last intake", - "oneShot": "A single dose", - "everyNWeeks": "Every {n} weeks", - "everyNMonths": "Every {n} months" + "daily": "Codziennie", + "weekdays": "W wybrane dni tygodnia", + "biweekly": "Co dwa tygodnie", + "monthly": "Co miesiąc", + "quarterly": "Co trzy miesiące", + "yearly": "Raz w roku", + "rolling": "Co {n} dni od ostatniego przyjęcia", + "oneShot": "Pojedyncza dawka", + "everyNWeeks": "Co {n} tygodni", + "everyNMonths": "Co {n} miesięcy" }, - "weekdaysDetail": ", on {days}", - "dayOfMonthDetail": ", on day {day}.", - "times": "at {times}", - "startsOn": "Starts: {date}", - "endsOn": "Ends: {date}", - "noEndDate": "No end date" + "weekdaysDetail": ", w {days}", + "dayOfMonthDetail": ", dnia {day}.", + "times": "o {times}", + "startsOn": "Początek: {date}", + "endsOn": "Koniec: {date}", + "noEndDate": "Brak daty zakończenia" }, "compose": { - "scheduleIndex": "Schedule {n} of {total}", + "scheduleIndex": "Harmonogram {n} z {total}", "list": { - "add": "Add another schedule", - "edit": "Edit", - "remove": "Remove", - "removeDisabled": "At least one schedule is required.", - "empty": "No schedule yet." + "add": "Dodaj kolejny harmonogram", + "edit": "Edytuj", + "remove": "Usuń", + "removeDisabled": "Wymagany jest co najmniej jeden harmonogram.", + "empty": "Brak harmonogramu." } }, "errors": { - "submitFailed": "Could not save the medication — please retry." + "submitFailed": "Nie udało się zapisać leku — spróbuj ponownie." } }, "doseStrength": { @@ -1160,88 +1160,88 @@ }, "detail": { "status": { - "active": "Active", - "paused": "Paused", - "ended": "Ended" + "active": "Aktywny", + "paused": "Wstrzymany", + "ended": "Zakończony" }, "today": { - "groupLabel": "Today's dose", - "taken": "Taken", - "skipped": "Skipped", - "toastTaken": "Today's dose logged", - "toastSkipped": "Today's dose skipped", - "recordedTaken": "Taken today at {time}", - "recordedSkipped": "Skipped today at {time}", - "recordedOneShot": "One-shot dose taken at {time}", - "noneScheduled": "No dose scheduled for today.", - "pausedHint": "Paused — no reminder today.", - "error": "Could not log the dose." + "groupLabel": "Dzisiejsza dawka", + "taken": "Przyjęto", + "skipped": "Pominięto", + "toastTaken": "Dzisiejsza dawka zapisana", + "toastSkipped": "Dzisiejsza dawka pominięta", + "recordedTaken": "Przyjęto dziś o {time}", + "recordedSkipped": "Pominięto dziś o {time}", + "recordedOneShot": "Jednorazową dawkę przyjęto o {time}", + "noneScheduled": "Na dziś nie zaplanowano żadnej dawki.", + "pausedHint": "Wstrzymano — dziś brak przypomnienia.", + "error": "Nie udało się zapisać dawki." }, "cadence": { - "oneShotOn": "One-time dose on {date}.", - "oneShotPending": "One-time dose pending." + "oneShotOn": "Dawka jednorazowa w dniu {date}.", + "oneShotPending": "Dawka jednorazowa oczekuje." }, "intake": { - "title": "Intake history", - "importButton": "Import", + "title": "Historia przyjęć", + "importButton": "Importuj", "rowActions": { - "openMenu": "Open row actions", - "edit": "Edit", - "delete": "Delete" + "openMenu": "Otwórz akcje wiersza", + "edit": "Edytuj", + "delete": "Usuń" }, "selection": { - "rowToggleLabel": "Select row" + "rowToggleLabel": "Zaznacz wiersz" }, "bulkDelete": { - "selectionCount": "{count} entries selected", - "deleteButton": "Delete selection", - "cancelButton": "Cancel", - "confirmTitle": "Delete the selected entries?", - "confirmBody": "{count} entries will be removed permanently. The medication itself stays.", - "confirmAction": "Delete", - "toast": "Selection deleted", - "failed": "Could not delete the selection." + "selectionCount": "{count} wpisów zaznaczono", + "deleteButton": "Usuń zaznaczone", + "cancelButton": "Anuluj", + "confirmTitle": "Usunąć zaznaczone wpisy?", + "confirmBody": "{count} wpisów zostanie trwale usuniętych. Sam lek pozostaje.", + "confirmAction": "Usuń", + "toast": "Zaznaczone usunięto", + "failed": "Nie udało się usunąć zaznaczonych." }, "edit": { - "dialogTitle": "Edit intake", - "takenAtLabel": "Taken at", - "skippedLabel": "Skipped", - "noteLabel": "Note", - "save": "Save", - "cancel": "Cancel", - "savedToast": "Intake updated", - "failed": "Could not update the intake." + "dialogTitle": "Edytuj przyjęcie", + "takenAtLabel": "Przyjęto o", + "skippedLabel": "Pominięto", + "noteLabel": "Notatka", + "save": "Zapisz", + "cancel": "Anuluj", + "savedToast": "Przyjęcie zaktualizowane", + "failed": "Nie udało się zaktualizować przyjęcia." }, "deleteRow": { - "confirmTitle": "Delete this intake?", - "confirmBody": "The entry is removed permanently.", - "confirmAction": "Delete", - "toast": "Intake deleted", - "failed": "Could not delete the intake." + "confirmTitle": "Usunąć to przyjęcie?", + "confirmBody": "Wpis zostanie trwale usunięty.", + "confirmAction": "Usuń", + "toast": "Przyjęcie usunięte", + "failed": "Nie udało się usunąć przyjęcia." } }, "notifications": { - "title": "Notifications", - "switchLabel": "Send a reminder when this dose is due", - "helperOn": "Reminders are on.", - "helperOff": "Reminders are off.", - "enabledToast": "Reminders on", - "disabledToast": "Reminders off", - "toggleFailed": "Could not change the reminder setting.", - "clientManagedChip": "Your iPhone manages reminders for this medication." + "title": "Przypomnienia", + "switchLabel": "Wyślij przypomnienie, gdy nadejdzie czas tej dawki", + "helperOn": "Przypomnienia są włączone.", + "helperOff": "Przypomnienia są wyłączone.", + "enabledToast": "Przypomnienia włączone", + "disabledToast": "Przypomnienia wyłączone", + "toggleFailed": "Nie udało się zmienić ustawienia przypomnień.", + "clientManagedChip": "Twój iPhone zarządza przypomnieniami dla tego leku." }, "settings": { - "title": "Settings", + "title": "Ustawienia", "phases": { - "openButton": "Configure phases", - "requiresCourseWindow": "Phases are available once a course window is set." + "openButton": "Skonfiguruj fazy", + "requiresCourseWindow": "Fazy są dostępne po ustawieniu okresu leczenia." }, "grace": { - "label": "Reminder window — applies to your primary schedule", - "primaryScheduleNote": "Multi-schedule medications keep their other schedules' default window.", - "unit": "minutes", - "saved": "Reminder window saved", - "failed": "Could not save the reminder window." + "label": "Okno przypomnienia — dotyczy głównego harmonogramu", + "primaryScheduleNote": "Leki z wieloma harmonogramami zachowują domyślne okno pozostałych harmonogramów.", + "unit": "minut", + "saved": "Okno przypomnienia zapisane", + "failed": "Nie udało się zapisać okna przypomnienia." }, "codes": { "label": "Kody kliniczne (ATC / RxNorm)", @@ -1253,57 +1253,57 @@ } }, "zone": { - "title": "Manage & danger zone", + "title": "Zarządzanie i strefa zagrożenia", "pause": { - "title": "Pause", - "helper": "Pause reminders until you turn them back on. History stays intact.", - "pausedToast": "Reminders paused", - "resumedToast": "Reminders resumed", - "failed": "Could not change the paused state." + "title": "Wstrzymaj", + "helper": "Wstrzymaj przypomnienia, aż włączysz je ponownie. Historia pozostaje nienaruszona.", + "pausedToast": "Przypomnienia wstrzymane", + "resumedToast": "Przypomnienia wznowione", + "failed": "Nie udało się zmienić stanu wstrzymania." }, "end": { - "title": "End medication", - "helper": "Stop reminders and mark the medication as ended. History stays visible.", - "button": "End", - "dialogTitle": "End {name} — stop reminders, history stays visible", - "dialogBody": "No more reminders. Old entries remain in the timeline.", - "toast": "Medication ended", - "failed": "Could not end the medication." + "title": "Zakończ lek", + "helper": "Zatrzymuje przypomnienia i oznacza lek jako zakończony. Historia pozostaje widoczna.", + "button": "Zakończ", + "dialogTitle": "Zakończ {name} — zatrzymuje przypomnienia, historia pozostaje widoczna", + "dialogBody": "Brak kolejnych przypomnień. Stare wpisy pozostają na osi czasu.", + "toast": "Lek zakończony", + "failed": "Nie udało się zakończyć leku." }, "purge": { - "title": "Delete history", - "helper": "{count} intake events are stored for this medication.", - "button": "Delete history", - "dialogTitle": "Really delete the history?", - "dialogBody": "The {count} intake events will be removed permanently. The medication itself stays.", - "toast": "History deleted", - "failed": "Could not delete the history." + "title": "Usuń historię", + "helper": "Dla tego leku zapisano {count} przyjęć.", + "button": "Usuń historię", + "dialogTitle": "Na pewno usunąć historię?", + "dialogBody": "{count} przyjęć zostanie trwale usuniętych. Sam lek pozostaje.", + "toast": "Historia usunięta", + "failed": "Nie udało się usunąć historii." }, "delete": { - "title": "Delete medication", - "helper": "Removes the medication and every related record.", - "button": "Delete", - "dialogTitle": "Delete {name}?", - "dialogBody": "The medication, its schedules, intake history, API tokens, phase config and reminders will be removed permanently.", - "toast": "Medication deleted", - "failed": "Could not delete the medication." + "title": "Usuń lek", + "helper": "Usuwa lek i wszystkie powiązane rekordy.", + "button": "Usuń", + "dialogTitle": "Usunąć {name}?", + "dialogBody": "Lek, jego harmonogramy, historia przyjęć, tokeny API, konfiguracja faz i przypomnienia zostaną trwale usunięte.", + "toast": "Lek usunięty", + "failed": "Nie udało się usunąć leku." } }, "phases": { - "modeLabel": "{phase} mode", - "saveFailed": "Could not save the reminder phases." + "modeLabel": "Tryb {phase}", + "saveFailed": "Nie udało się zapisać faz przypomnień." }, "api": { - "caption": "Endpoint for \"{name}\"", - "copyUrl": "Copy URL", - "urlCopied": "URL copied", - "mintToken": "Mint token", - "mintAnotherToken": "Mint another token", - "mintFailed": "Could not mint a token.", - "copyFailed": "Could not copy.", - "tokenCopied": "Token copied", - "copyToken": "Copy token", - "mintedHint": "Copy this token now — it is shown only once." + "caption": "Punkt końcowy dla „{name}”", + "copyUrl": "Kopiuj URL", + "urlCopied": "Skopiowano URL", + "mintToken": "Wygeneruj token", + "mintAnotherToken": "Wygeneruj kolejny token", + "mintFailed": "Nie udało się wygenerować tokena.", + "copyFailed": "Nie udało się skopiować.", + "tokenCopied": "Token skopiowany", + "copyToken": "Kopiuj token", + "mintedHint": "Skopiuj ten token teraz — jest pokazywany tylko raz." }, "edit": { "planOption": "Edytuj plan", From 0cb8070a225e2f99dae87700bdf169277bee4024 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 15:35:00 +0200 Subject: [PATCH 06/27] feat(i18n): translate Achievements copy for es/fr/it/pl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The achievements namespace — badge titles, descriptions, category labels, and dashboard card copy — rendered verbatim English outside German. Translate the surface for the four remaining locales, keeping the format-token strings and the Engagement category as-is. --- messages/es.json | 288 +++++++++++++++++++++++------------------------ messages/fr.json | 284 +++++++++++++++++++++++----------------------- messages/it.json | 288 +++++++++++++++++++++++------------------------ messages/pl.json | 288 +++++++++++++++++++++++------------------------ 4 files changed, 574 insertions(+), 574 deletions(-) diff --git a/messages/es.json b/messages/es.json index 36568a1f..ddba1ab5 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4108,284 +4108,284 @@ "carrierUnavailableGeoFallback": "{location} — carrier unavailable" }, "achievements": { - "title": "Achievements", - "subtitle": "Earn achievements by tracking your health and taking your meds on time.", - "loginRequired": "Please sign in to view achievements.", - "points": "Points", - "unlocked": "Unlocked", - "nextGoal": "Next goal", - "allCompleted": "All achievements unlocked!", - "completed": "Completed", - "goalReached": "Goal reached", - "remainingUnlocks": "{count} still unlockable", - "noneUnlockedYet": "No achievements unlocked yet.", - "pointsValue": "{points} points", + "title": "Logros", + "subtitle": "Consigue logros registrando tu salud y tomando tu medicación a tiempo.", + "loginRequired": "Inicia sesión para ver los logros.", + "points": "Puntos", + "unlocked": "Desbloqueado", + "nextGoal": "Próximo objetivo", + "allCompleted": "¡Todos los logros desbloqueados!", + "completed": "Completado", + "goalReached": "Objetivo alcanzado", + "remainingUnlocks": "Quedan {count} por desbloquear", + "noneUnlockedYet": "Aún no has desbloqueado ningún logro.", + "pointsValue": "{points} puntos", "metricCount": "{count}", - "metricDays": "{count} days", + "metricDays": "{count} días", "metricPercent": "{count}%", "badges": { "intakeTotal1": { - "title": "First intake", - "description": "Record your first taken medication event." + "title": "Primera toma", + "description": "Registra tu primera toma de medicación." }, "intakeTotal10": { - "title": "Intake starter", - "description": "Record 10 taken medication events." + "title": "Iniciado en las tomas", + "description": "Registra 10 tomas de medicación." }, "intakeTotal50": { - "title": "Intake routine", - "description": "Record 50 taken medication events." + "title": "Rutina de tomas", + "description": "Registra 50 tomas de medicación." }, "intakeTotal150": { - "title": "Intake expert", - "description": "Record 150 taken medication events." + "title": "Experto en tomas", + "description": "Registra 150 tomas de medicación." }, "intakeTotal300": { - "title": "Intake legend", - "description": "Record 300 taken medication events." + "title": "Leyenda de las tomas", + "description": "Registra 300 tomas de medicación." }, "overIntake1": { - "title": "Double take", - "description": "Take medication at least once more than planned." + "title": "Dosis doble", + "description": "Toma la medicación al menos una vez más de lo previsto." }, "skippedIntake1": { - "title": "Stepped back", - "description": "Skip at least one planned intake." + "title": "Un paso atrás", + "description": "Omite al menos una toma planificada." }, "passkeyCreated1": { - "title": "Passkey set up", - "description": "Create your first passkey." + "title": "Clave de acceso creada", + "description": "Crea tu primera clave de acceso." }, "passkeyLogin1": { - "title": "Passkey login", - "description": "Sign in at least once with a passkey." + "title": "Inicio con clave de acceso", + "description": "Inicia sesión al menos una vez con una clave de acceso." }, "passwordLogin1": { - "title": "Old school", - "description": "Sign in at least once with username and password." + "title": "A la antigua", + "description": "Inicia sesión al menos una vez con usuario y contraseña." }, "bugReport1": { - "title": "Bug hunter", - "description": "Submit a bug report." + "title": "Cazador de errores", + "description": "Envía un informe de error." }, "loginStreak7": { - "title": "Login streak 7d", - "description": "Sign in on 7 consecutive days." + "title": "Racha de inicios 7d", + "description": "Inicia sesión 7 días seguidos." }, "loginStreak30": { - "title": "Login streak 30d", - "description": "Sign in on 30 consecutive days." + "title": "Racha de inicios 30d", + "description": "Inicia sesión 30 días seguidos." }, "onTimePerfect1": { - "title": "On-time 1d", - "description": "Take all medications on time for 1 day." + "title": "Puntual 1d", + "description": "Toma todos los medicamentos a tiempo durante 1 día." }, "compliance801": { - "title": "80% adherence · 1d", - "description": "Reach at least 80% 30-day adherence for 1 day." + "title": "Adherencia 80% · 1d", + "description": "Alcanza al menos un 80% de adherencia de 30 días durante 1 día." }, "bmiGreen1": { - "title": "BMI green · 1d", - "description": "Keep your BMI in the normal range for 1 day." + "title": "IMC en verde · 1d", + "description": "Mantén tu IMC en el rango normal durante 1 día." }, "bpGreen1": { - "title": "BP green · 1d", - "description": "Keep your blood pressure in the normal range for 1 day." + "title": "Presión en verde · 1d", + "description": "Mantén tu presión arterial en el rango normal durante 1 día." }, "pulseGreen1": { - "title": "Pulse green · 1d", - "description": "Keep your resting pulse in the normal range for 1 day." + "title": "Pulso en verde · 1d", + "description": "Mantén tu pulso en reposo en el rango normal durante 1 día." }, "onTimePerfect7": { - "title": "On-time 7d", - "description": "Take all medications on time for 7 consecutive days." + "title": "Puntual 7d", + "description": "Toma todos los medicamentos a tiempo durante 7 días seguidos." }, "compliance807": { - "title": "80% adherence · 7d", - "description": "Reach at least 80% 30-day adherence for 7 consecutive days." + "title": "Adherencia 80% · 7d", + "description": "Alcanza al menos un 80% de adherencia de 30 días durante 7 días seguidos." }, "bmiGreen7": { - "title": "BMI green · 7d", - "description": "Keep your BMI in the normal range for 7 consecutive days." + "title": "IMC en verde · 7d", + "description": "Mantén tu IMC en el rango normal durante 7 días seguidos." }, "bpGreen7": { - "title": "BP green · 7d", - "description": "Keep your blood pressure in the normal range for 7 consecutive days." + "title": "Presión en verde · 7d", + "description": "Mantén tu presión arterial en el rango normal durante 7 días seguidos." }, "pulseGreen7": { - "title": "Pulse green · 7d", - "description": "Keep your resting pulse in the normal range for 7 consecutive days." + "title": "Pulso en verde · 7d", + "description": "Mantén tu pulso en reposo en el rango normal durante 7 días seguidos." }, "onTimePerfect30": { - "title": "On-time 30d", - "description": "Take all medications on time for 30 consecutive days." + "title": "Puntual 30d", + "description": "Toma todos los medicamentos a tiempo durante 30 días seguidos." }, "compliance8030": { - "title": "80% adherence · 30d", - "description": "Reach at least 80% 30-day adherence for 30 consecutive days." + "title": "Adherencia 80% · 30d", + "description": "Alcanza al menos un 80% de adherencia de 30 días durante 30 días seguidos." }, "bmiGreen30": { - "title": "BMI green · 30d", - "description": "Keep your BMI in the normal range for 30 consecutive days." + "title": "IMC en verde · 30d", + "description": "Mantén tu IMC en el rango normal durante 30 días seguidos." }, "bpGreen30": { - "title": "BP green · 30d", - "description": "Keep your blood pressure in the normal range for 30 consecutive days." + "title": "Presión en verde · 30d", + "description": "Mantén tu presión arterial en el rango normal durante 30 días seguidos." }, "pulseGreen30": { - "title": "Pulse green · 30d", - "description": "Keep your resting pulse in the normal range for 30 consecutive days." + "title": "Pulso en verde · 30d", + "description": "Mantén tu pulso en reposo en el rango normal durante 30 días seguidos." }, "onTimePerfect180": { - "title": "On-time 180d", - "description": "Take all medications on time for 180 consecutive days." + "title": "Puntual 180d", + "description": "Toma todos los medicamentos a tiempo durante 180 días seguidos." }, "compliance80180": { - "title": "80% adherence · 180d", - "description": "Reach at least 80% 30-day adherence for 180 consecutive days." + "title": "Adherencia 80% · 180d", + "description": "Alcanza al menos un 80% de adherencia de 30 días durante 180 días seguidos." }, "bmiGreen180": { - "title": "BMI green · 180d", - "description": "Keep your BMI in the normal range for 180 consecutive days." + "title": "IMC en verde · 180d", + "description": "Mantén tu IMC en el rango normal durante 180 días seguidos." }, "bpGreen180": { - "title": "BP green · 180d", - "description": "Keep your blood pressure in the normal range for 180 consecutive days." + "title": "Presión en verde · 180d", + "description": "Mantén tu presión arterial en el rango normal durante 180 días seguidos." }, "pulseGreen180": { - "title": "Pulse green · 180d", - "description": "Keep your resting pulse in the normal range for 180 consecutive days." + "title": "Pulso en verde · 180d", + "description": "Mantén tu pulso en reposo en el rango normal durante 180 días seguidos." }, "onTimePerfect360": { - "title": "On-time 360d", - "description": "Take all medications on time for 360 consecutive days." + "title": "Puntual 360d", + "description": "Toma todos los medicamentos a tiempo durante 360 días seguidos." }, "compliance80360": { - "title": "80% adherence · 360d", - "description": "Reach at least 80% 30-day adherence for 360 consecutive days." + "title": "Adherencia 80% · 360d", + "description": "Alcanza al menos un 80% de adherencia de 30 días durante 360 días seguidos." }, "bmiGreen360": { - "title": "BMI green · 360d", - "description": "Keep your BMI in the normal range for 360 consecutive days." + "title": "IMC en verde · 360d", + "description": "Mantén tu IMC en el rango normal durante 360 días seguidos." }, "bpGreen360": { - "title": "BP green · 360d", - "description": "Keep your blood pressure in the normal range for 360 consecutive days." + "title": "Presión en verde · 360d", + "description": "Mantén tu presión arterial en el rango normal durante 360 días seguidos." }, "pulseGreen360": { - "title": "Pulse green · 360d", - "description": "Keep your resting pulse in the normal range for 360 consecutive days." + "title": "Pulso en verde · 360d", + "description": "Mantén tu pulso en reposo en el rango normal durante 360 días seguidos." }, "moodFirst": { - "title": "First mood", - "description": "Log your first mood entry." + "title": "Primer estado de ánimo", + "description": "Registra tu primera entrada de estado de ánimo." }, "moodStreak7": { - "title": "Mood diarist 7d", - "description": "Log a mood entry on 7 consecutive days." + "title": "Diario de ánimo 7d", + "description": "Registra una entrada de estado de ánimo 7 días seguidos." }, "moodStreak30": { - "title": "Mood diarist 30d", - "description": "Log a mood entry on 30 consecutive days." + "title": "Diario de ánimo 30d", + "description": "Registra una entrada de estado de ánimo 30 días seguidos." }, "moodUp7": { - "title": "Brighter week", - "description": "Your 7-day mood average improved by at least 1.0 point compared to the previous week." + "title": "Semana más luminosa", + "description": "Tu media de ánimo de 7 días mejoró al menos 1,0 punto respecto a la semana anterior." }, "weightFirst": { - "title": "First weigh-in", - "description": "Record your first weight measurement." + "title": "Primer pesaje", + "description": "Registra tu primera medición de peso." }, "weight50": { - "title": "Fifty weigh-ins", - "description": "Record 50 weight measurements." + "title": "Cincuenta pesajes", + "description": "Registra 50 mediciones de peso." }, "weight200": { - "title": "200 weigh-ins", - "description": "Record 200 weight measurements." + "title": "200 pesajes", + "description": "Registra 200 mediciones de peso." }, "bpFirst": { - "title": "First reading", - "description": "Record your first blood-pressure measurement." + "title": "Primera medición", + "description": "Registra tu primera medición de presión arterial." }, "bp50": { - "title": "Fifty BP readings", - "description": "Record 50 blood-pressure measurements." + "title": "Cincuenta mediciones de presión", + "description": "Registra 50 mediciones de presión arterial." }, "bp200": { - "title": "200 BP readings", - "description": "Record 200 blood-pressure measurements." + "title": "200 mediciones de presión", + "description": "Registra 200 mediciones de presión arterial." }, "pulseFirst": { - "title": "First pulse", - "description": "Record your first pulse measurement." + "title": "Primer pulso", + "description": "Registra tu primera medición de pulso." }, "consistentMonth": { - "title": "Consistent month", - "description": "Log entries on at least 25 distinct days within a single calendar month." + "title": "Mes constante", + "description": "Registra entradas en al menos 25 días distintos dentro de un mismo mes natural." }, "entryStreak7": { - "title": "Tracker streak 7d", - "description": "Log at least one entry on 7 consecutive days." + "title": "Racha de registros 7d", + "description": "Registra al menos una entrada 7 días seguidos." }, "entryStreak30": { - "title": "Tracker streak 30d", - "description": "Log at least one entry on 30 consecutive days." + "title": "Racha de registros 30d", + "description": "Registra al menos una entrada 30 días seguidos." }, "weekendWarrior": { - "title": "Weekend tracker", - "description": "Log entries on 4 consecutive Saturday + Sunday weekend pairs." + "title": "Registrador de fin de semana", + "description": "Registra entradas en 4 fines de semana consecutivos (sábado y domingo)." }, "hiddenNightOwl": { - "title": "Night owl", - "description": "Logged an entry between 02:00 and 04:00 in the morning." + "title": "Búho nocturno", + "description": "Registraste una entrada entre las 02:00 y las 04:00 de la madrugada." }, "hiddenEarlyBird": { - "title": "Early bird", - "description": "Logged an entry between 04:00 and 06:00 in the morning." + "title": "Madrugador", + "description": "Registraste una entrada entre las 04:00 y las 06:00 de la mañana." }, "hiddenLeapDay": { - "title": "Leap-day legend", - "description": "Logged an entry on February 29." + "title": "Leyenda del año bisiesto", + "description": "Registraste una entrada el 29 de febrero." }, "hiddenDoctorPdf": { - "title": "House call", - "description": "Exported your first doctor-report PDF." + "title": "Visita a domicilio", + "description": "Exportaste tu primer informe médico en PDF." }, "hiddenLocaleFlip": { - "title": "Polyglot", - "description": "Switched the app language at least once." + "title": "Políglota", + "description": "Cambiaste el idioma de la app al menos una vez." }, "hiddenBugBuddy": { - "title": "Bug buddy", - "description": "Submitted at least 5 bug reports — the project loves you." + "title": "Colega de errores", + "description": "Enviaste al menos 5 informes de error — el proyecto te quiere." } }, - "nextProgressLabel": "Progress to unlock", - "completedOn": "Completed on {date}", - "locked": "Locked", + "nextProgressLabel": "Progreso para desbloquear", + "completedOn": "Completado el {date}", + "locked": "Bloqueado", "criterionHint": "{current} / {target}", "progressPercent": "{percent}%", "categories": { "medication": "Medicamento", - "vitals": "Vitals", + "vitals": "Constantes vitales", "mood": "Estado de ánimo", - "security": "Account & security", + "security": "Cuenta y seguridad", "engagement": "Engagement", - "hidden": "Hidden" + "hidden": "Oculto" }, "hiddenCard": { - "title": "Hidden achievement", - "description": "Keep tracking — you might just stumble across it.", - "ariaLabel": "Hidden locked achievement" + "title": "Logro oculto", + "description": "Sigue registrando — quizá te topes con él.", + "ariaLabel": "Logro oculto bloqueado" }, "hiddenUnlockToast": { - "title": "You unlocked a hidden achievement!" + "title": "¡Has desbloqueado un logro oculto!" }, "dashboardCard": { - "title": "Recent unlocks", - "viewAll": "View all", - "empty": "No achievements yet — keep logging to unlock your first." + "title": "Desbloqueos recientes", + "viewAll": "Ver todo", + "empty": "Aún no hay logros — sigue registrando para desbloquear el primero." } }, "bugreport": { diff --git a/messages/fr.json b/messages/fr.json index 5d63ec44..f7aa2124 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4108,284 +4108,284 @@ "carrierUnavailableGeoFallback": "{location} — carrier unavailable" }, "achievements": { - "title": "Achievements", - "subtitle": "Earn achievements by tracking your health and taking your meds on time.", - "loginRequired": "Please sign in to view achievements.", + "title": "Réussites", + "subtitle": "Débloquez des réussites en suivant votre santé et en prenant vos médicaments à temps.", + "loginRequired": "Connectez-vous pour voir les réussites.", "points": "Points", - "unlocked": "Unlocked", - "nextGoal": "Next goal", - "allCompleted": "All achievements unlocked!", - "completed": "Completed", - "goalReached": "Goal reached", - "remainingUnlocks": "{count} still unlockable", - "noneUnlockedYet": "No achievements unlocked yet.", + "unlocked": "Débloqué", + "nextGoal": "Prochain objectif", + "allCompleted": "Toutes les réussites débloquées !", + "completed": "Terminé", + "goalReached": "Objectif atteint", + "remainingUnlocks": "{count} encore à débloquer", + "noneUnlockedYet": "Aucune réussite débloquée pour l’instant.", "pointsValue": "{points} points", "metricCount": "{count}", - "metricDays": "{count} days", + "metricDays": "{count} jours", "metricPercent": "{count}%", "badges": { "intakeTotal1": { - "title": "First intake", - "description": "Record your first taken medication event." + "title": "Première prise", + "description": "Enregistrez votre première prise de médicament." }, "intakeTotal10": { - "title": "Intake starter", - "description": "Record 10 taken medication events." + "title": "Débutant des prises", + "description": "Enregistrez 10 prises de médicaments." }, "intakeTotal50": { - "title": "Intake routine", - "description": "Record 50 taken medication events." + "title": "Routine des prises", + "description": "Enregistrez 50 prises de médicaments." }, "intakeTotal150": { - "title": "Intake expert", - "description": "Record 150 taken medication events." + "title": "Expert des prises", + "description": "Enregistrez 150 prises de médicaments." }, "intakeTotal300": { - "title": "Intake legend", - "description": "Record 300 taken medication events." + "title": "Légende des prises", + "description": "Enregistrez 300 prises de médicaments." }, "overIntake1": { - "title": "Double take", - "description": "Take medication at least once more than planned." + "title": "Double dose", + "description": "Prenez votre médicament au moins une fois de plus que prévu." }, "skippedIntake1": { - "title": "Stepped back", - "description": "Skip at least one planned intake." + "title": "Pause prise", + "description": "Sautez au moins une prise prévue." }, "passkeyCreated1": { - "title": "Passkey set up", - "description": "Create your first passkey." + "title": "Clé d’accès configurée", + "description": "Créez votre première clé d’accès." }, "passkeyLogin1": { - "title": "Passkey login", - "description": "Sign in at least once with a passkey." + "title": "Connexion par clé d’accès", + "description": "Connectez-vous au moins une fois avec une clé d’accès." }, "passwordLogin1": { - "title": "Old school", - "description": "Sign in at least once with username and password." + "title": "À l’ancienne", + "description": "Connectez-vous au moins une fois avec un identifiant et un mot de passe." }, "bugReport1": { - "title": "Bug hunter", - "description": "Submit a bug report." + "title": "Chasseur de bugs", + "description": "Soumettez un rapport de bug." }, "loginStreak7": { - "title": "Login streak 7d", - "description": "Sign in on 7 consecutive days." + "title": "Série de connexions 7j", + "description": "Connectez-vous 7 jours d’affilée." }, "loginStreak30": { - "title": "Login streak 30d", - "description": "Sign in on 30 consecutive days." + "title": "Série de connexions 30j", + "description": "Connectez-vous 30 jours d’affilée." }, "onTimePerfect1": { - "title": "On-time 1d", - "description": "Take all medications on time for 1 day." + "title": "À l’heure 1j", + "description": "Prenez tous vos médicaments à l’heure pendant 1 jour." }, "compliance801": { - "title": "80% adherence · 1d", - "description": "Reach at least 80% 30-day adherence for 1 day." + "title": "Observance 80% · 1j", + "description": "Atteignez au moins 80% d’observance sur 30 jours pendant 1 jour." }, "bmiGreen1": { - "title": "BMI green · 1d", - "description": "Keep your BMI in the normal range for 1 day." + "title": "IMC au vert · 1j", + "description": "Maintenez votre IMC dans la plage normale pendant 1 jour." }, "bpGreen1": { - "title": "BP green · 1d", - "description": "Keep your blood pressure in the normal range for 1 day." + "title": "Tension au vert · 1j", + "description": "Maintenez votre tension dans la plage normale pendant 1 jour." }, "pulseGreen1": { - "title": "Pulse green · 1d", - "description": "Keep your resting pulse in the normal range for 1 day." + "title": "Pouls au vert · 1j", + "description": "Maintenez votre pouls au repos dans la plage normale pendant 1 jour." }, "onTimePerfect7": { - "title": "On-time 7d", - "description": "Take all medications on time for 7 consecutive days." + "title": "À l’heure 7j", + "description": "Prenez tous vos médicaments à l’heure pendant 7 jours d’affilée." }, "compliance807": { - "title": "80% adherence · 7d", - "description": "Reach at least 80% 30-day adherence for 7 consecutive days." + "title": "Observance 80% · 7j", + "description": "Atteignez au moins 80% d’observance sur 30 jours pendant 7 jours d’affilée." }, "bmiGreen7": { - "title": "BMI green · 7d", - "description": "Keep your BMI in the normal range for 7 consecutive days." + "title": "IMC au vert · 7j", + "description": "Maintenez votre IMC dans la plage normale pendant 7 jours d’affilée." }, "bpGreen7": { - "title": "BP green · 7d", - "description": "Keep your blood pressure in the normal range for 7 consecutive days." + "title": "Tension au vert · 7j", + "description": "Maintenez votre tension dans la plage normale pendant 7 jours d’affilée." }, "pulseGreen7": { - "title": "Pulse green · 7d", - "description": "Keep your resting pulse in the normal range for 7 consecutive days." + "title": "Pouls au vert · 7j", + "description": "Maintenez votre pouls au repos dans la plage normale pendant 7 jours d’affilée." }, "onTimePerfect30": { - "title": "On-time 30d", - "description": "Take all medications on time for 30 consecutive days." + "title": "À l’heure 30j", + "description": "Prenez tous vos médicaments à l’heure pendant 30 jours d’affilée." }, "compliance8030": { - "title": "80% adherence · 30d", - "description": "Reach at least 80% 30-day adherence for 30 consecutive days." + "title": "Observance 80% · 30j", + "description": "Atteignez au moins 80% d’observance sur 30 jours pendant 30 jours d’affilée." }, "bmiGreen30": { - "title": "BMI green · 30d", - "description": "Keep your BMI in the normal range for 30 consecutive days." + "title": "IMC au vert · 30j", + "description": "Maintenez votre IMC dans la plage normale pendant 30 jours d’affilée." }, "bpGreen30": { - "title": "BP green · 30d", - "description": "Keep your blood pressure in the normal range for 30 consecutive days." + "title": "Tension au vert · 30j", + "description": "Maintenez votre tension dans la plage normale pendant 30 jours d’affilée." }, "pulseGreen30": { - "title": "Pulse green · 30d", - "description": "Keep your resting pulse in the normal range for 30 consecutive days." + "title": "Pouls au vert · 30j", + "description": "Maintenez votre pouls au repos dans la plage normale pendant 30 jours d’affilée." }, "onTimePerfect180": { - "title": "On-time 180d", - "description": "Take all medications on time for 180 consecutive days." + "title": "À l’heure 180j", + "description": "Prenez tous vos médicaments à l’heure pendant 180 jours d’affilée." }, "compliance80180": { - "title": "80% adherence · 180d", - "description": "Reach at least 80% 30-day adherence for 180 consecutive days." + "title": "Observance 80% · 180j", + "description": "Atteignez au moins 80% d’observance sur 30 jours pendant 180 jours d’affilée." }, "bmiGreen180": { - "title": "BMI green · 180d", - "description": "Keep your BMI in the normal range for 180 consecutive days." + "title": "IMC au vert · 180j", + "description": "Maintenez votre IMC dans la plage normale pendant 180 jours d’affilée." }, "bpGreen180": { - "title": "BP green · 180d", - "description": "Keep your blood pressure in the normal range for 180 consecutive days." + "title": "Tension au vert · 180j", + "description": "Maintenez votre tension dans la plage normale pendant 180 jours d’affilée." }, "pulseGreen180": { - "title": "Pulse green · 180d", - "description": "Keep your resting pulse in the normal range for 180 consecutive days." + "title": "Pouls au vert · 180j", + "description": "Maintenez votre pouls au repos dans la plage normale pendant 180 jours d’affilée." }, "onTimePerfect360": { - "title": "On-time 360d", - "description": "Take all medications on time for 360 consecutive days." + "title": "À l’heure 360j", + "description": "Prenez tous vos médicaments à l’heure pendant 360 jours d’affilée." }, "compliance80360": { - "title": "80% adherence · 360d", - "description": "Reach at least 80% 30-day adherence for 360 consecutive days." + "title": "Observance 80% · 360j", + "description": "Atteignez au moins 80% d’observance sur 30 jours pendant 360 jours d’affilée." }, "bmiGreen360": { - "title": "BMI green · 360d", - "description": "Keep your BMI in the normal range for 360 consecutive days." + "title": "IMC au vert · 360j", + "description": "Maintenez votre IMC dans la plage normale pendant 360 jours d’affilée." }, "bpGreen360": { - "title": "BP green · 360d", - "description": "Keep your blood pressure in the normal range for 360 consecutive days." + "title": "Tension au vert · 360j", + "description": "Maintenez votre tension dans la plage normale pendant 360 jours d’affilée." }, "pulseGreen360": { - "title": "Pulse green · 360d", - "description": "Keep your resting pulse in the normal range for 360 consecutive days." + "title": "Pouls au vert · 360j", + "description": "Maintenez votre pouls au repos dans la plage normale pendant 360 jours d’affilée." }, "moodFirst": { - "title": "First mood", - "description": "Log your first mood entry." + "title": "Première humeur", + "description": "Enregistrez votre première entrée d’humeur." }, "moodStreak7": { - "title": "Mood diarist 7d", - "description": "Log a mood entry on 7 consecutive days." + "title": "Journal d’humeur 7j", + "description": "Enregistrez une humeur 7 jours d’affilée." }, "moodStreak30": { - "title": "Mood diarist 30d", - "description": "Log a mood entry on 30 consecutive days." + "title": "Journal d’humeur 30j", + "description": "Enregistrez une humeur 30 jours d’affilée." }, "moodUp7": { - "title": "Brighter week", - "description": "Your 7-day mood average improved by at least 1.0 point compared to the previous week." + "title": "Semaine plus lumineuse", + "description": "Votre moyenne d’humeur sur 7 jours s’est améliorée d’au moins 1,0 point par rapport à la semaine précédente." }, "weightFirst": { - "title": "First weigh-in", - "description": "Record your first weight measurement." + "title": "Première pesée", + "description": "Enregistrez votre première mesure de poids." }, "weight50": { - "title": "Fifty weigh-ins", - "description": "Record 50 weight measurements." + "title": "Cinquante pesées", + "description": "Enregistrez 50 mesures de poids." }, "weight200": { - "title": "200 weigh-ins", - "description": "Record 200 weight measurements." + "title": "200 pesées", + "description": "Enregistrez 200 mesures de poids." }, "bpFirst": { - "title": "First reading", - "description": "Record your first blood-pressure measurement." + "title": "Première mesure", + "description": "Enregistrez votre première mesure de tension." }, "bp50": { - "title": "Fifty BP readings", - "description": "Record 50 blood-pressure measurements." + "title": "Cinquante mesures de tension", + "description": "Enregistrez 50 mesures de tension." }, "bp200": { - "title": "200 BP readings", - "description": "Record 200 blood-pressure measurements." + "title": "200 mesures de tension", + "description": "Enregistrez 200 mesures de tension." }, "pulseFirst": { - "title": "First pulse", - "description": "Record your first pulse measurement." + "title": "Premier pouls", + "description": "Enregistrez votre première mesure de pouls." }, "consistentMonth": { - "title": "Consistent month", - "description": "Log entries on at least 25 distinct days within a single calendar month." + "title": "Mois régulier", + "description": "Enregistrez des entrées sur au moins 25 jours différents au cours d’un même mois calendaire." }, "entryStreak7": { - "title": "Tracker streak 7d", - "description": "Log at least one entry on 7 consecutive days." + "title": "Série de suivis 7j", + "description": "Enregistrez au moins une entrée 7 jours d’affilée." }, "entryStreak30": { - "title": "Tracker streak 30d", - "description": "Log at least one entry on 30 consecutive days." + "title": "Série de suivis 30j", + "description": "Enregistrez au moins une entrée 30 jours d’affilée." }, "weekendWarrior": { - "title": "Weekend tracker", - "description": "Log entries on 4 consecutive Saturday + Sunday weekend pairs." + "title": "Suivi du week-end", + "description": "Enregistrez des entrées sur 4 week-ends consécutifs (samedi + dimanche)." }, "hiddenNightOwl": { - "title": "Night owl", - "description": "Logged an entry between 02:00 and 04:00 in the morning." + "title": "Oiseau de nuit", + "description": "Vous avez enregistré une entrée entre 02h00 et 04h00 du matin." }, "hiddenEarlyBird": { - "title": "Early bird", - "description": "Logged an entry between 04:00 and 06:00 in the morning." + "title": "Lève-tôt", + "description": "Vous avez enregistré une entrée entre 04h00 et 06h00 du matin." }, "hiddenLeapDay": { - "title": "Leap-day legend", - "description": "Logged an entry on February 29." + "title": "Légende du jour bissextile", + "description": "Vous avez enregistré une entrée le 29 février." }, "hiddenDoctorPdf": { - "title": "House call", - "description": "Exported your first doctor-report PDF." + "title": "Visite à domicile", + "description": "Vous avez exporté votre premier rapport médical en PDF." }, "hiddenLocaleFlip": { - "title": "Polyglot", - "description": "Switched the app language at least once." + "title": "Polyglotte", + "description": "Vous avez changé la langue de l’application au moins une fois." }, "hiddenBugBuddy": { - "title": "Bug buddy", - "description": "Submitted at least 5 bug reports — the project loves you." + "title": "Compagnon des bugs", + "description": "Vous avez soumis au moins 5 rapports de bug — le projet vous adore." } }, - "nextProgressLabel": "Progress to unlock", - "completedOn": "Completed on {date}", - "locked": "Locked", + "nextProgressLabel": "Progression avant déblocage", + "completedOn": "Terminé le {date}", + "locked": "Verrouillé", "criterionHint": "{current} / {target}", "progressPercent": "{percent}%", "categories": { "medication": "Médicament", - "vitals": "Vitals", + "vitals": "Constantes vitales", "mood": "Humeur", - "security": "Account & security", + "security": "Compte et sécurité", "engagement": "Engagement", - "hidden": "Hidden" + "hidden": "Caché" }, "hiddenCard": { - "title": "Hidden achievement", - "description": "Keep tracking — you might just stumble across it.", - "ariaLabel": "Hidden locked achievement" + "title": "Réussite cachée", + "description": "Continuez à suivre — vous pourriez bien tomber dessus.", + "ariaLabel": "Réussite cachée verrouillée" }, "hiddenUnlockToast": { - "title": "You unlocked a hidden achievement!" + "title": "Vous avez débloqué une réussite cachée !" }, "dashboardCard": { - "title": "Recent unlocks", - "viewAll": "View all", - "empty": "No achievements yet — keep logging to unlock your first." + "title": "Débloqués récemment", + "viewAll": "Tout voir", + "empty": "Pas encore de réussites — continuez à suivre pour débloquer la première." } }, "bugreport": { diff --git a/messages/it.json b/messages/it.json index c303acfc..32d387f2 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4108,284 +4108,284 @@ "carrierUnavailableGeoFallback": "{location} — carrier unavailable" }, "achievements": { - "title": "Achievements", - "subtitle": "Earn achievements by tracking your health and taking your meds on time.", - "loginRequired": "Please sign in to view achievements.", - "points": "Points", - "unlocked": "Unlocked", - "nextGoal": "Next goal", - "allCompleted": "All achievements unlocked!", - "completed": "Completed", - "goalReached": "Goal reached", - "remainingUnlocks": "{count} still unlockable", - "noneUnlockedYet": "No achievements unlocked yet.", - "pointsValue": "{points} points", + "title": "Obiettivi", + "subtitle": "Ottieni obiettivi monitorando la tua salute e assumendo i farmaci in orario.", + "loginRequired": "Accedi per vedere gli obiettivi.", + "points": "Punti", + "unlocked": "Sbloccato", + "nextGoal": "Prossimo obiettivo", + "allCompleted": "Tutti gli obiettivi sbloccati!", + "completed": "Completato", + "goalReached": "Obiettivo raggiunto", + "remainingUnlocks": "Ancora {count} da sbloccare", + "noneUnlockedYet": "Nessun obiettivo ancora sbloccato.", + "pointsValue": "{points} punti", "metricCount": "{count}", - "metricDays": "{count} days", + "metricDays": "{count} giorni", "metricPercent": "{count}%", "badges": { "intakeTotal1": { - "title": "First intake", - "description": "Record your first taken medication event." + "title": "Prima assunzione", + "description": "Registra la tua prima assunzione di farmaco." }, "intakeTotal10": { - "title": "Intake starter", - "description": "Record 10 taken medication events." + "title": "Principiante delle assunzioni", + "description": "Registra 10 assunzioni di farmaci." }, "intakeTotal50": { - "title": "Intake routine", - "description": "Record 50 taken medication events." + "title": "Routine delle assunzioni", + "description": "Registra 50 assunzioni di farmaci." }, "intakeTotal150": { - "title": "Intake expert", - "description": "Record 150 taken medication events." + "title": "Esperto delle assunzioni", + "description": "Registra 150 assunzioni di farmaci." }, "intakeTotal300": { - "title": "Intake legend", - "description": "Record 300 taken medication events." + "title": "Leggenda delle assunzioni", + "description": "Registra 300 assunzioni di farmaci." }, "overIntake1": { - "title": "Double take", - "description": "Take medication at least once more than planned." + "title": "Doppia dose", + "description": "Assumi il farmaco almeno una volta in più del previsto." }, "skippedIntake1": { - "title": "Stepped back", - "description": "Skip at least one planned intake." + "title": "Pausa presa", + "description": "Salta almeno un’assunzione pianificata." }, "passkeyCreated1": { - "title": "Passkey set up", - "description": "Create your first passkey." + "title": "Passkey configurata", + "description": "Crea la tua prima passkey." }, "passkeyLogin1": { - "title": "Passkey login", - "description": "Sign in at least once with a passkey." + "title": "Accesso con passkey", + "description": "Accedi almeno una volta con una passkey." }, "passwordLogin1": { - "title": "Old school", - "description": "Sign in at least once with username and password." + "title": "Vecchia scuola", + "description": "Accedi almeno una volta con nome utente e password." }, "bugReport1": { - "title": "Bug hunter", - "description": "Submit a bug report." + "title": "Cacciatore di bug", + "description": "Invia una segnalazione di bug." }, "loginStreak7": { - "title": "Login streak 7d", - "description": "Sign in on 7 consecutive days." + "title": "Serie di accessi 7g", + "description": "Accedi per 7 giorni consecutivi." }, "loginStreak30": { - "title": "Login streak 30d", - "description": "Sign in on 30 consecutive days." + "title": "Serie di accessi 30g", + "description": "Accedi per 30 giorni consecutivi." }, "onTimePerfect1": { - "title": "On-time 1d", - "description": "Take all medications on time for 1 day." + "title": "Puntuale 1g", + "description": "Assumi tutti i farmaci in orario per 1 giorno." }, "compliance801": { - "title": "80% adherence · 1d", - "description": "Reach at least 80% 30-day adherence for 1 day." + "title": "Aderenza 80% · 1g", + "description": "Raggiungi almeno l’80% di aderenza su 30 giorni per 1 giorno." }, "bmiGreen1": { - "title": "BMI green · 1d", - "description": "Keep your BMI in the normal range for 1 day." + "title": "IMC nel verde · 1g", + "description": "Mantieni l’IMC nell’intervallo normale per 1 giorno." }, "bpGreen1": { - "title": "BP green · 1d", - "description": "Keep your blood pressure in the normal range for 1 day." + "title": "Pressione nel verde · 1g", + "description": "Mantieni la pressione nell’intervallo normale per 1 giorno." }, "pulseGreen1": { - "title": "Pulse green · 1d", - "description": "Keep your resting pulse in the normal range for 1 day." + "title": "Polso nel verde · 1g", + "description": "Mantieni il polso a riposo nell’intervallo normale per 1 giorno." }, "onTimePerfect7": { - "title": "On-time 7d", - "description": "Take all medications on time for 7 consecutive days." + "title": "Puntuale 7g", + "description": "Assumi tutti i farmaci in orario per 7 giorni consecutivi." }, "compliance807": { - "title": "80% adherence · 7d", - "description": "Reach at least 80% 30-day adherence for 7 consecutive days." + "title": "Aderenza 80% · 7g", + "description": "Raggiungi almeno l’80% di aderenza su 30 giorni per 7 giorni consecutivi." }, "bmiGreen7": { - "title": "BMI green · 7d", - "description": "Keep your BMI in the normal range for 7 consecutive days." + "title": "IMC nel verde · 7g", + "description": "Mantieni l’IMC nell’intervallo normale per 7 giorni consecutivi." }, "bpGreen7": { - "title": "BP green · 7d", - "description": "Keep your blood pressure in the normal range for 7 consecutive days." + "title": "Pressione nel verde · 7g", + "description": "Mantieni la pressione nell’intervallo normale per 7 giorni consecutivi." }, "pulseGreen7": { - "title": "Pulse green · 7d", - "description": "Keep your resting pulse in the normal range for 7 consecutive days." + "title": "Polso nel verde · 7g", + "description": "Mantieni il polso a riposo nell’intervallo normale per 7 giorni consecutivi." }, "onTimePerfect30": { - "title": "On-time 30d", - "description": "Take all medications on time for 30 consecutive days." + "title": "Puntuale 30g", + "description": "Assumi tutti i farmaci in orario per 30 giorni consecutivi." }, "compliance8030": { - "title": "80% adherence · 30d", - "description": "Reach at least 80% 30-day adherence for 30 consecutive days." + "title": "Aderenza 80% · 30g", + "description": "Raggiungi almeno l’80% di aderenza su 30 giorni per 30 giorni consecutivi." }, "bmiGreen30": { - "title": "BMI green · 30d", - "description": "Keep your BMI in the normal range for 30 consecutive days." + "title": "IMC nel verde · 30g", + "description": "Mantieni l’IMC nell’intervallo normale per 30 giorni consecutivi." }, "bpGreen30": { - "title": "BP green · 30d", - "description": "Keep your blood pressure in the normal range for 30 consecutive days." + "title": "Pressione nel verde · 30g", + "description": "Mantieni la pressione nell’intervallo normale per 30 giorni consecutivi." }, "pulseGreen30": { - "title": "Pulse green · 30d", - "description": "Keep your resting pulse in the normal range for 30 consecutive days." + "title": "Polso nel verde · 30g", + "description": "Mantieni il polso a riposo nell’intervallo normale per 30 giorni consecutivi." }, "onTimePerfect180": { - "title": "On-time 180d", - "description": "Take all medications on time for 180 consecutive days." + "title": "Puntuale 180g", + "description": "Assumi tutti i farmaci in orario per 180 giorni consecutivi." }, "compliance80180": { - "title": "80% adherence · 180d", - "description": "Reach at least 80% 30-day adherence for 180 consecutive days." + "title": "Aderenza 80% · 180g", + "description": "Raggiungi almeno l’80% di aderenza su 30 giorni per 180 giorni consecutivi." }, "bmiGreen180": { - "title": "BMI green · 180d", - "description": "Keep your BMI in the normal range for 180 consecutive days." + "title": "IMC nel verde · 180g", + "description": "Mantieni l’IMC nell’intervallo normale per 180 giorni consecutivi." }, "bpGreen180": { - "title": "BP green · 180d", - "description": "Keep your blood pressure in the normal range for 180 consecutive days." + "title": "Pressione nel verde · 180g", + "description": "Mantieni la pressione nell’intervallo normale per 180 giorni consecutivi." }, "pulseGreen180": { - "title": "Pulse green · 180d", - "description": "Keep your resting pulse in the normal range for 180 consecutive days." + "title": "Polso nel verde · 180g", + "description": "Mantieni il polso a riposo nell’intervallo normale per 180 giorni consecutivi." }, "onTimePerfect360": { - "title": "On-time 360d", - "description": "Take all medications on time for 360 consecutive days." + "title": "Puntuale 360g", + "description": "Assumi tutti i farmaci in orario per 360 giorni consecutivi." }, "compliance80360": { - "title": "80% adherence · 360d", - "description": "Reach at least 80% 30-day adherence for 360 consecutive days." + "title": "Aderenza 80% · 360g", + "description": "Raggiungi almeno l’80% di aderenza su 30 giorni per 360 giorni consecutivi." }, "bmiGreen360": { - "title": "BMI green · 360d", - "description": "Keep your BMI in the normal range for 360 consecutive days." + "title": "IMC nel verde · 360g", + "description": "Mantieni l’IMC nell’intervallo normale per 360 giorni consecutivi." }, "bpGreen360": { - "title": "BP green · 360d", - "description": "Keep your blood pressure in the normal range for 360 consecutive days." + "title": "Pressione nel verde · 360g", + "description": "Mantieni la pressione nell’intervallo normale per 360 giorni consecutivi." }, "pulseGreen360": { - "title": "Pulse green · 360d", - "description": "Keep your resting pulse in the normal range for 360 consecutive days." + "title": "Polso nel verde · 360g", + "description": "Mantieni il polso a riposo nell’intervallo normale per 360 giorni consecutivi." }, "moodFirst": { - "title": "First mood", - "description": "Log your first mood entry." + "title": "Primo umore", + "description": "Registra la tua prima voce di umore." }, "moodStreak7": { - "title": "Mood diarist 7d", - "description": "Log a mood entry on 7 consecutive days." + "title": "Diario dell’umore 7g", + "description": "Registra una voce di umore per 7 giorni consecutivi." }, "moodStreak30": { - "title": "Mood diarist 30d", - "description": "Log a mood entry on 30 consecutive days." + "title": "Diario dell’umore 30g", + "description": "Registra una voce di umore per 30 giorni consecutivi." }, "moodUp7": { - "title": "Brighter week", - "description": "Your 7-day mood average improved by at least 1.0 point compared to the previous week." + "title": "Settimana più serena", + "description": "La tua media dell’umore di 7 giorni è migliorata di almeno 1,0 punto rispetto alla settimana precedente." }, "weightFirst": { - "title": "First weigh-in", - "description": "Record your first weight measurement." + "title": "Prima pesata", + "description": "Registra la tua prima misurazione del peso." }, "weight50": { - "title": "Fifty weigh-ins", - "description": "Record 50 weight measurements." + "title": "Cinquanta pesate", + "description": "Registra 50 misurazioni del peso." }, "weight200": { - "title": "200 weigh-ins", - "description": "Record 200 weight measurements." + "title": "200 pesate", + "description": "Registra 200 misurazioni del peso." }, "bpFirst": { - "title": "First reading", - "description": "Record your first blood-pressure measurement." + "title": "Prima misurazione", + "description": "Registra la tua prima misurazione della pressione." }, "bp50": { - "title": "Fifty BP readings", - "description": "Record 50 blood-pressure measurements." + "title": "Cinquanta misurazioni della pressione", + "description": "Registra 50 misurazioni della pressione." }, "bp200": { - "title": "200 BP readings", - "description": "Record 200 blood-pressure measurements." + "title": "200 misurazioni della pressione", + "description": "Registra 200 misurazioni della pressione." }, "pulseFirst": { - "title": "First pulse", - "description": "Record your first pulse measurement." + "title": "Primo polso", + "description": "Registra la tua prima misurazione del polso." }, "consistentMonth": { - "title": "Consistent month", - "description": "Log entries on at least 25 distinct days within a single calendar month." + "title": "Mese costante", + "description": "Registra voci in almeno 25 giorni diversi nello stesso mese di calendario." }, "entryStreak7": { - "title": "Tracker streak 7d", - "description": "Log at least one entry on 7 consecutive days." + "title": "Serie di registrazioni 7g", + "description": "Registra almeno una voce per 7 giorni consecutivi." }, "entryStreak30": { - "title": "Tracker streak 30d", - "description": "Log at least one entry on 30 consecutive days." + "title": "Serie di registrazioni 30g", + "description": "Registra almeno una voce per 30 giorni consecutivi." }, "weekendWarrior": { - "title": "Weekend tracker", - "description": "Log entries on 4 consecutive Saturday + Sunday weekend pairs." + "title": "Tracker del weekend", + "description": "Registra voci in 4 fine settimana consecutivi (sabato + domenica)." }, "hiddenNightOwl": { - "title": "Night owl", - "description": "Logged an entry between 02:00 and 04:00 in the morning." + "title": "Nottambulo", + "description": "Hai registrato una voce tra le 02:00 e le 04:00 del mattino." }, "hiddenEarlyBird": { - "title": "Early bird", - "description": "Logged an entry between 04:00 and 06:00 in the morning." + "title": "Mattiniero", + "description": "Hai registrato una voce tra le 04:00 e le 06:00 del mattino." }, "hiddenLeapDay": { - "title": "Leap-day legend", - "description": "Logged an entry on February 29." + "title": "Leggenda dell’anno bisestile", + "description": "Hai registrato una voce il 29 febbraio." }, "hiddenDoctorPdf": { - "title": "House call", - "description": "Exported your first doctor-report PDF." + "title": "Visita a domicilio", + "description": "Hai esportato il tuo primo referto medico in PDF." }, "hiddenLocaleFlip": { - "title": "Polyglot", - "description": "Switched the app language at least once." + "title": "Poliglotta", + "description": "Hai cambiato la lingua dell’app almeno una volta." }, "hiddenBugBuddy": { - "title": "Bug buddy", - "description": "Submitted at least 5 bug reports — the project loves you." + "title": "Amico dei bug", + "description": "Hai inviato almeno 5 segnalazioni di bug — il progetto ti ama." } }, - "nextProgressLabel": "Progress to unlock", - "completedOn": "Completed on {date}", - "locked": "Locked", + "nextProgressLabel": "Progressi verso lo sblocco", + "completedOn": "Completato il {date}", + "locked": "Bloccato", "criterionHint": "{current} / {target}", "progressPercent": "{percent}%", "categories": { "medication": "Farmaco", - "vitals": "Vitals", + "vitals": "Parametri vitali", "mood": "Umore", - "security": "Account & security", + "security": "Account e sicurezza", "engagement": "Engagement", - "hidden": "Hidden" + "hidden": "Nascosto" }, "hiddenCard": { - "title": "Hidden achievement", - "description": "Keep tracking — you might just stumble across it.", - "ariaLabel": "Hidden locked achievement" + "title": "Obiettivo nascosto", + "description": "Continua a monitorare — potresti imbatterti in esso.", + "ariaLabel": "Obiettivo nascosto bloccato" }, "hiddenUnlockToast": { - "title": "You unlocked a hidden achievement!" + "title": "Hai sbloccato un obiettivo nascosto!" }, "dashboardCard": { - "title": "Recent unlocks", - "viewAll": "View all", - "empty": "No achievements yet — keep logging to unlock your first." + "title": "Sbloccati di recente", + "viewAll": "Vedi tutti", + "empty": "Ancora nessun obiettivo — continua a registrare per sbloccare il primo." } }, "bugreport": { diff --git a/messages/pl.json b/messages/pl.json index 8e1cb86f..fc8995f3 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4108,284 +4108,284 @@ "carrierUnavailableGeoFallback": "{location} — carrier unavailable" }, "achievements": { - "title": "Achievements", - "subtitle": "Earn achievements by tracking your health and taking your meds on time.", - "loginRequired": "Please sign in to view achievements.", - "points": "Points", - "unlocked": "Unlocked", - "nextGoal": "Next goal", - "allCompleted": "All achievements unlocked!", - "completed": "Completed", - "goalReached": "Goal reached", - "remainingUnlocks": "{count} still unlockable", - "noneUnlockedYet": "No achievements unlocked yet.", - "pointsValue": "{points} points", + "title": "Osiągnięcia", + "subtitle": "Zdobywaj osiągnięcia, śledząc swoje zdrowie i przyjmując leki na czas.", + "loginRequired": "Zaloguj się, aby zobaczyć osiągnięcia.", + "points": "Punkty", + "unlocked": "Odblokowano", + "nextGoal": "Następny cel", + "allCompleted": "Wszystkie osiągnięcia odblokowane!", + "completed": "Ukończono", + "goalReached": "Cel osiągnięty", + "remainingUnlocks": "Jeszcze {count} do odblokowania", + "noneUnlockedYet": "Nie odblokowano jeszcze żadnego osiągnięcia.", + "pointsValue": "{points} punktów", "metricCount": "{count}", - "metricDays": "{count} days", + "metricDays": "{count} dni", "metricPercent": "{count}%", "badges": { "intakeTotal1": { - "title": "First intake", - "description": "Record your first taken medication event." + "title": "Pierwsze przyjęcie", + "description": "Zarejestruj swoje pierwsze przyjęcie leku." }, "intakeTotal10": { - "title": "Intake starter", - "description": "Record 10 taken medication events." + "title": "Początkujący w przyjęciach", + "description": "Zarejestruj 10 przyjęć leków." }, "intakeTotal50": { - "title": "Intake routine", - "description": "Record 50 taken medication events." + "title": "Rutyna przyjęć", + "description": "Zarejestruj 50 przyjęć leków." }, "intakeTotal150": { - "title": "Intake expert", - "description": "Record 150 taken medication events." + "title": "Ekspert przyjęć", + "description": "Zarejestruj 150 przyjęć leków." }, "intakeTotal300": { - "title": "Intake legend", - "description": "Record 300 taken medication events." + "title": "Legenda przyjęć", + "description": "Zarejestruj 300 przyjęć leków." }, "overIntake1": { - "title": "Double take", - "description": "Take medication at least once more than planned." + "title": "Podwójna dawka", + "description": "Przyjmij lek co najmniej raz więcej niż zaplanowano." }, "skippedIntake1": { - "title": "Stepped back", - "description": "Skip at least one planned intake." + "title": "Krok wstecz", + "description": "Pomiń co najmniej jedno zaplanowane przyjęcie." }, "passkeyCreated1": { - "title": "Passkey set up", - "description": "Create your first passkey." + "title": "Klucz dostępu ustawiony", + "description": "Utwórz swój pierwszy klucz dostępu." }, "passkeyLogin1": { - "title": "Passkey login", - "description": "Sign in at least once with a passkey." + "title": "Logowanie kluczem dostępu", + "description": "Zaloguj się co najmniej raz kluczem dostępu." }, "passwordLogin1": { - "title": "Old school", - "description": "Sign in at least once with username and password." + "title": "Stara szkoła", + "description": "Zaloguj się co najmniej raz nazwą użytkownika i hasłem." }, "bugReport1": { - "title": "Bug hunter", - "description": "Submit a bug report." + "title": "Łowca błędów", + "description": "Wyślij zgłoszenie błędu." }, "loginStreak7": { - "title": "Login streak 7d", - "description": "Sign in on 7 consecutive days." + "title": "Seria logowań 7d", + "description": "Zaloguj się przez 7 kolejnych dni." }, "loginStreak30": { - "title": "Login streak 30d", - "description": "Sign in on 30 consecutive days." + "title": "Seria logowań 30d", + "description": "Zaloguj się przez 30 kolejnych dni." }, "onTimePerfect1": { - "title": "On-time 1d", - "description": "Take all medications on time for 1 day." + "title": "Na czas 1d", + "description": "Przyjmij wszystkie leki na czas przez 1 dzień." }, "compliance801": { - "title": "80% adherence · 1d", - "description": "Reach at least 80% 30-day adherence for 1 day." + "title": "Przestrzeganie 80% · 1d", + "description": "Osiągnij co najmniej 80% przestrzegania w 30 dni przez 1 dzień." }, "bmiGreen1": { - "title": "BMI green · 1d", - "description": "Keep your BMI in the normal range for 1 day." + "title": "BMI na zielono · 1d", + "description": "Utrzymaj BMI w normie przez 1 dzień." }, "bpGreen1": { - "title": "BP green · 1d", - "description": "Keep your blood pressure in the normal range for 1 day." + "title": "Ciśnienie na zielono · 1d", + "description": "Utrzymaj ciśnienie w normie przez 1 dzień." }, "pulseGreen1": { - "title": "Pulse green · 1d", - "description": "Keep your resting pulse in the normal range for 1 day." + "title": "Tętno na zielono · 1d", + "description": "Utrzymaj tętno spoczynkowe w normie przez 1 dzień." }, "onTimePerfect7": { - "title": "On-time 7d", - "description": "Take all medications on time for 7 consecutive days." + "title": "Na czas 7d", + "description": "Przyjmij wszystkie leki na czas przez 7 kolejnych dni." }, "compliance807": { - "title": "80% adherence · 7d", - "description": "Reach at least 80% 30-day adherence for 7 consecutive days." + "title": "Przestrzeganie 80% · 7d", + "description": "Osiągnij co najmniej 80% przestrzegania w 30 dni przez 7 kolejnych dni." }, "bmiGreen7": { - "title": "BMI green · 7d", - "description": "Keep your BMI in the normal range for 7 consecutive days." + "title": "BMI na zielono · 7d", + "description": "Utrzymaj BMI w normie przez 7 kolejnych dni." }, "bpGreen7": { - "title": "BP green · 7d", - "description": "Keep your blood pressure in the normal range for 7 consecutive days." + "title": "Ciśnienie na zielono · 7d", + "description": "Utrzymaj ciśnienie w normie przez 7 kolejnych dni." }, "pulseGreen7": { - "title": "Pulse green · 7d", - "description": "Keep your resting pulse in the normal range for 7 consecutive days." + "title": "Tętno na zielono · 7d", + "description": "Utrzymaj tętno spoczynkowe w normie przez 7 kolejnych dni." }, "onTimePerfect30": { - "title": "On-time 30d", - "description": "Take all medications on time for 30 consecutive days." + "title": "Na czas 30d", + "description": "Przyjmij wszystkie leki na czas przez 30 kolejnych dni." }, "compliance8030": { - "title": "80% adherence · 30d", - "description": "Reach at least 80% 30-day adherence for 30 consecutive days." + "title": "Przestrzeganie 80% · 30d", + "description": "Osiągnij co najmniej 80% przestrzegania w 30 dni przez 30 kolejnych dni." }, "bmiGreen30": { - "title": "BMI green · 30d", - "description": "Keep your BMI in the normal range for 30 consecutive days." + "title": "BMI na zielono · 30d", + "description": "Utrzymaj BMI w normie przez 30 kolejnych dni." }, "bpGreen30": { - "title": "BP green · 30d", - "description": "Keep your blood pressure in the normal range for 30 consecutive days." + "title": "Ciśnienie na zielono · 30d", + "description": "Utrzymaj ciśnienie w normie przez 30 kolejnych dni." }, "pulseGreen30": { - "title": "Pulse green · 30d", - "description": "Keep your resting pulse in the normal range for 30 consecutive days." + "title": "Tętno na zielono · 30d", + "description": "Utrzymaj tętno spoczynkowe w normie przez 30 kolejnych dni." }, "onTimePerfect180": { - "title": "On-time 180d", - "description": "Take all medications on time for 180 consecutive days." + "title": "Na czas 180d", + "description": "Przyjmij wszystkie leki na czas przez 180 kolejnych dni." }, "compliance80180": { - "title": "80% adherence · 180d", - "description": "Reach at least 80% 30-day adherence for 180 consecutive days." + "title": "Przestrzeganie 80% · 180d", + "description": "Osiągnij co najmniej 80% przestrzegania w 30 dni przez 180 kolejnych dni." }, "bmiGreen180": { - "title": "BMI green · 180d", - "description": "Keep your BMI in the normal range for 180 consecutive days." + "title": "BMI na zielono · 180d", + "description": "Utrzymaj BMI w normie przez 180 kolejnych dni." }, "bpGreen180": { - "title": "BP green · 180d", - "description": "Keep your blood pressure in the normal range for 180 consecutive days." + "title": "Ciśnienie na zielono · 180d", + "description": "Utrzymaj ciśnienie w normie przez 180 kolejnych dni." }, "pulseGreen180": { - "title": "Pulse green · 180d", - "description": "Keep your resting pulse in the normal range for 180 consecutive days." + "title": "Tętno na zielono · 180d", + "description": "Utrzymaj tętno spoczynkowe w normie przez 180 kolejnych dni." }, "onTimePerfect360": { - "title": "On-time 360d", - "description": "Take all medications on time for 360 consecutive days." + "title": "Na czas 360d", + "description": "Przyjmij wszystkie leki na czas przez 360 kolejnych dni." }, "compliance80360": { - "title": "80% adherence · 360d", - "description": "Reach at least 80% 30-day adherence for 360 consecutive days." + "title": "Przestrzeganie 80% · 360d", + "description": "Osiągnij co najmniej 80% przestrzegania w 30 dni przez 360 kolejnych dni." }, "bmiGreen360": { - "title": "BMI green · 360d", - "description": "Keep your BMI in the normal range for 360 consecutive days." + "title": "BMI na zielono · 360d", + "description": "Utrzymaj BMI w normie przez 360 kolejnych dni." }, "bpGreen360": { - "title": "BP green · 360d", - "description": "Keep your blood pressure in the normal range for 360 consecutive days." + "title": "Ciśnienie na zielono · 360d", + "description": "Utrzymaj ciśnienie w normie przez 360 kolejnych dni." }, "pulseGreen360": { - "title": "Pulse green · 360d", - "description": "Keep your resting pulse in the normal range for 360 consecutive days." + "title": "Tętno na zielono · 360d", + "description": "Utrzymaj tętno spoczynkowe w normie przez 360 kolejnych dni." }, "moodFirst": { - "title": "First mood", - "description": "Log your first mood entry." + "title": "Pierwszy nastrój", + "description": "Zapisz swój pierwszy wpis nastroju." }, "moodStreak7": { - "title": "Mood diarist 7d", - "description": "Log a mood entry on 7 consecutive days." + "title": "Dziennik nastroju 7d", + "description": "Zapisuj nastrój przez 7 kolejnych dni." }, "moodStreak30": { - "title": "Mood diarist 30d", - "description": "Log a mood entry on 30 consecutive days." + "title": "Dziennik nastroju 30d", + "description": "Zapisuj nastrój przez 30 kolejnych dni." }, "moodUp7": { - "title": "Brighter week", - "description": "Your 7-day mood average improved by at least 1.0 point compared to the previous week." + "title": "Pogodniejszy tydzień", + "description": "Twoja 7-dniowa średnia nastroju poprawiła się o co najmniej 1,0 punkt względem poprzedniego tygodnia." }, "weightFirst": { - "title": "First weigh-in", - "description": "Record your first weight measurement." + "title": "Pierwsze ważenie", + "description": "Zapisz swój pierwszy pomiar wagi." }, "weight50": { - "title": "Fifty weigh-ins", - "description": "Record 50 weight measurements." + "title": "Pięćdziesiąt ważeń", + "description": "Zapisz 50 pomiarów wagi." }, "weight200": { - "title": "200 weigh-ins", - "description": "Record 200 weight measurements." + "title": "200 ważeń", + "description": "Zapisz 200 pomiarów wagi." }, "bpFirst": { - "title": "First reading", - "description": "Record your first blood-pressure measurement." + "title": "Pierwszy pomiar", + "description": "Zapisz swój pierwszy pomiar ciśnienia." }, "bp50": { - "title": "Fifty BP readings", - "description": "Record 50 blood-pressure measurements." + "title": "Pięćdziesiąt pomiarów ciśnienia", + "description": "Zapisz 50 pomiarów ciśnienia." }, "bp200": { - "title": "200 BP readings", - "description": "Record 200 blood-pressure measurements." + "title": "200 pomiarów ciśnienia", + "description": "Zapisz 200 pomiarów ciśnienia." }, "pulseFirst": { - "title": "First pulse", - "description": "Record your first pulse measurement." + "title": "Pierwsze tętno", + "description": "Zapisz swój pierwszy pomiar tętna." }, "consistentMonth": { - "title": "Consistent month", - "description": "Log entries on at least 25 distinct days within a single calendar month." + "title": "Konsekwentny miesiąc", + "description": "Zapisz wpisy w co najmniej 25 różnych dniach w jednym miesiącu kalendarzowym." }, "entryStreak7": { - "title": "Tracker streak 7d", - "description": "Log at least one entry on 7 consecutive days." + "title": "Seria wpisów 7d", + "description": "Zapisuj co najmniej jeden wpis przez 7 kolejnych dni." }, "entryStreak30": { - "title": "Tracker streak 30d", - "description": "Log at least one entry on 30 consecutive days." + "title": "Seria wpisów 30d", + "description": "Zapisuj co najmniej jeden wpis przez 30 kolejnych dni." }, "weekendWarrior": { - "title": "Weekend tracker", - "description": "Log entries on 4 consecutive Saturday + Sunday weekend pairs." + "title": "Weekendowy rejestrator", + "description": "Zapisuj wpisy w 4 kolejnych weekendach (sobota + niedziela)." }, "hiddenNightOwl": { - "title": "Night owl", - "description": "Logged an entry between 02:00 and 04:00 in the morning." + "title": "Nocny marek", + "description": "Zapisałeś wpis między 02:00 a 04:00 nad ranem." }, "hiddenEarlyBird": { - "title": "Early bird", - "description": "Logged an entry between 04:00 and 06:00 in the morning." + "title": "Ranny ptaszek", + "description": "Zapisałeś wpis między 04:00 a 06:00 rano." }, "hiddenLeapDay": { - "title": "Leap-day legend", - "description": "Logged an entry on February 29." + "title": "Legenda dnia przestępnego", + "description": "Zapisałeś wpis 29 lutego." }, "hiddenDoctorPdf": { - "title": "House call", - "description": "Exported your first doctor-report PDF." + "title": "Wizyta domowa", + "description": "Wyeksportowałeś swój pierwszy raport dla lekarza w PDF." }, "hiddenLocaleFlip": { - "title": "Polyglot", - "description": "Switched the app language at least once." + "title": "Poliglota", + "description": "Zmieniłeś język aplikacji co najmniej raz." }, "hiddenBugBuddy": { - "title": "Bug buddy", - "description": "Submitted at least 5 bug reports — the project loves you." + "title": "Towarzysz błędów", + "description": "Wysłałeś co najmniej 5 zgłoszeń błędów — projekt Cię kocha." } }, - "nextProgressLabel": "Progress to unlock", - "completedOn": "Completed on {date}", - "locked": "Locked", + "nextProgressLabel": "Postęp do odblokowania", + "completedOn": "Ukończono {date}", + "locked": "Zablokowane", "criterionHint": "{current} / {target}", "progressPercent": "{percent}%", "categories": { "medication": "Lek", - "vitals": "Vitals", + "vitals": "Parametry życiowe", "mood": "Nastrój", - "security": "Account & security", + "security": "Konto i bezpieczeństwo", "engagement": "Engagement", - "hidden": "Hidden" + "hidden": "Ukryte" }, "hiddenCard": { - "title": "Hidden achievement", - "description": "Keep tracking — you might just stumble across it.", - "ariaLabel": "Hidden locked achievement" + "title": "Ukryte osiągnięcie", + "description": "Śledź dalej — może na nie natrafisz.", + "ariaLabel": "Ukryte zablokowane osiągnięcie" }, "hiddenUnlockToast": { - "title": "You unlocked a hidden achievement!" + "title": "Odblokowałeś ukryte osiągnięcie!" }, "dashboardCard": { - "title": "Recent unlocks", - "viewAll": "View all", - "empty": "No achievements yet — keep logging to unlock your first." + "title": "Ostatnio odblokowane", + "viewAll": "Zobacz wszystkie", + "empty": "Brak osiągnięć — zapisuj dalej, aby odblokować pierwsze." } }, "bugreport": { From 8434af40cb16a4a731ca70ee06d8858bd459af9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 15:21:12 +0200 Subject: [PATCH 07/27] fix(medications): surface failed dose logging and add an undo affordance A failed take/skip POST cleared the spinner without any feedback, so the user believed the dose was logged when it was not. Show an error toast on a non-ok response or a thrown request, and never show the success confirmation in that case. On a successful take/skip, attach an Undo action to the toast that soft-deletes the just-recorded intake via the per-event delete route, so a misclick no longer needs a trip through the intake history to correct. --- messages/de.json | 4 + messages/en.json | 4 + messages/es.json | 4 + messages/fr.json | 4 + messages/it.json | 4 + messages/pl.json | 4 + .../medications/medication-card.tsx | 85 ++++++++++++++----- 7 files changed, 88 insertions(+), 21 deletions(-) diff --git a/messages/de.json b/messages/de.json index fbd36098..e753b8a6 100644 --- a/messages/de.json +++ b/messages/de.json @@ -622,6 +622,10 @@ "skipped": "Übersprungen", "intakeToastTaken": "{name} als genommen erfasst", "intakeToastSkipped": "{name} übersprungen", + "intakeToastFailed": "{name} konnte nicht erfasst werden – bitte erneut versuchen", + "intakeUndo": "Rückgängig", + "intakeUndone": "Eintrag zurückgenommen", + "intakeUndoFailed": "Rückgängig fehlgeschlagen – im Verlauf korrigieren", "weekdaySunday": "Sonntag", "weekdayMonday": "Montag", "weekdayTuesday": "Dienstag", diff --git a/messages/en.json b/messages/en.json index f82aea72..02f68a71 100644 --- a/messages/en.json +++ b/messages/en.json @@ -622,6 +622,10 @@ "skipped": "Skipped", "intakeToastTaken": "{name} recorded as taken", "intakeToastSkipped": "{name} skipped", + "intakeToastFailed": "Could not record {name} — try again", + "intakeUndo": "Undo", + "intakeUndone": "Entry reverted", + "intakeUndoFailed": "Could not undo — open the history to fix it", "weekdaySunday": "Sunday", "weekdayMonday": "Monday", "weekdayTuesday": "Tuesday", diff --git a/messages/es.json b/messages/es.json index ddba1ab5..d42255e5 100644 --- a/messages/es.json +++ b/messages/es.json @@ -622,6 +622,10 @@ "skipped": "Omitida", "intakeToastTaken": "{name} registrada como tomada", "intakeToastSkipped": "{name} omitida", + "intakeToastFailed": "No se pudo registrar {name}; inténtalo de nuevo", + "intakeUndo": "Deshacer", + "intakeUndone": "Entrada revertida", + "intakeUndoFailed": "No se pudo deshacer; corrígelo en el historial", "weekdaySunday": "Domingo", "weekdayMonday": "Lunes", "weekdayTuesday": "Martes", diff --git a/messages/fr.json b/messages/fr.json index f7aa2124..a8834183 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -622,6 +622,10 @@ "skipped": "Sauté", "intakeToastTaken": "{name} enregistré comme pris", "intakeToastSkipped": "{name} sauté", + "intakeToastFailed": "Impossible d'enregistrer {name} — réessayez", + "intakeUndo": "Annuler", + "intakeUndone": "Entrée annulée", + "intakeUndoFailed": "Annulation impossible — corrigez dans l'historique", "weekdaySunday": "Dimanche", "weekdayMonday": "Lundi", "weekdayTuesday": "Mardi", diff --git a/messages/it.json b/messages/it.json index 32d387f2..536e0b18 100644 --- a/messages/it.json +++ b/messages/it.json @@ -622,6 +622,10 @@ "skipped": "Saltata", "intakeToastTaken": "{name} registrata come assunta", "intakeToastSkipped": "{name} saltata", + "intakeToastFailed": "Impossibile registrare {name}; riprova", + "intakeUndo": "Annulla", + "intakeUndone": "Voce annullata", + "intakeUndoFailed": "Impossibile annullare; correggi nella cronologia", "weekdaySunday": "Domenica", "weekdayMonday": "Lunedì", "weekdayTuesday": "Martedì", diff --git a/messages/pl.json b/messages/pl.json index fc8995f3..f5b13eb5 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -622,6 +622,10 @@ "skipped": "Pominięte", "intakeToastTaken": "{name} oznaczone jako przyjęte", "intakeToastSkipped": "{name} pominięte", + "intakeToastFailed": "Nie udało się zapisać {name} — spróbuj ponownie", + "intakeUndo": "Cofnij", + "intakeUndone": "Wpis cofnięty", + "intakeUndoFailed": "Nie udało się cofnąć — popraw w historii", "weekdaySunday": "Niedziela", "weekdayMonday": "Poniedziałek", "weekdayTuesday": "Wtorek", diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index a3f66f18..b3bbdb8b 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -168,6 +168,26 @@ export function MedicationCard({ return () => clearInterval(interval); }, []); + // v1.11.3 C2 — reverse the just-recorded intake via the soft-delete + // route. Surfaced from the success toast's Undo action so a misclicked + // take / skip no longer needs a history dive to correct. + async function undoIntake(eventId: string) { + try { + const res = await fetch( + `/api/medications/${medication.id}/intake/${eventId}`, + { method: "DELETE" }, + ); + if (!res.ok) { + toast.error(t("medications.intakeUndoFailed")); + return; + } + await invalidateKeys(queryClient, medicationDependentKeys); + toast.success(t("medications.intakeUndone")); + } catch { + toast.error(t("medications.intakeUndoFailed")); + } + } + async function recordIntake(skipped: boolean) { const key = skipped ? "skip" : "take"; setIntakeLoading(key); @@ -177,29 +197,52 @@ export function MedicationCard({ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ skipped }), }); - if (res.ok) { - toast.success( - t( - skipped - ? "medications.intakeToastSkipped" - : "medications.intakeToastTaken", - { name: medication.name }, - ), + // v1.11.3 C1 — a failed POST used to clear the spinner silently, so + // the user believed the dose was logged when it was not. Surface the + // failure and never show the success confirmation in that case. + if (!res.ok) { + toast.error( + t("medications.intakeToastFailed", { name: medication.name }), ); - await invalidateKeys(queryClient, medicationDependentKeys); - // v1.8.5 — after a TAKEN dose on a tracking-enabled injection, - // prompt (skippably) for the site. The dialog PATCHes it onto - // the just-created event via the status-toggle route. - if (!skipped && tracksInjection) { - try { - const json = await res.json(); - const eventId = json?.data?.id as string | undefined; - if (eventId) setSiteIntakeId(eventId); - } catch { - /* dose recorded; the site prompt is best-effort */ - } - } + return; + } + // The POST returns the created event (`apiSuccess(event, 201)`); its + // id drives both the Undo affordance and the optional injection-site + // prompt below. + let eventId: string | undefined; + try { + const json = await res.json(); + eventId = json?.data?.id as string | undefined; + } catch { + /* dose recorded; the body is best-effort for the id */ + } + toast.success( + t( + skipped + ? "medications.intakeToastSkipped" + : "medications.intakeToastTaken", + { name: medication.name }, + ), + eventId + ? { + action: { + label: t("medications.intakeUndo"), + onClick: () => void undoIntake(eventId), + }, + } + : undefined, + ); + await invalidateKeys(queryClient, medicationDependentKeys); + // v1.8.5 — after a TAKEN dose on a tracking-enabled injection, + // prompt (skippably) for the site. The dialog PATCHes it onto + // the just-created event via the status-toggle route. + if (!skipped && tracksInjection && eventId) { + setSiteIntakeId(eventId); } + } catch { + toast.error( + t("medications.intakeToastFailed", { name: medication.name }), + ); } finally { setIntakeLoading(null); } From 0c3c80a4f6b07666af8f0799e22cd7b6e67f3b5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 15:24:02 +0200 Subject: [PATCH 08/27] fix(medications): show a pending spinner on the intake-edit save button The intake-edit dialog already tracks a busy state and sets aria-busy, but the Save button gave no visual feedback while the PUT was in flight, unlike the wizard and quick-add. Render the same inline spinner during the mutation. --- src/components/medications/intake-edit-dialog.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/components/medications/intake-edit-dialog.tsx b/src/components/medications/intake-edit-dialog.tsx index ba1c3d16..45e51322 100644 --- a/src/components/medications/intake-edit-dialog.tsx +++ b/src/components/medications/intake-edit-dialog.tsx @@ -16,6 +16,7 @@ import { useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { @@ -174,7 +175,14 @@ function IntakeEditDialogBody({ - From 3c7d12f9c1d643dded09db5b85ad8fe6ab8aa704 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 15:24:07 +0200 Subject: [PATCH 09/27] fix(medications): replace the list loading spinner with card skeletons The bare centred spinner reserved a 32-unit-tall box that the resolved card grid then displaced, jumping the layout. Render a grid of card skeletons built on the real Card shell + shared Skeleton primitive so the loading state matches the loaded footprint. --- src/app/medications/page.tsx | 54 ++++++++++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/src/app/medications/page.tsx b/src/app/medications/page.tsx index f6bdb417..b3645823 100644 --- a/src/app/medications/page.tsx +++ b/src/app/medications/page.tsx @@ -13,7 +13,9 @@ import type { MedicationPayload } from "@/components/medications/wizard/wizard-p import { MedicationCard } from "@/components/medications/medication-card"; import { Glp1MedicationCard } from "@/components/medications/glp1-medication-card"; import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; import { EmptyState } from "@/components/ui/empty-state"; +import { Skeleton } from "@/components/ui/skeleton"; import { Loader2, Pill, Plus } from "lucide-react"; interface Schedule { @@ -71,6 +73,44 @@ interface Medication { schedules: Schedule[]; } +/** + * v1.11.3 C4 — loading placeholder for one medication card. Renders the + * real `Card`/`CardContent` shell (so padding, radius and gap match the + * loaded card exactly) with the shared `Skeleton` primitive standing in + * for each populated slot: header title + dose, status pill, next / last + * line, the two compliance bars and the action row. Replacing the bare + * centred spinner with a grid of these stops the layout jump when the + * cards resolve. + */ +function MedicationCardSkeleton() { + return ( + + ); +} + export default function MedicationsPage() { const { isAuthenticated, isLoading: authLoading } = useAuth(); const { t } = useTranslations(); @@ -223,8 +263,18 @@ export default function MedicationsPage() { {isLoading ? ( -
- + // v1.11.3 C4 — card skeletons instead of a bare centred spinner so + // the page reserves the loaded layout and does not jump when the + // medications resolve. +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))}
) : isError ? (
From fc6a55b91c9bab13ba4569dd8eff33b6475fb173 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 15:24:11 +0200 Subject: [PATCH 10/27] fix(medications): give the wizard stepper jump buttons a 44px touch target The first / last jump buttons rendered at 32px, below the minimum touch target. Extend the hit area with an invisible inset pseudo-element so the tappable region is 44px while the visual button stays 32px; the existing aria-labels are unchanged. --- src/components/medications/wizard/WizardStepper.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/medications/wizard/WizardStepper.tsx b/src/components/medications/wizard/WizardStepper.tsx index c4684875..d5ee67dc 100644 --- a/src/components/medications/wizard/WizardStepper.tsx +++ b/src/components/medications/wizard/WizardStepper.tsx @@ -88,7 +88,7 @@ export function WizardStepper({ type="button" variant="ghost" size="icon" - className="h-8 w-8 shrink-0" + className="relative h-8 w-8 shrink-0 before:absolute before:-inset-1.5 before:content-['']" onClick={onFirst} disabled={!firstEnabled} data-slot="wizard-stepper-first" @@ -184,7 +184,7 @@ export function WizardStepper({ type="button" variant="ghost" size="icon" - className="h-8 w-8 shrink-0" + className="relative h-8 w-8 shrink-0 before:absolute before:-inset-1.5 before:content-['']" onClick={onLast} disabled={!lastEnabled} data-slot="wizard-stepper-last" From 2e3bfbb971c59d7fb2dc148904b545a76c2abf62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 15:26:27 +0200 Subject: [PATCH 11/27] feat(coach): add a stop control and abort the stream on drawer close While a reply streams the composer now swaps the send button for a visible Stop control bound to the abort handler, so a long or off-track reply can be interrupted instead of waited out. Closing the drawer mid-stream also calls the cancel handler, so the SSE request no longer keeps running in the background after the drawer is gone. Add a localised "Stop" label across all six bundles. --- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/pl.json | 1 + .../__tests__/coach-input.test.tsx | 59 ++++++++++++++++++ .../insights/coach-panel/coach-drawer.tsx | 20 ++++--- .../insights/coach-panel/coach-input.tsx | 60 ++++++++++++++----- 9 files changed, 122 insertions(+), 23 deletions(-) diff --git a/messages/de.json b/messages/de.json index e753b8a6..3c69a1f9 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1700,6 +1700,7 @@ "tagline": "Persönlicher Gesundheitscoach, auf deine Daten gestützt.", "newChat": "Neuer Chat", "send": "Senden", + "stop": "Stopp", "thinking": "Denke nach…", "composerPlaceholder": "Frag mich etwas zu deinen Daten…", "composerHint": "Enter zum Senden, Umschalt+Enter für eine neue Zeile.", diff --git a/messages/en.json b/messages/en.json index 02f68a71..0004b2dc 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1700,6 +1700,7 @@ "tagline": "Personal health coach, grounded in your data.", "newChat": "New chat", "send": "Send", + "stop": "Stop", "thinking": "Thinking…", "composerPlaceholder": "Ask anything about your data…", "composerHint": "Press Enter to send, Shift+Enter for a new line.", diff --git a/messages/es.json b/messages/es.json index d42255e5..eb336d0f 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1700,6 +1700,7 @@ "tagline": "Coach personal de salud, apoyado en tus datos.", "newChat": "Nueva conversación", "send": "Enviar", + "stop": "Detener", "thinking": "Pensando…", "composerPlaceholder": "Pregúntame sobre tus datos…", "composerHint": "Intro para enviar, Mayús+Intro para una nueva línea.", diff --git a/messages/fr.json b/messages/fr.json index a8834183..40ed28bf 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1700,6 +1700,7 @@ "tagline": "Coach personnel de santé, ancré sur tes données.", "newChat": "Nouvelle conversation", "send": "Envoyer", + "stop": "Arrêter", "thinking": "Réflexion en cours…", "composerPlaceholder": "Pose-moi une question sur tes données…", "composerHint": "Entrée pour envoyer, Maj+Entrée pour une nouvelle ligne.", diff --git a/messages/it.json b/messages/it.json index 536e0b18..921291c6 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1700,6 +1700,7 @@ "tagline": "Coach personale di salute, basato sui tuoi dati.", "newChat": "Nuova chat", "send": "Invia", + "stop": "Interrompi", "thinking": "Sto pensando…", "composerPlaceholder": "Chiedimi qualcosa sui tuoi dati…", "composerHint": "Invio per inviare, Shift+Invio per una nuova riga.", diff --git a/messages/pl.json b/messages/pl.json index f5b13eb5..af00afbc 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1700,6 +1700,7 @@ "tagline": "Osobisty coach zdrowia, oparty na twoich danych.", "newChat": "Nowa rozmowa", "send": "Wyślij", + "stop": "Zatrzymaj", "thinking": "Myślę…", "composerPlaceholder": "Zapytaj o swoje dane…", "composerHint": "Enter wysyła, Shift+Enter przechodzi do nowej linii.", diff --git a/src/components/insights/coach-panel/__tests__/coach-input.test.tsx b/src/components/insights/coach-panel/__tests__/coach-input.test.tsx index fcf9290e..4785060a 100644 --- a/src/components/insights/coach-panel/__tests__/coach-input.test.tsx +++ b/src/components/insights/coach-panel/__tests__/coach-input.test.tsx @@ -122,6 +122,65 @@ describe("", () => { expect(html).toContain("animate-spin"); }); + it("swaps the send button for a Stop control while streaming with onCancel", () => { + // v1.11.3 D1 — while a reply streams the composer must surface a + // visible Stop affordance bound to the abort handler so the user + // can interrupt a long or off-track reply. The Stop button only + // appears when an `onCancel` handler is wired (the drawer always + // passes `send.cancel`). + const html = render( + {}} + onSubmit={() => {}} + onCancel={() => {}} + disabled + isStreaming + />, + ); + expect(html).toContain('data-slot="coach-input-stop"'); + expect(html).not.toContain('data-slot="coach-input-send"'); + expect(html).toContain("Stop"); + // The Stop control is a plain button, never a submit, so tapping it + // aborts rather than re-firing the form. + const stopTag = html.match( + /]*data-slot="coach-input-stop"[^>]*>/, + ); + expect(stopTag?.[0]).toContain('type="button"'); + }); + + it("keeps the send button (with spinner) while streaming without onCancel", () => { + // Backwards-compatible fallback: without an `onCancel` the composer + // keeps the legacy disabled-spinner send button. + const html = render( + {}} + onSubmit={() => {}} + disabled + isStreaming + />, + ); + expect(html).toContain('data-slot="coach-input-send"'); + expect(html).not.toContain('data-slot="coach-input-stop"'); + expect(html).toContain("animate-spin"); + }); + + it("renders the localised Stop label under the 'de' locale", () => { + const html = render( + {}} + onSubmit={() => {}} + onCancel={() => {}} + disabled + isStreaming + />, + "de", + ); + expect(html).toContain("Stopp"); + }); + it("invokes onChange when the parent passes a controlled handler", () => { // SSR can't fire DOM events; smoke-check the contract by calling // the supplied handler directly. diff --git a/src/components/insights/coach-panel/coach-drawer.tsx b/src/components/insights/coach-panel/coach-drawer.tsx index ef97a539..8e7c2344 100644 --- a/src/components/insights/coach-panel/coach-drawer.tsx +++ b/src/components/insights/coach-panel/coach-drawer.tsx @@ -163,9 +163,19 @@ export function CoachDrawer({ // and folds the saved window server-side — "what the rail shows" and // "what the model receives" cannot drift. + const { data: conversation } = useCoachConversation(currentConversationId); + const send = useSendCoachMessage({ + onDone: (resolvedId) => { + setCurrentConversationId(resolvedId); + }, + }); + const handleOpenChange = useCallback( (next: boolean) => { if (!next) { + // Abort any in-flight streamed reply so closing the drawer + // doesn't leave the SSE request running in the background. + send.cancel(); // Reset thread + composer on close so the next open starts on // the rail's empty hint instead of re-rendering the previous // conversation by accident. @@ -174,16 +184,9 @@ export function CoachDrawer({ } onOpenChange(next); }, - [onOpenChange, setInputValue], + [onOpenChange, send, setInputValue], ); - const { data: conversation } = useCoachConversation(currentConversationId); - const send = useSendCoachMessage({ - onDone: (resolvedId) => { - setCurrentConversationId(resolvedId); - }, - }); - async function handleSubmit(value: string) { const trimmed = value.trim(); if (!trimmed || send.isStreaming) return; @@ -352,6 +355,7 @@ export function CoachDrawer({ value={inputValue} onChange={setInputValue} onSubmit={() => handleSubmit(inputValue)} + onCancel={send.cancel} disabled={send.isStreaming} isStreaming={send.isStreaming} // v1.4.27 MB3 / CF-30 — the composer mounts on drawer diff --git a/src/components/insights/coach-panel/coach-input.tsx b/src/components/insights/coach-panel/coach-input.tsx index 5961d042..f44cda75 100644 --- a/src/components/insights/coach-panel/coach-input.tsx +++ b/src/components/insights/coach-panel/coach-input.tsx @@ -7,7 +7,7 @@ import { useEffect, useRef, } from "react"; -import { Info, Loader2, Send } from "lucide-react"; +import { Info, Loader2, Send, Square } from "lucide-react"; import { Button } from "@/components/ui/button"; import { @@ -43,6 +43,13 @@ export interface CoachInputProps { value: string; onChange: (next: string) => void; onSubmit: () => void; + /** + * Aborts the in-flight streamed reply. The composer swaps the send + * button for a Stop control while `isStreaming` is set; tapping it + * cancels the SSE request so the user can interrupt a long or + * off-track reply. + */ + onCancel?: () => void; /** Disabled while a previous reply is still streaming. */ disabled?: boolean; /** Surfaces the streaming-in-progress affordance on the send button. */ @@ -98,6 +105,7 @@ export function CoachInput({ value, onChange, onSubmit, + onCancel, disabled = false, isStreaming = false, inputId = "coach-composer-textarea", @@ -256,20 +264,42 @@ export function CoachInput({ {t("insights.coach.composerHint")} - + {isStreaming && onCancel ? ( + // While a reply streams, swap the send button for a Stop + // control bound to the abort handler so the user can + // interrupt a long or off-track reply instead of waiting it + // out. The spinner stays inside the Stop affordance so the + // in-progress state is still legible. + + ) : ( + + )}
From b12dc1e8703386920c07d3b26477075e69a561a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 15:26:34 +0200 Subject: [PATCH 12/27] perf(coach): parallelise the cold-path snapshot reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The snapshot builder ran its independent reads sequentially on a cache miss: feature extraction, the shared measurement query, the mood, compliance, sleep and workout table reads, and the GLP-1, derived, trajectory and memory helper blocks each awaited in turn. None consumes another's result, so they now fire concurrently and resolve in batched hops — feature extraction and the measurement read first (both gate the per-metric blocks), then the remaining reads in one Promise.all. The source guards are unchanged, so a disabled source still issues no query, and the synchronous block assembly keeps its original order, so the provenance envelope is byte-for-byte the same. Cuts several round-trips off the cold build; the warm path is cached and unaffected. --- src/lib/ai/coach/snapshot.ts | 242 ++++++++++++++++++++++------------- 1 file changed, 153 insertions(+), 89 deletions(-) diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index 49f845a8..e7847ae0 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -495,7 +495,13 @@ async function buildCoachSnapshotImpl( } const windowDays = windowToDays(window); - const features = await extractFeatures(userId, false, { + // v1.11.3 — kick the feature extraction off as a promise now and await + // it alongside the shared measurement read below. `extractFeatures` + // and the measurement `findMany` are independent (each only needs + // `userId` + the resolved window/sources), so running them + // concurrently shaves a round-trip off the cold path. No block reads + // `features` before the shared await, so the deferral is safe. + const featuresPromise = extractFeatures(userId, false, { sinceDays: windowDays, }); @@ -657,9 +663,9 @@ async function buildCoachSnapshotImpl( (source) => METRIC_TYPES[source] ?? [], ); - const measurementRows = + const measurementRowsPromise = wantedTypes.length > 0 - ? await prisma.measurement.findMany({ + ? prisma.measurement.findMany({ where: { userId, type: { in: wantedTypes as never[] }, @@ -677,7 +683,127 @@ async function buildCoachSnapshotImpl( glucoseContext: true, }, }) - : []; + : Promise.resolve([]); + + // v1.11.3 — `extractFeatures` and the shared measurement read are + // mutually independent and both gate the blocks below (every aggregate + // reads `features`; bp/weight/pulse/glucose read `measurementRows`), so + // run the two concurrently and resolve them in a single hop. + const [features, measurementRows] = await Promise.all([ + featuresPromise, + measurementRowsPromise, + ]); + + // v1.11.3 — the remaining cold-path reads are mutually independent: + // the four conditional table reads (mood / compliance / sleep / + // workouts) and the four helper-block reads (GLP-1 / derived / + // trajectory / memory) each consume only `userId`, the window cutoff, + // or the synchronously-derived `derivedProfile` — none reads another's + // result. Fire them all off concurrently now, KEEPING the original + // `wants…` / `sources.has(…)` guards so a disabled source still issues + // no query (the guard yields `null`/`undefined`, never a wasted + // round-trip), then await the batch in one hop. The synchronous block + // assembly further below consumes the resolved values in the original + // order, so provenance and block-registration order are unchanged. + // + // `derivedProfile` is derived from `features.context` (now resolved) + // and feeds the GLP-1 / derived / trajectory / memory readers; it is + // hoisted here so those reads can start immediately. + const derivedSources: CoachScopeSource[] = [ + "hrv", + "resting_hr", + "sleep", + "vo2_max", + ]; + const derivedCtx = features.context; + const derivedProfile: BaselineProfile = { + ageYears: derivedCtx?.ageYears ?? null, + sex: + derivedCtx?.gender === "MALE" || derivedCtx?.gender === "FEMALE" + ? (derivedCtx.gender as "MALE" | "FEMALE") + : null, + heightCm: derivedCtx?.heightCm ?? null, + }; + const derivedActive = derivedSources.some((s) => sources.has(s)); + + const moodRowsPromise = + wantsMood && features.mood + ? prisma.moodEntry.findMany({ + // v1.7.0 sync — exclude tombstoned rows from the Coach snapshot. + where: { userId, deletedAt: null, moodLoggedAt: { gte: cutoff } }, + orderBy: { moodLoggedAt: "asc" }, + select: { moodLoggedAt: true, score: true }, + }) + : null; + const intakeRowsPromise = wantsCompliance + ? prisma.medicationIntakeEvent.findMany({ + where: { userId, scheduledFor: { gte: cutoff } }, + orderBy: { scheduledFor: "asc" }, + select: { scheduledFor: true, takenAt: true, skipped: true }, + }) + : null; + const sleepRowsPromise = sources.has("sleep") + ? prisma.measurement.findMany({ + where: { + userId, + type: "SLEEP_DURATION" as never, + measuredAt: { gte: additiveCutoff("sleep") }, + deletedAt: null, + }, + orderBy: { measuredAt: "asc" }, + select: { value: true, measuredAt: true, sleepStage: true }, + }) + : null; + const workoutRowsPromise = sources.has("workouts") + ? prisma.workout.findMany({ + where: { userId, startedAt: { gte: additiveCutoff("workouts") } }, + orderBy: { startedAt: "desc" }, + select: { + sportType: true, + startedAt: true, + durationSec: true, + totalEnergyKcal: true, + totalDistanceM: true, + avgHeartRate: true, + maxHeartRate: true, + }, + }) + : null; + const glp1BlockPromise = excludesMedications + ? null + : buildGlp1SnapshotBlock(userId, now); + const derivedBlockPromise = derivedActive + ? buildDerivedSnapshotBlock(userId, derivedProfile, now) + : null; + const trajectoryBlockPromise = derivedActive + ? buildTrajectorySnapshotBlock(userId, derivedProfile, now) + : null; + const memoryBlockPromise = buildCoachMemoryBlock( + userId, + derivedProfile, + now, + coachLocale, + ); + + const [ + moodRows, + intakeRows, + sleepRows, + workoutRows, + glp1Block, + derivedBlock, + trajectoryBlock, + memoryBlock, + ] = await Promise.all([ + moodRowsPromise, + intakeRowsPromise, + sleepRowsPromise, + workoutRowsPromise, + glp1BlockPromise, + derivedBlockPromise, + trajectoryBlockPromise, + memoryBlockPromise, + ]); const byType = (t: string) => measurementRows @@ -749,15 +875,8 @@ async function buildCoachSnapshotImpl( counts.pulse = features.pulse.coverage?.count ?? undefined; registerBlock("pulse", "pulse"); } - if (wantsMood && features.mood) { - // Mood entries live on a separate model. Pull only the recent - // window for the day-level rows + bucket the rest. - const moodRows = await prisma.moodEntry.findMany({ - // v1.7.0 sync — exclude tombstoned rows from the Coach snapshot. - where: { userId, deletedAt: null, moodLoggedAt: { gte: cutoff } }, - orderBy: { moodLoggedAt: "asc" }, - select: { moodLoggedAt: true, score: true }, - }); + if (wantsMood && features.mood && moodRows) { + // Mood entries live on a separate model — read in parallel above. const normalised = moodRows.map((m) => ({ measuredAt: m.moodLoggedAt, value: m.score, @@ -779,17 +898,13 @@ async function buildCoachSnapshotImpl( registerBlock("mood", "mood"); } - if (wantsCompliance) { + if (wantsCompliance && intakeRows) { // Medication compliance lives outside the structured features // today — the legacy Coach surface labelled it as "general" // provenance only. v1.4.20.1 ships a per-day adherence row built // from the intake-event log so the Coach can answer "did I miss - // my dose on Tuesday?" without inventing the schedule. - const intakeRows = await prisma.medicationIntakeEvent.findMany({ - where: { userId, scheduledFor: { gte: cutoff } }, - orderBy: { scheduledFor: "asc" }, - select: { scheduledFor: true, takenAt: true, skipped: true }, - }); + // my dose on Tuesday?" without inventing the schedule. The read is + // issued in parallel above. if (intakeRows.length > 0) { // Per-day adherence rate within the recent window. Older days // collapse into a single weekly bucket. @@ -958,18 +1073,9 @@ async function buildCoachSnapshotImpl( // dedicated read of the SLEEP_DURATION rows that carry a non-null // stage; the duration timeline is built from the same rows summed per // night (one night = the sum of its per-stage rows). - if (sources.has("sleep")) { - const sleepCutoff = additiveCutoff("sleep"); - const sleepRows = await prisma.measurement.findMany({ - where: { - userId, - type: "SLEEP_DURATION" as never, - measuredAt: { gte: sleepCutoff }, - deletedAt: null, - }, - orderBy: { measuredAt: "asc" }, - select: { value: true, measuredAt: true, sleepStage: true }, - }); + if (sources.has("sleep") && sleepRows) { + // The SLEEP_DURATION rows (with the `sleepStage` column) are read in + // parallel above. if (sleepRows.length === 0) { annotate({ action: { name: "coach.cluster.empty_skipped" }, @@ -1081,21 +1187,8 @@ async function buildCoachSnapshotImpl( // energy, distance, avg/max HR) plus a per-sport weekly count + total // duration/energy rollup for the tail so the prompt stays bounded // even for a heavy-training account at a long window. - if (sources.has("workouts")) { - const workoutCutoff = additiveCutoff("workouts"); - const workoutRows = await prisma.workout.findMany({ - where: { userId, startedAt: { gte: workoutCutoff } }, - orderBy: { startedAt: "desc" }, - select: { - sportType: true, - startedAt: true, - durationSec: true, - totalEnergyKcal: true, - totalDistanceM: true, - avgHeartRate: true, - maxHeartRate: true, - }, - }); + if (sources.has("workouts") && workoutRows) { + // The workout sessions are read in parallel above. if (workoutRows.length === 0) { annotate({ action: { name: "coach.cluster.empty_skipped" }, @@ -1161,13 +1254,12 @@ async function buildCoachSnapshotImpl( // "your medication", never to make recommendations. // v1.4.36 W3 T2 — gated on `medications` exclusion. When excluded // we skip the Prisma lookup entirely so the read cost vanishes too. - if (!excludesMedications) { - const glp1Block = await buildGlp1SnapshotBlock(userId, now); - if (glp1Block) { - snapshot.weeklyContext = { glp1: glp1Block }; - metrics.add("compliance"); - registerBlock("weeklyContext", "compliance"); - } + if (!excludesMedications && glp1Block) { + // The GLP-1 block is read in parallel above (null when excluded or + // when the account has no active GLP-1 medication). + snapshot.weeklyContext = { glp1: glp1Block }; + metrics.add("compliance"); + registerBlock("weeklyContext", "compliance"); } // v1.4.36 W3 T2 — anthropometrics block (height / age / gender). @@ -1203,28 +1295,9 @@ async function buildCoachSnapshotImpl( // contract every surface uses — no recompute. Gated on at least one of // the signals the composites are built from staying in-scope (HRV / // resting HR / sleep / VO₂max), so a user who excludes those doesn't see - // the block. - const derivedSources: CoachScopeSource[] = [ - "hrv", - "resting_hr", - "sleep", - "vo2_max", - ]; - const derivedCtx = features.context; - const derivedProfile: BaselineProfile = { - ageYears: derivedCtx?.ageYears ?? null, - sex: - derivedCtx?.gender === "MALE" || derivedCtx?.gender === "FEMALE" - ? (derivedCtx.gender as "MALE" | "FEMALE") - : null, - heightCm: derivedCtx?.heightCm ?? null, - }; - if (derivedSources.some((s) => sources.has(s))) { - const derivedBlock = await buildDerivedSnapshotBlock( - userId, - derivedProfile, - now, - ); + // the block. `derivedActive` + `derivedProfile` are resolved up top so + // the derived / trajectory reads can run in the parallel batch. + if (derivedActive) { if (derivedBlock) { snapshot.derived = derivedBlock; metrics.add("hrv"); @@ -1238,12 +1311,8 @@ async function buildCoachSnapshotImpl( // Coach narrates the range conditionally (system-prompt rule 11 / // ground rule 16) only when this block is present. Registered under an // `environment`-cluster source so the soft-cap degrader sheds it FIRST, - // before any clinical cluster, under prompt-budget pressure. - const trajectoryBlock = await buildTrajectorySnapshotBlock( - userId, - derivedProfile, - now, - ); + // before any clinical cluster, under prompt-budget pressure. Read in + // the parallel batch above. if (trajectoryBlock) { snapshot.trajectory = trajectoryBlock; registerBlock("trajectory", "skin_temp"); @@ -1260,13 +1329,8 @@ async function buildCoachSnapshotImpl( // (`environment`, the tail of CLUSTER_PRIORITY) so `degradeToBudget` // sheds it FIRST under the char cap — before any clinical cluster. The // builder is fault-isolated per sub-source and returns null when neither - // a narrative nor any band movement is on file. - const memoryBlock = await buildCoachMemoryBlock( - userId, - derivedProfile, - now, - coachLocale, - ); + // a narrative nor any band movement is on file. Read in the parallel + // batch above. if (memoryBlock) { snapshot.memory = memoryBlock; // `skin_temp` maps to the `environment` cluster — the lowest priority From fb4037ed9681461e9e44fe4f8f4e54d18929fdde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 15:26:40 +0200 Subject: [PATCH 13/27] fix(coach): tidy the message thread type scale, bubble width and empty state Lift the disclaimer and feedback-thanks lines off the off-scale 10px size onto the standard extra-small step. Unify the user and assistant row gap, and budget the avatar column out of the bubble's 80% cap so a bubble plus its avatar never overflow a comfortable width on a narrow phone. Announce the empty-thread hero as a polite live region so screen readers read the hint when the thread first mounts. --- .../coach-panel/__tests__/message-thread.test.tsx | 12 ++++++++++++ .../insights/coach-panel/message-thread.tsx | 15 ++++++++++----- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx index 5f064674..f975defa 100644 --- a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx +++ b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx @@ -103,6 +103,18 @@ describe("", () => { ); }); + it("announces the empty-thread hero as a polite live region", () => { + // v1.11.3 D5 — the bespoke empty-state hero stays (gradient avatar), + // but it must carry `role="status"` + `aria-live="polite"` so screen + // readers announce the hint when the thread first mounts. + const html = render(); + const threadTag = html.match( + /]*data-slot="coach-message-thread"[^>]*>/, + ); + expect(threadTag?.[0]).toContain('role="status"'); + expect(threadTag?.[0]).toContain('aria-live="polite"'); + }); + it("renders user + assistant bubbles for a persisted conversation", () => { const html = render(); expect(html).toContain('data-slot="coach-bubble-user"'); diff --git a/src/components/insights/coach-panel/message-thread.tsx b/src/components/insights/coach-panel/message-thread.tsx index 684ee151..1affe968 100644 --- a/src/components/insights/coach-panel/message-thread.tsx +++ b/src/components/insights/coach-panel/message-thread.tsx @@ -236,6 +236,8 @@ export function MessageThread({ return (
{t("insights.coach.composerDisclaimer")}

@@ -371,12 +373,15 @@ function ChatBubble({ return (
@@ -438,7 +443,7 @@ function ChatBubble({ )}
-
+
{t("insights.coach.feedbackThanks")}

From 60b86f1ec97b028c2c0e37710b8699a4f5c587d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 15:28:28 +0200 Subject: [PATCH 14/27] fix(insights): suppress recommendation-card hover lift under reduced motion The recommendation cards carry `transition-all md:hover:-translate-y-0.5` for a subtle lift on pointer hover. Motion-sensitive users got the full transform with no opt-out. Pair the hover with `motion-reduce:transition-none motion-reduce:hover:translate-y-0` so the card stays put when the user asks for less motion. --- src/components/insights/recommendations-grid.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/insights/recommendations-grid.tsx b/src/components/insights/recommendations-grid.tsx index 3b1fe3c5..d6f47bbf 100644 --- a/src/components/insights/recommendations-grid.tsx +++ b/src/components/insights/recommendations-grid.tsx @@ -101,7 +101,7 @@ export function RecommendationsGrid({ recs }: RecommendationsGridProps) { } data-stagger-index={index} role="listitem" - className={`animate-insight-in transition-all md:hover:-translate-y-0.5 md:hover:shadow-lg ${borderClass} rounded-lg border-l-2`} + className={`animate-insight-in transition-all md:hover:-translate-y-0.5 md:hover:shadow-lg ${borderClass} rounded-lg border-l-2 motion-reduce:transition-none motion-reduce:hover:translate-y-0`} style={{ animationDelay: `${index * STAGGER_INTERVAL_MS}ms` }} > From e63fae564be970322ceb58248a5f1171db9579d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 15:28:34 +0200 Subject: [PATCH 15/27] test(insights): pin the reduced-motion guard for .animate-insight-in The 400 ms `.animate-insight-in` entrance keyframe is applied across five insight surfaces and is collapsed to `animation: none` once, centrally, in a `@media (prefers-reduced-motion: reduce)` block in globals.css. Extend the motion-reduce coverage test to assert that central guard so a future edit cannot silently drop it and replay the entrance motion for motion-sensitive users. --- .../motion-reduce-spin-coverage.test.ts | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/src/components/__tests__/motion-reduce-spin-coverage.test.ts b/src/components/__tests__/motion-reduce-spin-coverage.test.ts index c9a73057..225dda2e 100644 --- a/src/components/__tests__/motion-reduce-spin-coverage.test.ts +++ b/src/components/__tests__/motion-reduce-spin-coverage.test.ts @@ -65,3 +65,56 @@ describe("motion-reduce coverage — every animate-spin pairs with motion-reduce ).toEqual([]); }); }); + +/** + * v1.11.3 — `.animate-insight-in` reduced-motion guard. + * + * Unlike `animate-spin` (a Tailwind utility paired with an inline + * `motion-reduce:animate-none` per site), `.animate-insight-in` is a custom + * keyframe utility defined in `globals.css` and applied across five insight + * surfaces (recommendations-grid, recommendation-card, insight-status-card, + * hero-strip, arztbericht-hero-card). Guarding it at each call site would be + * fragile, so the guard lives once in `globals.css`: a + * `@media (prefers-reduced-motion: reduce)` block collapses the animation to + * `none`, covering every consumer at once. This test pins that central guard + * so a future edit cannot drop it and silently re-introduce a 400 ms motion + * for motion-sensitive users. + */ +describe("motion-reduce coverage — animate-insight-in collapses under prefers-reduced-motion", () => { + const GLOBALS = join(process.cwd(), "src", "app", "globals.css"); + const css = readFileSync(GLOBALS, "utf8"); + + it("globals.css defines the .animate-insight-in utility", () => { + expect(css).toMatch(/\.animate-insight-in\s*\{[^}]*animation:/); + }); + + it("a prefers-reduced-motion block collapses .animate-insight-in to no motion", () => { + // Locate the reduced-motion media query, then assert that within its + // body `.animate-insight-in` is reset to `animation: none`. The check + // is anchored on the media query so a stray `.animate-insight-in { + // animation: none }` outside a reduced-motion context cannot satisfy it. + const mediaStart = css.search( + /@media\s*\(\s*prefers-reduced-motion:\s*reduce\s*\)/, + ); + expect( + mediaStart, + "Expected a `@media (prefers-reduced-motion: reduce)` block in globals.css.", + ).toBeGreaterThanOrEqual(0); + + // Scan from the media query to the end of the file — every motion the app + // defers under reduced motion lives in such a block, and `.animate-insight-in` + // must be one of them. + const fromMedia = css.slice(mediaStart); + const guarded = /\.animate-insight-in\s*\{\s*animation:\s*none/.test( + fromMedia, + ); + + expect( + guarded, + "Expected `.animate-insight-in { animation: none }` inside a " + + "`@media (prefers-reduced-motion: reduce)` block in globals.css. " + + "Without it the 400 ms entrance keyframe plays for motion-sensitive " + + "users on every insight card.", + ).toBe(true); + }); +}); From 0baad1e736b38812fa3e338b709049c9b0bc53cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 15:29:07 +0200 Subject: [PATCH 16/27] refactor(insights): share one block skeleton across dynamic loaders, anchor the warm-assessments control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The six `next/dynamic` loaders on the insights overview each rendered a bespoke `
` with a guessed fixed height. Those heights pinned each placeholder taller or shorter than the resolved block, so the page shifted as each chunk landed. Route every loader through one shared `BlockSkeleton` built on the `Skeleton` primitive (which already honours reduced motion) and hold the row open with a `min-h` floor rather than a hard-coded pixel guess, removing the resolve-time layout shift. Decorative placeholders for the cards that can un-mount stay `aria-hidden`. Anchor the warm-assessments control too: it floated right-aligned with no label, reading as a context-less affordance. Pair it with a left-aligned caption that explains the nightly-refresh model so the button has a home. --- messages/de.json | 2 + messages/en.json | 2 + messages/es.json | 2 + messages/fr.json | 2 + messages/it.json | 2 + messages/pl.json | 2 + .../insights/__tests__/page-skeletons.test.ts | 88 ++++++++++++------- src/app/insights/page.tsx | 84 +++++++++++------- 8 files changed, 120 insertions(+), 64 deletions(-) diff --git a/messages/de.json b/messages/de.json index 3c69a1f9..0631b2ca 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1522,6 +1522,7 @@ "feedbackThanks": "Danke für dein Feedback", "feedbackConfirmed": "Feedback gespeichert", "feedbackAlreadyRated": "Bereits bewertet", + "feedbackError": "Dein Feedback konnte nicht gespeichert werden. Bitte versuche es erneut.", "confidence": "Vertrauen", "confidenceAria": "Vertrauen: {value} von 100", "confidenceHigh": "Hohes Vertrauen", @@ -1543,6 +1544,7 @@ "regenerateAnalysis": "Analyse neu starten", "regenerateSuccess": "Analyse wurde neu erstellt", "warmAssessments": "Auswertungen vorbereiten", + "warmAssessmentsHint": "Auswertungen werden über Nacht automatisch aktualisiert. Bereite sie jetzt vor, um den neuesten Stand zu sehen.", "warmStarted": "Auswertungen werden im Hintergrund erstellt", "narrativeTitle": "Dein Zeitraum im Rückblick", "narrativeWeek": "Diese Woche", diff --git a/messages/en.json b/messages/en.json index 0004b2dc..aaae6856 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1522,6 +1522,7 @@ "feedbackThanks": "Thanks for your feedback", "feedbackConfirmed": "Feedback recorded", "feedbackAlreadyRated": "Already rated", + "feedbackError": "Could not save your feedback. Please try again.", "confidence": "Confidence", "confidenceAria": "Confidence: {value} of 100", "confidenceHigh": "High confidence", @@ -1543,6 +1544,7 @@ "regenerateAnalysis": "Re-run analysis", "regenerateSuccess": "Analysis refreshed", "warmAssessments": "Prepare assessments", + "warmAssessmentsHint": "Assessments refresh overnight automatically. Prepare them now to read the latest.", "warmStarted": "Assessments are being prepared in the background", "narrativeTitle": "Your period in review", "narrativeWeek": "This week", diff --git a/messages/es.json b/messages/es.json index eb336d0f..6ef303f7 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1522,6 +1522,7 @@ "feedbackThanks": "Gracias por tu opinión", "feedbackConfirmed": "Opinión guardada", "feedbackAlreadyRated": "Ya valorado", + "feedbackError": "No se pudo guardar tu opinión. Inténtalo de nuevo.", "confidence": "Confianza", "confidenceAria": "Confianza: {value} de 100", "confidenceHigh": "Confianza alta", @@ -1543,6 +1544,7 @@ "regenerateAnalysis": "Volver a iniciar el análisis", "regenerateSuccess": "Análisis regenerado", "warmAssessments": "Preparar evaluaciones", + "warmAssessmentsHint": "Las evaluaciones se actualizan automáticamente durante la noche. Prepáralas ahora para ver lo más reciente.", "warmStarted": "Las evaluaciones se están preparando en segundo plano", "narrativeTitle": "Tu período en resumen", "narrativeWeek": "Esta semana", diff --git a/messages/fr.json b/messages/fr.json index 40ed28bf..eee9b4a7 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1522,6 +1522,7 @@ "feedbackThanks": "Merci pour ton retour", "feedbackConfirmed": "Retour enregistré", "feedbackAlreadyRated": "Déjà noté", + "feedbackError": "Impossible d’enregistrer ton retour. Réessaie.", "confidence": "Confiance", "confidenceAria": "Confiance : {value} sur 100", "confidenceHigh": "Confiance élevée", @@ -1543,6 +1544,7 @@ "regenerateAnalysis": "Relancer l’analyse", "regenerateSuccess": "Analyse regénérée", "warmAssessments": "Préparer les évaluations", + "warmAssessmentsHint": "Les évaluations sont actualisées automatiquement pendant la nuit. Préparez-les maintenant pour voir les dernières données.", "warmStarted": "Les évaluations sont en cours de préparation en arrière-plan", "narrativeTitle": "Votre période en revue", "narrativeWeek": "Cette semaine", diff --git a/messages/it.json b/messages/it.json index 921291c6..9d71167f 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1522,6 +1522,7 @@ "feedbackThanks": "Grazie per il riscontro", "feedbackConfirmed": "Feedback salvato", "feedbackAlreadyRated": "Già valutato", + "feedbackError": "Impossibile salvare il tuo riscontro. Riprova.", "confidence": "Confidenza", "confidenceAria": "Confidenza: {value} su 100", "confidenceHigh": "Confidenza alta", @@ -1543,6 +1544,7 @@ "regenerateAnalysis": "Riavvia l’analisi", "regenerateSuccess": "Analisi rigenerata", "warmAssessments": "Prepara le valutazioni", + "warmAssessmentsHint": "Le valutazioni si aggiornano automaticamente durante la notte. Preparale ora per vedere i dati più recenti.", "warmStarted": "Le valutazioni vengono preparate in background", "narrativeTitle": "Il tuo periodo in sintesi", "narrativeWeek": "Questa settimana", diff --git a/messages/pl.json b/messages/pl.json index af00afbc..bd93e4d3 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1522,6 +1522,7 @@ "feedbackThanks": "Dzięki za opinię", "feedbackConfirmed": "Opinia zapisana", "feedbackAlreadyRated": "Już ocenione", + "feedbackError": "Nie udało się zapisać opinii. Spróbuj ponownie.", "confidence": "Pewność", "confidenceAria": "Pewność: {value} na 100", "confidenceHigh": "Wysoka pewność", @@ -1543,6 +1544,7 @@ "regenerateAnalysis": "Uruchom analizę ponownie", "regenerateSuccess": "Analiza wygenerowana ponownie", "warmAssessments": "Przygotuj analizy", + "warmAssessmentsHint": "Analizy odświeżają się automatycznie w nocy. Przygotuj je teraz, aby zobaczyć najnowsze dane.", "warmStarted": "Analizy są przygotowywane w tle", "narrativeTitle": "Twój okres w skrócie", "narrativeWeek": "Ten tydzień", diff --git a/src/app/insights/__tests__/page-skeletons.test.ts b/src/app/insights/__tests__/page-skeletons.test.ts index 017a434e..1b225f1a 100644 --- a/src/app/insights/__tests__/page-skeletons.test.ts +++ b/src/app/insights/__tests__/page-skeletons.test.ts @@ -4,51 +4,79 @@ import { join } from "node:path"; /** * v1.4.43 W11 — insights mother-page dynamic-skeleton guards. + * v1.11.3 — rewritten for the shared `BlockSkeleton` loader. * - * The three `next/dynamic` loading placeholders for - * `DailyBriefing` / `CorrelationRow` / `TrendsRow` used to reserve - * heights (`h-48` / `h-32` / `h-64`) noticeably shorter than the - * loaded content (~24 rem / ~20 rem each), which CLS-shifted the - * page on slow networks. They also lacked `motion-reduce:animate-none`, - * so motion-sensitive users saw a continuous pulse. + * The `next/dynamic` loading placeholders for the below-the-hero blocks + * used to be bespoke `
` snippets with hard-coded guessed + * heights. Those fixed heights pinned each placeholder taller or shorter + * than the resolved block, so the page CLS-shifted as each chunk landed. * - * This textual guard pins both the larger reserved heights and the - * motion-reduce class. The check is intentionally simple — render- - * mounting the page would haul in TanStack-Query / Auth / I18n - * scaffolding for a property a substring search already proves. + * E1 collapses all six loaders onto a single shared `BlockSkeleton` that + * routes through the `Skeleton` primitive (which carries + * `motion-reduce:animate-none`) and holds the row open with a `min-h` + * floor rather than a fixed `h-[Xrem]`. This guard pins the new contract: + * every loader uses `BlockSkeleton`, none re-introduces a hard-coded + * fixed height, and the decorative (un-mountable) cards stay `aria-hidden`. + * + * The check is intentionally textual — render-mounting the page would haul + * in TanStack-Query / Auth / I18n scaffolding for a property a substring + * search already proves. */ -describe("insights mother-page dynamic-skeleton heights + motion-reduce", () => { +describe("insights mother-page dynamic-skeleton loaders use the shared BlockSkeleton", () => { const src = readFileSync( join(process.cwd(), "src/app/insights/page.tsx"), "utf8", ); - it("DailyBriefing skeleton reserves h-[24rem]", () => { - expect(src).toMatch( - /DailyBriefing[\s\S]*?h-\[24rem\][^"]*motion-reduce:animate-none/, - ); + it("defines the shared BlockSkeleton helper", () => { + expect(src).toMatch(/function BlockSkeleton\(/); + // The helper renders the shared Skeleton primitive, which carries + // motion-reduce:animate-none, so every consumer inherits the guard. + expect(src).toMatch(/ { - expect(src).toMatch( - /CorrelationRow[\s\S]*?h-\[20rem\][^"]*motion-reduce:animate-none/, - ); + it("every dynamic loader routes through BlockSkeleton with a min-h floor", () => { + // Each below-the-hero loader pins a `min-h-*` floor (not a guessed + // fixed `h-[Xrem]`) so the row holds open without fighting the + // resolved block's true height. + const loaderMatches = [ + ...src.matchAll( + /loading:\s*\(\)\s*=>\s* { - expect(src).toMatch( - /TrendsRow[\s\S]*?h-\[20rem\][^"]*motion-reduce:animate-none/, - ); + it("the decorative (un-mountable) card loaders stay aria-hidden via the decorative flag", () => { + // CoincidentDeviationCard + PeriodNarrativeCard can un-mount, so their + // placeholders must hide from assistive tech. + const decorativeLoaders = [ + ...src.matchAll(/ { - // The legacy classes shouldn't be reintroduced on the mother - // page's three loading placeholders. - const dynamicBlock = src.match(/const DailyBriefing[\s\S]*?const TrendsRow[\s\S]*?\);\n/); + it("no loader re-introduces a guessed fixed h-[Xrem] / h-[Xpx] height", () => { + // The guessed fixed heights were the CLS root cause; the loaders now + // hold the row open with a `min-h-*` floor instead. A fixed + // `h-[Xrem]` / `h-[Xpx]` must not creep back onto the dynamic-loader + // placeholders. + const dynamicBlock = src.match( + /const DailyBriefing[\s\S]*?const PeriodNarrativeCard[\s\S]*?\);\n/, + ); expect(dynamicBlock).not.toBeNull(); const block = dynamicBlock?.[0] ?? ""; - expect(block).not.toMatch(/"[^"]*\bh-48\b[^"]*"/); - expect(block).not.toMatch(/"[^"]*\bh-32\b[^"]*"/); - expect(block).not.toMatch(/"[^"]*\bh-64\b[^"]*"/); + expect(block).not.toMatch(/h-\[\d+rem\]/); + expect(block).not.toMatch(/h-\[\d+px\]/); + // No bespoke pulsing card div should remain — every placeholder is + // the shared BlockSkeleton. + expect(block).not.toMatch(/animate-pulse/); }); }); diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index 8c771c38..bd35614f 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -12,6 +12,8 @@ import { useScrollResetOnRoute } from "@/hooks/use-scroll-reset-on-route"; import { useTranslations } from "@/lib/i18n/context"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; +import { Skeleton } from "@/components/ui/skeleton"; +import { cn } from "@/lib/utils"; import { HeroStrip } from "@/components/insights/hero-strip"; import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor"; import { useInsightsWarm } from "@/components/insights/use-insights-warm"; @@ -23,16 +25,40 @@ import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; import type { InsightsAnalyticsData as AnalyticsData } from "@/types/analytics"; /** - * v1.4.33 IW2 — defer the three below-the-fold mother-page blocks - * behind `next/dynamic`. `` (the only above-the-fold piece) - * stays an eager import so the initial paint shows the greeting and - * health-score badge without a flash; the briefing, correlation row - * and trends row each carry their own icon-set + chart wiring (a chart - * card alone weighs in at the lucide tree-shake limit) and used to - * land on every Insights cold mount. Loader skeletons match the - * existing fallback so the layout doesn't shift while the chunks - * resolve. + * v1.4.33 IW2 — defer the below-the-fold mother-page blocks behind + * `next/dynamic`. `` (the only above-the-fold piece) stays an + * eager import so the initial paint shows the greeting and health-score + * badge without a flash; the briefing, correlation row and trends row + * each carry their own icon-set + chart wiring (a chart card alone weighs + * in at the lucide tree-shake limit) and used to land on every Insights + * cold mount. + * + * v1.11.3 — every loader fallback now routes through the shared + * `BlockSkeleton` instead of a bespoke `h-[Xrem] animate-pulse` div. The + * fixed guessed heights pinned each placeholder taller (or shorter) than + * the resolved block, so the page jumped as each chunk landed. The shared + * skeleton carries a `min-h` floor — enough to hold the row open at cold + * mount — and lets the block grow into its true height without fighting a + * hard-coded pixel guess, killing the resolve-time layout shift. Reduced + * motion is honoured by the `Skeleton` primitive (`motion-reduce:animate-none`). */ +function BlockSkeleton({ + minHeight, + decorative = false, +}: { + /** Tailwind `min-h-*` utility holding the row open before the chunk lands. */ + minHeight: string; + /** Decorative placeholders (cards that may un-mount) hide from a11y. */ + decorative?: boolean; +}) { + return ( + + ); +} + const DailyBriefing = dynamic( () => import("@/components/insights/daily-briefing").then((mod) => ({ @@ -40,9 +66,7 @@ const DailyBriefing = dynamic( })), { ssr: false, - loading: () => ( -
- ), + loading: () => , }, ); const CorrelationRow = dynamic( @@ -52,9 +76,7 @@ const CorrelationRow = dynamic( })), { ssr: false, - loading: () => ( -
- ), + loading: () => , }, ); const TrendsRow = dynamic( @@ -64,9 +86,7 @@ const TrendsRow = dynamic( })), { ssr: false, - loading: () => ( -
- ), + loading: () => , }, ); // v1.10.0 — the Vitals dashboard (Apple-Health-Highlights grid of @@ -80,9 +100,7 @@ const VitalsDashboard = dynamic( })), { ssr: false, - loading: () => ( -
- ), + loading: () => , }, ); // v1.10.0 — categorical events (WX-B). The device-flagged event awareness @@ -111,12 +129,7 @@ const CoincidentDeviationCard = dynamic( })), { ssr: false, - loading: () => ( -