diff --git a/e2e/score-history.spec.ts b/e2e/score-history.spec.ts
index b91dd5d01..74cd68ddb 100644
--- a/e2e/score-history.spec.ts
+++ b/e2e/score-history.spec.ts
@@ -137,12 +137,12 @@ async function expectSharedTabs(page: Page, chart: string, pressed: string) {
await expect(tabs).toHaveCount(4);
expect(
await tabs.evaluateAll((nodes) =>
- nodes.map((node) => node.getAttribute("data-range")),
+ nodes.map((node) => node.getAttribute("data-value")),
),
).toEqual(RANGES);
await expect(
page.locator(`${chart} [data-slot="chart-range-tab"][aria-pressed="true"]`),
- ).toHaveAttribute("data-range", pressed);
+ ).toHaveAttribute("data-value", pressed);
}
test.describe("score histories", () => {
@@ -170,7 +170,7 @@ test.describe("score histories", () => {
expect(asked).toContain(30);
await chart
- .locator('[data-slot="chart-range-tab"][data-range="7"]')
+ .locator('[data-slot="chart-range-tab"][data-value="7"]')
.click();
await expect(chart).toHaveAttribute("data-range", "7");
await expect.poll(() => asked.at(-1)).toBe(7);
@@ -185,7 +185,7 @@ test.describe("score histories", () => {
await expect(chart).toHaveAttribute("data-range", "7");
await chart
- .locator('[data-slot="chart-range-tab"][data-range="0"]')
+ .locator('[data-slot="chart-range-tab"][data-value="0"]')
.click();
await expect(chart).toHaveAttribute("data-range", "0");
await expect.poll(() => asked.at(-1)).toBe(3650);
@@ -231,7 +231,7 @@ test.describe("score histories", () => {
await expect(chart).toHaveAttribute("data-score", "READINESS");
await expectSharedTabs(page, '[data-slot="score-trend-chart"]', "30");
await chart
- .locator('[data-slot="chart-range-tab"][data-range="90"]')
+ .locator('[data-slot="chart-range-tab"][data-value="90"]')
.click();
await expect(chart).toHaveAttribute("data-range", "90");
await expect.poll(() => asked.at(-1)).toBe(90);
@@ -262,7 +262,7 @@ test.describe("score histories", () => {
page.locator(`${chart} [data-slot="chart-overlay-controls-trigger"]`),
).toHaveCount(0);
await page
- .locator(`${chart} [data-slot="chart-range-tab"][data-range="7"]`)
+ .locator(`${chart} [data-slot="chart-range-tab"][data-value="7"]`)
.click();
await expectSharedTabs(page, chart, "7");
await page.reload();
diff --git a/messages/de.json b/messages/de.json
index 1c92cceec..030c254cc 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -7736,6 +7736,7 @@
"tokensEmptyTitle": "Noch keine API-Tokens",
"tokensEmptyDescription": "Tokens werden bei der Anmeldung nativer Clients oder über Einstellungen → Account ausgestellt.",
"loginOverviewDescription": "Anmelde- und Auth-Ereignisse aller Konten, filterbar und als CSV exportierbar.",
+ "loginOverviewFilterLabel": "Ereignisse filtern",
"allAuthEvents": "Alle Auth-Ereignisse",
"failedOnly": "Nur fehlgeschlagene",
"loginEmptyDescription": "Anmeldungen, Abmeldungen und Passkey-Änderungen erscheinen hier.",
@@ -7864,6 +7865,7 @@
"admin": "Admins",
"user": "Nutzer"
},
+ "filterLabel": "Benutzer filtern",
"forceLogout": "Abmeldung erzwingen",
"forceLogoutConfirmTitle": "{name} abmelden?",
"forceLogoutConfirmBody": "Alle aktiven Sitzungen dieses Nutzers werden widerrufen. Auf allen Geräten ist eine erneute Anmeldung erforderlich.",
diff --git a/messages/en.json b/messages/en.json
index 20d1ac6a4..a486f636a 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -7736,6 +7736,7 @@
"tokensEmptyTitle": "No API tokens yet",
"tokensEmptyDescription": "Tokens are issued when a native client signs in or via Settings → Account.",
"loginOverviewDescription": "Sign-in and auth events across all accounts, filterable and exportable as CSV.",
+ "loginOverviewFilterLabel": "Filter events",
"allAuthEvents": "All auth events",
"failedOnly": "Failed only",
"loginEmptyDescription": "Sign-ins, sign-outs, and passkey changes will show up here.",
@@ -7864,6 +7865,7 @@
"admin": "Admins",
"user": "Users"
},
+ "filterLabel": "Filter users",
"forceLogout": "Force logout",
"forceLogoutConfirmTitle": "Force logout {name}?",
"forceLogoutConfirmBody": "All active sessions for this user will be revoked. They will need to sign in again on every device.",
diff --git a/messages/es.json b/messages/es.json
index 38c7f6f1c..a154ba9bd 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -7736,6 +7736,7 @@
"tokensEmptyTitle": "Aún no hay tokens de API",
"tokensEmptyDescription": "Los tokens se emiten cuando un cliente nativo inicia sesión o desde Ajustes → Cuenta.",
"loginOverviewDescription": "Eventos de inicio de sesión y autenticación de todas las cuentas, filtrables y exportables como CSV.",
+ "loginOverviewFilterLabel": "Filtrar eventos",
"allAuthEvents": "Todos los eventos de autenticación",
"failedOnly": "Solo fallidos",
"loginEmptyDescription": "Aquí aparecerán los inicios de sesión, los cierres de sesión y los cambios de passkey.",
@@ -7864,6 +7865,7 @@
"admin": "Administradores",
"user": "Usuarios"
},
+ "filterLabel": "Filtrar usuarios",
"forceLogout": "Forzar cierre de sesión",
"forceLogoutConfirmTitle": "¿Forzar el cierre de sesión de {name}?",
"forceLogoutConfirmBody": "Se revocarán todas las sesiones activas de este usuario. Tendrá que volver a iniciar sesión en todos los dispositivos.",
diff --git a/messages/fr.json b/messages/fr.json
index 7f653d505..0807d9df1 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -7736,6 +7736,7 @@
"tokensEmptyTitle": "Aucun jeton d'API pour l'instant",
"tokensEmptyDescription": "Les jetons sont émis lorsqu'un client natif se connecte ou via Paramètres → Compte.",
"loginOverviewDescription": "Événements de connexion et d’authentification de tous les comptes, filtrables et exportables en CSV.",
+ "loginOverviewFilterLabel": "Filtrer les événements",
"allAuthEvents": "Tous les événements d'authentification",
"failedOnly": "Échecs uniquement",
"loginEmptyDescription": "Les connexions, déconnexions et modifications de passkey apparaîtront ici.",
@@ -7864,6 +7865,7 @@
"admin": "Administrateurs",
"user": "Utilisateurs"
},
+ "filterLabel": "Filtrer les utilisateurs",
"forceLogout": "Forcer la déconnexion",
"forceLogoutConfirmTitle": "Forcer la déconnexion de {name} ?",
"forceLogoutConfirmBody": "Toutes les sessions actives de cet utilisateur seront révoquées. Il devra se reconnecter sur chaque appareil.",
diff --git a/messages/it.json b/messages/it.json
index 6e3618cb8..52629264f 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -7736,6 +7736,7 @@
"tokensEmptyTitle": "Ancora nessun token API",
"tokensEmptyDescription": "I token vengono emessi quando un client nativo accede o tramite Impostazioni → Account.",
"loginOverviewDescription": "Eventi di accesso e autenticazione di tutti gli account, filtrabili ed esportabili in CSV.",
+ "loginOverviewFilterLabel": "Filtra eventi",
"allAuthEvents": "Tutti gli eventi di autenticazione",
"failedOnly": "Solo non riusciti",
"loginEmptyDescription": "Qui compariranno accessi, disconnessioni e modifiche delle passkey.",
@@ -7864,6 +7865,7 @@
"admin": "Amministratori",
"user": "Utenti"
},
+ "filterLabel": "Filtra utenti",
"forceLogout": "Forza la disconnessione",
"forceLogoutConfirmTitle": "Forzare la disconnessione di {name}?",
"forceLogoutConfirmBody": "Tutte le sessioni attive di questo utente verranno revocate. Dovrà accedere di nuovo su ogni dispositivo.",
diff --git a/messages/ko.json b/messages/ko.json
index d1c43962a..3b245cf71 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -7711,6 +7711,7 @@
"tokensEmptyTitle": "아직 API 토큰이 없어요",
"tokensEmptyDescription": "네이티브 클라이언트가 로그인할 때나 설정 → 계정에서 토큰이 발급돼요.",
"loginOverviewDescription": "모든 계정의 로그인·인증 이벤트예요. 필터링하고 CSV로 내보낼 수 있어요.",
+ "loginOverviewFilterLabel": "이벤트 필터",
"allAuthEvents": "전체 인증 이벤트",
"failedOnly": "실패만",
"loginEmptyDescription": "로그인, 로그아웃, 패스키 변경이 여기에 표시돼요.",
@@ -7839,6 +7840,7 @@
"admin": "관리자",
"user": "사용자"
},
+ "filterLabel": "사용자 필터",
"forceLogout": "강제 로그아웃",
"forceLogoutConfirmTitle": "{name} 강제 로그아웃할까요?",
"forceLogoutConfirmBody": "이 사용자의 활성 세션이 모두 해제돼요. 모든 기기에서 다시 로그인해야 해요.",
diff --git a/messages/nl.json b/messages/nl.json
index f6540db5e..6732ea4dd 100644
--- a/messages/nl.json
+++ b/messages/nl.json
@@ -7736,6 +7736,7 @@
"tokensEmptyTitle": "Nog geen API-tokens",
"tokensEmptyDescription": "Tokens worden uitgegeven wanneer een native client inlogt of via Instellingen → Account.",
"loginOverviewDescription": "Inlog- en authenticatiegebeurtenissen van alle accounts, te filteren en te exporteren als CSV.",
+ "loginOverviewFilterLabel": "Gebeurtenissen filteren",
"allAuthEvents": "Alle authenticatiegebeurtenissen",
"failedOnly": "Alleen mislukte",
"loginEmptyDescription": "Inlogs, uitlogs en wijzigingen aan passkeys verschijnen hier.",
@@ -7864,6 +7865,7 @@
"admin": "Beheerders",
"user": "Gebruikers"
},
+ "filterLabel": "Gebruikers filteren",
"forceLogout": "Geforceerd uitloggen",
"forceLogoutConfirmTitle": "{name} geforceerd uitloggen?",
"forceLogoutConfirmBody": "Alle actieve sessies van deze gebruiker worden ingetrokken. Deze gebruiker moet daarna op elk apparaat opnieuw inloggen.",
diff --git a/messages/pl.json b/messages/pl.json
index 49f041e09..21304ed2c 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -7736,6 +7736,7 @@
"tokensEmptyTitle": "Brak tokenów API",
"tokensEmptyDescription": "Tokeny są wydawane, gdy klient natywny się loguje, lub przez Ustawienia → Konto.",
"loginOverviewDescription": "Zdarzenia logowania i uwierzytelniania wszystkich kont, z filtrowaniem i eksportem CSV.",
+ "loginOverviewFilterLabel": "Filtruj zdarzenia",
"allAuthEvents": "Wszystkie zdarzenia uwierzytelniania",
"failedOnly": "Tylko nieudane",
"loginEmptyDescription": "Tutaj pojawią się logowania, wylogowania i zmiany kluczy passkey.",
@@ -7864,6 +7865,7 @@
"admin": "Administratorzy",
"user": "Użytkownicy"
},
+ "filterLabel": "Filtruj użytkowników",
"forceLogout": "Wymuś wylogowanie",
"forceLogoutConfirmTitle": "Wymusić wylogowanie {name}?",
"forceLogoutConfirmBody": "Wszystkie aktywne sesje tego użytkownika zostaną unieważnione. Będzie musiał zalogować się ponownie na każdym urządzeniu.",
diff --git a/src/components/admin/login-overview-section.tsx b/src/components/admin/login-overview-section.tsx
index a6893ce0a..a20d6578d 100644
--- a/src/components/admin/login-overview-section.tsx
+++ b/src/components/admin/login-overview-section.tsx
@@ -16,6 +16,7 @@ import {
XCircle,
} from "lucide-react";
import { Button } from "@/components/ui/button";
+import { Segmented } from "@/components/ui/segmented";
import { EmptyState } from "@/components/ui/empty-state";
import { Input } from "@/components/ui/input";
import {
@@ -231,26 +232,17 @@ export function LoginOverviewSection() {
{/* Quick-filter pills. Failed-only is the most common
admin "is something wrong" question — keep the one-tap shortcut. */}
-
- setFilter("all"))}
- >
- {t("admin.allAuthEvents")}
-
- setFilter("failed"))}
- >
- {t("admin.failedOnly")}
-
-
+
{/* Detailed filter row */}
diff --git a/src/components/admin/user-management-section.tsx b/src/components/admin/user-management-section.tsx
index 2cc1b5aff..6f8f23a34 100644
--- a/src/components/admin/user-management-section.tsx
+++ b/src/components/admin/user-management-section.tsx
@@ -16,6 +16,7 @@ import { showErrorToast } from "@/lib/errors/error-toast";
import { Badge } from "@/components/ui/badge";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Button } from "@/components/ui/button";
+import { Segmented } from "@/components/ui/segmented";
import { EmptyState } from "@/components/ui/empty-state";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -312,20 +313,17 @@ export function UserManagementSection() {
className="flex flex-wrap items-center gap-1.5"
data-slot="admin-users-filter"
>
- {(["all", "admin", "user"] as const).map((value) => (
- setFilter(value)}
- aria-pressed={filter === value}
- >
- {t(`admin.section.users.filter.${value}`)}
-
- ))}
+ ({
+ value,
+ label: t(`admin.section.users.filter.${value}`),
+ }))}
+ />
{filteredUsers ? (
diff --git a/src/components/charts/chart-range-tabs.tsx b/src/components/charts/chart-range-tabs.tsx
index 0f3ba18b5..4c8528606 100644
--- a/src/components/charts/chart-range-tabs.tsx
+++ b/src/components/charts/chart-range-tabs.tsx
@@ -1,6 +1,6 @@
"use client";
-import { Button } from "@/components/ui/button";
+import { Segmented } from "@/components/ui/segmented";
import type { ChartRangePoints } from "@/lib/dashboard-layout";
import { useTranslations } from "@/lib/i18n/context";
@@ -50,22 +50,17 @@ export function ChartRangeTabs({
}) {
const { t } = useTranslations();
return (
- <>
- {CHART_RANGE_TABS.map((r) => (
-
onChange(r.days)}
- title={t(r.titleKey)}
- data-slot="chart-range-tab"
- data-range={r.days}
- >
- {t(r.labelKey)}
-
- ))}
- >
+
"chart-range-tab"}
+ value={String(value)}
+ onChange={(next) => onChange(Number(next) as ChartRangePoints)}
+ options={CHART_RANGE_TABS.map((r) => ({
+ value: String(r.days),
+ label: t(r.labelKey),
+ title: t(r.titleKey),
+ }))}
+ />
);
}
diff --git a/src/components/charts/mood-chart.tsx b/src/components/charts/mood-chart.tsx
index 28e9219ed..04c26438d 100644
--- a/src/components/charts/mood-chart.tsx
+++ b/src/components/charts/mood-chart.tsx
@@ -15,7 +15,6 @@ import {
ReferenceLine,
} from "recharts";
import { useEffect, useState, useMemo, useRef } from "react";
-import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Card, CardHeader, CardContent } from "@/components/ui/card";
import { TagChip } from "@/components/ui/tag-chip";
@@ -43,6 +42,7 @@ import {
useChartDayLinks,
} from "@/components/day/chart-day-links";
import { ChartEmptyState } from "./chart-empty-state";
+import { ChartRangeTabs } from "./chart-range-tabs";
import { ChartErrorState } from "./chart-error-state";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { shiftDailySeriesForward } from "@/lib/charts/comparison-shift";
@@ -127,32 +127,8 @@ interface MoodChartProps {
// --- Constants ---
// The range tabs select a CALENDAR-DAY window ending now, matching
-// `health-chart.tsx` (see the note there). Until v1.37.29 this chart
-// sliced the last N LOGGED DAYS out of the whole history instead — the
-// same "7" tab meant seven calendar days on a weight chart and seven
-// logged days (possibly weeks apart) on the mood chart one card over.
-const TIME_RANGES_KEYS = [
- {
- labelKey: "charts.days7Label",
- days: 7,
- titleKey: "charts.days7Title",
- },
- {
- labelKey: "charts.days30Label",
- days: 30,
- titleKey: "charts.days30Title",
- },
- {
- labelKey: "charts.days90Label",
- days: 90,
- titleKey: "charts.days90Title",
- },
- {
- labelKey: "charts.daysAllLabel",
- days: 0,
- titleKey: "charts.daysAllTitle",
- },
-] as const;
+// `health-chart.tsx`, through the same `ChartRangeTabs` every chart drawn in
+// days carries.
// MOOD_LABELS built dynamically via t() in the component
@@ -1126,31 +1102,21 @@ export function MoodChart({
className="flex flex-nowrap items-center justify-end gap-1 self-end sm:self-auto"
data-slot="chart-header-controls"
>
- {TIME_RANGES_KEYS.map((r) => (
- {
- setRangeHydrated(true);
- setRangePoints(r.days);
- // v1.30.1 — persist the pick per chart, same
- // model as the overlay toggles.
- if (chartKey && !windowOverride) {
- overlayPrefs.setPrefs({
- ...overlayPrefs.prefs,
- rangePoints: r.days,
- });
- }
- }}
- title={t(r.titleKey)}
- data-slot="chart-range-tab"
- >
- {t(r.labelKey)}
-
- ))}
+ {
+ setRangeHydrated(true);
+ setRangePoints(days);
+ // v1.30.1 — persist the pick per chart, same
+ // model as the overlay toggles.
+ if (chartKey && !windowOverride) {
+ overlayPrefs.setPrefs({
+ ...overlayPrefs.prefs,
+ rangePoints: days,
+ });
+ }
+ }}
+ />
{/* v1.4.18 — overlay-controls dropdown next to the
range tabs. Only painted when the chart is bound to
a persistent chartKey; ad-hoc usages (/insights mood
diff --git a/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx b/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx
index c38bf6bbd..74207d86a 100644
--- a/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx
+++ b/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx
@@ -183,10 +183,10 @@ describe("", () => {
expect(html).toContain('data-slot="sleep-stage-window-30"');
// The 7d button is default-active.
expect(html).toMatch(
- /data-slot="sleep-stage-window-7"[^>]*aria-pressed="true"/,
+ /aria-pressed="true"[^>]*data-slot="sleep-stage-window-7"/,
);
expect(html).toMatch(
- /data-slot="sleep-stage-window-14"[^>]*aria-pressed="false"/,
+ /aria-pressed="false"[^>]*data-slot="sleep-stage-window-14"/,
);
});
diff --git a/src/components/insights/derived/__tests__/score-trend-chart.test.tsx b/src/components/insights/derived/__tests__/score-trend-chart.test.tsx
index 0cda9f76c..3e655d79b 100644
--- a/src/components/insights/derived/__tests__/score-trend-chart.test.tsx
+++ b/src/components/insights/derived/__tests__/score-trend-chart.test.tsx
@@ -94,7 +94,7 @@ describe("", () => {
const html = render(30, { points: POINTS, band: null });
const tabs = html.match(/]*data-slot="chart-range-tab"[^>]*>/g);
expect(tabs).toHaveLength(4);
- expect(tabs!.map((tab) => tab.match(/data-range="(\d+)"/)![1])).toEqual([
+ expect(tabs!.map((tab) => tab.match(/data-value="(\d+)"/)![1])).toEqual([
"7",
"30",
"90",
@@ -102,7 +102,7 @@ describe("", () => {
]);
const pressed = tabs!.filter((tab) => tab.includes('aria-pressed="true"'));
expect(pressed).toHaveLength(1);
- expect(pressed[0]).toContain('data-range="30"');
+ expect(pressed[0]).toContain('data-value="30"');
expect(html).toContain('data-slot="score-trend-chart"');
// The range is remembered under the page's own slot.
expect(prefs.lastKey).toBe("scoreHealth");
@@ -115,7 +115,7 @@ describe("", () => {
html.match(/]*data-slot="chart-range-tab"[^>]*>/g) ?? []
).filter((tab) => tab.includes('aria-pressed="true"'));
expect(pressed).toHaveLength(1);
- expect(pressed[0]).toContain('data-range="90"');
+ expect(pressed[0]).toContain('data-value="90"');
// The seeded 90-day read is the one the chart drew from.
expect(html.match(/data-slot="chart-data-table-row"/g)).toHaveLength(3);
});
diff --git a/src/components/insights/period-narrative-card.tsx b/src/components/insights/period-narrative-card.tsx
index 94249a5a1..8602b8bbc 100644
--- a/src/components/insights/period-narrative-card.tsx
+++ b/src/components/insights/period-narrative-card.tsx
@@ -12,6 +12,7 @@ import {
import { formatUpdatedLabel } from "@/lib/i18n/relative-time";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
+import { Segmented } from "@/components/ui/segmented";
import { Skeleton } from "@/components/ui/skeleton";
import { SectionHeading } from "@/components/ui/section-heading";
import { AskCoachIconButton } from "@/components/insights/ask-coach-action";
@@ -175,29 +176,17 @@ export function PeriodNarrativeCard({
reads best. */}
{/* Calm segmented control, no chart. */}
-
- {(["week", "month"] as const).map((p) => {
- const active = period === p;
- return (
- setPeriod(p)}
- className={cn(
- "focus-visible:ring-ring inline-flex min-h-11 items-center rounded-md px-2.5 py-1 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none",
- active
- ? "bg-muted text-foreground"
- : "text-muted-foreground hover:text-foreground",
- )}
- >
- {p === "week"
- ? t("insights.narrativeWeek")
- : t("insights.narrativeMonth")}
-
- );
- })}
-
+
-
- {WINDOW_DAYS.map((w) => (
- setWindowDays(w)}
- aria-pressed={windowDays === w}
- data-slot={`sleep-stage-window-${w}`}
- >
- {/* The page's other range controls read "7T / 30T"; this
- one printed an English "7d" beside them. */}
- {t("charts.daysNLabel", { days: w })}
-
- ))}
-
+ {/* The page's other range controls read "7T / 30T"; this one
+ printed an English "7d" beside them, hence `daysNLabel`. */}
+ `sleep-stage-window-${w}`}
+ className="self-end sm:self-auto"
+ value={String(windowDays)}
+ onChange={(next) => setWindowDays(Number(next) as WindowSize)}
+ options={WINDOW_DAYS.map((w) => ({
+ value: String(w),
+ label: t("charts.daysNLabel", { days: w }),
+ }))}
+ />
diff --git a/src/components/labs/lab-form.tsx b/src/components/labs/lab-form.tsx
index 839244897..b75b4f90b 100644
--- a/src/components/labs/lab-form.tsx
+++ b/src/components/labs/lab-form.tsx
@@ -8,6 +8,7 @@ import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
+import { Segmented } from "@/components/ui/segmented";
import { DateTimeField } from "@/components/ui/date-time-field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -424,28 +425,18 @@ export function LabForm({
{t("labs.form.resultType")}
-
- setResultType("numeric")}
- aria-pressed={resultType === "numeric"}
- >
- {t("labs.form.numeric")}
-
- setResultType("qualitative")}
- aria-pressed={resultType === "qualitative"}
- >
- {t("labs.form.qualitative")}
-
-
+
{/* One column at every width, as in the measurement form. A second
diff --git a/src/components/settings/security-section/existing-factor-reauth-dialog.tsx b/src/components/settings/security-section/existing-factor-reauth-dialog.tsx
index 63b7234f1..357e8d4a1 100644
--- a/src/components/settings/security-section/existing-factor-reauth-dialog.tsx
+++ b/src/components/settings/security-section/existing-factor-reauth-dialog.tsx
@@ -13,7 +13,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
-import { Button } from "@/components/ui/button";
+import { Segmented } from "@/components/ui/segmented";
import { Input } from "@/components/ui/input";
import { useTranslations } from "@/lib/i18n/context";
import { ApiError, apiPost } from "@/lib/api/api-fetch";
@@ -180,28 +180,19 @@ export function ExistingFactorReauthDialog({
{methods.length > 1 && (
-
- {methods.map((m) => (
- {
- setMethod(m);
- setSecret("");
- setAssertionError(null);
- }}
- >
- {labels[m]}
-
- ))}
-
+ {
+ setMethod(m);
+ setSecret("");
+ setAssertionError(null);
+ }}
+ options={methods.map((m) => ({ value: m, label: labels[m] }))}
+ />
)}
{method === "password" && (
diff --git a/src/components/ui/__tests__/segmented.test.tsx b/src/components/ui/__tests__/segmented.test.tsx
index b3fef660b..26aa5b325 100644
--- a/src/components/ui/__tests__/segmented.test.tsx
+++ b/src/components/ui/__tests__/segmented.test.tsx
@@ -137,3 +137,62 @@ describe("one segmented control", () => {
expect(defining).toEqual(["components/ui/segmented.tsx"]);
});
});
+
+/**
+ * A row of buttons whose `aria-pressed` compares a state to each button's
+ * value is a segmented control spelled by hand: six of them sat beside
+ * `Segmented` (the narrative period, the lab result type, both admin filters,
+ * the chart range tabs, the mood chart's copy of them), each with its own
+ * size, fill and gap. New ones are refused; the ones that remain are not one
+ * choice on one track, and say why.
+ */
+describe("no hand-built segmented control", () => {
+ const PRESSED_BY_VALUE = /aria-pressed=\{[^}]*===[^}]*\}/g;
+
+ const NOT_A_SEGMENT: Record = {
+ "components/settings/security-section/passkey-list-section.tsx":
+ "one method is disabled while the account has no passkey; Segmented disables the whole track",
+ "components/medications/units-per-dose-field.tsx":
+ "a chip row that opens a free-number field for any other count",
+ "components/body-sites/body-sites-section.tsx":
+ "wrapping side chips under a body-site picker, not a track",
+ "components/illness/log-day-sheet.tsx":
+ "a graded severity scale per symptom, one square per level",
+ "components/cycle/log-day-sheet.tsx":
+ "a graded severity scale per symptom, one circle per level",
+ "components/charts/chart-overlay-controls.tsx":
+ "baselines stay pressable while greyed, with a tooltip saying why there is no data",
+ "components/timeline/timeline-view.tsx":
+ "a single button that opens the range sheet, beside the zoom Segmented",
+ };
+
+ function pressedByValue(source: string): number {
+ return (source.match(PRESSED_BY_VALUE) ?? []).length;
+ }
+
+ it("finds none outside the reasoned exceptions", async () => {
+ const { readFileSync } = await import("node:fs");
+ const { join } = await import("node:path");
+ const { stripComments, walkSourceFiles } =
+ await import("@/__tests__/helpers/source-files");
+ const src = join(__dirname, "../../..");
+ const found = walkSourceFiles(src, { floor: 2000 })
+ .filter((p) => /^(components|app)\//.test(p))
+ .filter((p) => !p.includes("__tests__/"))
+ .filter((p) => p !== "components/ui/segmented.tsx")
+ .filter(
+ (p) =>
+ pressedByValue(stripComments(readFileSync(join(src, p), "utf8"))) > 0,
+ )
+ .sort();
+ expect(found).toEqual(Object.keys(NOT_A_SEGMENT).sort());
+ });
+
+ it("counter-check: catches a hand-built pair, not an on/off toggle", () => {
+ expect(
+ pressedByValue(`All
+Failed `),
+ ).toBe(2);
+ expect(pressedByValue(`Show `)).toBe(0);
+ });
+});
diff --git a/src/components/ui/segmented.tsx b/src/components/ui/segmented.tsx
index 786ccb026..bd19683e0 100644
--- a/src/components/ui/segmented.tsx
+++ b/src/components/ui/segmented.tsx
@@ -37,6 +37,8 @@ export interface SegmentedOption {
icon?: LucideIcon;
/** Rendered after the label (a connected check beside a system name). */
trailing?: ReactNode;
+ /** A longer name shown on hover ("Last 7 days" for "7d"). */
+ title?: string;
}
export interface SegmentedProps {
@@ -118,8 +120,13 @@ export function Segmented({
...getRadioProps(index),
})}
{...(iconOnly
- ? { "aria-label": option.label, title: option.label }
- : {})}
+ ? {
+ "aria-label": option.label,
+ title: option.title ?? option.label,
+ }
+ : option.title
+ ? { title: option.title }
+ : {})}
disabled={disabled}
data-value={option.value}
data-slot={segmentSlot?.(option.value)}