mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
1 parent
1ff179178f
commit
247bc4f33c
25 files changed
+401
-400
No files matched your search
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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,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,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user