fix(ui): close consistency-audit contrast and duplication findings

Today hero's top-signal delta used opacity-on-muted text, landing below
AA on the app's most prominent card; five mood charts and the sleep-
stage IN_BED fill painted raw --dracula-*/hardcoded-hex hues with no
(or an unused) light-theme counterpart. Route all of them through
existing semantic/chart tokens instead (destructive/warning/success,
chart-1..5, and a newly minted --chart-inbed with its own Alucard
override) so every hue is AA-tuned per theme.

Also collapses three duplicated primitives the audit flagged: the
click-Popover VitalsInfoTip and hover-Tooltip InfoHint merge into one
touch-first ui/info-popover; MedicationViewToggle and ModuleViewToggle
were verbatim copy-paste and now wrap one shared ui/view-toggle; the
triplicated sport-icon map (already drifted — mixedCardio rendered a
different glyph in the list vs the detail/dashboard views) moves to
lib/workouts/sport-icons.ts with HeartPulse as the settled icon.

Adds a no-restricted-imports lint rule banning direct recharts imports
outside the chart-runtime barrel, catching the bundle-duplication
mistake at local lint instead of PR-CI.
This commit is contained in:
Marc-André Bombeck committed 2026-07-18 01:23:37 +02:00
1 parent 1ff179178f
commit 247bc4f33c
25 files changed
+401 -400

No files matched your search

