mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
release: v1.32.0 — hardening and scalability (#580)
* release: prepare v1.32.0 * test: stabilize water capture re-entry
This commit is contained in:
1 parent
b01822be48
commit
62eb431d66
372 files changed
+25178
-8561
No files matched your search
@@ -0,0 +1,155 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import de from "../../../../../messages/de.json";
|
||||
import en from "../../../../../messages/en.json";
|
||||
import es from "../../../../../messages/es.json";
|
||||
import fr from "../../../../../messages/fr.json";
|
||||
import itMessages from "../../../../../messages/it.json";
|
||||
import pl from "../../../../../messages/pl.json";
|
||||
|
||||
let statusPayload: { lastSyncedAt: string | null } | undefined;
|
||||
let statusLoading = false;
|
||||
let statusError = false;
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({
|
||||
data: statusPayload,
|
||||
isLoading: statusLoading,
|
||||
isError: statusError,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { AppleHealthCard } from "../apple-health-card";
|
||||
|
||||
function render(locale: Locale = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<AppleHealthCard enabled />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<AppleHealthCard>", () => {
|
||||
beforeEach(() => {
|
||||
statusPayload = undefined;
|
||||
statusLoading = false;
|
||||
statusError = false;
|
||||
});
|
||||
|
||||
it("explains that live sync uses the iOS app rather than OAuth", () => {
|
||||
statusPayload = { lastSyncedAt: null };
|
||||
statusLoading = false;
|
||||
|
||||
const html = render();
|
||||
|
||||
expect(html).toContain('data-testid="apple-health-card"');
|
||||
expect(html).toContain("HealthLog iOS app");
|
||||
expect(html).toContain("not a web or OAuth connection");
|
||||
expect(html).toContain("Settings → Apple Health");
|
||||
expect(html).toContain("when iOS grants background time");
|
||||
expect(html).not.toContain(">Connected<");
|
||||
});
|
||||
|
||||
it("keeps the status neutral when no Apple Health data has been observed", () => {
|
||||
statusPayload = { lastSyncedAt: null };
|
||||
statusLoading = false;
|
||||
|
||||
const html = render();
|
||||
|
||||
expect(html).toContain('data-testid="apple-health-status"');
|
||||
expect(html).toContain('data-state="setup"');
|
||||
expect(html).toContain("Set up in iOS app");
|
||||
expect(html).not.toContain('data-state="connected"');
|
||||
});
|
||||
|
||||
it("reports only observed data freshness without claiming a connection", () => {
|
||||
statusPayload = { lastSyncedAt: "2026-07-20T10:30:00.000Z" };
|
||||
statusLoading = false;
|
||||
|
||||
const html = render();
|
||||
|
||||
expect(html).toContain('data-state="recent-data"');
|
||||
expect(html).toContain("Apple Health data received");
|
||||
expect(html).toContain("Last Apple Health data:");
|
||||
expect(html).toContain('dateTime="2026-07-20T10:30:00.000Z"');
|
||||
expect(html).not.toContain(">Connected<");
|
||||
expect(html).not.toContain('data-state="connected"');
|
||||
});
|
||||
|
||||
it("links to the existing one-shot Apple Health import fallback", () => {
|
||||
statusPayload = { lastSyncedAt: null };
|
||||
statusLoading = false;
|
||||
|
||||
const html = render();
|
||||
|
||||
expect(html).toContain('data-testid="apple-health-import-link"');
|
||||
expect(html).toContain(
|
||||
'href="/settings/export#settings-section-import-title"',
|
||||
);
|
||||
expect(html).toContain("Open one-shot import");
|
||||
});
|
||||
|
||||
it("uses a non-committal checking state while status is loading", () => {
|
||||
statusPayload = undefined;
|
||||
statusLoading = true;
|
||||
|
||||
const html = render();
|
||||
|
||||
expect(html).toContain('data-state="checking"');
|
||||
expect(html).toContain("Checking Apple Health data…");
|
||||
expect(html).not.toContain("Connected");
|
||||
});
|
||||
it("does not turn a failed status read into a setup or connection claim", () => {
|
||||
statusError = true;
|
||||
|
||||
const html = render();
|
||||
|
||||
expect(html).toContain('data-state="unavailable"');
|
||||
expect(html).toContain("Apple Health status unavailable");
|
||||
expect(html).not.toContain('data-state="setup"');
|
||||
expect(html).not.toContain("Connected");
|
||||
});
|
||||
|
||||
it("defines every card key in all six locale catalogs", () => {
|
||||
const requiredKeys = [
|
||||
"title",
|
||||
"description",
|
||||
"lastDataLabel",
|
||||
"setupTitle",
|
||||
"permissionStep",
|
||||
"backgroundStep",
|
||||
"importNote",
|
||||
"importAction",
|
||||
] as const;
|
||||
|
||||
for (const catalog of [en, de, es, fr, itMessages, pl]) {
|
||||
const appleHealth = catalog.settings.appleHealth;
|
||||
for (const key of requiredKeys) {
|
||||
expect(appleHealth[key]).toBeTypeOf("string");
|
||||
expect(appleHealth[key].length).toBeGreaterThan(0);
|
||||
}
|
||||
expect(Object.keys(appleHealth.status).sort()).toEqual([
|
||||
"checking",
|
||||
"dataReceived",
|
||||
"setup",
|
||||
"unavailable",
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["en", "de", "es", "fr", "it", "pl"] as const)(
|
||||
"renders localized Apple Health copy for %s without leaking keys",
|
||||
(locale) => {
|
||||
statusPayload = { lastSyncedAt: null };
|
||||
statusLoading = false;
|
||||
|
||||
const html = render(locale);
|
||||
|
||||
expect(html).toContain("Apple Health");
|
||||
expect(html).not.toContain("settings.appleHealth.");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { CheckCircle2, HeartPulse, Info, Upload } from "lucide-react";
|
||||
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
interface HealthKitStatus {
|
||||
lastSyncedAt: string | null;
|
||||
}
|
||||
|
||||
export function AppleHealthCard({ enabled }: { enabled: boolean }) {
|
||||
const { t } = useTranslations();
|
||||
const format = useFormatters();
|
||||
const {
|
||||
data: status,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery({
|
||||
queryKey: queryKeys.healthKitStatus(),
|
||||
queryFn: () => apiGet<HealthKitStatus>("/api/integrations/healthkit"),
|
||||
enabled,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
const lastSyncedAt = status?.lastSyncedAt ?? null;
|
||||
const statusState = isLoading
|
||||
? "checking"
|
||||
: isError
|
||||
? "unavailable"
|
||||
: lastSyncedAt
|
||||
? "recent-data"
|
||||
: "setup";
|
||||
const statusLabel = isLoading
|
||||
? t("settings.appleHealth.status.checking")
|
||||
: isError
|
||||
? t("settings.appleHealth.status.unavailable")
|
||||
: lastSyncedAt
|
||||
? t("settings.appleHealth.status.dataReceived")
|
||||
: t("settings.appleHealth.status.setup");
|
||||
|
||||
return (
|
||||
<SettingsCard
|
||||
as="section"
|
||||
aria-labelledby="apple-health-card-title"
|
||||
data-testid="apple-health-card"
|
||||
className="space-y-4"
|
||||
>
|
||||
<SettingsCardHeader
|
||||
icon={HeartPulse}
|
||||
titleId="apple-health-card-title"
|
||||
title={t("settings.appleHealth.title")}
|
||||
description={t("settings.appleHealth.description")}
|
||||
status={
|
||||
<Badge
|
||||
variant={lastSyncedAt ? undefined : "outline"}
|
||||
data-testid="apple-health-status"
|
||||
data-state={statusState}
|
||||
className={
|
||||
lastSyncedAt
|
||||
? "border-success/30 bg-success/15 text-success max-w-full whitespace-nowrap"
|
||||
: "max-w-full whitespace-nowrap"
|
||||
}
|
||||
>
|
||||
{lastSyncedAt ? (
|
||||
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
|
||||
) : (
|
||||
<Info className="h-3 w-3" aria-hidden="true" />
|
||||
)}
|
||||
<span className="truncate">{statusLabel}</span>
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
|
||||
{lastSyncedAt && (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-testid="apple-health-last-data"
|
||||
>
|
||||
{t("settings.appleHealth.lastDataLabel")}{" "}
|
||||
<time dateTime={lastSyncedAt}>{format.dateTime(lastSyncedAt)}</time>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t("settings.appleHealth.setupTitle")}
|
||||
</h3>
|
||||
<ol className="text-muted-foreground list-decimal space-y-2 pl-5 text-sm">
|
||||
<li>{t("settings.appleHealth.permissionStep")}</li>
|
||||
<li>{t("settings.appleHealth.backgroundStep")}</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="border-border bg-muted/30 flex flex-col gap-3 rounded-lg border p-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-muted-foreground text-xs sm:max-w-xl">
|
||||
{t("settings.appleHealth.importNote")}
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 shrink-0 sm:min-h-9"
|
||||
>
|
||||
<Link
|
||||
href="/settings/export#settings-section-import-title"
|
||||
data-testid="apple-health-import-link"
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t("settings.appleHealth.importAction")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { AppleHealthCard } from "@/components/settings/integrations/apple-health-card";
|
||||
import { FitbitCard } from "@/components/settings/integrations/fitbit-card";
|
||||
import { GarminInfoNote } from "@/components/settings/integrations/garmin-info-note";
|
||||
import { GoogleHealthCard } from "@/components/settings/integrations/google-health-card";
|
||||
@@ -285,6 +286,9 @@ export function ConnectionsPanel() {
|
||||
<div id="google-health" className="scroll-mt-28">
|
||||
<GoogleHealthCard viewModel={googleHealthViewModel} />
|
||||
</div>
|
||||
<div id="apple-health" className="scroll-mt-28">
|
||||
<AppleHealthCard enabled={isAuthenticated} />
|
||||
</div>
|
||||
<div id="polar" className="scroll-mt-28">
|
||||
<PolarCard enabled={isAuthenticated} viewModel={polarViewModel} />
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user