mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
feat(integrations): add Nightscout CGM glucose sync
Pull continuous glucose readings from a self-hosted Nightscout instance and store them as immutable BLOOD_GLUCOSE mg/dL measurements (source=NIGHTSCOUT), giving the glucose panel real CGM density. - client over safeFetch: public-host pin by default, relaxed only on the per-user private-network opt-in; redirect pinned manual + timeout - credentials encrypted at rest on User; URL is the connect marker - sync maps SGV entries first-write-wins on (userId,type,source,externalId) - connect/disconnect/status routes: apiHandler + requireAuth + rate limit; connect validates by a live test fetch with clear errors - hourly poll-cohort job registered in the all-queues registry - settings card with URL, token, and the private-network toggle - i18n across all six locales
This commit is contained in:
1 parent
4884f019b7
commit
f5d0b2f696
23 files changed
+1803
-9
No files matched your search
@@ -131,9 +131,9 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
|
||||
});
|
||||
|
||||
const html = render();
|
||||
// Exactly one pill per card → 3 pills total (Withings, WHOOP,
|
||||
// Fitbit / Google Health). The moodLog integration was removed.
|
||||
expect(count(html, 'data-testid="integration-status-pill"')).toBe(3);
|
||||
// Exactly one pill per card → 4 pills total (Withings, WHOOP,
|
||||
// Fitbit / Google Health, Nightscout). The moodLog integration was removed.
|
||||
expect(count(html, 'data-testid="integration-status-pill"')).toBe(4);
|
||||
// The redundant banner from v1.4.15 is gone.
|
||||
expect(html).not.toContain('data-testid="integration-status-banner"');
|
||||
// Card-body "letzter Sync" repetition is gone — no
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { FitbitCard } from "@/components/settings/integrations/fitbit-card";
|
||||
import { NightscoutCard } from "@/components/settings/integrations/nightscout-card";
|
||||
import {
|
||||
pickStatus,
|
||||
useIntegrationStatuses,
|
||||
@@ -124,6 +125,7 @@ export function IntegrationsSection() {
|
||||
<WithingsCard viewModel={withingsViewModel} />
|
||||
<WhoopCard viewModel={whoopViewModel} />
|
||||
<FitbitCard viewModel={fitbitViewModel} />
|
||||
<NightscoutCard enabled={isAuthenticated} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
"use client";
|
||||
|
||||
// v1.17.0 — Nightscout CGM integration card. Unlike WHOOP / Fitbit (OAuth,
|
||||
// BYO-key), Nightscout is a URL + token the self-hoster pastes once: the user
|
||||
// runs their own instance and HealthLog pulls continuous glucose off it. The
|
||||
// card has its own status read (`/api/nightscout/status`) rather than the
|
||||
// consolidated envelope. Warm copy, mobile-first, the private-network opt-in
|
||||
// toggle maps to `nightscoutAllowPrivateHost`.
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Activity, Link2, Loader2, Save, Unlink } from "lucide-react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import {
|
||||
IntegrationStatusPill,
|
||||
type IntegrationPillState,
|
||||
} from "@/components/settings/integration-status-pill";
|
||||
import { apiFetchRaw, apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, measurementDependentKeys, queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import { IntegrationErrorMessage } from "./shared";
|
||||
|
||||
interface NightscoutStatus {
|
||||
connected: boolean;
|
||||
configured: boolean;
|
||||
hasToken?: boolean;
|
||||
allowPrivateHost?: boolean;
|
||||
state?: "connected" | "error_transient" | "error_reauth" | "disconnected" | "parked";
|
||||
lastSuccessAt?: string | null;
|
||||
lastAttemptAt?: string | null;
|
||||
lastError?: string | null;
|
||||
}
|
||||
|
||||
/** Map the shared ledger state onto the pill's display state. */
|
||||
function pillStateFor(status: NightscoutStatus | undefined): IntegrationPillState {
|
||||
if (!status?.connected) return "disconnected";
|
||||
switch (status.state) {
|
||||
case "parked":
|
||||
return "parked";
|
||||
case "error_reauth":
|
||||
return "error";
|
||||
case "error_transient":
|
||||
return "warning";
|
||||
default:
|
||||
return "connected";
|
||||
}
|
||||
}
|
||||
|
||||
export function NightscoutCard({ enabled = true }: { enabled?: boolean }) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [url, setUrl] = useState("");
|
||||
const [token, setToken] = useState("");
|
||||
const [allowPrivateHost, setAllowPrivateHost] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
|
||||
|
||||
const { data: status } = useQuery({
|
||||
queryKey: queryKeys.nightscoutStatus(),
|
||||
queryFn: async () => apiGet<NightscoutStatus>("/api/nightscout/status"),
|
||||
enabled,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
const disconnect = useMutation({
|
||||
mutationFn: async () => {
|
||||
await apiPost("/api/nightscout/disconnect");
|
||||
},
|
||||
onSuccess: () => {
|
||||
setUrl("");
|
||||
setToken("");
|
||||
setAllowPrivateHost(false);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.nightscout() });
|
||||
},
|
||||
});
|
||||
|
||||
async function handleConnect(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setMsg(null);
|
||||
setMsgType(null);
|
||||
try {
|
||||
const res = await apiFetchRaw("/api/nightscout/connect", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
url: url.trim(),
|
||||
token: token.trim(),
|
||||
allowPrivateHost,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
setMsg(t("settings.nightscoutConnected"));
|
||||
setMsgType("success");
|
||||
setToken("");
|
||||
void invalidateKeys(queryClient, measurementDependentKeys);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.nightscout() });
|
||||
} else {
|
||||
let detail = t("settings.nightscoutConnectFailed");
|
||||
try {
|
||||
const json = await res.json();
|
||||
if (json.error) detail = json.error;
|
||||
} catch {
|
||||
// keep the generic message
|
||||
}
|
||||
setMsg(detail);
|
||||
setMsgType("error");
|
||||
}
|
||||
} catch {
|
||||
setMsg(t("common.networkError"));
|
||||
setMsgType("error");
|
||||
}
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
const pillState = pillStateFor(status);
|
||||
const errorMessage =
|
||||
(pillState === "error" || pillState === "parked") && status?.lastError
|
||||
? status.lastError
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="nightscout-card"
|
||||
className="bg-card border-border rounded-xl border p-4 sm:p-6"
|
||||
>
|
||||
<SettingsCardHeader
|
||||
icon={Activity}
|
||||
title={t("settings.nightscout")}
|
||||
titleAccessory={
|
||||
<span className="bg-muted text-foreground rounded-full px-2 py-0.5 text-[0.6875rem] font-medium">
|
||||
{t("settings.nightscoutTag")}
|
||||
</span>
|
||||
}
|
||||
description={<p>{t("settings.nightscoutDescription")}</p>}
|
||||
status={
|
||||
<IntegrationStatusPill
|
||||
state={pillState}
|
||||
lastSyncAt={status?.lastSuccessAt ?? null}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<hr className="border-border/60 mt-4" />
|
||||
|
||||
<div className="mt-4 space-y-4 pl-7">
|
||||
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.nightscoutHelp")}
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleConnect} className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="nightscout-url">{t("settings.nightscoutUrl")}</Label>
|
||||
<Input
|
||||
id="nightscout-url"
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder={
|
||||
status?.configured
|
||||
? t("settings.nightscoutUrlSavedPlaceholder")
|
||||
: "https://your-site.up.railway.app"
|
||||
}
|
||||
maxLength={2048}
|
||||
autoComplete="off"
|
||||
inputMode="url"
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
enterKeyHint="next"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="nightscout-token">
|
||||
{t("settings.nightscoutToken")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="nightscout-token"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
placeholder={
|
||||
status?.hasToken
|
||||
? t("settings.nightscoutTokenSavedPlaceholder")
|
||||
: t("settings.nightscoutTokenOptional")
|
||||
}
|
||||
maxLength={512}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
enterKeyHint="done"
|
||||
/>
|
||||
<p className="text-muted-foreground/80 text-xs">
|
||||
{t("settings.nightscoutTokenHelp")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border-border/60 flex items-start justify-between gap-3 rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="nightscout-private" className="text-sm">
|
||||
{t("settings.nightscoutPrivateHost")}
|
||||
</Label>
|
||||
<p className="text-muted-foreground/80 text-xs">
|
||||
{t("settings.nightscoutPrivateHostHelp")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="nightscout-private"
|
||||
checked={allowPrivateHost}
|
||||
onCheckedChange={setAllowPrivateHost}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 w-full sm:w-auto"
|
||||
disabled={saving || !url.trim()}
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : status?.connected ? (
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{status?.connected
|
||||
? t("settings.nightscoutUpdate")
|
||||
: t("settings.nightscoutConnect")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{msg && (
|
||||
<p
|
||||
role="alert"
|
||||
className={`text-sm ${msgType === "success" ? "text-success" : "text-destructive"}`}
|
||||
>
|
||||
{msg}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{status?.connected && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive min-h-11"
|
||||
>
|
||||
<Unlink className="h-3.5 w-3.5" />
|
||||
{t("settings.nightscoutDisconnect")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.nightscoutDisconnectTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.nightscoutDisconnectDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => disconnect.mutate()}
|
||||
>
|
||||
{t("settings.nightscoutDisconnect")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user