feat(HAI-004): add settings modal with GET/PUT API endpoints

- Extend config schema and store helpers to support settings persistence
- Add GET/PUT /settings REST API endpoints in dashboard routes
- Create SettingsModal component with frontend API wiring
- Add settings trigger button to Header and integrate into App
- Add CSS styles for settings modal and header actions
This commit is contained in:
Dustin Byrne
2026-03-25 20:06:02 -04:00
9 changed files with 212 additions and 10 deletions

View File

@@ -1,4 +1,4 @@
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS } from "./types.js";
export type { Column, Task, TaskCreateInput, TaskDetail, BoardConfig, MergeResult } from "./types.js";
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS } from "./types.js";
export type { Column, Task, TaskCreateInput, TaskDetail, BoardConfig, MergeResult, Settings } from "./types.js";
export { TaskStore } from "./store.js";
export { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js";

View File

@@ -3,8 +3,8 @@ import { execSync } from "node:child_process";
import { mkdir, readFile, writeFile, readdir } from "node:fs/promises";
import { join, sep } from "node:path";
import { existsSync, watch, type FSWatcher } from "node:fs";
import type { Task, TaskDetail, TaskCreateInput, BoardConfig, Column, MergeResult } from "./types.js";
import { VALID_TRANSITIONS } from "./types.js";
import type { Task, TaskDetail, TaskCreateInput, BoardConfig, Column, MergeResult, Settings } from "./types.js";
import { VALID_TRANSITIONS, DEFAULT_SETTINGS } from "./types.js";
export interface TaskStoreEvents {
"task:created": [task: Task];
@@ -44,6 +44,20 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
}
}
async getSettings(): Promise<Settings> {
const config = await this.readConfig();
return { ...DEFAULT_SETTINGS, ...config.settings };
}
async updateSettings(patch: Partial<Settings>): Promise<Settings> {
const config = await this.readConfig();
const current = { ...DEFAULT_SETTINGS, ...config.settings };
const updated = { ...current, ...patch };
config.settings = updated;
await this.writeConfig(config);
return updated;
}
private async readConfig(): Promise<BoardConfig> {
const data = await readFile(this.configPath, "utf-8");
return JSON.parse(data);

View File

@@ -24,8 +24,19 @@ export interface TaskCreateInput {
dependencies?: string[];
}
export interface Settings {
maxConcurrent: number;
pollIntervalMs: number;
}
export const DEFAULT_SETTINGS: Settings = {
maxConcurrent: 2,
pollIntervalMs: 15000,
};
export interface BoardConfig {
nextId: number;
settings?: Settings;
}
export interface MergeResult {

View File

@@ -4,6 +4,7 @@ import { fetchConfig } from "./api";
import { Header } from "./components/Header";
import { Board } from "./components/Board";
import { TaskDetailModal } from "./components/TaskDetailModal";
import { SettingsModal } from "./components/SettingsModal";
import { ToastContainer } from "./components/ToastContainer";
import { useTasks } from "./hooks/useTasks";
import { ToastProvider, useToast } from "./hooks/useToast";
@@ -11,6 +12,7 @@ import { ToastProvider, useToast } from "./hooks/useToast";
function AppInner() {
const [isCreating, setIsCreating] = useState(false);
const [detailTask, setDetailTask] = useState<TaskDetail | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const [maxConcurrent, setMaxConcurrent] = useState(2);
const { tasks, createTask, moveTask, deleteTask, mergeTask } = useTasks();
@@ -41,7 +43,7 @@ function AppInner() {
return (
<>
<Header />
<Header onOpenSettings={() => setSettingsOpen(true)} />
<Board
tasks={tasks}
maxConcurrent={maxConcurrent}
@@ -63,6 +65,9 @@ function AppInner() {
addToast={addToast}
/>
)}
{settingsOpen && (
<SettingsModal onClose={() => setSettingsOpen(false)} addToast={addToast} />
)}
<ToastContainer toasts={toasts} onRemove={removeToast} />
</>
);

View File

@@ -1,4 +1,4 @@
import type { Task, TaskDetail, TaskCreateInput, Column, MergeResult } from "@hai/core";
import type { Task, TaskDetail, TaskCreateInput, Column, MergeResult, Settings } from "@hai/core";
async function api<T = unknown>(path: string, opts: RequestInit = {}): Promise<T> {
const res = await fetch(`/api${path}`, {
@@ -43,3 +43,14 @@ export function mergeTask(id: string): Promise<MergeResult> {
export function fetchConfig(): Promise<{ maxConcurrent: number }> {
return api<{ maxConcurrent: number }>("/config");
}
export function fetchSettings(): Promise<Settings> {
return api<Settings>("/settings");
}
export function updateSettings(settings: Partial<Settings>): Promise<Settings> {
return api<Settings>("/settings", {
method: "PUT",
body: JSON.stringify(settings),
});
}

View File

@@ -1,10 +1,19 @@
export function Header() {
interface HeaderProps {
onOpenSettings?: () => void;
}
export function Header({ onOpenSettings }: HeaderProps) {
return (
<header className="header">
<div className="header-left">
<h1 className="logo">hai</h1>
<span className="logo-sub">board</span>
</div>
<div className="header-actions">
<button className="btn-icon" onClick={onOpenSettings} title="Settings">
⚙
</button>
</div>
</header>
);
}

View File

@@ -0,0 +1,104 @@
import { useState, useEffect, useCallback } from "react";
import type { Settings } from "@hai/core";
import { fetchSettings, updateSettings } from "../api";
import type { ToastType } from "../hooks/useToast";
interface SettingsModalProps {
onClose: () => void;
addToast: (message: string, type?: ToastType) => void;
}
export function SettingsModal({ onClose, addToast }: SettingsModalProps) {
const [form, setForm] = useState<Settings>({ maxConcurrent: 2, pollIntervalMs: 15000 });
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchSettings()
.then((s) => {
setForm(s);
setLoading(false);
})
.catch((err) => {
addToast(err.message, "error");
setLoading(false);
});
}, [addToast]);
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}, [onClose]);
const handleOverlayClick = useCallback(
(e: React.MouseEvent) => {
if (e.target === e.currentTarget) onClose();
},
[onClose],
);
const handleSave = useCallback(async () => {
try {
await updateSettings(form);
addToast("Settings saved", "success");
onClose();
} catch (err: any) {
addToast(err.message, "error");
}
}, [form, onClose, addToast]);
return (
<div className="modal-overlay open" onClick={handleOverlayClick}>
<div className="modal">
<div className="modal-header">
<h3>Settings</h3>
<button className="modal-close" onClick={onClose}>
&times;
</button>
</div>
{loading ? (
<div style={{ padding: "20px", textAlign: "center" }}>Loading…</div>
) : (
<div className="settings-form">
<div className="form-group">
<label htmlFor="maxConcurrent">Max Concurrent Tasks</label>
<input
id="maxConcurrent"
type="number"
min={1}
max={10}
value={form.maxConcurrent}
onChange={(e) =>
setForm((f) => ({ ...f, maxConcurrent: Number(e.target.value) }))
}
/>
</div>
<div className="form-group">
<label htmlFor="pollIntervalMs">Poll Interval (ms)</label>
<input
id="pollIntervalMs"
type="number"
min={5000}
step={1000}
value={form.pollIntervalMs}
onChange={(e) =>
setForm((f) => ({ ...f, pollIntervalMs: Number(e.target.value) }))
}
/>
</div>
</div>
)}
<div className="modal-actions">
<button className="btn btn-sm" onClick={onClose}>
Cancel
</button>
<button className="btn btn-primary btn-sm" onClick={handleSave} disabled={loading}>
Save
</button>
</div>
</div>
</div>
);
}

View File

@@ -41,6 +41,22 @@ html, body {
}
.header-left { display: flex; align-items: baseline; gap: 8px; }
.header-actions { display: flex; align-items: center; gap: 8px; }
.btn-icon {
background: none;
border: none;
color: var(--text-muted);
font-size: 18px;
cursor: pointer;
padding: 4px 8px;
border-radius: var(--radius);
transition: color 0.15s, background 0.15s;
}
.btn-icon:hover {
color: var(--text);
background: var(--border);
}
.logo { font-size: 20px; font-weight: 700; letter-spacing: -0.5px; }
.logo-sub { font-size: 13px; color: var(--text-muted); font-weight: 400; }
@@ -309,6 +325,14 @@ html, body {
}
.form-group textarea { resize: vertical; }
/* === Settings Form === */
.settings-form {
display: flex;
flex-direction: column;
gap: 4px;
padding-bottom: 8px;
}
/* === Detail Modal === */
.detail-title-row { display: flex; align-items: center; gap: 10px; }

View File

@@ -6,9 +6,33 @@ import type { ServerOptions } from "./server.js";
export function createApiRoutes(store: TaskStore, options?: ServerOptions): Router {
const router = Router();
// Scheduler config
router.get("/config", (_req, res) => {
res.json({ maxConcurrent: options?.maxConcurrent ?? 2 });
// Scheduler config (includes persisted settings)
router.get("/config", async (_req, res) => {
try {
const settings = await store.getSettings();
res.json({ maxConcurrent: settings.maxConcurrent ?? options?.maxConcurrent ?? 2 });
} catch {
res.json({ maxConcurrent: options?.maxConcurrent ?? 2 });
}
});
// Settings CRUD
router.get("/settings", async (_req, res) => {
try {
const settings = await store.getSettings();
res.json(settings);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
router.put("/settings", async (req, res) => {
try {
const settings = await store.updateSettings(req.body);
res.json(settings);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// List all tasks