Mbonea's picture
Add discretion, positive reinforcement, and low-friction daily
8d145ad
Raw
History Blame Contribute Delete
15.2 kB
/** 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<T> = {
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<EntryCreate & { ts: string }>;
export type DailyUpsert = Omit<DailyRow, "date" | "points" | "updated_at">;
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<string, string> | 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<Entry["result"], number>;
by_remedy: StatRow[];
by_tag: StatRow[];
by_emotion: StatRow[];
by_intensity_bucket: StatRow[];
daily: Record<string, number>;
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<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
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<T>;
if (!body.ok || body.error) {
throw new ApiError(
body.error || { code: "internal", message: "Request failed" },
);
}
return body.data as T;
}
export function getMe(): Promise<MeData> {
return api<MeData>("/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<Entry> {
return api<Entry>("/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<EntryListData> {
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<EntryListData>(`/api/entries${qs ? `?${qs}` : ""}`);
}
export function getEntry(id: string): Promise<Entry> {
return api<Entry>(`/api/entries/${encodeURIComponent(id)}`);
}
export function updateEntry(id: string, payload: EntryUpdate): Promise<Entry> {
return api<Entry>(`/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<Entry["result"], "pending">; remedy?: string },
): Promise<Entry> {
return updateEntry(id, payload);
}
export function listPending(limit = 50): Promise<EntryListData> {
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<DailyRow> {
return api<DailyRow>(`/api/daily/${day}`);
}
export function listDaily(start: string, end: string): Promise<DailyRangeData> {
return api<DailyRangeData>(
`/api/daily?start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}`,
);
}
export function putDaily(day: string, payload: DailyUpsert): Promise<DailyRow> {
return api<DailyRow>(`/api/daily/${day}`, {
method: "PUT",
body: JSON.stringify(payload),
});
}
export function runCoach(payload: {
text?: string;
entry_id?: string;
persist?: boolean;
}): Promise<CoachResponse> {
return api<CoachResponse>("/api/coach", {
method: "POST",
body: JSON.stringify(payload),
});
}
export function coachEntry(id: string): Promise<CoachResponse> {
return api<CoachResponse>(`/api/entries/${encodeURIComponent(id)}/coach`, {
method: "POST",
body: JSON.stringify({}),
});
}
export function getStats(): Promise<StatsData> {
return api<StatsData>("/api/stats");
}
export function getRemedies(): Promise<{ items: StatRow[]; min_n: number }> {
return api("/api/stats/remedies");
}
export function getSettingsStatus(): Promise<SettingsStatus> {
return api("/api/settings/status");
}
export function logout(): Promise<{ authenticated: boolean }> {
return api("/api/auth/logout", { method: "POST" });
}
export async function getDebugPaste(): Promise<string> {
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<never>;
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<Blob> {
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<Record<string, unknown>>;
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<DayPlanView> {
return api(`/api/plan/${day}`);
}
export function putPlan(
day: string,
payload: {
blocks: Partial<ScheduledBlock>[];
source?: string;
notes?: string;
capacity_hint?: number;
force_p0_move?: boolean;
},
): Promise<DayPlanView> {
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<DayPlanView> {
return api(`/api/plan/${day}/blocks`, {
method: "POST",
body: JSON.stringify(payload),
});
}
export function patchPlanBlock(
day: string,
id: string,
payload: Partial<ScheduledBlock>,
): Promise<DayPlanView> {
return api(`/api/plan/${day}/blocks/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify(payload),
});
}
export function deletePlanBlock(day: string, id: string): Promise<DayPlanView> {
return api(`/api/plan/${day}/blocks/${encodeURIComponent(id)}`, {
method: "DELETE",
});
}
export function postBlockFeedback(
day: string,
id: string,
payload: Partial<BlockFeedback> & { 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<Record<string, unknown>>; 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<DayPlanView> {
return api(`/api/plan/${day}/seed`, { method: "POST", body: "{}" });
}
export function importChatPlan(
day: string,
plan: unknown,
mode: "replace" | "merge" = "replace",
): Promise<DayPlanView> {
return api(`/api/plan/${day}/import`, {
method: "POST",
body: JSON.stringify({ plan, mode }),
});
}
export function exportChatPlan(day: string): Promise<ChatPlan> {
return api(`/api/plan/${day}/export`);
}
export function checkPlanBlock(
day: string,
id: string,
payload: { status: "done" | "partial" | "skipped" | "planned"; skip_reason?: string },
): Promise<DayPlanView> {
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<DayReview>;
},
): Promise<DayPlanView> {
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<LoopSummary> {
return api(`/api/loop/summary?days=${days}`);
}