109 lines
3.4 KiB
TypeScript
109 lines
3.4 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[];
|
||
};
|
||
|
||
// ─── API ───────────────────────────────────────────────────────────────
|
||
|
||
export const sadhna = {
|
||
/** Today's progress + streak + plant state. */
|
||
dashboard: () => call<SadhnaDashboard>("/sadhna/dashboard"),
|
||
|
||
/** 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 }),
|
||
}),
|
||
};
|