chore(release): v1.4.27

v1.4.27 — Mobile capability and maintainer-finding cleanup.

ResponsiveSheet primitive (bottom-sheet on narrow viewports, centred dialog on desktop)
applied to BD, Weight, Glucose, Mood, Activity forms. Maintainer-finding cleanups across
Coach, Insights, Reports, Privacy. GeoLite2 offline geocoding with resilience layer
(graceful fallback when EULA not yet propagated). E2E gap-fill coverage. 450-line
release notes attached to the GitHub Release.
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-05-15 22:59:10 +02:00
1 parent d98e85cd03
commit 4f09c3f862
314 files changed
+26801 -4419

No files matched your search

@@ -0,0 +1,148 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* v1.4.27 MB5 — `/settings/api` mobile card-list parity.
*
* The section ships three tables: endpoint catalogue, active tokens,
* revoked tokens. Each table now renders twice:
* - desktop `<table>` wrapped in `hidden md:block`
* - mobile `<ul>` tagged `md:hidden`
*
* This file pins both surfaces against regressions so a future tweak
* can't strip the mobile card list (and force a 393 CSS-px viewport
* back into horizontal scroll).
*/
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/settings/api",
useSearchParams: () => new URLSearchParams(),
}));
const sampleTokens = [
{
id: "tok-active",
name: "iOS app",
permissions: ["ingest:medication"],
lastUsedAt: "2026-05-08T12:00:00Z",
expiresAt: null,
createdAt: "2026-05-01T08:00:00Z",
revoked: false,
},
{
id: "tok-revoked",
name: "Old laptop",
permissions: ["ingest:weight"],
lastUsedAt: null,
expiresAt: null,
createdAt: "2026-04-20T08:00:00Z",
revoked: true,
},
];
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: sampleTokens,
isLoading: false,
isError: false,
refetch: vi.fn(),
}),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
useMutation: () => ({
mutate: vi.fn(),
isPending: false,
isError: false,
error: null,
}),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: {
id: "u1",
username: "marc",
email: "marc@example.com",
role: "USER",
},
isAuthenticated: true,
isLoading: false,
refetch: vi.fn(),
}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { ApiSection } from "../api-section";
function render() {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<ApiSection />
</I18nProvider>,
);
}
describe("ApiSection — responsive dual-rendering", () => {
it("renders the endpoint table behind `hidden md:block`", () => {
const html = render();
// The endpoint catalogue is the first table; its desktop wrapper
// must carry the `hidden md:block` pair so phones never see it.
expect(html).toMatch(/<div[^>]*\bhidden\b[^>]*md:block[^>]*>[\s\S]*<table/);
});
it("exposes a mobile card list for the endpoint catalogue", () => {
const html = render();
expect(html).toContain('data-testid="settings-api-endpoints-mobile-list"');
// The mobile <ul> carries `md:hidden` (class attribute precedes
// `data-testid` in the rendered markup, so we check both anchors
// independently).
const endpointMatch = html.match(
/<ul[^>]*data-testid="settings-api-endpoints-mobile-list"[^>]*>/,
);
expect(endpointMatch).not.toBeNull();
expect(endpointMatch![0]).toContain("md:hidden");
});
it("exposes a mobile card list for the active tokens table", () => {
const html = render();
expect(html).toContain('data-testid="settings-api-tokens-mobile-list"');
const tokensMatch = html.match(
/<ul[^>]*data-testid="settings-api-tokens-mobile-list"[^>]*>/,
);
expect(tokensMatch).not.toBeNull();
expect(tokensMatch![0]).toContain("md:hidden");
// Every column from the desktop table is reachable as labelled
// copy in the mobile card body.
expect(html).toContain("iOS app");
expect(html).toContain("ingest:medication");
});
it("mobile card lists carry no overflow-x-auto wrapper", () => {
const html = render();
// Both mobile <ul> blocks must contain zero `overflow-x-auto`. That
// was the original v1.4.27 CF-19 regression — the table wrapper
// forced horizontal scroll on phones for content that easily fits
// a card column.
const endpointMatch = html.match(
/<ul[^>]*settings-api-endpoints-mobile-list[^>]*>([\s\S]*?)<\/ul>/,
);
expect(endpointMatch).not.toBeNull();
expect(endpointMatch![1]).not.toContain("overflow-x-auto");
const tokensMatch = html.match(
/<ul[^>]*settings-api-tokens-mobile-list[^>]*>([\s\S]*?)<\/ul>/,
);
expect(tokensMatch).not.toBeNull();
expect(tokensMatch![1]).not.toContain("overflow-x-auto");
});
it("preserves the desktop tables verbatim", () => {
const html = render();
// The desktop tables must still mount — we did not delete them,
// only paired them with the mobile fallback.
expect(html).toContain("<table");
// Active-token cell content shows up inside <td> elements as it did
// before this change.
expect(html).toMatch(/<td[^>]*>iOS app<\/td>/);
});
});
+1 -1
View File
@@ -284,7 +284,7 @@ export function AboutSection() {
disabled={checking}
>
{checking ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
)}
+59 -52
View File
@@ -37,7 +37,8 @@ import {
import { Input } from "@/components/ui/input";
import { DateInput } from "@/components/ui/date-input";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/settings/password-input";
import { NativeSelect } from "@/components/ui/native-select";
import { PasswordInput } from "@/components/ui/password-input";
import { useAuth } from "@/hooks/use-auth";
import { formatDate } from "@/lib/format";
import { locales, localeLabels, type Locale } from "@/lib/i18n/config";
@@ -53,15 +54,10 @@ interface PasskeyInfo {
createdAt: string;
}
/**
* Shared class string for native `<select>` elements inside the Account
* card. Mirrors the visual contract of `<Input>` and shadcn
* `<SelectTrigger>` (`h-9`, identical border/focus tokens) so gender,
* language, and any future native dropdown render at the same height
* and width as every other input in the form.
*/
const NATIVE_SELECT_CLASS =
"border-input bg-background text-foreground ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none";
// v1.4.27 MB7 / CF-52 — the in-file `NATIVE_SELECT_CLASS` constant
// retired; the shared `<NativeSelect>` primitive owns the visual
// contract now. Existing `<select className={NATIVE_SELECT_CLASS}>`
// call sites in this file swapped to `<NativeSelect>` below.
export function AccountSection() {
const { t, locale, setLocale } = useTranslations();
@@ -355,7 +351,12 @@ export function AccountSection() {
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="username">{t("settings.username")}</Label>
<Input id="username" value={user.username} disabled />
<Input
id="username"
value={user.username}
disabled
autoComplete="username"
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">{t("auth.email")}</Label>
@@ -366,6 +367,8 @@ export function AccountSection() {
onChange={(e) => setEmail(e.target.value)}
placeholder={t("auth.emailPlaceholder")}
maxLength={320}
autoComplete="email"
enterKeyHint="next"
/>
</div>
</div>
@@ -373,16 +376,15 @@ export function AccountSection() {
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="gender">{t("settings.gender")}</Label>
<select
<NativeSelect
id="gender"
value={gender}
onChange={(e) => setGender(e.target.value)}
className={NATIVE_SELECT_CLASS}
>
<option value="">{t("settings.genderNone")}</option>
<option value="MALE">{t("settings.genderMale")}</option>
<option value="FEMALE">{t("settings.genderFemale")}</option>
</select>
</NativeSelect>
<p className="text-muted-foreground text-xs">
{t("settings.genderHint")}
</p>
@@ -392,6 +394,8 @@ export function AccountSection() {
<Input
id="height"
type="number"
inputMode="decimal"
enterKeyHint="next"
value={heightCm}
onChange={(e) => setHeightCm(e.target.value)}
placeholder="175"
@@ -402,13 +406,12 @@ export function AccountSection() {
</div>
</div>
{/* Date of birth pairs with the height/gender block above —
all three are "biological profile" data the app uses for
BMI + BP target calculations. Language is handled as its
own row below: it's a UI preference (cookie-backed),
not a profile attribute, so v1.4.19 A6 lifted it out of
the dob/language pair where users mistook it for a
per-account locale setting. */}
{/* Date of birth + language share one paired grid row so the
profile form keeps a single rhythm (every row two cells
wide on sm+). Date of birth is the bottom of the
biological-profile block; language is the only UI
preference on this card. They sit together to close the
"single-cell row" gap that broke the form's grid. */}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="dob">{t("settings.dateOfBirth")}</Label>
@@ -422,29 +425,27 @@ export function AccountSection() {
{t("settings.dateOfBirthHint")}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="language-select">{t("settings.language")}</Label>
<NativeSelect
id="language-select"
value={locale}
onChange={(e) => setLocale(e.target.value as Locale)}
>
{locales.map((loc) => (
<option key={loc} value={loc}>
{localeLabels[loc as Locale]}
</option>
))}
</NativeSelect>
<p className="text-muted-foreground text-xs">
{t("settings.languageDescription")}
</p>
</div>
</div>
<TimezonePicker value={timezone} onChange={setTimezone} />
<div className="space-y-2">
<Label htmlFor="language-select">{t("settings.language")}</Label>
<select
id="language-select"
value={locale}
onChange={(e) => setLocale(e.target.value as Locale)}
className={`${NATIVE_SELECT_CLASS} sm:max-w-xs`}
>
{locales.map((loc) => (
<option key={loc} value={loc}>
{localeLabels[loc as Locale]}
</option>
))}
</select>
<p className="text-muted-foreground text-xs">
{t("settings.languageDescription")}
</p>
</div>
{saveMsg && (
<p
role="alert"
@@ -461,7 +462,7 @@ export function AccountSection() {
<div className="flex justify-end">
<Button type="submit" disabled={saving}>
{saving ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
@@ -485,7 +486,7 @@ export function AccountSection() {
disabled={passkeyLoading}
>
{passkeyLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<KeyRound className="mr-2 h-4 w-4" />
)}
@@ -609,9 +610,9 @@ export function AccountSection() {
</DialogDescription>
</DialogHeader>
<form onSubmit={handleChangePassword} className="space-y-3">
<form onSubmit={handleChangePassword} className="space-y-4">
<div className="grid gap-3 sm:grid-cols-3">
<div className="space-y-1.5">
<div className="space-y-2">
<Label htmlFor="current-password">
{t("settings.currentPassword")}
</Label>
@@ -621,7 +622,7 @@ export function AccountSection() {
onChange={(e) => setCurrentPassword(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<div className="space-y-2">
<Label htmlFor="new-password">
{t("settings.newPassword")}
</Label>
@@ -631,7 +632,7 @@ export function AccountSection() {
onChange={(e) => setNewPassword(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<div className="space-y-2">
<Label htmlFor="confirm-password">
{t("settings.confirmNewPassword")}
</Label>
@@ -709,7 +710,7 @@ function PasskeyListSection({ isAuthenticated }: { isAuthenticated: boolean }) {
if (!passkeys || passkeys.length === 0) {
return (
<div>
<h3 className="text-sm font-medium">{t("settings.passkeys")}</h3>
<h3 className="text-sm font-semibold">{t("settings.passkeys")}</h3>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.noPasskeys")}
</p>
@@ -719,7 +720,7 @@ function PasskeyListSection({ isAuthenticated }: { isAuthenticated: boolean }) {
return (
<div>
<h3 className="text-sm font-medium">
<h3 className="text-sm font-semibold">
{t("settings.registeredPasskeys")}
</h3>
<p className="text-muted-foreground mt-1 text-xs">
@@ -832,11 +833,17 @@ function PasskeyListSection({ isAuthenticated }: { isAuthenticated: boolean }) {
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{pk.name}</p>
<p className="text-muted-foreground text-xs">
{DEVICE_TYPE_LABELS[pk.credentialDeviceType] ??
pk.credentialDeviceType}
</p>
{/* v1.4.27 MB7 / CF-75 — promote the device-type from
plain text to an outline Badge so the mobile card
list reads consistent with the desktop table's
"Single-device / Multi-device" column. Sits on the
same chip row as the backup status and date so all
metadata reads as a single horizontal stride. */}
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px]">
<Badge variant="outline" className="text-[11px]">
{DEVICE_TYPE_LABELS[pk.credentialDeviceType] ??
pk.credentialDeviceType}
</Badge>
<Badge
variant={pk.credentialBackedUp ? "secondary" : "outline"}
className="text-[11px]"
+1 -1
View File
@@ -281,7 +281,7 @@ function DataResetCard() {
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm" disabled={deleting}>
{deleting ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Trash2 className="mr-1 h-3.5 w-3.5" />
)}
+32 -29
View File
@@ -31,8 +31,9 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { Switch } from "@/components/ui/switch";
import { PasswordInput } from "@/components/settings/password-input";
import { PasswordInput } from "@/components/ui/password-input";
import { useAuth } from "@/hooks/use-auth";
import { formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
@@ -249,7 +250,7 @@ function AiInsightsCard({ isAuthenticated }: { isAuthenticated: boolean }) {
}
return (
<div className="bg-card border-border space-y-6 rounded-xl border p-6">
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex items-center gap-2">
<Sparkles className="text-primary h-5 w-5" />
@@ -341,12 +342,14 @@ function ProviderStatusBadges({
}
/**
* The single Pulldown that drives every form below it. Uses a native
* `<select>` so the SSR-only settings test renders deterministically
* without a portal-based Radix tree. Mobile: full-width, height matched
* to the shared 36-px input contract used everywhere else in Settings
* (`<Input>` is `h-9`); tap target stays comfortable thanks to the
* full-width chevron region.
* The single Pulldown that drives every form below it. Uses the
* shared `<NativeSelect>` primitive so the SSR-only settings test
* renders deterministically without a portal-based Radix tree, and so
* the visual contract stays uniform with the other settings pickers
* that landed behind the same primitive in MB7 / CF-52. Mobile:
* full-width, height matched to the shared 36 px input contract used
* everywhere else in Settings; tap target stays comfortable thanks to
* the full-width chevron region.
*/
function ActiveProviderSelect({
value,
@@ -367,7 +370,7 @@ function ActiveProviderSelect({
<Label htmlFor="ai-active-provider-select">
{t("settings.ai.activeProviderLabel")}
</Label>
<select
<NativeSelect
id="ai-active-provider-select"
data-testid="ai-active-provider-select"
value={value}
@@ -375,14 +378,14 @@ function ActiveProviderSelect({
const next = e.target.value;
if (isProviderType(next)) onChange(next);
}}
className="bg-background border-input mt-1 h-9 w-full rounded-md border px-3 text-sm sm:max-w-md"
className="mt-1 sm:max-w-md"
>
{PROVIDER_TYPES.map((p) => (
<option key={p} value={p}>
{t(`settings.ai.providerSelect.${p}` as const)}
</option>
))}
</select>
</NativeSelect>
</div>
);
}
@@ -820,12 +823,12 @@ function OpenAIProviderForm({
<Label htmlFor="ai-openai-model">
{t("settings.ai.openai.modelSelect")}
</Label>
<select
<NativeSelect
id="ai-openai-model"
data-testid="ai-openai-model"
value={modelChoice}
onChange={(e) => setModelChoice(e.target.value)}
className="bg-background border-input mt-1 h-9 w-full rounded-md border px-2 text-sm"
className="mt-1"
>
<option value="">{t("settings.ai.modelOptionDefault")}</option>
{OPENAI_MODEL_PRESETS.map((m) => (
@@ -836,7 +839,7 @@ function OpenAIProviderForm({
<option value={CUSTOM_MODEL_SENTINEL}>
{t("settings.ai.openai.modelOptionCustom")}
</option>
</select>
</NativeSelect>
</div>
{modelChoice === CUSTOM_MODEL_SENTINEL && (
@@ -889,7 +892,7 @@ function OpenAIProviderForm({
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
@@ -1005,11 +1008,11 @@ function AnthropicProviderForm({
<Label htmlFor="ai-anthropic-model">
{t("settings.ai.modelLabel")}
</Label>
<select
<NativeSelect
id="ai-anthropic-model"
value={modelChoice}
onChange={(e) => setModelChoice(e.target.value)}
className="bg-background border-input mt-1 h-9 w-full rounded-md border px-2 text-sm"
className="mt-1"
>
<option value="">{t("settings.ai.modelOptionDefault")}</option>
{ANTHROPIC_MODEL_PRESETS.map((m) => (
@@ -1020,7 +1023,7 @@ function AnthropicProviderForm({
<option value={CUSTOM_MODEL_SENTINEL}>
{t("settings.ai.modelOptionCustom")}
</option>
</select>
</NativeSelect>
</div>
{modelChoice === CUSTOM_MODEL_SENTINEL && (
@@ -1045,7 +1048,7 @@ function AnthropicProviderForm({
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
@@ -1163,11 +1166,11 @@ function LocalProviderForm({
</div>
<div>
<Label htmlFor="ai-local-model">{t("settings.ai.modelLabel")}</Label>
<select
<NativeSelect
id="ai-local-model"
value={modelChoice}
onChange={(e) => setModelChoice(e.target.value)}
className="bg-background border-input mt-1 h-9 w-full rounded-md border px-2 text-sm"
className="mt-1"
>
<option value="">{t("settings.ai.modelOptionDefault")}</option>
{LOCAL_MODEL_PRESETS.map((m) => (
@@ -1178,7 +1181,7 @@ function LocalProviderForm({
<option value={CUSTOM_MODEL_SENTINEL}>
{t("settings.ai.modelOptionCustom")}
</option>
</select>
</NativeSelect>
</div>
{modelChoice === CUSTOM_MODEL_SENTINEL && (
<div>
@@ -1202,7 +1205,7 @@ function LocalProviderForm({
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
@@ -1454,7 +1457,7 @@ function FallbackChainCard({
data-testid="ai-fallback-chain-save"
>
{saveMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
@@ -1507,18 +1510,18 @@ function AddProviderControl({
return (
<div className="flex items-center gap-2">
<select
<NativeSelect
aria-label={t("settings.ai.providerChain.addProvider")}
value={picked}
onChange={(e) => setPicked(e.target.value)}
className="bg-background border-input h-9 rounded-md border px-2 text-sm"
className="w-auto"
>
{addable.map((p) => (
<option key={p} value={p}>
{t(`settings.ai.providerChain.types.${p}`)}
</option>
))}
</select>
</NativeSelect>
<Button
type="button"
size="sm"
@@ -1659,7 +1662,7 @@ function RuntimeActionsRow({
data-testid="ai-test-active-provider"
>
{testing ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
@@ -1673,7 +1676,7 @@ function RuntimeActionsRow({
disabled={regen}
>
{regen ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
)}
+242 -53
View File
@@ -58,6 +58,21 @@ export function ApiSection() {
function ApiEndpointsCard() {
const { t } = useTranslations();
// v1.4.27 MB5 — dual-render: <md gets a stacked card list, md+
// keeps the original table. Mobile cards wrap inside their box and
// never trigger horizontal scroll, mirroring the pattern from
// `<ApiTokenOverviewSection>`. The endpoint catalogue currently
// ships a single row but stays array-driven so adding new rows
// remains a one-line edit on both surfaces.
const endpoints = [
{
method: "POST",
path: "/api/ingest/medication",
auth: "Authorization: Bearer hlk_...",
example: `{ "medicationName": "...", "scheduledFor": "..." }`,
},
];
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex items-center gap-2">
@@ -70,7 +85,8 @@ function ApiEndpointsCard() {
{t("settings.apiEndpointsDescription")}
</p>
<div className="border-border mt-4 overflow-x-auto rounded-lg border">
{/* Desktop table — verbatim layout for md+. */}
<div className="border-border mt-4 hidden overflow-x-auto rounded-lg border md:block">
<table className="w-full min-w-[760px] text-xs md:min-w-0">
<thead>
<tr className="bg-muted/40 text-muted-foreground border-b">
@@ -89,19 +105,52 @@ function ApiEndpointsCard() {
</tr>
</thead>
<tbody>
<tr>
<td className="px-3 py-2 font-medium">POST</td>
<td className="px-3 py-2 font-mono">/api/ingest/medication</td>
<td className="px-3 py-2 font-mono">
Authorization: Bearer hlk_...
</td>
<td className="px-3 py-2 font-mono">
{`{ "medicationName": "...", "scheduledFor": "..." }`}
</td>
</tr>
{endpoints.map((endpoint) => (
<tr key={`${endpoint.method} ${endpoint.path}`}>
<td className="px-3 py-2 font-medium">{endpoint.method}</td>
<td className="px-3 py-2 font-mono">{endpoint.path}</td>
<td className="px-3 py-2 font-mono">{endpoint.auth}</td>
<td className="px-3 py-2 font-mono">{endpoint.example}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile card list — each endpoint stacks its columns vertically
with a labelled meta row. No horizontal scroll. */}
<ul
className="mt-4 space-y-2 md:hidden"
data-testid="settings-api-endpoints-mobile-list"
>
{endpoints.map((endpoint) => (
<li
key={`${endpoint.method} ${endpoint.path}`}
className="bg-muted/30 border-border space-y-1.5 rounded-lg border p-3 text-xs"
>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="text-[10px]">
{endpoint.method}
</Badge>
<code className="font-mono break-all">{endpoint.path}</code>
</div>
<div>
<p className="text-muted-foreground text-[10px] uppercase tracking-wide">
{t("settings.apiEndpointAuth")}
</p>
<code className="font-mono break-all">{endpoint.auth}</code>
</div>
<div>
<p className="text-muted-foreground text-[10px] uppercase tracking-wide">
{t("settings.apiEndpointExample")}
</p>
<code className="block font-mono break-all">
{endpoint.example}
</code>
</div>
</li>
))}
</ul>
</div>
);
}
@@ -210,7 +259,7 @@ function ApiTokensCard() {
size="sm"
disabled={creating || !newName.trim()}
>
{creating && <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />}
{creating && <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />}
{t("common.create")}
</Button>
</form>
@@ -236,7 +285,8 @@ function ApiTokensCard() {
<p className="mb-2 text-sm font-medium">
{t("settings.activeTokensTitle")}
</p>
<div className="border-border overflow-x-auto rounded-lg border">
{/* Desktop table — verbatim layout for md+, hidden on phones. */}
<div className="border-border hidden overflow-x-auto rounded-lg border md:block">
<table className="w-full min-w-[860px] text-sm md:min-w-0">
<thead>
<tr className="bg-muted/40 text-muted-foreground border-b text-xs">
@@ -342,6 +392,103 @@ function ApiTokensCard() {
</tbody>
</table>
</div>
{/* Mobile card list — each row stacks its columns vertically
with explicit labels so the data is readable inside a
narrow viewport. The revoke action stays full-width to
clear the 44pt tap-target floor. */}
{activeTokens.length === 0 ? (
<p
className="text-muted-foreground rounded-lg border border-dashed px-3 py-4 text-center text-sm md:hidden"
data-testid="settings-api-tokens-active-empty"
>
{t("settings.noActiveTokens")}
</p>
) : (
<ul
className="space-y-2 md:hidden"
data-testid="settings-api-tokens-mobile-list"
>
{activeTokens.map((tok) => {
const isExpired =
tok.expiresAt && new Date(tok.expiresAt) < new Date();
return (
<li
key={tok.id}
className="bg-muted/30 border-border space-y-2 rounded-lg border p-3"
>
<div className="flex flex-wrap items-start justify-between gap-2">
<p className="min-w-0 flex-1 text-sm font-medium break-all">
{tok.name}
</p>
{isExpired ? (
<Badge variant="destructive" className="text-[10px]">
{t("settings.tokenExpired")}
</Badge>
) : (
<Badge className="bg-dracula-green/15 text-dracula-green text-[10px]">
{t("settings.tokenActive")}
</Badge>
)}
</div>
<p className="text-muted-foreground text-[11px]">
<span className="font-medium">
{t("settings.tokenTablePermissions")}:
</span>{" "}
{tok.permissions.join(", ")}
</p>
<p className="text-muted-foreground text-[11px]">
<span className="font-medium">
{t("settings.tokenTableCreated")}:
</span>{" "}
{formatDate(tok.createdAt)}
</p>
<p className="text-muted-foreground text-[11px]">
<span className="font-medium">
{t("settings.tokenTableLastUsed")}:
</span>{" "}
{tok.lastUsedAt
? formatDateTime(tok.lastUsedAt)
: t("settings.tokenNeverUsed")}
</p>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="text-destructive border-destructive/30 min-h-11 w-full"
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
{t("settings.tokenRevoke")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.tokenRevoke")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.tokenRevokeDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => handleRevoke(tok.id)}
>
{t("settings.tokenRevoked")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</li>
);
})}
</ul>
)}
</div>
{revokedTokens.length > 0 && (
@@ -359,47 +506,89 @@ function ApiTokensCard() {
})}
</button>
{showRevokedTokens && (
<div className="border-border overflow-x-auto rounded-lg border">
<table className="w-full min-w-[760px] text-sm md:min-w-0">
<thead>
<tr className="bg-muted/40 text-muted-foreground border-b text-xs">
<th className="px-3 py-2 text-left font-medium">
{t("settings.tokenTableName")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.tokenTablePermissions")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.tokenTableCreated")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.tokenTableLastUsed")}
</th>
</tr>
</thead>
<tbody className="divide-border divide-y">
{revokedTokens.map((tok, index) => (
<tr
key={tok.id}
className={index % 2 === 0 ? "bg-muted/20" : ""}
>
<td className="px-3 py-2 font-medium">{tok.name}</td>
<td className="text-muted-foreground px-3 py-2 text-xs">
{tok.permissions.join(", ")}
</td>
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
{formatDate(tok.createdAt)}
</td>
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
{tok.lastUsedAt
? formatDateTime(tok.lastUsedAt)
: t("settings.tokenNeverUsed")}
</td>
<>
{/* Desktop table — verbatim layout for md+. */}
<div className="border-border hidden overflow-x-auto rounded-lg border md:block">
<table className="w-full min-w-[760px] text-sm md:min-w-0">
<thead>
<tr className="bg-muted/40 text-muted-foreground border-b text-xs">
<th className="px-3 py-2 text-left font-medium">
{t("settings.tokenTableName")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.tokenTablePermissions")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.tokenTableCreated")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.tokenTableLastUsed")}
</th>
</tr>
))}
</tbody>
</table>
</div>
</thead>
<tbody className="divide-border divide-y">
{revokedTokens.map((tok, index) => (
<tr
key={tok.id}
className={index % 2 === 0 ? "bg-muted/20" : ""}
>
<td className="px-3 py-2 font-medium">{tok.name}</td>
<td className="text-muted-foreground px-3 py-2 text-xs">
{tok.permissions.join(", ")}
</td>
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
{formatDate(tok.createdAt)}
</td>
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
{tok.lastUsedAt
? formatDateTime(tok.lastUsedAt)
: t("settings.tokenNeverUsed")}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile card list — stacked meta layout, no
horizontal scroll. Revoked tokens are read-only so
no action footer is needed. */}
<ul
className="space-y-2 md:hidden"
data-testid="settings-api-tokens-revoked-mobile-list"
>
{revokedTokens.map((tok) => (
<li
key={tok.id}
className="bg-muted/30 border-border space-y-1.5 rounded-lg border p-3"
>
<p className="text-sm font-medium break-all">
{tok.name}
</p>
<p className="text-muted-foreground text-[11px]">
<span className="font-medium">
{t("settings.tokenTablePermissions")}:
</span>{" "}
{tok.permissions.join(", ")}
</p>
<p className="text-muted-foreground text-[11px]">
<span className="font-medium">
{t("settings.tokenTableCreated")}:
</span>{" "}
{formatDate(tok.createdAt)}
</p>
<p className="text-muted-foreground text-[11px]">
<span className="font-medium">
{t("settings.tokenTableLastUsed")}:
</span>{" "}
{tok.lastUsedAt
? formatDateTime(tok.lastUsedAt)
: t("settings.tokenNeverUsed")}
</p>
</li>
))}
</ul>
</>
)}
</div>
)}
@@ -44,10 +44,6 @@ const WIDGET_LABEL_KEYS: Record<DashboardWidgetId, string> = {
bpInTarget: "dashboard.bpInTarget",
oxygenSaturation: "measurements.typeOxygenSaturation",
achievements: "achievements.title",
// v1.4.16 phase D reconcile (CRITICAL C2) — dashboard preview tile
// for the polished `<InsightsCardPreview>` (top severity-ordered AI
// recommendations + ring confidence meter + "View all" CTA).
insightsPreview: "dashboard.insightsPreview",
// v1.4.25 W8d — VO2 max secondary-metric tile (opt-in).
vo2Max: "dashboard.vo2Max",
};
@@ -245,7 +241,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
{isLoading || !layout ? (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2 className="h-4 w-4 animate-spin" />
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
{t("common.loading")}
</div>
) : (
@@ -257,7 +253,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
of the two surfaces (per feedback_dashboard_top_tiles
_selectable.md). */}
<div className="text-muted-foreground flex items-center gap-3 px-3 pb-1 text-[10px] font-medium tracking-wide uppercase">
<span className="w-5" aria-hidden="true" />
<span className="w-11" aria-hidden="true" />
<span className="flex-1" />
<span className="w-12 text-center">
{t("dashboard.layoutTileColumn")}
@@ -284,25 +280,25 @@ export function DashboardLayoutSection({ id }: { id: string }) {
type="button"
variant="ghost"
size="icon"
className="h-5 w-5"
className="size-11"
onClick={() => move(widget.id, -1)}
disabled={index === 0 || saveMutation.isPending}
aria-label={t("dashboard.moveUp")}
>
<ArrowUp className="h-3 w-3" />
<ArrowUp className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-5 w-5"
className="size-11"
onClick={() => move(widget.id, 1)}
disabled={
index === arr.length - 1 || saveMutation.isPending
}
aria-label={t("dashboard.moveDown")}
>
<ArrowDown className="h-3 w-3" />
<ArrowDown className="h-4 w-4" />
</Button>
</div>
<span className="flex-1 text-sm">{t(labelKey)}</span>
@@ -346,7 +342,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
disabled={saveMutation.isPending}
>
{saveMutation.isPending && (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>
+4 -4
View File
@@ -224,7 +224,7 @@ function DoctorReportCard() {
onClick={() => setOpen(true)}
disabled={busy}
>
{busy && <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />}
{busy && <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />}
{t("settings.sections.export.cards.doctorReport.action")}
</Button>
}
@@ -358,7 +358,7 @@ function CsvCard({
disabled={busy}
>
{busy ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Download className="mr-1 h-3.5 w-3.5" />
)}
@@ -445,7 +445,7 @@ function MedicationsCsvCard() {
disabled={busy}
>
{busy ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Download className="mr-1 h-3.5 w-3.5" />
)}
@@ -534,7 +534,7 @@ function FullBackupCard() {
disabled={busy}
>
{busy ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<FileText className="mr-1 h-3.5 w-3.5" />
)}
@@ -27,7 +27,7 @@ import {
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/settings/password-input";
import { PasswordInput } from "@/components/ui/password-input";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
import type { IntegrationPillState } from "@/components/settings/integration-status-pill";
@@ -385,11 +385,20 @@ function WithingsCard({
</a>
)}
<div className="space-y-3">
<h3 className="text-sm font-medium">
<h3 className="text-sm font-semibold">
{t("settings.withingsCredentials")}
</h3>
<form onSubmit={handleSaveCredentials} className="space-y-3">
<div className="grid gap-3 sm:grid-cols-3">
{/* v1.4.27 MB7 / CF-53 — the credentials grid drops from
a 3-column row (client-id / secret / save) to a
2-column row of inputs at `sm:`, with the Save button
lifted out into its own row below. The previous
"invisible Label" hack to align the button with the
input baselines fell apart on Galaxy Fold; lifting
the button into a dedicated `flex justify-end` row
gives it consistent placement on every viewport and
lets the input pair span the full width. */}
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="w-clientid">
{t("settings.withingsClientId")}
@@ -404,6 +413,11 @@ function WithingsCard({
: t("settings.withingsClientId")
}
maxLength={200}
autoComplete="off"
inputMode="text"
spellCheck={false}
autoCapitalize="none"
enterKeyHint="next"
/>
</div>
<div className="space-y-1.5">
@@ -420,27 +434,31 @@ function WithingsCard({
: t("settings.withingsClientSecret")
}
maxLength={200}
autoComplete="off"
inputMode="text"
spellCheck={false}
autoCapitalize="none"
enterKeyHint="done"
/>
</div>
<div className="space-y-1.5">
<Label className="invisible">{t("common.save")}</Label>
<Button
type="submit"
variant="outline"
size="sm"
className="h-9 w-full"
disabled={
credsSaving || !clientId.trim() || !clientSecret.trim()
}
>
{credsSaving ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<Save className="mr-1 h-3.5 w-3.5" />
)}
{t("settings.withingsSaveCredentials")}
</Button>
</div>
</div>
<div className="flex justify-end">
<Button
type="submit"
variant="outline"
size="sm"
className="w-full sm:w-auto"
disabled={
credsSaving || !clientId.trim() || !clientSecret.trim()
}
>
{credsSaving ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Save className="mr-1 h-3.5 w-3.5" />
)}
{t("settings.withingsSaveCredentials")}
</Button>
</div>
{credsMsg && (
<p
@@ -455,7 +473,13 @@ function WithingsCard({
{status?.connected ? (
<>
<div className="flex flex-wrap items-start gap-2">
{/* v1.4.27 MB7 / CF-57 — the action row already wraps via
`flex-wrap`, but on Pixel 5 the four AlertDialog +
test-connection triggers each took a fractional slot
that read jagged. Force each button to a `min-w-[10rem]`
on `<sm` so they stack two per row at most and fill
their column cleanly. Tablet+ keeps the inline row. */}
<div className="flex flex-wrap items-start gap-2 [&>*]:min-w-[10rem] sm:[&>*]:min-w-0">
<Button
variant="outline"
size="sm"
@@ -463,7 +487,7 @@ function WithingsCard({
disabled={syncing}
>
{syncing ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RefreshCw className="mr-1 h-3.5 w-3.5" />
)}
@@ -473,7 +497,7 @@ function WithingsCard({
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" disabled={syncing}>
{syncing ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RefreshCw className="mr-1 h-3.5 w-3.5" />
)}
@@ -703,6 +727,10 @@ function MoodLogCard({
placeholder={t("settings.moodLogUrlPlaceholder")}
value={url}
onChange={(e) => setUrl(e.target.value)}
autoComplete="off"
spellCheck={false}
autoCapitalize="none"
enterKeyHint="next"
/>
</div>
<div>
@@ -718,6 +746,11 @@ function MoodLogCard({
}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
autoComplete="off"
inputMode="text"
spellCheck={false}
autoCapitalize="none"
enterKeyHint="done"
/>
</div>
<div className="flex flex-wrap items-start gap-2">
@@ -726,7 +759,7 @@ function MoodLogCard({
disabled={saving || (!url.trim() && !apiKey.trim())}
size="sm"
>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />}
<Save className="mr-2 h-4 w-4" />
{t("common.save")}
</Button>
@@ -792,7 +825,7 @@ function MoodLogCard({
disabled={syncing}
onClick={() => handleSync(false)}
>
{syncing && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{syncing && <Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />}
<RefreshCw className="mr-2 h-4 w-4" />
{t("settings.moodLogSync")}
</Button>
@@ -112,7 +112,7 @@ export function NotificationStatusCard() {
if (isLoading) {
return (
<div className="bg-card border-border rounded-xl border p-6">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
</div>
);
}
@@ -279,7 +279,7 @@ function ChannelRow({
className="min-h-11"
>
{reEnablePending ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RotateCw className="mr-1 h-3.5 w-3.5" />
)}
@@ -296,7 +296,7 @@ function ChannelRow({
className="min-h-11"
>
{testPending ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Send className="mr-1 h-3.5 w-3.5" />
)}
@@ -54,18 +54,23 @@ export function NotificationsSection() {
<NotificationStatusCard />
{/* v1.4.27 MB3 — `scroll-mt-28` keeps each notification card's
anchor target below the safe-area sticky page header when the
user lands on `/settings/notifications#telegram` or similar.
Without the offset the section title pins under the header on
mobile and the user has to scroll back up to find it. */}
{showTelegram && (
<div id="telegram">
<div id="telegram" className="scroll-mt-28">
<TelegramCard isAuthenticated={isAuthenticated} />
</div>
)}
{showNtfy && (
<div id="ntfy">
<div id="ntfy" className="scroll-mt-28">
<NtfyCard isAuthenticated={isAuthenticated} />
</div>
)}
{showWebPush && (
<div id="web-push">
<div id="web-push" className="scroll-mt-28">
<WebPushCard />
</div>
)}
+3 -3
View File
@@ -9,7 +9,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { PasswordInput } from "@/components/settings/password-input";
import { PasswordInput } from "@/components/ui/password-input";
import { useTranslations } from "@/lib/i18n/context";
interface NtfySettings {
@@ -195,7 +195,7 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
onClick={() => test.mutate()}
>
{test.isPending ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Send className="mr-1 h-3.5 w-3.5" />
)}
@@ -203,7 +203,7 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
</Button>
<Button type="submit" disabled={save.isPending}>
{save.isPending && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>
@@ -1,36 +0,0 @@
"use client";
import { useState } from "react";
import { Eye, EyeOff } from "lucide-react";
import { Input } from "@/components/ui/input";
import { useTranslations } from "@/lib/i18n/context";
/**
* Password input with a built-in show/hide toggle. Extracted from the legacy
* settings monolith so every section that needs a masked secret (Withings,
* moodLog, ntfy, Telegram, AI provider keys, account password) can share the
* same UX without duplicating the visibility-toggle plumbing.
*/
export function PasswordInput(props: React.ComponentProps<typeof Input>) {
const [visible, setVisible] = useState(false);
const { t } = useTranslations();
return (
<div className="relative">
<Input {...props} type={visible ? "text" : "password"} />
<button
type="button"
tabIndex={-1}
// WCAG 2.1.2 button-name — the icon-only toggle has no visible text
// and screen readers fall back to the icon's filename otherwise.
aria-label={
visible ? t("common.hidePassword") : t("common.showPassword")
}
onClick={() => setVisible((v) => !v)}
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-3 -translate-y-1/2"
>
{visible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
);
}
+6 -2
View File
@@ -214,8 +214,12 @@ export function SettingsShell({ active, children }: SettingsShellProps) {
</div>
</aside>
{/* Main column — page renders its own h1 + subtitle */}
<main className="min-w-0">{children}</main>
{/* Main column — page renders its own h1 + subtitle. The
`min-h-[calc(100dvh-12rem)]` reserve keeps the column tall
enough that swapping a short loading state for a long
section list (Thresholds, Sources) does not jump the page
height under the sticky sidebar. */}
<main className="min-h-[calc(100dvh-12rem)] min-w-0">{children}</main>
</div>
</div>
);
+93 -54
View File
@@ -40,6 +40,7 @@ import {
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import {
@@ -314,10 +315,7 @@ export function SourcesSection() {
</p>
{isLoading || !priority ? (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2 className="h-4 w-4 animate-spin" />
{t("common.loading")}
</div>
<SourcesSkeletonList />
) : (
<div className="space-y-3">
{SOURCE_PRIORITY_METRIC_KEYS.map((metric) => {
@@ -346,30 +344,37 @@ export function SourcesSection() {
<span className="flex-1 text-sm">
{t(SOURCE_LABEL_KEYS[source] ?? source)}
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="h-11 w-11"
onClick={() => moveSource(metric, index, -1)}
disabled={index === 0 || saveMutation.isPending}
aria-label={t("settings.sections.sources.moveUp")}
>
<ArrowUp className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-11 w-11"
onClick={() => moveSource(metric, index, 1)}
disabled={
index === list.length - 1 || saveMutation.isPending
}
aria-label={t("settings.sections.sources.moveDown")}
>
<ArrowDown className="h-3.5 w-3.5" />
</Button>
{/* v1.4.27 R3d MB2 — stack up/down vertically on
narrow viewports so each button keeps the
44 px floor without crowding the row. From
`sm:` up the two buttons sit side-by-side
again to preserve the desktop layout. */}
<div className="flex flex-col gap-1 sm:flex-row sm:gap-2">
<Button
type="button"
variant="ghost"
size="icon"
className="h-11 w-11"
onClick={() => moveSource(metric, index, -1)}
disabled={index === 0 || saveMutation.isPending}
aria-label={t("settings.sections.sources.moveUp")}
>
<ArrowUp className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-11 w-11"
onClick={() => moveSource(metric, index, 1)}
disabled={
index === list.length - 1 || saveMutation.isPending
}
aria-label={t("settings.sections.sources.moveDown")}
>
<ArrowDown className="h-4 w-4" />
</Button>
</div>
</li>
))}
</ul>
@@ -504,32 +509,36 @@ export function SourcesSection() {
<span className="flex-1 text-sm">
{t(DEVICE_TYPE_LABEL_KEYS[deviceType])}
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="h-11 w-11"
onClick={() =>
moveDeviceType(null, index, -1)
}
disabled={index === 0 || saveMutation.isPending}
aria-label={t("settings.sections.sources.moveUp")}
>
<ArrowUp className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-11 w-11"
onClick={() => moveDeviceType(null, index, 1)}
disabled={
index === list.length - 1 || saveMutation.isPending
}
aria-label={t("settings.sections.sources.moveDown")}
>
<ArrowDown className="h-3.5 w-3.5" />
</Button>
{/* v1.4.27 R3d MB2 — same stacked-on-mobile shape
as the metric source rows above. */}
<div className="flex flex-col gap-1 sm:flex-row sm:gap-2">
<Button
type="button"
variant="ghost"
size="icon"
className="h-11 w-11"
onClick={() =>
moveDeviceType(null, index, -1)
}
disabled={index === 0 || saveMutation.isPending}
aria-label={t("settings.sections.sources.moveUp")}
>
<ArrowUp className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-11 w-11"
onClick={() => moveDeviceType(null, index, 1)}
disabled={
index === list.length - 1 || saveMutation.isPending
}
aria-label={t("settings.sections.sources.moveDown")}
>
<ArrowDown className="h-4 w-4" />
</Button>
</div>
</li>
))}
</ul>
@@ -564,3 +573,33 @@ export function SourcesSection() {
</section>
);
}
/**
* Skeleton placeholder rendered while `/api/auth/me/source-priority`
* is in flight. Reserves one row per `SOURCE_PRIORITY_METRIC_KEYS`
* entry at roughly the loaded height so the page does not jump when
* the fetched ladder list swaps in. The pulsing animation honours
* `prefers-reduced-motion` via Tailwind's `motion-reduce:animate-none`.
*/
function SourcesSkeletonList() {
return (
<div
className="space-y-3"
data-testid="sources-skeleton"
aria-hidden="true"
>
{SOURCE_PRIORITY_METRIC_KEYS.map((metric) => (
<div
key={metric}
className="border-border bg-background/30 space-y-2 rounded-md border p-3"
>
<Skeleton className="h-4 w-40" />
<div className="space-y-1">
<Skeleton className="h-9 w-full rounded-md" />
<Skeleton className="h-9 w-full rounded-md" />
</div>
</div>
))}
</div>
);
}
+13 -3
View File
@@ -9,7 +9,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { PasswordInput } from "@/components/settings/password-input";
import { PasswordInput } from "@/components/ui/password-input";
import { useTranslations } from "@/lib/i18n/context";
interface TelegramSettings {
@@ -148,6 +148,11 @@ export function TelegramCard({
: "123456:ABC-DEF..."
}
maxLength={100}
autoComplete="off"
inputMode="text"
spellCheck={false}
autoCapitalize="none"
enterKeyHint="next"
/>
</div>
<div className="space-y-2">
@@ -158,6 +163,11 @@ export function TelegramCard({
onChange={(e) => setChatId(e.target.value)}
placeholder="123456789"
maxLength={50}
autoComplete="off"
inputMode="text"
spellCheck={false}
autoCapitalize="none"
enterKeyHint="done"
/>
</div>
</div>
@@ -200,7 +210,7 @@ export function TelegramCard({
onClick={handleTest}
>
{testing ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Send className="mr-2 h-4 w-4" />
)}
@@ -208,7 +218,7 @@ export function TelegramCard({
</Button>
<Button type="submit" disabled={saving}>
{saving ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
@@ -87,7 +87,7 @@ export function TestConnectionButton({
disabled={disabled || testing}
>
{testing ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Plug className="mr-1 h-3.5 w-3.5" />
)}
@@ -3,15 +3,11 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
SlidersHorizontal,
RotateCcw,
Loader2,
AlertTriangle,
} from "lucide-react";
import { SlidersHorizontal, RotateCcw, AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
@@ -121,7 +117,7 @@ export function ThresholdsEditorSection({ id }: { id: string }) {
return (
<div
id={id}
className="bg-card border-border scroll-mt-28 space-y-5 rounded-xl border p-6"
className="bg-card border-border scroll-mt-28 space-y-4 rounded-xl border p-6"
>
{/* v1.4.19 A8 / F-07: page header `settings.sections.thresholds.*`
already provides the title + description for this route, so the
@@ -147,10 +143,7 @@ export function ThresholdsEditorSection({ id }: { id: string }) {
</div>
{isLoading || !data ? (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2 className="h-4 w-4 animate-spin" />
{t("common.loading")}
</div>
<ThresholdsSkeletonList />
) : (
<div className="space-y-3">
{METRIC_ORDER.map((metric) => (
@@ -170,6 +163,41 @@ export function ThresholdsEditorSection({ id }: { id: string }) {
);
}
/**
* Skeleton placeholder rendered while `/api/user/thresholds` is in
* flight. Reserves one row per `METRIC_ORDER` entry at roughly the
* loaded height so the page does not jump when the fetched list
* swaps in. The pulsing animation honours `prefers-reduced-motion`
* via Tailwind's `motion-reduce:animate-none`.
*/
function ThresholdsSkeletonList() {
return (
<div
className="space-y-3"
data-testid="thresholds-skeleton"
aria-hidden="true"
>
{METRIC_ORDER.map((metric) => (
<div
key={metric}
className="border-border space-y-3 rounded-lg border p-4"
>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-48" />
</div>
<div className="flex shrink-0 items-center gap-2">
<Skeleton className="h-3 w-12" />
<Skeleton className="h-5 w-9 rounded-full" />
</div>
</div>
</div>
))}
</div>
);
}
interface MetricRowProps {
metric: ThresholdMetric;
effective: EffectiveRange | undefined;
@@ -254,6 +282,8 @@ function MetricRow({
<Input
id={`min-${metric}`}
type="number"
inputMode={metric === "ACTIVITY_STEPS" ? "numeric" : "decimal"}
enterKeyHint="next"
step={metric === "ACTIVITY_STEPS" ? 100 : 0.1}
min={bounds.min}
max={bounds.max}
@@ -270,6 +300,8 @@ function MetricRow({
<Input
id={`max-${metric}`}
type="number"
inputMode={metric === "ACTIVITY_STEPS" ? "numeric" : "decimal"}
enterKeyHint="done"
step={metric === "ACTIVITY_STEPS" ? 100 : 0.1}
min={bounds.min}
max={bounds.max}
+9 -6
View File
@@ -21,14 +21,17 @@ import { Compass } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import {
detectBrowserTimezone,
listSupportedTimezones,
} from "@/lib/tz/format";
import { useTranslations } from "@/lib/i18n/context";
const NATIVE_SELECT_CLASS =
"border-input bg-background text-foreground ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none";
// v1.4.27 MB7 / CF-52 — the in-file `NATIVE_SELECT_CLASS` constant
// retired; the shared `<NativeSelect>` primitive owns the visual
// contract now. The `sm:max-w-sm` modifier still composes via the
// `className` prop below so the picker keeps its narrow desktop width.
export interface TimezonePickerProps {
/** The user's current stored timezone. */
@@ -88,12 +91,12 @@ export function TimezonePicker({
return (
<div className="space-y-2">
<Label htmlFor={id}>{t("settings.timezone")}</Label>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<select
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<NativeSelect
id={id}
value={value}
onChange={(e) => onChange(e.target.value)}
className={`${NATIVE_SELECT_CLASS} sm:max-w-sm`}
className="sm:max-w-sm"
>
{/* Preserve the stored value even if the runtime's IANA
list changed across engines or tzdata rolls. */}
@@ -105,7 +108,7 @@ export function TimezonePicker({
{zone}
</option>
))}
</select>
</NativeSelect>
<Button
type="button"
variant="outline"
+3 -3
View File
@@ -182,7 +182,7 @@ export function WebPushCard() {
<div className="mt-4 space-y-3">
{loading ? (
<div className="flex items-center gap-2">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
</div>
) : !isSupported ? (
<p className="text-muted-foreground text-sm">
@@ -202,7 +202,7 @@ export function WebPushCard() {
disabled={actionLoading}
>
{actionLoading && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.webPushUnsubscribe")}
</Button>
@@ -214,7 +214,7 @@ export function WebPushCard() {
disabled={actionLoading}
>
{actionLoading ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<BellRing className="mr-1 h-3.5 w-3.5" />
)}