wip: sadhna streak

This commit is contained in:
gosangam
2026-06-07 22:32:34 +05:30
parent d5fb350652
commit ecb32d2e85
6 changed files with 1560 additions and 0 deletions
+330
View File
@@ -0,0 +1,330 @@
"use client";
/**
* /sadhna-streak — the devotee's daily sādhana home.
*
* • a thin 7-day streak strip across the top
* • two daily disciplines as action cards: finish today's chanting, and
* post at least one quote from the Vāṇī — each flips to a "done" state
* once complete
* • a plant that grows from a seedling toward a tree as the streak holds,
* and withers back through its stages (eventually dying) when it breaks
*
* Auth-gated like /notifications and /u/me. Re-fetches on focus so a quote
* posted elsewhere (posting is selection-driven while reading) reflects
* here when the devotee returns.
*/
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { BookOpenText, Check, Flame, BadgePlus } from "lucide-react";
import { useAuth } from "@/components/auth/auth-provider";
import { useAuthGate } from "@/components/auth/auth-gate";
import { sadhna, type SadhnaDashboard, type SadhnaDay } from "@/lib/sadhna";
import { SadhnaVine } from "@/components/sadhna/sadhna-vine";
import { cn } from "@/lib/utils";
export default function SadhnaStreakPage() {
const { user, loading } = useAuth();
const { requireAuth } = useAuthGate();
const router = useRouter();
const [dash, setDash] = useState<SadhnaDashboard | null>(null);
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
// Auth gate — same pattern as /notifications.
useEffect(() => {
if (loading || user) return;
let cancelled = false;
(async () => {
const ok = await requireAuth();
if (!ok && !cancelled) router.replace("/");
})();
return () => {
cancelled = true;
};
}, [loading, user, requireAuth, router]);
const load = useCallback(() => {
sadhna
.dashboard()
.then((d) => {
setDash(d);
setState("ready");
})
.catch(() => setState("error"));
}, []);
// Load once we have a user, and again whenever the tab regains focus.
useEffect(() => {
if (!user) return;
load();
const onFocus = () => load();
window.addEventListener("focus", onFocus);
return () => window.removeEventListener("focus", onFocus);
}, [user, load]);
if (loading || !user) {
return (
<main className="flex-1 flex items-center justify-center px-4 py-12">
<p className="text-sm text-muted-foreground">
{loading ? "Loading…" : "Sign in to track your sādhana."}
</p>
</main>
);
}
return (
<main className="flex-1 w-full max-w-2xl mx-auto px-4 sm:px-6 py-5 flex flex-col gap-4">
{/* Streak strip */}
<StreakStrip dash={dash} loading={state === "loading"} />
<header className="text-center space-y-0.5">
<h1 className="font-[family-name:var(--font-title)] text-2xl sm:text-3xl font-semibold text-foreground">
Daily Sādhana
</h1>
<p className="text-sm text-muted-foreground">{vineCaption(dash)}</p>
</header>
{/* Action items */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<ChantAction dash={dash} />
<QuoteAction dash={dash} />
</div>
{/* The creeper — climbs the pole, one fold per day of the streak */}
<VinePanel dash={dash} error={state === "error"} />
</main>
);
}
// ─── Streak strip ────────────────────────────────────────────────────────
function StreakStrip({
dash,
loading,
}: {
dash: SadhnaDashboard | null;
loading: boolean;
}) {
const week = dash?.week ?? placeholderWeek();
const streak = dash?.current_streak ?? 0;
return (
<div className="flex items-center justify-between gap-3 rounded-full border border-border bg-card/60 px-4 py-2">
<div className="flex items-center gap-1.5 shrink-0">
<Flame
className={cn(
"size-4",
streak > 0 ? "text-orange-500" : "text-muted-foreground",
)}
/>
<span className="text-sm font-semibold text-foreground tabular-nums">
{streak}
</span>
<span className="text-xs text-muted-foreground hidden sm:inline">
day{streak === 1 ? "" : "s"}
</span>
</div>
<div className={cn("flex items-center gap-2", loading && "opacity-40")}>
{week.map((day, i) => (
<DayDot key={day.date} day={day} isToday={i === week.length - 1} />
))}
</div>
</div>
);
}
function DayDot({ day, isToday }: { day: SadhnaDay; isToday: boolean }) {
const partial = !day.completed && (day.chant_done || day.quote_done);
return (
<div className="flex flex-col items-center gap-1">
<div
className={cn(
"size-5 rounded-full flex items-center justify-center transition-colors",
day.completed
? "bg-primary text-primary-foreground"
: partial
? "bg-primary/20 ring-1 ring-primary/50"
: "bg-muted",
isToday && !day.completed && "ring-2 ring-primary/60",
)}
>
{day.completed ? <Check className="size-3" strokeWidth={3} /> : null}
</div>
<span className="text-[10px] leading-none text-muted-foreground">
{weekdayLetter(day.date)}
</span>
</div>
);
}
// ─── Action cards ──────────────────────────────────────────────────────
function ChantAction({ dash }: { dash: SadhnaDashboard | null }) {
const done = dash?.chant_done ?? false;
const target = dash?.target_rounds ?? 16;
const rounds = dash?.rounds_completed ?? 0;
return (
<ActionCard
href="/sadhna-streak/chant"
icon={<BadgePlus className="size-5" />}
title="Finish today's chanting"
subtitle={
done
? `${rounds} rounds chanted`
: `${rounds} / ${target} rounds`
}
done={done}
cta={rounds > 0 ? "Continue" : "Begin"}
/>
);
}
function QuoteAction({ dash }: { dash: SadhnaDashboard | null }) {
const done = dash?.quote_done ?? false;
return (
<ActionCard
href="/books"
icon={<BookOpenText className="size-5" />}
title="Post a quote from the Vāṇī"
subtitle={
done
? "Shared today — thank you"
: "Select a passage while reading, then Share to Feed"
}
done={done}
cta="Find a verse"
/>
);
}
function ActionCard({
href,
icon,
title,
subtitle,
done,
cta,
}: {
href: string;
icon: React.ReactNode;
title: string;
subtitle: string;
done: boolean;
cta: string;
}) {
return (
<Link
href={href}
className={cn(
"group relative flex flex-col gap-2 rounded-2xl border p-4 transition-colors",
done
? "border-primary/40 bg-primary/5"
: "border-border bg-card hover:border-primary/40 hover:bg-muted/40",
)}
>
<div className="flex items-center justify-between">
<span
className={cn(
"inline-flex size-9 items-center justify-center rounded-xl",
done
? "bg-primary text-primary-foreground"
: "bg-primary/10 text-primary",
)}
>
{done ? <Check className="size-5" strokeWidth={3} /> : icon}
</span>
{done ? (
<span className="text-xs font-medium text-primary">Done</span>
) : (
<span className="text-xs font-medium text-muted-foreground group-hover:text-foreground">
{cta} →
</span>
)}
</div>
<div className="space-y-0.5">
<p className="text-sm font-semibold text-foreground leading-snug">
{title}
</p>
<p className="text-xs text-muted-foreground leading-snug">{subtitle}</p>
</div>
</Link>
);
}
// ─── Vine panel ───────────────────────────────────────────────────────
function VinePanel({
dash,
error,
}: {
dash: SadhnaDashboard | null;
error: boolean;
}) {
return (
<section className="relative h-[68svh] min-h-[420px] rounded-2xl border border-border bg-gradient-to-b from-sky-50/50 to-emerald-50/30 dark:from-sky-950/20 dark:to-emerald-950/10 overflow-hidden">
{error ? (
<div className="absolute inset-0 flex items-center justify-center px-4">
<p className="text-sm text-destructive text-center">
Could not load your sādhana. Please try again.
</p>
</div>
) : dash ? (
<SadhnaVine
days={dash.vine_days}
targetRounds={dash.target_rounds}
growth={dash.growth}
alive={dash.alive}
totalStreak={dash.current_streak}
/>
) : (
<div className="absolute inset-0 flex items-center justify-center">
<p className="text-sm text-muted-foreground">Loading your vine…</p>
</div>
)}
</section>
);
}
// ─── Copy + helpers ────────────────────────────────────────────────────
function vineCaption(dash: SadhnaDashboard | null): string {
if (!dash) return "Two simple offerings each day. Keep them up and watch it climb.";
if (dash.current_streak === 0 && !dash.alive) {
return "Begin today, and a new vine starts climbing the pole.";
}
if (dash.today_completed) {
return "Today's fold is complete. The vine climbs higher.";
}
if (dash.chant_done || dash.quote_done) {
return "One offering done — finish the other to complete today's fold.";
}
return "Each day's sādhana winds another fold around the pole.";
}
/** Single-letter weekday (UTC, matching the backend's day buckets). */
function weekdayLetter(date: string): string {
const d = new Date(`${date}T00:00:00Z`);
if (Number.isNaN(d.getTime())) return "";
return ["S", "M", "T", "W", "T", "F", "S"][d.getUTCDay()];
}
/** A blank 7-day strip for the initial render before data arrives. */
function placeholderWeek(): SadhnaDay[] {
const out: SadhnaDay[] = [];
const now = new Date();
const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
for (let i = 6; i >= 0; i--) {
const d = new Date(today - i * 86400000);
out.push({
date: d.toISOString().slice(0, 10),
chant_done: false,
quote_done: false,
completed: false,
});
}
return out;
}