"use client"; /** * StreakCalendar — the sādhana streak calendar with week/month views. * * Shows the current/best streak and a grid of days; a lotus blooms beneath * every day the sādhana was completed (both disciplines). The user can * switch between a single-week view and a full-month grid, and page * backward/forward through time. "Next" stops at the current period — there * is nothing to show in the future. * * Streak counters come from the dashboard payload; the grid itself is fed by * the /sadhna/history range endpoint, fetched per visible period and cached * so paging back to a period you've seen is instant. Everything is bucketed * by UTC calendar day to match the backend. */ import { useEffect, useMemo, useRef, useState } from "react"; import { Check, ChevronLeft, ChevronRight, Gift } from "lucide-react"; import { sadhna, type SadhnaDashboard, type SadhnaDay } from "@/lib/sadhna"; import { StreakFlame } from "@/components/sadhna/streak-flame"; import { cn } from "@/lib/utils"; const DAY = 86400000; const WEEKDAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"]; type View = "week" | "month"; // ── UTC date helpers (operate on epoch-ms at UTC midnight) ── function ymd(ms: number): string { return new Date(ms).toISOString().slice(0, 10); } function startOfDayUTC(d: Date): number { return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); } function startOfWeek(ms: number): number { return ms - new Date(ms).getUTCDay() * DAY; } export function StreakCalendar({ dash }: { dash: SadhnaDashboard | null }) { const todayMs = startOfDayUTC(new Date()); const todayStr = ymd(todayMs); const [view, setView] = useState("week"); // A day inside the visible period. Starts on today. const [anchorMs, setAnchorMs] = useState(todayMs); // Visible grid range + metadata for the chosen view. const grid = useMemo(() => { if (view === "week") { const start = startOfWeek(anchorMs); return { start, weeks: 1, monthIndex: -1 }; } const d = new Date(anchorMs); const monthStart = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1); const monthEnd = Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0); const start = startOfWeek(monthStart); const end = startOfWeek(monthEnd) + 6 * DAY; const weeks = Math.round((end - start) / (7 * DAY)) + 1; return { start, weeks, monthIndex: d.getUTCMonth() }; }, [view, anchorMs]); const fromStr = ymd(grid.start); const toStr = ymd(grid.start + (grid.weeks * 7 - 1) * DAY); // Fetch the visible range, cached by range key; race-safe. const cacheRef = useRef>>(new Map()); const reqRef = useRef(0); const [byDate, setByDate] = useState>(new Map()); const [loading, setLoading] = useState(false); useEffect(() => { const key = `${fromStr}_${toStr}`; const cached = cacheRef.current.get(key); if (cached) { setByDate(cached); return; } const token = ++reqRef.current; setLoading(true); sadhna .history(fromStr, toStr) .then((days) => { const m = new Map((days ?? []).map((d) => [d.date, d] as const)); cacheRef.current.set(key, m); if (reqRef.current === token) setByDate(m); }) .catch(() => { /* keep whatever's shown */ }) .finally(() => { if (reqRef.current === token) setLoading(false); }); }, [fromStr, toStr]); // The dashboard's trailing-7-day `week` is the freshest authoritative // source for recent days (it reflects a completion the moment dash // reloads on mount/focus). Overlay it on top of the fetched history so the // current week's cells — especially today — always agree with the streak // counter, no matter how the history fetch raced. const dashByDate = useMemo( () => new Map((dash?.week ?? []).map((d) => [d.date, d] as const)), [dash], ); const dayFor = (date: string) => dashByDate.get(date) ?? byDate.get(date); // Navigation bounds: never page past the current period. const currentPeriodStart = view === "week" ? startOfWeek(todayMs) : Date.UTC(new Date(todayMs).getUTCFullYear(), new Date(todayMs).getUTCMonth(), 1); const visiblePeriodStart = view === "week" ? grid.start : Date.UTC(new Date(anchorMs).getUTCFullYear(), new Date(anchorMs).getUTCMonth(), 1); const atCurrent = visiblePeriodStart >= currentPeriodStart; const shift = (dir: -1 | 1) => { if (view === "week") { setAnchorMs((ms) => ms + dir * 7 * DAY); } else { const d = new Date(anchorMs); setAnchorMs(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + dir, 1)); } }; const streak = dash?.current_streak ?? 0; const longest = dash?.longest_streak ?? 0; const giftDate = dash?.next_reward_date ?? null; const cells = Array.from({ length: grid.weeks * 7 }, (_, i) => grid.start + i * DAY); return (
{/* Streak + view toggle */}
0 ? "bg-orange-500/15" : "bg-muted", )} >

{streak} day{streak === 1 ? "" : "s"}

{streak > 0 ? "current streak" : "start your streak today"} {longest > 0 ? ` · best ${longest}` : ""}

{(["week", "month"] as View[]).map((v) => ( ))}
{/* Period nav */}
shift(-1)}>
{view === "week" ? weekRangeLabel(grid.start) : monthLabel(anchorMs)} {!atCurrent ? ( ) : null}
shift(1)} disabled={atCurrent}>
{/* Weekday header (month view) */} {view === "month" ? (
{WEEKDAYS.map((w) => ( {w} ))}
) : null}
{cells.map((ms) => { const date = ymd(ms); return ( todayMs} isGift={!!giftDate && date === giftDate} inMonth={view === "week" || new Date(ms).getUTCMonth() === grid.monthIndex} /> ); })}
); } function NavButton({ label, onClick, disabled, children, }: { label: string; onClick: () => void; disabled?: boolean; children: React.ReactNode; }) { return ( ); } function DayCell({ date, day, view, isToday, isFuture, isGift, inMonth, }: { date: string; day: SadhnaDay | undefined; view: View; isToday: boolean; isFuture: boolean; isGift: boolean; inMonth: boolean; }) { const completed = !!day?.completed; const partial = !!day && !completed && (day.chant_done || day.quote_done); const isWeek = view === "week"; return (
{isWeek ? ( {WEEKDAYS[new Date(`${date}T00:00:00Z`).getUTCDay()]} ) : null}
{dayOfMonth(date)}
{/* Marker slot — fixed height keeps every column's baseline aligned. */}
{completed ? ( ) : isGift ? ( ) : null}
); } // ── labels ── function dayOfMonth(date: string): number | string { const d = new Date(`${date}T00:00:00Z`); return Number.isNaN(d.getTime()) ? "" : d.getUTCDate(); } function monthLabel(ms: number): string { return new Date(ms).toLocaleDateString(undefined, { month: "long", year: "numeric", timeZone: "UTC", }); } function weekRangeLabel(startMs: number): string { const f = new Date(startMs); const l = new Date(startMs + 6 * DAY); const fm = f.toLocaleDateString(undefined, { month: "short", timeZone: "UTC" }); const lm = l.toLocaleDateString(undefined, { month: "short", timeZone: "UTC" }); return fm === lm ? `${fm} ${f.getUTCDate()} – ${l.getUTCDate()}` : `${fm} ${f.getUTCDate()} – ${lm} ${l.getUTCDate()}`; }