mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
feat(integrations): IntegrationStatusPill component (single tag, mobile-safe)
Settings → Integrations had three- to four-fold redundant status displays per card. The new pill is the one place a connection status appears: top-right, Dracula-tokenized chip, "Connected · 12 min ago" relative-time pattern. Locale-aware (EN + DE), mobile-safe (whitespace- nowrap, abbreviated "min" / "h" / "d" forms so it fits a 360px card). Reusable for v1.4.20's Apple Health card on iOS. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
b5e9a95227
commit
ba0d6b87d1
6 files changed
+334
-55
No files matched your search
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* v1.4.19 phase A5 — IntegrationStatusPill
|
||||
*
|
||||
* The single tag rendered top-right of every integration card. The
|
||||
* pill is the ONLY place a connection status surfaces — Marc was
|
||||
* staring at three- to four-fold redundancy across Withings + Mood Log
|
||||
* cards in v1.4.18 and called it out. This component is reusable so
|
||||
* v1.4.20 can drop the same pill on the Apple Health card.
|
||||
*
|
||||
* The four states this file locks in (one assertion each):
|
||||
* 1. connected → "Connected · 12 min ago" pattern
|
||||
* 2. error → "Error — reconnect" pattern
|
||||
* 3. error (without ts) → bare error label, no "ago" suffix
|
||||
* 4. disconnected → "Not connected" pattern (no relative ts)
|
||||
*
|
||||
* Plus locale parity: switching to `de` renders the German strings.
|
||||
* Plus a "no last-sync timestamp" guard for connected state.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { IntegrationStatusPill } from "../integration-status-pill";
|
||||
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("IntegrationStatusPill", () => {
|
||||
it("renders the connected state with relative time when lastSyncAt is recent", () => {
|
||||
const twelveMinAgo = new Date(Date.now() - 12 * 60 * 1000);
|
||||
const html = render(
|
||||
<IntegrationStatusPill
|
||||
state="connected"
|
||||
lastSyncAt={twelveMinAgo}
|
||||
now={new Date()}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Connected");
|
||||
// 12 min in EN locale
|
||||
expect(html).toMatch(/12\s+min(\.|utes)?\s+ago/);
|
||||
// Renders within a single pill marker so cards can target it.
|
||||
expect(html).toContain('data-testid="integration-status-pill"');
|
||||
expect(html).toContain('data-state="connected"');
|
||||
});
|
||||
|
||||
it("renders the connected state without relative time when lastSyncAt is null", () => {
|
||||
const html = render(
|
||||
<IntegrationStatusPill state="connected" lastSyncAt={null} />,
|
||||
);
|
||||
expect(html).toContain("Connected");
|
||||
// Must not render the relative-time separator " · " when the
|
||||
// timestamp is missing, otherwise the pill reads "Connected · ".
|
||||
expect(html).not.toMatch(/Connected.*\s·\s\s/);
|
||||
});
|
||||
|
||||
it("renders the error state with the reconnect-cta phrasing", () => {
|
||||
const html = render(
|
||||
<IntegrationStatusPill state="error" lastSyncAt={null} />,
|
||||
);
|
||||
expect(html).toContain("Error");
|
||||
expect(html).toContain("reconnect");
|
||||
expect(html).toContain('data-state="error"');
|
||||
});
|
||||
|
||||
it("renders the disconnected state with the 'not connected' label", () => {
|
||||
const html = render(
|
||||
<IntegrationStatusPill state="disconnected" lastSyncAt={null} />,
|
||||
);
|
||||
expect(html).toContain("Not connected");
|
||||
expect(html).toContain('data-state="disconnected"');
|
||||
});
|
||||
|
||||
it("renders German strings when the active locale is 'de'", () => {
|
||||
const html = render(
|
||||
<IntegrationStatusPill state="disconnected" lastSyncAt={null} />,
|
||||
"de",
|
||||
);
|
||||
expect(html).toContain("Nicht verbunden");
|
||||
});
|
||||
|
||||
it("uses an abbreviated relative-time form for very recent syncs (< 1 min)", () => {
|
||||
const fortySecondsAgo = new Date(Date.now() - 40 * 1000);
|
||||
const html = render(
|
||||
<IntegrationStatusPill
|
||||
state="connected"
|
||||
lastSyncAt={fortySecondsAgo}
|
||||
now={new Date()}
|
||||
/>,
|
||||
);
|
||||
// Sub-minute syncs collapse to "just now" so the pill stays
|
||||
// narrow on Pixel 5.
|
||||
expect(html).toMatch(/just\s+now/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import { CheckCircle2, AlertTriangle, CircleSlash } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* v1.4.19 phase A5 — single status pill rendered top-right of every
|
||||
* integration card (Withings, Mood Log, Apple Health…). It replaces
|
||||
* the three- to four-fold redundant status surface Marc spotted in
|
||||
* v1.4.18:
|
||||
*
|
||||
* - top-right "Connected" badge
|
||||
* - middle container with "Connected / last successful / last attempt"
|
||||
* - bottom-of-card "letzter Sync" line
|
||||
*
|
||||
* Now there is exactly ONE place a sync status can appear on a card
|
||||
* and it follows the pattern Marc liked from the Withings header: a
|
||||
* Dracula-tokenized chip with the relative time inline.
|
||||
*
|
||||
* The component is locale-aware (EN + DE) and mobile-safe: on a
|
||||
* Pixel-5 viewport (393 CSS px) the pill stays on the same line as
|
||||
* the card title because it whitespace-nowraps and tucks the icon +
|
||||
* abbreviated time form into a 12 char string at most.
|
||||
*/
|
||||
|
||||
export type IntegrationPillState = "connected" | "error" | "disconnected";
|
||||
|
||||
interface IntegrationStatusPillProps {
|
||||
state: IntegrationPillState;
|
||||
/** When connected, the last-successful-sync timestamp drives the
|
||||
* relative "12 min ago" suffix. Pass `null` to suppress the
|
||||
* suffix (used when the integration was just configured but has
|
||||
* never synced yet). */
|
||||
lastSyncAt: Date | string | null;
|
||||
/** Override "now" for deterministic testing. Defaults to `new Date()`. */
|
||||
now?: Date;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function toDate(value: Date | string): Date {
|
||||
return value instanceof Date ? value : new Date(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a millisecond delta into a short, locale-aware relative
|
||||
* string. We keep the buckets small and fixed (just-now / minutes /
|
||||
* hours / days) because the pill must fit on a 360 px wide card.
|
||||
*/
|
||||
function formatRelative(
|
||||
deltaMs: number,
|
||||
t: (key: string, params?: Record<string, string | number>) => string,
|
||||
): string {
|
||||
if (deltaMs < 60_000) return t("settings.integrationPill.justNow");
|
||||
const minutes = Math.floor(deltaMs / 60_000);
|
||||
if (minutes < 60) {
|
||||
return t("settings.integrationPill.minutesAgo", { count: minutes });
|
||||
}
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) {
|
||||
return t("settings.integrationPill.hoursAgo", { count: hours });
|
||||
}
|
||||
const days = Math.floor(hours / 24);
|
||||
return t("settings.integrationPill.daysAgo", { count: days });
|
||||
}
|
||||
|
||||
export function IntegrationStatusPill({
|
||||
state,
|
||||
lastSyncAt,
|
||||
now,
|
||||
className,
|
||||
}: IntegrationStatusPillProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
let label: string;
|
||||
let icon: React.ReactNode;
|
||||
let chipClass: string;
|
||||
|
||||
switch (state) {
|
||||
case "connected":
|
||||
label = t("settings.integrationPill.connected");
|
||||
icon = <CheckCircle2 aria-hidden="true" className="h-3 w-3" />;
|
||||
chipClass =
|
||||
"border-dracula-green/30 bg-dracula-green/15 text-dracula-green";
|
||||
break;
|
||||
case "error":
|
||||
label = t("settings.integrationPill.errorReconnect");
|
||||
icon = <AlertTriangle aria-hidden="true" className="h-3 w-3" />;
|
||||
chipClass = "";
|
||||
break;
|
||||
case "disconnected":
|
||||
label = t("settings.integrationPill.notConnected");
|
||||
icon = <CircleSlash aria-hidden="true" className="h-3 w-3" />;
|
||||
chipClass = "";
|
||||
break;
|
||||
}
|
||||
|
||||
const reference = now ?? new Date();
|
||||
const relative =
|
||||
state === "connected" && lastSyncAt
|
||||
? formatRelative(
|
||||
Math.max(0, reference.getTime() - toDate(lastSyncAt).getTime()),
|
||||
t,
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Badge
|
||||
data-testid="integration-status-pill"
|
||||
data-state={state}
|
||||
variant={state === "connected" ? undefined : state === "error" ? "destructive" : "outline"}
|
||||
aria-label={t("settings.integrationPill.ariaLabel")}
|
||||
className={cn(
|
||||
"max-w-full whitespace-nowrap",
|
||||
state === "connected" && chipClass,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
<span className="truncate">
|
||||
{label}
|
||||
{relative ? <span aria-hidden="true"> · {relative}</span> : null}
|
||||
</span>
|
||||
{relative ? <span className="sr-only">{relative}</span> : null}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user