feat(HAI-002): implement shared hooks and API layer

This commit is contained in:
Dustin Byrne
2026-03-25 18:25:13 -04:00
parent c0c1ce0e83
commit 57a8015ee3
3 changed files with 142 additions and 0 deletions

View File

@@ -0,0 +1,37 @@
import type { Task, TaskDetail, TaskCreateInput, Column } from "@hai/core";
async function api<T = unknown>(path: string, opts: RequestInit = {}): Promise<T> {
const res = await fetch(`/api${path}`, {
headers: { "Content-Type": "application/json" },
...opts,
});
const data = await res.json();
if (!res.ok) throw new Error((data as { error?: string }).error || "Request failed");
return data as T;
}
export function fetchTasks(): Promise<Task[]> {
return api<Task[]>("/tasks");
}
export function fetchTaskDetail(id: string): Promise<TaskDetail> {
return api<TaskDetail>(`/tasks/${id}`);
}
export function createTask(input: TaskCreateInput): Promise<Task> {
return api<Task>("/tasks", {
method: "POST",
body: JSON.stringify(input),
});
}
export function moveTask(id: string, column: Column): Promise<Task> {
return api<Task>(`/tasks/${id}/move`, {
method: "POST",
body: JSON.stringify({ column }),
});
}
export function deleteTask(id: string): Promise<Task> {
return api<Task>(`/tasks/${id}`, { method: "DELETE" });
}

View File

@@ -0,0 +1,63 @@
import { useState, useEffect, useCallback, useRef } from "react";
import type { Task, Column, TaskCreateInput } from "@hai/core";
import * as api from "../api";
export function useTasks() {
const [tasks, setTasks] = useState<Task[]>([]);
const tasksRef = useRef(tasks);
tasksRef.current = tasks;
// Fetch initial tasks
useEffect(() => {
api.fetchTasks().then(setTasks).catch(() => setTasks([]));
}, []);
// SSE live updates
useEffect(() => {
const es = new EventSource("/api/events");
es.addEventListener("task:created", (e) => {
const task: Task = JSON.parse(e.data);
setTasks((prev) => [...prev, task]);
});
es.addEventListener("task:moved", (e) => {
const { task }: { task: Task } = JSON.parse(e.data);
setTasks((prev) => prev.map((t) => (t.id === task.id ? task : t)));
});
es.addEventListener("task:updated", (e) => {
const task: Task = JSON.parse(e.data);
setTasks((prev) => prev.map((t) => (t.id === task.id ? task : t)));
});
es.addEventListener("task:deleted", (e) => {
const task: Task = JSON.parse(e.data);
setTasks((prev) => prev.filter((t) => t.id !== task.id));
});
es.addEventListener("error", () => {
setTimeout(() => {
if (es.readyState === EventSource.CLOSED) {
// Will reconnect via new effect cycle
}
}, 3000);
});
return () => es.close();
}, []);
const createTask = useCallback(async (input: TaskCreateInput): Promise<Task> => {
return api.createTask(input);
}, []);
const moveTask = useCallback(async (id: string, column: Column): Promise<Task> => {
return api.moveTask(id, column);
}, []);
const deleteTask = useCallback(async (id: string): Promise<Task> => {
return api.deleteTask(id);
}, []);
return { tasks, createTask, moveTask, deleteTask };
}

View File

@@ -0,0 +1,42 @@
import { useState, useCallback, useRef, createContext, useContext } from "react";
import type { ReactNode } from "react";
import React from "react";
export type ToastType = "success" | "error" | "info";
export interface Toast {
id: number;
message: string;
type: ToastType;
}
interface ToastContextValue {
toasts: Toast[];
addToast: (message: string, type?: ToastType) => void;
removeToast: (id: number) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const nextId = useRef(0);
const removeToast = useCallback((id: number) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
const addToast = useCallback((message: string, type: ToastType = "info") => {
const id = nextId.current++;
setToasts((prev) => [...prev, { id, message, type }]);
setTimeout(() => removeToast(id), 4000);
}, [removeToast]);
return React.createElement(ToastContext.Provider, { value: { toasts, addToast, removeToast } }, children);
}
export function useToast(): ToastContextValue {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error("useToast must be used within ToastProvider");
return ctx;
}