feat: add webpush and sadhna tracker

This commit is contained in:
gosangam
2026-06-08 15:48:12 +05:30
parent ecb32d2e85
commit 0334e03c3c
52 changed files with 2118 additions and 545 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
* directly. The BFF handles sanitization, pagination, and caching.
*/
import { bff, bffList, bffOrThrow } from "@/lib/bff";
import { bff, bffList } from "@/lib/bff";
// ---------------------------------------------------------------------------
// Types
-1
View File
@@ -12,7 +12,6 @@ import {
getBookMeta,
getIntroSegments,
getIntroSegment,
getChapter,
getContent,
getSidebarItems,
type BookMeta,
-1
View File
@@ -9,7 +9,6 @@ import {
getBookMeta,
getIntroSegments,
getIntroSegment,
getChapter,
getContent,
getSidebarItems,
type BookMeta,
-1
View File
@@ -12,7 +12,6 @@ import {
getBookMeta,
getIntroSegments,
getIntroSegment,
getChapter,
getContent,
getSidebarItems,
type BookMeta,
+137
View File
@@ -0,0 +1,137 @@
/**
* Web Push client helpers.
*
* Flow: read the VAPID public key from the BFF → register the service worker
* → request notification permission → subscribe via PushManager → send the
* subscription to the BFF (which stores it and pushes to it on each
* notification). Disabling unsubscribes locally and on the server.
*
* All BFF calls ride the auth cookie (credentials: "include"), matching the
* other lib clients.
*/
const BFF_BASE = process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:8080";
/** True when the browser can do Web Push at all. */
export function pushSupported(): boolean {
return (
typeof window !== "undefined" &&
"serviceWorker" in navigator &&
"PushManager" in window &&
"Notification" in window
);
}
/** The browser's current notification permission, or "unsupported". */
export function pushPermission(): NotificationPermission | "unsupported" {
if (!pushSupported()) return "unsupported";
return Notification.permission;
}
type VapidKey = { public_key: string; enabled: boolean };
async function getVapidKey(): Promise<VapidKey> {
const res = await fetch(`${BFF_BASE}/api/v1/push/key`, {
credentials: "include",
});
if (!res.ok) throw new Error("Could not load push configuration");
const body = (await res.json()) as { data?: VapidKey };
return body.data ?? { public_key: "", enabled: false };
}
async function registration(): Promise<ServiceWorkerRegistration | null> {
if (!pushSupported()) return null;
return (await navigator.serviceWorker.getRegistration()) ?? null;
}
/** Whether push is currently enabled (permission granted + a live sub). */
export async function isPushEnabled(): Promise<boolean> {
if (!pushSupported() || Notification.permission !== "granted") return false;
const reg = await registration();
if (!reg) return false;
return !!(await reg.pushManager.getSubscription());
}
/** Whether the server has push configured (VAPID keys present). */
export async function pushConfigured(): Promise<boolean> {
try {
return (await getVapidKey()).enabled;
} catch {
return false;
}
}
/**
* Enable push: requests permission, subscribes, and registers the
* subscription with the BFF. Throws a user-facing message on failure.
*/
export async function enablePush(): Promise<void> {
if (!pushSupported()) {
throw new Error("This browser doesn't support push notifications.");
}
const { public_key, enabled } = await getVapidKey();
if (!enabled || !public_key) {
throw new Error("Push notifications aren't available right now.");
}
const permission = await Notification.requestPermission();
if (permission !== "granted") {
throw new Error("Notification permission was not granted.");
}
const reg = await navigator.serviceWorker.register("/sw.js");
await navigator.serviceWorker.ready;
let sub = await reg.pushManager.getSubscription();
if (!sub) {
sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(public_key),
});
}
const json = sub.toJSON() as {
endpoint?: string;
keys?: { p256dh?: string; auth?: string };
};
const res = await fetch(`${BFF_BASE}/api/v1/push/subscribe`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ endpoint: json.endpoint, keys: json.keys }),
});
if (!res.ok) throw new Error("Could not save your subscription.");
}
/** Disable push: unsubscribe locally and remove the subscription server-side. */
export async function disablePush(): Promise<void> {
const reg = await registration();
const sub = reg ? await reg.pushManager.getSubscription() : null;
if (!sub) return;
const endpoint = sub.endpoint;
try {
await sub.unsubscribe();
} catch {
/* ignore */
}
await fetch(`${BFF_BASE}/api/v1/push/unsubscribe`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ endpoint }),
}).catch(() => {
/* best-effort */
});
}
/** VAPID public keys are base64url; PushManager wants a Uint8Array. */
function urlBase64ToUint8Array(base64: string): Uint8Array<ArrayBuffer> {
const padding = "=".repeat((4 - (base64.length % 4)) % 4);
const normalized = (base64 + padding).replace(/-/g, "+").replace(/_/g, "/");
const raw = atob(normalized);
// Back with a concrete ArrayBuffer so the type is Uint8Array<ArrayBuffer>,
// which PushManager.subscribe's applicationServerKey requires.
const out = new Uint8Array(new ArrayBuffer(raw.length));
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
return out;
}
+54
View File
@@ -91,6 +91,29 @@ export type SadhnaDashboard = {
// plus an in-progress fold for an unfinished today. Capped server-side;
// current_streak holds the true length.
vine_days: VineDay[];
// Rewards. `next_reward_date` is the projected UTC date (YYYY-MM-DD) of the
// next gift if the streak continues. `pending_reward` is a freshly earned
// gift to celebrate now (null when there's nothing to show).
next_reward_date: string | null;
pending_reward: SadhnaRewardGrant | null;
};
/** A gift item in the streak-reward catalog. */
export type SadhnaReward = {
id: string;
key: string;
name: string;
meaning: string;
image_url: string;
active: boolean;
sort: number;
};
/** A reward earned at a streak milestone (a multiple of 7 days). */
export type SadhnaRewardGrant = {
milestone: number;
granted_at: string;
reward: SadhnaReward;
};
// ─── API ───────────────────────────────────────────────────────────────
@@ -99,10 +122,41 @@ export const sadhna = {
/** Today's progress + streak + plant state. */
dashboard: () => call<SadhnaDashboard>("/sadhna/dashboard"),
/** Per-day state for an inclusive UTC date range (YYYY-MM-DD), for the
* calendar's week/month views. Range is bounded to a year server-side. */
history: (from: string, to: string) =>
call<SadhnaDay[]>(
`/sadhna/history?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`,
),
/** Save today's chanting round count; returns the recomputed dashboard. */
saveChant: (roundsCompleted: number) =>
call<SadhnaDashboard>("/sadhna/chant", {
method: "POST",
body: JSON.stringify({ rounds_completed: roundsCompleted }),
}),
/** The user's collected gifts (newest first) + giftable catalog size. */
rewards: () =>
call<{ collected: SadhnaRewardGrant[]; total: number }>("/sadhna/rewards"),
/** Acknowledge pending celebrations after showing the confetti. */
ackRewards: () => call<void>("/sadhna/rewards/ack", { method: "POST" }),
/** Whether daily sādhana reminders are enabled for the caller. */
reminderPrefs: () => call<{ enabled: boolean }>("/sadhna/reminders"),
/** Toggle daily sādhana reminders. */
setReminderPrefs: (enabled: boolean) =>
call<{ enabled: boolean }>("/sadhna/reminders", {
method: "PUT",
body: JSON.stringify({ enabled }),
}),
/** Report the browser's IANA timezone so reminders fire at local time. */
setTimezone: (timezone: string) =>
call<void>("/sadhna/timezone", {
method: "POST",
body: JSON.stringify({ timezone }),
}),
};
+17
View File
@@ -0,0 +1,17 @@
import { useSyncExternalStore } from "react";
const emptySubscribe = () => () => {};
/**
* useMounted — false during SSR and the first client render, true thereafter.
* Uses useSyncExternalStore (not an effect), so it's safe for gating
* client-only work like createPortal without tripping the
* set-state-in-effect rule. Mirrors the pattern in components/logo.tsx.
*/
export function useMounted(): boolean {
return useSyncExternalStore(
emptySubscribe,
() => true,
() => false,
);
}