367 lines
12 KiB
TypeScript
367 lines
12 KiB
TypeScript
"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()}`;
|
||
}
|