From ecb32d2e85e844e6c9dbe561d5dead6cb8f31355 Mon Sep 17 00:00:00 2001 From: gosangam Date: Sun, 7 Jun 2026 22:32:34 +0530 Subject: [PATCH] wip: sadhna streak --- .env.example | 3 + app/(app)/sadhna-streak/chant/page.tsx | 230 ++++++++ app/(app)/sadhna-streak/page.tsx | 330 +++++++++++ app/(app)/sadhna-streak/preview/page.tsx | 207 +++++++ components/sadhna/sadhna-vine.tsx | 682 +++++++++++++++++++++++ lib/sadhna.ts | 108 ++++ 6 files changed, 1560 insertions(+) create mode 100644 app/(app)/sadhna-streak/chant/page.tsx create mode 100644 app/(app)/sadhna-streak/page.tsx create mode 100644 app/(app)/sadhna-streak/preview/page.tsx create mode 100644 components/sadhna/sadhna-vine.tsx create mode 100644 lib/sadhna.ts diff --git a/.env.example b/.env.example index 13bc9612..f85b346b 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/app/(app)/sadhna-streak/chant/page.tsx b/app/(app)/sadhna-streak/chant/page.tsx new file mode 100644 index 00000000..4ecd99fa --- /dev/null +++ b/app/(app)/sadhna-streak/chant/page.tsx @@ -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 ( +
+

+ {loading ? "Loading…" : "Sign in to chant."} +

+
+ ); + } + + const complete = count >= target; + const dirty = count !== savedAt; + const pct = target > 0 ? Math.min(100, (count / target) * 100) : 0; + + return ( +
+
+ + + Sādhana + + +
+ + {/* Mantra */} +
+

+ The Mahā-mantra +

+
+ {MANTRA.map((line) => ( +

{line}

+ ))} +
+
+ + {/* Big tap-to-count dial */} +
+ + + +
+ +

+ One round is 108 names on the mālā. +

+ + {/* Save */} +
+ + {state === "error" ? ( +

+ Could not load today`'s progress. +

+ ) : null} +
+
+ ); +} diff --git a/app/(app)/sadhna-streak/page.tsx b/app/(app)/sadhna-streak/page.tsx new file mode 100644 index 00000000..9b451458 --- /dev/null +++ b/app/(app)/sadhna-streak/page.tsx @@ -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(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 ( +
+

+ {loading ? "Loading…" : "Sign in to track your sādhana."} +

+
+ ); + } + + return ( +
+ {/* Streak strip */} + + +
+

+ Daily Sādhana +

+

{vineCaption(dash)}

+
+ + {/* Action items */} +
+ + +
+ + {/* The creeper — climbs the pole, one fold per day of the streak */} + +
+ ); +} + +// ─── Streak strip ──────────────────────────────────────────────────────── + +function StreakStrip({ + dash, + loading, +}: { + dash: SadhnaDashboard | null; + loading: boolean; +}) { + const week = dash?.week ?? placeholderWeek(); + const streak = dash?.current_streak ?? 0; + + return ( +
+
+ 0 ? "text-orange-500" : "text-muted-foreground", + )} + /> + + {streak} + + + day{streak === 1 ? "" : "s"} + +
+ +
+ {week.map((day, i) => ( + + ))} +
+
+ ); +} + +function DayDot({ day, isToday }: { day: SadhnaDay; isToday: boolean }) { + const partial = !day.completed && (day.chant_done || day.quote_done); + return ( +
+
+ {day.completed ? : null} +
+ + {weekdayLetter(day.date)} + +
+ ); +} + +// ─── 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 ( + } + 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 ( + } + 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 ( + +
+ + {done ? : icon} + + {done ? ( + Done + ) : ( + + {cta} → + + )} +
+
+

+ {title} +

+

{subtitle}

+
+ + ); +} + +// ─── Vine panel ─────────────────────────────────────────────────────── + +function VinePanel({ + dash, + error, +}: { + dash: SadhnaDashboard | null; + error: boolean; +}) { + return ( +
+ {error ? ( +
+

+ Could not load your sādhana. Please try again. +

+
+ ) : dash ? ( + + ) : ( +
+

Loading your vine…

+
+ )} +
+ ); +} + +// ─── 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; +} diff --git a/app/(app)/sadhna-streak/preview/page.tsx b/app/(app)/sadhna-streak/preview/page.tsx new file mode 100644 index 00000000..4fc098c3 --- /dev/null +++ b/app/(app)/sadhna-streak/preview/page.tsx @@ -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 ( +
+

Not available.

+
+ ); + } + return ; +} + +/** 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 ( +
+
+ + Dev + +

+ Vine preview +

+
+ + {/* Controls */} +
+ + + + + +
+ setGrowth(Math.min(100, completed * 4))}> + sync health → streak + + setGrowth(0)}>kill +
+
+ + {/* Live vine — same panel chrome as the real page */} +
+ +
+
+ ); +} + +function Slider({ + label, + value, + min, + max, + onChange, + disabled, +}: { + label: string; + value: number; + min: number; + max: number; + onChange: (v: number) => void; + disabled?: boolean; +}) { + return ( +
+
+ {label} + {value} +
+ onChange(Number(e.target.value))} + className="w-full accent-primary" + /> +
+ ); +} + +function Chip({ + children, + onClick, +}: { + children: React.ReactNode; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/components/sadhna/sadhna-vine.tsx b/components/sadhna/sadhna-vine.tsx new file mode 100644 index 00000000..c7860209 --- /dev/null +++ b/components/sadhna/sadhna-vine.tsx @@ -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(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 ( +
+ + + {ready ? ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* Back-facing leaves */} + {backLeaves.map((lf) => ( + + ))} + + {/* Vine far half — behind the pole */} + {topPhase > 0 ? : null} + + {/* Pole */} + + + {/* 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 ( + + ); + }, + )} + + {/* Finial + saffron flag */} + + + {filledToTop ? ( + + + + ) : null} + + {/* Vine near half — over the pole, with a tube sheen */} + {runs.map(([a, b], i) => ( + + ))} + {runs.map(([a, b], i) => ( + + ))} + + {/* Soil */} + + + + {/* Tendrils */} + {tendrils.map((td) => ( + + ))} + + {/* Petioles + front leaves */} + {petioles.map((p) => ( + + ))} + {frontLeaves.map((lf) => ( + + ))} + + ) : null} + + {/* Day chips (HTML overlay, aligned to the SVG nodes) */} + {ready + ? nodes.map((n) => ( + + )) + : null} + + {/* Hidden-folds hint at the base */} + {ready && hidden > 0 ? ( +
+ +{hidden} earlier {hidden === 1 ? "day" : "days"} +
+ ) : null} +
+ ); +} + +function Leaf({ + lf, + pal, + fill, +}: { + lf: { x: number; y: number; rot: number; L: number; delay: number }; + pal: Palette; + fill: string; +}) { + return ( + + + + + + ); +} + +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 ( +
+ + {isTip ? "Today" : fmtDate(day.date)} + + {blank ? ( + + Begin + + ) : ( + + + + {day.rounds} + + + + {day.posts} + + + )} +
+ ); +} + +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", + }); +} diff --git a/lib/sadhna.ts b/lib/sadhna.ts new file mode 100644 index 00000000..5ed2b19b --- /dev/null +++ b/lib/sadhna.ts @@ -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 = { + success: boolean; + data?: T; + error?: { code: number; message: string }; +}; + +async function call(path: string, init?: RequestInit): Promise { + 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; + 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("/sadhna/dashboard"), + + /** Save today's chanting round count; returns the recomputed dashboard. */ + saveChant: (roundsCompleted: number) => + call("/sadhna/chant", { + method: "POST", + body: JSON.stringify({ rounds_completed: roundsCompleted }), + }), +};