mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
The dashboard snapshot fires the moment login succeeds instead of waiting for the page chunk (start ~670 to ~200 ms throttled). Guided follow-ups are personalised from the user's snapshot and an answer gets the coach's streamed reaction before the next question. Archived schedule eras can be corrected through a superseding revision that keeps the original as an audit trail, era writes serialise per medication, and duplicate dose times collapse in validation. A rolling medication whose next dose is tomorrow can no longer read as heavily overdue — the card status derives strictly from the served next-due — and the adherence bars keep a fixed geometry in every state. The server-wide assistant key gains its admin card, the one duplicated coach preference collapses onto a single owner, twenty-seven admin and settings card headers move onto the shared pattern with sixty containers normalised, and the remaining hydration warnings are gone.
236 lines
7.3 KiB
TypeScript
236 lines
7.3 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { BellRing, Loader2 } from "lucide-react";
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
import { TestConnectionButton } from "@/components/settings/test-connection-button";
|
|
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
|
import { useTranslations } from "@/lib/i18n/context";
|
|
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
|
|
|
/** Convert a URL-safe base64 string to an ArrayBuffer (for VAPID key). */
|
|
function urlBase64ToArrayBuffer(base64String: string): ArrayBuffer {
|
|
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
|
|
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
|
|
const raw = atob(base64);
|
|
const buffer = new ArrayBuffer(raw.length);
|
|
const view = new Uint8Array(buffer);
|
|
for (let i = 0; i < raw.length; ++i) {
|
|
view[i] = raw.charCodeAt(i);
|
|
}
|
|
return buffer;
|
|
}
|
|
|
|
export function WebPushCard() {
|
|
const { t } = useTranslations();
|
|
const [isSubscribed, setIsSubscribed] = useState(false);
|
|
const [isSupported, setIsSupported] = useState(true);
|
|
const [isDenied, setIsDenied] = useState(false);
|
|
const [loading, setLoading] = useState(true);
|
|
const [actionLoading, setActionLoading] = useState(false);
|
|
const [msg, setMsg] = useState<string | null>(null);
|
|
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
|
|
|
|
useEffect(() => {
|
|
// Async I/O (browser API + service worker registration). The lint rule
|
|
// allows setState inside async callbacks — only synchronous setState in
|
|
// the effect body is rejected.
|
|
async function checkSubscription() {
|
|
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
|
|
setIsSupported(false);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
if (Notification.permission === "denied") {
|
|
setIsDenied(true);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const registration =
|
|
await navigator.serviceWorker.getRegistration("/sw.js");
|
|
if (registration) {
|
|
const subscription = await registration.pushManager.getSubscription();
|
|
setIsSubscribed(!!subscription);
|
|
}
|
|
} catch {
|
|
// Ignore errors during check
|
|
}
|
|
setLoading(false);
|
|
}
|
|
|
|
void checkSubscription();
|
|
}, []);
|
|
|
|
async function handleSubscribe() {
|
|
setActionLoading(true);
|
|
setMsg(null);
|
|
setMsgType(null);
|
|
|
|
try {
|
|
const vapidRes = await apiFetchRaw("/api/notifications/vapid");
|
|
if (!vapidRes.ok) {
|
|
setMsg(t("settings.webPushNotConfigured"));
|
|
setMsgType("error");
|
|
setActionLoading(false);
|
|
return;
|
|
}
|
|
const vapidData = await vapidRes.json();
|
|
const vapidPublicKey = vapidData.data.publicKey;
|
|
|
|
const registration = await navigator.serviceWorker.register("/sw.js");
|
|
await navigator.serviceWorker.ready;
|
|
|
|
const subscription = await registration.pushManager.subscribe({
|
|
userVisibleOnly: true,
|
|
applicationServerKey: urlBase64ToArrayBuffer(vapidPublicKey),
|
|
});
|
|
|
|
const subJson = subscription.toJSON();
|
|
|
|
const res = await apiFetchRaw("/api/notifications/web-push", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
endpoint: subJson.endpoint,
|
|
keys: {
|
|
p256dh: subJson.keys?.p256dh ?? "",
|
|
auth: subJson.keys?.auth ?? "",
|
|
},
|
|
}),
|
|
});
|
|
|
|
if (res.ok) {
|
|
setIsSubscribed(true);
|
|
setMsg(t("settings.webPushSubscribed"));
|
|
setMsgType("success");
|
|
} else {
|
|
setMsg(t("settings.webPushSubscribeFailed"));
|
|
setMsgType("error");
|
|
}
|
|
} catch {
|
|
if (Notification.permission === "denied") {
|
|
setIsDenied(true);
|
|
setMsg(t("settings.webPushDenied"));
|
|
} else {
|
|
setMsg(t("settings.webPushSubscribeFailed"));
|
|
}
|
|
setMsgType("error");
|
|
} finally {
|
|
setActionLoading(false);
|
|
}
|
|
}
|
|
|
|
async function handleUnsubscribe() {
|
|
setActionLoading(true);
|
|
setMsg(null);
|
|
setMsgType(null);
|
|
|
|
try {
|
|
const registration =
|
|
await navigator.serviceWorker.getRegistration("/sw.js");
|
|
if (registration) {
|
|
const subscription = await registration.pushManager.getSubscription();
|
|
if (subscription) {
|
|
await apiFetchRaw("/api/notifications/web-push", {
|
|
method: "DELETE",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ endpoint: subscription.endpoint }),
|
|
});
|
|
|
|
await subscription.unsubscribe();
|
|
}
|
|
}
|
|
|
|
setIsSubscribed(false);
|
|
setMsg(t("settings.webPushUnsubscribed"));
|
|
setMsgType("success");
|
|
} catch {
|
|
setMsg(t("settings.webPushSubscribeFailed"));
|
|
setMsgType("error");
|
|
} finally {
|
|
setActionLoading(false);
|
|
}
|
|
}
|
|
|
|
// The primary action sits in the header row (right of the title) rather than
|
|
// bottom-left, so an inactive card does not waste a wide empty band before
|
|
// its enable button. The loading spinner rides the same slot; the
|
|
// unsupported / denied notices stay in the body.
|
|
const headerAction = loading ? (
|
|
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
|
|
) : !isSupported || isDenied ? null : (
|
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
|
{isSubscribed ? (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="min-h-11"
|
|
onClick={handleUnsubscribe}
|
|
disabled={actionLoading}
|
|
>
|
|
{actionLoading && (
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
|
)}
|
|
{t("settings.webPushUnsubscribe")}
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="min-h-11"
|
|
onClick={handleSubscribe}
|
|
disabled={actionLoading}
|
|
>
|
|
{actionLoading ? (
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
|
) : (
|
|
<BellRing className="h-3.5 w-3.5" />
|
|
)}
|
|
{t("settings.webPushSubscribe")}
|
|
</Button>
|
|
)}
|
|
{isSubscribed && (
|
|
<TestConnectionButton endpoint="/api/notifications/web-push/test" />
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
|
<SettingsCardHeader
|
|
icon={BellRing}
|
|
title={t("settings.webPush")}
|
|
description={t("settings.webPushDescription")}
|
|
status={headerAction}
|
|
/>
|
|
|
|
{(!loading && !isSupported) || (!loading && isDenied) || msg ? (
|
|
<div className="mt-4 space-y-4 pl-7">
|
|
{!loading && !isSupported ? (
|
|
<p className="text-muted-foreground text-sm">
|
|
{t("settings.webPushNotSupported")}
|
|
</p>
|
|
) : !loading && isDenied ? (
|
|
<p className="text-destructive text-sm">
|
|
{t("settings.webPushDenied")}
|
|
</p>
|
|
) : null}
|
|
|
|
{msg && (
|
|
<p
|
|
role="alert"
|
|
className={`text-sm ${msgType === "success" ? "text-success" : "text-destructive"}`}
|
|
>
|
|
{msg}
|
|
</p>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|