Merge the last UI round into release/v1.42.0

This commit is contained in:
Marc-André Bombeck committed 2026-10-08 21:12:05 +02:00
commit a9bf72a7ce
42 files changed
+2140 -2241

No files matched your search

+503 -1391
View File
File diff suppressed because it is too large. Load diff
+24 -20
View File
@@ -149,38 +149,42 @@ test.describe("Settings mobile consistency (Pixel 5)", () => {
}
});
test("/settings/account: Sprache select shares one grid row with date-of-birth", async ({
test("/settings/account: the language select sits in the display card, outside the profile form", async ({
page,
}) => {
await page.goto("/settings/account", { waitUntil: "networkidle" });
// Gate on the two elements this test is about, not on the network. An idle
// network says nothing about whether React has rendered the profile form,
// and the single `evaluate` below does not retry — so without this the
// test reads an empty document and reports "language + dob fields must
// exist", which sounds like a missing feature and is really a race.
// Gate on the elements this test is about, not on the network: the
// single `evaluate` below does not retry.
await expect(page.locator("#language-select")).toBeAttached();
await expect(page.locator("#dob")).toBeAttached();
// The v1.4.27 R1 settings audit pairs date-of-birth with language
// in a single `grid sm:grid-cols-2` row so the profile form keeps
// a uniform two-column rhythm and the language field no longer
// sits alone at the bottom with a `sm:max-w-xs` clamp.
const sharedGrid = await page.evaluate(() => {
// The language, unit and format selects save the moment they change, so
// they live in their own card with no save button. Inside the profile
// form they read as covered by its Save. Each card keeps a two-column
// grid: the language shares one with the units, the date of birth one
// with the timezone.
const layout = await page.evaluate(() => {
const lang = document.getElementById("language-select");
const dob = document.getElementById("dob");
if (!lang || !dob) return { found: false, sharedGrid: false };
const langGrid = lang.closest('[class*="grid"]');
const dobGrid = dob.closest('[class*="grid"]');
if (!lang || !dob) return null;
const displayCard = document.querySelector(
'[data-testid="settings-display-card"]',
);
return {
found: true,
sharedGrid: langGrid !== null && langGrid === dobGrid,
langInDisplayCard: !!displayCard && displayCard.contains(lang),
langInForm: lang.closest("form") !== null,
dobInForm: dob.closest("form") !== null,
langHasGrid: lang.closest('[class*="grid"]') !== null,
dobHasGrid: dob.closest('[class*="grid"]') !== null,
};
});
expect(sharedGrid.found, "language + dob fields must exist").toBe(true);
expect(sharedGrid.sharedGrid, "language + dob must share a grid").toBe(
true,
);
expect(layout, "language + dob fields must exist").not.toBeNull();
expect(layout!.langInDisplayCard).toBe(true);
expect(layout!.langInForm).toBe(false);
expect(layout!.dobInForm).toBe(true);
expect(layout!.langHasGrid).toBe(true);
expect(layout!.dobHasGrid).toBe(true);
});
test("/: Compare-to trigger renders at 44 px", async ({ page }) => {
+9 -2
View File
@@ -503,13 +503,20 @@ test.describe.serial("scoped sharing browser journeys", () => {
await expect(banner).toHaveAttribute("data-record-kind", "shared");
await page.goto("/measurements");
await expect(page.locator('[data-slot="measurement-add"]')).toBeVisible();
// An empty list carries its add action in the empty state rather than
// the header, so the write grant shows whichever of the two applies.
const addControl = page
.locator(
'[data-slot="measurement-add"], [data-slot="measurement-add-first"]',
)
.first();
await expect(addControl).toBeVisible();
const post = page.waitForResponse(
(response) =>
response.request().method() === "POST" &&
new URL(response.url()).pathname === "/api/measurements",
);
await page.locator('[data-slot="measurement-add"]').click();
await addControl.click();
await page.locator("#sys").fill("124");
await page.locator("#dia").fill("78");
await page.getByRole("button", { name: /^save$/i }).click();
+39 -43
View File
@@ -5310,12 +5310,10 @@
"sections": {
"account": {
"title": "Konto",
"description": "Profil, Passwort, Passkeys und deine Onboarding-Tour.",
"subtitle": "Dein Profil, deine Identität und deine Anmeldedaten."
},
"integrations": {
"title": "Integrationen",
"description": "Verbundene Dienste, Zustellkanäle und die Quellen-Priorität an einem Ort.",
"subtitle": "Datenquellen und Geräte verbinden und verwalten.",
"connectionsHeading": "Verbindungen",
"channelsHeading": "Zustellkanäle",
@@ -5324,16 +5322,14 @@
},
"notifications": {
"title": "Benachrichtigungen",
"description": "Alle Erinnerungen an einem Ort — was wann erinnert wird und wohin es zugestellt wird.",
"subtitle": "Wähle, welche Erinnerungen du erhältst und wohin sie zugestellt werden.",
"remindersHeading": "Erinnerungen",
"matrixLink": "Benachrichtigungen je Kanal",
"channelsLink": "Zustellkanäle"
},
"layout": {
"title": "Darstellung",
"description": "Lege fest, wie Dashboard, Auswertungen und deine Tracking-Module dargestellt und angeordnet sind.",
"backToHub": "Zurück zu Darstellung",
"title": "Layout",
"backToHub": "Zurück zu Layout",
"dashboard": {
"title": "Dashboard",
"description": "Kachel-Layout und Reihenfolge."
@@ -5342,7 +5338,7 @@
"title": "Insights",
"description": "Reihenfolge der Übersicht und der Navigations-Pills."
},
"subtitle": "Lege fest, wie Dashboard, Auswertungen und Tracking-Module dargestellt werden.",
"subtitle": "Lege fest, was Dashboard, Auswertungen und Tracking-Module zeigen und in welcher Reihenfolge.",
"medications": {
"title": "Medikamente",
"description": "Listenansicht und manuelle Reihenfolge."
@@ -5373,12 +5369,10 @@
},
"thresholds": {
"title": "Zielwerte",
"description": "Pro Metrik dein eigener Zielbereich für gesunde Werte.",
"subtitle": "Lege deine persönlichen Zielbereiche je Messgröße fest."
},
"sources": {
"title": "Quellen-Priorität",
"description": "Lege fest, welche Quelle führt, wenn mehrere am selben Tag dieselbe Metrik liefern.",
"integrationsHint": "Neue Datenquellen verbindest oder trennst du auf der Integrationen-Seite.",
"integrationsHintLink": "Integrationen öffnen",
"cardTitle": "Rangfolge je Messgröße",
@@ -5460,26 +5454,23 @@
"moduleHintLink": "Module verwalten",
"detail": "Nur lesend — die Quell-App besitzt die Werte."
},
"helpDetail": "Die übrigen bleiben für die Audit-Spur gespeichert."
"helpDetail": "Die übrigen bleiben für die Audit-Spur gespeichert. Aufgeführt sind nur Quellen, die du verbunden hast oder von denen Daten vorliegen.",
"noneInUse": "Noch keine Quelle zum Sortieren. Verbinde eine unter Integrationen oder trage einen Wert ein."
},
"ai": {
"title": "KI-Anbieter",
"description": "Provider, Modell und API-Key für die Auswertungen — dazu dein „Über mich“-Kontext.",
"subtitle": "Wähle deinen KI-Anbieter und hinterlege deinen eigenen Schlüssel."
},
"api": {
"title": "API & Tokens",
"description": "Bearer-Tokens für eigene Skripte und Apps — erfasse Messwerte oder Einnahmen von überall.",
"subtitle": "API-Endpunkte und Zugriffstoken verwalten."
},
"advanced": {
"title": "Erweitert",
"description": "Der Notausgang: alle Gesundheitsdaten unwiderruflich löschen oder das Konto auflösen.",
"subtitle": "Daten zurücksetzen und Konto löschen."
},
"export": {
"title": "Export & Import",
"description": "Lade deine Daten als CSV oder vollständiges JSON-Backup herunter — oder importiere aus Apple Health, JSON oder CSV. Den Arztbericht findest du jetzt unter Gesundheitsakte.",
"otherOptionsHeading": "Weitere Export-Optionen",
"downloadFailed": "Der Export konnte nicht heruntergeladen werden. Bitte versuche es erneut.",
"actions": {
@@ -5724,12 +5715,10 @@
},
"about": {
"title": "Über diese App",
"description": "Version, Lizenz, Links.",
"subtitle": "Version, Lizenz und Projektlinks."
},
"sharing": {
"title": "Freigabe",
"description": "Erstelle einen schreibgeschützten Link, um deine Gesundheitsakte mit einer Ärztin oder einem Arzt zu teilen.",
"subtitle": "Teile schreibgeschützten Zugriff auf deine Daten."
},
"insights": {
@@ -5764,12 +5753,10 @@
},
"gesundheitsakte": {
"title": "Gesundheitsakte",
"description": "Vollständige Gesundheitsakte als PDF, FHIR R4 oder kombiniertes ZIP-Paket exportieren.",
"subtitle": "Exportiere eine vollständige, arzttaugliche Gesundheitsakte."
},
"coach": {
"title": "Coach",
"description": "Coach aktivieren, Ton und Umfang, gespeicherte Erinnerungen.",
"subtitle": "Stelle den Coach, seine proaktiven Hinweise und sein Gedächtnis ein."
},
"security": {
@@ -5778,8 +5765,7 @@
},
"privacy": {
"title": "Daten & Datenschutz",
"subtitle": "Deine Daten, Aufbewahrung, Verschlüsselung und Kontosicherheit an einem Ort.",
"description": "Export, Löschung, Aufbewahrung, Verschlüsselungsstatus, Sitzungen und Sicherheitsaktivität."
"subtitle": "Deine Daten, Aufbewahrung, Verschlüsselung und Kontosicherheit an einem Ort."
},
"mcp": {
"title": "MCP-Connector",
@@ -5787,8 +5773,7 @@
},
"anamnesis": {
"title": "Anamnese",
"subtitle": "Deine Vorgeschichte, Lebensumstände, Notfalldaten und was die KI lesen darf.",
"description": "Strukturierte Datensätze zu Allergien und Familienanamnese."
"subtitle": "Deine Vorgeschichte, Lebensumstände, Notfalldaten und was die KI lesen darf."
},
"environment": {
"title": "Standort & Wetter",
@@ -6542,30 +6527,32 @@
"fitbitViewData": "Schlaf ansehen",
"googleHealthViewData": "Schritte ansehen",
"apiTokens": "API-Tokens",
"apiTokensDescription": "API-Tokens für die externe Medikamenten-Einnahme prüfen und widerrufen.",
"apiTokensDescription": "Tokens für Messwerte, Trainings, Dokumente und Medikamenten-Einnahme prüfen und widerrufen.",
"measurementsToken": {
"title": "Messwert-Tokens",
"description": "Erzeuge ein Token, damit eine Waage, eine Uhr oder deine Hausautomatisierung Werte eintragen kann.",
"detail": "Das Token wird nur einmal angezeigt.",
"scopeNote": "Es kann ausschließlich neue Werte in deine eigene Akte eintragen — deine Daten nicht lesen, nichts ändern oder löschen und kein weiteres Token erstellen. Werte werden als von einem externen Gerät gekennzeichnet, damit du sie später erkennst, und lassen sich jederzeit korrigieren.",
"createFailed": "Das Token konnte nicht erstellt werden. Bitte versuche es erneut."
"createFailed": "Das Token konnte nicht erstellt werden. Bitte versuche es erneut.",
"namePlaceholder": "Name, z. B. Home Assistant"
},
"workoutsToken": {
"title": "Training-Tokens",
"description": "Erzeuge ein Token, damit eine Uhren-Brücke oder ein Sync-Skript deine Trainings eintragen kann.",
"detail": "Das Token wird nur einmal angezeigt.",
"scopeNote": "Es kann ausschließlich neue Trainings in deine eigene Akte eintragen. Es kann deine Daten nicht lesen, nichts ändern oder löschen und kein weiteres Token erstellen. Trainings, die darüber kommen, werden als von einem externen Gerät gekennzeichnet. Hat dein Telefon dieselbe Einheit aufgezeichnet, siehst du die Aufzeichnung des Telefons.",
"createFailed": "Das Token konnte nicht erstellt werden. Bitte versuche es erneut."
"createFailed": "Das Token konnte nicht erstellt werden. Bitte versuche es erneut.",
"namePlaceholder": "Name, z. B. Garmin"
},
"documentsToken": {
"title": "Dokument-Tokens",
"description": "Erzeuge ein Token, damit Paperless-ngx, Papra oder das Import-Skript Dokumente in deine Ablage legen können.",
"detail": "Das Token wird nur einmal angezeigt.",
"scopeNote": "Es kann ausschließlich neue Dokumente in deine eigene Ablage legen. Es kann nichts auflisten, öffnen, herunterladen, ändern oder löschen und kein weiteres Token erstellen. Zurück bekommt es nur, ob eine Datei gespeichert wurde, nicht wie du sie abgelegt hast.",
"createFailed": "Das Token konnte nicht erstellt werden. Bitte versuche es erneut."
"createFailed": "Das Token konnte nicht erstellt werden. Bitte versuche es erneut.",
"namePlaceholder": "Name, z. B. Paperless-ngx"
},
"tokenMintMovedDescription": "Tokens werden dort ausgestellt, wo sie gelten: oben für Messwerte und Dokumente, oder im API-Endpoint eines Medikaments für genau dieses Medikament.",
"tokenNamePlaceholder": "Token-Name (z. B. iPhone Shortcut)",
"tokenMintMovedDescription": "Tokens werden dort ausgestellt, wo sie gelten: oben für Messwerte, Trainings und Dokumente, oder im API-Endpoint eines Medikaments für genau dieses Medikament.",
"tokenCreated": "Token erstellt — kopiere ihn jetzt! Er wird nicht erneut angezeigt.",
"tokenCopy": "Token kopieren",
"tokenCopied": "Token in die Zwischenablage kopiert",
@@ -6757,7 +6744,8 @@
"statusNeedsReselect": "Inhalt neu wählen",
"reselectReason": "Dieser Link lieferte einen Umfang, den niemand gewählt hatte, und wurde deshalb geschlossen. Lege einen neuen an und wähle den Inhalt.",
"reselectAction": "Ersatz anlegen",
"createDetail": "Der Token wird nur einmal bei der Erstellung angezeigt — sichere ihn dann; er lässt sich nicht wiederherstellen."
"createDetail": "Der Token wird nur einmal bei der Erstellung angezeigt — sichere ihn dann; er lässt sich nicht wiederherstellen.",
"activeDescription": "Deine geteilten Links, wann sie ablaufen und wie oft sie geöffnet wurden."
},
"genderOther": "Divers",
"nightscout": "Nightscout",
@@ -6983,12 +6971,11 @@
},
"stored": {
"title": "Was gespeichert wird",
"measurements": "Messwerte (Gewicht, Blutdruck, Puls, Glukose, Schlaf, Körperzusammensetzung)",
"medications": "Medikamente und Einnahmeverlauf",
"moodLabs": "Stimmungseinträge und Laborwerte",
"coach": "Coach-Unterhaltungen",
"measurements": "Messwerte: Gewicht, Blutdruck, Puls und jeder weitere Wert, den du oder ein Gerät erfasst",
"integrations": "Integrations-Tokens (verschlüsselt)",
"security": "Konto- und Sicherheitsaktivität (Sitzungen, Anmeldeprotokoll)"
"security": "Konto- und Sicherheitsaktivität (Sitzungen, Anmeldeprotokoll)",
"profile": "Dein Profil, deine Anamnese und die Angaben für die Gesundheitsakte",
"modules": "Einträge der Module: {list}"
},
"retention": {
"title": "Aufbewahrung",
@@ -7019,8 +7006,6 @@
"mcp": {
"enableTitle": "Remote-MCP-Endpunkt",
"enableDescription": "Erlaube einem MCP-fähigen Assistenten (Claude.ai, ChatGPT), deine Gesundheitsdaten über ein begrenztes Token zu lesen.",
"enabled": "Aktiv",
"disabled": "Inaktiv",
"enableToggleLabel": "MCP-Endpunkt aktivieren",
"endpointLabel": "Connector-URL",
"connectDescription": "Füge diese URL in die Einrichtung des eigenen Connectors deines Assistenten ein und autorisiere dich mit dem Token unten oder melde dich bei Aufforderung an.",
@@ -7039,7 +7024,8 @@
"connectionRevokeDescription": "Dies beendet den Zugriff des Assistenten sofort und verhindert eine Erneuerung. Für eine erneute Verbindung ist eine neue Autorisierung nötig.",
"connectionRevokeFailed": "Assistent konnte nicht getrennt werden.",
"tokensDetail": "Das Token wird nur einmal angezeigt.",
"connectionsDetail": "Das Widerrufen einer Verbindung beendet den Zugriff sofort, einschließlich der Möglichkeit zur Erneuerung."
"connectionsDetail": "Das Widerrufen einer Verbindung beendet den Zugriff sofort, einschließlich der Möglichkeit zur Erneuerung.",
"tokenNamePlaceholder": "Name, z. B. Claude Desktop"
},
"strava": "Strava",
"stravaDescription": "Verbinde dein Strava-Konto, um deine Aktivitäten in deinen Trainingsverlauf zu übernehmen.",
@@ -7169,7 +7155,12 @@
"signInAgain": "Melde dich neu an und versuche es dann noch einmal. Dein Konto hat kein Passwort und keinen Passkey, den du hier bestätigen könntest.",
"failed": "Das hat nicht geklappt. Prüfe deine Eingabe und versuche es noch einmal."
},
"passwordTitle": "Passwort"
"passwordTitle": "Passwort",
"tokenNameLabel": "Token-Name",
"displayCard": {
"title": "Darstellung",
"description": "Sprache, Einheiten und Formate, gespeichert, sobald du sie änderst."
}
},
"admin": {
"title": "Administration",
@@ -7377,8 +7368,8 @@
"coachFollowsSwitch": "Folgt dem Coach-Schalter im Bereich Coach."
},
"invites": {
"title": "Einladungen",
"description": "Einladungslinks für die Registrierung — funktionieren auch bei geschlossener Registrierung.",
"title": "Einladungslinks",
"description": "Jeder Link lässt eine Registrierung zu, auch wenn sie geschlossen ist.",
"create": "Einladung erstellen",
"createTitle": "Einladung erstellen",
"createDescription": "Der Link erlaubt eine Registrierung auch bei geschlossener Registrierung.",
@@ -7424,8 +7415,8 @@
"revokedToast": "Einladung widerrufen.",
"revokeError": "Einladung konnte nicht widerrufen werden."
},
"userManagement": "Nutzerverwaltung",
"userManagementDescription": "Konten verwalten: Rollen, Passwort-Resets und erzwungene Abmeldungen.",
"userManagement": "Konten",
"userManagementDescription": "Rollen, Passwort-Resets und erzwungene Abmeldungen für jedes Konto.",
"userName": "Name",
"userEmail": "E-Mail",
"userRole": "Rolle",
@@ -7881,7 +7872,12 @@
"backupsNone": "Noch keine Sicherung hat die Schlüssel festgehalten, die sie braucht.",
"backupsStillNeed": "Alle Zeilen sind rotiert, aber Sicherungen brauchen noch {keys}",
"backupsUnrecordedBadge": "Alle Zeilen sind rotiert, aber ältere Sicherungen können noch einen ausgemusterten Schlüssel brauchen",
"backupsDetail": "Die Rotation verschlüsselt jede Zeile neu, aber eine Sicherung behält den Schlüssel, unter dem ihr Inhalt geschrieben wurde, und ihre Wiederherstellung braucht ihn. Behalte einen ausgemusterten Schlüssel in ENCRYPTION_KEYS, bis er unten nicht mehr aufgeführt ist."
"backupsDetail": "Die Rotation verschlüsselt jede Zeile neu, aber eine Sicherung behält den Schlüssel, unter dem ihr Inhalt geschrieben wurde, und ihre Wiederherstellung braucht ihn. Behalte einen ausgemusterten Schlüssel in ENCRYPTION_KEYS, bis er unten nicht mehr aufgeführt ist.",
"columnsShow": "Tabelle anzeigen ({count} mit Zeilen)",
"columnsHide": "Tabelle ausblenden",
"columnsShowAll": "Alle {count} Spalten anzeigen",
"columnsShowWithRows": "Nur Spalten mit Zeilen",
"columnsNoneWithRows": "Noch keine verschlüsselte Spalte enthält Zeilen."
},
"maintenance": {
"title": "Pflege der Messwert-Tabelle",
+78 -82
View File
@@ -392,7 +392,7 @@
"insights": "Insights",
"achievements": "Achievements",
"settings": "Settings",
"admin": "Admin Console",
"admin": "Admin console",
"logout": "Sign out",
"login": "Sign in",
"notifications": "Notifications",
@@ -3279,7 +3279,7 @@
"relativeDaysAgoFew": "{count} days ago",
"navOverview": "Overview",
"navCatalog": "All metrics",
"navBloodPressure": "Blood Pressure",
"navBloodPressure": "Blood pressure",
"navWeight": "Weight",
"navPulse": "Pulse",
"navMood": "Mood",
@@ -3814,7 +3814,7 @@
"description": "Body-water readings appear here once a connected body-composition scale syncs."
}
},
"navTotalBodyWater": "Body Water",
"navTotalBodyWater": "Body water",
"boneMass": {
"title": "Bone Mass",
"description": "Your bone mass over time, in kilograms.",
@@ -3824,7 +3824,7 @@
"description": "Bone-mass readings appear here once a connected body-composition scale syncs."
}
},
"navBoneMass": "Bone Mass",
"navBoneMass": "Bone mass",
"flightsClimbed": {
"title": "Flights Climbed",
"description": "Flights of stairs you climbed each day.",
@@ -3854,7 +3854,7 @@
"description": "Fat-free-mass readings appear here once a connected body-composition scale syncs."
}
},
"navFatFreeMass": "Fat-Free Mass",
"navFatFreeMass": "Fat-free mass",
"fatMass": {
"title": "Fat Mass",
"description": "Your fat mass over time, in kilograms.",
@@ -3874,8 +3874,8 @@
"cta": "Log body fat"
}
},
"navFatMass": "Fat Mass",
"navBodyFat": "Body Fat",
"navFatMass": "Fat mass",
"navBodyFat": "Body fat",
"muscleMass": {
"title": "Muscle Mass",
"description": "Your muscle mass over time, in kilograms.",
@@ -3885,7 +3885,7 @@
"description": "Muscle-mass readings appear here once a connected body-composition scale syncs."
}
},
"navMuscleMass": "Muscle Mass",
"navMuscleMass": "Muscle mass",
"skinTemperature": {
"title": "Skin Temperature",
"description": "Your wrist skin temperature over time, in degrees Celsius.",
@@ -3895,7 +3895,7 @@
"description": "Skin-temperature readings appear here once a wearable syncs overnight wrist temperature."
}
},
"navSkinTemperature": "Skin Temp",
"navSkinTemperature": "Skin temp",
"pulseWaveVelocity": {
"title": "Pulse Wave Velocity",
"description": "Your arterial pulse-wave velocity over time, in m/s — higher values suggest stiffer arteries.",
@@ -3905,7 +3905,7 @@
"description": "Pulse-wave-velocity readings appear here once a compatible Withings device syncs."
}
},
"navPulseWaveVelocity": "Pulse Wave Velocity",
"navPulseWaveVelocity": "Pulse wave velocity",
"vascularAge": {
"title": "Vascular Age",
"description": "Your estimated vascular age over time, in years.",
@@ -3915,7 +3915,7 @@
"description": "Vascular-age estimates appear here once a compatible Withings device syncs."
}
},
"navVascularAge": "Vascular Age",
"navVascularAge": "Vascular age",
"visceralFat": {
"title": "Visceral Fat",
"description": "Your visceral-fat rating over time.",
@@ -3925,7 +3925,7 @@
"description": "Visceral-fat ratings appear here once a compatible body-composition scale syncs."
}
},
"navVisceralFat": "Visceral Fat",
"navVisceralFat": "Visceral fat",
"audioExposureEnv": {
"title": "Environmental Audio",
"description": "Your daily average environmental sound exposure, in dBA.",
@@ -3935,7 +3935,7 @@
"description": "Environmental sound-level readings appear here once Apple Health syncs."
}
},
"navAudioExposureEnv": "Environmental Audio",
"navAudioExposureEnv": "Environmental audio",
"audioExposureHeadphone": {
"title": "Headphone Audio",
"description": "Your daily average headphone audio exposure, in dBA.",
@@ -3945,7 +3945,7 @@
"description": "Headphone audio-level readings appear here once Apple Health syncs."
}
},
"navAudioExposureHeadphone": "Headphone Audio",
"navAudioExposureHeadphone": "Headphone audio",
"timeInDaylight": {
"title": "Time in Daylight",
"description": "Minutes spent in daylight each day.",
@@ -3965,7 +3965,7 @@
"description": "Walking-steadiness readings appear here once Apple Health syncs."
}
},
"navWalkingSteadiness": "Walking Steadiness",
"navWalkingSteadiness": "Walking steadiness",
"audioExposureEvent": {
"title": "Loud Audio Events",
"description": "Days with a loud-environment exposure event.",
@@ -3975,7 +3975,7 @@
"description": "Loud-environment events appear here once Apple Health syncs."
}
},
"navAudioExposureEvent": "Loud Events",
"navAudioExposureEvent": "Loud events",
"respiratoryRate": {
"title": "Respiratory Rate",
"description": "Your respiratory rate over time, in breaths per minute.",
@@ -3985,7 +3985,7 @@
"description": "Respiratory-rate readings appear here once Apple Health syncs sleep or workout breathing."
}
},
"navRespiratoryRate": "Respiratory Rate",
"navRespiratoryRate": "Respiratory rate",
"leanBodyMass": {
"title": "Lean Body Mass",
"description": "Your lean body mass over time, in kilograms.",
@@ -3995,7 +3995,7 @@
"description": "Lean-body-mass readings appear here once a connected body-composition scale syncs."
}
},
"navLeanBodyMass": "Lean Body Mass",
"navLeanBodyMass": "Lean body mass",
"walkingHeartRateAverage": {
"title": "Walking Heart Rate",
"description": "Your average heart rate while walking, in bpm.",
@@ -4005,7 +4005,7 @@
"description": "Walking-heart-rate readings appear here once Apple Health syncs."
}
},
"navWalkingHeartRateAverage": "Walking Heart Rate",
"navWalkingHeartRateAverage": "Walking heart rate",
"walkingAsymmetry": {
"title": "Walking Asymmetry",
"description": "Your walking asymmetry over time, as a percentage.",
@@ -4015,7 +4015,7 @@
"description": "Walking-asymmetry readings appear here once Apple Health syncs."
}
},
"navWalkingAsymmetry": "Walking Asymmetry",
"navWalkingAsymmetry": "Walking asymmetry",
"walkingDoubleSupport": {
"title": "Double Support Time",
"description": "The percentage of each stride spent with both feet on the ground.",
@@ -4025,7 +4025,7 @@
"description": "Double-support readings appear here once Apple Health syncs."
}
},
"navWalkingDoubleSupport": "Double Support",
"navWalkingDoubleSupport": "Double support",
"walkingStepLength": {
"title": "Walking Step Length",
"description": "Your average walking step length over time, in metres.",
@@ -4035,7 +4035,7 @@
"description": "Step-length readings appear here once Apple Health syncs."
}
},
"navWalkingStepLength": "Step Length",
"navWalkingStepLength": "Step length",
"walkingSpeed": {
"title": "Walking Speed",
"description": "Your average walking speed over time, in km/h.",
@@ -4045,7 +4045,7 @@
"description": "Walking-speed readings appear here once Apple Health syncs."
}
},
"navWalkingSpeed": "Walking Speed",
"navWalkingSpeed": "Walking speed",
"mood": {
"logLink": "Log a mood entry",
"heatmapTitle": "Mood calendar",
@@ -4426,7 +4426,7 @@
"description": "VO₂ max readings appear here once Apple Health syncs from your workouts."
}
},
"navCardioFitness": "Cardio Fitness",
"navCardioFitness": "Cardio fitness",
"cardioRecovery": {
"title": "Cardio Recovery",
"description": "How far your heart rate drops one minute after peak exercise.",
@@ -4436,7 +4436,7 @@
"description": "Cardio-recovery readings appear here once Apple Health syncs from a workout."
}
},
"navCardioRecovery": "Cardio Recovery",
"navCardioRecovery": "Cardio recovery",
"wristTemperature": {
"title": "Wrist Temperature",
"description": "Your overnight wrist temperature, tracked against your own baseline.",
@@ -4446,7 +4446,7 @@
"description": "Wrist-temperature readings appear here once Apple Health syncs overnight wear."
}
},
"navWristTemperature": "Wrist Temp",
"navWristTemperature": "Wrist temp",
"falls": {
"title": "Falls",
"description": "Hard falls your Apple Watch detected, counted per day.",
@@ -4466,7 +4466,7 @@
"description": "Six-minute-walk estimates appear here once Apple Health syncs."
}
},
"navSixMinuteWalk": "6-Min Walk",
"navSixMinuteWalk": "6-min walk",
"stairAscentSpeed": {
"title": "Stair Ascent Speed",
"description": "How fast you climb stairs, measured by your iPhone or Watch.",
@@ -4476,7 +4476,7 @@
"description": "Stair-ascent-speed readings appear here once Apple Health syncs."
}
},
"navStairAscentSpeed": "Stair Ascent",
"navStairAscentSpeed": "Stair ascent",
"stairDescentSpeed": {
"title": "Stair Descent Speed",
"description": "How fast you go down stairs, measured by your iPhone or Watch.",
@@ -4486,7 +4486,7 @@
"description": "Stair-descent-speed readings appear here once Apple Health syncs."
}
},
"navStairDescentSpeed": "Stair Descent",
"navStairDescentSpeed": "Stair descent",
"breathingDisturbances": {
"title": "Breathing Disturbances",
"description": "How often your breathing was disturbed during sleep.",
@@ -5310,12 +5310,10 @@
"sections": {
"account": {
"title": "Account",
"description": "Profile, password, passkeys, and your onboarding tour.",
"subtitle": "Your profile, identity, and sign-in details."
},
"integrations": {
"title": "Integrations",
"description": "Connected services, delivery channels, and source priority in one place.",
"subtitle": "Connect and manage your data sources and devices.",
"connectionsHeading": "Connections",
"channelsHeading": "Delivery channels",
@@ -5324,16 +5322,14 @@
},
"notifications": {
"title": "Notifications",
"description": "Every reminder in one place — what gets reminded and when, and where it's delivered.",
"subtitle": "Choose which reminders you receive and where they're delivered.",
"remindersHeading": "Reminders",
"matrixLink": "Per-channel notifications",
"channelsLink": "Delivery channels"
},
"layout": {
"title": "Appearance",
"description": "Choose how the dashboard, insights, and your tracking modules look and are arranged.",
"backToHub": "Back to Appearance",
"title": "Layout",
"backToHub": "Back to Layout",
"dashboard": {
"title": "Dashboard",
"description": "Tile layout and order."
@@ -5342,7 +5338,7 @@
"title": "Insights",
"description": "Overview sections and navigation-pill order."
},
"subtitle": "Personalize how your dashboard, insights, and tracking modules look.",
"subtitle": "Choose what your dashboard, insights and tracking modules show, and in which order.",
"medications": {
"title": "Medications",
"description": "List view and manual order."
@@ -5373,12 +5369,10 @@
},
"thresholds": {
"title": "Targets",
"description": "Per metric: your own target range for healthy values.",
"subtitle": "Set your personal target ranges per metric."
},
"sources": {
"title": "Source priority",
"description": "Pick which ingest source wins when more than one records the same metric for the same day.",
"integrationsHint": "Connect or disconnect data sources on the Integrations page.",
"integrationsHintLink": "Open integrations",
"cardTitle": "Ranking per metric",
@@ -5460,26 +5454,23 @@
"moduleHintLink": "Manage modules",
"detail": "Read-only — the source app owns the numbers."
},
"helpDetail": "The others stay stored for the audit trail."
"helpDetail": "The others stay stored for the audit trail. Only sources you have connected or hold data from are listed.",
"noneInUse": "No source to rank yet. Connect one under Integrations or record a value."
},
"ai": {
"title": "AI provider",
"description": "Provider, model, and API key for the insights — plus your “about me” context.",
"subtitle": "Choose your AI provider and bring your own key."
},
"api": {
"title": "API & Tokens",
"description": "Bearer tokens for your own scripts and apps — log measurements or medication intake from anywhere.",
"title": "API & tokens",
"subtitle": "Manage API endpoints and access tokens."
},
"advanced": {
"title": "Advanced",
"description": "The way out: erase every health record for good, or close the account entirely.",
"subtitle": "Data reset and account deletion."
},
"export": {
"title": "Export & Import",
"description": "Download your data as CSV or a full JSON backup — or import from Apple Health, JSON, or CSV. The doctor report now lives under Health Record.",
"title": "Export & import",
"otherOptionsHeading": "Other export options",
"downloadFailed": "Couldn't download the export. Please try again.",
"actions": {
@@ -5724,12 +5715,10 @@
},
"about": {
"title": "About this app",
"description": "Version, license, links.",
"subtitle": "Version, license, and project links."
},
"sharing": {
"title": "Sharing",
"description": "Create a read-only link to share your health record with a clinician.",
"subtitle": "Share read-only access to your data."
},
"insights": {
@@ -5764,12 +5753,10 @@
},
"gesundheitsakte": {
"title": "Health record",
"description": "Export your full health record as PDF, FHIR R4, or a combined ZIP package.",
"subtitle": "Export a complete, clinician-ready health record."
},
"coach": {
"title": "Coach",
"description": "Enable the Coach, tone and scope, stored memories.",
"subtitle": "Tune the Coach, its proactive nudges, and its memory."
},
"security": {
@@ -5777,18 +5764,16 @@
"subtitle": "Two-factor authentication, recovery codes, and passkeys."
},
"privacy": {
"title": "Data & Privacy",
"subtitle": "Your data, retention, encryption, and account security in one place.",
"description": "Export, deletion, retention, encryption status, sessions, and security activity."
"title": "Data & privacy",
"subtitle": "Your data, retention, encryption, and account security in one place."
},
"mcp": {
"title": "MCP Connector",
"title": "MCP connector",
"subtitle": "Connect an external assistant to your own health data."
},
"anamnesis": {
"title": "Medical history",
"subtitle": "Your medical background, lifestyle, emergency details and what the AI may read.",
"description": "Structured allergies and family history records."
"subtitle": "Your medical background, lifestyle, emergency details and what the AI may read."
},
"environment": {
"title": "Location & weather",
@@ -5847,7 +5832,7 @@
}
},
"score": {
"title": "Health Score",
"title": "Health score",
"subtitle": "Choose which pillars count toward your score.",
"card": {
"title": "What counts toward your score",
@@ -6541,31 +6526,33 @@
"whoopViewData": "View your recovery",
"fitbitViewData": "View your sleep",
"googleHealthViewData": "View your steps",
"apiTokens": "API Tokens",
"apiTokensDescription": "Review and revoke API tokens for external medication intake.",
"apiTokens": "API tokens",
"apiTokensDescription": "Review and revoke tokens for measurements, workouts, documents and medication intake.",
"measurementsToken": {
"title": "Measurement tokens",
"description": "Mint a token so a scale, a watch bridge or your home automation can add readings.",
"detail": "The token is shown once.",
"scopeNote": "It can only add new readings to your own record — it cannot read your data, change or delete anything, or create another token. Readings are marked as coming from an external device, so you can tell them apart later, and you can still correct them yourself.",
"createFailed": "The token could not be created. Please try again."
"createFailed": "The token could not be created. Please try again.",
"namePlaceholder": "Name, e.g. Home Assistant"
},
"workoutsToken": {
"title": "Workout tokens",
"description": "Mint a token so a watch bridge or a sync script can add your workouts.",
"detail": "The token is shown once.",
"scopeNote": "It can only add new workouts to your own record. It cannot read your data, change or delete anything, or create another token. Workouts it adds are marked as coming from an external device, and when your phone recorded the same session, you see the phone's copy.",
"createFailed": "The token could not be created. Please try again."
"createFailed": "The token could not be created. Please try again.",
"namePlaceholder": "Name, e.g. Garmin"
},
"documentsToken": {
"title": "Document tokens",
"description": "Mint a token so Paperless-ngx, Papra or the import script can add documents to your vault.",
"detail": "The token is shown once.",
"scopeNote": "It can only add new documents to your own vault. It cannot list, open, download, change or delete anything, and it cannot create another token. What it gets back is whether a file was stored, not how you filed it.",
"createFailed": "The token could not be created. Please try again."
"createFailed": "The token could not be created. Please try again.",
"namePlaceholder": "Name, e.g. Paperless-ngx"
},
"tokenMintMovedDescription": "Tokens are issued where they apply: above for measurements and documents, or in a medication's own API endpoint for that one medication.",
"tokenNamePlaceholder": "Token name (e.g., iPhone Shortcut)",
"tokenMintMovedDescription": "Tokens are issued where they apply: above for measurements, workouts and documents, or in a medication's own API endpoint for that one medication.",
"tokenCreated": "Token created — copy it now! It won't be shown again.",
"tokenCopy": "Copy token",
"tokenCopied": "Token copied to clipboard",
@@ -6757,7 +6744,8 @@
"statusNeedsReselect": "Pick contents again",
"reselectReason": "This link served a scope that was never chosen, so it was closed. Create a new one and pick what it should contain.",
"reselectAction": "Create a replacement",
"createDetail": "The token is shown once on creation — store it then; it cannot be recovered."
"createDetail": "The token is shown once on creation — store it then; it cannot be recovered.",
"activeDescription": "Links you have shared, when they expire and how often they were opened."
},
"genderOther": "Other",
"nightscout": "Nightscout",
@@ -6983,12 +6971,11 @@
},
"stored": {
"title": "What's stored",
"measurements": "Measurements (weight, blood pressure, pulse, glucose, sleep, body composition)",
"medications": "Medications and intake history",
"moodLabs": "Mood entries and lab results",
"coach": "Coach conversations",
"measurements": "Measurements: weight, blood pressure, pulse and every other value you or a device record",
"integrations": "Integration tokens (encrypted)",
"security": "Account and security activity (sessions, sign-in log)"
"security": "Account and security activity (sessions, sign-in log)",
"profile": "Your profile, medical history and health-record details",
"modules": "Records of each module: {list}"
},
"retention": {
"title": "Retention",
@@ -7019,8 +7006,6 @@
"mcp": {
"enableTitle": "Remote MCP endpoint",
"enableDescription": "Let an MCP-capable assistant (Claude.ai, ChatGPT) read your health data over a scoped token.",
"enabled": "Enabled",
"disabled": "Disabled",
"enableToggleLabel": "Enable the MCP endpoint",
"endpointLabel": "Connector URL",
"connectDescription": "Paste this URL into your assistant's custom-connector setup, then authorize with the token below or sign in when prompted.",
@@ -7039,7 +7024,8 @@
"connectionRevokeDescription": "This ends the assistant's access immediately and stops it from renewing. It will need to be re-authorized to reconnect.",
"connectionRevokeFailed": "Could not disconnect the assistant.",
"connectionsDetail": "Revoking a connection ends its access immediately, including its ability to refresh.",
"tokensDetail": "The token is shown once."
"tokensDetail": "The token is shown once.",
"tokenNamePlaceholder": "Name, e.g. Claude Desktop"
},
"strava": "Strava",
"stravaDescription": "Connect your Strava account to sync your activities into your workout history.",
@@ -7169,7 +7155,12 @@
"signInAgain": "Sign in again, then try once more. Your account has no password or passkey to confirm here.",
"failed": "That did not work. Check what you entered and try again."
},
"passwordTitle": "Password"
"passwordTitle": "Password",
"tokenNameLabel": "Token name",
"displayCard": {
"title": "Display",
"description": "Language, units and formats, saved as soon as you change them."
}
},
"admin": {
"title": "Administration",
@@ -7377,8 +7368,8 @@
"coachFollowsSwitch": "Follows the Coach switch in the Coach section."
},
"invites": {
"title": "Invites",
"description": "Registration invite links — they work even while registration is closed.",
"title": "Invite links",
"description": "Each link admits a signup even while registration is closed.",
"create": "Create invite",
"createTitle": "Create invite",
"createDescription": "The link admits a signup even while registration is closed.",
@@ -7424,8 +7415,8 @@
"revokedToast": "Invite revoked.",
"revokeError": "Could not revoke the invite."
},
"userManagement": "User Management",
"userManagementDescription": "Manage accounts: roles, password resets, and forced logouts.",
"userManagement": "Accounts",
"userManagementDescription": "Roles, password resets and forced logouts for every account.",
"userName": "Name",
"userEmail": "Email",
"userRole": "Role",
@@ -7446,7 +7437,7 @@
"passwordReset": "Password has been reset",
"reset": "Reset",
"loadingUsers": "Loading users…",
"apiTokens": "API Tokens",
"apiTokens": "API tokens",
"apiTokensDescription": "All active and revoked API tokens across all accounts.",
"tokenUser": "User",
"tokenName": "Name",
@@ -7557,7 +7548,7 @@
},
"section": {
"system-status": {
"title": "System Status",
"title": "System status",
"subtitle": "Worker, database, and integration health at a glance."
},
"general": {
@@ -7603,7 +7594,7 @@
"subtitle": "Mint, track, and revoke registration invite links."
},
"api-tokens": {
"title": "API Tokens",
"title": "API tokens",
"subtitle": "Active bearer tokens issued to native clients and integrations."
},
"login-overview": {
@@ -7823,7 +7814,7 @@
"restoreSkippedVaccinationCiphertext": "Vaccination note sealed with a key this server does not hold (dose kept without it)"
},
"danger-zone": {
"title": "Danger Zone",
"title": "Danger zone",
"subtitle": "Destructive system-wide operations."
},
"about": {
@@ -7881,7 +7872,12 @@
"backupsNone": "No backup has recorded the keys it needs yet.",
"backupsStillNeed": "Every row is rotated, but backups still need {keys}",
"backupsUnrecordedBadge": "Every row is rotated, but older backups may still need a retired key",
"backupsDetail": "Rotation re-encrypts every row, but a backup keeps the key its content was written under, and restoring it needs that key. Keep a retired key in ENCRYPTION_KEYS until nothing below lists it."
"backupsDetail": "Rotation re-encrypts every row, but a backup keeps the key its content was written under, and restoring it needs that key. Keep a retired key in ENCRYPTION_KEYS until nothing below lists it.",
"columnsShow": "Show the table ({count} with rows)",
"columnsHide": "Hide the table",
"columnsShowAll": "Show all {count} columns",
"columnsShowWithRows": "Only columns with rows",
"columnsNoneWithRows": "No encrypted column holds rows yet."
},
"maintenance": {
"title": "Measurement table maintenance",
+40 -44
View File
@@ -5310,12 +5310,10 @@
"sections": {
"account": {
"title": "Cuenta",
"description": "Perfil, contraseña, claves de acceso y tu recorrido de incorporación.",
"subtitle": "Tu perfil, identidad y datos de acceso."
},
"integrations": {
"title": "Integraciones",
"description": "Servicios conectados, canales de entrega y prioridad de fuentes en un solo lugar.",
"subtitle": "Conecta y gestiona tus fuentes de datos y dispositivos.",
"connectionsHeading": "Conexiones",
"channelsHeading": "Canales de entrega",
@@ -5324,16 +5322,14 @@
},
"notifications": {
"title": "Notificaciones",
"description": "Todos los recordatorios en un solo lugar: qué se recuerda y cuándo, y dónde se entrega.",
"subtitle": "Elige qué recordatorios recibes y dónde se entregan.",
"remindersHeading": "Recordatorios",
"matrixLink": "Notificaciones por canal",
"channelsLink": "Canales de entrega"
},
"layout": {
"title": "Apariencia",
"description": "Elige cómo se ven y se organizan el panel, los análisis y tus módulos de seguimiento.",
"backToHub": "Volver a Apariencia",
"title": "Diseño",
"backToHub": "Volver a Diseño",
"dashboard": {
"title": "Panel",
"description": "Diseño y orden de los mosaicos."
@@ -5342,7 +5338,7 @@
"title": "Análisis",
"description": "Orden de la vista general y de las pestañas de navegación."
},
"subtitle": "Personaliza cómo se ven tu panel, los análisis y los módulos de seguimiento.",
"subtitle": "Elige qué muestran tu panel, los análisis y los módulos de seguimiento, y en qué orden.",
"medications": {
"title": "Medicamentos",
"description": "Vista de lista y orden manual."
@@ -5373,12 +5369,10 @@
},
"thresholds": {
"title": "Objetivos",
"description": "Por métrica: tu propio rango objetivo de valores saludables.",
"subtitle": "Define tus rangos objetivo personales por métrica."
},
"sources": {
"title": "Prioridad de fuentes",
"description": "Elige qué fuente prevalece cuando varias registran la misma métrica el mismo día.",
"integrationsHint": "Conecta o desconecta fuentes de datos en la página de integraciones.",
"integrationsHintLink": "Abrir integraciones",
"cardTitle": "Orden por métrica",
@@ -5460,26 +5454,23 @@
"moduleHintLink": "Gestionar módulos",
"detail": "Solo lectura: la app de origen es la dueña de los valores."
},
"helpDetail": "Las demás se conservan para la traza de auditoría."
"helpDetail": "Las demás se conservan para la traza de auditoría. Solo aparecen las fuentes que has conectado o de las que hay datos.",
"noneInUse": "Aún no hay fuentes que ordenar. Conecta una en Integraciones o registra un valor."
},
"ai": {
"title": "Proveedor de IA",
"description": "Proveedor, modelo y clave API para los análisis, además de tu contexto «sobre mí».",
"subtitle": "Elige tu proveedor de IA y usa tu propia clave."
},
"api": {
"title": "API & Tokens",
"description": "Tokens Bearer para tus propios scripts y apps — registra mediciones o tomas de medicación desde cualquier lugar.",
"title": "API y tokens",
"subtitle": "Gestiona los endpoints de la API y los tokens de acceso."
},
"advanced": {
"title": "Avanzado",
"description": "La salida: borra todos los registros de salud para siempre o cierra la cuenta.",
"subtitle": "Restablecer datos y eliminar cuenta."
},
"export": {
"title": "Exportar e importar",
"description": "Descarga tus datos como CSV o una copia de seguridad JSON completa, o importa desde Apple Health, JSON o CSV. El informe médico ahora está en Historia de salud.",
"otherOptionsHeading": "Otras opciones de exportación",
"downloadFailed": "No se pudo descargar la exportación. Inténtalo de nuevo.",
"actions": {
@@ -5724,12 +5715,10 @@
},
"about": {
"title": "Acerca de esta app",
"description": "Versión, licencia, enlaces.",
"subtitle": "Versión, licencia y enlaces del proyecto."
},
"sharing": {
"title": "Compartir",
"description": "Crea un enlace de solo lectura para compartir tu historial de salud con un profesional médico.",
"subtitle": "Comparte acceso de solo lectura a tus datos."
},
"insights": {
@@ -5764,12 +5753,10 @@
},
"gesundheitsakte": {
"title": "Historial médico",
"description": "Exporta tu historial médico completo como PDF, FHIR R4 o un paquete ZIP combinado.",
"subtitle": "Exporta un historial de salud completo, listo para el médico."
},
"coach": {
"title": "Coach",
"description": "Activa el Coach, tono y alcance, recuerdos guardados.",
"subtitle": "Ajusta el Coach, sus avisos proactivos y su memoria."
},
"security": {
@@ -5778,8 +5765,7 @@
},
"privacy": {
"title": "Datos y privacidad",
"subtitle": "Tus datos, su retención, el cifrado y la seguridad de la cuenta en un solo lugar.",
"description": "Exportación, eliminación, retención, estado de cifrado, sesiones y actividad de seguridad."
"subtitle": "Tus datos, su retención, el cifrado y la seguridad de la cuenta en un solo lugar."
},
"mcp": {
"title": "Conector MCP",
@@ -5787,8 +5773,7 @@
},
"anamnesis": {
"title": "Antecedentes médicos",
"subtitle": "Tus antecedentes, tu estilo de vida, tus datos de emergencia y lo que la IA puede leer.",
"description": "Registros estructurados de alergias y antecedentes familiares."
"subtitle": "Tus antecedentes, tu estilo de vida, tus datos de emergencia y lo que la IA puede leer."
},
"environment": {
"title": "Ubicación y clima",
@@ -6542,30 +6527,32 @@
"fitbitViewData": "Ver tu sueño",
"googleHealthViewData": "Ver tus pasos",
"apiTokens": "Tokens de API",
"apiTokensDescription": "Revisa y revoca tokens de API para el registro externo de tomas de medicación.",
"apiTokensDescription": "Revisa y revoca los tokens de mediciones, entrenamientos, documentos y tomas de medicación.",
"measurementsToken": {
"title": "Tokens de mediciones",
"description": "Crea un token para que una báscula, un reloj o tu domótica añadan mediciones.",
"detail": "El token se muestra una sola vez.",
"scopeNote": "Solo puede añadir mediciones nuevas a tu propio historial: no puede leer tus datos, ni modificar o borrar nada, ni crear otro token. Las mediciones se marcan como procedentes de un dispositivo externo, así las distingues más tarde, y puedes corregirlas igualmente.",
"createFailed": "No se pudo crear el token. Inténtalo de nuevo."
"createFailed": "No se pudo crear el token. Inténtalo de nuevo.",
"namePlaceholder": "Nombre, p. ej. Home Assistant"
},
"workoutsToken": {
"title": "Tokens de entrenamientos",
"description": "Crea un token para que un puente de tu reloj o un script de sincronización añadan tus entrenamientos.",
"detail": "El token se muestra una sola vez.",
"scopeNote": "Solo puede añadir entrenamientos nuevos a tu propio historial. No puede leer tus datos, ni modificar o borrar nada, ni crear otro token. Los entrenamientos que añade se marcan como procedentes de un dispositivo externo, y si tu teléfono registró la misma sesión, ves la copia del teléfono.",
"createFailed": "No se pudo crear el token. Inténtalo de nuevo."
"createFailed": "No se pudo crear el token. Inténtalo de nuevo.",
"namePlaceholder": "Nombre, p. ej. Garmin"
},
"documentsToken": {
"title": "Tokens de documentos",
"description": "Crea un token para que Paperless-ngx, Papra o el script de importación añadan documentos a tu archivo.",
"detail": "El token se muestra una sola vez.",
"scopeNote": "Solo puede añadir documentos nuevos a tu propio archivo. No puede listar, abrir, descargar, modificar ni borrar nada, ni crear otro token. Lo único que recibe es si un archivo se guardó, no cómo lo archivaste.",
"createFailed": "No se pudo crear el token. Inténtalo de nuevo."
"createFailed": "No se pudo crear el token. Inténtalo de nuevo.",
"namePlaceholder": "Nombre, p. ej. Paperless-ngx"
},
"tokenMintMovedDescription": "Los tokens se emiten donde se aplican: arriba para las mediciones y los documentos, o en el endpoint de API de un medicamento para ese medicamento concreto.",
"tokenNamePlaceholder": "Nombre del token (p. ej., Atajo de iPhone)",
"tokenMintMovedDescription": "Los tokens se emiten donde se aplican: arriba para las mediciones, los entrenamientos y los documentos, o en el endpoint de API de un medicamento para ese medicamento concreto.",
"tokenCreated": "Token creado — ¡cópialo ahora! No se mostrará de nuevo.",
"tokenCopy": "Copiar token",
"tokenCopied": "Token copiado al portapapeles",
@@ -6757,7 +6744,8 @@
"statusNeedsReselect": "Elegir contenido de nuevo",
"reselectReason": "Este enlace mostraba un alcance que nadie eligió, por eso se cerró. Crea uno nuevo y elige su contenido.",
"reselectAction": "Crear un reemplazo",
"createDetail": "El token se muestra una sola vez al crearlo — guárdalo entonces; no se puede recuperar."
"createDetail": "El token se muestra una sola vez al crearlo — guárdalo entonces; no se puede recuperar.",
"activeDescription": "Los enlaces que has compartido, cuándo caducan y cuántas veces se abrieron."
},
"genderOther": "Otro",
"nightscout": "Nightscout",
@@ -6983,12 +6971,11 @@
},
"stored": {
"title": "Qué se almacena",
"measurements": "Mediciones (peso, presión arterial, pulso, glucosa, sueño, composición corporal)",
"medications": "Medicamentos e historial de tomas",
"moodLabs": "Entradas de estado de ánimo y resultados de laboratorio",
"coach": "Conversaciones con el asistente",
"measurements": "Mediciones: peso, presión arterial, pulso y cualquier otro valor que registres tú o un dispositivo",
"integrations": "Tokens de integración (cifrados)",
"security": "Actividad de la cuenta y de seguridad (sesiones, registro de inicio de sesión)"
"security": "Actividad de la cuenta y de seguridad (sesiones, registro de inicio de sesión)",
"profile": "Tu perfil, tus antecedentes médicos y los datos del historial de salud",
"modules": "Registros de cada módulo: {list}"
},
"retention": {
"title": "Retención",
@@ -7019,8 +7006,6 @@
"mcp": {
"enableTitle": "Endpoint MCP remoto",
"enableDescription": "Permite que un asistente compatible con MCP (Claude.ai, ChatGPT) lea tus datos de salud mediante un token limitado.",
"enabled": "Activado",
"disabled": "Desactivado",
"enableToggleLabel": "Activar el endpoint MCP",
"endpointLabel": "URL del conector",
"connectDescription": "Pega esta URL en la configuración del conector personalizado de tu asistente y autoriza con el token de abajo o inicia sesión cuando se te solicite.",
@@ -7039,7 +7024,8 @@
"connectionRevokeDescription": "Esto finaliza el acceso del asistente de inmediato e impide su renovación. Será necesario autorizarlo de nuevo para volver a conectarlo.",
"connectionRevokeFailed": "No se pudo desconectar el asistente.",
"tokensDetail": "El token se muestra una sola vez.",
"connectionsDetail": "Revocar una conexión finaliza su acceso de inmediato, incluida su capacidad de renovación."
"connectionsDetail": "Revocar una conexión finaliza su acceso de inmediato, incluida su capacidad de renovación.",
"tokenNamePlaceholder": "Nombre, p. ej. Claude Desktop"
},
"strava": "Strava",
"stravaDescription": "Conecta tu cuenta de Strava para sincronizar tus actividades con tu historial de entrenamientos.",
@@ -7169,7 +7155,12 @@
"signInAgain": "Vuelve a iniciar sesión y inténtalo de nuevo. Tu cuenta no tiene contraseña ni llave de acceso que confirmar aquí.",
"failed": "No ha funcionado. Revisa lo que has introducido e inténtalo de nuevo."
},
"passwordTitle": "Contraseña"
"passwordTitle": "Contraseña",
"tokenNameLabel": "Nombre del token",
"displayCard": {
"title": "Visualización",
"description": "Idioma, unidades y formatos; se guardan en cuanto los cambias."
}
},
"admin": {
"title": "Administration",
@@ -7377,8 +7368,8 @@
"coachFollowsSwitch": "Sigue el interruptor del Coach en la sección Coach."
},
"invites": {
"title": "Invitaciones",
"description": "Enlaces de invitación para el registro: funcionan incluso con el registro cerrado.",
"title": "Enlaces de invitación",
"description": "Cada enlace permite un registro aunque el registro esté cerrado.",
"create": "Crear invitación",
"createTitle": "Crear invitación",
"createDescription": "El enlace permite registrarse incluso con el registro cerrado.",
@@ -7424,8 +7415,8 @@
"revokedToast": "Invitación revocada.",
"revokeError": "No se pudo revocar la invitación."
},
"userManagement": "Gestión de usuarios",
"userManagementDescription": "Gestiona las cuentas: roles, restablecimientos de contraseña y cierres de sesión forzados.",
"userManagement": "Cuentas",
"userManagementDescription": "Roles, restablecimientos de contraseña y cierres de sesión forzados de cada cuenta.",
"userName": "Nombre",
"userEmail": "Correo electrónico",
"userRole": "Rol",
@@ -7881,7 +7872,12 @@
"backupsNone": "Ninguna copia ha registrado todavía las claves que necesita.",
"backupsStillNeed": "Todas las filas están rotadas, pero las copias aún necesitan {keys}",
"backupsUnrecordedBadge": "Todas las filas están rotadas, pero copias antiguas pueden necesitar aún una clave retirada",
"backupsDetail": "La rotación vuelve a cifrar cada fila, pero una copia conserva la clave con la que se escribió su contenido, y restaurarla necesita esa clave. Mantén una clave retirada en ENCRYPTION_KEYS hasta que no aparezca abajo."
"backupsDetail": "La rotación vuelve a cifrar cada fila, pero una copia conserva la clave con la que se escribió su contenido, y restaurarla necesita esa clave. Mantén una clave retirada en ENCRYPTION_KEYS hasta que no aparezca abajo.",
"columnsShow": "Mostrar la tabla ({count} con filas)",
"columnsHide": "Ocultar la tabla",
"columnsShowAll": "Mostrar las {count} columnas",
"columnsShowWithRows": "Solo columnas con filas",
"columnsNoneWithRows": "Ninguna columna cifrada contiene filas todavía."
},
"maintenance": {
"title": "Mantenimiento de la tabla de mediciones",
+40 -44
View File
@@ -5310,12 +5310,10 @@
"sections": {
"account": {
"title": "Compte",
"description": "Profil, mot de passe, clés d’accès et votre visite guidée.",
"subtitle": "Votre profil, votre identité et vos identifiants."
},
"integrations": {
"title": "Intégrations",
"description": "Services connectés, canaux de diffusion et priorité des sources au même endroit.",
"subtitle": "Connectez et gérez vos sources de données et appareils.",
"connectionsHeading": "Connexions",
"channelsHeading": "Canaux de diffusion",
@@ -5324,16 +5322,14 @@
},
"notifications": {
"title": "Notifications",
"description": "Tous les rappels au même endroit — quoi et quand, et où c'est envoyé.",
"subtitle": "Choisissez les rappels que vous recevez et où ils sont envoyés.",
"remindersHeading": "Rappels",
"matrixLink": "Notifications par canal",
"channelsLink": "Canaux de diffusion"
},
"layout": {
"title": "Apparence",
"description": "Choisissez l'apparence et l'agencement du tableau de bord, des analyses et de vos modules de suivi.",
"backToHub": "Retour à Apparence",
"title": "Disposition",
"backToHub": "Retour à Disposition",
"dashboard": {
"title": "Tableau de bord",
"description": "Disposition et ordre des tuiles."
@@ -5342,7 +5338,7 @@
"title": "Analyses",
"description": "Ordre de la vue d'ensemble et des onglets de navigation."
},
"subtitle": "Personnalisez l'apparence du tableau de bord, des analyses et des modules de suivi.",
"subtitle": "Choisissez ce qu'affichent le tableau de bord, les analyses et les modules de suivi, et dans quel ordre.",
"medications": {
"title": "Médicaments",
"description": "Vue en liste et ordre manuel."
@@ -5373,12 +5369,10 @@
},
"thresholds": {
"title": "Objectifs",
"description": "Par métrique : votre propre plage cible pour des valeurs saines.",
"subtitle": "Définissez vos plages cibles personnelles par mesure."
},
"sources": {
"title": "Priorité des sources",
"description": "Choisissez quelle source prévaut quand plusieurs enregistrent la même métrique le même jour.",
"integrationsHint": "Connectez ou déconnectez des sources de données sur la page Intégrations.",
"integrationsHintLink": "Ouvrir les intégrations",
"cardTitle": "Classement par métrique",
@@ -5460,26 +5454,23 @@
"moduleHintLink": "Gérer les modules",
"detail": "Lecture seule : l'app source reste propriétaire des valeurs."
},
"helpDetail": "Les autres restent enregistrées pour la piste d’audit."
"helpDetail": "Les autres restent enregistrées pour la piste d’audit. Seules les sources connectées ou ayant fourni des données sont listées.",
"noneInUse": "Aucune source à classer pour l'instant. Connectez-en une dans Intégrations ou saisissez une valeur."
},
"ai": {
"title": "Fournisseur d'IA",
"description": "Fournisseur, modèle et clé API pour les analyses, ainsi que votre contexte « à propos de moi ».",
"subtitle": "Choisissez votre fournisseur d'IA et utilisez votre propre clé."
},
"api": {
"title": "API & Tokens",
"description": "Jetons Bearer pour vos propres scripts et applis — enregistrez des mesures ou des prises de médicaments depuis n’importe où.",
"title": "API et jetons",
"subtitle": "Gérez les points d'accès API et les jetons d'accès."
},
"advanced": {
"title": "Avancé",
"description": "La sortie : effacer définitivement toutes les données de santé ou fermer le compte.",
"subtitle": "Réinitialisation des données et suppression du compte."
},
"export": {
"title": "Export et import",
"description": "Téléchargez vos données en CSV ou en sauvegarde JSON complète, ou importez depuis Apple Santé, JSON ou CSV. Le compte rendu médical se trouve désormais dans Dossier de santé.",
"otherOptionsHeading": "Autres options d'export",
"downloadFailed": "Échec du téléchargement de l'export. Veuillez réessayer.",
"actions": {
@@ -5724,12 +5715,10 @@
},
"about": {
"title": "À propos de cette app",
"description": "Version, licence, liens.",
"subtitle": "Version, licence et liens du projet."
},
"sharing": {
"title": "Partage",
"description": "Créez un lien en lecture seule pour partager votre dossier de santé avec un professionnel de santé.",
"subtitle": "Partagez un accès en lecture seule à vos données."
},
"insights": {
@@ -5764,12 +5753,10 @@
},
"gesundheitsakte": {
"title": "Dossier médical",
"description": "Exportez votre dossier médical complet au format PDF, FHIR R4 ou en paquet ZIP combiné.",
"subtitle": "Exportez un dossier de santé complet, prêt pour le médecin."
},
"coach": {
"title": "Coach",
"description": "Activez le Coach, le ton et la portée, les souvenirs enregistrés.",
"subtitle": "Réglez le Coach, ses suggestions proactives et sa mémoire."
},
"security": {
@@ -5778,8 +5765,7 @@
},
"privacy": {
"title": "Données et confidentialité",
"subtitle": "Vos données, leur conservation, le chiffrement et la sécurité du compte au même endroit.",
"description": "Export, suppression, conservation, état du chiffrement, sessions et activité de sécurité."
"subtitle": "Vos données, leur conservation, le chiffrement et la sécurité du compte au même endroit."
},
"mcp": {
"title": "Connecteur MCP",
@@ -5787,8 +5773,7 @@
},
"anamnesis": {
"title": "Antécédents médicaux",
"subtitle": "Vos antécédents, votre mode de vie, vos informations d’urgence et ce que l’IA peut lire.",
"description": "Dossiers structurés des allergies et des antécédents familiaux."
"subtitle": "Vos antécédents, votre mode de vie, vos informations d’urgence et ce que l’IA peut lire."
},
"environment": {
"title": "Localisation et météo",
@@ -6542,30 +6527,32 @@
"fitbitViewData": "Voir votre sommeil",
"googleHealthViewData": "Voir vos pas",
"apiTokens": "Jetons API",
"apiTokensDescription": "Consultez et révoquez les jetons API pour l’enregistrement externe des prises de médicaments.",
"apiTokensDescription": "Consultez et révoquez les jetons des mesures, entraînements, documents et prises de médicaments.",
"measurementsToken": {
"title": "Jetons de mesures",
"description": "Créez un jeton pour qu'une balance, une montre ou votre domotique ajoutent des mesures.",
"detail": "Le jeton n'est affiché qu'une fois.",
"scopeNote": "Il peut uniquement ajouter de nouvelles mesures à votre propre dossier : il ne peut pas lire vos données, ni modifier ou supprimer quoi que ce soit, ni créer un autre jeton. Les mesures sont signalées comme provenant d'un appareil externe, ce qui vous permet de les reconnaître plus tard, et vous pouvez toujours les corriger.",
"createFailed": "Le jeton n'a pas pu être créé. Veuillez réessayer."
"createFailed": "Le jeton n'a pas pu être créé. Veuillez réessayer.",
"namePlaceholder": "Nom, p. ex. Home Assistant"
},
"workoutsToken": {
"title": "Jetons d'entraînements",
"description": "Créez un jeton pour qu'une passerelle de montre ou un script de synchronisation ajoutent vos entraînements.",
"detail": "Le jeton n'est affiché qu'une fois.",
"scopeNote": "Il peut uniquement ajouter de nouveaux entraînements à votre propre dossier. Il ne peut pas lire vos données, ni modifier ou supprimer quoi que ce soit, ni créer un autre jeton. Les entraînements qu'il ajoute sont signalés comme provenant d'un appareil externe, et si votre téléphone a enregistré la même séance, c'est la copie du téléphone que vous voyez.",
"createFailed": "Le jeton n'a pas pu être créé. Veuillez réessayer."
"createFailed": "Le jeton n'a pas pu être créé. Veuillez réessayer.",
"namePlaceholder": "Nom, p. ex. Garmin"
},
"documentsToken": {
"title": "Jetons de documents",
"description": "Créez un jeton pour que Paperless-ngx, Papra ou le script d'import ajoutent des documents à votre coffre.",
"detail": "Le jeton n'est affiché qu'une fois.",
"scopeNote": "Il peut uniquement ajouter de nouveaux documents à votre propre coffre. Il ne peut rien lister, ouvrir, télécharger, modifier ou supprimer, ni créer un autre jeton. Il apprend seulement si un fichier a été enregistré, pas comment vous l'avez classé.",
"createFailed": "Le jeton n'a pas pu être créé. Veuillez réessayer."
"createFailed": "Le jeton n'a pas pu être créé. Veuillez réessayer.",
"namePlaceholder": "Nom, p. ex. Paperless-ngx"
},
"tokenMintMovedDescription": "Les jetons sont émis là où ils s'appliquent : ci-dessus pour les mesures et les documents, ou dans le point de terminaison API d'un médicament pour ce seul médicament.",
"tokenNamePlaceholder": "Nom du jeton (par exemple Raccourci iPhone)",
"tokenMintMovedDescription": "Les jetons sont émis là où ils s'appliquent : ci-dessus pour les mesures, les entraînements et les documents, ou dans le point de terminaison API d'un médicament pour ce seul médicament.",
"tokenCreated": "Jeton créé — copiez-le maintenant ! Il ne sera plus affiché.",
"tokenCopy": "Copier le jeton",
"tokenCopied": "Jeton copié dans le presse-papiers",
@@ -6757,7 +6744,8 @@
"statusNeedsReselect": "Choisir à nouveau le contenu",
"reselectReason": "Ce lien diffusait un périmètre que personne n'avait choisi ; il a donc été fermé. Créez-en un nouveau et choisissez son contenu.",
"reselectAction": "Créer un remplacement",
"createDetail": "Le jeton n'est affiché qu'une fois à la création — conservez-le alors ; il ne peut pas être récupéré."
"createDetail": "Le jeton n'est affiché qu'une fois à la création — conservez-le alors ; il ne peut pas être récupéré.",
"activeDescription": "Les liens que vous avez partagés, leur expiration et le nombre d'ouvertures."
},
"genderOther": "Autre",
"nightscout": "Nightscout",
@@ -6983,12 +6971,11 @@
},
"stored": {
"title": "Ce qui est stocké",
"measurements": "Mesures (poids, tension, pouls, glycémie, sommeil, composition corporelle)",
"medications": "Médicaments et historique des prises",
"moodLabs": "Entrées d'humeur et résultats de laboratoire",
"coach": "Conversations avec le coach",
"measurements": "Mesures : poids, tension, pouls et toute autre valeur enregistrée par vous ou un appareil",
"integrations": "Jetons d'intégration (chiffrés)",
"security": "Activité du compte et de sécurité (sessions, journal de connexion)"
"security": "Activité du compte et de sécurité (sessions, journal de connexion)",
"profile": "Votre profil, vos antécédents médicaux et les informations du dossier de santé",
"modules": "Données de chaque module : {list}"
},
"retention": {
"title": "Conservation",
@@ -7019,8 +7006,6 @@
"mcp": {
"enableTitle": "Point de terminaison MCP distant",
"enableDescription": "Autorisez un assistant compatible MCP (Claude.ai, ChatGPT) à lire vos données de santé via un jeton restreint.",
"enabled": "Activé",
"disabled": "Désactivé",
"enableToggleLabel": "Activer le point de terminaison MCP",
"endpointLabel": "URL du connecteur",
"connectDescription": "Collez cette URL dans la configuration du connecteur personnalisé de votre assistant, puis autorisez avec le jeton ci-dessous ou connectez-vous lorsque vous y êtes invité.",
@@ -7039,7 +7024,8 @@
"connectionRevokeDescription": "Cela met fin immédiatement à l'accès de l'assistant et l'empêche de se renouveler. Une nouvelle autorisation sera nécessaire pour le reconnecter.",
"connectionRevokeFailed": "Impossible de déconnecter l'assistant.",
"tokensDetail": "Le jeton n'est affiché qu'une seule fois.",
"connectionsDetail": "Révoquer une connexion met fin à son accès immédiatement, y compris sa capacité à se renouveler."
"connectionsDetail": "Révoquer une connexion met fin à son accès immédiatement, y compris sa capacité à se renouveler.",
"tokenNamePlaceholder": "Nom, p. ex. Claude Desktop"
},
"strava": "Strava",
"stravaDescription": "Connecte ton compte Strava pour synchroniser tes activités avec ton historique d'entraînement.",
@@ -7169,7 +7155,12 @@
"signInAgain": "Reconnectez-vous, puis réessayez. Votre compte n'a ni mot de passe ni clé d'accès à confirmer ici.",
"failed": "Cela n'a pas fonctionné. Vérifiez votre saisie et réessayez."
},
"passwordTitle": "Mot de passe"
"passwordTitle": "Mot de passe",
"tokenNameLabel": "Nom du jeton",
"displayCard": {
"title": "Affichage",
"description": "Langue, unités et formats, enregistrés dès que vous les modifiez."
}
},
"admin": {
"title": "Administration",
@@ -7377,8 +7368,8 @@
"coachFollowsSwitch": "Suit l'interrupteur Coach de la section Coach."
},
"invites": {
"title": "Invitations",
"description": "Liens d'invitation pour l'inscription — ils fonctionnent même lorsque les inscriptions sont fermées.",
"title": "Liens d'invitation",
"description": "Chaque lien permet une inscription même lorsque les inscriptions sont fermées.",
"create": "Créer une invitation",
"createTitle": "Créer une invitation",
"createDescription": "Le lien permet une inscription même lorsque les inscriptions sont fermées.",
@@ -7424,8 +7415,8 @@
"revokedToast": "Invitation révoquée.",
"revokeError": "Impossible de révoquer l'invitation."
},
"userManagement": "Gestion des utilisateurs",
"userManagementDescription": "Gérez les comptes : rôles, réinitialisations de mot de passe et déconnexions forcées.",
"userManagement": "Comptes",
"userManagementDescription": "Rôles, réinitialisations de mot de passe et déconnexions forcées pour chaque compte.",
"userName": "Nom",
"userEmail": "E-mail",
"userRole": "Rôle",
@@ -7881,7 +7872,12 @@
"backupsNone": "Aucune sauvegarde n'a encore enregistré les clés dont elle a besoin.",
"backupsStillNeed": "Toutes les lignes sont renouvelées, mais des sauvegardes ont encore besoin de {keys}",
"backupsUnrecordedBadge": "Toutes les lignes sont renouvelées, mais d'anciennes sauvegardes peuvent encore avoir besoin d'une clé retirée",
"backupsDetail": "La rotation rechiffre chaque ligne, mais une sauvegarde garde la clé sous laquelle son contenu a été écrit, et sa restauration en a besoin. Gardez une clé retirée dans ENCRYPTION_KEYS tant qu'elle figure ci-dessous."
"backupsDetail": "La rotation rechiffre chaque ligne, mais une sauvegarde garde la clé sous laquelle son contenu a été écrit, et sa restauration en a besoin. Gardez une clé retirée dans ENCRYPTION_KEYS tant qu'elle figure ci-dessous.",
"columnsShow": "Afficher le tableau ({count} avec des lignes)",
"columnsHide": "Masquer le tableau",
"columnsShowAll": "Afficher les {count} colonnes",
"columnsShowWithRows": "Seulement les colonnes avec des lignes",
"columnsNoneWithRows": "Aucune colonne chiffrée ne contient encore de lignes."
},
"maintenance": {
"title": "Maintenance de la table des mesures",
+40 -44
View File
@@ -5310,12 +5310,10 @@
"sections": {
"account": {
"title": "Account",
"description": "Profilo, password, passkey e il tour di benvenuto.",
"subtitle": "Il tuo profilo, la tua identità e i dati di accesso."
},
"integrations": {
"title": "Integrazioni",
"description": "Servizi collegati, canali di consegna e priorità delle fonti in un unico posto.",
"subtitle": "Collega e gestisci le tue fonti di dati e i dispositivi.",
"connectionsHeading": "Connessioni",
"channelsHeading": "Canali di recapito",
@@ -5324,16 +5322,14 @@
},
"notifications": {
"title": "Notifiche",
"description": "Tutti i promemoria in un unico posto — cosa e quando, e dove vengono recapitati.",
"subtitle": "Scegli quali promemoria ricevere e dove vengono recapitati.",
"remindersHeading": "Promemoria",
"matrixLink": "Notifiche per canale",
"channelsLink": "Canali di recapito"
},
"layout": {
"title": "Aspetto",
"description": "Scegli l'aspetto e la disposizione di dashboard, approfondimenti e dei tuoi moduli di monitoraggio.",
"backToHub": "Torna ad Aspetto",
"title": "Layout",
"backToHub": "Torna a Layout",
"dashboard": {
"title": "Cruscotto",
"description": "Layout e ordine dei riquadri."
@@ -5342,7 +5338,7 @@
"title": "Approfondimenti",
"description": "Ordine della panoramica e delle schede di navigazione."
},
"subtitle": "Personalizza l'aspetto di dashboard, approfondimenti e moduli di monitoraggio.",
"subtitle": "Scegli cosa mostrano dashboard, approfondimenti e moduli di monitoraggio, e in quale ordine.",
"medications": {
"title": "Farmaci",
"description": "Vista elenco e ordine manuale."
@@ -5373,12 +5369,10 @@
},
"thresholds": {
"title": "Obiettivi",
"description": "Per metrica: il tuo intervallo target per valori sani.",
"subtitle": "Imposta i tuoi intervalli target personali per metrica."
},
"sources": {
"title": "Priorità delle fonti",
"description": "Scegli quale fonte prevale quando più di una registra la stessa metrica lo stesso giorno.",
"integrationsHint": "Collega o scollega le fonti dati nella pagina Integrazioni.",
"integrationsHintLink": "Apri le integrazioni",
"cardTitle": "Ordine per metrica",
@@ -5460,26 +5454,23 @@
"moduleHintLink": "Gestisci moduli",
"detail": "Sola lettura: i valori appartengono all'app di origine."
},
"helpDetail": "Le altre restano memorizzate per la traccia di controllo."
"helpDetail": "Le altre restano memorizzate per la traccia di controllo. Sono elencate solo le fonti collegate o con dati.",
"noneInUse": "Ancora nessuna fonte da ordinare. Collegane una in Integrazioni o registra un valore."
},
"ai": {
"title": "Provider IA",
"description": "Provider, modello e chiave API per gli approfondimenti, oltre al tuo contesto «su di me».",
"subtitle": "Scegli il tuo provider IA e usa la tua chiave."
},
"api": {
"title": "API & Tokens",
"description": "Token Bearer per i tuoi script e le tue app — registra misurazioni o assunzioni di farmaci da ovunque.",
"title": "API e token",
"subtitle": "Gestisci gli endpoint API e i token di accesso."
},
"advanced": {
"title": "Avanzate",
"description": "La via d'uscita: cancella per sempre tutti i dati di salute o chiudi l'account.",
"subtitle": "Ripristino dati ed eliminazione account."
},
"export": {
"title": "Esporta e importa",
"description": "Scarica i tuoi dati come CSV o backup JSON completo, oppure importa da Apple Salute, JSON o CSV. Il referto medico ora si trova in Cartella sanitaria.",
"otherOptionsHeading": "Altre opzioni di esportazione",
"downloadFailed": "Impossibile scaricare l'esportazione. Riprova.",
"actions": {
@@ -5724,12 +5715,10 @@
},
"about": {
"title": "Informazioni su questa app",
"description": "Versione, licenza, link.",
"subtitle": "Versione, licenza e link al progetto."
},
"sharing": {
"title": "Condivisione",
"description": "Crea un link di sola lettura per condividere la tua cartella clinica con un medico.",
"subtitle": "Condividi l'accesso in sola lettura ai tuoi dati."
},
"insights": {
@@ -5764,12 +5753,10 @@
},
"gesundheitsakte": {
"title": "Cartella clinica",
"description": "Esporta la tua cartella clinica completa in PDF, FHIR R4 o in un pacchetto ZIP combinato.",
"subtitle": "Esporta una cartella clinica completa, pronta per il medico."
},
"coach": {
"title": "Coach",
"description": "Attiva il Coach, tono e ambito, ricordi salvati.",
"subtitle": "Regola il Coach, i suoi suggerimenti proattivi e la sua memoria."
},
"security": {
@@ -5778,8 +5765,7 @@
},
"privacy": {
"title": "Dati e privacy",
"subtitle": "I tuoi dati, la conservazione, la cifratura e la sicurezza dell'account in un unico posto.",
"description": "Esportazione, eliminazione, conservazione, stato della cifratura, sessioni e attività di sicurezza."
"subtitle": "I tuoi dati, la conservazione, la cifratura e la sicurezza dell'account in un unico posto."
},
"mcp": {
"title": "Connettore MCP",
@@ -5787,8 +5773,7 @@
},
"anamnesis": {
"title": "Anamnesi",
"subtitle": "La tua anamnesi, lo stile di vita, i dati di emergenza e ciò che l’IA può leggere.",
"description": "Record strutturati di allergie e anamnesi familiare."
"subtitle": "La tua anamnesi, lo stile di vita, i dati di emergenza e ciò che l’IA può leggere."
},
"environment": {
"title": "Posizione e meteo",
@@ -6542,30 +6527,32 @@
"fitbitViewData": "Vedi il tuo sonno",
"googleHealthViewData": "Vedi i tuoi passi",
"apiTokens": "Token API",
"apiTokensDescription": "Rivedi e revoca i token API per la registrazione esterna delle assunzioni di farmaci.",
"apiTokensDescription": "Rivedi e revoca i token per misurazioni, allenamenti, documenti e assunzioni di farmaci.",
"measurementsToken": {
"title": "Token per misurazioni",
"description": "Crea un token perché una bilancia, un orologio o la tua domotica aggiungano misurazioni.",
"detail": "Il token viene mostrato una sola volta.",
"scopeNote": "Può soltanto aggiungere nuove misurazioni alla tua cartella: non può leggere i tuoi dati, modificare o eliminare nulla, né creare un altro token. Le misurazioni vengono contrassegnate come provenienti da un dispositivo esterno, così le riconosci in seguito, e puoi comunque correggerle.",
"createFailed": "Non è stato possibile creare il token. Riprova."
"createFailed": "Non è stato possibile creare il token. Riprova.",
"namePlaceholder": "Nome, ad es. Home Assistant"
},
"workoutsToken": {
"title": "Token per allenamenti",
"description": "Crea un token perché un ponte dell'orologio o uno script di sincronizzazione aggiungano i tuoi allenamenti.",
"detail": "Il token viene mostrato una sola volta.",
"scopeNote": "Può soltanto aggiungere nuovi allenamenti alla tua cartella. Non può leggere i tuoi dati, modificare o eliminare nulla, né creare un altro token. Gli allenamenti che aggiunge vengono contrassegnati come provenienti da un dispositivo esterno e, se il telefono ha registrato la stessa sessione, vedi la copia del telefono.",
"createFailed": "Non è stato possibile creare il token. Riprova."
"createFailed": "Non è stato possibile creare il token. Riprova.",
"namePlaceholder": "Nome, ad es. Garmin"
},
"documentsToken": {
"title": "Token per documenti",
"description": "Crea un token perché Paperless-ngx, Papra o lo script di importazione aggiungano documenti al tuo archivio.",
"detail": "Il token viene mostrato una sola volta.",
"scopeNote": "Può soltanto aggiungere nuovi documenti al tuo archivio. Non può elencare, aprire, scaricare, modificare o eliminare nulla, né creare un altro token. Riceve solo l'informazione se un file è stato salvato, non come l'hai archiviato.",
"createFailed": "Non è stato possibile creare il token. Riprova."
"createFailed": "Non è stato possibile creare il token. Riprova.",
"namePlaceholder": "Nome, ad es. Paperless-ngx"
},
"tokenMintMovedDescription": "I token vengono emessi dove si applicano: qui sopra per le misurazioni e i documenti, oppure nell'endpoint API di un farmaco per quel singolo farmaco.",
"tokenNamePlaceholder": "Nome del token (ad es. Comando rapido iPhone)",
"tokenMintMovedDescription": "I token vengono emessi dove si applicano: qui sopra per le misurazioni, gli allenamenti e i documenti, oppure nell'endpoint API di un farmaco per quel singolo farmaco.",
"tokenCreated": "Token creato — copialo ora! Non verrà mostrato di nuovo.",
"tokenCopy": "Copia token",
"tokenCopied": "Token copiato negli appunti",
@@ -6757,7 +6744,8 @@
"statusNeedsReselect": "Scegli di nuovo il contenuto",
"reselectReason": "Questo link mostrava un ambito che nessuno aveva scelto, perciò è stato chiuso. Creane uno nuovo e scegli cosa deve contenere.",
"reselectAction": "Crea un sostituto",
"createDetail": "Il token viene mostrato una sola volta alla creazione — salvalo subito; non può essere recuperato."
"createDetail": "Il token viene mostrato una sola volta alla creazione — salvalo subito; non può essere recuperato.",
"activeDescription": "I link che hai condiviso, quando scadono e quante volte sono stati aperti."
},
"genderOther": "Altro",
"nightscout": "Nightscout",
@@ -6983,12 +6971,11 @@
},
"stored": {
"title": "Cosa viene memorizzato",
"measurements": "Misurazioni (peso, pressione, polso, glicemia, sonno, composizione corporea)",
"medications": "Farmaci e cronologia delle assunzioni",
"moodLabs": "Voci dell'umore e referti di laboratorio",
"coach": "Conversazioni con il coach",
"measurements": "Misurazioni: peso, pressione, battito e ogni altro valore registrato da te o da un dispositivo",
"integrations": "Token di integrazione (cifrati)",
"security": "Attività dell'account e di sicurezza (sessioni, registro degli accessi)"
"security": "Attività dell'account e di sicurezza (sessioni, registro degli accessi)",
"profile": "Il tuo profilo, l'anamnesi e i dati della cartella sanitaria",
"modules": "Dati di ogni modulo: {list}"
},
"retention": {
"title": "Conservazione",
@@ -7019,8 +7006,6 @@
"mcp": {
"enableTitle": "Endpoint MCP remoto",
"enableDescription": "Consenti a un assistente compatibile con MCP (Claude.ai, ChatGPT) di leggere i tuoi dati sulla salute tramite un token limitato.",
"enabled": "Attivo",
"disabled": "Inattivo",
"enableToggleLabel": "Attiva l'endpoint MCP",
"endpointLabel": "URL del connettore",
"connectDescription": "Incolla questo URL nella configurazione del connettore personalizzato del tuo assistente e autorizza con il token qui sotto o accedi quando richiesto.",
@@ -7039,7 +7024,8 @@
"connectionRevokeDescription": "Questo termina immediatamente l'accesso dell'assistente e ne impedisce il rinnovo. Per riconnetterlo sarà necessaria una nuova autorizzazione.",
"connectionRevokeFailed": "Impossibile disconnettere l'assistente.",
"tokensDetail": "Il token viene mostrato una sola volta.",
"connectionsDetail": "Revocare una connessione ne termina subito l'accesso, inclusa la capacità di rinnovarsi."
"connectionsDetail": "Revocare una connessione ne termina subito l'accesso, inclusa la capacità di rinnovarsi.",
"tokenNamePlaceholder": "Nome, ad es. Claude Desktop"
},
"strava": "Strava",
"stravaDescription": "Collega il tuo account Strava per sincronizzare le tue attività con la cronologia degli allenamenti.",
@@ -7169,7 +7155,12 @@
"signInAgain": "Accedi di nuovo, poi riprova. Il tuo account non ha una password o una passkey da confermare qui.",
"failed": "Non ha funzionato. Controlla quanto inserito e riprova."
},
"passwordTitle": "Password"
"passwordTitle": "Password",
"tokenNameLabel": "Nome del token",
"displayCard": {
"title": "Visualizzazione",
"description": "Lingua, unità e formati, salvati appena li modifichi."
}
},
"admin": {
"title": "Administration",
@@ -7377,8 +7368,8 @@
"coachFollowsSwitch": "Segue l'interruttore Coach nella sezione Coach."
},
"invites": {
"title": "Inviti",
"description": "Link di invito per la registrazione: funzionano anche quando la registrazione è chiusa.",
"title": "Link di invito",
"description": "Ogni link consente una registrazione anche quando la registrazione è chiusa.",
"create": "Crea invito",
"createTitle": "Crea invito",
"createDescription": "Il link consente una registrazione anche quando la registrazione è chiusa.",
@@ -7424,8 +7415,8 @@
"revokedToast": "Invito revocato.",
"revokeError": "Impossibile revocare l'invito."
},
"userManagement": "Gestione utenti",
"userManagementDescription": "Gestisci gli account: ruoli, reset delle password e disconnessioni forzate.",
"userManagement": "Account",
"userManagementDescription": "Ruoli, reset delle password e disconnessioni forzate per ogni account.",
"userName": "Nome",
"userEmail": "E-mail",
"userRole": "Ruolo",
@@ -7881,7 +7872,12 @@
"backupsNone": "Nessun backup ha ancora registrato le chiavi di cui ha bisogno.",
"backupsStillNeed": "Tutte le righe sono ruotate, ma i backup hanno ancora bisogno di {keys}",
"backupsUnrecordedBadge": "Tutte le righe sono ruotate, ma backup più vecchi potrebbero ancora richiedere una chiave ritirata",
"backupsDetail": "La rotazione cifra di nuovo ogni riga, ma un backup conserva la chiave con cui è stato scritto il suo contenuto, e ripristinarlo richiede quella chiave. Tieni una chiave ritirata in ENCRYPTION_KEYS finché compare qui sotto."
"backupsDetail": "La rotazione cifra di nuovo ogni riga, ma un backup conserva la chiave con cui è stato scritto il suo contenuto, e ripristinarlo richiede quella chiave. Tieni una chiave ritirata in ENCRYPTION_KEYS finché compare qui sotto.",
"columnsShow": "Mostra la tabella ({count} con righe)",
"columnsHide": "Nascondi la tabella",
"columnsShowAll": "Mostra tutte le {count} colonne",
"columnsShowWithRows": "Solo colonne con righe",
"columnsNoneWithRows": "Nessuna colonna cifrata contiene ancora righe."
},
"maintenance": {
"title": "Manutenzione della tabella delle misurazioni",
+37 -41
View File
@@ -5310,12 +5310,10 @@
"sections": {
"account": {
"title": "계정",
"description": "프로필, 비밀번호, 패스키, 시작 설정 둘러보기.",
"subtitle": "프로필과 신원, 로그인 정보예요."
},
"integrations": {
"title": "연동",
"description": "연결된 서비스, 전송 채널, 소스 우선순위를 한곳에서.",
"subtitle": "데이터 소스와 기기를 연결하고 관리해요.",
"connectionsHeading": "연결",
"channelsHeading": "전송 채널",
@@ -5324,7 +5322,6 @@
},
"notifications": {
"title": "알림",
"description": "모든 알림을 한곳에서 — 무엇을 언제 알릴지, 어디로 보낼지.",
"subtitle": "받을 알림과 전달될 곳을 골라요.",
"remindersHeading": "알림",
"matrixLink": "채널별 알림",
@@ -5332,7 +5329,6 @@
},
"layout": {
"title": "화면 구성",
"description": "대시보드, 인사이트, 기록 모듈의 모습과 배치를 정해요.",
"backToHub": "화면 구성으로 돌아가기",
"dashboard": {
"title": "대시보드",
@@ -5342,7 +5338,7 @@
"title": "인사이트",
"description": "개요 섹션과 내비게이션 칩 순서."
},
"subtitle": "대시보드, 인사이트, 기록 모듈의 모습을 원하는 대로 바꿔요.",
"subtitle": "대시보드, 인사이트, 기록 모듈에 무엇을 어떤 순서로 보여줄지 정해요.",
"medications": {
"title": "복약",
"description": "목록 보기와 수동 순서."
@@ -5373,12 +5369,10 @@
},
"thresholds": {
"title": "목표",
"description": "지표별로 건강한 값의 목표 범위를 정해요.",
"subtitle": "지표별 목표 범위를 직접 정해요."
},
"sources": {
"title": "소스 우선순위",
"description": "같은 날 같은 지표를 여러 소스가 기록했을 때 어느 소스를 쓸지 골라요.",
"integrationsHint": "데이터 소스 연결과 해제는 연동 페이지에서 해요.",
"integrationsHintLink": "연동 열기",
"cardTitle": "지표별 순위",
@@ -5460,26 +5454,23 @@
"moduleHintLink": "모듈 관리",
"detail": "읽기 전용 — 숫자는 원본 앱이 관리해요."
},
"helpDetail": "나머지는 감사 기록용으로 그대로 보관해요."
"helpDetail": "나머지는 감사 기록용으로 그대로 보관해요. 연결했거나 데이터가 있는 소스만 보여요.",
"noneInUse": "아직 순위를 정할 소스가 없어요. 연동에서 연결하거나 값을 기록해 보세요."
},
"ai": {
"title": "AI 제공자",
"description": "인사이트에 쓸 제공자, 모델, API 키 — 그리고 '나에 대해' 컨텍스트.",
"subtitle": "AI 제공자를 고르고 직접 키를 등록해요."
},
"api": {
"title": "API와 토큰",
"description": "내 스크립트와 앱에서 쓸 베어러 토큰 — 어디서든 측정값이나 약 복용을 기록해요.",
"subtitle": "API 엔드포인트와 액세스 토큰을 관리해요."
},
"advanced": {
"title": "고급",
"description": "마지막 출구: 모든 건강 기록을 영구히 지우거나 계정을 완전히 닫아요.",
"subtitle": "데이터 초기화와 계정 삭제."
},
"export": {
"title": "내보내기와 불러오기",
"description": "데이터를 CSV나 전체 JSON 백업으로 내려받고, Apple Health·JSON·CSV에서 불러와요. 진료용 리포트는 이제 건강 기록에 있어요.",
"otherOptionsHeading": "다른 내보내기 옵션",
"downloadFailed": "내보내기 파일을 내려받지 못했어요. 다시 시도해 주세요.",
"actions": {
@@ -5724,12 +5715,10 @@
},
"about": {
"title": "앱 정보",
"description": "버전, 라이선스, 링크.",
"subtitle": "버전, 라이선스, 프로젝트 링크."
},
"sharing": {
"title": "공유",
"description": "임상의와 건강 기록을 나눌 읽기 전용 링크를 만들어요.",
"subtitle": "데이터를 읽기 전용으로 공유해요."
},
"insights": {
@@ -5764,12 +5753,10 @@
},
"gesundheitsakte": {
"title": "건강 기록",
"description": "전체 건강 기록을 PDF, FHIR R4, 또는 하나로 묶은 ZIP으로 내보내요.",
"subtitle": "임상의에게 바로 건넬 수 있는 완전한 건강 기록을 내보내요."
},
"coach": {
"title": "코치",
"description": "코치 켜기, 말투와 범위, 저장된 기억.",
"subtitle": "코치의 말투, 먼저 건네는 알림, 기억을 조정해요."
},
"security": {
@@ -5778,8 +5765,7 @@
},
"privacy": {
"title": "데이터와 개인정보",
"subtitle": "데이터, 보관 기간, 암호화, 계정 보안을 한곳에서.",
"description": "내보내기, 삭제, 보관 기간, 암호화 상태, 세션, 보안 활동."
"subtitle": "데이터, 보관 기간, 암호화, 계정 보안을 한곳에서."
},
"mcp": {
"title": "MCP 커넥터",
@@ -5787,8 +5773,7 @@
},
"anamnesis": {
"title": "병력",
"subtitle": "병력, 생활 습관, 응급 정보와 AI가 읽을 수 있는 범위를 관리해요.",
"description": "구조화된 알레르기·가족력 기록."
"subtitle": "병력, 생활 습관, 응급 정보와 AI가 읽을 수 있는 범위를 관리해요."
},
"environment": {
"title": "위치 및 날씨",
@@ -6542,30 +6527,32 @@
"fitbitViewData": "수면 보기",
"googleHealthViewData": "걸음 수 보기",
"apiTokens": "API 토큰",
"apiTokensDescription": "외부에서 복용을 기록할 때 쓰는 API 토큰을 확인하고 폐기해요.",
"apiTokensDescription": "측정값, 운동, 문서, 복용 기록용 토큰을 확인하고 폐기해요.",
"measurementsToken": {
"title": "측정 토큰",
"description": "체중계나 시계, 홈 오토메이션이 측정값을 기록할 수 있도록 토큰을 만들어요.",
"detail": "토큰은 한 번만 표시돼요.",
"scopeNote": "본인 기록에 새 측정값을 추가하는 것만 할 수 있어요. 데이터를 읽거나, 무언가를 고치거나 지우거나, 다른 토큰을 만들 수는 없어요. 측정값은 외부 기기에서 온 것으로 표시되니 나중에 구분할 수 있고, 언제든 고칠 수 있어요.",
"createFailed": "토큰을 만들지 못했어요. 다시 시도해 주세요."
"createFailed": "토큰을 만들지 못했어요. 다시 시도해 주세요.",
"namePlaceholder": "이름 (예: Home Assistant)"
},
"workoutsToken": {
"title": "운동 토큰",
"description": "시계 연동 브리지나 동기화 스크립트가 운동 기록을 추가할 수 있도록 토큰을 만들어요.",
"detail": "토큰은 한 번만 표시돼요.",
"scopeNote": "본인 기록에 새 운동을 추가하는 것만 할 수 있어요. 데이터를 읽거나, 무언가를 고치거나 지우거나, 다른 토큰을 만들 수는 없어요. 이 토큰으로 추가된 운동은 외부 기기에서 온 것으로 표시되고, 휴대폰이 같은 운동을 기록했다면 휴대폰의 기록이 보여요.",
"createFailed": "토큰을 만들지 못했어요. 다시 시도해 주세요."
"createFailed": "토큰을 만들지 못했어요. 다시 시도해 주세요.",
"namePlaceholder": "이름 (예: Garmin)"
},
"documentsToken": {
"title": "문서 토큰",
"description": "Paperless-ngx, Papra 또는 가져오기 스크립트가 문서함에 문서를 추가할 수 있도록 토큰을 만들어요.",
"detail": "토큰은 한 번만 표시돼요.",
"scopeNote": "본인 문서함에 새 문서를 추가하는 것만 할 수 있어요. 목록을 보거나, 열거나, 내려받거나, 고치거나 지울 수 없고, 다른 토큰을 만들 수도 없어요. 파일이 저장됐는지만 알 수 있고, 어떻게 정리했는지는 알 수 없어요.",
"createFailed": "토큰을 만들지 못했어요. 다시 시도해 주세요."
"createFailed": "토큰을 만들지 못했어요. 다시 시도해 주세요.",
"namePlaceholder": "이름 (예: Paperless-ngx)"
},
"tokenMintMovedDescription": "토큰은 쓰이는 곳에서 발급돼요. 측정값과 문서는 위에서, 특정 약은 그 약의 API 엔드포인트에서 만들 수 있어요.",
"tokenNamePlaceholder": "토큰 이름 (예: iPhone 단축어)",
"tokenMintMovedDescription": "토큰은 쓰이는 곳에서 발급돼요. 측정값, 운동, 문서는 위에서, 특정 약은 그 약의 API 엔드포인트에서 만들 수 있어요.",
"tokenCreated": "토큰을 만들었어요 — 지금 복사하세요! 다시 보여주지 않아요.",
"tokenCopy": "토큰 복사",
"tokenCopied": "토큰을 클립보드에 복사했어요",
@@ -6757,7 +6744,8 @@
"statusNeedsReselect": "내용 다시 선택",
"reselectReason": "이 링크는 선택한 적 없는 범위를 제공하고 있어서 닫았어요. 새로 만들고 담을 내용을 골라 주세요.",
"reselectAction": "새 링크 만들기",
"createDetail": "토큰은 만들 때 한 번만 보여줘요 — 그때 저장해 두세요. 복구할 수 없어요."
"createDetail": "토큰은 만들 때 한 번만 보여줘요 — 그때 저장해 두세요. 복구할 수 없어요.",
"activeDescription": "공유한 링크와 만료 시점, 열람 횟수를 보여줘요."
},
"genderOther": "기타",
"nightscout": "Nightscout",
@@ -6983,12 +6971,11 @@
},
"stored": {
"title": "저장되는 항목",
"measurements": "측정값 (체중, 혈압, 맥박, 혈당, 수면, 체성분)",
"medications": "약과 복용 기록",
"moodLabs": "기분 기록과 검사 결과",
"coach": "코치 대화",
"measurements": "측정값: 체중, 혈압, 맥박과 직접 또는 기기로 기록한 모든 값",
"integrations": "연동 토큰 (암호화됨)",
"security": "계정·보안 활동 (세션, 로그인 기록)"
"security": "계정·보안 활동 (세션, 로그인 기록)",
"profile": "프로필, 병력, 건강 기록 정보",
"modules": "모듈별 기록: {list}"
},
"retention": {
"title": "보관 기간",
@@ -7019,8 +7006,6 @@
"mcp": {
"enableTitle": "원격 MCP 엔드포인트",
"enableDescription": "MCP를 지원하는 어시스턴트(Claude.ai, ChatGPT)가 범위가 제한된 토큰으로 건강 데이터를 읽게 해요.",
"enabled": "켜짐",
"disabled": "꺼짐",
"enableToggleLabel": "MCP 엔드포인트 켜기",
"endpointLabel": "커넥터 URL",
"connectDescription": "어시스턴트의 커스텀 커넥터 설정에 이 URL을 붙여 넣고, 아래 토큰으로 인증하거나 안내가 나오면 로그인하세요.",
@@ -7039,7 +7024,8 @@
"connectionRevokeDescription": "어시스턴트의 접근을 즉시 끊고 갱신도 막아요. 다시 연결하려면 승인을 새로 받아야 해요.",
"connectionRevokeFailed": "어시스턴트 연결을 해제하지 못했어요.",
"connectionsDetail": "연결을 해지하면 갱신 권한까지 포함해 접근이 즉시 끊겨요.",
"tokensDetail": "토큰은 한 번만 보여줘요."
"tokensDetail": "토큰은 한 번만 보여줘요.",
"tokenNamePlaceholder": "이름 (예: Claude Desktop)"
},
"strava": "Strava",
"stravaDescription": "Strava 계정을 연결해 활동을 운동 기록으로 동기화해요.",
@@ -7169,7 +7155,12 @@
"signInAgain": "다시 로그인한 후 한 번 더 시도하세요. 이 계정에는 여기서 확인할 비밀번호나 패스키가 없습니다.",
"failed": "확인하지 못했습니다. 입력한 내용을 확인하고 다시 시도하세요."
},
"passwordTitle": "비밀번호"
"passwordTitle": "비밀번호",
"tokenNameLabel": "토큰 이름",
"displayCard": {
"title": "표시",
"description": "언어, 단위, 형식은 바꾸는 즉시 저장돼요."
}
},
"admin": {
"title": "관리자",
@@ -7353,8 +7344,8 @@
"coachFollowsSwitch": "코치 섹션의 코치 스위치를 따라요."
},
"invites": {
"title": "초대",
"description": "가입 초대 링크 — 회원가입이 닫혀 있어도 동작해요.",
"title": "초대 링크",
"description": "링크마다 회원가입이 닫혀 있어도 가입할 수 있어요.",
"create": "초대 만들기",
"createTitle": "초대 만들기",
"createDescription": "회원가입이 닫혀 있어도 이 링크로는 가입할 수 있어요.",
@@ -7400,8 +7391,8 @@
"revokedToast": "초대를 취소했어요.",
"revokeError": "초대를 취소하지 못했어요."
},
"userManagement": "사용자 관리",
"userManagementDescription": "계정 관리: 역할, 비밀번호 재설정, 강제 로그아웃.",
"userManagement": "계정",
"userManagementDescription": "계정마다 역할, 비밀번호 재설정, 강제 로그아웃을 관리해요.",
"userName": "이름",
"userEmail": "이메일",
"userRole": "역할",
@@ -7857,7 +7848,12 @@
"backupsNone": "아직 필요한 키를 기록한 백업이 없습니다.",
"backupsStillNeed": "모든 행이 회전되었지만 백업에 아직 {keys}가 필요합니다",
"backupsUnrecordedBadge": "모든 행이 회전되었지만 이전 백업에는 아직 폐기한 키가 필요할 수 있습니다",
"backupsDetail": "회전은 모든 행을 다시 암호화하지만, 백업은 내용이 기록된 키를 그대로 유지하며 복원에는 그 키가 필요합니다. 아래에 더 이상 표시되지 않을 때까지 폐기한 키를 ENCRYPTION_KEYS에 유지하세요."
"backupsDetail": "회전은 모든 행을 다시 암호화하지만, 백업은 내용이 기록된 키를 그대로 유지하며 복원에는 그 키가 필요합니다. 아래에 더 이상 표시되지 않을 때까지 폐기한 키를 ENCRYPTION_KEYS에 유지하세요.",
"columnsShow": "표 보기 (행 있음 {count}개)",
"columnsHide": "표 숨기기",
"columnsShowAll": "열 {count}개 모두 보기",
"columnsShowWithRows": "행이 있는 열만",
"columnsNoneWithRows": "아직 행이 있는 암호화 열이 없어요."
},
"maintenance": {
"title": "측정값 테이블 유지 관리",
+40 -44
View File
@@ -5310,12 +5310,10 @@
"sections": {
"account": {
"title": "Konto",
"description": "Profil, hasło, klucze dostępu i przewodnik wprowadzający.",
"subtitle": "Twój profil, tożsamość i dane logowania."
},
"integrations": {
"title": "Integracje",
"description": "Połączone usługi, kanały dostarczania i priorytet źródeł w jednym miejscu.",
"subtitle": "Połącz i zarządzaj źródłami danych oraz urządzeniami.",
"connectionsHeading": "Połączenia",
"channelsHeading": "Kanały dostarczania",
@@ -5324,16 +5322,14 @@
},
"notifications": {
"title": "Powiadomienia",
"description": "Wszystkie przypomnienia w jednym miejscu — co i kiedy oraz gdzie są dostarczane.",
"subtitle": "Wybierz, które przypomnienia otrzymujesz i gdzie są dostarczane.",
"remindersHeading": "Przypomnienia",
"matrixLink": "Powiadomienia wg kanału",
"channelsLink": "Kanały dostarczania"
},
"layout": {
"title": "Wygląd",
"description": "Ustal, jak wyglądają i są rozłożone pulpit, analizy i Twoje moduły śledzenia.",
"backToHub": "Powrót do Wygląd",
"title": "Układ",
"backToHub": "Powrót do układu",
"dashboard": {
"title": "Pulpit",
"description": "Układ i kolejność kafelków."
@@ -5342,7 +5338,7 @@
"title": "Analizy",
"description": "Kolejność przeglądu i pigułek nawigacyjnych."
},
"subtitle": "Dostosuj wygląd pulpitu, analiz i modułów śledzenia.",
"subtitle": "Wybierz, co pokazują pulpit, analizy i moduły śledzenia oraz w jakiej kolejności.",
"medications": {
"title": "Leki",
"description": "Widok listy i ręczna kolejność."
@@ -5373,12 +5369,10 @@
},
"thresholds": {
"title": "Cele",
"description": "Dla każdej metryki: własny zakres docelowy zdrowych wartości.",
"subtitle": "Ustaw osobiste zakresy docelowe dla każdej metryki."
},
"sources": {
"title": "Priorytet źródeł",
"description": "Wybierz, które źródło wygrywa, gdy kilka rejestruje tę samą metrykę tego samego dnia.",
"integrationsHint": "Źródła danych podłączysz lub odłączysz na stronie integracji.",
"integrationsHintLink": "Otwórz integracje",
"cardTitle": "Kolejność według metryki",
@@ -5460,26 +5454,23 @@
"moduleHintLink": "Zarządzaj modułami",
"detail": "Tylko do odczytu — wartości należą do aplikacji źródłowej."
},
"helpDetail": "Pozostałe pozostają zapisane na potrzeby audytu."
"helpDetail": "Pozostałe pozostają zapisane na potrzeby audytu. Widać tylko źródła, które połączono lub z których są dane.",
"noneInUse": "Nie ma jeszcze źródeł do uszeregowania. Połącz jedno w Integracjach albo wpisz wartość."
},
"ai": {
"title": "Dostawca AI",
"description": "Dostawca, model i klucz API do analiz, a także Twój kontekst „o mnie”.",
"subtitle": "Wybierz dostawcę AI i użyj własnego klucza."
},
"api": {
"title": "API & Tokens",
"description": "Tokeny Bearer dla własnych skryptów i aplikacji — zapisuj pomiary lub przyjęcia leków z dowolnego miejsca.",
"title": "API i tokeny",
"subtitle": "Zarządzaj punktami końcowymi API i tokenami dostępu."
},
"advanced": {
"title": "Zaawansowane",
"description": "Wyjście awaryjne: trwale usuń wszystkie dane zdrowotne albo zamknij konto.",
"subtitle": "Reset danych i usunięcie konta."
},
"export": {
"title": "Eksport i import",
"description": "Pobierz swoje dane jako CSV lub pełną kopię zapasową JSON albo zaimportuj z Apple Health, JSON lub CSV. Raport dla lekarza znajdziesz teraz w Dokumentacji zdrowotnej.",
"otherOptionsHeading": "Inne opcje eksportu",
"downloadFailed": "Nie udało się pobrać eksportu. Spróbuj ponownie.",
"actions": {
@@ -5724,12 +5715,10 @@
},
"about": {
"title": "O tej aplikacji",
"description": "Wersja, licencja, linki.",
"subtitle": "Wersja, licencja i linki do projektu."
},
"sharing": {
"title": "Udostępnianie",
"description": "Utwórz link tylko do odczytu, aby udostępnić swoją dokumentację zdrowotną lekarzowi.",
"subtitle": "Udostępnij dostęp tylko do odczytu do swoich danych."
},
"insights": {
@@ -5764,12 +5753,10 @@
},
"gesundheitsakte": {
"title": "Dokumentacja zdrowotna",
"description": "Wyeksportuj pełną dokumentację zdrowotną jako PDF, FHIR R4 lub łączony pakiet ZIP.",
"subtitle": "Eksportuj kompletną dokumentację zdrowotną gotową dla lekarza."
},
"coach": {
"title": "Coach",
"description": "Włącz Coacha, ton i zakres, zapisane wspomnienia.",
"subtitle": "Dostosuj Coacha, jego proaktywne podpowiedzi i pamięć."
},
"security": {
@@ -5778,8 +5765,7 @@
},
"privacy": {
"title": "Dane i prywatność",
"subtitle": "Twoje dane, retencja, szyfrowanie i bezpieczeństwo konta w jednym miejscu.",
"description": "Eksport, usuwanie, retencja, stan szyfrowania, sesje i aktywność zabezpieczeń."
"subtitle": "Twoje dane, retencja, szyfrowanie i bezpieczeństwo konta w jednym miejscu."
},
"mcp": {
"title": "Łącznik MCP",
@@ -5787,8 +5773,7 @@
},
"anamnesis": {
"title": "Wywiad medyczny",
"subtitle": "Twoja historia zdrowia, styl życia, dane alarmowe i to, co może czytać AI.",
"description": "Ustrukturyzowane rekordy alergii i wywiadu rodzinnego."
"subtitle": "Twoja historia zdrowia, styl życia, dane alarmowe i to, co może czytać AI."
},
"environment": {
"title": "Lokalizacja i pogoda",
@@ -6542,30 +6527,32 @@
"fitbitViewData": "Zobacz swój sen",
"googleHealthViewData": "Zobacz swoje kroki",
"apiTokens": "Tokeny API",
"apiTokensDescription": "Przeglądaj i unieważniaj tokeny API do zewnętrznego rejestrowania przyjęć leków.",
"apiTokensDescription": "Przeglądaj i unieważniaj tokeny do pomiarów, treningów, dokumentów i przyjęć leków.",
"measurementsToken": {
"title": "Tokeny pomiarów",
"description": "Utwórz token, aby waga, zegarek lub automatyka domowa mogły dodawać pomiary.",
"detail": "Token pokazujemy tylko raz.",
"scopeNote": "Może wyłącznie dodawać nowe pomiary do Twojej dokumentacji — nie odczyta danych, niczego nie zmieni ani nie usunie i nie utworzy kolejnego tokenu. Pomiary są oznaczane jako pochodzące z urządzenia zewnętrznego, dzięki czemu później je rozpoznasz, i nadal możesz je poprawić.",
"createFailed": "Nie udało się utworzyć tokenu. Spróbuj ponownie."
"createFailed": "Nie udało się utworzyć tokenu. Spróbuj ponownie.",
"namePlaceholder": "Nazwa, np. Home Assistant"
},
"workoutsToken": {
"title": "Tokeny treningów",
"description": "Utwórz token, aby mostek zegarka lub skrypt synchronizacji mogły dodawać Twoje treningi.",
"detail": "Token pokazujemy tylko raz.",
"scopeNote": "Może wyłącznie dodawać nowe treningi do Twojej dokumentacji. Nie odczyta danych, niczego nie zmieni ani nie usunie i nie utworzy kolejnego tokenu. Dodane przez niego treningi są oznaczane jako pochodzące z urządzenia zewnętrznego, a jeśli telefon zapisał tę samą sesję, widzisz zapis z telefonu.",
"createFailed": "Nie udało się utworzyć tokenu. Spróbuj ponownie."
"createFailed": "Nie udało się utworzyć tokenu. Spróbuj ponownie.",
"namePlaceholder": "Nazwa, np. Garmin"
},
"documentsToken": {
"title": "Tokeny dokumentów",
"description": "Utwórz token, aby Paperless-ngx, Papra lub skrypt importu mogły dodawać dokumenty do Twojego archiwum.",
"detail": "Token pokazujemy tylko raz.",
"scopeNote": "Może wyłącznie dodawać nowe dokumenty do Twojego archiwum. Niczego nie wyświetli, nie otworzy, nie pobierze, nie zmieni ani nie usunie i nie utworzy kolejnego tokenu. Dowiaduje się tylko, czy plik został zapisany, a nie to, jak został opisany.",
"createFailed": "Nie udało się utworzyć tokenu. Spróbuj ponownie."
"createFailed": "Nie udało się utworzyć tokenu. Spróbuj ponownie.",
"namePlaceholder": "Nazwa, np. Paperless-ngx"
},
"tokenMintMovedDescription": "Tokeny wydajemy tam, gdzie działają: powyżej dla pomiarów i dokumentów albo w punkcie końcowym API danego leku dla tego jednego leku.",
"tokenNamePlaceholder": "Nazwa tokena (np. Skrót iPhone)",
"tokenMintMovedDescription": "Tokeny wydajemy tam, gdzie działają: powyżej dla pomiarów, treningów i dokumentów albo w punkcie końcowym API danego leku dla tego jednego leku.",
"tokenCreated": "Token utworzony — skopiuj go teraz! Nie zostanie pokazany ponownie.",
"tokenCopy": "Kopiuj token",
"tokenCopied": "Token skopiowany do schowka",
@@ -6757,7 +6744,8 @@
"statusNeedsReselect": "Wybierz zawartość ponownie",
"reselectReason": "Ten link udostępniał zakres, którego nikt nie wybrał, więc został zamknięty. Utwórz nowy i wybierz jego zawartość.",
"reselectAction": "Utwórz zamiennik",
"createDetail": "Token jest pokazywany tylko raz przy tworzeniu — zapisz go wtedy; nie można go odzyskać."
"createDetail": "Token jest pokazywany tylko raz przy tworzeniu — zapisz go wtedy; nie można go odzyskać.",
"activeDescription": "Udostępnione linki, termin ich wygaśnięcia i liczba otwarć."
},
"genderOther": "Inna",
"nightscout": "Nightscout",
@@ -6983,12 +6971,11 @@
},
"stored": {
"title": "Co jest przechowywane",
"measurements": "Pomiary (waga, ciśnienie, tętno, glukoza, sen, skład ciała)",
"medications": "Leki i historia przyjmowania",
"moodLabs": "Wpisy nastroju i wyniki badań laboratoryjnych",
"coach": "Rozmowy z asystentem",
"measurements": "Pomiary: waga, ciśnienie, tętno i każda inna wartość zapisana przez ciebie lub urządzenie",
"integrations": "Tokeny integracji (szyfrowane)",
"security": "Aktywność konta i zabezpieczeń (sesje, dziennik logowań)"
"security": "Aktywność konta i zabezpieczeń (sesje, dziennik logowań)",
"profile": "Twój profil, wywiad medyczny i dane dokumentacji zdrowotnej",
"modules": "Wpisy poszczególnych modułów: {list}"
},
"retention": {
"title": "Retencja",
@@ -7019,8 +7006,6 @@
"mcp": {
"enableTitle": "Zdalny punkt końcowy MCP",
"enableDescription": "Zezwól asystentowi obsługującemu MCP (Claude.ai, ChatGPT) na odczyt danych zdrowotnych za pomocą ograniczonego tokenu.",
"enabled": "Włączony",
"disabled": "Wyłączony",
"enableToggleLabel": "Włącz punkt końcowy MCP",
"endpointLabel": "Adres URL łącznika",
"connectDescription": "Wklej ten adres URL w konfiguracji własnego łącznika asystenta, a następnie autoryzuj za pomocą poniższego tokenu lub zaloguj się po wyświetleniu monitu.",
@@ -7039,7 +7024,8 @@
"connectionRevokeDescription": "To natychmiast kończy dostęp asystenta i uniemożliwia odnawianie. Ponowne połączenie będzie wymagać nowej autoryzacji.",
"connectionRevokeFailed": "Nie udało się rozłączyć asystenta.",
"tokensDetail": "Token jest wyświetlany tylko raz.",
"connectionsDetail": "Cofnięcie połączenia natychmiast kończy dostęp, w tym możliwość odnawiania."
"connectionsDetail": "Cofnięcie połączenia natychmiast kończy dostęp, w tym możliwość odnawiania.",
"tokenNamePlaceholder": "Nazwa, np. Claude Desktop"
},
"strava": "Strava",
"stravaDescription": "Połącz konto Strava, aby synchronizować aktywności z historią treningów.",
@@ -7169,7 +7155,12 @@
"signInAgain": "Zaloguj się ponownie, a potem spróbuj jeszcze raz. Twoje konto nie ma hasła ani klucza dostępu do potwierdzenia tutaj.",
"failed": "To się nie udało. Sprawdź wprowadzone dane i spróbuj ponownie."
},
"passwordTitle": "Hasło"
"passwordTitle": "Hasło",
"tokenNameLabel": "Nazwa tokena",
"displayCard": {
"title": "Wyświetlanie",
"description": "Język, jednostki i formaty zapisywane od razu po zmianie."
}
},
"admin": {
"title": "Administracja",
@@ -7377,8 +7368,8 @@
"coachFollowsSwitch": "Podlega przełącznikowi Coach w sekcji Coach."
},
"invites": {
"title": "Zaproszenia",
"description": "Linki z zaproszeniem do rejestracji — działają nawet przy zamkniętej rejestracji.",
"title": "Linki z zaproszeniem",
"description": "Każdy link pozwala na rejestrację nawet przy zamkniętej rejestracji.",
"create": "Utwórz zaproszenie",
"createTitle": "Utwórz zaproszenie",
"createDescription": "Link umożliwia rejestrację nawet przy zamkniętej rejestracji.",
@@ -7424,8 +7415,8 @@
"revokedToast": "Zaproszenie unieważnione.",
"revokeError": "Nie udało się unieważnić zaproszenia."
},
"userManagement": "Zarządzanie użytkownikami",
"userManagementDescription": "Zarządzaj kontami: role, resety haseł i wymuszone wylogowania.",
"userManagement": "Konta",
"userManagementDescription": "Role, resety haseł i wymuszone wylogowania dla każdego konta.",
"userName": "Nazwa",
"userEmail": "E-mail",
"userRole": "Rola",
@@ -7881,7 +7872,12 @@
"backupsNone": "Żadna kopia nie zapisała jeszcze kluczy, których potrzebuje.",
"backupsStillNeed": "Wszystkie wiersze są zrotowane, ale kopie nadal potrzebują {keys}",
"backupsUnrecordedBadge": "Wszystkie wiersze są zrotowane, ale starsze kopie mogą nadal potrzebować wycofanego klucza",
"backupsDetail": "Rotacja ponownie szyfruje każdy wiersz, ale kopia zachowuje klucz, którym zapisano jej zawartość, a jej przywrócenie wymaga tego klucza. Trzymaj wycofany klucz w ENCRYPTION_KEYS, dopóki widnieje poniżej."
"backupsDetail": "Rotacja ponownie szyfruje każdy wiersz, ale kopia zachowuje klucz, którym zapisano jej zawartość, a jej przywrócenie wymaga tego klucza. Trzymaj wycofany klucz w ENCRYPTION_KEYS, dopóki widnieje poniżej.",
"columnsShow": "Pokaż tabelę (z wierszami: {count})",
"columnsHide": "Ukryj tabelę",
"columnsShowAll": "Pokaż wszystkie kolumny ({count})",
"columnsShowWithRows": "Tylko kolumny z wierszami",
"columnsNoneWithRows": "Żadna zaszyfrowana kolumna nie zawiera jeszcze wierszy."
},
"maintenance": {
"title": "Konserwacja tabeli pomiarów",
@@ -379,7 +379,7 @@ const PINNED_AFFORDANCES: Record<
"text-success": 2,
},
"src/components/settings/mcp-section.tsx": {
"text-success": 5,
"text-success": 4,
"toast.success": 2,
},
"src/components/settings/modules-section.tsx": { "toast.success": 1 },
@@ -11,6 +11,10 @@ vi.mock("@/lib/db", () => ({
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/integrations/sources-in-use", () => ({
getSourcesInUse: vi.fn().mockResolvedValue(["WITHINGS", "MANUAL"]),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
@@ -73,6 +77,17 @@ describe("GET /api/auth/me/source-priority", () => {
expect(env.data).toHaveProperty("metricPriority");
expect(env.data).toHaveProperty("deviceTypePriority");
});
it("carries the account's sources in use beside the ladders", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
sourcePriorityJson: null,
} as never);
const res = await (GET as () => Promise<Response>)();
const env = (await res.json()) as { data: { inUse: string[] } };
expect(env.data.inUse).toEqual(["WITHINGS", "MANUAL"]);
});
});
describe("PUT /api/auth/me/source-priority", () => {
+17 -5
View File
@@ -30,6 +30,7 @@ import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { invalidateUserProfile } from "@/lib/cache/invalidate";
import { getSourcesInUse } from "@/lib/integrations/sources-in-use";
import {
parseSourcePriority,
sourcePrioritySchema,
@@ -41,11 +42,19 @@ export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "auth.me.source-priority.get" } });
const row = await prisma.user.findUnique({
where: { id: user.id },
select: { sourcePriorityJson: true },
const [row, inUse] = await Promise.all([
prisma.user.findUnique({
where: { id: user.id },
select: { sourcePriorityJson: true },
}),
getSourcesInUse(user.id),
]);
// `inUse` is read-only context for the editor: which ranked sources this
// account has connected or holds data from. It never feeds the ladder.
return apiSuccess({
...parseSourcePriority(row?.sourcePriorityJson),
inUse,
});
return apiSuccess(parseSourcePriority(row?.sourcePriorityJson));
});
export const PUT = apiHandler(async (req: Request) => {
@@ -118,5 +127,8 @@ export const PUT = apiHandler(async (req: Request) => {
action: { name: "auth.me.source-priority.put" },
meta: { keys: Object.keys(parsed.data).length },
});
return apiSuccess(parseSourcePriority(parsed.data));
return apiSuccess({
...parseSourcePriority(parsed.data),
inUse: await getSourcesInUse(user.id),
});
});
@@ -0,0 +1,65 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* `/admin/app-logs` pages the buffered events fifty at a time, like the
* login overview, instead of painting up to ~500 rows in one table.
*/
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/admin/app-logs",
}));
const events = Array.from({ length: 120 }, (_, i) => ({
request_id: `req-${i}`,
trace_id: `trace${String(i).padStart(11, "0")}`,
level: "info" as const,
timestamp: "2026-05-10T09:00:00Z",
duration_ms: 12,
action: { name: `event.number.${i}` },
kind: "request",
}));
let served = events;
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: { events: served, meta: { total: served.length, bufferMax: 500 } },
isLoading: false,
refetch: vi.fn(),
isFetching: false,
}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import {
APP_LOG_PAGE_SIZE,
AppLogPreviewSection,
} from "../app-log-preview-section";
function render() {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<AppLogPreviewSection />
</I18nProvider>,
);
}
describe("AppLogPreviewSection — pagination", () => {
it("paints one page of fifty rows and a pager", () => {
served = events;
const html = render();
expect(APP_LOG_PAGE_SIZE).toBe(50);
expect(html.match(/<tr /g)?.length).toBe(APP_LOG_PAGE_SIZE + 1);
expect(html).toContain(">event.number.0<");
expect(html).not.toContain(">event.number.50<");
expect(html).toContain('data-testid="app-log-pagination"');
expect(html).toContain("Page 1 of 3");
});
it("shows no pager when everything fits on one page", () => {
served = events.slice(0, 10);
expect(render()).not.toContain('data-testid="app-log-pagination"');
});
});
@@ -23,7 +23,7 @@ vi.mock("@tanstack/react-query", () => ({
vi.mock("../key-backup-card", () => ({ KeyBackupCard: () => null }));
import { I18nProvider } from "@/lib/i18n/context";
import { EncryptionSection } from "../encryption-section";
import { ColumnCoverage, EncryptionSection } from "../encryption-section";
function statusWith(configuredKeyCount: number) {
return {
@@ -77,9 +77,64 @@ describe("encryption coverage — the legacy-key badge", () => {
});
it("names the per-column table as a focusable scroll region", () => {
status.current = statusWith(1);
expect(render()).toMatch(
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<ColumnCoverage
columns={[column("User", "a", 3)]}
activeKeyId="v2"
initiallyOpen
/>
</I18nProvider>,
);
expect(html).toMatch(
/role="region"[^>]*tabindex="0"|tabindex="0"[^>]*role="region"/,
);
});
});
function column(model: string, field: string, total: number) {
return {
model,
field,
kind: "string" as const,
total,
byKeyId: { v2: total },
legacy: 0,
};
}
describe("encryption coverage — the per-column table", () => {
const columns = [
column("User", "withRows", 4),
column("User", "empty", 0),
column("Note", "alsoEmpty", 0),
];
function renderCoverage(initiallyOpen: boolean) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<ColumnCoverage
columns={columns}
activeKeyId="v2"
initiallyOpen={initiallyOpen}
/>
</I18nProvider>,
);
}
it("starts collapsed on the page, naming how many columns hold rows", () => {
status.current = { ...statusWith(1), columns };
const html = render();
expect(html).toContain('data-testid="encryption-columns-toggle"');
expect(html).toContain('aria-expanded="false"');
expect(html).toContain("Show the table (1 with rows)");
expect(html).not.toContain('data-slot="encryption-column-rows"');
});
it("lists only columns with rows when opened, with a toggle for all", () => {
const html = renderCoverage(true);
expect(html).toContain("User.withRows");
expect(html).not.toContain("User.empty");
expect(html).toContain("Show all 3 columns");
});
});
@@ -102,7 +102,7 @@ describe("admin sections — SSR smoke", () => {
it("<UserManagementSection> renders with filter pills", () => {
const html = render(<UserManagementSection />);
expect(html).toContain("User Management");
expect(html).toContain("Accounts");
// Filter pills painted from i18n
expect(html).toContain("All");
expect(html).toContain("Admins");
@@ -110,7 +110,7 @@ describe("admin sections — SSR smoke", () => {
it("<ApiTokenOverviewSection> renders", () => {
const html = render(<ApiTokenOverviewSection />);
expect(html).toContain("API Tokens");
expect(html).toContain("API tokens");
});
it("<LoginOverviewSection> renders", () => {
@@ -55,6 +55,13 @@ import { apiGet } from "@/lib/api/api-fetch";
type RangePreset = "15m" | "1h" | "6h" | "all";
/**
* Rows per page. The buffer answers with up to ~500 events at once; fifty a
* page matches the login overview's default and keeps the table one screen
* of scrolling instead of ten.
*/
export const APP_LOG_PAGE_SIZE = 50;
interface AppLogsResponse {
events: WideEvent[];
meta: {
@@ -92,6 +99,7 @@ export function AppLogPreviewSection() {
const [level, setLevel] = useState<LogLevel | "__all__">("__all__");
const [range, setRange] = useState<RangePreset>("1h");
const [selected, setSelected] = useState<WideEvent | null>(null);
const [page, setPage] = useState(1);
const params = useMemo(() => {
const p = new URLSearchParams();
@@ -114,6 +122,14 @@ export function AppLogPreviewSection() {
});
const events = data?.events ?? [];
// The buffer refetches every 30 s and can shrink under a narrower filter,
// so the page is clamped on read rather than trusted.
const lastPage = Math.max(1, Math.ceil(events.length / APP_LOG_PAGE_SIZE));
const currentPage = Math.min(page, lastPage);
const pageEvents = events.slice(
(currentPage - 1) * APP_LOG_PAGE_SIZE,
currentPage * APP_LOG_PAGE_SIZE,
);
return (
<SettingsCard>
@@ -133,19 +149,28 @@ export function AppLogPreviewSection() {
type="search"
placeholder={t("admin.section.app-logs.filterTraceId")}
value={traceId}
onChange={(e) => setTraceId(e.target.value)}
onChange={(e) => {
setTraceId(e.target.value);
setPage(1);
}}
aria-label={t("admin.section.app-logs.filterTraceId")}
/>
<Input
type="search"
placeholder={t("admin.section.app-logs.filterAction")}
value={actionFilter}
onChange={(e) => setActionFilter(e.target.value)}
onChange={(e) => {
setActionFilter(e.target.value);
setPage(1);
}}
aria-label={t("admin.section.app-logs.filterAction")}
/>
<Select
value={level}
onValueChange={(v) => setLevel(v as LogLevel | "__all__")}
onValueChange={(v) => {
setLevel(v as LogLevel | "__all__");
setPage(1);
}}
>
<SelectTrigger
className="w-full"
@@ -165,7 +190,13 @@ export function AppLogPreviewSection() {
<SelectItem value="error">error</SelectItem>
</SelectContent>
</Select>
<Select value={range} onValueChange={(v) => setRange(v as RangePreset)}>
<Select
value={range}
onValueChange={(v) => {
setRange(v as RangePreset);
setPage(1);
}}
>
<SelectTrigger
className="w-full"
aria-label={t("admin.section.app-logs.filterRange")}
@@ -246,7 +277,7 @@ export function AppLogPreviewSection() {
data-slot="app-log-rows"
className="divide-border divide-y"
>
{events.map((event, i) => {
{pageEvents.map((event, i) => {
const actionLabel =
event.action?.name ??
(`${event.http?.method ?? ""} ${event.http?.path ?? ""}`.trim() ||
@@ -290,7 +321,7 @@ export function AppLogPreviewSection() {
</table>
</div>
<div
className="text-muted-foreground flex items-center justify-between text-xs"
className="text-muted-foreground flex flex-wrap items-center justify-between gap-2 text-xs"
data-testid="app-log-preview-summary"
>
<span>
@@ -299,6 +330,38 @@ export function AppLogPreviewSection() {
bufferMax: data?.meta.bufferMax ?? 500,
})}
</span>
{/* Same pager as the login overview. */}
{lastPage > 1 ? (
<div
className="flex items-center gap-2"
data-testid="app-log-pagination"
>
<Button
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={currentPage <= 1}
onClick={() => setPage(Math.max(1, currentPage - 1))}
>
{t("admin.section.auditLog.prev")}
</Button>
<span>
{t("admin.section.auditLog.pageOf", {
page: currentPage,
total: lastPage,
})}
</span>
<Button
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={currentPage >= lastPage}
onClick={() => setPage(Math.min(lastPage, currentPage + 1))}
>
{t("admin.section.auditLog.next")}
</Button>
</div>
) : null}
</div>
</>
)}
+130 -52
View File
@@ -12,9 +12,11 @@
* which case the documented CLI is the path).
*/
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Archive,
ChevronDown,
KeyRound,
Loader2,
RotateCw,
@@ -360,63 +362,139 @@ export function EncryptionSection() {
title={t("admin.section.encryption.columnsTitle")}
description={t("admin.section.encryption.columnsDescription")}
/>
{/* Focusable, named scroll region: on a phone the table scrolls
sideways, and a keyboard user has to be able to reach it
(axe scrollable-region-focusable). */}
<div
className="overflow-x-auto"
tabIndex={0}
role="region"
aria-label={t("admin.section.encryption.columnsTitle")}
>
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground border-b text-left">
<th className="py-2 pr-3 font-medium">
{t("admin.section.encryption.colColumn")}
</th>
<th className="py-2 pr-3 font-medium">
{t("admin.section.encryption.colTotal")}
</th>
<th className="py-2 pr-3 font-medium">
{t("admin.section.encryption.colActive")}
</th>
<th className="py-2 font-medium">
{t("admin.section.encryption.colStale")}
</th>
</tr>
</thead>
<tbody>
{columns.map((c) => {
const active = c.byKeyId[s.activeKeyId] ?? 0;
const stale = c.total - active;
return (
<tr key={`${c.model}.${c.field}`} className="border-b">
<td className="py-2 pr-3 font-mono text-xs">
{c.model}.{c.field}
</td>
<td className="py-2 pr-3">{c.total.toLocaleString()}</td>
<td className="py-2 pr-3">{active.toLocaleString()}</td>
<td className="py-2">
{stale > 0 ? (
<Badge variant="secondary">
{stale.toLocaleString()}
</Badge>
) : (
<span className="text-muted-foreground">0</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<ColumnCoverage columns={columns} activeKeyId={s.activeKeyId} />
</SettingsCard>
</div>
);
}
/**
* The per-column table, collapsed by default. A fresh install lists well over
* a hundred encrypted columns, nearly all of them empty, and the table pushed
* every other card off the page. Opened, it shows the columns that hold rows
* (the ones a rotation has to touch), with a toggle for the full list.
*/
export function ColumnCoverage({
columns,
activeKeyId,
initiallyOpen = false,
}: {
columns: ColumnScan[];
activeKeyId: string;
/** Start expanded; the page always starts collapsed. */
initiallyOpen?: boolean;
}) {
const { t } = useTranslations();
const [open, setOpen] = useState(initiallyOpen);
const [showAll, setShowAll] = useState(false);
const withRows = columns.filter((c) => c.total > 0);
const shown = showAll ? columns : withRows;
return (
<div className="space-y-3">
<button
type="button"
onClick={() => setOpen((prev) => !prev)}
aria-expanded={open}
aria-controls="encryption-columns-panel"
data-testid="encryption-columns-toggle"
className="text-foreground hover:text-primary flex min-h-11 items-center gap-2 text-sm font-medium transition-colors sm:min-h-9"
>
<ChevronDown
aria-hidden="true"
className={`size-4 transition-transform ${open ? "rotate-180" : ""}`}
/>
{open
? t("admin.section.encryption.columnsHide")
: t("admin.section.encryption.columnsShow", {
count: withRows.length,
})}
</button>
{open ? (
<div id="encryption-columns-panel" className="space-y-3">
{shown.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("admin.section.encryption.columnsNoneWithRows")}
</p>
) : (
/* Focusable, named scroll region: on a phone the table scrolls
sideways, and a keyboard user has to be able to reach it
(axe scrollable-region-focusable). */
<div
className="overflow-x-auto"
tabIndex={0}
role="region"
aria-label={t("admin.section.encryption.columnsTitle")}
>
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground border-b text-left">
<th className="py-2 pr-3 font-medium">
{t("admin.section.encryption.colColumn")}
</th>
<th className="py-2 pr-3 font-medium">
{t("admin.section.encryption.colTotal")}
</th>
<th className="py-2 pr-3 font-medium">
{t("admin.section.encryption.colActive")}
</th>
<th className="py-2 font-medium">
{t("admin.section.encryption.colStale")}
</th>
</tr>
</thead>
<tbody data-slot="encryption-column-rows">
{shown.map((c) => {
const active = c.byKeyId[activeKeyId] ?? 0;
const stale = c.total - active;
return (
<tr key={`${c.model}.${c.field}`} className="border-b">
<td className="py-2 pr-3 font-mono text-xs">
{c.model}.{c.field}
</td>
<td className="py-2 pr-3">
{c.total.toLocaleString()}
</td>
<td className="py-2 pr-3">{active.toLocaleString()}</td>
<td className="py-2">
{stale > 0 ? (
<Badge variant="secondary">
{stale.toLocaleString()}
</Badge>
) : (
<span className="text-muted-foreground">0</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{withRows.length < columns.length ? (
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => setShowAll((prev) => !prev)}
aria-pressed={showAll}
data-testid="encryption-columns-show-all"
>
{showAll
? t("admin.section.encryption.columnsShowWithRows")
: t("admin.section.encryption.columnsShowAll", {
count: columns.length,
})}
</Button>
) : null}
</div>
) : null}
</div>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return <StatTile label={label} value={value} valueClassName="font-mono" />;
}
@@ -231,45 +231,50 @@ export function UserManagementSection() {
<ShieldCheck className="h-3 w-3" aria-hidden="true" />
{u.mfaEnforced ? t("admin.mfaEnforcedOn") : t("admin.mfaEnforcedOff")}
</Button>
<Button
variant="ghost"
size="sm"
className="min-h-11 min-w-11 px-2 text-xs"
onClick={() => startEdit(u)}
title={t("admin.editUser")}
aria-label={t("admin.editUser")}
>
<Pencil className="h-3 w-3" aria-hidden="true" />
</Button>
<Button
variant="ghost"
size="sm"
className="min-h-11 min-w-11 px-2 text-xs"
onClick={() => startReset(u)}
title={t("admin.resetPassword")}
aria-label={t("admin.resetPassword")}
>
<KeyRound className="h-3 w-3" aria-hidden="true" />
</Button>
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground min-h-11 min-w-11 px-2 text-xs"
onClick={() => setLogoutTarget(u)}
disabled={u.id === currentUserId}
title={
u.id === currentUserId
? t("admin.section.users.cannotLogoutSelf")
: t("admin.section.users.forceLogout")
}
aria-label={
u.id === currentUserId
? t("admin.section.users.cannotLogoutSelf")
: t("admin.section.users.forceLogout")
}
>
<LogOut className="h-3 w-3" aria-hidden="true" />
</Button>
{/* The three icon tools travel as one group: on a phone the row wraps
between the labelled toggles and the tools, never leaving the
sign-out icon alone on a line. */}
<span className="ml-auto flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="min-h-11 min-w-11 px-2 text-xs"
onClick={() => startEdit(u)}
title={t("admin.editUser")}
aria-label={t("admin.editUser")}
>
<Pencil className="h-3 w-3" aria-hidden="true" />
</Button>
<Button
variant="ghost"
size="sm"
className="min-h-11 min-w-11 px-2 text-xs"
onClick={() => startReset(u)}
title={t("admin.resetPassword")}
aria-label={t("admin.resetPassword")}
>
<KeyRound className="h-3 w-3" aria-hidden="true" />
</Button>
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground min-h-11 min-w-11 px-2 text-xs"
onClick={() => setLogoutTarget(u)}
disabled={u.id === currentUserId}
title={
u.id === currentUserId
? t("admin.section.users.cannotLogoutSelf")
: t("admin.section.users.forceLogout")
}
aria-label={
u.id === currentUserId
? t("admin.section.users.cannotLogoutSelf")
: t("admin.section.users.forceLogout")
}
>
<LogOut className="h-3 w-3" aria-hidden="true" />
</Button>
</span>
</>
);
@@ -306,7 +311,9 @@ export function UserManagementSection() {
{(["all", "admin", "user"] as const).map((value) => (
<Button
key={value}
variant={filter === value ? "default" : "outline"}
// Neutral pills: the selected filter is a state, not a call to
// action, so it never takes the primary fill.
variant={filter === value ? "secondary" : "outline"}
size="sm"
className="min-h-11 min-w-11 px-3 text-xs sm:min-h-9"
onClick={() => setFilter(value)}
@@ -50,7 +50,7 @@ describe("<InsightsTabStrip> — availability gating (v1.4.27 F19)", () => {
// only Overview, then pills light up as data arrives.
const html = render(<InsightsTabStrip />);
expect(html).toContain(">Overview<");
expect(html).not.toContain(">Blood Pressure<");
expect(html).not.toContain(">Blood pressure<");
expect(html).not.toContain(">Weight<");
expect(html).not.toContain(">Pulse<");
expect(html).not.toContain(">Mood<");
@@ -75,7 +75,7 @@ describe("<InsightsTabStrip> — availability gating (v1.4.27 F19)", () => {
expect(html).toContain(">Weight<");
expect(html).toContain('data-group="body"');
// Pills without data drop.
expect(html).not.toContain(">Blood Pressure<");
expect(html).not.toContain(">Blood pressure<");
expect(html).not.toContain(">Mood<");
expect(html).not.toContain(">Medication<");
expect(html).not.toContain(">Sleep<");
@@ -234,7 +234,7 @@ describe("<InsightsTabStrip> — vitals group collapse (v1.4.34 IW-D)", () => {
it("preserves the pinned flat pills inline", () => {
const html = render(<InsightsTabStrip availability={fullAvailability} />);
// Pinned pills must still be reachable directly.
expect(html).toContain(">Blood Pressure<");
expect(html).toContain(">Blood pressure<");
expect(html).toContain(">Weight<");
expect(html).toContain(">Sleep<");
expect(html).toContain(">Mood<");
@@ -361,7 +361,7 @@ describe("<InsightsTabStrip> — saved-layout visibility gate (v1.15.14 W2)", ()
visibleTileIds={visibleTileIds}
/>,
);
expect(html).not.toContain(">Blood Pressure<");
expect(html).not.toContain(">Blood pressure<");
expect(html).toContain(">Weight<");
});
@@ -63,7 +63,7 @@ describe("<ExportSection> — SSR smoke", () => {
</SettingsSectionFrame>,
);
// v1.15.7 — the section was relabelled "Export & Import" (issue #281).
expect(html).toContain("Export &amp; Import");
expect(html).toContain("Export &amp; import");
// Raw key never leaks past i18n.
expect(html).not.toContain("settings.sections.export.");
});
@@ -1,5 +1,5 @@
/**
* v1.25.11 (#148) — the "Appearance" hub (slug stays `layout`).
* v1.25.11 (#148) — the "Layout" hub (slug stays `layout`).
*
* The hub is a navigable index: it lists each module as a clickable row
* (`<a href="/settings/layout/<id>">` with the localized title + description +
@@ -0,0 +1,59 @@
/**
* Settings → Data & privacy: "What's stored" is read off the module registry,
* so every module that keeps records is named and a new one joins the list
* without an edit here.
*/
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: undefined, isLoading: true, isError: false }),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: null, isAuthenticated: true, isLoading: false }),
}));
vi.mock("@/components/settings/security-sessions-card", () => ({
SecuritySessionsCard: () => null,
}));
vi.mock("@/components/settings/security-activity-card", () => ({
SecurityActivityCard: () => null,
}));
vi.mock("@/components/settings/trusted-devices-card", () => ({
TrustedDevicesCard: () => null,
}));
import { I18nProvider } from "@/lib/i18n/context";
import { MODULE_KEYS, MODULE_REGISTRY } from "@/lib/modules/registry";
import { PrivacySection, storedModuleLabelKeys } from "../privacy-section";
function render(): string {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<PrivacySection />
</I18nProvider>,
);
}
describe("Data & privacy — what's stored", () => {
it("names every module that keeps records", () => {
const keys = storedModuleLabelKeys();
for (const key of MODULE_KEYS) {
const { category, labelKey } = MODULE_REGISTRY[key];
const holdsRecords = category !== "export" && category !== "integration";
expect(keys.includes(labelKey), key).toBe(holdsRecords);
}
});
it("lists documents, vaccinations, the cycle and workouts on the page", () => {
const html = render();
const row = html.match(/data-slot="privacy-stored-modules"[^>]*>([^<]*)</);
expect(row).not.toBeNull();
for (const label of ["Documents", "Vaccinations", "Workouts"]) {
expect(row![1]).toContain(label);
}
expect(row![1]).toMatch(/Cycle/);
expect(row![1]).not.toContain("Doctor report");
});
});
@@ -258,7 +258,7 @@ describe("settings sections — SSR smoke", () => {
it("<ApiSection> renders endpoints + tokens cards", () => {
const html = renderFramed("api", <ApiSection />);
expect(html).toContain("API &amp; Tokens");
expect(html).toContain("API &amp; tokens");
});
it("<AdvancedSection> renders the danger-zone card only (export moved to /settings/export)", () => {
@@ -433,11 +433,11 @@ describe("<SettingsShell>", () => {
// `/notifications` inbox now shares the "Notifications" label.
expect(html).toContain("Notifications");
// v1.17.1 (F-2) — the view/arrangement editors are reached through one hub
// entry (route stays `/settings/layout`). v1.25.3 — the hub is renamed
// "Appearance" (DE "Darstellung") and indexes every module's view surface;
// the route is unchanged.
// entry (route stays `/settings/layout`). v1.42 — the hub is named
// "Layout": it arranges and picks what each module shows and holds no
// theme, so "Appearance" promised a control it does not have.
expect(html).toContain('href="/settings/layout"');
expect(html).toContain(">Appearance</a>");
expect(html).toContain(">Layout</a>");
// v1.25.3 — Channels folded into Notifications, so it is no longer a
// left-side entry; Sources keeps its own.
expect(html).not.toContain('href="/settings/channels"');
@@ -451,7 +451,7 @@ describe("<SettingsShell>", () => {
expect(html).not.toContain('href="/settings/sharing"');
// The ampersand is HTML-escaped by React SSR — assert on the encoded
// form so we don't accidentally match a parser that double-escapes.
expect(html).toContain("API &amp; Tokens");
expect(html).toContain("API &amp; tokens");
// v1.18.0 (S5) — the health record is its own top-level entry.
expect(html).toContain('href="/settings/gesundheitsakte"');
expect(html).toContain("Health record");
@@ -478,10 +478,10 @@ describe("<SettingsShell>", () => {
// inbox now shares the "Benachrichtigungen" label.
expect(html).toContain("Benachrichtigungen");
// v1.17.1 (F-2) — the view editors are reached through one hub entry
// (route stays `/settings/layout`). v1.25.3 — the German nav label reads
// "Darstellung" (Appearance) to match the renamed hub.
// (route stays `/settings/layout`). v1.42 — "Layout" in German too;
// "Darstellung" now names the account page's language-and-units card.
expect(html).toContain('href="/settings/layout"');
expect(html).toContain(">Darstellung</a>");
expect(html).toContain(">Layout</a>");
// v1.25.3 — Channels ("Kanäle") folded into Notifications; Sources keeps
// its own left-side entry.
expect(html).not.toContain('href="/settings/channels"');
@@ -494,7 +494,7 @@ describe("<SettingsShell>", () => {
// v1.18.6 (W9) — the AI section is named "KI-Anbieter" in German: the
// page is about the provider / BYOK, not an "Auswertung".
expect(html).toContain("KI-Anbieter");
// API & Tokens is identical in both locales (proper noun + ampersand)
// German keeps the noun capitalised: "API & Tokens".
expect(html).toContain("API &amp; Tokens");
expect(html).toContain("Erweitert");
// "Über diese App" (About) is back as the last in-shell nav entry
@@ -0,0 +1,72 @@
/**
* Settings → Source priority lists only the sources the account has connected
* or holds data from, and opens one metric group at a time. The ladders it
* edits stay whole: a hidden source keeps its slot when a visible one moves.
*/
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { parseSourcePriority } from "@/lib/validations/source-priority";
import { moveAmongVisible, SourcesSection } from "../sources-section";
function render(inUse: string[] | undefined): string {
const client = new QueryClient({
defaultOptions: { queries: { retry: 0, staleTime: Infinity } },
});
client.setQueryData(queryKeys.sourcePriority(), {
...parseSourcePriority(null),
...(inUse ? { inUse } : {}),
});
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<SourcesSection />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("Settings → Source priority, sources in use", () => {
it("lists only metrics and sources the account uses", () => {
const html = render(["WITHINGS"]);
expect(html).toContain('data-testid="sources-metric-weight"');
// WHOOP-only metric classes have nothing in use here.
expect(html).not.toContain('data-testid="sources-metric-stress"');
expect(html).not.toContain("Apple Health");
});
it("opens the first group and collapses the rest", () => {
const html = render(["WITHINGS", "MANUAL"]);
const expanded = html.match(/aria-expanded="true"/g) ?? [];
expect(expanded).toHaveLength(1);
expect(html).toContain('aria-expanded="false"');
});
it("says so when no source is in use at all", () => {
expect(render([])).toContain('data-testid="sources-none-in-use"');
});
it("lists every source when the server sends no in-use list", () => {
expect(render(undefined)).toContain("Apple Health");
});
});
describe("moveAmongVisible", () => {
const visible = (s: string) => s !== "B";
it("swaps past a hidden neighbour, keeping its slot", () => {
expect(moveAmongVisible(["A", "B", "C"], "C", -1, visible)).toEqual([
"C",
"B",
"A",
]);
});
it("refuses a move with no visible neighbour", () => {
expect(moveAmongVisible(["A", "B", "C"], "A", -1, visible)).toBeNull();
expect(moveAmongVisible(["A", "B"], "A", 1, visible)).toBeNull();
});
});
@@ -0,0 +1,53 @@
/**
* Settings → Account: the controls that save on change (language, units,
* glucose unit, hour and date format) sit in their own card with no save
* button, apart from the profile form whose Save does not cover them.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import { I18nProvider } from "@/lib/i18n/context";
import { DisplayPreferencesCard } from "../index";
function render(): string {
const client = new QueryClient({
defaultOptions: { queries: { retry: 0, enabled: false } },
});
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<DisplayPreferencesCard isAuthenticated={false} />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("Settings → Account display card", () => {
it("holds the language select and carries no form or save button", () => {
const html = render();
expect(html).toContain('data-testid="settings-display-card"');
expect(html).toContain('id="language-select"');
expect(html).not.toContain("<form");
expect(html).not.toContain('type="submit"');
});
it("leaves the save-on-change controls out of the profile form", () => {
const src = readFileSync(join(__dirname, "../index.tsx"), "utf8");
const form = src.slice(src.indexOf("<form"), src.indexOf("</form>"));
expect(form.length).toBeGreaterThan(0);
for (const control of [
"language-select",
"UnitPreferenceSelect",
"GlucoseUnitSelect",
"TimeFormatSelect",
"DateFormatSelect",
]) {
expect(form).not.toContain(control);
}
// The timezone saves with the form, so it stays inside it.
expect(form).toContain("TimezonePicker");
});
});
@@ -2,7 +2,7 @@
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Save, User } from "lucide-react";
import { Languages, Loader2, Save, User } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
@@ -62,7 +62,7 @@ import { dateOnlyKey } from "@/lib/tz/date-only";
export { resolveInitialTimezone } from "./account-section-utils";
export function AccountSection() {
const { t, locale, setLocale, pendingLocale } = useTranslations();
const { t } = useTranslations();
const { user, isLoading, isAuthenticated, refetch } = useAuth();
// v1.16.4 — see `useMounted`: keeps the hydration render identical to
// the SSR HTML when this boundary hydrates after `/api/auth/me`
@@ -517,12 +517,10 @@ export function AccountSection() {
</div>
</div>
{/* Date of birth + language share one paired grid row so the
profile form keeps a single rhythm (every row two cells
wide on sm+). Date of birth is the bottom of the
biological-profile block; language is the only UI
preference on this card. They sit together to close the
"single-cell row" gap that broke the form's grid. */}
{/* Date of birth + timezone share one paired grid row. The
timezone stays on this card because it saves with the form
(its own PUT runs beside the profile PUT); the display
preferences that save on change live in their own card below. */}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="dob">{t("settings.dateOfBirth")}</Label>
@@ -544,44 +542,7 @@ export function AccountSection() {
</p>
<FieldError id="dob-error" message={fieldErrors.dateOfBirth} />
</div>
<div className="space-y-2">
<Label htmlFor="language-select">{t("settings.language")}</Label>
{/* While a switch waits on its message bundle (dynamic
import per locale), show the target value and lock the
control — the context only flips locale + strings
together once the bundle arrived. */}
<NativeSelect
id="language-select"
value={pendingLocale ?? locale}
disabled={pendingLocale !== null}
aria-busy={pendingLocale !== null}
onChange={(e) => setLocale(e.target.value as Locale)}
>
{locales.map((loc) => (
<option key={loc} value={loc}>
{localeLabels[loc as Locale]}
</option>
))}
</NativeSelect>
<p className="text-muted-foreground text-xs">
{t("settings.languageDescription")}
</p>
</div>
</div>
{/* Timezone, unit system, glucose unit and the date/hour formats
share one grid block — all personal display preferences (like
language above). The glucose unit is its own dropdown rather
than a branch of the unit system because metric countries are
split on which unit they read glucose in. Every dropdown here
PATCHes its own endpoint on change; the timezone saves through
the form's submit handler. */}
<div className="grid gap-4 sm:grid-cols-2">
<TimezonePicker value={timezone} onChange={setTimezone} />
<UnitPreferenceSelect isAuthenticated={isAuthenticated} />
<GlucoseUnitSelect isAuthenticated={isAuthenticated} />
<TimeFormatSelect isAuthenticated={isAuthenticated} />
<DateFormatSelect isAuthenticated={isAuthenticated} />
</div>
{/* v1.7.0 — optional patient-identity fields surfaced on the
@@ -702,6 +663,8 @@ export function AccountSection() {
{recentProof.dialog}
</SettingsCard>
<DisplayPreferencesCard isAuthenticated={isAuthenticated} />
{/* v1.18.0 (S5) — injection-site exclusions moved to the dedicated
Medikamente settings section, where every medication-specific
preference now lives. */}
@@ -734,3 +697,59 @@ export function AccountSection() {
</div>
);
}
/**
* Language, units and formats: every control here saves the moment it
* changes (each PATCHes its own endpoint), so they sit in a card with no save
* button. Inside the profile form they read as if the form's Save covered
* them, and a person who changed the language and then cancelled the form
* found it changed anyway.
*/
export function DisplayPreferencesCard({
isAuthenticated,
}: {
isAuthenticated: boolean;
}) {
const { t, locale, setLocale, pendingLocale } = useTranslations();
return (
<SettingsCard data-testid="settings-display-card">
<SettingsCardHeader
icon={Languages}
title={t("settings.displayCard.title")}
description={t("settings.displayCard.description")}
/>
{/* The glucose unit is its own dropdown rather than a branch of the
unit system because metric countries are split on which unit they
read glucose in. */}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="language-select">{t("settings.language")}</Label>
{/* While a switch waits on its message bundle (dynamic import per
locale), show the target value and lock the control — the
context only flips locale + strings together once the bundle
arrived. */}
<NativeSelect
id="language-select"
value={pendingLocale ?? locale}
disabled={pendingLocale !== null}
aria-busy={pendingLocale !== null}
onChange={(e) => setLocale(e.target.value as Locale)}
>
{locales.map((loc) => (
<option key={loc} value={loc}>
{localeLabels[loc as Locale]}
</option>
))}
</NativeSelect>
<p className="text-muted-foreground text-xs">
{t("settings.languageDescription")}
</p>
</div>
<UnitPreferenceSelect isAuthenticated={isAuthenticated} />
<GlucoseUnitSelect isAuthenticated={isAuthenticated} />
<TimeFormatSelect isAuthenticated={isAuthenticated} />
<DateFormatSelect isAuthenticated={isAuthenticated} />
</div>
</SettingsCard>
);
}
+10 -4
View File
@@ -91,6 +91,7 @@ function MeasurementTokensCard() {
description: t("settings.measurementsToken.description"),
detail: t("settings.measurementsToken.detail"),
scopeNote: t("settings.measurementsToken.scopeNote"),
namePlaceholder: t("settings.measurementsToken.namePlaceholder"),
createFailed: t("settings.measurementsToken.createFailed"),
}}
/>
@@ -114,6 +115,7 @@ function WorkoutTokensCard() {
description: t("settings.workoutsToken.description"),
detail: t("settings.workoutsToken.detail"),
scopeNote: t("settings.workoutsToken.scopeNote"),
namePlaceholder: t("settings.workoutsToken.namePlaceholder"),
createFailed: t("settings.workoutsToken.createFailed"),
}}
/>
@@ -137,6 +139,7 @@ function DocumentTokensCard() {
description: t("settings.documentsToken.description"),
detail: t("settings.documentsToken.detail"),
scopeNote: t("settings.documentsToken.scopeNote"),
namePlaceholder: t("settings.documentsToken.namePlaceholder"),
createFailed: t("settings.documentsToken.createFailed"),
}}
/>
@@ -161,6 +164,7 @@ function IngestTokenCard({
description: string;
detail: string;
scopeNote: string;
namePlaceholder: string;
createFailed: string;
};
}) {
@@ -234,16 +238,18 @@ function IngestTokenCard({
/>
<div className="space-y-3">
<p className="text-sm leading-relaxed">{copy.detail}</p>
<p className="text-muted-foreground text-sm leading-relaxed">
{copy.scopeNote}
{/* "Shown once" and what the token can do are one statement about
the credential, so they read as one paragraph. */}
<p className="text-sm leading-relaxed">
{copy.detail} {copy.scopeNote}
</p>
<form onSubmit={handleCreate} className="flex items-center gap-2">
<Input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder={t("settings.tokenNamePlaceholder")}
placeholder={copy.namePlaceholder}
aria-label={t("settings.tokenNameLabel")}
maxLength={100}
className="flex-1"
/>
@@ -1,7 +1,7 @@
"use client";
/**
* Settings → Appearance → Documents.
* Settings → Layout → Documents.
*
* Reached from the wrench beside the vault's Upload button, the way the
* illness journal's wrench reaches its own page. Two choices, each in its own
+2 -2
View File
@@ -12,7 +12,7 @@ import { DocumentsSection } from "./documents-section";
import { VorsorgeSection } from "./vorsorge-section";
/**
* v1.25.11 (#148) — single source of truth for the "Appearance" (slug
* v1.25.11 (#148) — single source of truth for the "Layout" (slug
* `layout`) hub + its per-module subpages.
*
* "How my app looks and is arranged" was scattered across several unrelated
@@ -21,7 +21,7 @@ import { VorsorgeSection } from "./vorsorge-section";
* back into a HUB → SUBPAGE model: `/settings/layout` lists the modules as
* clickable rows, and each row opens `/settings/layout/<id>`, which renders
* ONLY that module's section (the same existing section component, verbatim)
* with a "← Appearance" back-link.
* with a "← Layout" back-link.
*
* This module is intentionally NOT `"use client"`: the client hub
* (`layout-section.tsx`) AND the server subpage route
@@ -11,7 +11,7 @@ import { useTranslations } from "@/lib/i18n/context";
import type { ModuleKey } from "@/lib/modules/registry";
/**
* Client gate for an Appearance subpage (`/settings/layout/<module>`).
* Client gate for a Layout subpage (`/settings/layout/<module>`).
*
* The subpage is a real URL a person can land on (a bookmark, the hub's own
* link, a link somebody sent) with its module switched off. It answers in
+2 -2
View File
@@ -11,14 +11,14 @@ import { isSurfaceVisible } from "@/lib/modules/surface";
import { LAYOUT_GROUPS } from "./layout-groups";
/**
* v1.25.11 (#148) — the "Appearance" hub (slug stays `layout`).
* v1.25.11 (#148) — the "Layout" hub (slug stays `layout`).
*
* The hub is a navigable index, NOT a stacked composition: it lists each
* module as a clickable row (title + description + chevron) that opens the
* module's own subpage at `/settings/layout/<id>`. Nothing is stacked inline;
* the per-module view/sort surfaces live on their subpages so the hub reads as
* a short, scannable directory. The visible page heading + subtitle
* ("Appearance" / "Personalize how …") are painted by `SettingsShell` from the
* ("Layout" / "Choose what …") are painted by `SettingsShell` from the
* `layout` slug; this body renders only the row list.
*
* Per-module gating fails OPEN (`!== false`): a missing key reads as enabled so
+7 -13
View File
@@ -232,15 +232,6 @@ function McpEnableCard() {
icon={Plug}
title={t("settings.mcp.enableTitle")}
description={t("settings.mcp.enableDescription")}
status={
enabled ? (
<Badge className="bg-success/15 text-success">
{t("settings.mcp.enabled")}
</Badge>
) : (
<Badge variant="outline">{t("settings.mcp.disabled")}</Badge>
)
}
/>
<div className="space-y-4">
@@ -371,10 +362,12 @@ function McpTokensCard() {
title={t("settings.mcp.tokensTitle")}
description={t("settings.mcp.tokensDescription")}
/>
<p className="text-sm">{t("settings.mcp.tokensDetail")}</p>
<div className="space-y-4">
<p className="text-sm leading-relaxed">{t("settings.mcp.scopeNote")}</p>
{/* "Shown once" is part of the same statement as the scope note, so
it reads inside that paragraph rather than as a line of its own. */}
<p className="text-sm leading-relaxed">
{t("settings.mcp.tokensDetail")} {t("settings.mcp.scopeNote")}
</p>
<div className="border-border bg-muted/30 space-y-2 rounded-lg border p-3">
<div className="flex items-center justify-between gap-4">
@@ -396,7 +389,8 @@ function McpTokensCard() {
<Input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder={t("settings.tokenNamePlaceholder")}
placeholder={t("settings.mcp.tokenNamePlaceholder")}
aria-label={t("settings.tokenNameLabel")}
maxLength={100}
className="flex-1"
/>
+29 -5
View File
@@ -34,6 +34,7 @@ import { SecurityActivityCard } from "@/components/settings/security-activity-ca
import { TrustedDevicesCard } from "@/components/settings/trusted-devices-card";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { MODULE_KEYS, MODULE_REGISTRY } from "@/lib/modules/registry";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
@@ -50,8 +51,22 @@ interface PrivacySummary {
};
}
/**
* The modules that keep records of their own, read off the module registry
* so a new module joins the "What's stored" list without anyone editing it.
* The hand-kept list this replaced had stopped at six lines while documents,
* vaccinations, the cycle and workouts were all being stored. Two categories
* hold nothing of their own: an export assembles what other modules store,
* and the connector's tokens are covered by the integration line.
*/
export function storedModuleLabelKeys(): string[] {
return MODULE_KEYS.map((key) => MODULE_REGISTRY[key])
.filter((m) => m.category !== "export" && m.category !== "integration")
.map((m) => m.labelKey);
}
export function PrivacySection() {
const { t } = useTranslations();
const { t, locale } = useTranslations();
const { isAuthenticated } = useAuth();
const {
@@ -91,11 +106,20 @@ export function PrivacySection() {
icon={Database}
title={t("settings.privacy.stored.title")}
/>
<ul className="text-muted-foreground list-disc space-y-1 text-sm">
<ul
className="list-disc space-y-1 pl-5 text-sm"
data-testid="privacy-stored-list"
>
<li>{t("settings.privacy.stored.measurements")}</li>
<li>{t("settings.privacy.stored.medications")}</li>
<li>{t("settings.privacy.stored.moodLabs")}</li>
<li>{t("settings.privacy.stored.coach")}</li>
<li>{t("settings.privacy.stored.profile")}</li>
<li data-slot="privacy-stored-modules">
{t("settings.privacy.stored.modules", {
list: new Intl.ListFormat(locale, {
style: "long",
type: "conjunction",
}).format(storedModuleLabelKeys().map((key) => t(key))),
})}
</li>
<li>{t("settings.privacy.stored.integrations")}</li>
<li>{t("settings.privacy.stored.security")}</li>
</ul>
@@ -29,6 +29,8 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { EmptyState } from "@/components/ui/empty-state";
import { formatDate } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -193,11 +195,13 @@ export function PasskeyListSection({
description={t("settings.passkeysDescription")}
/>
<div>
<div className="space-y-4">
{!passkeys || passkeys.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("settings.noPasskeys")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("settings.noPasskeys")}
/>
) : (
<ul className="space-y-2" data-testid="passkeys-list">
{passkeys.map((pk) => (
@@ -341,6 +345,16 @@ export function PasskeyListSection({
</ul>
)}
{msg && !reauthOpen && (
<div
role="alert"
className="text-destructive flex items-center gap-2 text-sm"
>
<AlertTriangle className="h-4 w-4 shrink-0" />
{msg}
</div>
)}
<AlertDialog
open={reauthOpen}
onOpenChange={(open) => {
@@ -349,11 +363,10 @@ export function PasskeyListSection({
if (open) setMsg(null);
}}
>
<div className="mt-4">
<SettingsCardActions>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="outline"
className="min-h-11 sm:min-h-9"
disabled={add.isPending}
>
@@ -365,7 +378,7 @@ export function PasskeyListSection({
{t("settings.addPasskey")}
</Button>
</AlertDialogTrigger>
</div>
</SettingsCardActions>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
@@ -475,16 +488,6 @@ export function PasskeyListSection({
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{msg && !reauthOpen && (
<div
role="alert"
className="text-destructive mt-3 flex items-center gap-2 text-sm"
>
<AlertTriangle className="h-4 w-4 shrink-0" />
{msg}
</div>
)}
</div>
</SettingsCard>
);
@@ -28,6 +28,7 @@ import { Input } from "@/components/ui/input";
import { EmptyState } from "@/components/ui/empty-state";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { ApiError, apiDelete, apiPatch, apiPost } from "@/lib/api/api-fetch";
@@ -197,7 +198,7 @@ export function SecurityKeysCard({
description={t("settings.security.keys.description")}
/>
<div>
<div className="space-y-4">
{keys.length === 0 ? (
<EmptyState
variant="plain"
@@ -335,10 +336,19 @@ export function SecurityKeysCard({
</ul>
)}
<div className="mt-4">
{error && (
<div
role="alert"
className="text-destructive flex items-center gap-2 text-sm"
>
<AlertTriangle className="h-4 w-4 shrink-0" />
{error}
</div>
)}
<SettingsCardActions>
<Button
type="button"
variant="outline"
className="min-h-11 sm:min-h-9"
onClick={() => add.mutate(undefined)}
disabled={add.isPending}
@@ -350,7 +360,7 @@ export function SecurityKeysCard({
)}
{t("settings.security.keys.add")}
</Button>
</div>
</SettingsCardActions>
<ExistingFactorReauthDialog
open={reauthOpen}
onOpenChange={(open) => {
@@ -362,16 +372,6 @@ export function SecurityKeysCard({
error={reauthError}
onProof={(proof) => add.mutate(proof)}
/>
{error && (
<div
role="alert"
className="text-destructive mt-3 flex items-center gap-2 text-sm"
>
<AlertTriangle className="h-4 w-4 shrink-0" />
{error}
</div>
)}
</div>
</SettingsCard>
);
+199 -185
View File
@@ -23,7 +23,7 @@
*/
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { FileText, KeyRound, Link2, Trash2 } from "lucide-react";
import { FileText, KeyRound, Link2, List, Trash2 } from "lucide-react";
import {
AlertDialog,
@@ -138,213 +138,227 @@ function ShareLinksCard() {
[links],
);
// Two cards: making a link and the links already out are separate jobs, and
// a growing list under the form pushed the create action out of reach.
return (
<SettingsCard>
<SettingsCardHeader
icon={Link2}
title={t("settings.sharing.createTitle")}
description={t("settings.sharing.createDescription")}
/>
<p className="text-sm">{t("settings.sharing.createDetail")}</p>
<>
<SettingsCard id="share-link-create" className="scroll-mt-28">
<SettingsCardHeader
icon={Link2}
title={t("settings.sharing.createTitle")}
description={t("settings.sharing.createDescription")}
/>
<p className="text-sm">{t("settings.sharing.createDetail")}</p>
<ShareLinkCreateForm
key={prefill?.key ?? "new"}
initialLabel={prefill?.label}
initialRangeDays={prefill?.rangeDays}
initialExpiryDays={prefill?.expiryDays}
/>
<ShareLinkCreateForm
key={prefill?.key ?? "new"}
initialLabel={prefill?.label}
initialRangeDays={prefill?.rangeDays}
initialExpiryDays={prefill?.expiryDays}
/>
</SettingsCard>
{isError ? (
// A read failure must not read as "no active links" on a clinical
// sharing surface — that is the §6 fall-through this card must avoid.
<QueryErrorCard onRetry={() => refetch()} />
) : isLoading ? (
<div className="space-y-2" data-testid="share-links-loading">
<Skeleton className="h-24 w-full rounded-lg" />
<Skeleton className="h-24 w-full rounded-lg" />
</div>
) : (
<>
<div className="space-y-2">
<h3 className="text-sm font-medium">
{t("settings.sharing.activeTitle")}
</h3>
{activeLinks.length === 0 ? (
<EmptyState
size="compact"
data-testid="share-active-empty"
title={t("settings.sharing.noActive")}
/>
) : (
<ul className="space-y-2" data-testid="share-active-list">
{activeLinks.map((link) => (
<li
key={link.id}
className="bg-muted/30 border-border space-y-2 rounded-lg border p-3"
>
<div className="flex flex-wrap items-start justify-between gap-2">
<p className="min-w-0 flex-1 text-sm font-medium break-words">
{link.label}
</p>
<div className="flex flex-wrap items-center gap-1.5">
<Badge className="bg-success/15 text-success text-xs">
{t("settings.sharing.statusActive")}
</Badge>
{link.protected && (
<Badge
variant="outline"
className="gap-1 text-xs"
data-testid="share-protected-badge"
>
<KeyRound className="h-2.5 w-2.5" />
{t("settings.sharing.protected")}
</Badge>
)}
{link.documentCount > 0 && (
<Badge
variant="outline"
className="gap-1 text-xs"
data-testid="share-doc-count-badge"
aria-label={t("settings.sharing.documentCount", {
count: link.documentCount,
})}
>
<FileText className="h-2.5 w-2.5" />
{link.documentCount}
</Badge>
)}
</div>
</div>
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.sharing.created")}:
</span>{" "}
{formatDate(link.createdAt)}
</p>
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.sharing.expires")}:
</span>{" "}
{formatDateTime(link.expiresAt)}
</p>
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.sharing.accessCount")}:
</span>{" "}
{link.accessCount}
{link.lastAccessAt
? ` · ${formatDateTime(link.lastAccessAt)}`
: ""}
</p>
{/* §12 — a right-aligned row action, not a full-width
destructive strip stacked once per link (which reads as
an alarm wall on a list of shares). */}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="ml-auto flex min-h-11 w-fit sm:min-h-9"
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
{t("settings.sharing.revoke")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.sharing.revoke")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.sharing.revokeDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={() => revokeMutation.mutate(link.id)}
>
{t("settings.sharing.revoke")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</li>
))}
</ul>
)}
<SettingsCard data-testid="share-links-card">
<SettingsCardHeader
icon={List}
title={t("settings.sharing.activeTitle")}
description={t("settings.sharing.activeDescription")}
/>
{isError ? (
// A read failure must not read as "no active links" on a clinical
// sharing surface — that is the §6 fall-through this card must avoid.
<QueryErrorCard onRetry={() => refetch()} />
) : isLoading ? (
<div className="space-y-2" data-testid="share-links-loading">
<Skeleton className="h-24 w-full rounded-lg" />
<Skeleton className="h-24 w-full rounded-lg" />
</div>
{inactiveLinks.length > 0 && (
) : (
<>
<div className="space-y-2">
<button
type="button"
onClick={() => setShowRevoked((prev) => !prev)}
className="text-foreground hover:text-primary text-sm font-medium transition-colors"
>
{t("settings.sharing.inactiveTitle", {
count: inactiveLinks.length,
})}
</button>
{showRevoked && (
<ul className="space-y-2" data-testid="share-inactive-list">
{inactiveLinks.map((link) => (
{activeLinks.length === 0 ? (
<EmptyState
size="compact"
data-testid="share-active-empty"
title={t("settings.sharing.noActive")}
/>
) : (
<ul className="space-y-2" data-testid="share-active-list">
{activeLinks.map((link) => (
<li
key={link.id}
className="bg-muted/20 border-border space-y-1.5 rounded-lg border p-3"
className="bg-muted/30 border-border space-y-2 rounded-lg border p-3"
>
<div className="flex flex-wrap items-start justify-between gap-2">
<p className="min-w-0 flex-1 text-sm font-medium break-words">
{link.label}
</p>
<Badge variant="secondary" className="text-xs">
{link.needsReselection
? t("settings.sharing.statusNeedsReselect")
: link.revokedAt
? t("settings.sharing.statusRevoked")
: t("settings.sharing.statusExpired")}
</Badge>
</div>
{/* A link whose frozen scope predates the selection model.
It served a scope nobody chose, so it was closed; the
owner re-mints above with the label, window and expiry
still in front of them. */}
{link.needsReselection ? (
<div
className="space-y-1.5"
data-testid={`share-needs-reselect-${link.id}`}
>
<p className="text-muted-foreground text-xs">
{t("settings.sharing.reselectReason")}
</p>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
data-testid={`share-reselect-${link.id}`}
onClick={() => setPrefill(prefillFrom(link))}
>
{t("settings.sharing.reselectAction")}
</Button>
<div className="flex flex-wrap items-center gap-1.5">
<Badge className="bg-success/15 text-success text-xs">
{t("settings.sharing.statusActive")}
</Badge>
{link.protected && (
<Badge
variant="outline"
className="gap-1 text-xs"
data-testid="share-protected-badge"
>
<KeyRound className="h-2.5 w-2.5" />
{t("settings.sharing.protected")}
</Badge>
)}
{link.documentCount > 0 && (
<Badge
variant="outline"
className="gap-1 text-xs"
data-testid="share-doc-count-badge"
aria-label={t("settings.sharing.documentCount", {
count: link.documentCount,
})}
>
<FileText className="h-2.5 w-2.5" />
{link.documentCount}
</Badge>
)}
</div>
) : null}
</div>
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.sharing.created")}:
</span>{" "}
{formatDate(link.createdAt)}
</p>
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.sharing.expires")}:
</span>{" "}
{formatDateTime(link.expiresAt)}
</p>
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.sharing.accessCount")}:
</span>{" "}
{link.accessCount}
{link.lastAccessAt
? ` · ${formatDateTime(link.lastAccessAt)}`
: ""}
</p>
{/* §12 — a right-aligned row action, not a full-width
destructive strip stacked once per link (which reads as
an alarm wall on a list of shares). */}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="ml-auto flex min-h-11 w-fit sm:min-h-9"
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
{t("settings.sharing.revoke")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.sharing.revoke")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.sharing.revokeDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={() => revokeMutation.mutate(link.id)}
>
{t("settings.sharing.revoke")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</li>
))}
</ul>
)}
</div>
)}
</>
)}
</SettingsCard>
{inactiveLinks.length > 0 && (
<div className="space-y-2">
<button
type="button"
onClick={() => setShowRevoked((prev) => !prev)}
className="text-foreground hover:text-primary text-sm font-medium transition-colors"
>
{t("settings.sharing.inactiveTitle", {
count: inactiveLinks.length,
})}
</button>
{showRevoked && (
<ul className="space-y-2" data-testid="share-inactive-list">
{inactiveLinks.map((link) => (
<li
key={link.id}
className="bg-muted/20 border-border space-y-1.5 rounded-lg border p-3"
>
<div className="flex flex-wrap items-start justify-between gap-2">
<p className="min-w-0 flex-1 text-sm font-medium break-words">
{link.label}
</p>
<Badge variant="secondary" className="text-xs">
{link.needsReselection
? t("settings.sharing.statusNeedsReselect")
: link.revokedAt
? t("settings.sharing.statusRevoked")
: t("settings.sharing.statusExpired")}
</Badge>
</div>
{/* A link whose frozen scope predates the selection model.
It served a scope nobody chose, so it was closed; the
owner re-mints above with the label, window and expiry
still in front of them. */}
{link.needsReselection ? (
<div
className="space-y-1.5"
data-testid={`share-needs-reselect-${link.id}`}
>
<p className="text-muted-foreground text-xs">
{t("settings.sharing.reselectReason")}
</p>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
data-testid={`share-reselect-${link.id}`}
onClick={() => {
setPrefill(prefillFrom(link));
// The form it fills sits in the card above.
document
.getElementById("share-link-create")
?.scrollIntoView({ block: "start" });
}}
>
{t("settings.sharing.reselectAction")}
</Button>
</div>
) : null}
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.sharing.accessCount")}:
</span>{" "}
{link.accessCount}
</p>
</li>
))}
</ul>
)}
</div>
)}
</>
)}
</SettingsCard>
</>
);
}
+171 -70
View File
@@ -69,6 +69,38 @@ import { apiGet, apiPut } from "@/lib/api/api-fetch";
interface ResolvedPriority extends Required<MetricPriority> {
metricPriority: Required<MetricPriority>;
deviceTypePriority: DeviceTypePriority;
/**
* The ranked sources this account has connected or holds data from. The
* editor lists only these; the ladders it edits keep every source.
*/
inUse?: string[];
}
type LadderSource = Required<MetricPriority>[SourcePriorityMetricKey][number];
/**
* Move `source` one step past its nearest VISIBLE neighbour in `list`.
*
* The editor hides sources the account does not use, so an adjacent swap on
* the full ladder would trade places with an invisible entry and look like a
* button that did nothing. Swapping with the next visible entry keeps the
* hidden ones in their slots. Returns `null` when there is no visible
* neighbour in that direction.
*/
export function moveAmongVisible<T>(
list: readonly T[],
source: T,
delta: -1 | 1,
isVisible: (entry: T) => boolean,
): T[] | null {
const from = list.indexOf(source);
if (from < 0) return null;
let to = from + delta;
while (to >= 0 && to < list.length && !isVisible(list[to])) to += delta;
if (to < 0 || to >= list.length) return null;
const next = [...list];
[next[from], next[to]] = [next[to], next[from]];
return next;
}
const METRIC_LABEL_KEYS: Record<SourcePriorityMetricKey, string> = {
@@ -207,13 +239,13 @@ export function SourcesSection() {
function moveSource(
metric: SourcePriorityMetricKey,
index: number,
source: LadderSource,
delta: -1 | 1,
) {
if (!priority) return;
const current =
priority.metricPriority[metric] ?? DEFAULT_SOURCE_PRIORITY[metric];
const list = reorderLadder(current, index, delta);
const list = moveAmongVisible(current, source, delta, isSourceVisible);
if (!list) return;
const nextMetric = { ...priority.metricPriority, [metric]: list };
setDraft({
@@ -262,6 +294,46 @@ export function SourcesSection() {
const dirty = draft !== null && priority !== null;
// An older server sends no `inUse`; then every source stays listed.
const remoteInUse = remote?.inUse;
const inUse = useMemo(
() => (remoteInUse ? new Set(remoteInUse) : null),
[remoteInUse],
);
function isSourceVisible(source: string): boolean {
return inUse === null || inUse.has(source);
}
// The metrics worth showing: those with at least one source in use. A
// metric nobody delivers has nothing to rank.
const visibleMetrics = useMemo(() => {
if (!priority) return [] as SourcePriorityMetricKey[];
return SOURCE_PRIORITY_METRIC_KEYS.filter((metric) =>
(priority.metricPriority[metric] ?? DEFAULT_SOURCE_PRIORITY[metric]).some(
(source) => inUse === null || inUse.has(source),
),
);
}, [priority, inUse]);
// Every group starts collapsed except the first, so the page opens on one
// worked example instead of twenty open ladders. Toggled groups are
// remembered by key; the first group's default is the inverse.
const [toggledMetrics, setToggledMetrics] = useState<
ReadonlySet<SourcePriorityMetricKey>
>(new Set());
function isMetricOpen(metric: SourcePriorityMetricKey): boolean {
const openByDefault = metric === visibleMetrics[0];
return toggledMetrics.has(metric) ? !openByDefault : openByDefault;
}
function toggleMetric(metric: SourcePriorityMetricKey) {
setToggledMetrics((prev) => {
const next = new Set(prev);
if (next.has(metric)) next.delete(metric);
else next.add(metric);
return next;
});
}
// The per-metric expander shows every metric whose ladder has been
// overridden, plus a hint when none has. Counter lives next to the
// expander label so the section "asks" for attention proportional
@@ -303,69 +375,100 @@ export function SourcesSection() {
{isLoading || !priority ? (
<SourcesSkeletonList />
) : visibleMetrics.length === 0 ? (
<EmptyState
variant="plain"
size="compact"
data-testid="sources-none-in-use"
title={t("settings.sections.sources.noneInUse")}
/>
) : (
<div className="space-y-3">
{SOURCE_PRIORITY_METRIC_KEYS.map((metric) => {
const list =
{visibleMetrics.map((metric) => {
const list = (
priority.metricPriority[metric] ??
DEFAULT_SOURCE_PRIORITY[metric];
DEFAULT_SOURCE_PRIORITY[metric]
).filter(isSourceVisible);
const open = isMetricOpen(metric);
const panelId = `sources-metric-${metric}`;
return (
<div
key={metric}
className="border-border bg-background/30 space-y-2 rounded-md border p-3"
data-testid={`sources-metric-${metric}`}
className="border-border bg-background/30 rounded-md border"
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
<button
type="button"
onClick={() => toggleMetric(metric)}
aria-expanded={open}
aria-controls={panelId}
className="hover:bg-muted/40 flex min-h-11 w-full items-center gap-2 rounded-md px-3 py-2 text-left transition-colors"
>
<ChevronDown
aria-hidden="true"
className={`text-muted-foreground size-4 shrink-0 transition-transform ${
open ? "rotate-180" : ""
}`}
/>
<span className="min-w-0 flex-1 text-sm font-medium">
{t(METRIC_LABEL_KEYS[metric])}
</span>
</div>
<ul className="space-y-1">
{list.map((source, index) => (
<li
key={`${metric}-${source}-${index}`}
className="border-border bg-card flex items-center gap-2 rounded-md border px-2 py-1.5"
>
<span className="text-muted-foreground w-5 text-xs font-medium tabular-nums">
{index + 1}.
</span>
<span className="flex-1 text-sm">
{t(MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS[source])}
</span>
{/* Up/down sit side by side at every width: two
44 px buttons leave a 390 px row ~170 px for the
source name. Stacking them on phones (v1.4.27
MB2) doubled every row and pushed this page past
twelve thousand pixels. */}
<div className="flex shrink-0 gap-1 sm:gap-2">
<Button
type="button"
variant="ghost"
size="icon"
className="h-11 w-11"
onClick={() => moveSource(metric, index, -1)}
disabled={index === 0 || saveMutation.isPending}
aria-label={t("settings.sections.sources.moveUp")}
>
<ArrowUp className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-11 w-11"
onClick={() => moveSource(metric, index, 1)}
disabled={
index === list.length - 1 ||
saveMutation.isPending
}
aria-label={t("settings.sections.sources.moveDown")}
>
<ArrowDown className="h-4 w-4" />
</Button>
</div>
</li>
))}
</ul>
{/* Collapsed, the group still says who wins. */}
{!open && list[0] ? (
<span className="text-muted-foreground shrink-0 truncate text-xs">
{t(MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS[list[0]])}
</span>
) : null}
</button>
{open ? (
<ul id={panelId} className="space-y-1 px-3 pb-3">
{list.map((source, index) => (
<li
key={`${metric}-${source}`}
className="border-border bg-card flex items-center gap-2 rounded-md border px-2 py-1.5"
>
<span className="text-muted-foreground w-5 text-xs font-medium tabular-nums">
{index + 1}.
</span>
<span className="flex-1 text-sm">
{t(MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS[source])}
</span>
{/* Up/down sit side by side at every width: two
44 px buttons leave a 390 px row ~170 px for the
source name. */}
<div className="flex shrink-0 gap-1 sm:gap-2">
<Button
type="button"
variant="ghost"
size="icon"
className="h-11 w-11"
onClick={() => moveSource(metric, source, -1)}
disabled={index === 0 || saveMutation.isPending}
aria-label={t("settings.sections.sources.moveUp")}
>
<ArrowUp className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-11 w-11"
onClick={() => moveSource(metric, source, 1)}
disabled={
index === list.length - 1 ||
saveMutation.isPending
}
aria-label={t(
"settings.sections.sources.moveDown",
)}
>
<ArrowDown className="h-4 w-4" />
</Button>
</div>
</li>
))}
</ul>
) : null}
</div>
);
})}
@@ -607,9 +710,9 @@ export function SourcesSection() {
/**
* Skeleton placeholder rendered while `/api/auth/me/source-priority`
* is in flight. Reserves one row per `SOURCE_PRIORITY_METRIC_KEYS`
* entry at roughly the loaded height so the page does not jump when
* the fetched ladder list swaps in. The pulsing animation honours
* is in flight. Mirrors the loaded shape: the first group open with a
* couple of rows, the rest collapsed to their header line, so the page does
* not jump when the ladders swap in. The pulsing animation honours
* `prefers-reduced-motion` via Tailwind's `motion-reduce:animate-none`.
*/
function SourcesSkeletonList() {
@@ -619,17 +722,15 @@ function SourcesSkeletonList() {
data-testid="sources-skeleton"
aria-hidden="true"
>
{SOURCE_PRIORITY_METRIC_KEYS.map((metric) => (
<div
key={metric}
className="border-border bg-background/30 space-y-2 rounded-md border p-3"
>
<Skeleton className="h-4 w-40" />
<div className="space-y-1">
<Skeleton className="h-9 w-full rounded-md" />
<Skeleton className="h-9 w-full rounded-md" />
</div>
</div>
<div className="border-border bg-background/30 space-y-2 rounded-md border p-3">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-14 w-full rounded-md" />
<Skeleton className="h-14 w-full rounded-md" />
</div>
{/* One collapsed line per remaining metric: the upper bound, so the
action row below never jumps down when the ladders arrive. */}
{SOURCE_PRIORITY_METRIC_KEYS.slice(1).map((metric) => (
<Skeleton key={metric} className="h-11 w-full rounded-md" />
))}
</div>
);
@@ -0,0 +1,70 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
measurement: { findFirst: vi.fn() },
user: { findUnique: vi.fn() },
withingsConnection: { findUnique: vi.fn() },
whoopConnection: { findUnique: vi.fn() },
fitbitConnection: { findUnique: vi.fn() },
googleHealthConnection: { findUnique: vi.fn() },
},
}));
import { prisma } from "@/lib/db";
import {
getSourcesInUse,
RANKABLE_SOURCES,
} from "@/lib/integrations/sources-in-use";
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue(null);
vi.mocked(prisma.whoopConnection.findUnique).mockResolvedValue(null);
vi.mocked(prisma.fitbitConnection.findUnique).mockResolvedValue(null);
vi.mocked(prisma.googleHealthConnection.findUnique).mockResolvedValue(null);
});
describe("getSourcesInUse", () => {
it("lists a source the account holds data from", async () => {
vi.mocked(prisma.measurement.findFirst).mockImplementation(((args: {
where: { source: string };
}) =>
Promise.resolve(
args.where.source === "MANUAL" ? { id: "m1" } : null,
)) as never);
expect(await getSourcesInUse("u1")).toEqual(["MANUAL"]);
});
it("lists a connected integration that has not delivered yet", async () => {
vi.mocked(prisma.measurement.findFirst).mockResolvedValue(null);
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue({
userId: "u1",
} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
polarAccessTokenEncrypted: "x",
ouraAccessTokenEncrypted: null,
stravaAccessTokenEncrypted: null,
} as never);
const inUse = await getSourcesInUse("u1");
expect(inUse).toContain("WITHINGS");
expect(inUse).toContain("POLAR");
expect(inUse).not.toContain("OURA");
});
it("lists nothing for an account with no data and no connection", async () => {
vi.mocked(prisma.measurement.findFirst).mockResolvedValue(null);
expect(await getSourcesInUse("u1")).toEqual([]);
});
it("probes every rankable source exactly once", async () => {
vi.mocked(prisma.measurement.findFirst).mockResolvedValue(null);
await getSourcesInUse("u1");
expect(prisma.measurement.findFirst).toHaveBeenCalledTimes(
RANKABLE_SOURCES.length,
);
});
});
+78
View File
@@ -0,0 +1,78 @@
import type { MeasurementSource } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { DEFAULT_SOURCE_PRIORITY } from "@/lib/validations/source-priority";
/**
* Every source a source-priority ladder can rank, in a stable order.
*
* Derived from the default ladders rather than listed by hand, so a source
* added to a ladder is probed without a second edit here.
*/
export const RANKABLE_SOURCES: readonly MeasurementSource[] = Array.from(
new Set(Object.values(DEFAULT_SOURCE_PRIORITY).flat()),
) as MeasurementSource[];
/**
* The rankable sources this account actually has: a source it holds at least
* one measurement from, or an integration it has connected that has not
* delivered yet.
*
* The Settings ladder lists only these. Ranking a source the person has never
* used decides nothing, and ten of them per metric buried the two that do.
* The ladders themselves stay whole: hidden sources keep their slot, so a
* source that starts delivering later lands where the ladder already put it.
*
* Each data probe is a `LIMIT 1` on the `(userId, source, measuredAt)` index,
* so the cost is one index lookup per rankable source, not a scan.
*/
export async function getSourcesInUse(
userId: string,
): Promise<MeasurementSource[]> {
const [withData, user, withings, whoop, fitbit, googleHealth] =
await Promise.all([
Promise.all(
RANKABLE_SOURCES.map((source) =>
prisma.measurement
.findFirst({ where: { userId, source }, select: { id: true } })
.then((row) => (row ? source : null)),
),
),
prisma.user.findUnique({
where: { id: userId },
select: {
polarAccessTokenEncrypted: true,
ouraAccessTokenEncrypted: true,
stravaAccessTokenEncrypted: true,
},
}),
prisma.withingsConnection.findUnique({
where: { userId },
select: { userId: true },
}),
prisma.whoopConnection.findUnique({
where: { userId },
select: { userId: true },
}),
prisma.fitbitConnection.findUnique({
where: { userId },
select: { userId: true },
}),
prisma.googleHealthConnection.findUnique({
where: { userId },
select: { userId: true },
}),
]);
const inUse = new Set<MeasurementSource>(
withData.filter((s): s is MeasurementSource => s !== null),
);
if (withings) inUse.add("WITHINGS");
if (whoop) inUse.add("WHOOP");
if (fitbit) inUse.add("FITBIT");
if (googleHealth) inUse.add("GOOGLE_HEALTH");
if (user?.polarAccessTokenEncrypted) inUse.add("POLAR");
if (user?.ouraAccessTokenEncrypted) inUse.add("OURA");
if (user?.stravaAccessTokenEncrypted) inUse.add("STRAVA");
return RANKABLE_SOURCES.filter((s) => inUse.has(s));
}
+18 -2
View File
@@ -9,6 +9,7 @@ import { z } from "zod/v4";
import type { ZodOpenApiObject } from "zod-openapi";
import { notificationPrefsSchema } from "@/lib/validations/notification-prefs";
import { sourcePrioritySchema } from "@/lib/validations/source-priority";
import { measurementSourceEnum } from "@/lib/validations/measurement";
import { modulePrefsPatchSchema } from "@/lib/validations/modules";
import { MODULE_KEYS } from "@/lib/modules/registry";
import {
@@ -204,6 +205,21 @@ export const aiAccountBlock = z
"Which AI capabilities the record this session is inside has, and why not when it has none. Resolved on the server for the ACTIVE RECORD and masked to the sections the active grant opens, exactly like `modules` and `moduleAccess`; with no switch (every native request) it describes the caller's own record.",
});
/**
* The source-priority read and save both echo the resolved ladders plus
* `inUse`: the ranked sources the account has connected or holds data from.
* Context for an editor, never part of the stored shape (the PUT body stays
* `SourcePriority`).
*/
const sourcePriorityResolved = sourcePrioritySchema
.extend({
inUse: z.array(measurementSourceEnum).meta({
description:
"Ranked sources this account has connected or holds at least one measurement from, in ladder-catalogue order. Read-only; an editor may list only these while the ladders keep every source.",
}),
})
.meta({ id: "ResolvedSourcePriority" });
const moduleMapEnvelopeInner = z
.object({ modules: moduleMapResolved, updatedAt: updatedAtTokenField })
.meta({ id: "ModulesResponse" });
@@ -1425,7 +1441,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
content: {
"application/json": {
schema: dataEnvelope(
sourcePrioritySchema,
sourcePriorityResolved,
"GetSourcePriorityResponse",
),
},
@@ -1449,7 +1465,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
content: {
"application/json": {
schema: dataEnvelope(
sourcePrioritySchema,
sourcePriorityResolved,
"PutSourcePriorityResponse",
),
},