Clear the accessibility findings on about, privacy, achievements, cycle and admin

Inline links on the prose pages carry an underline and their body copy
reads in the content colour, so the links no longer rely on colour alone
and the code chips meet contrast. Locked achievements drop the opacity
that pushed their text below AA. The cycle calendar nests its headers and
day cells in ARIA rows, the login overview's page-size select is named,
and the measurement type badges take a lighter wash in the light theme so
their label holds 4.5:1.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 16:21:00 +02:00
1 parent 03f374794e
commit b80dc14142
7 files changed
+328 -273

No files matched your search

+5 -5
View File
@@ -60,7 +60,7 @@ function Section({
<h2 className="text-xl font-semibold tracking-tight md:text-2xl">
{title}
</h2>
<div className="text-muted-foreground space-y-3 text-sm leading-relaxed md:text-base">
<div className="text-foreground space-y-3 text-sm leading-relaxed md:text-base">
{children}
</div>
</section>
@@ -117,7 +117,7 @@ export default function AboutPage() {
href="https://github.com/MBombeck/HealthLog"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
className="text-primary underline underline-offset-2"
>
github.com/MBombeck/HealthLog
</a>{" "}
@@ -145,7 +145,7 @@ export default function AboutPage() {
href="https://www.maxmind.com"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
className="text-primary underline underline-offset-2"
>
www.maxmind.com
</a>
@@ -164,7 +164,7 @@ export default function AboutPage() {
href="https://www.maxmind.com/en/geolite/eula"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
className="text-primary underline underline-offset-2"
>
GeoLite End User License Agreement
</a>
@@ -173,7 +173,7 @@ export default function AboutPage() {
href="https://creativecommons.org/licenses/by-sa/4.0/"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
className="text-primary underline underline-offset-2"
>
Creative Commons Attribution-ShareAlike 4.0 International License
</a>{" "}
+1 -1
View File
@@ -175,7 +175,7 @@ function AchievementCard({ achievement, t }: AchievementCardProps) {
className={
unlocked
? "border-primary/30 from-primary/8 to-primary/0 rounded-xl border bg-gradient-to-br p-3"
: "border-border bg-card/50 rounded-xl border p-3 opacity-70"
: "border-border bg-card/50 rounded-xl border p-3"
}
>
<div className="mb-2 flex items-start justify-between gap-2">
+9 -9
View File
@@ -71,7 +71,7 @@ function Section({
{`${numberLabel}. ${titleEn}`}
</h2>
<div
className="text-muted-foreground space-y-3 text-sm leading-relaxed md:text-base"
className="text-foreground space-y-3 text-sm leading-relaxed md:text-base"
data-slot="privacy-section-de"
lang="de"
>
@@ -94,7 +94,7 @@ function Section({
</span>
</summary>
<div
className="text-muted-foreground space-y-3 px-3 pb-3 text-sm leading-relaxed md:text-base"
className="text-foreground space-y-3 px-3 pb-3 text-sm leading-relaxed md:text-base"
lang="en"
>
{bodyEn}
@@ -164,7 +164,7 @@ function SubProcessor({
href={policyUrl}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
className="text-primary underline underline-offset-2"
>
Privacy policy / Datenschutzerklärung
</a>
@@ -358,7 +358,7 @@ export default function PrivacyPage() {
href="https://github.com/MBombeck/HealthLog"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
className="text-primary underline underline-offset-2"
>
github.com/MBombeck/HealthLog
</a>{" "}
@@ -392,7 +392,7 @@ export default function PrivacyPage() {
href="https://github.com/MBombeck/HealthLog"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
className="text-primary underline underline-offset-2"
>
github.com/MBombeck/HealthLog
</a>{" "}
@@ -1959,7 +1959,7 @@ export default function PrivacyPage() {
</span>{" "}
<a
href="mailto:mbombeck@gmail.com"
className="text-primary hover:underline"
className="text-primary underline underline-offset-2"
>
mbombeck@gmail.com
</a>
@@ -1980,7 +1980,7 @@ export default function PrivacyPage() {
href="https://github.com/MBombeck/HealthLog/issues"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
className="text-primary underline underline-offset-2"
>
github.com/MBombeck/HealthLog/issues
</a>{" "}
@@ -2008,7 +2008,7 @@ export default function PrivacyPage() {
</span>{" "}
<a
href="mailto:mbombeck@gmail.com"
className="text-primary hover:underline"
className="text-primary underline underline-offset-2"
>
mbombeck@gmail.com
</a>
@@ -2028,7 +2028,7 @@ export default function PrivacyPage() {
href="https://github.com/MBombeck/HealthLog/issues"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
className="text-primary underline underline-offset-2"
>
github.com/MBombeck/HealthLog/issues
</a>{" "}
@@ -328,7 +328,10 @@ export function LoginOverviewSection() {
{/* Toolbar row: per-page + export */}
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">
<span
id="login-overview-per-page"
className="text-muted-foreground"
>
{t("admin.section.auditLog.perPage")}
</span>
<Select
@@ -338,7 +341,10 @@ export function LoginOverviewSection() {
setPerPage(Number(v) as PerPageValue);
}}
>
<SelectTrigger className="h-8 w-20">
<SelectTrigger
className="h-8 w-20"
aria-labelledby="login-overview-per-page"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -97,6 +97,18 @@ describe("<CycleCalendar>", () => {
expect(html).toContain('role="columnheader"');
});
it("nests headers and day cells in ARIA rows (grid > row > cell)", () => {
const html = render(
<CycleCalendar days={[]} today={today} onSelectDay={() => {}} />,
);
// A header row plus at least four week rows; a header or cell directly
// under the grid is an invalid ARIA tree.
expect(html.match(/role="row"/g)?.length ?? 0).toBeGreaterThanOrEqual(5);
expect(html).not.toMatch(
/role="grid"[^>]*>\s*<div[^>]*role="(columnheader|gridcell)"/,
);
});
it("exposes the flow level on a logged period day as a stable data-attr", () => {
const days = [
{ ...dayBase("2026-06-10"), isPeriodLogged: true, flow: "HEAVY" },
+191 -179
View File
@@ -236,204 +236,216 @@ export function CycleCalendar({
aria-label={monthLabel}
className="grid grid-cols-7 gap-1"
>
{weekdays.map((wk, i) => (
<div
key={i}
role="columnheader"
className="text-muted-foreground text-2xs pb-1 text-center font-medium uppercase"
>
{wk}
</div>
))}
{/* Rows are `display: contents`: they give the grid its ARIA rows
(grid > row > columnheader | gridcell) without touching the
seven-column CSS grid the cells lay out in. */}
<div role="row" className="contents">
{weekdays.map((wk, i) => (
<div
key={i}
role="columnheader"
className="text-muted-foreground text-2xs pb-1 text-center font-medium uppercase"
>
{wk}
</div>
))}
</div>
{grid.map((cell, i) => {
if (!cell) return <div key={`empty-${i}`} aria-hidden="true" />;
const date = ymd(cell);
const info = byDate.get(date);
const isToday = date === today;
// A CONFIRMED-ovulation day is the predicted-ovulation day that also
// matches the prediction's retrospective estimate. It supersedes the
// predicted dot with the distinct light oval (Apple's idiom).
const isConfirmedOvulation =
!!info?.isPredictedOvulation &&
confirmedOvulation != null &&
date === confirmedOvulation;
const isPredictedOvulationDot =
!!info?.isPredictedOvulation && !isConfirmedOvulation;
{Array.from({ length: Math.ceil(grid.length / 7) }, (_, r) => (
<div key={`row-${r}`} role="row" className="contents">
{grid.slice(r * 7, r * 7 + 7).map((cell, j) => {
const i = r * 7 + j;
if (!cell) return <div key={`empty-${i}`} aria-hidden="true" />;
const date = ymd(cell);
const info = byDate.get(date);
const isToday = date === today;
// A CONFIRMED-ovulation day is the predicted-ovulation day that also
// matches the prediction's retrospective estimate. It supersedes the
// predicted dot with the distinct light oval (Apple's idiom).
const isConfirmedOvulation =
!!info?.isPredictedOvulation &&
confirmedOvulation != null &&
date === confirmedOvulation;
const isPredictedOvulationDot =
!!info?.isPredictedOvulation && !isConfirmedOvulation;
const markers: string[] = [];
if (info?.isPeriodLogged) {
// Restate the flow grade in the aria text so the shading is never
// colour-only (the legend covers it; the cell names it too).
const flowKey =
info.flow && info.flow !== "NONE"
? `cycle.calendar.flow${info.flow.charAt(0)}${info.flow.slice(1).toLowerCase()}`
: null;
markers.push(
flowKey
? `${t("cycle.calendar.legendPeriod")} (${t(flowKey)})`
: t("cycle.calendar.legendPeriod"),
);
}
if (info?.isPredictedPeriod)
markers.push(t("cycle.calendar.legendPredicted"));
if (info?.isFertileWindow)
markers.push(t("cycle.calendar.legendFertile"));
if (isConfirmedOvulation)
markers.push(t("cycle.calendar.legendOvulationConfirmed"));
else if (isPredictedOvulationDot)
markers.push(t("cycle.calendar.legendOvulation"));
if (info?.hasSymptoms)
markers.push(t("cycle.calendar.legendSymptoms"));
const hasIntercourse = !!info?.sexualActivity;
if (hasIntercourse)
markers.push(t("cycle.calendar.legendIntercourse"));
const others = info ? otherEntryLabels(info, t) : [];
markers.push(...others);
const markers: string[] = [];
if (info?.isPeriodLogged) {
// Restate the flow grade in the aria text so the shading is never
// colour-only (the legend covers it; the cell names it too).
const flowKey =
info.flow && info.flow !== "NONE"
? `cycle.calendar.flow${info.flow.charAt(0)}${info.flow.slice(1).toLowerCase()}`
: null;
markers.push(
flowKey
? `${t("cycle.calendar.legendPeriod")} (${t(flowKey)})`
: t("cycle.calendar.legendPeriod"),
);
}
if (info?.isPredictedPeriod)
markers.push(t("cycle.calendar.legendPredicted"));
if (info?.isFertileWindow)
markers.push(t("cycle.calendar.legendFertile"));
if (isConfirmedOvulation)
markers.push(t("cycle.calendar.legendOvulationConfirmed"));
else if (isPredictedOvulationDot)
markers.push(t("cycle.calendar.legendOvulation"));
if (info?.hasSymptoms)
markers.push(t("cycle.calendar.legendSymptoms"));
const hasIntercourse = !!info?.sexualActivity;
if (hasIntercourse)
markers.push(t("cycle.calendar.legendIntercourse"));
const others = info ? otherEntryLabels(info, t) : [];
markers.push(...others);
const aria = `${date}${markers.length ? `, ${markers.join(", ")}` : ""}`;
const flowLevel =
info?.isPeriodLogged && info.flow && info.flow !== "NONE"
? info.flow
: info?.isPeriodLogged
? "UNGRADED"
: undefined;
const aria = `${date}${markers.length ? `, ${markers.join(", ")}` : ""}`;
const flowLevel =
info?.isPeriodLogged && info.flow && info.flow !== "NONE"
? info.flow
: info?.isPeriodLogged
? "UNGRADED"
: undefined;
const content = (
<>
{/* Fertile window: a soft full-cell band (calm fill, not a hard
const content = (
<>
{/* Fertile window: a soft full-cell band (calm fill, not a hard
ring) so the window reads as a continuous range across days. */}
{info?.isFertileWindow ? (
<span
aria-hidden="true"
className="absolute inset-0.5 rounded-md opacity-15"
style={{ backgroundColor: FERTILE_HUE }}
/>
) : null}
{info?.isFertileWindow ? (
<span
aria-hidden="true"
className="absolute inset-0.5 rounded-md opacity-15"
style={{ backgroundColor: FERTILE_HUE }}
/>
) : null}
{/* Logged-period filled pip behind the number — shaded by flow on
{/* Logged-period filled pip behind the number — shaded by flow on
the single-hue opacity ladder (SPOTTING→HEAVY). */}
{info?.isPeriodLogged ? (
<span
aria-hidden="true"
className="absolute inset-1 rounded-md"
style={{
backgroundColor: FLOW_HUE,
opacity: flowOpacity(info.flow),
}}
/>
) : null}
{info?.isPeriodLogged ? (
<span
aria-hidden="true"
className="absolute inset-1 rounded-md"
style={{
backgroundColor: FLOW_HUE,
opacity: flowOpacity(info.flow),
}}
/>
) : null}
{/* Confirmed-ovulation oval: a restrained light oval ringed in the
{/* Confirmed-ovulation oval: a restrained light oval ringed in the
ovulatory hue, deliberately distinct from the predicted dot. */}
{isConfirmedOvulation ? (
<span
aria-hidden="true"
className="absolute inset-x-1 inset-y-2 rounded-full ring-2 ring-inset"
style={
{
backgroundColor: OVULATION_HUE,
opacity: 0.22,
"--tw-ring-color": OVULATION_HUE,
} as React.CSSProperties
}
/>
) : null}
{isConfirmedOvulation ? (
<span
aria-hidden="true"
className="absolute inset-x-1 inset-y-2 rounded-full ring-2 ring-inset"
style={
{
backgroundColor: OVULATION_HUE,
opacity: 0.22,
"--tw-ring-color": OVULATION_HUE,
} as React.CSSProperties
}
/>
) : null}
<span className={cn("relative z-10", isToday && "text-primary")}>
{cell.getDate()}
</span>
<span
className={cn("relative z-10", isToday && "text-primary")}
>
{cell.getDate()}
</span>
{/* Predicted-period day: a soft FILL behind the number (like a
{/* Predicted-period day: a soft FILL behind the number (like a
logged period day, but lighter) with a dashed outline so it
still reads as predicted, not confirmed — replaces the old
hatched underline, which looked unfinished. */}
{info?.isPredictedPeriod && !info?.isPeriodLogged ? (
<span
aria-hidden="true"
className="absolute inset-1 rounded-md border border-dashed"
style={{
backgroundColor: FLOW_HUE,
opacity: 0.3,
borderColor: FLOW_HUE,
}}
/>
) : null}
{info?.isPredictedPeriod && !info?.isPeriodLogged ? (
<span
aria-hidden="true"
className="absolute inset-1 rounded-md border border-dashed"
style={{
backgroundColor: FLOW_HUE,
opacity: 0.3,
borderColor: FLOW_HUE,
}}
/>
) : null}
{/* Marker row: predicted-ovulation + symptom dots. The confirmed
{/* Marker row: predicted-ovulation + symptom dots. The confirmed
oval lives behind the number, so it needs no row dot. */}
<span
aria-hidden="true"
className="relative z-10 mt-0.5 flex h-2 items-center gap-0.5"
>
{isPredictedOvulationDot ? (
<span
className="h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: OVULATION_HUE }}
/>
) : null}
{info?.hasSymptoms ? (
<span className="bg-muted-foreground/70 h-1 w-1 rounded-full" />
) : null}
{hasIntercourse ? (
<span
data-slot="cycle-calendar-intercourse"
className="mx-px size-2 rotate-45"
style={{ backgroundColor: INTERCOURSE_HUE }}
/>
) : null}
{others.length > 0 ? (
<span
data-slot="cycle-calendar-other"
className="border-muted-foreground size-1.5 rounded-full border"
/>
) : null}
</span>
</>
);
aria-hidden="true"
className="relative z-10 mt-0.5 flex h-2 items-center gap-0.5"
>
{isPredictedOvulationDot ? (
<span
className="h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: OVULATION_HUE }}
/>
) : null}
{info?.hasSymptoms ? (
<span className="bg-muted-foreground/70 h-1 w-1 rounded-full" />
) : null}
{hasIntercourse ? (
<span
data-slot="cycle-calendar-intercourse"
className="mx-px size-2 rotate-45"
style={{ backgroundColor: INTERCOURSE_HUE }}
/>
) : null}
{others.length > 0 ? (
<span
data-slot="cycle-calendar-other"
className="border-muted-foreground size-1.5 rounded-full border"
/>
) : null}
</span>
</>
);
const cellClass = cn(
"relative flex aspect-square min-h-11 flex-col items-center justify-center rounded-lg text-sm transition-colors",
isToday && "font-semibold",
);
const cellAttrs = {
role: "gridcell" as const,
"aria-label": aria,
"aria-current": isToday ? ("date" as const) : undefined,
"data-flow-level": flowLevel,
"data-fertile": info?.isFertileWindow ? "true" : undefined,
"data-predicted":
info?.isPredictedPeriod && !info?.isPeriodLogged
? "true"
: undefined,
"data-ovulation": isConfirmedOvulation
? "confirmed"
: isPredictedOvulationDot
? "predicted"
: undefined,
"data-intercourse": hasIntercourse ? "true" : undefined,
"data-other-entries": others.length > 0 ? "true" : undefined,
};
const cellClass = cn(
"relative flex aspect-square min-h-11 flex-col items-center justify-center rounded-lg text-sm transition-colors",
isToday && "font-semibold",
);
const cellAttrs = {
role: "gridcell" as const,
"aria-label": aria,
"aria-current": isToday ? ("date" as const) : undefined,
"data-flow-level": flowLevel,
"data-fertile": info?.isFertileWindow ? "true" : undefined,
"data-predicted":
info?.isPredictedPeriod && !info?.isPeriodLogged
? "true"
: undefined,
"data-ovulation": isConfirmedOvulation
? "confirmed"
: isPredictedOvulationDot
? "predicted"
: undefined,
"data-intercourse": hasIntercourse ? "true" : undefined,
"data-other-entries": others.length > 0 ? "true" : undefined,
};
return onSelectDay ? (
<button
key={date}
type="button"
{...cellAttrs}
onClick={() => onSelectDay(date)}
className={cn(
cellClass,
"hover:bg-accent focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
)}
>
{content}
</button>
) : (
<div key={date} {...cellAttrs} className={cellClass}>
{content}
</div>
);
})}
return onSelectDay ? (
<button
key={date}
type="button"
{...cellAttrs}
onClick={() => onSelectDay(date)}
className={cn(
cellClass,
"hover:bg-accent focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
)}
>
{content}
</button>
) : (
<div key={date} {...cellAttrs} className={cellClass}>
{content}
</div>
);
})}
</div>
))}
</div>
<CalendarLegend />
@@ -270,127 +270,152 @@ export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
};
export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
WEIGHT: "bg-chart-1/20 text-chart-1",
BLOOD_PRESSURE_SYS: "bg-chart-3/20 text-chart-3",
BLOOD_PRESSURE_DIA: "bg-chart-3/20 text-chart-3",
PULSE: "bg-chart-5/20 text-chart-5",
BODY_FAT: "bg-chart-4/20 text-chart-4",
SLEEP_DURATION: "bg-chart-2/20 text-chart-2",
ACTIVITY_STEPS: "bg-chart-2/20 text-chart-2",
BLOOD_GLUCOSE: "bg-chart-3/20 text-chart-3",
TOTAL_BODY_WATER: "bg-chart-2/20 text-chart-2",
BONE_MASS: "bg-chart-4/20 text-chart-4",
OXYGEN_SATURATION: "bg-chart-5/20 text-chart-5",
WEIGHT: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
BLOOD_PRESSURE_SYS: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
BLOOD_PRESSURE_DIA: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
PULSE: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20",
BODY_FAT: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20",
SLEEP_DURATION: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
ACTIVITY_STEPS: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
BLOOD_GLUCOSE: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
TOTAL_BODY_WATER: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
BONE_MASS: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20",
OXYGEN_SATURATION: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20",
// ── v1.4.23 Apple Health additions ──
HEART_RATE_VARIABILITY: "bg-chart-5/20 text-chart-5",
RESTING_HEART_RATE: "bg-chart-3/20 text-chart-3",
ACTIVE_ENERGY_BURNED: "bg-chart-4/20 text-chart-4",
FLIGHTS_CLIMBED: "bg-chart-2/20 text-chart-2",
WALKING_RUNNING_DISTANCE: "bg-chart-2/20 text-chart-2",
VO2_MAX: "bg-chart-1/20 text-chart-1",
BODY_TEMPERATURE: "bg-chart-4/20 text-chart-4",
HEART_RATE_VARIABILITY: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20",
RESTING_HEART_RATE: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
ACTIVE_ENERGY_BURNED: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20",
FLIGHTS_CLIMBED: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
WALKING_RUNNING_DISTANCE: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
VO2_MAX: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
BODY_TEMPERATURE: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20",
// ── v1.4.25 W5d Withings full coverage ──
// chart-1 (mass), chart-3 (cardio), chart-4 (fat/temp), chart-5
// (pulse-derived) — extend the existing color-family conventions.
FAT_FREE_MASS: "bg-chart-1/20 text-chart-1",
FAT_MASS: "bg-chart-4/20 text-chart-4",
MUSCLE_MASS: "bg-chart-1/20 text-chart-1",
SKIN_TEMPERATURE: "bg-chart-4/20 text-chart-4",
PULSE_WAVE_VELOCITY: "bg-chart-5/20 text-chart-5",
VASCULAR_AGE: "bg-chart-3/20 text-chart-3",
VISCERAL_FAT: "bg-chart-4/20 text-chart-4",
FAT_FREE_MASS: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
FAT_MASS: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20",
MUSCLE_MASS: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
SKIN_TEMPERATURE: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20",
PULSE_WAVE_VELOCITY: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20",
VASCULAR_AGE: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
VISCERAL_FAT: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20",
// ── v1.4.25 W8d Apple Health server-prep ──
// chart-5 (pulse / sound family) carries audio exposure; chart-2
// (activity / daylight family) carries time-in-daylight so the
// existing palette conventions hold.
AUDIO_EXPOSURE_ENV: "bg-chart-5/20 text-chart-5",
AUDIO_EXPOSURE_HEADPHONE: "bg-chart-5/20 text-chart-5",
TIME_IN_DAYLIGHT: "bg-chart-2/20 text-chart-2",
AUDIO_EXPOSURE_ENV: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20",
AUDIO_EXPOSURE_HEADPHONE: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20",
TIME_IN_DAYLIGHT: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
// ── v1.4.30 R-F T1.4 + T1.5 ──
WALKING_STEADINESS: "bg-chart-2/20 text-chart-2",
AUDIO_EXPOSURE_EVENT: "bg-chart-5/20 text-chart-5",
WALKING_STEADINESS: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
AUDIO_EXPOSURE_EVENT: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20",
// ── v1.5.5 iOS-coord additions ──
// Reuse the existing palette families: chart-5 (cardio/pulse),
// chart-1 (mass), chart-2 (activity/gait).
RESPIRATORY_RATE: "bg-chart-5/20 text-chart-5",
BODY_MASS_INDEX: "bg-chart-1/20 text-chart-1",
LEAN_BODY_MASS: "bg-chart-1/20 text-chart-1",
WALKING_HEART_RATE_AVERAGE: "bg-chart-3/20 text-chart-3",
WALKING_ASYMMETRY: "bg-chart-2/20 text-chart-2",
WALKING_DOUBLE_SUPPORT: "bg-chart-2/20 text-chart-2",
RESPIRATORY_RATE: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20",
BODY_MASS_INDEX: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
LEAN_BODY_MASS: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
WALKING_HEART_RATE_AVERAGE: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
WALKING_ASYMMETRY: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
WALKING_DOUBLE_SUPPORT: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
// ── v1.5.5 iOS-coord follow-up — raw-SI gait pair ──
// Stay in chart-2 (Dracula green) — the entire Mobility cluster
// (steadiness + asymmetry + double-support + step length + speed)
// shares the activity-family colour so the gait cards read as one
// visual group on Insights.
WALKING_STEP_LENGTH: "bg-chart-2/20 text-chart-2",
WALKING_SPEED: "bg-chart-2/20 text-chart-2",
WALKING_STEP_LENGTH: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
WALKING_SPEED: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
// ── v1.10.0 — additive HealthKit signals (WX-A) ──
// chart-3 (cardio), chart-4 (temp), chart-2 (activity/gait family),
// chart-5 (sleep-breathing, shares the SpO2 family).
CARDIO_RECOVERY: "bg-chart-3/20 text-chart-3",
WRIST_TEMPERATURE: "bg-chart-4/20 text-chart-4",
FALL_COUNT: "bg-chart-2/20 text-chart-2",
SIX_MINUTE_WALK_DISTANCE: "bg-chart-2/20 text-chart-2",
STAIR_ASCENT_SPEED: "bg-chart-2/20 text-chart-2",
STAIR_DESCENT_SPEED: "bg-chart-2/20 text-chart-2",
BREATHING_DISTURBANCES: "bg-chart-5/20 text-chart-5",
CARDIO_RECOVERY: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
WRIST_TEMPERATURE: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20",
FALL_COUNT: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
SIX_MINUTE_WALK_DISTANCE: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
STAIR_ASCENT_SPEED: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
STAIR_DESCENT_SPEED: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
BREATHING_DISTURBANCES: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20",
// ── v1.10.0 — categorical events (WX-B) ──
// chart-3 (cardio family) carries the rhythm + heart-rate events;
// chart-2 (activity/mobility) carries the steadiness event; chart-5
// (respiratory/pulse family) carries the breathing event.
IRREGULAR_RHYTHM_NOTIFICATION: "bg-chart-3/20 text-chart-3",
HIGH_HEART_RATE_EVENT: "bg-chart-3/20 text-chart-3",
LOW_HEART_RATE_EVENT: "bg-chart-3/20 text-chart-3",
WALKING_STEADINESS_EVENT: "bg-chart-2/20 text-chart-2",
BREATHING_DISTURBANCE_EVENT: "bg-chart-5/20 text-chart-5",
IRREGULAR_RHYTHM_NOTIFICATION:
"bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
HIGH_HEART_RATE_EVENT: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
LOW_HEART_RATE_EVENT: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
WALKING_STEADINESS_EVENT: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
BREATHING_DISTURBANCE_EVENT: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20",
// ── v1.10.0 — computed scores (WX-C) ──
// chart-1 (Dracula purple) marks the server-derived composites as their
// own visual group, distinct from the raw-signal families above.
RECOVERY_SCORE: "bg-chart-1/20 text-chart-1",
STRESS_SCORE: "bg-chart-1/20 text-chart-1",
STRAIN_SCORE: "bg-chart-1/20 text-chart-1",
RECOVERY_SCORE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
STRESS_SCORE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
STRAIN_SCORE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
// ── v1.11.0 — WHOOP-native score classes ──
// chart-1 (Dracula purple) for the strain composites (same group as the
// WX-C scores); chart-2 (sleep/activity family) for the sleep-quality set
// and energy; chart-5 (cardio/pulse family) for RMSSD HRV.
HRV_RMSSD: "bg-chart-5/20 text-chart-5",
DAY_STRAIN: "bg-chart-1/20 text-chart-1",
WORKOUT_STRAIN: "bg-chart-1/20 text-chart-1",
SLEEP_PERFORMANCE: "bg-chart-2/20 text-chart-2",
SLEEP_EFFICIENCY: "bg-chart-2/20 text-chart-2",
SLEEP_CONSISTENCY: "bg-chart-2/20 text-chart-2",
SLEEP_NEED: "bg-chart-2/20 text-chart-2",
ENERGY_EXPENDITURE_KJ: "bg-chart-4/20 text-chart-4",
HRV_RMSSD: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20",
DAY_STRAIN: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
WORKOUT_STRAIN: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
SLEEP_PERFORMANCE: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
SLEEP_EFFICIENCY: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
SLEEP_CONSISTENCY: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
SLEEP_NEED: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
ENERGY_EXPENDITURE_KJ: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20",
// ── v1.12.8 — WHOOP cycle + sleep coverage completion ──
// chart-3 (cardio family) for the daily-aggregate heart-rate pair;
// chart-2 (sleep/activity family) for the per-night disturbance count.
AVERAGE_HEART_RATE: "bg-chart-3/20 text-chart-3",
MAX_HEART_RATE: "bg-chart-3/20 text-chart-3",
SLEEP_DISTURBANCE_COUNT: "bg-chart-2/20 text-chart-2",
AVERAGE_HEART_RATE: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
MAX_HEART_RATE: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
SLEEP_DISTURBANCE_COUNT: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
// ── v1.17.1 — Polar Nightly Recharge + Training Load Pro components ──
// chart-1 (strain/score family) for both, matching DAY_STRAIN.
ANS_CHARGE: "bg-chart-1/20 text-chart-1",
CARDIO_LOAD: "bg-chart-1/20 text-chart-1",
ANS_CHARGE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
CARDIO_LOAD: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
// ── v1.17.1 — Oura coverage completion ──
// chart-2 (sleep family) for the Sleep Score; chart-5 (metabolic/thermal)
// for the body-temperature deviation.
SLEEP_SCORE: "bg-chart-2/20 text-chart-2",
BODY_TEMPERATURE_DEVIATION: "bg-chart-5/20 text-chart-5",
SLEEP_SCORE: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
BODY_TEMPERATURE_DEVIATION: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20",
// ── v1.19.0 — Oura resilience ──
// chart-1 (Dracula purple) marks the derived recovery / score composites as
// their own visual group, matching RECOVERY_SCORE / DAY_STRAIN.
RESILIENCE: "bg-chart-1/20 text-chart-1",
RESILIENCE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
// ── v1.25 — clinical-signals wave ──
// chart-1 (composite/score family) for the screener totals + grip strength;
// chart-3 (cardio/alert family) for pain; chart-4 (body family) for waist.
PHQ9_SCORE: "bg-chart-1/20 text-chart-1",
GAD7_SCORE: "bg-chart-1/20 text-chart-1",
PHQ9_SCORE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
GAD7_SCORE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
// ── v1.27.9 — screening scores join the composite/score colour family ──
WHO5_SCORE: "bg-chart-1/20 text-chart-1",
SCI_SCORE: "bg-chart-1/20 text-chart-1",
GRIP_STRENGTH: "bg-chart-1/20 text-chart-1",
PAIN_NRS: "bg-chart-3/20 text-chart-3",
WAIST_CIRCUMFERENCE: "bg-chart-4/20 text-chart-4",
WAIST_TO_HEIGHT: "bg-chart-4/20 text-chart-4",
WHO5_SCORE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
SCI_SCORE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
GRIP_STRENGTH: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
PAIN_NRS: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
WAIST_CIRCUMFERENCE: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20",
WAIST_TO_HEIGHT: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20",
};
/**
* Count-shaped types whose stored unit is a source token ("count",
* "count/min", "steps") rather than a word a reader should see. The list
* shows the same localised unit the type's insights page uses, so a row
* reads "13 Etagen", not "13 count".
*/
export const MEASUREMENT_UNIT_LABEL_KEYS: Partial<Record<string, string>> = {
ACTIVITY_STEPS: "insights.units.steps",
FLIGHTS_CLIMBED: "insights.units.flights",
FALL_COUNT: "insights.units.falls",
BREATHING_DISTURBANCES: "insights.units.breathingEvents",
RESPIRATORY_RATE: "insights.units.respiratoryRate",
};
/** The unit a list row shows for `type`: the localised word, else `unit`. */
export function measurementUnitLabel(
type: string,
unit: string,
t: (key: string) => string,
): string {
const key = MEASUREMENT_UNIT_LABEL_KEYS[type];
return key ? t(key) : unit;
}