wip: sadhna streak
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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`'s progress.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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 }),
|
||||
}),
|
||||
};
|
||||
Reference in New Issue
Block a user