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
@@ -6,7 +6,6 @@ import {
useEffect,
useMemo,
useState,
useSyncExternalStore,
} from "react";
import { useAudioPlayer } from "./audio-context";
import { AudioWaveAnimation } from "./audio-wave";
@@ -35,41 +34,6 @@ import {
const MINIMIZE_DELAY = 8000;
/**
* `useSyncExternalStore` is the React-recommended way to expose values
* from external systems (window, media-queries, etc.) without a
* mirror-into-state effect. The previous useEffect-+-setState version
* tripped React 19's "setState synchronously inside an effect" warning
* — this variant has no effect at all.
*
* On the server we default to false (desktop). The player only mounts
* when a track is playing, which is always after hydration, so the
* initial-paint value never matters for SSR markup.
*/
const MOBILE_QUERY = "(max-width: 767px)";
function subscribeMobile(callback: () => void): () => void {
const mq = window.matchMedia(MOBILE_QUERY);
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
function getMobileSnapshot(): boolean {
return window.matchMedia(MOBILE_QUERY).matches;
}
function getMobileServerSnapshot(): boolean {
return false;
}
function useIsMobile() {
return useSyncExternalStore(
subscribeMobile,
getMobileSnapshot,
getMobileServerSnapshot,
);
}
/**
* Returns [minimized, setMinimized, resetTimer].
* Auto-minimizes after `MINIMIZE_DELAY` ms of inactivity. Call
@@ -460,7 +424,6 @@ export function FloatingPlayer() {
const playerRef = useRef<HTMLDivElement>(null);
const [selectionToolbarVisible, setSelectionToolbarVisible] = useState(false);
const isMobile = useIsMobile();
const { minimized, setMinimized, resetTimer } =
useAutoMinimize(!!currentTrack);
+3
View File
@@ -72,6 +72,9 @@ export function AuthGateProvider({ children }: { children: React.ReactNode }) {
if (user && (open || resolversRef.current.length > 0)) {
const pending = resolversRef.current;
resolversRef.current = [];
// Intentional: react to the user becoming authenticated by closing the
// gate and resolving the queued promises.
// eslint-disable-next-line react-hooks/set-state-in-effect
setOpen(false);
pending.forEach((r) => r(true));
}
@@ -27,6 +27,7 @@ import {
import { toast } from "sonner";
import { useAuth } from "@/components/auth/auth-provider";
import { notifications } from "@/lib/social";
import { sadhna } from "@/lib/sadhna";
type NotificationsContextValue = {
/** Live unread count for the header bell. */
@@ -86,13 +87,26 @@ export function NotificationsProvider({
// Seed the count whenever the signed-in user changes (async — no
// synchronous setState in the effect body).
useEffect(() => {
if (!user) return;
if (!user?.id) return;
refreshUnread();
}, [user?.id, refreshUnread]);
// Open the realtime connection while signed in.
// Report the browser's timezone (once per signed-in session) so sādhana
// reminders fire at the user's local clock time. Best-effort.
useEffect(() => {
if (!user) return;
if (!user?.id) return;
try {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (tz) void sadhna.setTimezone(tz).catch(() => {});
} catch {
/* Intl unavailable — leave the server default (UTC) */
}
}, [user?.id]);
// Open the realtime connection while signed in.
const userId = user?.id;
useEffect(() => {
if (!userId) return;
const key = process.env.NEXT_PUBLIC_SOKETI_KEY;
const host = process.env.NEXT_PUBLIC_SOKETI_HOST;
if (!key || !host) return; // realtime not configured
@@ -100,7 +114,7 @@ export function NotificationsProvider({
const port = Number(process.env.NEXT_PUBLIC_SOKETI_PORT || "443");
const forceTLS =
(process.env.NEXT_PUBLIC_SOKETI_FORCE_TLS ?? "true") !== "false";
const channelName = `notifications.${user.id}`;
const channelName = `notifications.${userId}`;
let cancelled = false;
// Dynamic import keeps pusher-js out of SSR (it touches window) and the
@@ -124,6 +138,16 @@ export function NotificationsProvider({
setLastEventAt(Date.now());
toast(messageFor(data ?? {}), { duration: 4000 });
});
// Sādhana reminders are ephemeral nudges — toast only, no unread bump
// (they aren't persisted to the notification center).
channel.bind(
"reminder",
(data: { title?: string; body?: string }) => {
toast(data?.body || data?.title || "A gentle reminder", {
duration: 6000,
});
},
);
cleanup = () => {
channel.unbind_all();
pusher.unsubscribe(channelName);
@@ -135,7 +159,7 @@ export function NotificationsProvider({
cancelled = true;
cleanup?.();
};
}, [user?.id]);
}, [userId]);
return (
<NotificationsContext.Provider
+119
View File
@@ -0,0 +1,119 @@
"use client";
/**
* PushPrompt — a gentle on-load invitation to enable browser notifications,
* shown to signed-in users who haven't decided yet.
*
* Browsers require a user gesture to request notification permission, so this
* is a *soft* ask: the real `Notification.requestPermission()` only fires
* when the user taps "Enable". We show it when push is supported, the server
* has push configured, permission is still "default", the user isn't already
* subscribed, and they haven't recently dismissed it (7-day cooldown).
*/
import { useEffect, useState } from "react";
import { Bell, X } from "lucide-react";
import { toast } from "sonner";
import { useAuth } from "@/components/auth/auth-provider";
import {
enablePush,
isPushEnabled,
pushConfigured,
pushPermission,
pushSupported,
} from "@/lib/push";
const DISMISS_KEY = "push.prompt.dismissedAt";
const COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000;
export function PushPrompt() {
const { user, loading } = useAuth();
const [show, setShow] = useState(false);
const [busy, setBusy] = useState(false);
const userId = user?.id;
useEffect(() => {
if (loading || !userId) return;
let cancelled = false;
// Async eligibility check — never setState synchronously in the effect.
(async () => {
if (!pushSupported() || pushPermission() !== "default") return;
const dismissed = Number(window.localStorage.getItem(DISMISS_KEY) || 0);
if (dismissed && Date.now() - dismissed < COOLDOWN_MS) return;
if (!(await pushConfigured())) return;
if (await isPushEnabled()) return;
if (!cancelled) setShow(true);
})();
return () => {
cancelled = true;
};
}, [userId, loading]);
function dismiss() {
window.localStorage.setItem(DISMISS_KEY, String(Date.now()));
setShow(false);
}
async function enable() {
setBusy(true);
try {
await enablePush();
toast.success("Notifications enabled 🙏");
setShow(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Couldn't enable notifications.");
// A denial / dismissal shouldn't keep re-prompting.
dismiss();
} finally {
setBusy(false);
}
}
if (!show) return null;
return (
<div className="fixed inset-x-0 bottom-20 z-40 flex justify-center px-4 md:bottom-4">
<div className="flex w-full max-w-md items-start gap-3 rounded-2xl border border-border bg-card p-4 shadow-lg">
<span className="inline-flex size-9 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
<Bell className="size-4.5" />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-foreground">
Stay with your sādhana
</p>
<p className="mt-0.5 text-sm text-muted-foreground leading-snug">
Turn on notifications for daily reminders, follow requests, and
more — even when the app is closed.
</p>
<div className="mt-3 flex items-center gap-2">
<button
type="button"
onClick={enable}
disabled={busy}
className="inline-flex h-9 items-center justify-center rounded-lg bg-primary px-4 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-60"
>
{busy ? "Enabling…" : "Enable"}
</button>
<button
type="button"
onClick={dismiss}
className="inline-flex h-9 items-center justify-center rounded-lg px-3 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
>
Not now
</button>
</div>
</div>
<button
type="button"
onClick={dismiss}
aria-label="Dismiss"
className="-mr-1 -mt-1 inline-flex size-7 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
<X className="size-4" />
</button>
</div>
</div>
);
}
+128
View File
@@ -0,0 +1,128 @@
"use client";
/**
* PushToggle — the settings control for browser push notifications. Reads the
* current state on mount (supported? permission? server-configured?
* subscribed?) and lets the user enable/disable. Renders as a settings row
* matching the rest of /settings.
*/
import { useEffect, useState } from "react";
import { toast } from "sonner";
import {
disablePush,
enablePush,
isPushEnabled,
pushConfigured,
pushPermission,
pushSupported,
} from "@/lib/push";
type State =
| "loading"
| "unsupported"
| "unavailable" // server has no VAPID keys
| "denied" // browser permission blocked
| "on"
| "off";
export function PushToggle() {
const [state, setState] = useState<State>("loading");
const [busy, setBusy] = useState(false);
useEffect(() => {
let cancelled = false;
(async () => {
if (!pushSupported()) return finish("unsupported");
if (pushPermission() === "denied") return finish("denied");
if (!(await pushConfigured())) return finish("unavailable");
finish((await isPushEnabled()) ? "on" : "off");
function finish(s: State) {
if (!cancelled) setState(s);
}
})();
return () => {
cancelled = true;
};
}, []);
async function toggle() {
setBusy(true);
try {
if (state === "on") {
await disablePush();
setState("off");
toast.success("Push notifications turned off");
} else {
await enablePush();
setState("on");
toast.success("Push notifications turned on");
}
} catch (err) {
const msg =
err instanceof Error ? err.message : "Something went wrong.";
// A fresh block surfaces as a permission error — reflect it.
if (pushPermission() === "denied") setState("denied");
toast.error(msg);
} finally {
setBusy(false);
}
}
const description = describe(state);
return (
<ul className="divide-y divide-border">
<li className="flex items-center justify-between gap-4 py-3">
<div className="min-w-0">
<span className="text-sm text-foreground">
Push notifications
</span>
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
</div>
<div className="shrink-0">
{state === "on" || state === "off" ? (
<button
type="button"
onClick={toggle}
disabled={busy}
aria-pressed={state === "on"}
className={[
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:opacity-60",
state === "on" ? "bg-primary" : "bg-muted-foreground/30",
].join(" ")}
>
<span
className={[
"inline-block size-5 transform rounded-full bg-white shadow transition-transform",
state === "on" ? "translate-x-5" : "translate-x-0.5",
].join(" ")}
/>
</button>
) : (
<span className="text-xs text-muted-foreground">
{state === "loading" ? "…" : "Unavailable"}
</span>
)}
</div>
</li>
</ul>
);
}
function describe(state: State): string {
switch (state) {
case "on":
return "On — you'll be notified about follow requests and more, even when the app is closed.";
case "off":
return "Get notified about follow requests and more, even when the app is closed.";
case "denied":
return "Blocked in your browser. Enable notifications for this site in your browser settings, then reload.";
case "unsupported":
return "This browser doesn't support push notifications.";
case "unavailable":
return "Push notifications aren't available right now.";
default:
return "Checking…";
}
}
+85
View File
@@ -0,0 +1,85 @@
"use client";
/**
* Confetti — a lightweight, dependency-free confetti shower for the reward
* celebration. Spawns a fixed set of colored pieces that fall with a little
* horizontal drift and spin, then fade. Pointer-events-none so it never
* blocks the dialog beneath it. Honors prefers-reduced-motion.
*/
import { useMemo } from "react";
const COLORS = [
"#f59e0b",
"#fb7185",
"#34d399",
"#60a5fa",
"#fbbf24",
"#f472b6",
"#a78bfa",
"#facc15",
];
// Deterministic pseudo-random in [0,1) from a seed. Math.sin is pure, so this
// keeps the confetti layout stable across renders (no Math.random impurity).
function rand(seed: number): number {
const x = Math.sin(seed * 127.1) * 43758.5453;
return x - Math.floor(x);
}
export function Confetti({ pieces = 140 }: { pieces?: number }) {
// Computed once per mount; varying by index keeps it deterministic-ish
// without Date/Math.random concerns (this is a normal component).
const items = useMemo(
() =>
Array.from({ length: pieces }, (_, i) => {
const r = (k: number) => rand(i * 9.17 + k * 3.13);
return {
left: r(1) * 100,
delay: r(2) * 0.5,
dur: 2.4 + r(3) * 1.8,
rot: r(4) * 360,
drift: (r(5) * 2 - 1) * 70,
size: 6 + r(6) * 7,
color: COLORS[i % COLORS.length],
round: r(7) < 0.3,
};
}),
[pieces],
);
return (
<div
className="pointer-events-none absolute inset-0 overflow-hidden"
aria-hidden
>
<style>{`
@keyframes sadhna-confetti {
0% { transform: translate(0,-10%) rotateZ(0deg); opacity: 1; }
85% { opacity: 1; }
100% { transform: translate(var(--drift), 115vh) rotateZ(720deg); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.sadhna-confetti-piece { display: none; }
}
`}</style>
{items.map((p, i) => (
<span
key={i}
className="sadhna-confetti-piece absolute top-0 block"
style={{
left: `${p.left}%`,
width: p.size,
height: p.size * (p.round ? 1 : 0.5),
background: p.color,
borderRadius: p.round ? "9999px" : "1px",
transform: `rotateZ(${p.rot}deg)`,
// CSS var consumed by the keyframe's final transform.
["--drift" as string]: `${p.drift}px`,
animation: `sadhna-confetti ${p.dur}s cubic-bezier(0.2,0.6,0.4,1) ${p.delay}s forwards`,
}}
/>
))}
</div>
);
}
+104
View File
@@ -0,0 +1,104 @@
"use client";
/**
* ReminderToggle — the settings control for daily sādhana reminders (email +
* push + websocket). Reads the current preference and lets the user opt out.
* Renders as a settings row matching the rest of /settings.
*/
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { sadhna } from "@/lib/sadhna";
import {
enablePush,
isPushEnabled,
pushPermission,
pushSupported,
} from "@/lib/push";
export function ReminderToggle() {
const [enabled, setEnabled] = useState<boolean | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
let cancelled = false;
sadhna
.reminderPrefs()
.then((r) => {
if (!cancelled) setEnabled(r.enabled);
})
.catch(() => {
if (!cancelled) setEnabled(true); // default-on; toggle still works
});
return () => {
cancelled = true;
};
}, []);
async function toggle() {
if (enabled === null) return;
const next = !enabled;
setBusy(true);
try {
// Turning reminders ON also offers push permission on this device, so
// the reminder can actually reach a closed app. Best-effort: if the
// user declines or push isn't available, reminders still go by email
// and in-app. Done before saving the pref so the permission request
// stays close to the toggle gesture.
if (
next &&
pushSupported() &&
pushPermission() !== "denied" &&
!(await isPushEnabled())
) {
await enablePush().catch(() => {
/* declined / unavailable — reminders still work elsewhere */
});
}
await sadhna.setReminderPrefs(next);
setEnabled(next);
toast.success(next ? "Reminders turned on" : "Reminders turned off");
} catch {
toast.error("Could not update reminders");
} finally {
setBusy(false);
}
}
return (
<ul className="divide-y divide-border">
<li className="flex items-center justify-between gap-4 py-3">
<div className="min-w-0">
<span className="text-sm text-foreground">Sādhana reminders</span>
<p className="text-xs text-muted-foreground mt-0.5">
Daily nudges to chant your rounds and share a verse — by email,
push, and in-app.
</p>
</div>
<div className="shrink-0">
{enabled === null ? (
<span className="text-xs text-muted-foreground">…</span>
) : (
<button
type="button"
onClick={toggle}
disabled={busy}
aria-pressed={enabled}
className={[
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:opacity-60",
enabled ? "bg-primary" : "bg-muted-foreground/30",
].join(" ")}
>
<span
className={[
"inline-block size-5 transform rounded-full bg-white shadow transition-transform",
enabled ? "translate-x-5" : "translate-x-0.5",
].join(" ")}
/>
</button>
)}
</div>
</li>
</ul>
);
}
+102
View File
@@ -0,0 +1,102 @@
"use client";
/**
* RewardCelebration — a full-screen confetti celebration shown when the
* devotee earns a streak gift. Centered card with the reward image, its
* cosmic name, and symbolic meaning. Dismissing it acknowledges the gift
* (via the caller) so it surfaces exactly once.
*
* Scrolls internally on small screens (max-h + overflow) and reads in both
* themes (card/foreground tokens). Esc or backdrop-click closes.
*/
import { useEffect } from "react";
import { createPortal } from "react-dom";
import Image from "next/image";
import { motion } from "framer-motion";
import { Confetti } from "./confetti";
import type { SadhnaRewardGrant } from "@/lib/sadhna";
import { useMounted } from "@/lib/use-mounted";
export function RewardCelebration({
grant,
onClose,
}: {
grant: SadhnaRewardGrant;
onClose: () => void;
}) {
const mounted = useMounted();
// Esc to close + lock background scroll while open.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = prev;
};
}, [onClose]);
if (!mounted) return null;
const r = grant.reward;
return createPortal(
<div className="fixed inset-0 z-[120] flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
<Confetti />
<motion.div
initial={{ scale: 0.85, opacity: 0, y: 16 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 220, damping: 20 }}
className="relative z-10 w-full max-w-md max-h-[90vh] overflow-y-auto rounded-3xl border border-border bg-card p-6 text-center shadow-2xl"
>
<p className="text-xs font-semibold uppercase tracking-wider text-primary">
A gift for your sādhana
</p>
<h2 className="mt-1 font-(family-name:--font-title) text-2xl font-semibold text-foreground">
You&apos;ve earned a reward!
</h2>
<p className="mt-1 text-sm text-muted-foreground">
For {grant.milestone} days of unbroken sādhana 🙏
</p>
<div className="relative mx-auto my-6 flex size-48 items-center justify-center">
<div className="absolute inset-0 rounded-full bg-primary/15 blur-2xl" />
<Image
src={r.image_url}
alt={r.name}
width={256}
height={256}
sizes="256px"
className="relative size-full object-contain drop-shadow-xl"
/>
</div>
<h3 className="font-(family-name:--font-title) text-xl font-semibold text-foreground">
{r.name}
</h3>
{r.meaning ? (
<p className="mx-auto mt-2 max-w-sm text-[15px] leading-relaxed text-muted-foreground">
{r.meaning}
</p>
) : null}
<button
type="button"
onClick={onClose}
className="mt-6 inline-flex w-full items-center justify-center rounded-xl bg-primary px-4 py-2.5 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
>
Hari bol! 🌸
</button>
</motion.div>
</div>,
document.body,
);
}
+177
View File
@@ -0,0 +1,177 @@
"use client";
/**
* RewardsGallery — the collected-gifts section on /sadhna-streak. Shows each
* unique reward the devotee has earned as a tappable tile (image + short
* name); tapping opens a detail dialog with the full name + symbolic
* meaning + which streak day it was earned. Remaining catalog slots show as
* locked tiles to entice completion.
*
* Built to read in both themes (card/foreground tokens), keep names legible
* (line-clamped, never overflowing), and resize the large R2 images via
* next/image.
*/
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import Image from "next/image";
import { Gift } from "lucide-react";
import type { SadhnaRewardGrant } from "@/lib/sadhna";
import { useMounted } from "@/lib/use-mounted";
export function RewardsGallery({
collected,
nextRewardDate,
}: {
collected: SadhnaRewardGrant[];
nextRewardDate: string | null;
}) {
const [detail, setDetail] = useState<SadhnaRewardGrant | null>(null);
// De-dupe by reward (collected is newest-first, so the first wins).
const seen = new Set<string>();
const unique: SadhnaRewardGrant[] = [];
for (const g of collected) {
if (!seen.has(g.reward.key)) {
seen.add(g.reward.key);
unique.push(g);
}
}
return (
<section className="rounded-2xl border border-border bg-card p-4 sm:p-5">
<div className="mb-3 flex items-center gap-2">
<Gift className="size-5 text-primary" />
<h2 className="text-base font-semibold text-foreground">
Sacred Rewards
</h2>
</div>
{unique.length === 0 ? (
<div className="flex flex-col items-center gap-2 rounded-xl border border-dashed border-border bg-muted/20 px-4 py-8 text-center">
<Gift className="size-8 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">
No gifts yet. Keep your sādhana streak — a sacred reward arrives
every 7 days.
</p>
</div>
) : (
<div className="grid grid-cols-3 gap-2.5 sm:grid-cols-4">
{unique.map((g) => (
<button
key={g.reward.key}
type="button"
onClick={() => setDetail(g)}
className="group flex flex-col items-center gap-1.5 rounded-xl border border-border bg-background p-2 text-center transition-colors hover:border-primary/40 hover:bg-muted/40"
>
<div className="relative aspect-square w-full">
<Image
src={g.reward.image_url}
alt={g.reward.name}
fill
sizes="(max-width: 640px) 30vw, 120px"
className="object-contain p-1"
/>
</div>
<span className="line-clamp-2 text-[13px] font-medium leading-tight text-foreground">
{shortName(g.reward.name)}
</span>
</button>
))}
</div>
)}
{nextRewardDate ? (
<p className="mt-3.5 text-center text-xs text-muted-foreground">
Next gift on{" "}
<span className="font-medium text-foreground">
{fmtLong(nextRewardDate)}
</span>
</p>
) : null}
{detail ? (
<RewardDetail grant={detail} onClose={() => setDetail(null)} />
) : null}
</section>
);
}
function RewardDetail({
grant,
onClose,
}: {
grant: SadhnaRewardGrant;
onClose: () => void;
}) {
const mounted = useMounted();
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
window.addEventListener("keydown", onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = prev;
};
}, [onClose]);
if (!mounted) return null;
const r = grant.reward;
return createPortal(
<div className="fixed inset-0 z-[120] flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
<div className="relative z-10 w-full max-w-md max-h-[90vh] overflow-y-auto rounded-3xl border border-border bg-card p-6 text-center shadow-2xl">
<div className="relative mx-auto mb-4 flex size-40 items-center justify-center">
<div className="absolute inset-0 rounded-full bg-primary/10 blur-2xl" />
<Image
src={r.image_url}
alt={r.name}
width={224}
height={224}
sizes="224px"
className="relative size-full object-contain drop-shadow-lg"
/>
</div>
<h3 className="font-(family-name:--font-title) text-xl font-semibold text-foreground">
{r.name}
</h3>
{r.meaning ? (
<p className="mx-auto mt-2 max-w-sm text-[15px] leading-relaxed text-muted-foreground">
{r.meaning}
</p>
) : null}
<p className="mt-4 text-xs text-muted-foreground">
Earned on day {grant.milestone} of your streak
</p>
<button
type="button"
onClick={onClose}
className="mt-5 inline-flex w-full items-center justify-center rounded-xl border border-border px-4 py-2.5 text-sm font-semibold text-foreground transition-colors hover:bg-muted"
>
Close
</button>
</div>
</div>,
document.body,
);
}
/** The evocative term before the em-dash, for compact tile labels. */
function shortName(name: string): string {
const parts = name.split("—");
return (parts[0] ?? name).trim();
}
function fmtLong(date: string): string {
const d = new Date(`${date}T00:00:00Z`);
if (Number.isNaN(d.getTime())) return "";
return d.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
timeZone: "UTC",
});
}
+361
View File
@@ -0,0 +1,361 @@
"use client";
/**
* StreakCalendar — the sādhana streak calendar with week/month views.
*
* Shows the current/best streak and a grid of days; a lotus blooms beneath
* every day the sādhana was completed (both disciplines). The user can
* switch between a single-week view and a full-month grid, and page
* backward/forward through time. "Next" stops at the current period — there
* is nothing to show in the future.
*
* Streak counters come from the dashboard payload; the grid itself is fed by
* the /sadhna/history range endpoint, fetched per visible period and cached
* so paging back to a period you've seen is instant. Everything is bucketed
* by UTC calendar day to match the backend.
*/
import { useEffect, useMemo, useRef, useState } from "react";
import { Check, ChevronLeft, ChevronRight, Gift } from "lucide-react";
import { sadhna, type SadhnaDashboard, type SadhnaDay } from "@/lib/sadhna";
import { StreakFlame } from "@/components/sadhna/streak-flame";
import { cn } from "@/lib/utils";
const DAY = 86400000;
const WEEKDAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
type View = "week" | "month";
// ── UTC date helpers (operate on epoch-ms at UTC midnight) ──
function ymd(ms: number): string {
return new Date(ms).toISOString().slice(0, 10);
}
function startOfDayUTC(d: Date): number {
return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
}
function startOfWeek(ms: number): number {
return ms - new Date(ms).getUTCDay() * DAY;
}
export function StreakCalendar({ dash }: { dash: SadhnaDashboard | null }) {
const todayMs = startOfDayUTC(new Date());
const todayStr = ymd(todayMs);
const [view, setView] = useState<View>("week");
// A day inside the visible period. Starts on today.
const [anchorMs, setAnchorMs] = useState(todayMs);
// Visible grid range + metadata for the chosen view.
const grid = useMemo(() => {
if (view === "week") {
const start = startOfWeek(anchorMs);
return { start, weeks: 1, monthIndex: -1 };
}
const d = new Date(anchorMs);
const monthStart = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1);
const monthEnd = Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0);
const start = startOfWeek(monthStart);
const end = startOfWeek(monthEnd) + 6 * DAY;
const weeks = Math.round((end - start) / (7 * DAY)) + 1;
return { start, weeks, monthIndex: d.getUTCMonth() };
}, [view, anchorMs]);
const fromStr = ymd(grid.start);
const toStr = ymd(grid.start + (grid.weeks * 7 - 1) * DAY);
// Fetch the visible range, cached by range key; race-safe.
const cacheRef = useRef<Map<string, Map<string, SadhnaDay>>>(new Map());
const reqRef = useRef(0);
const [byDate, setByDate] = useState<Map<string, SadhnaDay>>(new Map());
const [loading, setLoading] = useState(false);
useEffect(() => {
const key = `${fromStr}_${toStr}`;
const cached = cacheRef.current.get(key);
if (cached) {
setByDate(cached);
return;
}
const token = ++reqRef.current;
setLoading(true);
sadhna
.history(fromStr, toStr)
.then((days) => {
const m = new Map((days ?? []).map((d) => [d.date, d] as const));
cacheRef.current.set(key, m);
if (reqRef.current === token) setByDate(m);
})
.catch(() => {
/* keep whatever's shown */
})
.finally(() => {
if (reqRef.current === token) setLoading(false);
});
}, [fromStr, toStr]);
// The dashboard's trailing-7-day `week` is the freshest authoritative
// source for recent days (it reflects a completion the moment dash
// reloads on mount/focus). Overlay it on top of the fetched history so the
// current week's cells — especially today — always agree with the streak
// counter, no matter how the history fetch raced.
const dashByDate = useMemo(
() => new Map((dash?.week ?? []).map((d) => [d.date, d] as const)),
[dash],
);
const dayFor = (date: string) => dashByDate.get(date) ?? byDate.get(date);
// Navigation bounds: never page past the current period.
const currentPeriodStart =
view === "week"
? startOfWeek(todayMs)
: Date.UTC(new Date(todayMs).getUTCFullYear(), new Date(todayMs).getUTCMonth(), 1);
const visiblePeriodStart =
view === "week"
? grid.start
: Date.UTC(new Date(anchorMs).getUTCFullYear(), new Date(anchorMs).getUTCMonth(), 1);
const atCurrent = visiblePeriodStart >= currentPeriodStart;
const shift = (dir: -1 | 1) => {
if (view === "week") {
setAnchorMs((ms) => ms + dir * 7 * DAY);
} else {
const d = new Date(anchorMs);
setAnchorMs(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + dir, 1));
}
};
const streak = dash?.current_streak ?? 0;
const longest = dash?.longest_streak ?? 0;
const giftDate = dash?.next_reward_date ?? null;
const cells = Array.from({ length: grid.weeks * 7 }, (_, i) => grid.start + i * DAY);
return (
<section className="rounded-2xl border border-border bg-card p-4 sm:p-5">
{/* Streak + view toggle */}
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2.5">
<span
className={cn(
"inline-flex size-10 items-center justify-center rounded-full",
streak > 0 ? "bg-orange-500/15" : "bg-muted",
)}
>
<StreakFlame className="size-6" muted={streak === 0} />
</span>
<div className="leading-tight">
<p className="text-lg font-semibold text-foreground tabular-nums">
{streak} day{streak === 1 ? "" : "s"}
</p>
<p className="text-xs text-muted-foreground">
{streak > 0 ? "current streak" : "start your streak today"}
{longest > 0 ? ` · best ${longest}` : ""}
</p>
</div>
</div>
<div className="inline-flex rounded-lg border border-border p-0.5 text-xs">
{(["week", "month"] as View[]).map((v) => (
<button
key={v}
type="button"
onClick={() => setView(v)}
className={cn(
"rounded-md px-2.5 py-1 capitalize transition-colors",
view === v
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{v}
</button>
))}
</div>
</div>
{/* Period nav */}
<div className="mb-3 flex items-center justify-between">
<NavButton label="Previous" onClick={() => shift(-1)}>
<ChevronLeft className="size-4" />
</NavButton>
<div className="flex items-center gap-2">
<span
className={cn(
"text-sm font-medium text-foreground",
loading && "opacity-60",
)}
>
{view === "week"
? weekRangeLabel(grid.start)
: monthLabel(anchorMs)}
</span>
{!atCurrent ? (
<button
type="button"
onClick={() => setAnchorMs(todayMs)}
className="rounded-full border border-border px-2 py-0.5 text-[11px] text-muted-foreground transition-colors hover:text-foreground"
>
Today
</button>
) : null}
</div>
<NavButton label="Next" onClick={() => shift(1)} disabled={atCurrent}>
<ChevronRight className="size-4" />
</NavButton>
</div>
{/* Weekday header (month view) */}
{view === "month" ? (
<div className="mb-1 grid grid-cols-7 gap-1">
{WEEKDAYS.map((w) => (
<span
key={w}
className="text-center text-[11px] leading-none text-muted-foreground"
>
{w}
</span>
))}
</div>
) : null}
<div className={cn("grid grid-cols-7 gap-1", loading && "opacity-70")}>
{cells.map((ms) => {
const date = ymd(ms);
return (
<DayCell
key={date}
date={date}
day={dayFor(date)}
view={view}
isToday={date === todayStr}
isFuture={ms > todayMs}
isGift={!!giftDate && date === giftDate}
inMonth={view === "week" || new Date(ms).getUTCMonth() === grid.monthIndex}
/>
);
})}
</div>
</section>
);
}
function NavButton({
label,
onClick,
disabled,
children,
}: {
label: string;
onClick: () => void;
disabled?: boolean;
children: React.ReactNode;
}) {
return (
<button
type="button"
aria-label={label}
onClick={onClick}
disabled={disabled}
className={cn(
"inline-flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",
"disabled:pointer-events-none disabled:opacity-30",
)}
>
{children}
</button>
);
}
function DayCell({
date,
day,
view,
isToday,
isFuture,
isGift,
inMonth,
}: {
date: string;
day: SadhnaDay | undefined;
view: View;
isToday: boolean;
isFuture: boolean;
isGift: boolean;
inMonth: boolean;
}) {
const completed = !!day?.completed;
const partial = !!day && !completed && (day.chant_done || day.quote_done);
const isWeek = view === "week";
return (
<div
className={cn(
"flex flex-col items-center gap-1.5",
!inMonth && "opacity-35",
)}
>
{isWeek ? (
<span
className={cn(
"text-[11px] leading-none",
isToday ? "font-semibold text-primary" : "text-muted-foreground",
)}
>
{WEEKDAYS[new Date(`${date}T00:00:00Z`).getUTCDay()]}
</span>
) : null}
<div
className={cn(
"flex items-center justify-center rounded-full font-medium transition-colors",
isWeek ? "size-9 text-sm sm:size-10" : "size-8 text-xs",
completed && "bg-primary text-primary-foreground",
!completed && partial && "bg-primary/15 text-foreground ring-1 ring-primary/40",
!completed && !partial && isToday && "text-foreground ring-2 ring-primary",
!completed && !partial && !isToday && isFuture && "text-muted-foreground/40",
!completed && !partial && !isToday && !isFuture && "bg-muted text-muted-foreground",
)}
>
{dayOfMonth(date)}
</div>
{/* Marker slot — fixed height keeps every column's baseline aligned. */}
<div
className={cn(
"flex items-center justify-center",
isWeek ? "h-5" : "h-3.5",
)}
>
{completed ? (
<Check
className={cn("text-primary", isWeek ? "size-4" : "size-3")}
strokeWidth={3}
/>
) : isGift ? (
<Gift
className={cn("text-amber-500", isWeek ? "size-4" : "size-3")}
/>
) : null}
</div>
</div>
);
}
// ── labels ──
function dayOfMonth(date: string): number | string {
const d = new Date(`${date}T00:00:00Z`);
return Number.isNaN(d.getTime()) ? "" : d.getUTCDate();
}
function monthLabel(ms: number): string {
return new Date(ms).toLocaleDateString(undefined, {
month: "long",
year: "numeric",
timeZone: "UTC",
});
}
function weekRangeLabel(startMs: number): string {
const f = new Date(startMs);
const l = new Date(startMs + 6 * DAY);
const fm = f.toLocaleDateString(undefined, { month: "short", timeZone: "UTC" });
const lm = l.toLocaleDateString(undefined, { month: "short", timeZone: "UTC" });
return fm === lm
? `${fm} ${f.getUTCDate()} – ${l.getUTCDate()}`
: `${fm} ${f.getUTCDate()} – ${lm} ${l.getUTCDate()}`;
}
+82
View File
@@ -0,0 +1,82 @@
"use client";
/**
* SadhnaStreakCTA — a compact streak card for the homepage. Tapping it opens
* /sadhna-streak. For a signed-in devotee it shows their live streak and
* today's status; for visitors it's a gentle invitation to begin. The feed
* will sit alongside this later.
*/
import { useEffect, useState } from "react";
import Link from "next/link";
import { ChevronRight } from "lucide-react";
import { useAuth } from "@/components/auth/auth-provider";
import { sadhna, type SadhnaDashboard } from "@/lib/sadhna";
import { StreakFlame } from "@/components/sadhna/streak-flame";
export function SadhnaStreakCTA() {
const { user, loading } = useAuth();
const authed = !loading && !!user;
const [dash, setDash] = useState<SadhnaDashboard | null>(null);
useEffect(() => {
// Only fetch when signed in; never setState synchronously here. A stale
// dash from a previous session is ignored in render via `data` below.
if (!authed) return;
let cancelled = false;
sadhna
.dashboard()
.then((d) => {
if (!cancelled) setDash(d);
})
.catch(() => {
/* card stays in its invitation state */
});
return () => {
cancelled = true;
};
}, [authed]);
// Ignore any leftover dashboard when not authed (e.g. after sign-out).
const data = authed ? dash : null;
const streak = data?.current_streak ?? 0;
const title = !authed
? "Daily Sādhana"
: streak > 0
? `${streak} day${streak === 1 ? "" : "s"} sadhna streak`
: "Start your streak";
const subtitle = !authed
? "Chant your rounds and share a verse each day"
: data?.today_completed
? "Today's sādhana is complete 🌸"
: data?.chant_done || data?.quote_done
? "One offering done — finish today"
: "Chant and post a quote to complete today";
return (
<Link
href="/sadhna-streak"
className="group flex w-full max-w-md items-center gap-3.5 rounded-2xl border border-border bg-card p-4 text-left transition-colors hover:border-primary/40 hover:bg-muted/40"
>
<span
className={
"inline-flex size-12 shrink-0 items-center justify-center rounded-full " +
(streak > 0 ? "bg-orange-500/15" : "bg-muted")
}
>
<StreakFlame className="size-7" muted={streak === 0} />
</span>
<div className="min-w-0 flex-1">
<p className="text-base font-semibold text-foreground leading-snug">
{title}
</p>
<p className="truncate text-sm text-muted-foreground leading-snug">
{subtitle}
</p>
</div>
<ChevronRight className="size-5 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-foreground" />
</Link>
);
}
+41
View File
@@ -0,0 +1,41 @@
/**
* StreakFlame — a vivid two-tone flame for the sādhana streak badge: a warm
* orange body with an inner gold flame. Pass `muted` for a zero / broken
* streak (greyed out). Size it with a height/size class.
*/
export function StreakFlame({
className,
muted = false,
}: {
className?: string;
muted?: boolean;
}) {
const outer = muted
? ["#c2c6cd", "#9aa0a8", "#868c95"]
: ["#ff9d2e", "#ff5722", "#e23a0e"];
const inner = muted ? ["#dde0e4", "#bcc0c6"] : ["#ffe08a", "#ffb01f"];
// One flame renders at a time, so fixed gradient ids are fine.
return (
<svg viewBox="0 0 24 24" className={className} role="img" aria-label="Streak">
<defs>
<linearGradient id="streak-flame-outer" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor={outer[0]} />
<stop offset="0.55" stopColor={outer[1]} />
<stop offset="1" stopColor={outer[2]} />
</linearGradient>
<linearGradient id="streak-flame-inner" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor={inner[0]} />
<stop offset="1" stopColor={inner[1]} />
</linearGradient>
</defs>
<path
fill="url(#streak-flame-outer)"
d="M13.6 1.8c0.2 2.6 2 4.1 3.4 5.9c1.5 1.9 2.5 4 2.5 6.3c0 4.2-3.4 7.2-7.5 7.2c-4.1 0-7.5-3-7.5-7.2c0-2.1 0.9-3.9 2.3-5.3c0.1 1 0.6 1.9 1.4 2.5c-0.4-2.6 0.5-5.2 2.3-7c1-1 2.3-1.8 3.1-3.1c0.2-0.4 0.4-0.9 0.5-1.3z"
/>
<path
fill="url(#streak-flame-inner)"
d="M12.4 9.6c0.7 1 1.9 1.9 2.6 3c0.6 0.9 0.9 1.9 0.9 2.9c0 2.2-1.8 3.8-3.9 3.8c-2.1 0-3.7-1.6-3.7-3.6c0-1.6 0.9-2.7 1.9-3.7c0.1 0.6 0.4 1.1 0.9 1.5c-0.2-1.4 0.2-2.6 1-3.4c0.2-0.2 0.3-0.3 0.4-0.5z"
/>
</svg>
);
}
+1 -1
View File
@@ -16,7 +16,7 @@
import Image from "next/image";
import Link from "next/link";
import { Globe, Lock } from "lucide-react";
import { Lock } from "lucide-react";
import { cn } from "@/lib/utils";
import type { Collection } from "@/lib/social";
import { quoteCardBgStyle } from "./post-card-shared";
+2 -1
View File
@@ -155,9 +155,10 @@ export function PageBookmarks() {
// Belt-and-braces: when this component unmounts (route change), tear
// down every mark so the next page starts clean.
useEffect(() => {
const appliedIds = appliedIdsRef.current;
return () => {
removeBookmarkMarks();
appliedIdsRef.current.clear();
appliedIds.clear();
};
}, []);
@@ -218,8 +218,8 @@ export function UpdateBackgroundModal({
Update Background
</h2>
<p className="mt-1 text-sm text-muted-foreground leading-relaxed">
Pick an image to set as the card's background, or remove the
current one.
Pick an image to set as the card&apos;s background, or remove
the current one.
</p>
</div>
<button
+3 -4
View File
@@ -54,10 +54,9 @@ export function TextSelectionToolbar() {
// after saving the user's last allowed bookmark.
const [bookmarkCount, setBookmarkCount] = useState(0);
useEffect(() => {
if (!user) {
setBookmarkCount(0);
return;
}
// `limitReached` below already guards on `!!user`, so a stale count
// while signed out is harmless — no need to reset it here.
if (!user) return;
let cancelled = false;
const refetch = () => {
bookmarks