mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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:
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. */}
|
||||
|
||||
Reference in new issue
Block a user