mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
refactor(settings): one header, one action row, one integration card
The sweep that follows from the primitives. Nothing here is new behaviour; every change removes a second way of saying something the surface already said one way somewhere else. Headers. The two collapsible security cards stop rebuilding `SettingsCardHeader` by hand to carry a chevron — the chevron rides inside the title button, and the description moves out of the collapsed region, so the first paint is no longer a bare title. The import card shell, the export and import section headings, and the four free-standing group headers stop inventing their own type scale: a heading above a GROUP of cards is `SectionHeading` (`text-base`), a card title is `SettingsCardHeader` (`text-lg`), and the step between them is what says which is which. The targets card gets a title instead of a naked icon. Actions. Every card-level row is `SettingsCardActions`. The primary action comes out of the header's status slot in six cards (password, web push, pillar order, coach memory, and the two danger-zone triggers) and out of the middle of the card in three more; the reset that hung in the dashboard-layout header joins save as a secondary in the same row. Result and status lines move ABOVE the row — sources' cross-link, the invite form's two status lines — because nothing follows a card's actions. Revoking a share link is `variant="destructive"` rather than an `outline` hand-tinted red in two different ways. The import row had four variants for four buttons; it has two. Export downloads are the card's primary, not an outline link. Integrations. One description sentence with the setup-guide link inline: the second paragraph was byte-identical across six of eight cards, which is the tell that it said nothing about the provider it sat under. The `<hr>` under the header — which existed only in this folder — is gone. So is the hint that pointed at a guide 40 px above it, and the second format for "not configured". Connect is one button everywhere: primary, `sm`, in the action row, instead of primary-and-small in three cards and outline-and-large in four. Plus the standards leftovers the audit listed: two `pl-5` list gutters, one `scroll-mt-20` anchor, a purple header icon, `text-[10px]` badges, four hand-rolled callout shells that `SettingsInfoTile` already paints, and the two inline buttons that never had the mobile tap floor at all.
This commit is contained in:
1 parent
ce9b2814e6
commit
b2e1aa0cde
63 files changed
+720
-760
No files matched your search
@@ -146,12 +146,12 @@ describe("<AppleHealthCard>", () => {
|
||||
expect(html).toContain("quiet");
|
||||
});
|
||||
|
||||
it("carries the shared card divider like every sibling", () => {
|
||||
it("carries no card divider — the card gap does that job", () => {
|
||||
statusPayload = {
|
||||
lastSyncedAt: null,
|
||||
syncHealth: { verdict: "pending_first_sync", since: null },
|
||||
};
|
||||
expect(render()).toContain('data-testid="integration-card-divider"');
|
||||
expect(render()).not.toContain('data-testid="integration-card-divider"');
|
||||
});
|
||||
|
||||
it("links to the existing one-shot Apple Health import fallback", () => {
|
||||
|
||||
@@ -80,13 +80,13 @@ describe("NightscoutCard — parked + test + data-link + invalidation", () => {
|
||||
expect(html).toContain('href="/insights/blood-glucose"');
|
||||
});
|
||||
|
||||
it("carries the shared card divider like every sibling", () => {
|
||||
it("carries no card divider — the card gap does that job", () => {
|
||||
const html = render({
|
||||
connected: true,
|
||||
configured: true,
|
||||
syncHealth: { verdict: "fresh", since: null },
|
||||
});
|
||||
expect(html).toContain('data-testid="integration-card-divider"');
|
||||
expect(html).not.toContain('data-testid="integration-card-divider"');
|
||||
});
|
||||
|
||||
it("renders the per-metric freshness disclosure with a stale row", () => {
|
||||
|
||||
@@ -160,8 +160,6 @@ export function AppleHealthCard({ enabled }: { enabled: boolean }) {
|
||||
}
|
||||
/>
|
||||
|
||||
<hr data-testid="integration-card-divider" className="border-border/60" />
|
||||
|
||||
<div className="space-y-4">
|
||||
{isError && (
|
||||
<IntegrationErrorMessage
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -243,8 +244,6 @@ export function FitbitCard({
|
||||
}
|
||||
/>
|
||||
|
||||
<hr data-testid="integration-card-divider" className="border-border/60" />
|
||||
|
||||
<div className="space-y-4">
|
||||
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
|
||||
{/* Parked-integration resume CTA. Surfaces only when the row
|
||||
@@ -360,10 +359,7 @@ export function FitbitCard({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.integrationCredentialsHint")}
|
||||
</p>
|
||||
<div className="flex justify-end">
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
@@ -380,7 +376,7 @@ export function FitbitCard({
|
||||
)}
|
||||
{t("settings.fitbitSaveCredentials")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCardActions>
|
||||
{credsMsg && (
|
||||
<p
|
||||
role="alert"
|
||||
@@ -503,19 +499,26 @@ export function FitbitCard({
|
||||
</Link>
|
||||
</>
|
||||
) : status?.configured ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
window.location.href = "/api/fitbit/connect";
|
||||
}}
|
||||
>
|
||||
<Link2 className="h-4 w-4" />
|
||||
{t("settings.fitbitConnect")}
|
||||
</Button>
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
size="sm"
|
||||
className="min-h-11 w-full sm:w-auto"
|
||||
data-testid="fitbit-connect"
|
||||
onClick={() => {
|
||||
window.location.href = "/api/fitbit/connect";
|
||||
}}
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
{t("settings.fitbitConnect")}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
) : (
|
||||
<div className="bg-muted/50 text-muted-foreground rounded-lg p-3 text-sm">
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-testid="integration-unavailable"
|
||||
>
|
||||
{t("settings.fitbitNoCredentials")}
|
||||
</div>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -415,8 +416,6 @@ export function GoogleHealthCard({
|
||||
}
|
||||
/>
|
||||
|
||||
<hr data-testid="integration-card-divider" className="border-border/60" />
|
||||
|
||||
<div className="space-y-4">
|
||||
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
|
||||
|
||||
@@ -553,10 +552,7 @@ export function GoogleHealthCard({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.integrationCredentialsHint")}
|
||||
</p>
|
||||
<div className="flex justify-end">
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
@@ -573,7 +569,7 @@ export function GoogleHealthCard({
|
||||
)}
|
||||
{t("settings.googleHealthSaveCredentials")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCardActions>
|
||||
{credsMsg && (
|
||||
<p
|
||||
role="alert"
|
||||
@@ -740,19 +736,26 @@ export function GoogleHealthCard({
|
||||
</Link>
|
||||
</>
|
||||
) : status?.configured ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
window.location.href = "/api/google-health/connect";
|
||||
}}
|
||||
>
|
||||
<Link2 className="h-4 w-4" />
|
||||
{t("settings.googleHealthConnect")}
|
||||
</Button>
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
size="sm"
|
||||
className="min-h-11 w-full sm:w-auto"
|
||||
data-testid="googlehealth-connect"
|
||||
onClick={() => {
|
||||
window.location.href = "/api/google-health/connect";
|
||||
}}
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
{t("settings.googleHealthConnect")}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
) : (
|
||||
<div className="bg-muted/50 text-muted-foreground rounded-lg p-3 text-sm">
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-testid="integration-unavailable"
|
||||
>
|
||||
{t("settings.googleHealthNoCredentials")}
|
||||
</div>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
@@ -216,8 +217,6 @@ export function NightscoutCard({
|
||||
}
|
||||
/>
|
||||
|
||||
<hr data-testid="integration-card-divider" className="border-border/60" />
|
||||
|
||||
<div className="space-y-4">
|
||||
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
|
||||
{operatorApprovalRequired && (
|
||||
@@ -323,7 +322,7 @@ export function NightscoutCard({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
@@ -342,7 +341,7 @@ export function NightscoutCard({
|
||||
? t("settings.nightscoutUpdate")
|
||||
: t("settings.nightscoutConnect")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCardActions>
|
||||
|
||||
{msg && (
|
||||
<p
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
@@ -285,8 +286,6 @@ export function OAuthProviderCard({
|
||||
}
|
||||
/>
|
||||
|
||||
<hr data-testid="integration-card-divider" className="border-border/60" />
|
||||
|
||||
<div className="space-y-4">
|
||||
{errorMessage && (
|
||||
<div data-testid={`${provider}-error`}>
|
||||
@@ -387,10 +386,7 @@ export function OAuthProviderCard({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.integrationCredentialsHint")}
|
||||
</p>
|
||||
<div className="flex justify-end">
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
@@ -407,7 +403,7 @@ export function OAuthProviderCard({
|
||||
)}
|
||||
{t(`${i18nPrefix}SaveCredentials`)}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCardActions>
|
||||
{credsMsg && (
|
||||
<p
|
||||
role="alert"
|
||||
@@ -505,17 +501,19 @@ export function OAuthProviderCard({
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-11 w-full sm:w-auto"
|
||||
disabled={serverUnavailable}
|
||||
onClick={handleConnect}
|
||||
data-testid={`${provider}-connect`}
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
{t(`${i18nPrefix}Connect`)}
|
||||
</Button>
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-11 w-full sm:w-auto"
|
||||
disabled={serverUnavailable}
|
||||
onClick={handleConnect}
|
||||
data-testid={`${provider}-connect`}
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
{t(`${i18nPrefix}Connect`)}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
)}
|
||||
|
||||
{msg && (
|
||||
|
||||
@@ -102,11 +102,14 @@ export function IntegrationRedirectGuide({
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.18.7 (Wave F) — the one description skeleton every integration card paints:
|
||||
* a WHITE primary sentence (`${i18nPrefix}Description`), then a GREY secondary
|
||||
* sentence (`${i18nPrefix}DescriptionSecondary`) that ends with the setup-guide
|
||||
* link rendered INLINE at the very end of the grey copy — not a standalone
|
||||
* bottom-of-card element. Single-sourced so all six cards read as one family.
|
||||
* The one description every integration card paints: ONE sentence, then the
|
||||
* setup-guide link inline at the end of it.
|
||||
*
|
||||
* It used to be two paragraphs stacked in the description slot — a white
|
||||
* primary sentence and a grey secondary one whose only job was to carry the
|
||||
* link. The secondary sentence was byte-identical across six of the eight
|
||||
* cards, which is the tell: it said nothing about the provider it sat under.
|
||||
* The link is the part that carried information, so the link is what stayed.
|
||||
*/
|
||||
export function IntegrationCardDescription({
|
||||
i18nPrefix,
|
||||
@@ -118,21 +121,18 @@ export function IntegrationCardDescription({
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<>
|
||||
<p className="text-foreground">{t(`${i18nPrefix}Description`)}</p>
|
||||
<p className="text-muted-foreground">
|
||||
{t(`${i18nPrefix}DescriptionSecondary`)}{" "}
|
||||
<a
|
||||
href={integrationDocsHref(provider)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-testid={`${provider}-setup-guide`}
|
||||
data-slot="integration-setup-guide"
|
||||
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 underline underline-offset-2"
|
||||
>
|
||||
{t("settings.integrationSetupGuide")}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
</a>
|
||||
</p>
|
||||
{t(`${i18nPrefix}Description`)}{" "}
|
||||
<a
|
||||
href={integrationDocsHref(provider)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-testid={`${provider}-setup-guide`}
|
||||
data-slot="integration-setup-guide"
|
||||
className="hover:text-foreground inline-flex items-center gap-1 underline underline-offset-2"
|
||||
>
|
||||
{t("settings.integrationSetupGuide")}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
@@ -224,8 +225,6 @@ export function WhoopCard({
|
||||
}
|
||||
/>
|
||||
|
||||
<hr data-testid="integration-card-divider" className="border-border/60" />
|
||||
|
||||
<div className="space-y-4">
|
||||
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
|
||||
{/* Parked-integration resume CTA. Surfaces only when the row
|
||||
@@ -342,10 +341,7 @@ export function WhoopCard({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.integrationCredentialsHint")}
|
||||
</p>
|
||||
<div className="flex justify-end">
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
@@ -362,7 +358,7 @@ export function WhoopCard({
|
||||
)}
|
||||
{t("settings.whoopSaveCredentials")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCardActions>
|
||||
{credsMsg && (
|
||||
<p
|
||||
role="alert"
|
||||
@@ -485,19 +481,26 @@ export function WhoopCard({
|
||||
</Link>
|
||||
</>
|
||||
) : status?.configured ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
window.location.href = "/api/whoop/connect";
|
||||
}}
|
||||
>
|
||||
<Link2 className="h-4 w-4" />
|
||||
{t("settings.whoopConnect")}
|
||||
</Button>
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
size="sm"
|
||||
className="min-h-11 w-full sm:w-auto"
|
||||
data-testid="whoop-connect"
|
||||
onClick={() => {
|
||||
window.location.href = "/api/whoop/connect";
|
||||
}}
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
{t("settings.whoopConnect")}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
) : (
|
||||
<div className="bg-muted/50 text-muted-foreground rounded-lg p-3 text-sm">
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-testid="integration-unavailable"
|
||||
>
|
||||
{t("settings.whoopNoCredentials")}
|
||||
</div>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
@@ -236,8 +237,6 @@ export function WithingsCard({
|
||||
}
|
||||
/>
|
||||
|
||||
<hr data-testid="integration-card-divider" className="border-border/60" />
|
||||
|
||||
<div className="space-y-4">
|
||||
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
|
||||
{/* v1.4.25 W5d — Withings activity-scope reconnect banner.
|
||||
@@ -390,10 +389,7 @@ export function WithingsCard({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.integrationCredentialsHint")}
|
||||
</p>
|
||||
<div className="flex justify-end">
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
@@ -410,7 +406,7 @@ export function WithingsCard({
|
||||
)}
|
||||
{t("settings.withingsSaveCredentials")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCardActions>
|
||||
{credsMsg && (
|
||||
<p
|
||||
role="alert"
|
||||
@@ -534,19 +530,26 @@ export function WithingsCard({
|
||||
</Link>
|
||||
</>
|
||||
) : status?.configured ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
window.location.href = "/api/withings/connect";
|
||||
}}
|
||||
>
|
||||
<Link2 className="h-4 w-4" />
|
||||
{t("settings.withingsConnect")}
|
||||
</Button>
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
size="sm"
|
||||
className="min-h-11 w-full sm:w-auto"
|
||||
data-testid="withings-connect"
|
||||
onClick={() => {
|
||||
window.location.href = "/api/withings/connect";
|
||||
}}
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
{t("settings.withingsConnect")}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
) : (
|
||||
<div className="bg-muted/50 text-muted-foreground rounded-lg p-3 text-sm">
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-testid="integration-unavailable"
|
||||
>
|
||||
{t("settings.withingsNoCredentials")}
|
||||
</div>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
Reference in new issue
Block a user