/** Typed API client with credentials and envelope handling. */ export type ApiErrorBody = { code: string; message: string; }; export class ApiError extends Error { code: string; constructor(error: ApiErrorBody) { super(error.message); this.code = error.code; this.name = "ApiError"; } } type Envelope = { ok: boolean; data: T | null; error: ApiErrorBody | null; }; export type MeData = { authenticated: boolean; app_name: string; }; export type WalkType = "interrupt" | "fantasy" | "mixed"; export type Entry = { id: string; ts: string; created_at: string; updated_at: string; activity: string; happened: string; emotions: string[]; intensity: number; remedy: string; result: "worked" | "partial" | "failed" | "pending"; tags: string[]; notes: string; fse_spike?: boolean | null; avoidance_types?: string[]; proof_brick?: string | null; walk_type?: WalkType | null; coach: { text: string | null; source: string | null; model: string | null; ts: string | null; trace_id: string | null; }; }; export type EntryListData = { items: Entry[]; total: number; limit: number; offset: number; }; export type PrimaryBrick = "A" | "B" | "C" | "D" | "E" | "S" | "none"; export type ProofBrickKind = | "interrupt_walk" | "body_or_room" | "trip_admin" | "earn" | "boundary" | "food" | "survive" | "other" | "leave_room" | "admin_line" | "send_message" | "body_care"; export type DailyRow = { date: string; primary_brick: PrimaryBrick; brick_done: boolean; corn_sessions: number; delay_ok: boolean; daydream: "none" | "done" | "fc"; rerun: "clean" | "R"; court: "closed" | "court"; stayed_indoors_all_day: boolean; left_room: boolean; left_home: boolean; movement_minutes: number; interrupt_walk_minutes: number; fantasy_walk_minutes: number; headphones_on_walk: boolean; music_cinematic_on_walk: boolean; proof_brick_done: boolean; proof_brick_kind: ProofBrickKind | null; fantasy_minutes_scheduled: number; fantasy_minutes_unplanned: number; points: number; note: string; win?: string; updated_at: string; }; export type DailyRangeData = { items: DailyRow[]; week_points: number; band: "incomplete" | "strong" | "mixed" | "escape_heavy"; days_present: number; }; export type EntryCreate = { activity: string; happened: string; emotions: string[]; intensity: number; remedy: string; result: Entry["result"]; tags?: string[]; notes?: string; fse_spike?: boolean | null; avoidance_types?: string[]; proof_brick?: string | null; walk_type?: WalkType | null; }; export type EntryUpdate = Partial; export type DailyUpsert = Omit; export type ServerPick = { remedy_key: string; n: number; p_helped: number; p_worked?: number; rank?: number; pick?: number; match?: number; }; export type CoachResponse = { text: string; source: "model" | "backup" | "model_unparsed_fallback"; model: string | null; trace_id: string; flags: string[]; server_picks: ServerPick[]; parsed: Record | null; }; export type StatRow = { key: string; n: number; p_helped?: number; p_worked?: number; p_failed?: number; rank?: number; }; export type StatsData = { n_entries_total: number; n_entries_scored: number; outcomes: Record; by_remedy: StatRow[]; by_tag: StatRow[]; by_emotion: StatRow[]; by_intensity_bucket: StatRow[]; daily: Record; generated_at: string; DATA_THIN: boolean; }; export type SettingsStatus = { app_name: string; app_version: string; coach_configured: boolean; model: string; data_ok: boolean; env: string; }; function redirectLogin(): void { if (typeof window === "undefined") return; const hash = window.location.hash.replace(/^#/, "") || "/"; if (hash.startsWith("/login")) return; window.location.hash = "#/login"; } export async function api( path: string, options: RequestInit = {}, ): Promise { const headers = new Headers(options.headers || {}); if (!headers.has("Accept")) headers.set("Accept", "application/json"); if (options.body && !headers.has("Content-Type")) { headers.set("Content-Type", "application/json"); } const res = await fetch(path, { ...options, credentials: "include", headers, }); if (res.status === 401) { redirectLogin(); throw new ApiError({ code: "unauthorized", message: "Login required" }); } const body = (await res.json()) as Envelope; if (!body.ok || body.error) { throw new ApiError( body.error || { code: "internal", message: "Request failed" }, ); } return body.data as T; } export function getMe(): Promise { return api("/api/auth/me"); } export function login(password: string): Promise<{ authenticated: boolean }> { return api("/api/auth/login", { method: "POST", body: JSON.stringify({ password }), }); } export function createEntry(payload: EntryCreate): Promise { return api("/api/entries", { method: "POST", body: JSON.stringify(payload), }); } export function listEntries(params: { result?: Entry["result"]; tag?: string; start?: string; end?: string; limit?: number; offset?: number; }): Promise { const query = new URLSearchParams(); if (params.result) query.set("result", params.result); if (params.tag) query.set("tag", params.tag); if (params.start) query.set("start", params.start); if (params.end) query.set("end", params.end); if (params.limit) query.set("limit", String(params.limit)); if (params.offset) query.set("offset", String(params.offset)); const qs = query.toString(); return api(`/api/entries${qs ? `?${qs}` : ""}`); } export function getEntry(id: string): Promise { return api(`/api/entries/${encodeURIComponent(id)}`); } export function updateEntry(id: string, payload: EntryUpdate): Promise { return api(`/api/entries/${encodeURIComponent(id)}`, { method: "PATCH", body: JSON.stringify(payload), }); } /** PATCH helper for resolve flows (result + optional remedy). */ export function patchEntry( id: string, payload: { result: Exclude; remedy?: string }, ): Promise { return updateEntry(id, payload); } export function listPending(limit = 50): Promise { return listEntries({ result: "pending", limit, offset: 0 }); } export function deleteEntry(id: string): Promise<{ deleted: boolean; id: string }> { return api(`/api/entries/${encodeURIComponent(id)}`, { method: "DELETE" }); } export function getDaily(day: string): Promise { return api(`/api/daily/${day}`); } export function listDaily(start: string, end: string): Promise { return api( `/api/daily?start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}`, ); } export function putDaily(day: string, payload: DailyUpsert): Promise { return api(`/api/daily/${day}`, { method: "PUT", body: JSON.stringify(payload), }); } export function runCoach(payload: { text?: string; entry_id?: string; persist?: boolean; }): Promise { return api("/api/coach", { method: "POST", body: JSON.stringify(payload), }); } export function coachEntry(id: string): Promise { return api(`/api/entries/${encodeURIComponent(id)}/coach`, { method: "POST", body: JSON.stringify({}), }); } export function getStats(): Promise { return api("/api/stats"); } export function getRemedies(): Promise<{ items: StatRow[]; min_n: number }> { return api("/api/stats/remedies"); } export function getSettingsStatus(): Promise { return api("/api/settings/status"); } export function logout(): Promise<{ authenticated: boolean }> { return api("/api/auth/logout", { method: "POST" }); } export async function getDebugPaste(): Promise { const res = await fetch("/api/debug/last/paste", { credentials: "include" }); if (res.status === 401) { redirectLogin(); throw new ApiError({ code: "unauthorized", message: "Login required" }); } if (!res.ok) { let message = "Debug paste unavailable"; try { const body = (await res.json()) as Envelope; message = body.error?.message || message; } catch { // Plain response: keep the generic message. } throw new ApiError({ code: "request_failed", message }); } return res.text(); } export async function downloadExport(path: string): Promise { const res = await fetch(path, { credentials: "include" }); if (res.status === 401) { redirectLogin(); throw new ApiError({ code: "unauthorized", message: "Login required" }); } if (!res.ok) throw new ApiError({ code: "request_failed", message: "Export failed" }); return res.blob(); } /* —— Schedule / Plan —— */ export type TaskKind = | "earn_ship" | "admin_spain" | "body_care" | "move_out" | "boundary" | "food_out" | "stabilize" | "explore" | "restore_fun" | "sleep_window" | "other"; export type ScheduledBlock = { id: string; date: string; start: string; end: string; title: string; kind: TaskKind; intent: "duty" | "explore" | "restore_fun" | "measure"; priority: "P0" | "P1" | "P2"; planned_min: number; status: "planned" | "done" | "partial" | "skipped" | "moved" | "cancelled"; source: string; locked: boolean; notes: string; version_added: number; label?: string; feedback?: BlockFeedback | null; }; export type BlockFeedback = { block_id: string; date: string; did: "done" | "partial" | "skipped"; actual_min: number | null; quality: number | null; fun: number | null; energy_after: number | null; money_amount: number | null; money_currency: string; would_repeat: "yes" | "no" | "maybe" | null; skip_reason: string | null; note: string; emotions?: string[]; fse_event?: string; intensity?: number | null; strong: boolean; ts: string; }; export type DayReview = { comment: string; emotions: string[]; fse_events: string; what_moved: string; what_avoided: string; tomorrow_change: string; }; export type DayPlanView = { date: string; version: number; source: string; blocks: ScheduledBlock[]; capacity_hint: number; notes: string; title?: string; intention?: string; constraints?: string[]; day_review?: DayReview; warnings?: string[]; updated_at: string; parent_version: number | null; health?: { score: number; strong_feedback_count: number; planned_count: number; p0_done_rate: number; explore_or_restore_done: boolean; }; import_warnings?: string[]; }; export type ChatPlan = { schema_version: number; date: string; title: string; intention: string; constraints: string[]; blocks: Array>; day_review: DayReview; summary?: { blocks_total: number; blocks_done: number; blocks_skipped: number; p0_done: number; p0_total: number; planned_min_sum: number; actual_min_sum: number; }; }; export type ScheduleTemplate = { id: string; title: string; kind: TaskKind; intent: string; priority: string; default_min: number; label: string; }; export function getPlan(day: string): Promise { return api(`/api/plan/${day}`); } export function putPlan( day: string, payload: { blocks: Partial[]; source?: string; notes?: string; capacity_hint?: number; force_p0_move?: boolean; }, ): Promise { return api(`/api/plan/${day}`, { method: "PUT", body: JSON.stringify(payload), }); } export function addPlanBlock( day: string, payload: { start: string; end: string; title: string; kind: TaskKind; intent: ScheduledBlock["intent"]; priority: ScheduledBlock["priority"]; planned_min?: number; locked?: boolean; notes?: string; }, ): Promise { return api(`/api/plan/${day}/blocks`, { method: "POST", body: JSON.stringify(payload), }); } export function patchPlanBlock( day: string, id: string, payload: Partial, ): Promise { return api(`/api/plan/${day}/blocks/${encodeURIComponent(id)}`, { method: "PATCH", body: JSON.stringify(payload), }); } export function deletePlanBlock(day: string, id: string): Promise { return api(`/api/plan/${day}/blocks/${encodeURIComponent(id)}`, { method: "DELETE", }); } export function postBlockFeedback( day: string, id: string, payload: Partial & { did: BlockFeedback["did"] }, ): Promise<{ feedback: BlockFeedback; plan: DayPlanView }> { return api(`/api/plan/${day}/blocks/${encodeURIComponent(id)}/feedback`, { method: "POST", body: JSON.stringify(payload), }); } export function getScheduleTemplates(): Promise<{ items: ScheduleTemplate[] }> { return api("/api/schedule/templates"); } export function getSchedulePriors(): Promise<{ kinds: Array>; markdown: string }> { return api("/api/schedule/priors"); } export function reschedulePlan( day: string, payload: { reason?: string; force?: boolean } = {}, ): Promise<{ plan: DayPlanView; source: string; version: number }> { return api(`/api/plan/${day}/reschedule`, { method: "POST", body: JSON.stringify(payload), }); } export function seedPlan(day: string): Promise { return api(`/api/plan/${day}/seed`, { method: "POST", body: "{}" }); } export function importChatPlan( day: string, plan: unknown, mode: "replace" | "merge" = "replace", ): Promise { return api(`/api/plan/${day}/import`, { method: "POST", body: JSON.stringify({ plan, mode }), }); } export function exportChatPlan(day: string): Promise { return api(`/api/plan/${day}/export`); } export function checkPlanBlock( day: string, id: string, payload: { status: "done" | "partial" | "skipped" | "planned"; skip_reason?: string }, ): Promise { return api(`/api/plan/${day}/blocks/${encodeURIComponent(id)}/check`, { method: "POST", body: JSON.stringify(payload), }); } export function patchPlanMeta( day: string, payload: { title?: string; intention?: string; constraints?: string[]; day_review?: Partial; }, ): Promise { return api(`/api/plan/${day}/meta`, { method: "PATCH", body: JSON.stringify(payload), }); } export type LoopSummary = { days: number; as_of: string; indoors_streak: number; proof_rate: number; proof_days: number; days_with_daily: number; label_only_fail_rate: number; label_only_fail_n: number; high_intensity_n: number; movement_minutes_sum: number; pending_count: number; }; export function getLoopSummary(days = 7): Promise { return api(`/api/loop/summary?days=${days}`); }