mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
03f374794e
commit
b80dc14142
7 files changed
+328
-273
No files matched your search
@@ -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>{" "}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user