+48
View File
@@ -77,6 +77,54 @@ const eslintConfig = defineConfig([
"healthlog/spacing-scale": "error",
},
},
// v1.28.17 — every recharts-rendering component funnels through the
// shared `chart-runtime.ts` barrel (see its file header): pointing N
// `next/dynamic` boundaries at N different modules mints a separate
// ~312 KB recharts chunk per boundary, a regression the project already
// paid for once. Direct `from "recharts"` imports outside the barrel's
// own static import graph previously only tripped the bundle-budget
// gate at PR-CI; this rule catches the same mistake at local lint,
// mirroring how `healthlog/safe-fetch-required` scopes its own
// wrapper-internals exemption by file list.
{
files: ["src/**/*.{ts,tsx}"],
ignores: [
"src/components/charts/chart-runtime.ts",
"src/components/admin/host-metrics-chart.tsx",
"src/components/charts/health-chart.tsx",
"src/components/charts/medication-compliance-chart.tsx",
"src/components/charts/mood-chart.tsx",
"src/components/charts/nutrient-daily-bar-chart.tsx",
"src/components/charts/scatter-correlation-chart.tsx",
"src/components/custom-metrics/custom-metric-chart.tsx",
"src/components/cycle/bbt-chart.tsx",
"src/components/insights/derived/delta-sparkline.tsx",
"src/components/insights/intraday-pulse-chart.tsx",
"src/components/insights/mood/mood-distribution-chart.tsx",
"src/components/insights/mood/mood-time-of-day-chart.tsx",
"src/components/insights/mood/mood-weekday-chart.tsx",
"src/components/insights/sleep-stage-stacked-bar.tsx",
"src/components/labs/lab-biomarker-chart.tsx",
"src/components/medications/detail/efficacy/efficacy-chart.tsx",
"src/components/medications/dose-strength-curve.tsx",
"src/components/medications/drug-level-chart.tsx",
"src/components/mental-health/assessment-history-chart.tsx",
],
rules: {
"no-restricted-imports": [
"error",
{
paths: [
{
name: "recharts",
message:
"Import chart components through @/components/charts/chart-runtime (the shared dynamic-import barrel), not recharts directly — a second static import mints a duplicate recharts chunk. Add the component to chart-runtime.ts's export list if it's a new chart.",
},
],
},
],
},
},
]);
export default eslintConfig;
+14
View File
@@ -160,6 +160,17 @@
--dracula-red: #ff5555;
--dracula-yellow: #f1fa8c;
--dracula-lavender: #d6acff;
/*
* v1.28.17 — the sleep-stage IN_BED fill. Distinct from `--dracula-comment`
* above (an orphaned token — no `@theme` utility was ever generated for
* it, per the `confidence-badge.ts` note — and its `#9aa3b3` value never
* matched the classic Dracula "Comment" blue-grey this chart actually
* paints). Minted here instead of retuning the unrelated token, so this
* default carries the exact hex the stacked bar hardcoded before
* (`#6272a4`) and the light override below (`:root.light`) is this
* token's own AA-tuned counterpart.
*/
--chart-inbed: #6272a4;
/*
* Semantic feedback tokens (see @theme `--color-success` / `--color-warning`
@@ -378,6 +389,9 @@
/* Lavender has no semantic twin; a violet one step lighter than purple,
* AA-tuned — 4.97:1 card / 4.63:1 bg. */
--dracula-lavender: #7c50e0;
/* IN_BED sleep-stage fill (see the `:root` default above) — same
* blue-grey hue family, darkened for AA — 6.66:1 card / 6.03:1 bg. */
--chart-inbed: #4a5b8a;
--sidebar: oklch(0.988 0.003 300);
--sidebar-foreground: oklch(0.3 0.012 295);
--sidebar-primary: #644ac9;
+1 -1
View File
@@ -138,7 +138,7 @@ export function TodayHero({ digest }: { digest: DailyDigest }) {
>
{topSignal.headline}
{topSignal.delta ? (
<span className="text-muted-foreground/80">
<span className="text-muted-foreground">
{" "}
· {topSignal.delta}
</span>
@@ -1,16 +1,7 @@
"use client";
import Link from "next/link";
import {
Activity,
Bike,
Dumbbell,
Footprints,
HeartPulse,
Mountain,
PersonStanding,
type LucideIcon,
} from "lucide-react";
import { Activity } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { useWorkouts, type WorkoutListEntry } from "@/hooks/use-workouts";
@@ -19,6 +10,7 @@ import { TileHeader } from "@/components/insights/tile-header";
import { getDateTimeFormat } from "@/lib/intl/formatter-cache";
import { Skeleton } from "@/components/ui/skeleton";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { iconForSport } from "@/lib/workouts/sport-icons";
/**
* v1.4.32 — `<RecentWorkoutsTile>`.
@@ -38,33 +30,6 @@ import { QueryErrorCard } from "@/components/ui/query-error-card";
* round-trip per session.
*/
const SPORT_TYPE_ICON: Record<string, LucideIcon> = {
walking: Footprints,
running: PersonStanding,
cycling: Bike,
hiking: Mountain,
swimming: Activity,
rowing: Activity,
elliptical: Activity,
stairClimber: Activity,
yoga: PersonStanding,
mindAndBody: PersonStanding,
strength: Dumbbell,
hiit: Activity,
dance: Activity,
golf: Activity,
tennis: Activity,
basketball: Activity,
soccer: Activity,
crossTraining: Activity,
mixedCardio: HeartPulse,
other: Activity,
};
function iconForSport(sportType: string): LucideIcon {
return SPORT_TYPE_ICON[sportType] ?? Activity;
}
function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
@@ -16,7 +16,7 @@ import { SectionHeading } from "@/components/insights/section-heading";
import { SparklineDeltaTile } from "./sparkline-delta-tile";
import { LearnMoreLink } from "@/components/ui/learn-more-link";
import { CoverageMeter } from "./coverage-meter";
import { VitalsInfoTip } from "./vitals-info-tip";
import { InfoPopover } from "@/components/ui/info-popover";
import { METRIC_PROVENANCE } from "./standards";
import { type DerivedBatchRead } from "./use-derived-metric";
import {
@@ -171,11 +171,11 @@ function tileOrder(layout: InsightsLayout | undefined, tileId: string): number {
/**
* The (i) info-tip trigger for one derived metric, wired from the map.
* `provenance` (inputs / source / window / asOf) isn't rendered here —
* `VitalsInfoTip` carries only the method/caveat + cited standard, mirroring
* what `ProvenanceExplainer` itself renders today — but the param is kept so
* every call site (which already reads `data.provenance` off the derived
* batch) doesn't need touching if a future pass restores the detail.
* `provenance` (inputs / source / window / asOf) isn't rendered here — the
* shared `InfoPopover` carries only the method/caveat + cited standard,
* mirroring what `ProvenanceExplainer` itself renders today — but the param
* is kept so every call site (which already reads `data.provenance` off the
* derived batch) doesn't need touching if a future pass restores the detail.
*/
function MetricProvenance({
metric,
@@ -195,7 +195,13 @@ function MetricProvenance({
{t(meta.methodKey)}
</>
);
return <VitalsInfoTip method={method} standard={meta.standard} />;
return (
<InfoPopover
content={method}
link={meta.standard}
label={t("insights.derived.vitals.infoLabel")}
/>
);
}
interface TileProps {
@@ -291,7 +297,7 @@ function BaselineTile({
// v1.29.1 muted this on VITALS_BASELINE tiles because the always-on
// "typical range = median ± MAD" caption repeated across every vital
// baseline tile and read as clutter. v1.29.2 restores the context —
// now behind the compact (i) trigger (`VitalsInfoTip`) instead of a
// now behind the compact (i) trigger (`InfoPopover`) instead of a
// full-width caption, so every baseline tile carries it again
// without the repetition or the header-row squeeze that clipped the
// cardio-fitness heading.
@@ -1,90 +0,0 @@
"use client";
import { useState } from "react";
import { Info } from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { ProvenanceStandard } from "./provenance-explainer";
/**
* v1.29.2 QoL — the vitals-dashboard tile's (i) info affordance.
*
* v1.29.1 dropped the always-on "your personal normal range is the
* median…" method caption from every `VITALS_BASELINE` tile because it
* repeated verbatim across up to six tiles and read as clutter at the top
* of the vitals surface — and, on the narrower tiles, squeezed the heading
* itself (the VO₂max/cardio-fitness clip). Rather than reintroduce the
* caption at full length, the SAME method/caveat/standard content moves
* behind a small (i) trigger: a click-opened `Popover` (not a hover
* tooltip — the vitals grid sees most of its use on touch), so the context
* is one tap away instead of permanently occupying header space.
*
* This is a DELIBERATE, narrowly-scoped exception to the v1.22 "no
* icon-only disclosure" direction (`ProvenanceExplainer`'s own docstring):
* that decision targeted the always-visible-caption pattern on OTHER
* surfaces (coincident-deviation, the score-anatomy pages), which stay
* unchanged. The vitals grid is dense enough, and the caption repetitive
* enough, that the same content is better one tap away here.
*/
export interface VitalsInfoTipProps {
/** Plain-language method (+ optional caveat) description — text children only. */
method: React.ReactNode;
/** Optional cited standard, rendered as a plain external link. */
standard?: ProvenanceStandard;
className?: string;
}
export function VitalsInfoTip({
method,
standard,
className,
}: VitalsInfoTipProps) {
const { t } = useTranslations();
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
data-slot="vitals-info-trigger"
aria-label={t("insights.derived.vitals.infoLabel")}
aria-expanded={open}
className={cn(
"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50",
"inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full",
"-mx-2 -my-3",
"transition-colors focus-visible:ring-2 focus-visible:outline-none",
className,
)}
>
<Info className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</PopoverTrigger>
<PopoverContent
data-slot="vitals-info-body"
align="end"
sideOffset={6}
className="max-w-xs space-y-1.5"
>
<p className="text-muted-foreground text-xs leading-snug">{method}</p>
{standard ? (
<a
href={standard.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary block text-xs leading-snug hover:underline"
>
{standard.name}
</a>
) : null}
</PopoverContent>
</Popover>
);
}
@@ -3,7 +3,7 @@
import { useId, useState } from "react";
import { ChevronDown } from "lucide-react";
import { InfoHint } from "@/components/ui/info-hint";
import { InfoPopover } from "@/components/ui/info-popover";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { GlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
@@ -149,7 +149,7 @@ function AdvancedStat({
<div className="space-y-0.5">
<dt className="text-muted-foreground flex items-center gap-1 text-xs">
{label}
<InfoHint label={help} />
<InfoPopover content={help} />
</dt>
<dd className="text-foreground text-base font-semibold tabular-nums">
{value}
@@ -6,7 +6,7 @@ import { Activity, Droplet, FlaskConical, TrendingUp } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { InfoHint } from "@/components/ui/info-hint";
import { InfoPopover } from "@/components/ui/info-popover";
import { LearningGate } from "@/components/ui/learning-gate";
import { LearnMoreLink } from "@/components/ui/learn-more-link";
import { Skeleton } from "@/components/ui/skeleton";
@@ -198,7 +198,9 @@ export function GlucoseClinicalPanel() {
<div className="space-y-0.5" data-slot="glucose-cv">
<span className="text-muted-foreground flex items-center gap-1 text-xs">
{t("insights.bloodGlucose.clinical.cv.label")}
<InfoHint label={t("insights.bloodGlucose.clinical.cv.help")} />
<InfoPopover
content={t("insights.bloodGlucose.clinical.cv.help")}
/>
</span>
<div className="flex items-center gap-1.5">
<span className="text-foreground text-lg font-semibold tabular-nums">
@@ -285,7 +287,7 @@ function Metric({
<span className="text-muted-foreground flex items-center gap-1 text-xs">
{icon}
{label}
{help ? <InfoHint label={help} /> : null}
{help ? <InfoPopover content={help} /> : null}
</span>
<span className="text-foreground flex items-baseline gap-1">
<span className="text-lg font-semibold tabular-nums">{value}</span>
@@ -34,9 +34,10 @@ describe("mood tag bars — full-saturation palette (v1.19.0)", () => {
for (const fill of fills ?? []) {
expect(fill).not.toContain("opacity-55");
}
// The level hue itself stays a Dracula token.
expect(html).toContain("var(--dracula-red)");
expect(html).toContain("var(--dracula-green)");
// The level hue itself stays a token — semantic since v1.28.17 (was a
// raw `--dracula-*` primitive; see mood-tag-breakdown.tsx).
expect(html).toContain("var(--destructive)");
expect(html).toContain("var(--success)");
});
it("structured tag breakdown bars render at full opacity", () => {
@@ -66,12 +66,16 @@ const Y_LABEL_KEY: Record<MetricKind, string> = {
bloodPressure: "insights.mood.correlation.bloodPressureAxis",
};
// v1.28.17 — the shared `--chart-1..5` vocabulary (same values as the
// `--dracula-purple/cyan/pink/green/orange` primitives it replaces, per
// theme), not raw palette tokens — matches the `no-raw-palette-color`
// discipline and the app's other chart-series colouring.
const FILL_BY_KIND: Record<MetricKind, string> = {
sleep: "var(--dracula-purple)",
steps: "var(--dracula-cyan)",
pulse: "var(--dracula-pink)",
weight: "var(--dracula-green)",
bloodPressure: "var(--dracula-orange)",
sleep: "var(--chart-1)",
steps: "var(--chart-4)",
pulse: "var(--chart-3)",
weight: "var(--chart-2)",
bloodPressure: "var(--chart-5)",
};
const STRENGTH_KEY: Record<CorrelationResult["strength"], string> = {
@@ -28,12 +28,17 @@ export interface MoodDistributionRow {
count: number;
}
// v1.28.17 — semantic tokens, not raw `--dracula-*` primitives: every one of
// these already carries an AA-tuned Alucard (light-theme) override, but
// routing through the meaning-bearing alias (destructive/warning/yellow/
// success) keeps this map legible as a severity ladder rather than an
// arbitrary palette pick, matching the `no-raw-palette-color` discipline.
const BAR_COLOR_BY_SCORE: Record<number, string> = {
1: "var(--dracula-red)",
2: "var(--dracula-orange)",
1: "var(--destructive)",
2: "var(--warning)",
3: "var(--dracula-yellow)",
4: "var(--dracula-green)",
5: "var(--dracula-green)",
4: "var(--success)",
5: "var(--success)",
};
export function MoodDistributionChart({
@@ -68,8 +73,8 @@ export function MoodDistributionChart({
// not overridden in `:root.light` (Alucard white card) → white-on-
// white. `--muted-foreground` is the token every other in-app chart
// uses for axis ticks and is legible in both themes; dark mode is
// unchanged. The mood-hue bar Cells stay on `--dracula-*` — only the
// axis text legibility changes here.
// unchanged. The mood-hue bar Cells route through semantic tokens
// (see `BAR_COLOR_BY_SCORE`), AA-tuned per theme too.
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
stroke="var(--muted-foreground)"
interval={0}
@@ -80,8 +85,8 @@ export function MoodDistributionChart({
// not overridden in `:root.light` (Alucard white card) → white-on-
// white. `--muted-foreground` is the token every other in-app chart
// uses for axis ticks and is legible in both themes; dark mode is
// unchanged. The mood-hue bar Cells stay on `--dracula-*` — only the
// axis text legibility changes here.
// unchanged. The mood-hue bar Cells route through semantic tokens
// (see `BAR_COLOR_BY_SCORE`), AA-tuned per theme too.
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
stroke="var(--muted-foreground)"
width={28}
@@ -117,7 +122,7 @@ export function MoodDistributionChart({
{data.map((row) => (
<Cell
key={row.score}
fill={BAR_COLOR_BY_SCORE[row.score] ?? "var(--dracula-purple)"}
fill={BAR_COLOR_BY_SCORE[row.score] ?? "var(--primary)"}
/>
))}
</Bar>
@@ -19,11 +19,13 @@ export interface MoodTagRow {
avgScore: number;
}
// v1.28.17 — semantic tokens, not raw `--dracula-*` primitives (see
// mood-distribution-chart's `BAR_COLOR_BY_SCORE` for the same swap).
function colorForScore(score: number): string {
if (score < 2) return "var(--dracula-red)";
if (score < 3) return "var(--dracula-orange)";
if (score < 2) return "var(--destructive)";
if (score < 3) return "var(--warning)";
if (score < 3.5) return "var(--dracula-yellow)";
return "var(--dracula-green)";
return "var(--success)";
}
export function MoodTagBreakdown({ tags }: { tags: MoodTagRow[] }) {
@@ -47,11 +47,13 @@ const LABEL_KEYS: Record<MoodTimeOfDayBucket, string> = {
night: "insights.mood.timeOfDay.night",
};
// v1.28.17 — semantic tokens, not raw `--dracula-*` primitives (see
// mood-distribution-chart's `BAR_COLOR_BY_SCORE` for the same swap).
function colorForScore(score: number): string {
if (score < 2) return "var(--dracula-red)";
if (score < 3) return "var(--dracula-orange)";
if (score < 2) return "var(--destructive)";
if (score < 3) return "var(--warning)";
if (score < 3.5) return "var(--dracula-yellow)";
return "var(--dracula-green)";
return "var(--success)";
}
export function MoodTimeOfDayChart({
@@ -39,11 +39,13 @@ const WEEKDAY_LABEL_KEYS = [
"charts.weekdaysFull.sun",
] as const;
// v1.28.17 — semantic tokens, not raw `--dracula-*` primitives (see
// mood-distribution-chart's `BAR_COLOR_BY_SCORE` for the same swap).
function colorForScore(score: number): string {
if (score < 2) return "var(--dracula-red)";
if (score < 3) return "var(--dracula-orange)";
if (score < 2) return "var(--destructive)";
if (score < 3) return "var(--warning)";
if (score < 3.5) return "var(--dracula-yellow)";
return "var(--dracula-green)";
return "var(--success)";
}
export function MoodWeekdayChart({ weekday }: { weekday: MoodWeekdayRow[] }) {
@@ -88,7 +90,8 @@ export function MoodWeekdayChart({ weekday }: { weekday: MoodWeekdayRow[] }) {
// v1.15.14 — theme-aware axis text (see mood-distribution-chart).
// `--dracula-fg` was white-on-white on the light-mode card; switch
// to the shared `--muted-foreground` axis-tick token. Bar mood
// hues stay `--dracula-*`; dark mode is unchanged.
// hues route through semantic tokens (see `colorForScore`);
// dark mode is unchanged.
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
stroke="var(--muted-foreground)"
interval={0}
@@ -99,7 +102,8 @@ export function MoodWeekdayChart({ weekday }: { weekday: MoodWeekdayRow[] }) {
// v1.15.14 — theme-aware axis text (see mood-distribution-chart).
// `--dracula-fg` was white-on-white on the light-mode card; switch
// to the shared `--muted-foreground` axis-tick token. Bar mood
// hues stay `--dracula-*`; dark mode is unchanged.
// hues route through semantic tokens (see `colorForScore`);
// dark mode is unchanged.
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
stroke="var(--muted-foreground)"
width={24}
@@ -94,7 +94,7 @@ export const STAGE_COLORS: Record<string, string> = {
CORE: "var(--info)", // dracula-cyan — bulk of sleep
ASLEEP: "var(--success)", // dracula-green — legacy iOS 15- unspecified
AWAKE: "var(--dracula-yellow)", // dracula-yellow — wake bouts
IN_BED: "#6272a4", // dracula-comment — pre-asleep
IN_BED: "var(--chart-inbed)", // muted blue-grey — pre-asleep
};
function formatMinutes(total: number, locale: string): string {
@@ -4,7 +4,7 @@ import { Moon } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { InfoHint } from "@/components/ui/info-hint";
import { InfoPopover } from "@/components/ui/info-popover";
import { TileHeader } from "@/components/insights/tile-header";
import { LearningGate } from "@/components/ui/learning-gate";
import type { SleepDebtDto } from "./use-sleep-rhythm";
@@ -43,7 +43,7 @@ export function SleepDebtCard({ debt }: { debt: SleepDebtDto }) {
title={t("insights.sleep.debt.title")}
right={
debt.source === "COMPUTED" ? (
<InfoHint label={t("insights.sleep.debt.computedInfo")} />
<InfoPopover content={t("insights.sleep.debt.computedInfo")} />
) : undefined
}
/>
@@ -75,7 +75,7 @@ export function SleepDebtCard({ debt }: { debt: SleepDebtDto }) {
title={t("insights.sleep.debt.title")}
right={
debt.source === "COMPUTED" ? (
<InfoHint label={t("insights.sleep.debt.computedInfo")} />
<InfoPopover content={t("insights.sleep.debt.computedInfo")} />
) : undefined
}
/>
+1 -32
View File
@@ -2,17 +2,12 @@
import type { ReactNode } from "react";
import {
Activity,
Bike,
Dumbbell,
Flame,
Footprints,
HeartPulse,
Map,
Mountain,
PersonStanding,
Timer,
type LucideIcon,
} from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -23,6 +18,7 @@ import {
hourCycleOptions,
type TimeFormatPreference,
} from "@/lib/format-locale";
import { iconForSport } from "@/lib/workouts/sport-icons";
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
/**
@@ -35,33 +31,6 @@ import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
* `WorkoutDetailPayload`.
*/
const SPORT_TYPE_ICON: Record<string, LucideIcon> = {
walking: Footprints,
running: PersonStanding,
cycling: Bike,
hiking: Mountain,
swimming: Activity,
rowing: Activity,
elliptical: Activity,
stairClimber: Activity,
yoga: PersonStanding,
mindAndBody: PersonStanding,
strength: Dumbbell,
hiit: Activity,
dance: Activity,
golf: Activity,
tennis: Activity,
basketball: Activity,
soccer: Activity,
crossTraining: Activity,
mixedCardio: HeartPulse,
other: Activity,
};
function iconForSport(sportType: string): LucideIcon {
return SPORT_TYPE_ICON[sportType] ?? Activity;
}
function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
+1 -37
View File
@@ -1,16 +1,6 @@
"use client";
import Link from "next/link";
import {
Activity,
Bike,
Dumbbell,
Footprints,
Heart,
Mountain,
PersonStanding,
type LucideIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache";
@@ -19,6 +9,7 @@ import {
hourCycleOptions,
type TimeFormatPreference,
} from "@/lib/format-locale";
import { iconForSport } from "@/lib/workouts/sport-icons";
import type { WorkoutListEntry } from "@/hooks/use-workouts";
/**
@@ -33,33 +24,6 @@ import type { WorkoutListEntry } from "@/hooks/use-workouts";
* lands here.
*/
const SPORT_TYPE_ICON: Record<string, LucideIcon> = {
walking: Footprints,
running: PersonStanding,
cycling: Bike,
hiking: Mountain,
swimming: Activity,
rowing: Activity,
elliptical: Activity,
stairClimber: Activity,
yoga: PersonStanding,
mindAndBody: PersonStanding,
strength: Dumbbell,
hiit: Activity,
dance: Activity,
golf: Activity,
tennis: Activity,
basketball: Activity,
soccer: Activity,
crossTraining: Activity,
mixedCardio: Heart,
other: Activity,
};
function iconForSport(sportType: string): LucideIcon {
return SPORT_TYPE_ICON[sportType] ?? Activity;
}
function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
@@ -3,16 +3,13 @@
import { LayoutGrid, Table as TableIcon } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { ViewToggle } from "@/components/ui/view-toggle";
import type { MedicationListView } from "@/lib/medication-list-layout";
/**
* v1.16.10 — icon-only card/table view toggle for the /medications page
* header. A small two-segment control: the active segment carries the
* filled surface, the inactive one stays ghost. `aria-pressed` announces
* the state per segment; the group is named for screen readers. Segments
* keep the 44-px mobile tap floor (`size-11`), shrinking to the standard
* 36-px control height on desktop like the neighbouring Add button.
* header. Thin wrapper over the shared `ViewToggle` primitive; see its
* docstring for the interaction grammar.
*/
interface MedicationViewToggleProps {
view: MedicationListView;
@@ -25,49 +22,24 @@ export function MedicationViewToggle({
}: MedicationViewToggleProps) {
const { t } = useTranslations();
const segments = [
{
value: "cards" as const,
label: t("medications.viewCards"),
icon: LayoutGrid,
},
{
value: "table" as const,
label: t("medications.viewTable"),
icon: TableIcon,
},
];
return (
<div
role="group"
aria-label={t("medications.viewToggleLabel")}
className="border-border bg-background/30 inline-flex shrink-0 items-center rounded-md border p-0.5"
>
{segments.map(({ value, label, icon: Icon }) => {
const active = view === value;
return (
<button
key={value}
type="button"
onClick={() => {
if (!active) onChange(value);
}}
aria-pressed={active}
aria-label={label}
title={label}
data-slot={`medications-view-${value}`}
className={cn(
"focus-visible:ring-ring inline-flex size-10 items-center justify-center rounded-[5px] transition-colors focus-visible:ring-2 focus-visible:outline-none motion-reduce:transition-none sm:size-8",
active
? "bg-secondary text-secondary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
<Icon className="size-4" aria-hidden="true" />
</button>
);
})}
</div>
<ViewToggle
view={view}
onChange={onChange}
groupLabel={t("medications.viewToggleLabel")}
dataSlotPrefix="medications-view"
segments={[
{
value: "cards",
label: t("medications.viewCards"),
icon: LayoutGrid,
},
{
value: "table",
label: t("medications.viewTable"),
icon: TableIcon,
},
]}
/>
);
}
@@ -18,7 +18,7 @@
* `assessment-result`, `assessment-history`, `crisis-card`).
* The landing follows the card grammar of Vorsorge / Medications: instrument
* cards (last-result line + Start) + a history card. The disclaimer renders
* ONLY here (a muted caption) and behind the InfoHint — never while testing.
* ONLY here (a muted caption) and behind the InfoPopover — never while testing.
*
* v1.27.9 — the landing is intro + instrument cards, nothing else: the
* combined history card left the main page entirely. Each card opens a
@@ -3,15 +3,15 @@
import { LayoutGrid, List as ListIcon } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { ViewToggle } from "@/components/ui/view-toggle";
import type { ModuleListView } from "@/lib/module-list-prefs";
/**
* v1.18.6 (W8 / MOD-03) — icon-only card/list view toggle shared by the
* Vorsorge / Illness / Labs settings pages. Mirrors the medication module's
* `MedicationViewToggle` grammar (two ghost segments, the active one filled,
* `aria-pressed` per segment) but switches between a card grid and a compact
* list rather than cards-vs-table, so the label set is `moduleList.view*`.
* Vorsorge / Illness / Labs settings pages. Thin wrapper over the shared
* `ViewToggle` primitive (mirrors the medication module's
* `MedicationViewToggle`, which is the same wrapper switching between a
* card grid and a compact list rather than cards-vs-table).
*/
interface ModuleViewToggleProps {
view: ModuleListView;
@@ -21,49 +21,24 @@ interface ModuleViewToggleProps {
export function ModuleViewToggle({ view, onChange }: ModuleViewToggleProps) {
const { t } = useTranslations();
const segments = [
{
value: "cards" as const,
label: t("moduleList.viewCards"),
icon: LayoutGrid,
},
{
value: "list" as const,
label: t("moduleList.viewList"),
icon: ListIcon,
},
];
return (
<div
role="group"
aria-label={t("moduleList.viewToggleLabel")}
className="border-border bg-background/30 inline-flex shrink-0 items-center rounded-md border p-0.5"
>
{segments.map(({ value, label, icon: Icon }) => {
const active = view === value;
return (
<button
key={value}
type="button"
onClick={() => {
if (!active) onChange(value);
}}
aria-pressed={active}
aria-label={label}
title={label}
data-slot={`module-view-${value}`}
className={cn(
"focus-visible:ring-ring inline-flex size-10 items-center justify-center rounded-[5px] transition-colors focus-visible:ring-2 focus-visible:outline-none motion-reduce:transition-none sm:size-8",
active
? "bg-secondary text-secondary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
<Icon className="size-4" aria-hidden="true" />
</button>
);
})}
</div>
<ViewToggle
view={view}
onChange={onChange}
groupLabel={t("moduleList.viewToggleLabel")}
dataSlotPrefix="module-view"
segments={[
{
value: "cards",
label: t("moduleList.viewCards"),
icon: LayoutGrid,
},
{
value: "list",
label: t("moduleList.viewList"),
icon: ListIcon,
},
]}
/>
);
}
-60
View File
@@ -1,60 +0,0 @@
"use client";
import { Info } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
/**
* A small, tap-reachable help affordance. Replaces the bare `title=`
* attribute, which never surfaces on touch. The trigger is a real button
* with an accessible name, so keyboard and screen-reader users reach the
* same hint a pointer user gets on hover; tapping the button focuses it,
* which opens the tooltip on touch devices.
*/
export function InfoHint({
label,
className,
}: {
/** The hint text shown inside the tooltip. */
label: string;
className?: string;
}) {
const { t } = useTranslations();
return (
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
data-slot="info-hint"
aria-label={t("common.moreInfo")}
className={cn(
// A 44px (WCAG 2.5.5) tap target whose visible footprint stays at
// the 24px the icon needs: the 44px box is offset by an equal
// negative margin (`p-2.5` + `-m-2.5`), so surrounding inline
// layout is unchanged while the touchable area meets the floor.
"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 -m-2.5 inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full p-2.5 transition-colors focus-visible:ring-2 focus-visible:outline-none",
className,
)}
>
<Info className="size-3.5" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent
data-slot="info-hint-body"
align="start"
className="max-w-xs leading-relaxed"
>
{label}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
+96
View File
@@ -0,0 +1,96 @@
"use client";
import { useState } from "react";
import { Info } from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
/** A plain external citation link rendered under the popover body. */
export interface InfoPopoverLink {
/** Display name of the cited source (e.g. "WHO BMI classification"). */
name: string;
/** Absolute URL, rendered as a plain `<a>`, never HTML. */
url: string;
}
/**
* The app's one (i) info-affordance. Replaces the bare `title=` attribute
* (which never surfaces on touch) and, since v1.28.17, the earlier
* two-primitive split: a click-opened `Popover` — not a hover `Tooltip` —
* because most of this app's use is on touch, where a hover-only tooltip
* never opens reliably on tap. `v1.29.2`'s `VitalsInfoTip` made exactly
* this argument for the vitals grid; the same reasoning applies to every
* other (i) trigger in the tree, so the two primitives merge into this one.
*
* The trigger is a real button with an accessible name, so keyboard and
* screen-reader users reach the same content a pointer user gets on tap.
*/
export interface InfoPopoverProps {
/** Body content — plain text or a short mix of text nodes. */
content: React.ReactNode;
/** Optional cited source, rendered as a plain external link. */
link?: InfoPopoverLink;
/**
* Accessible name of the trigger button. Defaults to the generic
* "More information" — pass a more specific label (e.g. "How is this
* calculated?") where the caller wants that framing.
*/
label?: string;
className?: string;
}
export function InfoPopover({
content,
link,
label,
className,
}: InfoPopoverProps) {
const { t } = useTranslations();
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
data-slot="info-popover-trigger"
aria-label={label ?? t("common.moreInfo")}
aria-expanded={open}
className={cn(
"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50",
"inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full",
"-mx-2 -my-3",
"transition-colors focus-visible:ring-2 focus-visible:outline-none",
className,
)}
>
<Info className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</PopoverTrigger>
<PopoverContent
data-slot="info-popover-body"
align="end"
sideOffset={6}
className="max-w-xs space-y-1.5"
>
<p className="text-muted-foreground text-xs leading-snug">{content}</p>
{link ? (
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary block text-xs leading-snug hover:underline"
>
{link.name}
</a>
) : null}
</PopoverContent>
</Popover>
);
}
+77
View File
@@ -0,0 +1,77 @@
"use client";
import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
export interface ViewToggleSegment<T extends string> {
value: T;
label: string;
icon: LucideIcon;
}
export interface ViewToggleProps<T extends string> {
view: T;
onChange: (view: T) => void;
segments: ViewToggleSegment<T>[];
/** Accessible name for the segmented-control group. */
groupLabel: string;
/** `data-slot` prefix per segment, rendered as `${dataSlotPrefix}-${value}`. */
dataSlotPrefix: string;
}
/**
* Shared icon-only segmented view toggle (e.g. cards vs table/list). A small
* two-segment control: the active segment carries the filled surface, the
* inactive one stays ghost. `aria-pressed` announces the state per segment;
* the group is named for screen readers. Segments keep the 44-px mobile tap
* floor (`size-11` via `size-10` + the `sm:size-8` desktop step), shrinking
* to the standard 36-px control height on desktop like a neighbouring Add
* button.
*
* Previously forked between the medications module
* (`medications/medication-view-toggle.tsx`) and the Vorsorge/Illness/Labs
* settings pages (`module-list/module-view-toggle.tsx`) — verbatim
* copy-paste that only diverged in icons + i18n keys and had already
* drifted. Both are now thin wrappers over this one primitive.
*/
export function ViewToggle<T extends string>({
view,
onChange,
segments,
groupLabel,
dataSlotPrefix,
}: ViewToggleProps<T>) {
return (
<div
role="group"
aria-label={groupLabel}
className="border-border bg-background/30 inline-flex shrink-0 items-center rounded-md border p-0.5"
>
{segments.map(({ value, label, icon: Icon }) => {
const active = view === value;
return (
<button
key={value}
type="button"
onClick={() => {
if (!active) onChange(value);
}}
aria-pressed={active}
aria-label={label}
title={label}
data-slot={`${dataSlotPrefix}-${value}`}
className={cn(
"focus-visible:ring-ring inline-flex size-10 items-center justify-center rounded-[5px] transition-colors focus-visible:ring-2 focus-visible:outline-none motion-reduce:transition-none sm:size-8",
active
? "bg-secondary text-secondary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
<Icon className="size-4" aria-hidden="true" />
</button>
);
})}
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
import {
Activity,
Bike,
Dumbbell,
Footprints,
HeartPulse,
Mountain,
PersonStanding,
type LucideIcon,
} from "lucide-react";
/**
* Shared sport-type → icon map. Previously three private copies
* (`workout-list.tsx`, `workout-detail.tsx`, `recent-workouts-tile.tsx`)
* that had already drifted — `mixedCardio` rendered `Heart` in the list but
* `HeartPulse` in the detail view and the dashboard tile, so the same
* workout showed two different glyphs between the row and its detail page.
* `HeartPulse` won as the shared value (two of three sites already agreed).
*/
export const SPORT_TYPE_ICON: Record<string, LucideIcon> = {
walking: Footprints,
running: PersonStanding,
cycling: Bike,
hiking: Mountain,
swimming: Activity,
rowing: Activity,
elliptical: Activity,
stairClimber: Activity,
yoga: PersonStanding,
mindAndBody: PersonStanding,
strength: Dumbbell,
hiit: Activity,
dance: Activity,
golf: Activity,
tennis: Activity,
basketball: Activity,
soccer: Activity,
crossTraining: Activity,
mixedCardio: HeartPulse,
other: Activity,
};
export function iconForSport(sportType: string): LucideIcon {
return SPORT_TYPE_ICON[sportType] ?? Activity;
}