mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Fix copy, colour and frame details across the record pages
- Achievements: '1 Tag', not '1 Tage'; point totals grouped; whole descriptions instead of a clamp that hid the target at the end. - Illness: the episode note label is translated, day notes read in the content colour. - Coach history: conversation titles read in the content colour. - Anamnesis and mental wellbeing: drop a second page gutter the shell already provides, and the nested main landmark. - Privacy: repair words broken by a stray hyphen and space. - Cycle: the ring caption reads 'Tag 9 deines Zyklus' as one phrase. - Practitioners: the add button says 'Hinzufügen' like every other page; a shorter search placeholder fits a phone. - Empty state cards inset their content from their border, so a phone's full-width action no longer runs into the frame.
This commit is contained in:
1 parent
9fd43623dd
commit
3e1f630fa1
17 files changed
+133
-82
No files matched your search
+5
-4
@@ -8015,7 +8015,9 @@
|
||||
"noneUnlockedYet": "Noch keine Erfolge freigeschaltet.",
|
||||
"pointsValue": "{points} Punkte",
|
||||
"metricCount": "{count}",
|
||||
"metricDays": "{count} Tage",
|
||||
"metricDaysOne": "{count} Tag",
|
||||
"metricDaysFew": "{count} Tage",
|
||||
"metricDaysOther": "{count} Tage",
|
||||
"metricPercent": "{count}%",
|
||||
"badges": {
|
||||
"intakeTotal1": {
|
||||
@@ -9929,7 +9931,7 @@
|
||||
"loadError": "Diese Episode konnte nicht geladen werden.",
|
||||
"backToList": "Zurück zum Journal",
|
||||
"detail": {
|
||||
"note": "Note"
|
||||
"note": "Notiz"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Episode bearbeiten",
|
||||
@@ -12019,8 +12021,7 @@
|
||||
"title": "Praxen und Ärzte",
|
||||
"description": "Dein eigenes Adressbuch — einmal tippen, überall auswählen.",
|
||||
"linkLabel": "Adressbuch",
|
||||
"add": "Eintrag",
|
||||
"searchPlaceholder": "Name, Praxis oder Fachrichtung suchen",
|
||||
"searchPlaceholder": "Name, Praxis oder Fach suchen",
|
||||
"loadError": "Adressbuch konnte nicht geladen werden.",
|
||||
"emptyTitle": "Noch kein Eintrag",
|
||||
"emptyDescription": "Lege eine Praxis an, damit du sie beim Termin auswählen kannst.",
|
||||
|
||||
+3
-2
@@ -8015,7 +8015,9 @@
|
||||
"noneUnlockedYet": "No achievements unlocked yet.",
|
||||
"pointsValue": "{points} points",
|
||||
"metricCount": "{count}",
|
||||
"metricDays": "{count} days",
|
||||
"metricDaysOne": "{count} day",
|
||||
"metricDaysFew": "{count} days",
|
||||
"metricDaysOther": "{count} days",
|
||||
"metricPercent": "{count}%",
|
||||
"badges": {
|
||||
"intakeTotal1": {
|
||||
@@ -12019,7 +12021,6 @@
|
||||
"title": "Practices and doctors",
|
||||
"description": "Your own address book — type a practice once, pick it everywhere.",
|
||||
"linkLabel": "Address book",
|
||||
"add": "Entry",
|
||||
"searchPlaceholder": "Search name, practice or specialty",
|
||||
"loadError": "The address book could not be loaded.",
|
||||
"emptyTitle": "No entry yet",
|
||||
|
||||
+4
-3
@@ -8015,7 +8015,9 @@
|
||||
"noneUnlockedYet": "Aún no has desbloqueado ningún logro.",
|
||||
"pointsValue": "{points} puntos",
|
||||
"metricCount": "{count}",
|
||||
"metricDays": "{count} días",
|
||||
"metricDaysOne": "{count} día",
|
||||
"metricDaysFew": "{count} días",
|
||||
"metricDaysOther": "{count} días",
|
||||
"metricPercent": "{count}%",
|
||||
"badges": {
|
||||
"intakeTotal1": {
|
||||
@@ -9929,7 +9931,7 @@
|
||||
"loadError": "No se pudo cargar este episodio.",
|
||||
"backToList": "Volver al diario",
|
||||
"detail": {
|
||||
"note": "Note"
|
||||
"note": "Nota"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Editar episodio",
|
||||
@@ -12019,7 +12021,6 @@
|
||||
"title": "Consultas y médicos",
|
||||
"description": "Tu propia agenda: escribe una consulta una vez y elígela en todas partes.",
|
||||
"linkLabel": "Agenda",
|
||||
"add": "Entrada",
|
||||
"searchPlaceholder": "Buscar nombre, consulta o especialidad",
|
||||
"loadError": "No se pudo cargar la agenda.",
|
||||
"emptyTitle": "Todavía no hay entradas",
|
||||
|
||||
+3
-2
@@ -8015,7 +8015,9 @@
|
||||
"noneUnlockedYet": "Aucune réussite débloquée pour l’instant.",
|
||||
"pointsValue": "{points} points",
|
||||
"metricCount": "{count}",
|
||||
"metricDays": "{count} jours",
|
||||
"metricDaysOne": "{count} jour",
|
||||
"metricDaysFew": "{count} jours",
|
||||
"metricDaysOther": "{count} jours",
|
||||
"metricPercent": "{count}%",
|
||||
"badges": {
|
||||
"intakeTotal1": {
|
||||
@@ -12019,7 +12021,6 @@
|
||||
"title": "Cabinets et médecins",
|
||||
"description": "Votre carnet d'adresses : saisissez un cabinet une fois, choisissez-le partout.",
|
||||
"linkLabel": "Carnet d'adresses",
|
||||
"add": "Entrée",
|
||||
"searchPlaceholder": "Rechercher un nom, un cabinet ou une spécialité",
|
||||
"loadError": "Impossible de charger le carnet d'adresses.",
|
||||
"emptyTitle": "Aucune entrée",
|
||||
|
||||
+4
-3
@@ -8015,7 +8015,9 @@
|
||||
"noneUnlockedYet": "Nessun obiettivo ancora sbloccato.",
|
||||
"pointsValue": "{points} punti",
|
||||
"metricCount": "{count}",
|
||||
"metricDays": "{count} giorni",
|
||||
"metricDaysOne": "{count} giorno",
|
||||
"metricDaysFew": "{count} giorni",
|
||||
"metricDaysOther": "{count} giorni",
|
||||
"metricPercent": "{count}%",
|
||||
"badges": {
|
||||
"intakeTotal1": {
|
||||
@@ -9929,7 +9931,7 @@
|
||||
"loadError": "Impossibile caricare questo episodio.",
|
||||
"backToList": "Torna al diario",
|
||||
"detail": {
|
||||
"note": "Note"
|
||||
"note": "Nota"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Modifica episodio",
|
||||
@@ -12019,7 +12021,6 @@
|
||||
"title": "Studi e medici",
|
||||
"description": "La tua rubrica: scrivi uno studio una volta e selezionalo ovunque.",
|
||||
"linkLabel": "Rubrica",
|
||||
"add": "Voce",
|
||||
"searchPlaceholder": "Cerca nome, studio o specializzazione",
|
||||
"loadError": "Impossibile caricare la rubrica.",
|
||||
"emptyTitle": "Nessuna voce",
|
||||
|
||||
+3
-2
@@ -8015,7 +8015,9 @@
|
||||
"noneUnlockedYet": "아직 달성한 업적이 없어요.",
|
||||
"pointsValue": "{points}포인트",
|
||||
"metricCount": "{count}",
|
||||
"metricDays": "{count}일",
|
||||
"metricDaysOne": "{count}일",
|
||||
"metricDaysFew": "{count}일",
|
||||
"metricDaysOther": "{count}일",
|
||||
"metricPercent": "{count}%",
|
||||
"badges": {
|
||||
"intakeTotal1": {
|
||||
@@ -12019,7 +12021,6 @@
|
||||
"title": "병원과 의사",
|
||||
"description": "나만의 주소록이에요 — 병원을 한 번만 입력하면 어디서든 골라 쓸 수 있어요.",
|
||||
"linkLabel": "주소록",
|
||||
"add": "항목",
|
||||
"searchPlaceholder": "이름, 병원, 진료과 검색",
|
||||
"loadError": "주소록을 불러오지 못했어요.",
|
||||
"emptyTitle": "아직 항목이 없어요",
|
||||
|
||||
+4
-3
@@ -8015,7 +8015,9 @@
|
||||
"noneUnlockedYet": "Nie odblokowano jeszcze żadnego osiągnięcia.",
|
||||
"pointsValue": "{points} punktów",
|
||||
"metricCount": "{count}",
|
||||
"metricDays": "{count} dni",
|
||||
"metricDaysOne": "{count} dzień",
|
||||
"metricDaysFew": "{count} dni",
|
||||
"metricDaysOther": "{count} dni",
|
||||
"metricPercent": "{count}%",
|
||||
"badges": {
|
||||
"intakeTotal1": {
|
||||
@@ -9929,7 +9931,7 @@
|
||||
"loadError": "Nie udało się wczytać tego epizodu.",
|
||||
"backToList": "Powrót do dziennika",
|
||||
"detail": {
|
||||
"note": "Note"
|
||||
"note": "Notatka"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Edytuj epizod",
|
||||
@@ -12019,7 +12021,6 @@
|
||||
"title": "Gabinety i lekarze",
|
||||
"description": "Twoja własna książka adresowa — wpisz gabinet raz, wybieraj go wszędzie.",
|
||||
"linkLabel": "Książka adresowa",
|
||||
"add": "Wpis",
|
||||
"searchPlaceholder": "Szukaj nazwiska, gabinetu lub specjalizacji",
|
||||
"loadError": "Nie udało się wczytać książki adresowej.",
|
||||
"emptyTitle": "Brak wpisów",
|
||||
|
||||
@@ -35,7 +35,7 @@ import { PageHeader } from "@/components/ui/page-header";
|
||||
import { PageAuthGate } from "@/components/ui/page-auth-gate";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { useAchievementsQuery } from "@/lib/queries/use-achievements-query";
|
||||
import {
|
||||
@@ -81,12 +81,13 @@ const CATEGORY_LABEL_KEY: Record<AchievementCategory, string> = {
|
||||
function formatMetric(
|
||||
format: AchievementProgress["format"],
|
||||
value: number,
|
||||
t: (key: string, params?: Record<string, string | number>) => string,
|
||||
i18n: Pick<ReturnType<typeof useTranslations>, "t" | "tCount">,
|
||||
) {
|
||||
if (format === "percent")
|
||||
return t("achievements.metricPercent", { count: value });
|
||||
if (format === "days") return t("achievements.metricDays", { count: value });
|
||||
return t("achievements.metricCount", { count: value });
|
||||
return i18n.t("achievements.metricPercent", { count: value });
|
||||
// "1 Tag", not "1 Tage": the day count takes the plural form.
|
||||
if (format === "days") return i18n.tCount("achievements.metricDays", value);
|
||||
return i18n.t("achievements.metricCount", { count: value });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,9 +128,11 @@ export function groupByCategory(
|
||||
interface AchievementCardProps {
|
||||
achievement: AchievementProgress;
|
||||
t: ReturnType<typeof useTranslations>["t"];
|
||||
tCount: ReturnType<typeof useTranslations>["tCount"];
|
||||
}
|
||||
|
||||
function AchievementCard({ achievement, t }: AchievementCardProps) {
|
||||
function AchievementCard({ achievement, t, tCount }: AchievementCardProps) {
|
||||
const i18n = { t, tCount };
|
||||
const Icon = iconMap[achievement.icon] ?? Star;
|
||||
const unlocked = achievement.unlocked;
|
||||
|
||||
@@ -191,7 +194,7 @@ function AchievementCard({ achievement, t }: AchievementCardProps) {
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold">{t(achievement.titleKey)}</h3>
|
||||
<p className="text-muted-foreground line-clamp-2 text-xs">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t(achievement.descriptionKey)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -215,8 +218,16 @@ function AchievementCard({ achievement, t }: AchievementCardProps) {
|
||||
<div className="mt-2 space-y-1.5">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("achievements.criterionHint", {
|
||||
current: formatMetric(achievement.format, achievement.current, t),
|
||||
target: formatMetric(achievement.format, achievement.target, t),
|
||||
current: formatMetric(
|
||||
achievement.format,
|
||||
achievement.current,
|
||||
i18n,
|
||||
),
|
||||
target: formatMetric(
|
||||
achievement.format,
|
||||
achievement.target,
|
||||
i18n,
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
{/* v1.4.33 IW9 — aria-label so the bar carries an accessible name. */}
|
||||
@@ -224,8 +235,16 @@ function AchievementCard({ achievement, t }: AchievementCardProps) {
|
||||
value={achievement.progressPercent}
|
||||
className="h-1.5"
|
||||
aria-label={t("achievements.criterionHint", {
|
||||
current: formatMetric(achievement.format, achievement.current, t),
|
||||
target: formatMetric(achievement.format, achievement.target, t),
|
||||
current: formatMetric(
|
||||
achievement.format,
|
||||
achievement.current,
|
||||
i18n,
|
||||
),
|
||||
target: formatMetric(
|
||||
achievement.format,
|
||||
achievement.target,
|
||||
i18n,
|
||||
),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
@@ -259,7 +278,9 @@ function AchievementCard({ achievement, t }: AchievementCardProps) {
|
||||
|
||||
export default function AchievementsPage() {
|
||||
const { user, isAuthenticated, isLoading: authLoading } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
const { t, tCount } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const i18n = { t, tCount };
|
||||
|
||||
// v1.18.0 — the achievements module gate. When the account has the
|
||||
// module turned off the whole page disappears: an empty state stands in
|
||||
@@ -335,9 +356,9 @@ export default function AchievementsPage() {
|
||||
{t("achievements.points")}
|
||||
</p>
|
||||
<p className="text-4xl leading-none font-bold md:text-5xl">
|
||||
{summary?.earnedPoints ?? 0}
|
||||
{fmt.integer(summary?.earnedPoints ?? 0)}
|
||||
<span className="text-muted-foreground ml-2 text-base font-medium">
|
||||
/ {summary?.totalPoints ?? 0}
|
||||
/ {fmt.integer(summary?.totalPoints ?? 0)}
|
||||
</span>
|
||||
</p>
|
||||
<Progress
|
||||
@@ -373,7 +394,7 @@ export default function AchievementsPage() {
|
||||
<p className="text-base font-semibold">
|
||||
{t(summary.nextAchievement.titleKey)}
|
||||
</p>
|
||||
<p className="text-muted-foreground line-clamp-2 text-xs">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t(summary.nextAchievement.descriptionKey)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||
@@ -381,13 +402,13 @@ export default function AchievementsPage() {
|
||||
{formatMetric(
|
||||
summary.nextAchievement.format,
|
||||
summary.nextAchievement.current,
|
||||
t,
|
||||
i18n,
|
||||
)}{" "}
|
||||
/{" "}
|
||||
{formatMetric(
|
||||
summary.nextAchievement.format,
|
||||
summary.nextAchievement.target,
|
||||
t,
|
||||
i18n,
|
||||
)}{" "}
|
||||
({summary.nextAchievement.progressPercent}%)
|
||||
</p>
|
||||
@@ -449,6 +470,7 @@ export default function AchievementsPage() {
|
||||
key={achievement.id}
|
||||
achievement={achievement}
|
||||
t={t}
|
||||
tCount={tCount}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -219,7 +219,9 @@ function CoachConversationsBody({ readOnly }: { readOnly: boolean }) {
|
||||
className={cn(
|
||||
"group relative flex flex-wrap items-center gap-1.5 rounded-xl px-3 py-2.5",
|
||||
"text-sm transition-colors",
|
||||
"text-muted-foreground hover:bg-muted/40 hover:text-foreground",
|
||||
// The title is the reader's own content: foreground,
|
||||
// with the timestamp below as the muted meta line.
|
||||
"text-foreground hover:bg-muted/40",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
|
||||
@@ -44,8 +44,7 @@ export default function MentalWellbeingPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6">
|
||||
<MentalWellbeing />
|
||||
</div>
|
||||
// The shell owns the page gutter; a second one here doubled it.
|
||||
<MentalWellbeing />
|
||||
);
|
||||
}
|
||||
+36
-37
@@ -386,8 +386,9 @@ export default function PrivacyPage() {
|
||||
bodyEn={
|
||||
<>
|
||||
<p>
|
||||
HealthLog is an open-source, self-hostable personal-health-
|
||||
tracking application. Source code is published at{" "}
|
||||
HealthLog is an open-source, self-hostable
|
||||
personal-health-tracking application. Source code is published
|
||||
at{" "}
|
||||
<a
|
||||
href="https://github.com/MBombeck/HealthLog"
|
||||
target="_blank"
|
||||
@@ -406,7 +407,7 @@ export default function PrivacyPage() {
|
||||
</p>
|
||||
<p>
|
||||
The controller under Art. 4 (7) GDPR is the operator of this
|
||||
instance (an individual based in Germany). Self- hosted
|
||||
instance (an individual based in Germany). Self-hosted
|
||||
deployments controlled by a different operator are governed by
|
||||
that operator's own privacy policy; this document applies
|
||||
only to this instance.
|
||||
@@ -454,7 +455,7 @@ export default function PrivacyPage() {
|
||||
</li>
|
||||
<li>
|
||||
Profil-Metadaten: Sprache, Zeitzone, Geburtsdatum (optional),
|
||||
biologisches Geschlecht (optional), Körper- größe (optional) —
|
||||
biologisches Geschlecht (optional), Körpergröße (optional) —
|
||||
zur Berechnung altersgerechter Zielbereiche und des BMI.
|
||||
</li>
|
||||
</ul>
|
||||
@@ -462,7 +463,7 @@ export default function PrivacyPage() {
|
||||
2.2 Manuell erfasste Gesundheitsdaten
|
||||
</h3>
|
||||
<p>
|
||||
Jeder in den App-Formularen eingegebene Wert, einschließ- lich
|
||||
Jeder in den App-Formularen eingegebene Wert, einschließlich
|
||||
Zeitstempel, optionaler Notiz und Metrik-Typ. Die vollständige
|
||||
Aufzählung der unterstützten Metriken steht im OpenAPI-Schema;
|
||||
die nutzerrelevante Teilmenge umfasst Körpergewicht, Körperfett,
|
||||
@@ -587,9 +588,9 @@ export default function PrivacyPage() {
|
||||
</ul>
|
||||
<p>
|
||||
Schreibzugriff wird für eine Teilmenge angefordert (Körpermasse,
|
||||
Blutdruck systolisch / diastolisch, Blut- zucker), damit
|
||||
manuelle Einträge in der iOS-App zurück nach HealthKit fließen
|
||||
können. Der Nutzer steuert beide Richtungen über die
|
||||
Blutdruck systolisch / diastolisch, Blutzucker), damit manuelle
|
||||
Einträge in der iOS-App zurück nach HealthKit fließen können.
|
||||
Der Nutzer steuert beide Richtungen über die
|
||||
Berechtigungsoberfläche der iOS- Health-App und kann jederzeit
|
||||
widerrufen.
|
||||
</p>
|
||||
@@ -598,25 +599,24 @@ export default function PrivacyPage() {
|
||||
</h3>
|
||||
<p>
|
||||
Verbindet der Nutzer ein Withings-Konto, speichert HealthLog die
|
||||
OAuth-Refresh- und -Access-Token (spalten- weise verschlüsselt)
|
||||
OAuth-Refresh- und -Access-Token (spaltenweise verschlüsselt)
|
||||
sowie die Withings-Nutzerkennung. Webhook-getriebene
|
||||
Folgesynchronisierungen ziehen Körper- gewicht, Fettanteil,
|
||||
Folgesynchronisierungen ziehen Körpergewicht, Fettanteil,
|
||||
fettfreie Masse, Muskelmasse, Knochenmasse, Wassergehalt,
|
||||
Magermasse, Hydration, Blutdruck (systolisch / diastolisch) und
|
||||
Puls, Blut- zucker, Körpertemperatur (Basal- und
|
||||
Hauttemperatur), Aktivitätssummen (Schritte, Distanz, aktive
|
||||
Kalorien), Schlafsitzungen mit Pro-Stadien-Segmenten (Awake /
|
||||
Light / Deep / REM) sowie SpO₂ und Herzfrequenzvariabilität,
|
||||
soweit vorhanden.
|
||||
Puls, Blutzucker, Körpertemperatur (Basal- und Hauttemperatur),
|
||||
Aktivitätssummen (Schritte, Distanz, aktive Kalorien),
|
||||
Schlafsitzungen mit Pro-Stadien-Segmenten (Awake / Light / Deep
|
||||
/ REM) sowie SpO₂ und Herzfrequenzvariabilität, soweit
|
||||
vorhanden.
|
||||
</p>
|
||||
<h3 className="text-foreground pt-2 text-base font-semibold">
|
||||
2.5 Medikamente
|
||||
</h3>
|
||||
<ul className="list-disc space-y-1 pl-5">
|
||||
<li>
|
||||
Aktive Verordnungen: Wirkstoffname, Stärke, Verabreich-
|
||||
ungsweg, Einnahmeplan, Behandlungsklasse (Standard oder
|
||||
GLP-1).
|
||||
Aktive Verordnungen: Wirkstoffname, Stärke, Verabreichungsweg,
|
||||
Einnahmeplan, Behandlungsklasse (Standard oder GLP-1).
|
||||
</li>
|
||||
<li>
|
||||
Einnahme-Ereignisse: geplante Zeit, tatsächliche Zeit, Status
|
||||
@@ -716,8 +716,8 @@ export default function PrivacyPage() {
|
||||
<code className="bg-muted rounded px-1 py-0.5 text-xs">
|
||||
X-Device-Id
|
||||
</code>{" "}
|
||||
-Header — für Mehrgeräte-Synchronisation und Miss-
|
||||
brauchsschutz.
|
||||
-Header — für Mehrgeräte-Synchronisation und
|
||||
Missbrauchsschutz.
|
||||
</li>
|
||||
<li>
|
||||
Apple-Push-Notification-Service-(APNs)-Geräte-Token und
|
||||
@@ -759,8 +759,8 @@ export default function PrivacyPage() {
|
||||
</h3>
|
||||
<ul className="list-disc space-y-1 pl-5">
|
||||
<li>
|
||||
Keine Werbe-Identifier, Fingerprints oder anwendungs-
|
||||
übergreifenden Tracker.
|
||||
Keine Werbe-Identifier, Fingerprints oder
|
||||
anwendungsübergreifenden Tracker.
|
||||
</li>
|
||||
<li>Keine Zahlungsdaten (es gibt keinen Bezahltarif).</li>
|
||||
<li>
|
||||
@@ -938,11 +938,11 @@ export default function PrivacyPage() {
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
Write access is requested for a subset (body mass, blood-
|
||||
pressure systolic / diastolic, blood glucose) so that manual
|
||||
entries made inside the iOS app can flow back into HealthKit.
|
||||
The user controls both directions in the iOS Health app's
|
||||
permission surface and may revoke at any time.
|
||||
Write access is requested for a subset (body mass,
|
||||
blood-pressure systolic / diastolic, blood glucose) so that
|
||||
manual entries made inside the iOS app can flow back into
|
||||
HealthKit. The user controls both directions in the iOS Health
|
||||
app's permission surface and may revoke at any time.
|
||||
</p>
|
||||
<h3 className="text-foreground pt-2 text-base font-semibold">
|
||||
2.4 Withings sync (optional)
|
||||
@@ -1057,8 +1057,8 @@ export default function PrivacyPage() {
|
||||
</h3>
|
||||
<ul className="list-disc space-y-1 pl-5">
|
||||
<li>
|
||||
A device identifier (random UUID generated client- side,
|
||||
stored in the iOS Keychain) sent as the{" "}
|
||||
A device identifier (random UUID generated client-side, stored
|
||||
in the iOS Keychain) sent as the{" "}
|
||||
<code className="bg-muted rounded px-1 py-0.5 text-xs">
|
||||
X-Device-Id
|
||||
</code>{" "}
|
||||
@@ -1135,16 +1135,15 @@ export default function PrivacyPage() {
|
||||
Authentifizierungsdaten
|
||||
</span>{" "}
|
||||
— Identifikation des Nutzers und Sicherung der Sitzung.
|
||||
Rechtsgrundlage: Art. 6 Abs. 1 lit. b DSGVO (Vertrags-
|
||||
erfüllung).
|
||||
Rechtsgrundlage: Art. 6 Abs. 1 lit. b DSGVO (Vertragserfüllung).
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-foreground font-medium">
|
||||
Gesundheitsdaten
|
||||
</span>{" "}
|
||||
— Darstellung von Verläufen, Berechnung der Zielwerte- Treue,
|
||||
Erzeugung des Coach-Snapshots auf Abruf. Rechts- grundlage: Art.
|
||||
9 Abs. 2 lit. a DSGVO (ausdrückliche Einwilligung für Daten
|
||||
Erzeugung des Coach-Snapshots auf Abruf. Rechtsgrundlage: Art. 9
|
||||
Abs. 2 lit. a DSGVO (ausdrückliche Einwilligung für Daten
|
||||
besonderer Kategorien).
|
||||
</li>
|
||||
<li>
|
||||
@@ -1420,8 +1419,8 @@ export default function PrivacyPage() {
|
||||
<span className="text-foreground font-medium">
|
||||
Kein HealthLog-iCloud-Speicher
|
||||
</span>{" "}
|
||||
— HealthLog speichert Server-Datenbank, lokale Gesundheits-
|
||||
caches, Offline-Outbox, ECG- und Workout-Payloads,
|
||||
— HealthLog speichert Server-Datenbank, lokale
|
||||
Gesundheits-Caches, Offline-Outbox, ECG- und Workout-Payloads,
|
||||
KI-Transkript-Cache, Medikamentenplaner-Datenbank sowie Widget-
|
||||
und Watch-Snapshots weder in iCloud noch in CloudKit. Lokale
|
||||
gesundheitsbezogene Speicher liegen in geschütztem App- oder
|
||||
@@ -1587,10 +1586,10 @@ export default function PrivacyPage() {
|
||||
<span className="text-foreground font-medium">
|
||||
Widerspruch und automatisierte Entscheidungen (Art. 21–22)
|
||||
</span>{" "}
|
||||
— der KI-Coach trifft keine automatisierten Entscheid- ungen im
|
||||
— der KI-Coach trifft keine automatisierten Entscheidungen im
|
||||
Sinne von Art. 22 DSGVO. Er erzeugt Texte zum Nachlesen; ohne
|
||||
ausdrückliche Bestätigung wird keine Aktion ausgeführt. Siehe
|
||||
Abschnitt 7 zur Medizinpro- dukte-Grenze.
|
||||
Abschnitt 7 zur Medizinprodukte-Grenze.
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-foreground font-medium">
|
||||
|
||||
@@ -271,7 +271,14 @@ export function CycleView() {
|
||||
days: verdict.overdueDays,
|
||||
})
|
||||
: t("cycle.ring.periodOverdue")
|
||||
: t("cycle.ring.caption")}
|
||||
: // The ring shows the bare number; the caption under it
|
||||
// reads as a whole phrase ("Tag 9 deines Zyklus"), not
|
||||
// a fragment hanging off the ring.
|
||||
verdict?.dayOfCycle != null
|
||||
? t("cycle.insightsSummary.currentDay", {
|
||||
day: verdict.dayOfCycle,
|
||||
})
|
||||
: t("cycle.ring.caption")}
|
||||
</p>
|
||||
) : null}
|
||||
{/* Log CTA — first period when no cycle exists yet, next period
|
||||
|
||||
@@ -86,7 +86,8 @@ function DayLogRow({ log }: { log: IllnessDayLogDTO }) {
|
||||
) : null}
|
||||
|
||||
{log.note ? (
|
||||
<p className="text-muted-foreground text-sm whitespace-pre-wrap">
|
||||
// User-written text reads in the content colour, never muted.
|
||||
<p className="text-foreground text-sm whitespace-pre-wrap">
|
||||
{log.note}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -98,7 +98,7 @@ export function PractitionerList({ enabled = true }: { enabled?: boolean }) {
|
||||
onClick={() => openSheet("new")}
|
||||
>
|
||||
<Plus className="size-4" aria-hidden />
|
||||
{t("practitioners.add")}
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
) : null;
|
||||
|
||||
|
||||
@@ -130,7 +130,9 @@ export function ProfileSummaryPage() {
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="container mx-auto max-w-3xl space-y-6 px-4 py-6">
|
||||
// The shell owns the page frame (gutter, width, top space) and the
|
||||
// <main> landmark; this page only sets its rhythm.
|
||||
<div className="space-y-6">
|
||||
<PageHeader title={t("settings.sections.anamnesis.title")} />
|
||||
{summary.isPending ? (
|
||||
<p className="text-muted-foreground text-sm" aria-live="polite">
|
||||
@@ -141,6 +143,6 @@ export function ProfileSummaryPage() {
|
||||
<QueryErrorCard onRetry={() => void summary.refetch()} />
|
||||
) : null}
|
||||
{summary.data ? <ProfileSummary summary={summary.data} /> : null}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -46,4 +46,13 @@ describe("<EmptyState>", () => {
|
||||
expect(card).toContain("border-dashed");
|
||||
expect(plain).not.toContain("border-dashed");
|
||||
});
|
||||
|
||||
it("insets the card variant's content from its border, not the plain one", () => {
|
||||
const card = renderToStaticMarkup(<EmptyState title="leer" />);
|
||||
const plain = renderToStaticMarkup(
|
||||
<EmptyState title="leer" variant="plain" />,
|
||||
);
|
||||
expect(card).toMatch(/class="[^"]*\bpx-4\b/);
|
||||
expect(plain).not.toMatch(/\bpx-4\b/);
|
||||
});
|
||||
});
|
||||
@@ -64,6 +64,9 @@ function EmptyState({
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center text-center",
|
||||
size === "compact" ? "gap-2 py-4" : "gap-3 py-8",
|
||||
// The card variant paints a border, so its content insets from it:
|
||||
// a full-width phone CTA otherwise ran edge to edge into the frame.
|
||||
variant === "card" && "px-4",
|
||||
)}
|
||||
>
|
||||
{icon ? (
|
||||
|
||||
Reference in new issue
Block a user