/** * Browser-side BFF client for the sādhana streak (/sadhna-streak). * * Mirrors lib/social.ts: same `/api/v1` base, same `credentials: "include"` * so the auth cookie rides along, same envelope unwrapping. Kept in its * own module so the streak feature doesn't bloat the social client. */ const BFF_BASE = process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:8080"; export class SadhnaError extends Error { constructor( public status: number, message: string, ) { super(message); this.name = "SadhnaError"; } } type Envelope = { success: boolean; data?: T; error?: { code: number; message: string }; }; async function call(path: string, init?: RequestInit): Promise { const res = await fetch(`${BFF_BASE}/api/v1${path}`, { credentials: "include", headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) }, ...init, }); if (res.status === 204) return undefined as T; const body = (await res.json().catch(() => ({}))) as Envelope; if (!res.ok || body.success === false) { throw new SadhnaError(res.status, body.error?.message || res.statusText); } return (body.data ?? (undefined as unknown as T)) as T; } // ─── Types ─────────────────────────────────────────────────────────────── /** Plant growth stage, mirrors the BFF's stageFor(). */ export type SadhnaStage = | "dead" | "sprout" | "seedling" | "sapling" | "young" | "mature"; /** One cell of the 7-day streak strip. */ export type SadhnaDay = { date: string; // YYYY-MM-DD (UTC) chant_done: boolean; quote_done: boolean; completed: boolean; }; /** * One fold of the sādhana creeper — a single day the vine has wound around * its pole. The vine grows a fresh fold for each day of the current streak * (oldest first); the final fold may be today, still in progress (the * growing tip). `rounds`/`posts` annotate what was offered that day. */ export type VineDay = { date: string; // YYYY-MM-DD (UTC) rounds: number; posts: number; completed: boolean; in_progress: boolean; }; export type SadhnaDashboard = { // Today's two disciplines. target_rounds: number; rounds_completed: number; chant_done: boolean; quote_done: boolean; today_completed: boolean; // Streak counters. current_streak: number; longest_streak: number; // Plant state (growth 0–100). growth: number; stage: SadhnaStage; alive: boolean; // Trailing 7 days, oldest first. week: SadhnaDay[]; // The creeper: one fold per day of the current streak (oldest first), // plus an in-progress fold for an unfinished today. Capped server-side; // current_streak holds the true length. vine_days: VineDay[]; // Rewards. `next_reward_date` is the projected UTC date (YYYY-MM-DD) of the // next gift if the streak continues. `pending_reward` is a freshly earned // gift to celebrate now (null when there's nothing to show). next_reward_date: string | null; pending_reward: SadhnaRewardGrant | null; }; /** A gift item in the streak-reward catalog. */ export type SadhnaReward = { id: string; key: string; name: string; meaning: string; image_url: string; active: boolean; sort: number; }; /** A reward earned at a streak milestone (a multiple of 7 days). */ export type SadhnaRewardGrant = { milestone: number; granted_at: string; reward: SadhnaReward; }; // ─── API ─────────────────────────────────────────────────────────────── export const sadhna = { /** Today's progress + streak + plant state. */ dashboard: () => call("/sadhna/dashboard"), /** Per-day state for an inclusive UTC date range (YYYY-MM-DD), for the * calendar's week/month views. Range is bounded to a year server-side. */ history: (from: string, to: string) => call( `/sadhna/history?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`, ), /** Save today's chanting round count; returns the recomputed dashboard. */ saveChant: (roundsCompleted: number) => call("/sadhna/chant", { method: "POST", body: JSON.stringify({ rounds_completed: roundsCompleted }), }), /** The user's collected gifts (newest first) + giftable catalog size. */ rewards: () => call<{ collected: SadhnaRewardGrant[]; total: number }>("/sadhna/rewards"), /** Acknowledge pending celebrations after showing the confetti. */ ackRewards: () => call("/sadhna/rewards/ack", { method: "POST" }), // Reminder prefs moved to the notification matrix — // see `notifications.prefs()` in lib/social.ts. /** Report the browser's IANA timezone so reminders fire at local time. */ setTimezone: (timezone: string) => call("/sadhna/timezone", { method: "POST", body: JSON.stringify({ timezone }), }), };