Files
spsays-training/components/sadhna/streak-calendar.tsx
T

367 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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. "Today" is the browser's
* local date, matching the backend's local-timezone day boundary.
*/
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";
// Each calendar day is represented as the UTC-midnight epoch-ms of that date
// (so the date string and all arithmetic stay tz-free and internally
// consistent). "Today", however, is anchored to the browser's LOCAL date so
// the day boundary matches the backend's local-timezone day reset.
function ymd(ms: number): string {
return new Date(ms).toISOString().slice(0, 10);
}
// UTC-midnight ms whose calendar date is the browser's local today.
function localTodayMs(): number {
const d = new Date();
return Date.UTC(d.getFullYear(), d.getMonth(), d.getDate());
}
function startOfWeek(ms: number): number {
return ms - new Date(ms).getUTCDay() * DAY;
}
export function StreakCalendar({ dash }: { dash: SadhnaDashboard | null }) {
const todayMs = localTodayMs();
const todayStr = ymd(todayMs);
const [view, setView] = useState<View>("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<Map<string, Map<string, SadhnaDay>>>(new Map());
const reqRef = useRef(0);
const [byDate, setByDate] = useState<Map<string, SadhnaDay>>(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 (
<section className="rounded-2xl border border-border bg-card p-4 sm:p-5">
{/* Streak + view toggle */}
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2.5">
<span
className={cn(
"inline-flex size-10 items-center justify-center rounded-full",
streak > 0 ? "bg-orange-500/15" : "bg-muted",
)}
>
<StreakFlame className="size-6" muted={streak === 0} />
</span>
<div className="leading-tight">
<p className="text-lg font-semibold text-foreground tabular-nums">
{streak} day{streak === 1 ? "" : "s"}
</p>
<p className="text-xs text-muted-foreground">
{streak > 0 ? "current streak" : "start your streak today"}
{longest > 0 ? ` · best ${longest}` : ""}
</p>
</div>
</div>
<div className="inline-flex rounded-lg border border-border p-0.5 text-xs">
{(["week", "month"] as View[]).map((v) => (
<button
key={v}
type="button"
onClick={() => setView(v)}
className={cn(
"rounded-md px-2.5 py-1 capitalize transition-colors",
view === v
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{v}
</button>
))}
</div>
</div>
{/* Period nav */}
<div className="mb-3 flex items-center justify-between">
<NavButton label="Previous" onClick={() => shift(-1)}>
<ChevronLeft className="size-4" />
</NavButton>
<div className="flex items-center gap-2">
<span
className={cn(
"text-sm font-medium text-foreground",
loading && "opacity-60",
)}
>
{view === "week"
? weekRangeLabel(grid.start)
: monthLabel(anchorMs)}
</span>
{!atCurrent ? (
<button
type="button"
onClick={() => setAnchorMs(todayMs)}
className="rounded-full border border-border px-2 py-0.5 text-[11px] text-muted-foreground transition-colors hover:text-foreground"
>
Today
</button>
) : null}
</div>
<NavButton label="Next" onClick={() => shift(1)} disabled={atCurrent}>
<ChevronRight className="size-4" />
</NavButton>
</div>
{/* Weekday header (month view) */}
{view === "month" ? (
<div className="mb-1 grid grid-cols-7 gap-1">
{WEEKDAYS.map((w) => (
<span
key={w}
className="text-center text-[11px] leading-none text-muted-foreground"
>
{w}
</span>
))}
</div>
) : null}
<div className={cn("grid grid-cols-7 gap-1", loading && "opacity-70")}>
{cells.map((ms) => {
const date = ymd(ms);
return (
<DayCell
key={date}
date={date}
day={dayFor(date)}
view={view}
isToday={date === todayStr}
isFuture={ms > todayMs}
isGift={!!giftDate && date === giftDate}
inMonth={view === "week" || new Date(ms).getUTCMonth() === grid.monthIndex}
/>
);
})}
</div>
</section>
);
}
function NavButton({
label,
onClick,
disabled,
children,
}: {
label: string;
onClick: () => void;
disabled?: boolean;
children: React.ReactNode;
}) {
return (
<button
type="button"
aria-label={label}
onClick={onClick}
disabled={disabled}
className={cn(
"inline-flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",
"disabled:pointer-events-none disabled:opacity-30",
)}
>
{children}
</button>
);
}
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 (
<div
className={cn(
"flex flex-col items-center gap-1.5",
!inMonth && "opacity-35",
)}
>
{isWeek ? (
<span
className={cn(
"text-[11px] leading-none",
isToday ? "font-semibold text-primary" : "text-muted-foreground",
)}
>
{WEEKDAYS[new Date(`${date}T00:00:00Z`).getUTCDay()]}
</span>
) : null}
<div
className={cn(
"flex items-center justify-center rounded-full font-medium transition-colors",
isWeek ? "size-9 text-sm sm:size-10" : "size-8 text-xs",
completed && "bg-primary text-primary-foreground",
!completed && partial && "bg-primary/15 text-foreground ring-1 ring-primary/40",
!completed && !partial && isToday && "text-foreground ring-2 ring-primary",
!completed && !partial && !isToday && isFuture && "text-muted-foreground/40",
!completed && !partial && !isToday && !isFuture && "bg-muted text-muted-foreground",
)}
>
{dayOfMonth(date)}
</div>
{/* Marker slot — fixed height keeps every column's baseline aligned. */}
<div
className={cn(
"flex items-center justify-center",
isWeek ? "h-5" : "h-3.5",
)}
>
{completed ? (
<Check
className={cn("text-primary", isWeek ? "size-4" : "size-3")}
strokeWidth={3}
/>
) : isGift ? (
<Gift
className={cn("text-amber-500", isWeek ? "size-4" : "size-3")}
/>
) : null}
</div>
</div>
);
}
// ── 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()}`;
}