wip: sadhna streak
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user