feat(settings): put a sync-now button on the four cards that lacked one

A route with no caller is an API detail, not a feature. The Oura, Polar,
Strava and Nightscout cards get the same action WHOOP already had, in the
same row as Test connection, rendered only once connected: post the sync,
show the count or the failure, invalidate the reads that depend on
measurements. No full-history dialog, since these providers have no such
arm. Keys added across the six locales.
This commit is contained in:
Marc-André Bombeck committed 2026-07-25 04:43:12 +02:00
1 parent a9f1629f82
commit 0bcbb88db1
10 files changed
+252

No files matched your search

@@ -83,3 +83,26 @@ describe("NightscoutCard — parked + test + data-link + invalidation", () => {
expect(invalidatedKeys).toContainEqual(queryKeys.nightscoutStatus());
});
});
/**
* v1.32.28 — sync now, matching the treatment the OAuth cards get.
*/
describe("NightscoutCard — sync-now action", () => {
it("offers the sync action on a connected instance", () => {
statusPayload = {
connected: true,
configured: true,
state: "connected",
lastSuccessAt: null,
lastError: null,
};
const html = render();
expect(html).toContain('data-testid="nightscout-sync"');
expect(html).toContain("Sync now");
});
it("does not offer it before an instance is configured", () => {
statusPayload = { connected: false, configured: false };
expect(render()).not.toContain('data-testid="nightscout-sync"');
});
});
@@ -178,3 +178,34 @@ describe("OAuthProviderCard — redirect-URI mini-guide (v1.29.x, UX audit H2)",
expect(html).not.toContain('data-testid="polar-redirect-guide"');
});
});
/**
* v1.32.28 — sync now. The route without the button would be an endpoint with
* no caller; the button is what makes the manual sync a feature rather than an
* API detail. Rendered only once the provider is connected, in the same action
* row as Test connection.
*/
describe("OAuthProviderCard — sync-now action", () => {
it("offers the sync action on a connected provider", () => {
statusPayload = {
connected: true,
configured: true,
available: true,
state: "connected",
lastSuccessAt: null,
lastError: null,
};
const html = render();
expect(html).toContain('data-testid="polar-sync"');
expect(html).toContain("Sync now");
});
it("does not offer it before the provider is connected", () => {
statusPayload = {
connected: false,
configured: true,
available: true,
};
expect(render()).not.toContain('data-testid="polar-sync"');
});
});
@@ -15,6 +15,7 @@ import {
Droplet,
Link2,
Loader2,
RefreshCw,
Save,
Unlink,
} from "lucide-react";
@@ -96,6 +97,11 @@ export function NightscoutCard({ enabled = true }: { enabled?: boolean }) {
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
const [syncing, setSyncing] = useState(false);
const [syncMsg, setSyncMsg] = useState<string | null>(null);
const [syncMsgType, setSyncMsgType] = useState<"success" | "error" | null>(
null,
);
const { data: status } = useQuery({
queryKey: queryKeys.nightscoutStatus(),
@@ -170,6 +176,39 @@ export function NightscoutCard({ enabled = true }: { enabled?: boolean }) {
setSaving(false);
}
// Sync now. Same shape as the WHOOP card's incremental arm, minus the
// full-history dialog — Nightscout has no full-sync arm to call.
async function handleSync() {
setSyncing(true);
setSyncMsg(null);
setSyncMsgType(null);
try {
const res = await apiFetchRaw("/api/nightscout/sync", { method: "POST" });
const json = await res.json();
if (res.ok) {
setSyncMsg(
t("settings.nightscoutSyncResult", { count: json.data.imported }),
);
setSyncMsgType("success");
void invalidateKeys(queryClient, measurementDependentKeys);
queryClient.invalidateQueries({
queryKey: queryKeys.nightscoutStatus(),
});
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
} else {
setSyncMsg(json.error || t("settings.nightscoutSyncFailed"));
setSyncMsgType("error");
}
} catch {
setSyncMsg(t("settings.nightscoutSyncFailed"));
setSyncMsgType("error");
} finally {
setSyncing(false);
}
}
const pillState = pillStateFor(status);
const errorMessage =
(pillState === "error" || pillState === "parked") && status?.lastError
@@ -337,6 +376,22 @@ export function NightscoutCard({ enabled = true }: { enabled?: boolean }) {
{status?.connected && (
<>
<div className="flex flex-wrap items-start gap-2 [&>*]:min-w-[10rem] sm:[&>*]:min-w-0">
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11"
onClick={handleSync}
disabled={syncing}
data-testid="nightscout-sync"
>
{syncing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
{t("settings.nightscoutSync")}
</Button>
<TestConnectionButton
endpoint="/api/nightscout/test"
disabled={!status?.connected}
@@ -373,6 +428,15 @@ export function NightscoutCard({ enabled = true }: { enabled?: boolean }) {
</AlertDialogContent>
</AlertDialog>
</div>
{syncMsg && (
<p
role="alert"
data-testid="nightscout-sync-result"
className={`text-sm ${syncMsgType === "success" ? "text-success" : "text-destructive"}`}
>
{syncMsg}
</p>
)}
{/* connect→data loop: a discreet link to where the glucose
readings now surface — doubles as the "your data is richer"
cue. */}
@@ -15,6 +15,7 @@ import {
ArrowRight,
Link2,
Loader2,
RefreshCw,
Save,
Unlink,
type LucideIcon,
@@ -125,6 +126,11 @@ export function OAuthProviderCard({
const { t } = useTranslations();
const queryClient = useQueryClient();
const [msg, setMsg] = useState<string | null>(null);
const [syncing, setSyncing] = useState(false);
const [syncMsg, setSyncMsg] = useState<string | null>(null);
const [syncMsgType, setSyncMsgType] = useState<"success" | "error" | null>(
null,
);
const [clientId, setClientId] = useState("");
const [clientSecret, setClientSecret] = useState("");
const [credsSaving, setCredsSaving] = useState(false);
@@ -145,6 +151,37 @@ export function OAuthProviderCard({
});
const status = viewModel ?? fetchedStatus;
// Sync now. Same shape as the WHOOP card's incremental arm, minus the
// full-history dialog — none of these providers has a full-sync arm to call.
async function handleSync() {
setSyncing(true);
setSyncMsg(null);
setSyncMsgType(null);
try {
const res = await apiFetchRaw(`/api/${provider}/sync`, {
method: "POST",
});
const json = await res.json();
if (res.ok) {
setSyncMsg(t(`${i18nPrefix}SyncResult`, { count: json.data.imported }));
setSyncMsgType("success");
void invalidateKeys(queryClient, measurementDependentKeys);
queryClient.invalidateQueries({ queryKey: statusQueryKey });
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
} else {
setSyncMsg(json.error || t(`${i18nPrefix}SyncFailed`));
setSyncMsgType("error");
}
} catch {
setSyncMsg(t(`${i18nPrefix}SyncFailed`));
setSyncMsgType("error");
} finally {
setSyncing(false);
}
}
async function handleSaveCredentials(e: React.FormEvent) {
e.preventDefault();
setCredsSaving(true);
@@ -374,6 +411,22 @@ export function OAuthProviderCard({
{status?.connected ? (
<>
<div className="flex flex-wrap items-start gap-2 [&>*]:min-w-[10rem] sm:[&>*]:min-w-0">
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11"
onClick={handleSync}
disabled={syncing}
data-testid={`${provider}-sync`}
>
{syncing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
{t(`${i18nPrefix}Sync`)}
</Button>
<TestConnectionButton
endpoint={`/api/${provider}/test`}
disabled={!status?.connected}
@@ -411,6 +464,15 @@ export function OAuthProviderCard({
</AlertDialogContent>
</AlertDialog>
</div>
{syncMsg && (
<p
role="alert"
data-testid={`${provider}-sync-result`}
className={`text-sm ${syncMsgType === "success" ? "text-success" : "text-destructive"}`}
>
{syncMsg}
</p>
)}
{/* connect→data loop: a discreet link to where this provider's
readings now surface — doubles as the "your data is richer"
cue. */}