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
+3
View File
@@ -13,3 +13,6 @@ NEXT_PUBLIC_SOKETI_KEY=spsays
NEXT_PUBLIC_SOKETI_HOST=ws.prabhupadasays.org
NEXT_PUBLIC_SOKETI_PORT=443
NEXT_PUBLIC_SOKETI_FORCE_TLS=true
# Analytics
NEXT_PUBLIC_RYBBIT_HOST=https://analytics.krispire.cloud
+230
View File
@@ -0,0 +1,230 @@
"use client";
/**
* /sadhna-streak/chant — the japa counter.
*
* Shows the Hare Kṛṣṇa mahā-mantra with a big tap-to-count round counter
* running toward the devotee's daily target (their profile's
* daily_chanting_rounds, falling back to 16). Save persists the count;
* reaching the target marks today's chanting discipline complete.
*
* Auth-gated like the rest of /sadhna-streak. It seeds the counter from
* today's already-saved progress so a devotee can chant in sittings.
*/
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { ArrowLeft, Check, Minus, RotateCcw } from "lucide-react";
import { toast } from "sonner";
import { useAuth } from "@/components/auth/auth-provider";
import { useAuthGate } from "@/components/auth/auth-gate";
import { sadhna } from "@/lib/sadhna";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
const MANTRA = [
"Hare Kṛṣṇa Hare Kṛṣṇa",
"Kṛṣṇa Kṛṣṇa Hare Hare",
"Hare Rāma Hare Rāma",
"Rāma Rāma Hare Hare",
];
const FALLBACK_TARGET = 16;
export default function ChantPage() {
const { user, loading } = useAuth();
const { requireAuth } = useAuthGate();
const router = useRouter();
const [target, setTarget] = useState(FALLBACK_TARGET);
const [count, setCount] = useState(0);
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
const [saving, setSaving] = useState(false);
const [savedAt, setSavedAt] = useState(0); // count value at last save
// Auth gate.
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]);
// Seed from today's saved progress.
useEffect(() => {
if (!user) return;
let cancelled = false;
sadhna
.dashboard()
.then((d) => {
if (cancelled) return;
setTarget(d.target_rounds || FALLBACK_TARGET);
setCount(d.rounds_completed || 0);
setSavedAt(d.rounds_completed || 0);
setState("ready");
})
.catch(() => !cancelled && setState("error"));
return () => {
cancelled = true;
};
}, [user]);
const inc = useCallback(() => {
setCount((c) => Math.min(target, c + 1));
}, [target]);
const dec = useCallback(() => setCount((c) => Math.max(0, c - 1)), []);
const reset = useCallback(() => setCount(0), []);
const save = useCallback(async () => {
setSaving(true);
try {
const d = await sadhna.saveChant(count);
setSavedAt(count);
setTarget(d.target_rounds || FALLBACK_TARGET);
toast.success(
d.chant_done
? "Today's chanting complete 🙏"
: `Saved — ${count} of ${d.target_rounds} rounds`,
);
} catch {
toast.error("Could not save. Please try again.");
} finally {
setSaving(false);
}
}, [count]);
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 chant."}
</p>
</main>
);
}
const complete = count >= target;
const dirty = count !== savedAt;
const pct = target > 0 ? Math.min(100, (count / target) * 100) : 0;
return (
<main className="flex-1 w-full max-w-md mx-auto px-4 sm:px-6 py-5 flex flex-col">
<div className="flex items-center justify-between">
<Link
href="/sadhna-streak"
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="size-4" />
Sādhana
</Link>
<button
onClick={reset}
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
aria-label="Reset counter"
>
<RotateCcw className="size-3.5" />
Reset
</button>
</div>
{/* Mantra */}
<div className="mt-6 text-center">
<p className="text-xs uppercase tracking-widest text-muted-foreground">
The Mahā-mantra
</p>
<div className="mt-3 space-y-1 grid grid-cols-1 md:grid-cols-2 font-serif text-lg sm:text-xl text-foreground leading-relaxed">
{MANTRA.map((line) => (
<p key={line}>{line}</p>
))}
</div>
</div>
{/* Big tap-to-count dial */}
<div className="mt-8 flex flex-col items-center">
<button
onClick={inc}
disabled={state !== "ready" || complete}
className={cn(
"relative grid place-items-center size-56 rounded-full select-none transition-transform active:scale-[0.97]",
"outline-none focus-visible:ring-2 focus-visible:ring-ring",
complete
? "bg-primary/10"
: "bg-card hover:bg-muted/50 cursor-pointer",
"border border-border",
)}
aria-label="Count one round"
>
{/* progress ring */}
<svg className="absolute inset-0 size-full -rotate-90" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="46" fill="none" stroke="currentColor" strokeOpacity="0.08" strokeWidth="4" />
<circle
cx="50"
cy="50"
r="46"
fill="none"
className="text-primary transition-[stroke-dashoffset] duration-500 ease-out"
stroke="currentColor"
strokeWidth="4"
strokeLinecap="round"
strokeDasharray={2 * Math.PI * 46}
strokeDashoffset={(1 - pct / 100) * 2 * Math.PI * 46}
/>
</svg>
<div className="text-center">
<div className="text-6xl font-semibold tabular-nums text-foreground leading-none">
{count}
</div>
<div className="mt-1 text-sm text-muted-foreground">
of {target} rounds
</div>
{complete ? (
<div className="mt-2 inline-flex items-center gap-1 text-xs font-medium text-primary">
<Check className="size-3.5" strokeWidth={3} /> Complete
</div>
) : (
<div className="mt-2 text-[11px] text-muted-foreground">
tap for each round
</div>
)}
</div>
</button>
<button
onClick={dec}
disabled={count === 0}
className="mt-4 inline-flex items-center gap-1.5 rounded-full border border-border bg-card px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors disabled:opacity-40"
aria-label="Remove one round"
>
<Minus className="size-4" /> One round
</button>
</div>
<p className="mt-6 text-center text-xs text-muted-foreground">
One round is 108 names on the mālā.
</p>
{/* Save */}
<div className="mt-auto pt-8">
<Button
onClick={save}
disabled={saving || !dirty || state !== "ready"}
size="lg"
className="w-full"
>
{saving ? "Saving…" : dirty ? "Save progress" : "Saved"}
</Button>
{state === "error" ? (
<p className="mt-2 text-center text-xs text-destructive">
Could not load today`&apos;s progress.
</p>
) : null}
</div>
</main>
);
}
+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;
}
+207
View File
@@ -0,0 +1,207 @@
"use client";
/**
* /sadhna-streak/preview — DEV-ONLY sandbox for the sādhana creeper.
*
* Lets you fast-track the vine: scrub the streak length, set today's
* in-progress rounds/posts, and dial the health (growth) score to watch the
* vine climb the pole, sprout folds + blossoms, and wither — all without
* seeding real logs. Returns nothing in production builds.
*
* The synthesized payload matches what the BFF's Dashboard builds, so what
* you see here mirrors live.
*/
import { useState } from "react";
import { SadhnaVine } from "@/components/sadhna/sadhna-vine";
import type { VineDay } from "@/lib/sadhna";
import { cn } from "@/lib/utils";
export default function SadhnaPreviewPage() {
if (process.env.NODE_ENV === "production") {
return (
<main className="flex-1 flex items-center justify-center px-4 py-12">
<p className="text-sm text-muted-foreground">Not available.</p>
</main>
);
}
return <Preview />;
}
/** Build a vine_days payload exactly the way the BFF would. */
function buildDays(
completed: number,
target: number,
todayRounds: number,
todayPosts: number,
todayComplete: boolean,
): VineDay[] {
const out: VineDay[] = [];
const now = new Date();
const todayMs = Date.UTC(
now.getUTCFullYear(),
now.getUTCMonth(),
now.getUTCDate(),
);
const endOffset = todayComplete ? 0 : 1; // days-ago of the last finished fold
for (let i = completed - 1; i >= 0; i--) {
const daysAgo = endOffset + i;
const date = new Date(todayMs - daysAgo * 86400000)
.toISOString()
.slice(0, 10);
out.push({
date,
rounds: target + (i % 3),
posts: 1 + (i % 2),
completed: true,
in_progress: false,
});
}
if (!todayComplete) {
out.push({
date: new Date(todayMs).toISOString().slice(0, 10),
rounds: todayRounds,
posts: todayPosts,
completed: false,
in_progress: true,
});
}
return out;
}
function Preview() {
const [completed, setCompleted] = useState(3);
const [target] = useState(16);
const [todayComplete, setTodayComplete] = useState(false);
const [todayRounds, setTodayRounds] = useState(8);
const [todayPosts, setTodayPosts] = useState(0);
const [growth, setGrowth] = useState(60);
const days = buildDays(completed, target, todayRounds, todayPosts, todayComplete);
const streak = completed; // current streak = finished folds
const alive = growth > 0;
return (
<main className="flex-1 w-full max-w-2xl mx-auto px-4 sm:px-6 py-5 flex flex-col gap-4">
<div className="flex items-center gap-2">
<span className="rounded bg-amber-500/15 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-amber-600 dark:text-amber-400">
Dev
</span>
<h1 className="font-[family-name:var(--font-title)] text-xl font-semibold text-foreground">
Vine preview
</h1>
</div>
{/* Controls */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 rounded-2xl border border-border bg-card p-4">
<Slider
label="Streak (finished folds)"
value={completed}
min={0}
max={30}
onChange={setCompleted}
/>
<Slider
label="Health (growth)"
value={growth}
min={0}
max={100}
onChange={setGrowth}
/>
<Slider
label="Today's rounds"
value={todayRounds}
min={0}
max={target}
disabled={todayComplete}
onChange={setTodayRounds}
/>
<Slider
label="Today's posts"
value={todayPosts}
min={0}
max={3}
disabled={todayComplete}
onChange={setTodayPosts}
/>
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={todayComplete}
onChange={(e) => setTodayComplete(e.target.checked)}
className="size-4 accent-primary"
/>
Today already complete
</label>
<div className="flex flex-wrap gap-2">
<Chip onClick={() => setGrowth(Math.min(100, completed * 4))}>
sync health → streak
</Chip>
<Chip onClick={() => setGrowth(0)}>kill</Chip>
</div>
</div>
{/* Live vine — same panel chrome as the real page */}
<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">
<SadhnaVine
days={days}
targetRounds={target}
growth={growth}
alive={alive}
totalStreak={streak}
/>
</section>
</main>
);
}
function Slider({
label,
value,
min,
max,
onChange,
disabled,
}: {
label: string;
value: number;
min: number;
max: number;
onChange: (v: number) => void;
disabled?: boolean;
}) {
return (
<div className={cn("space-y-1", disabled && "opacity-40")}>
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">{label}</span>
<span className="font-semibold tabular-nums text-foreground">{value}</span>
</div>
<input
type="range"
min={min}
max={max}
value={value}
disabled={disabled}
onChange={(e) => onChange(Number(e.target.value))}
className="w-full accent-primary"
/>
</div>
);
}
function Chip({
children,
onClick,
}: {
children: React.ReactNode;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
className="rounded-full border border-border px-3 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
>
{children}
</button>
);
}
+682
View File
@@ -0,0 +1,682 @@
"use client";
/**
* SadhnaVine — the sādhana creeper.
*
* A realistic crawler vine twines up a pole, gaining one full fold (a 360°
* wrap) for every day of the current streak — oldest at the base, climbing
* toward the top. An unfinished today is the soft growing tip above the last
* finished fold, tipped with a tendril and reaching a little higher as the
* day's two disciplines are met. Each finished fold carries a chip showing
* how many rounds were chanted and how many quotes were posted that day.
*
* The pole stands its full height from the start — the practice is to climb
* it. When the vine fills the pole a saffron flag crowns it. Lose the streak
* and the living vine you built is gone: you begin the climb again, and a
* faltering practice yellows, then browns, the foliage (the `growth` health
* score drives the palette).
*
* Rendering: the stem is a tapered, tube-shaded ribbon following a helix; its
* far half is drawn behind the pole and near half in front, so it wraps the
* pole convincingly. Heart-shaped leaves (veined, gradient-shaded, on
* petioles) and coiled tendrils dress the stem. Geometry is in real pixels,
* sized to the container via a ResizeObserver, so the day chips (HTML) line
* up with the SVG. A gentle CSS breeze sways leaves, tendrils and the tip;
* the stem stays put so the chips track it.
*
* The drawing math mirrors the standalone SVG study iterated with the
* render-svg tool, kept parametric so it scales with the container and the
* streak length.
*/
import { useEffect, useRef, useState } from "react";
import { CircleDot, MessageSquareQuote, Sparkles } from "lucide-react";
import type { VineDay } from "@/lib/sadhna";
import { cn } from "@/lib/utils";
const TAU = Math.PI * 2;
type Palette = {
stemFrontTop: string;
stemFrontMid: string;
stemFrontBot: string;
stemBack: string;
sheen: string;
leafFront: [string, string, string];
leafBack: [string, string, string];
vein: string;
tendril: string;
droop: number; // extra rotation toward the ground when wilting
dim: boolean;
};
function palette(growth: number, alive: boolean): Palette {
if (!alive || growth <= 0) {
return {
stemFrontTop: "#a98f63",
stemFrontMid: "#8c7149",
stemFrontBot: "#6f5836",
stemBack: "#5f4a2e",
sheen: "#cdb085",
leafFront: ["#bfa46f", "#9c8451", "#6f5a35"],
leafBack: ["#9c8451", "#7d683f", "#54462a"],
vein: "#5f4a2e",
tendril: "#8c7149",
droop: 22,
dim: true,
};
}
if (growth < 30) {
return {
stemFrontTop: "#9fb04a",
stemFrontMid: "#84983a",
stemFrontBot: "#5f7029",
stemBack: "#566b27",
sheen: "#cfe07f",
leafFront: ["#c2d85e", "#9bbf45", "#6f8a30"],
leafBack: ["#9bbf45", "#7ea136", "#5b7327"],
vein: "#566b27",
tendril: "#84983a",
droop: 8,
dim: false,
};
}
if (growth < 60) {
return {
stemFrontTop: "#57a046",
stemFrontMid: "#3f8a38",
stemFrontBot: "#2a6a2b",
stemBack: "#26602a",
sheen: "#7bbf5e",
leafFront: ["#74c044", "#45a23b", "#27702c"],
leafBack: ["#5aa844", "#3a8a34", "#1f5e26"],
vein: "#235f27",
tendril: "#4e9a40",
droop: 0,
dim: false,
};
}
return {
stemFrontTop: "#57a046",
stemFrontMid: "#3f8a38",
stemFrontBot: "#2a6a2b",
stemBack: "#235a27",
sheen: "#8fce6a",
leafFront: ["#7cc848", "#46a23c", "#27732f"],
leafBack: ["#5aa844", "#3a8a34", "#1f5e26"],
vein: "#1f5a23",
tendril: "#4e9a40",
droop: 0,
dim: false,
};
}
// Heart-shaped crawler leaf outline, tip up at -L, petiole at origin.
function leafOutline(L: number): string {
const w = L * 0.66;
return `M 0 ${-L * 0.2}
C ${w * 0.16} ${-L * 0.1} ${w * 0.36} 0 ${w * 0.52} ${-L * 0.02}
C ${w * 0.8} ${-L * 0.05} ${w} ${-L * 0.22} ${w} ${-L * 0.41}
C ${w} ${-L * 0.61} ${w * 0.64} ${-L * 0.81} ${w * 0.3} ${-L * 0.92}
C ${w * 0.18} ${-L * 0.96} ${w * 0.08} ${-L * 0.98} 0 ${-L}
C ${-w * 0.08} ${-L * 0.98} ${-w * 0.18} ${-L * 0.96} ${-w * 0.3} ${-L * 0.92}
C ${-w * 0.64} ${-L * 0.81} ${-w} ${-L * 0.61} ${-w} ${-L * 0.41}
C ${-w} ${-L * 0.22} ${-w * 0.8} ${-L * 0.05} ${-w * 0.52} ${-L * 0.02}
C ${-w * 0.36} 0 ${-w * 0.16} ${-L * 0.1} 0 ${-L * 0.2} Z`;
}
function leafVeins(L: number): string {
const w = L * 0.66;
const lat = (yf: number, xf: number, s: number) =>
`M0 ${-L * yf} Q ${s * w * xf * 0.55} ${-L * (yf + 0.1)} ${s * w * xf} ${-L * (yf + 0.2)}`;
return [
`M0 ${-L * 0.2} L0 ${-L * 0.95}`,
lat(0.3, 0.74, 1),
lat(0.3, 0.74, -1),
lat(0.48, 0.6, 1),
lat(0.48, 0.6, -1),
lat(0.64, 0.44, 1),
lat(0.64, 0.44, -1),
lat(0.78, 0.28, 1),
lat(0.78, 0.28, -1),
].join(" ");
}
export function SadhnaVine({
days,
targetRounds,
growth,
alive,
totalStreak,
className,
}: {
days: VineDay[];
targetRounds: number;
growth: number;
alive: boolean;
totalStreak: number;
className?: string;
}) {
const ref = useRef<HTMLDivElement>(null);
const [size, setSize] = useState({ w: 0, h: 0 });
useEffect(() => {
const el = ref.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
const cr = entries[0].contentRect;
setSize({ w: cr.width, h: cr.height });
});
ro.observe(el);
return () => ro.disconnect();
}, []);
const W = size.w;
const H = size.h;
const ready = W > 40 && H > 140;
const pal = palette(growth, alive);
// ── Geometry ──
const centerX = W / 2;
const baseY = H - 34;
const topPad = 24;
const availH = Math.max(120, baseY - topPad);
const amp = Math.min(46, Math.max(22, W * 0.17));
const pitch = Math.min(86, Math.max(54, availH / 12));
const poleW = Math.min(14, Math.max(9, amp * 0.3));
const wBase = Math.min(15, Math.max(8, amp * 0.3));
const wTip = 3;
const maxWraps = Math.max(1, Math.floor(availH / pitch));
// Growing tip — today, still unfinished — extends with the day's progress.
const tip =
days.length > 0 && days[days.length - 1].in_progress
? days[days.length - 1]
: null;
const chantPortion =
targetRounds > 0
? Math.min(1, (tip?.rounds ?? 0) / targetRounds)
: (tip?.rounds ?? 0) > 0
? 1
: 0;
const quotePortion = (tip?.posts ?? 0) > 0 ? 1 : 0;
const partialFrac = tip ? 0.18 + 0.78 * ((chantPortion + quotePortion) / 2) : 0;
// Trim oldest folds beyond the pole's capacity.
const completedAll = days.filter((d) => d.completed).length;
const unitsNeeded = completedAll + (tip ? 1 : 0);
const hidden = Math.max(0, unitsNeeded - maxWraps);
const render = hidden > 0 ? days.slice(hidden) : days;
const renderCompleted = render.filter((d) => d.completed).length;
const renderTip =
render.length > 0 && render[render.length - 1].in_progress
? render[render.length - 1]
: null;
const topPhase =
renderCompleted * TAU + (renderTip ? partialFrac * TAU : 0);
// ── Helix helpers (closures over the measured geometry) ──
const ptOf = (ph: number) => ({
x: centerX + amp * Math.sin(ph),
y: baseY - (ph / TAU) * pitch,
depth: Math.cos(ph),
});
const tanOf = (ph: number) => {
const dx = amp * Math.cos(ph);
const dy = -(pitch / TAU);
const l = Math.hypot(dx, dy) || 1;
return { tx: dx / l, ty: dy / l };
};
const widthOf = (t: number) => wBase + (wTip - wBase) * t;
const ribbon = (p0: number, p1: number) => {
const steps = Math.max(2, Math.round(((p1 - p0) / TAU) * 56));
const left: string[] = [];
const right: string[] = [];
for (let i = 0; i <= steps; i++) {
const ph = p0 + ((p1 - p0) * i) / steps;
const c = ptOf(ph);
const { tx, ty } = tanOf(ph);
const nx = -ty;
const ny = tx;
const w = widthOf(topPhase > 0 ? ph / topPhase : 0) / 2;
left.push(`${(c.x + nx * w).toFixed(1)} ${(c.y + ny * w).toFixed(1)}`);
right.push(`${(c.x - nx * w).toFixed(1)} ${(c.y - ny * w).toFixed(1)}`);
}
return `M${left.join(" L")} L${right.reverse().join(" L")} Z`;
};
const centerPath = (p0: number, p1: number) => {
const steps = Math.max(2, Math.round(((p1 - p0) / TAU) * 56));
const pts: string[] = [];
for (let i = 0; i <= steps; i++) {
const c = ptOf(p0 + ((p1 - p0) * i) / steps);
pts.push(`${c.x.toFixed(1)} ${c.y.toFixed(1)}`);
}
return `M${pts.join(" L")}`;
};
const frontRuns = () => {
const runs: [number, number][] = [];
let start: number | null = null;
const step = TAU / 80;
for (let ph = 0; ph <= topPhase + 1e-6; ph += step) {
const d = Math.cos(ph);
if (d >= 0 && start === null) start = ph;
if (d < 0 && start !== null) {
runs.push([start, ph]);
start = null;
}
}
if (start !== null) runs.push([start, topPhase]);
return runs;
};
const tendrilPath = (x: number, y: number, dir: number, scale: number) => {
const loops = 2.3;
const R = 11 * scale;
const a0 = dir > 0 ? 0 : Math.PI;
const ex = x + dir * 15 * scale;
const ey = y - 5 * scale;
const steps = 56;
const pts: string[] = [`${x.toFixed(1)} ${y.toFixed(1)}`];
for (let i = 0; i <= steps; i++) {
const a = a0 + (i / steps) * loops * TAU;
const r = R * (1 - 0.62 * (i / steps));
pts.push(
`${(ex + dir * r * Math.cos(a)).toFixed(1)} ${(ey + r * Math.sin(a)).toFixed(1)}`,
);
}
return `M${pts.join(" L")}`;
};
// ── Build foliage ──
type LeafSpec = {
key: string;
x: number;
y: number;
rot: number;
L: number;
back: boolean;
delay: number;
};
const leaves: LeafSpec[] = [];
const petioles: { key: string; d: string }[] = [];
if (ready && topPhase > 0) {
let k = 0;
for (let ph = Math.PI / 2; ph < topPhase - 0.25; ph += Math.PI, k++) {
const c = ptOf(ph);
const right = Math.sin(ph) > 0;
const t = ph / topPhase;
const L = (54 - 22 * t) * (k % 3 === 0 ? 0.82 : 1);
const petLen = 11 * (1 - 0.4 * t);
const rot = (right ? 56 : -56) + (k % 2 ? 8 : -6) + (right ? pal.droop : -pal.droop);
const aa = ((rot - 90) * Math.PI) / 180;
const lx = c.x + Math.cos(aa) * petLen;
const ly = c.y + Math.sin(aa) * petLen;
petioles.push({
key: `p${k}`,
d: `M${c.x.toFixed(1)} ${c.y.toFixed(1)} q ${(Math.cos(aa) * petLen * 0.4).toFixed(1)} ${(Math.sin(aa) * petLen * 0.4).toFixed(1)} ${(Math.cos(aa) * petLen).toFixed(1)} ${(Math.sin(aa) * petLen).toFixed(1)}`,
});
leaves.push({
key: `l${k}`,
x: lx,
y: ly,
rot,
L,
back: c.depth < -0.1,
delay: (k % 5) * 0.45,
});
}
}
const backLeaves = leaves.filter((l) => l.back);
const frontLeaves = leaves.filter((l) => !l.back);
// Tendrils: always one at the growing tip, plus a couple along the way.
const tendrils: { key: string; d: string }[] = [];
if (ready && topPhase > 0) {
const t = ptOf(topPhase);
tendrils.push({
key: "t-tip",
d: tendrilPath(t.x, t.y, Math.sin(topPhase) >= 0 ? 1 : -1, 1.1),
});
for (let ph = Math.PI * 1.5; ph < topPhase - 0.4; ph += TAU) {
const c = ptOf(ph);
tendrils.push({
key: `t${ph.toFixed(0)}`,
d: tendrilPath(c.x, c.y, -1, 0.85),
});
}
}
// ── Day nodes (chips anchor here, at each fold's front-centre top) ──
type Node = {
key: string;
x: number;
y: number;
day: VineDay;
kind: "fold" | "tip";
sideRight: boolean;
};
const nodes: Node[] = [];
if (ready) {
for (let i = 0; i < renderCompleted; i++) {
const phase = (i + 1) * TAU;
const c = ptOf(phase);
nodes.push({
key: render[i].date,
x: c.x,
y: c.y,
day: render[i],
kind: "fold",
sideRight: i % 2 === 0,
});
}
if (renderTip) {
const c = ptOf(topPhase);
nodes.push({
key: `${renderTip.date}-tip`,
x: c.x,
y: c.y,
day: renderTip,
kind: "tip",
sideRight: renderCompleted % 2 === 0,
});
}
}
const filledToTop = ready && unitsNeeded >= maxWraps && renderCompleted >= 1;
const runs = ready ? frontRuns() : [];
return (
<div ref={ref} className={cn("relative w-full h-full overflow-hidden", className)}>
<style>{`
@keyframes sv-leaf { 0%,100%{transform:rotate(-5deg)} 50%{transform:rotate(5deg)} }
@keyframes sv-flag { 0%,100%{transform:scaleX(1)} 50%{transform:scaleX(0.82)} }
@keyframes sv-tip { 0%,100%{transform:rotate(-6deg)} 50%{transform:rotate(7deg)} }
.sv-leaf { transform-box:fill-box; transform-origin:50% 100%; animation:sv-leaf 4.5s ease-in-out infinite; }
.sv-flag { transform-box:fill-box; transform-origin:0% 50%; animation:sv-flag 2.6s ease-in-out infinite; }
.sv-tip { transform-box:fill-box; transform-origin:50% 100%; animation:sv-tip 3.4s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce){ .sv-leaf,.sv-flag,.sv-tip{ animation:none } }
`}</style>
{ready ? (
<svg
viewBox={`0 0 ${W} ${H}`}
width={W}
height={H}
className="absolute inset-0"
role="img"
aria-label={`Your sādhana vine — ${totalStreak} day streak`}
>
<defs>
<linearGradient id="sv-pole" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stopColor="#caa066" />
<stop offset="0.5" stopColor="#a87a40" />
<stop offset="1" stopColor="#724d24" />
</linearGradient>
<linearGradient
id="sv-stem"
gradientUnits="userSpaceOnUse"
x1={centerX - amp}
y1="0"
x2={centerX + amp}
y2="0"
>
<stop offset="0" stopColor={pal.stemFrontTop} />
<stop offset="0.5" stopColor={pal.stemFrontMid} />
<stop offset="1" stopColor={pal.stemFrontBot} />
</linearGradient>
<linearGradient id="sv-leaf-front" x1="-0.1" y1="0" x2="0.3" y2="1">
<stop offset="0" stopColor={pal.leafFront[0]} />
<stop offset="0.5" stopColor={pal.leafFront[1]} />
<stop offset="1" stopColor={pal.leafFront[2]} />
</linearGradient>
<linearGradient id="sv-leaf-back" x1="-0.1" y1="0" x2="0.3" y2="1">
<stop offset="0" stopColor={pal.leafBack[0]} />
<stop offset="0.5" stopColor={pal.leafBack[1]} />
<stop offset="1" stopColor={pal.leafBack[2]} />
</linearGradient>
<linearGradient id="sv-soil" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#9c7350" />
<stop offset="1" stopColor="#6b4a31" />
</linearGradient>
</defs>
{/* Back-facing leaves */}
{backLeaves.map((lf) => (
<Leaf key={lf.key} lf={lf} pal={pal} fill="url(#sv-leaf-back)" />
))}
{/* Vine far half — behind the pole */}
{topPhase > 0 ? <path d={ribbon(0, topPhase)} fill={pal.stemBack} /> : null}
{/* Pole */}
<rect
x={centerX - poleW / 2}
y={topPad}
width={poleW}
height={baseY - topPad}
rx={poleW / 2}
fill="url(#sv-pole)"
/>
<rect
x={centerX - poleW / 2 + 1.5}
y={topPad}
width={1.8}
height={baseY - topPad}
rx={1}
fill="#ecd6ad"
opacity={0.5}
/>
{/* bamboo node rings */}
{Array.from({ length: Math.max(2, Math.floor((baseY - topPad) / 90)) }).map(
(_, i) => {
const y = baseY - (i + 1) * 90;
if (y < topPad + 8) return null;
return (
<rect
key={`ring${i}`}
x={centerX - poleW / 2 - 1}
y={y}
width={poleW + 2}
height={3}
rx={1.5}
fill="#6b4a26"
opacity={0.45}
/>
);
},
)}
{/* Finial + saffron flag */}
<circle cx={centerX} cy={topPad} r={poleW * 0.55} fill="#b8842f" />
<circle cx={centerX - 1} cy={topPad - 1} r={poleW * 0.22} fill="#f0d9a8" opacity={0.6} />
{filledToTop ? (
<g className="sv-flag">
<path
d={`M${centerX + poleW * 0.4} ${topPad - 2}
L${centerX + poleW * 0.4 + 32} ${topPad + 5}
L${centerX + poleW * 0.4} ${topPad + 12} Z`}
fill="#ef8a35"
/>
</g>
) : null}
{/* Vine near half — over the pole, with a tube sheen */}
{runs.map(([a, b], i) => (
<path key={`fr${i}`} d={ribbon(a, b)} fill="url(#sv-stem)" />
))}
{runs.map(([a, b], i) => (
<path
key={`sh${i}`}
d={centerPath(a, b)}
fill="none"
stroke={pal.sheen}
strokeWidth={Math.max(2, wBase * 0.22)}
strokeLinecap="round"
opacity={pal.dim ? 0.25 : 0.45}
/>
))}
{/* Soil */}
<ellipse cx={centerX} cy={baseY + 8} rx={Math.min(70, W * 0.3)} ry={12} fill="url(#sv-soil)" />
<ellipse cx={centerX} cy={baseY + 3} rx={Math.min(58, W * 0.25)} ry={6} fill="#9c7350" />
{/* Tendrils */}
{tendrils.map((td) => (
<path
key={td.key}
className="sv-tip"
d={td.d}
fill="none"
stroke={pal.tendril}
strokeWidth={2.2}
strokeLinecap="round"
/>
))}
{/* Petioles + front leaves */}
{petioles.map((p) => (
<path
key={p.key}
d={p.d}
fill="none"
stroke={pal.stemFrontMid}
strokeWidth={2.4}
strokeLinecap="round"
/>
))}
{frontLeaves.map((lf) => (
<Leaf key={lf.key} lf={lf} pal={pal} fill="url(#sv-leaf-front)" />
))}
</svg>
) : null}
{/* Day chips (HTML overlay, aligned to the SVG nodes) */}
{ready
? nodes.map((n) => (
<DayChip
key={`c-${n.key}`}
x={n.x}
y={n.y}
W={W}
sideRight={n.sideRight}
day={n.day}
isTip={n.kind === "tip"}
dim={pal.dim}
/>
))
: null}
{/* Hidden-folds hint at the base */}
{ready && hidden > 0 ? (
<div
className="absolute left-1/2 -translate-x-1/2 rounded-full border border-border bg-card/90 px-2.5 py-0.5 text-[10px] text-muted-foreground shadow-sm"
style={{ bottom: 6 }}
>
+{hidden} earlier {hidden === 1 ? "day" : "days"}
</div>
) : null}
</div>
);
}
function Leaf({
lf,
pal,
fill,
}: {
lf: { x: number; y: number; rot: number; L: number; delay: number };
pal: Palette;
fill: string;
}) {
return (
<g
className="sv-leaf"
style={{ animationDelay: `${lf.delay}s` }}
transform={`translate(${lf.x.toFixed(1)} ${lf.y.toFixed(1)}) rotate(${lf.rot.toFixed(1)})`}
>
<path d={leafOutline(lf.L)} fill={fill} stroke={pal.vein} strokeWidth={0.7} />
<path
d={leafVeins(lf.L)}
fill="none"
stroke={pal.vein}
strokeWidth={Math.max(0.5, lf.L * 0.013)}
strokeLinecap="round"
opacity={0.45}
/>
<path
d={`M ${-lf.L * 0.1} ${-lf.L * 0.3} Q ${-lf.L * 0.28} ${-lf.L * 0.55} ${-lf.L * 0.12} ${-lf.L * 0.8}`}
fill="none"
stroke="#d8f2b0"
strokeWidth={lf.L * 0.045}
strokeLinecap="round"
opacity={pal.dim ? 0.12 : 0.25}
/>
</g>
);
}
function DayChip({
x,
y,
W,
sideRight,
day,
isTip,
dim,
}: {
x: number;
y: number;
W: number;
sideRight: boolean;
day: VineDay;
isTip: boolean;
dim: boolean;
}) {
const gap = 18;
const style: React.CSSProperties = sideRight
? { left: x + gap, top: y, transform: "translateY(-50%)" }
: { right: W - x + gap, top: y, transform: "translateY(-50%)" };
const blank = isTip && day.rounds === 0 && day.posts === 0;
return (
<div
className={cn(
"pointer-events-none absolute flex max-w-[42%] flex-col gap-0.5 rounded-xl border bg-card/90 px-2 py-1 shadow-sm backdrop-blur-sm",
isTip ? "border-primary/50 ring-1 ring-primary/20" : "border-border",
dim && !isTip && "opacity-70",
)}
style={style}
>
<span className="text-[10px] font-medium leading-none text-muted-foreground">
{isTip ? "Today" : fmtDate(day.date)}
</span>
{blank ? (
<span className="flex items-center gap-1 text-[11px] font-medium leading-none text-primary">
<Sparkles className="size-3" /> Begin
</span>
) : (
<span className="flex items-center gap-2 text-[11px] font-semibold leading-none text-foreground tabular-nums">
<span className="flex items-center gap-0.5" title="rounds chanted">
<CircleDot className="size-3 text-primary" />
{day.rounds}
</span>
<span className="flex items-center gap-0.5" title="quotes posted">
<MessageSquareQuote className="size-3 text-primary" />
{day.posts}
</span>
</span>
)}
</div>
);
}
function fmtDate(iso: string): string {
const d = new Date(`${iso}T00:00:00Z`);
if (Number.isNaN(d.getTime())) return "";
return d.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
timeZone: "UTC",
});
}
+108
View File
@@ -0,0 +1,108 @@
/**
* Browser-side BFF client for the sādhana streak (/sadhna-streak).
*
* Mirrors lib/social.ts: same `/api/v1` base, same `credentials: "include"`
* so the auth cookie rides along, same envelope unwrapping. Kept in its
* own module so the streak feature doesn't bloat the social client.
*/
const BFF_BASE = process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:8080";
export class SadhnaError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
this.name = "SadhnaError";
}
}
type Envelope<T> = {
success: boolean;
data?: T;
error?: { code: number; message: string };
};
async function call<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BFF_BASE}/api/v1${path}`, {
credentials: "include",
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
...init,
});
if (res.status === 204) return undefined as T;
const body = (await res.json().catch(() => ({}))) as Envelope<T>;
if (!res.ok || body.success === false) {
throw new SadhnaError(res.status, body.error?.message || res.statusText);
}
return (body.data ?? (undefined as unknown as T)) as T;
}
// ─── Types ───────────────────────────────────────────────────────────────
/** Plant growth stage, mirrors the BFF's stageFor(). */
export type SadhnaStage =
| "dead"
| "sprout"
| "seedling"
| "sapling"
| "young"
| "mature";
/** One cell of the 7-day streak strip. */
export type SadhnaDay = {
date: string; // YYYY-MM-DD (UTC)
chant_done: boolean;
quote_done: boolean;
completed: boolean;
};
/**
* One fold of the sādhana creeper — a single day the vine has wound around
* its pole. The vine grows a fresh fold for each day of the current streak
* (oldest first); the final fold may be today, still in progress (the
* growing tip). `rounds`/`posts` annotate what was offered that day.
*/
export type VineDay = {
date: string; // YYYY-MM-DD (UTC)
rounds: number;
posts: number;
completed: boolean;
in_progress: boolean;
};
export type SadhnaDashboard = {
// Today's two disciplines.
target_rounds: number;
rounds_completed: number;
chant_done: boolean;
quote_done: boolean;
today_completed: boolean;
// Streak counters.
current_streak: number;
longest_streak: number;
// Plant state (growth 0–100).
growth: number;
stage: SadhnaStage;
alive: boolean;
// Trailing 7 days, oldest first.
week: SadhnaDay[];
// The creeper: one fold per day of the current streak (oldest first),
// plus an in-progress fold for an unfinished today. Capped server-side;
// current_streak holds the true length.
vine_days: VineDay[];
};
// ─── API ───────────────────────────────────────────────────────────────
export const sadhna = {
/** Today's progress + streak + plant state. */
dashboard: () => call<SadhnaDashboard>("/sadhna/dashboard"),
/** 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 }),
}),
};