mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
The two consoles used the same primitives and still read as two systems, because everything without a primitive had drifted: the header-to-body step existed in three competing techniques at once (`mt-4` on the first body child, `space-y-4` on the card, `mb-4` on the header), the body started either at the card edge or 28 px in, and both shells hand-rolled the page title instead of calling `PageHeader`. Move the decisions into the primitives so a call site cannot re-open them: - `SettingsCard` is gap-based like `Card` (`flex flex-col gap-4`). The header-to-body step is the container's, not a margin somebody remembers. A dense card still overrides the gap on the card itself. - The body starts at the card edge. `pl-7` is gone — off the spacing scale, and it mis-aligned action rows against their own forms wherever two neighbouring cards disagreed about it. - The card header's description slot loses its `space-y-1`. That was the licence call sites used to stack explainer paragraphs into a muted `text-xs` slot; the slot holds one sentence. - New `SettingsCardActions`: one action row, trailing edge, last thing in the card. `align="start"` spells the one exception, a card whose only actions are links out. - New `StatTile` replaces four hand-rolled label/value boxes in Admin. - `SectionHeading` moves from `insights/` to `ui/`. It is the heading above a group of cards, which is what Settings needs; Settings had been using a card header with no card, which reads as a card that failed to paint. Both shells now render their heading through `PageHeader`, which gained `headingAs="div"` for the second copy each shell paints (mobile above the nav strip, desktop inside the grid) so only one is a real `<h1>`. The page explainer is muted everywhere: the primitive said foreground, the standards and every hand-rolled copy said muted, so pages disagreed with each other. Medications, Vorsorge, the labs / custom-metric / illness detail pages and the clinician view drop their hand-rolled title blocks too, including the mid-word truncation on the medications subtitle. Tests pin the parts that can be checked exactly: the card's gap, the description slot's missing rhythm, the action row's two alignments, the `<h1>`-free shells, and a sweep asserting `pl-7` stays gone.
102 lines
3.0 KiB
TypeScript
102 lines
3.0 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* The catch-all row — no envelope entry renders nowhere.
|
|
*
|
|
* A provider whose Settings card was removed kept its sync cron, its ledger
|
|
* row and its block on the status envelope, and lost the only surface that
|
|
* could have shown it dying. It then stopped syncing for a month and nothing,
|
|
* anywhere, said so.
|
|
*
|
|
* This row closes the class rather than the instance: the connections panel
|
|
* iterates the envelope and renders this for every configured entry no bespoke
|
|
* card claims. Adding a provider to the envelope without a card now produces a
|
|
* visible row instead of a silent JSON block, and a structural test pins that
|
|
* nothing can go unrendered.
|
|
*
|
|
* Read-only by design. A deliberately dropped integration does not get its
|
|
* management surface back; it gets an honest status.
|
|
*/
|
|
|
|
import { Plug } from "lucide-react";
|
|
|
|
import { SettingsCard } from "@/components/settings/settings-card";
|
|
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
|
import {
|
|
formatRelative,
|
|
IntegrationStatusPill,
|
|
} from "@/components/settings/integration-status-pill";
|
|
import { useTranslations } from "@/lib/i18n/context";
|
|
|
|
import {
|
|
pillFailurePropsFor,
|
|
pillStateFor,
|
|
pillTimestampFor,
|
|
type IntegrationKey,
|
|
type IntegrationStatusViewModel,
|
|
} from "./shared";
|
|
|
|
/**
|
|
* Display names, mirroring the server-side `INTEGRATION_LABELS`. Only the
|
|
* card-less keys can ever reach this row, but the map stays complete so a
|
|
* future drop needs no edit here.
|
|
*/
|
|
export const INTEGRATION_DISPLAY_NAMES: Record<IntegrationKey, string> = {
|
|
withings: "Withings",
|
|
whoop: "WHOOP",
|
|
fitbit: "Fitbit",
|
|
nightscout: "Nightscout",
|
|
polar: "Polar",
|
|
oura: "Oura",
|
|
"google-health": "Google Health",
|
|
strava: "Strava",
|
|
};
|
|
|
|
export function IntegrationFallbackRow({
|
|
status,
|
|
now,
|
|
}: {
|
|
status: IntegrationStatusViewModel;
|
|
/** Override "now" for deterministic testing. */
|
|
now?: Date;
|
|
}) {
|
|
const { t } = useTranslations();
|
|
|
|
const pillState = pillStateFor(status);
|
|
const reference = now ?? new Date();
|
|
const lastAttemptAt =
|
|
status.lastAttemptAt ?? status.syncHealth?.since ?? null;
|
|
const meta = lastAttemptAt
|
|
? t("settings.integrationFallback.lastAttempt", {
|
|
relative: formatRelative(
|
|
Math.max(0, reference.getTime() - new Date(lastAttemptAt).getTime()),
|
|
t,
|
|
),
|
|
})
|
|
: t("settings.integrationFallback.noAttempt");
|
|
|
|
return (
|
|
<SettingsCard
|
|
data-testid="integration-fallback-row"
|
|
data-integration={status.integration}
|
|
>
|
|
<SettingsCardHeader
|
|
icon={Plug}
|
|
title={
|
|
INTEGRATION_DISPLAY_NAMES[status.integration] ?? status.integration
|
|
}
|
|
description={t("settings.integrationFallback.description")}
|
|
status={
|
|
<IntegrationStatusPill
|
|
state={pillState}
|
|
lastSyncAt={pillTimestampFor(status)}
|
|
{...pillFailurePropsFor(status)}
|
|
now={now}
|
|
/>
|
|
}
|
|
/>
|
|
<p className="text-muted-foreground text-xs">{meta}</p>
|
|
</SettingsCard>
|
|
);
|
|
}
|