Lead the privacy and about pages with the reader's language

The privacy policy is German first in every body, but its section
headings, title and table of contents led in English and its date read
2026-08-11 on both lines. Headings now lead in German with the English
title beside them, the contents list follows, and each date line writes
the date as its language does. The about page's headings, date and
sign-in link follow the reader's locale. No legal wording changed.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 19:02:00 +02:00
1 parent 5bb6b6ad21
commit b3fb9afa2c
10 files changed
+159 -48

No files matched your search

+7
View File
@@ -12497,5 +12497,12 @@
"title": "Lebensereignis löschen?",
"body": "Titel und Notiz sind danach nicht mehr da."
}
},
"aboutPage": {
"eyebrow": "Info",
"title": "Über HealthLog",
"lastUpdated": "Zuletzt aktualisiert: {date}",
"project": "Projekt",
"credits": "Danksagungen"
}
}
+7
View File
@@ -12497,5 +12497,12 @@
"title": "Delete this life event?",
"body": "Its title and note will be gone."
}
},
"aboutPage": {
"eyebrow": "About",
"title": "About HealthLog",
"lastUpdated": "Last updated: {date}",
"project": "Project",
"credits": "Credits"
}
}
+7
View File
@@ -12497,5 +12497,12 @@
"title": "¿Eliminar este acontecimiento vital?",
"body": "Su título y su nota desaparecerán."
}
},
"aboutPage": {
"eyebrow": "Información",
"title": "Acerca de HealthLog",
"lastUpdated": "Última actualización: {date}",
"project": "Proyecto",
"credits": "Créditos"
}
}
+7
View File
@@ -12497,5 +12497,12 @@
"title": "Supprimer cet événement de vie ?",
"body": "Son titre et sa note disparaîtront."
}
},
"aboutPage": {
"eyebrow": "À propos",
"title": "À propos de HealthLog",
"lastUpdated": "Dernière mise à jour : {date}",
"project": "Projet",
"credits": "Crédits"
}
}
+7
View File
@@ -12497,5 +12497,12 @@
"title": "Eliminare questo evento di vita?",
"body": "Titolo e nota non ci saranno più."
}
},
"aboutPage": {
"eyebrow": "Informazioni",
"title": "Informazioni su HealthLog",
"lastUpdated": "Ultimo aggiornamento: {date}",
"project": "Progetto",
"credits": "Riconoscimenti"
}
}
+7
View File
@@ -12497,5 +12497,12 @@
"title": "이 삶의 사건을 삭제할까요?",
"body": "제목과 메모가 사라져요."
}
},
"aboutPage": {
"eyebrow": "정보",
"title": "HealthLog 정보",
"lastUpdated": "마지막 업데이트: {date}",
"project": "프로젝트",
"credits": "크레딧"
}
}
+7
View File
@@ -12497,5 +12497,12 @@
"title": "Usunąć to wydarzenie życiowe?",
"body": "Tytuł i notatka znikną."
}
},
"aboutPage": {
"eyebrow": "Informacje",
"title": "O HealthLog",
"lastUpdated": "Ostatnia aktualizacja: {date}",
"project": "Projekt",
"credits": "Podziękowania"
}
}
+23 -7
View File
@@ -1,6 +1,10 @@
import type { Metadata } from "next";
import Link from "next/link";
import { resolveIntlLocale } from "@/lib/format-locale";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { getServerTranslator } from "@/lib/i18n/server-translator";
/**
* v1.4.27 B3 — Public about / credits page.
*
@@ -67,7 +71,17 @@ function Section({
);
}
export default function AboutPage() {
export default async function AboutPage() {
// The headings, the date and the sign-in link follow the reader's locale;
// the body is the attribution text the licences ask for and stays as
// written.
const locale = await resolveServerLocale();
const { t } = getServerTranslator(locale);
const lastUpdated = new Intl.DateTimeFormat(resolveIntlLocale(locale), {
dateStyle: "medium",
timeZone: "UTC",
}).format(new Date(`${LAST_UPDATED}T12:00:00Z`));
return (
<div className="bg-background text-foreground min-h-dvh">
<header className="border-border/60 bg-background/80 sticky top-0 z-10 border-b pt-[env(safe-area-inset-top)] backdrop-blur">
@@ -82,7 +96,7 @@ export default function AboutPage() {
href="/auth/login"
className="text-muted-foreground hover:text-foreground inline-flex min-h-11 items-center text-sm"
>
Sign in
{t("auth.login")}
</Link>
</div>
</header>
@@ -99,17 +113,19 @@ export default function AboutPage() {
>
<div className="space-y-3">
<p className="text-muted-foreground text-xs tracking-wider uppercase">
About
{t("aboutPage.eyebrow")}
</p>
<h1 className="text-3xl font-bold tracking-tight md:text-4xl">
About HealthLog
{t("aboutPage.title")}
</h1>
<p className="text-muted-foreground text-sm">
Last updated: {LAST_UPDATED}
<time dateTime={LAST_UPDATED}>
{t("aboutPage.lastUpdated", { date: lastUpdated })}
</time>
</p>
</div>
<Section id="project" title="Project">
<Section id="project" title={t("aboutPage.project")}>
<p>
HealthLog is an open-source, self-hostable personal-health-tracking
application. The source code lives at{" "}
@@ -127,7 +143,7 @@ export default function AboutPage() {
</p>
</Section>
<Section id="credits" title="Credits">
<Section id="credits" title={t("aboutPage.credits")}>
<p>
HealthLog stands on a number of open-source libraries and public
data sources. The list below covers the third-party assets that ship
+45 -22
View File
@@ -20,9 +20,10 @@ import PrivacyPage from "../page";
* enumerated verbatim by identifier. Drift from the iOS source
* (`HealthKitWireConverter.swift` + `HealthKitService.swift`)
* would be a compliance hole.
* 4. The numbered section headings are all present (English form, so
* the scannable spine matches the in-app `Settings → Privacy`
* link target and the table of contents above each section).
* 4. The numbered section headings are all present, German first with
* the English title beside it, so the scannable spine still matches
* the in-app `Settings → Privacy` link target and the table of
* contents.
* 5. v1.4.40 SB-3 additions — consent receipt endpoint, TLS 1.3,
* operator email + deletion-route disclosure, retention windows.
*/
@@ -46,31 +47,53 @@ describe("<PrivacyPage>", () => {
it("advertises the policy version + last-updated date", () => {
const html = render();
expect(html).toContain(`Policy version ${POLICY_VERSION}`);
expect(html).toContain(`Last updated: ${LAST_UPDATED}`);
expect(html).toContain(`Stand: ${LAST_UPDATED}`);
// Each language writes the date its own way, on its own line.
expect(html).toContain("Stand: 11. August 2026");
expect(html).toContain("Last updated: 11 August 2026");
expect(html).toContain(`dateTime="${LAST_UPDATED}"`);
expect(html).toContain('data-slot="privacy-last-updated"');
});
it("renders the numbered section headings", () => {
const html = render();
// 11 numbered sections — H2 headings carry the English form as the
// canonical scan spine, with the German title appearing inline in
// each section body. The TOC at the top mirrors both.
const expectedHeadings = [
"1. Overview",
"2. Data we collect",
"3. Why we collect each category",
"4. Third-party sub-processors",
"5. Data storage and retention",
"6. Your rights (GDPR Art. 15-22, DSGVO)",
"7. Medical-device boundary (EU MDR 2017/745, MDCG 2021-24)",
"8. Apple App Store privacy categories",
"9. Children",
"10. Changes to this policy",
"11. Contact",
// 11 numbered sections. The H2 leads in German, like the body under it,
// and carries the English title after it so the English table of
// contents and the in-app link target still scan the same spine.
const expectedHeadings: Array<[string, string]> = [
["1. Überblick", "Overview"],
["2. Erhobene Daten", "Data we collect"],
["3. Zwecke der Verarbeitung", "Why we collect each category"],
[
"4. Auftragsverarbeiter und Drittanbieter",
"Third-party sub-processors",
],
[
"5. Speicherung, Speicherdauer und Verschlüsselung",
"Data storage and retention",
],
[
"6. Ihre Rechte (DSGVO Art. 15–22)",
"Your rights (GDPR Art. 15-22, DSGVO)",
],
[
"7. Medizinprodukte-Grenze (EU-MDR 2017/745, MDCG 2021-24)",
"Medical-device boundary (EU MDR 2017/745, MDCG 2021-24)",
],
[
"8. Apple-App-Store-Datenschutzkategorien",
"Apple App Store privacy categories",
],
["9. Kinder", "Children"],
["10. Änderungen dieser Erklärung", "Changes to this policy"],
["11. Kontakt", "Contact"],
];
for (const heading of expectedHeadings) {
expect(html, `missing H2 ${JSON.stringify(heading)}`).toContain(heading);
for (const [de, en] of expectedHeadings) {
expect(html, `missing H2 ${JSON.stringify(de)}`).toContain(
`<span lang="de">${de.replaceAll("&", "&amp;")}</span>`,
);
expect(html, `missing English title ${JSON.stringify(en)}`).toContain(
` / ${en}`,
);
}
});
+42 -19
View File
@@ -38,6 +38,16 @@ export const dynamic = "force-dynamic";
const POLICY_VERSION = "1.37.12";
const LAST_UPDATED = "2026-08-11";
const LAST_UPDATED_DATE = new Date(`${LAST_UPDATED}T12:00:00Z`);
// The date as each language writes it, on the line that language reads.
const LAST_UPDATED_DE = new Intl.DateTimeFormat("de-DE", {
dateStyle: "long",
timeZone: "UTC",
}).format(LAST_UPDATED_DATE);
const LAST_UPDATED_EN = new Intl.DateTimeFormat("en-GB", {
dateStyle: "long",
timeZone: "UTC",
}).format(LAST_UPDATED_DATE);
export const metadata: Metadata = {
title: "Datenschutzerklärung / Privacy Policy — HealthLog",
@@ -67,8 +77,14 @@ function Section({
}: SectionProps) {
return (
<section id={id} className="scroll-mt-28 space-y-4">
{/* German first, like the body under it; the English title rides
along so the scan spine still matches the English table of
contents and the in-app link target. */}
<h2 className="text-xl font-semibold tracking-tight md:text-2xl">
{`${numberLabel}. ${titleEn}`}
<span lang="de">{`${numberLabel}. ${titleDe}`}</span>
<span className="text-muted-foreground" lang="en">
{` / ${titleEn}`}
</span>
</h2>
<div
className="text-foreground space-y-3 text-sm leading-relaxed md:text-base"
@@ -76,7 +92,7 @@ function Section({
lang="de"
>
<p className="text-foreground/80 text-xs font-medium tracking-wider uppercase">
Deutsch — {titleDe}
Deutsch
</p>
{bodyDe}
</div>
@@ -166,7 +182,7 @@ function SubProcessor({
rel="noopener noreferrer"
className="text-primary underline underline-offset-2"
>
Privacy policy / Datenschutzerklärung
Datenschutzerklärung / Privacy policy
</a>
</p>
</li>
@@ -212,13 +228,20 @@ export default function PrivacyPage() {
Policy version {POLICY_VERSION}
</p>
<h1 className="text-3xl font-bold tracking-tight md:text-4xl">
Datenschutzerklärung / Privacy Policy
<span lang="de">Datenschutzerklärung</span>{" "}
<span className="text-muted-foreground" lang="en">
/ Privacy Policy
</span>
</h1>
<p
className="text-muted-foreground text-sm"
data-slot="privacy-last-updated"
>
Last updated: {LAST_UPDATED} · Stand: {LAST_UPDATED}
<time dateTime={LAST_UPDATED}>
<span lang="de">Stand: {LAST_UPDATED_DE}</span>
{" · "}
<span lang="en">Last updated: {LAST_UPDATED_EN}</span>
</time>
</p>
<p className="text-muted-foreground text-sm" lang="de">
Diese Erklärung ist deutsch-englisch geführt. Jeder Abschnitt
@@ -253,7 +276,7 @@ export default function PrivacyPage() {
className="hover:text-foreground hover:underline"
href="#intro"
>
1. Overview / Überblick
1. Überblick / Overview
</a>
</li>
<li>
@@ -261,7 +284,7 @@ export default function PrivacyPage() {
className="hover:text-foreground hover:underline"
href="#data-we-collect"
>
2. Data we collect / Erhobene Daten
2. Erhobene Daten / Data we collect
</a>
</li>
<li>
@@ -269,7 +292,7 @@ export default function PrivacyPage() {
className="hover:text-foreground hover:underline"
href="#purpose"
>
3. Why we collect each category / Zwecke der Verarbeitung
3. Zwecke der Verarbeitung / Why we collect each category
</a>
</li>
<li>
@@ -277,7 +300,7 @@ export default function PrivacyPage() {
className="hover:text-foreground hover:underline"
href="#sub-processors"
>
4. Third-party sub-processors / Auftragsverarbeiter
4. Auftragsverarbeiter / Third-party sub-processors
</a>
</li>
<li>
@@ -285,8 +308,8 @@ export default function PrivacyPage() {
className="hover:text-foreground hover:underline"
href="#storage"
>
5. Data storage and retention / Speicherung &amp;
Speicherdauer
5. Speicherung &amp; Speicherdauer / Data storage and
retention
</a>
</li>
<li>
@@ -294,7 +317,7 @@ export default function PrivacyPage() {
className="hover:text-foreground hover:underline"
href="#rights"
>
6. Your rights (GDPR Art. 15-22, DSGVO) / Ihre Rechte
6. Ihre Rechte / Your rights (GDPR Art. 15-22, DSGVO)
</a>
</li>
<li>
@@ -302,8 +325,8 @@ export default function PrivacyPage() {
className="hover:text-foreground hover:underline"
href="#medical-boundary"
>
7. Medical-device boundary (EU MDR 2017/745, MDCG 2021-24) /
Medizinprodukte-Grenze
7. Medizinprodukte-Grenze / Medical-device boundary (EU MDR
2017/745, MDCG 2021-24)
</a>
</li>
<li>
@@ -311,8 +334,8 @@ export default function PrivacyPage() {
className="hover:text-foreground hover:underline"
href="#apple-categories"
>
8. Apple App Store privacy categories / Apple-App-Store-
Datenschutzkategorien
8. Apple-App-Store- Datenschutzkategorien / Apple App Store
privacy categories
</a>
</li>
<li>
@@ -320,7 +343,7 @@ export default function PrivacyPage() {
className="hover:text-foreground hover:underline"
href="#children"
>
9. Children / Kinder
9. Kinder / Children
</a>
</li>
<li>
@@ -328,7 +351,7 @@ export default function PrivacyPage() {
className="hover:text-foreground hover:underline"
href="#changes"
>
10. Changes to this policy / Änderungen
10. Änderungen / Changes to this policy
</a>
</li>
<li>
@@ -336,7 +359,7 @@ export default function PrivacyPage() {
className="hover:text-foreground hover:underline"
href="#contact"
>
11. Contact / Kontakt
11. Kontakt / Contact
</a>
</li>
</ol>