feat: add webpush and sadhna tracker
This commit is contained in:
@@ -0,0 +1,361 @@
|
||||
"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<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()}`;
|
||||
}
|
||||
Reference in New Issue
Block a user