156 lines
5.0 KiB
TypeScript
156 lines
5.0 KiB
TypeScript
/**
|
||
* 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<T> = {
|
||
success: boolean;
|
||
data?: T;
|
||
error?: { code: number; message: string };
|
||
};
|
||
|
||
async function call<T>(path: string, init?: RequestInit): Promise<T> {
|
||
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<T>;
|
||
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<SadhnaDashboard>("/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<SadhnaDay[]>(
|
||
`/sadhna/history?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`,
|
||
),
|
||
|
||
/** Save today's chanting round count; returns the recomputed dashboard. */
|
||
saveChant: (roundsCompleted: number) =>
|
||
call<SadhnaDashboard>("/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<void>("/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<void>("/sadhna/timezone", {
|
||
method: "POST",
|
||
body: JSON.stringify({ timezone }),
|
||
}),
|
||
};
|