From 462af3341c09dcfceba3dc029082ba133a985448 Mon Sep 17 00:00:00 2001 From: Thomas Bray Date: Mon, 27 Apr 2026 07:47:34 -0400 Subject: [PATCH 1/5] Add Halo Todo web UI release --- .gitignore | 1 + CHANGELOG.md | 8 + MANIFEST.in | 1 + README.md | 17 +- RELEASE.md | 8 +- frontend/src/App.test.tsx | 119 + frontend/src/App.tsx | 236 ++ frontend/src/api.ts | 55 + frontend/src/main.tsx | 9 + frontend/src/styles.css | 268 ++ frontend/src/test-setup.ts | 1 + index.html | 12 + package-lock.json | 2950 +++++++++++++++++ package.json | 24 + pyproject.toml | 9 +- src/halocli/__init__.py | 2 +- src/halocli/cli.py | 18 + src/halocli/todo.py | 167 +- src/halocli/todo_web.py | 182 + .../web_static/assets/index-BdlrrfrQ.js | 9 + .../web_static/assets/index-oM2j46sw.css | 1 + src/halocli/web_static/index.html | 13 + tests/test_todo.py | 52 + tests/test_todo_web.py | 100 + tsconfig.json | 20 + vite.config.ts | 14 + 26 files changed, 4286 insertions(+), 10 deletions(-) create mode 100644 frontend/src/App.test.tsx create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/styles.css create mode 100644 frontend/src/test-setup.ts create mode 100644 index.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/halocli/todo_web.py create mode 100644 src/halocli/web_static/assets/index-BdlrrfrQ.js create mode 100644 src/halocli/web_static/assets/index-oM2j46sw.css create mode 100644 src/halocli/web_static/index.html create mode 100644 tests/test_todo_web.py create mode 100644 tsconfig.json create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore index 9514bef..6d9a453 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ __pycache__/ .ruff_cache/ dist/ build/ +node_modules/ .coverage htmlcov/ .env diff --git a/CHANGELOG.md b/CHANGELOG.md index 38d83b8..784b50e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ HaloCLI uses semantic-ish versioning while it is young: patch releases are small fixes and packaging polish, minor releases may add commands or change operator workflows, and major releases are reserved for breaking CLI behavior. +## 0.5.0 - 2026-04-27 + +- Added `halocli todo web`, a local-first FastAPI/Vite React Todo web UI over + Halo appointment-backed tasks. +- Added normalized Todo API routes for listing, creating, updating, completing, + and noting tasks, preserving the HaloCLI metadata marker in `note_html`. +- Added the optional `web` package extra for FastAPI and Uvicorn. + ## 0.4.0 - 2026-04-26 - Added a central Halo resource registry. diff --git a/MANIFEST.in b/MANIFEST.in index 7813919..90ad964 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -3,3 +3,4 @@ include FRIDAY_PRESENT.md include THIRD_PARTY_NOTICES.md include CHANGELOG.md include RELEASE.md +recursive-include src/halocli/web_static * diff --git a/README.md b/README.md index 5bc86c3..60ab2f0 100644 --- a/README.md +++ b/README.md @@ -11,13 +11,13 @@ the same command surface. From the latest GitHub release tag: ```powershell -pipx install git+https://github.com/Midtown-Technology-Group/halocli.git@v0.4.0 +pipx install git+https://github.com/Midtown-Technology-Group/halocli.git@v0.5.0 ``` If you use `uv`: ```powershell -uv tool install git+https://github.com/Midtown-Technology-Group/halocli.git@v0.4.0 +uv tool install git+https://github.com/Midtown-Technology-Group/halocli.git@v0.5.0 ``` From a local checkout: @@ -187,6 +187,19 @@ Create a lightweight Halo Todo backed by Halo's `Appointment` API: halocli todo add "Independent todo list front end for HaloPSA" --owner 37 --due 2026-04-26 --tag microsoft-todo --tag halo-todo ``` +Run the local-first Todo web UI from the same HaloCLI profile: + +```powershell +python -m pip install -e ".[web]" +halocli todo web --profile midtown --host 127.0.0.1 --port 8766 +``` + +The web UI serves a compact three-pane task triage surface over Halo appointment +tasks: quick capture, Inbox/Today/Upcoming/Blocked/Completed views, search, +keyboard selection, completion, detail editing, notes, and source metadata for +imported Microsoft To Do items. HaloPSA remains the system of record; the UI API +returns normalized Todo JSON and does not create a local database. + ## Bifrost Compatibility This package does not import Bifrost. Bifrost workflows can shell out to diff --git a/RELEASE.md b/RELEASE.md index a0ebf25..651e893 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -33,8 +33,8 @@ The raw write command should exit nonzero and refuse the request unless ## Tag A Release ```powershell -git tag v0.4.0 -git push origin v0.4.0 +git tag v0.5.0 +git push origin v0.5.0 ``` Pushing a `v*.*.*` tag runs the release workflow, builds the wheel and source @@ -44,8 +44,8 @@ artifacts to a GitHub Release. ## Install From A Release Tag ```powershell -pipx install git+https://github.com/Midtown-Technology-Group/halocli.git@v0.4.0 -uv tool install git+https://github.com/Midtown-Technology-Group/halocli.git@v0.4.0 +pipx install git+https://github.com/Midtown-Technology-Group/halocli.git@v0.5.0 +uv tool install git+https://github.com/Midtown-Technology-Group/halocli.git@v0.5.0 ``` Use the tagged install form for demos and managed rollout scripts so everyone diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx new file mode 100644 index 0000000..6205a7d --- /dev/null +++ b/frontend/src/App.test.tsx @@ -0,0 +1,119 @@ +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test } from "vitest"; +import { App } from "./App"; +import type { TodoApi } from "./api"; + +const todos = [ + { + id: 10038, + title: "Independent todo list front end for HaloPSA", + description: "Build the first UI slice.", + status: "open", + priority: "high", + due_date: "2026-04-27", + owner: 37, + client_id: 12, + ticket_id: 12345, + tags: ["microsoft-todo", "Tasks"], + notes: [], + source_metadata: { source: "microsoft.todo" } + }, + { + id: 10039, + title: "Caller verification", + description: "", + status: "open", + priority: "normal", + due_date: null, + owner: 37, + client_id: null, + ticket_id: null, + tags: [], + notes: [], + source_metadata: { source: "halocli" } + } +]; + +function fakeApi(): TodoApi { + const state = [...todos]; + return { + async listTodos() { + return { count: state.length, items: state.map((item) => ({ ...item, notes: [...item.notes], tags: [...item.tags] })) }; + }, + async createTodo(payload) { + const created = { + id: 20000, + description: "", + status: "open", + priority: "normal", + due_date: null, + owner: null, + client_id: null, + ticket_id: null, + tags: [], + notes: [], + source_metadata: { source: "halocli" }, + ...payload + }; + state.unshift(created); + return { todo: created }; + }, + async updateTodo(id, payload) { + const item = state.find((todo) => todo.id === id)!; + Object.assign(item, payload); + return { todo: item }; + }, + async completeTodo(id) { + const item = state.find((todo) => todo.id === id)!; + item.status = "done"; + return { todo: item }; + }, + async addNote(id, note) { + const item = state.find((todo) => todo.id === id)!; + item.notes.push({ body: note }); + return { todo: item }; + } + }; +} + +describe("Halo Todo app", () => { + afterEach(() => cleanup()); + + test("renders the triage layout and selected task detail", async () => { + render(); + + expect(await screen.findByText("Inbox")).toBeInTheDocument(); + expect(screen.getByText("Independent todo list front end for HaloPSA")).toBeInTheDocument(); + expect(screen.getByText("Build the first UI slice.")).toBeInTheDocument(); + expect(screen.getByText("Ticket #12345")).toBeInTheDocument(); + }); + + test("quick-add creates a task and keyboard completion completes the selected task", async () => { + const user = userEvent.setup(); + render(); + + await user.type(await screen.findByLabelText("Quick add title"), "Review imported tasks"); + await user.click(screen.getByRole("button", { name: "Add task" })); + + expect(await screen.findByText("Review imported tasks")).toBeInTheDocument(); + + await user.keyboard("x"); + + await waitFor(() => { + expect(screen.getByText("Done")).toBeInTheDocument(); + }); + }); + + test("slash focuses search and filters task list", async () => { + const user = userEvent.setup(); + render(); + + await screen.findByText("Caller verification"); + await user.keyboard("/"); + await user.type(screen.getByLabelText("Search tasks"), "caller"); + + expect(screen.getByText("Caller verification")).toBeInTheDocument(); + expect(screen.queryByText("Independent todo list front end for HaloPSA")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..bd31549 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,236 @@ +import { FormEvent, useEffect, useMemo, useRef, useState } from "react"; +import type { Todo, TodoApi } from "./api"; +import { httpTodoApi } from "./api"; +import "./styles.css"; + +type ViewKey = "open" | "today" | "upcoming" | "blocked" | "done" | "tagged"; + +const views: { key: ViewKey; label: string }[] = [ + { key: "open", label: "Inbox" }, + { key: "today", label: "Today" }, + { key: "upcoming", label: "Upcoming" }, + { key: "blocked", label: "Blocked" }, + { key: "done", label: "Completed" }, + { key: "tagged", label: "Tagged" } +]; + +export function App({ api = httpTodoApi }: { api?: TodoApi }) { + const [todos, setTodos] = useState([]); + const [selectedId, setSelectedId] = useState(null); + const [view, setView] = useState("open"); + const [query, setQuery] = useState(""); + const [quickTitle, setQuickTitle] = useState(""); + const [busy, setBusy] = useState(false); + const searchRef = useRef(null); + const quickRef = useRef(null); + + async function refresh() { + setBusy(true); + try { + const result = await api.listTodos({ status: view === "done" ? "done" : view === "blocked" ? "blocked" : "open" }); + setTodos(result.items); + setSelectedId((current) => current ?? result.items[0]?.id ?? null); + } finally { + setBusy(false); + } + } + + useEffect(() => { + refresh(); + }, [view]); + + const visibleTodos = useMemo(() => { + const today = new Date().toISOString().slice(0, 10); + const needle = query.trim().toLowerCase(); + return todos.filter((todo) => { + if (view === "today" && todo.due_date !== today) return false; + if (view === "upcoming" && (!todo.due_date || todo.due_date <= today)) return false; + if (view === "tagged" && todo.tags.length === 0) return false; + if (!needle) return true; + return ( + todo.title.toLowerCase().includes(needle) || + todo.description.toLowerCase().includes(needle) || + todo.tags.some((tag) => tag.toLowerCase().includes(needle)) + ); + }); + }, [query, todos, view]); + + const selected = visibleTodos.find((todo) => todo.id === selectedId) ?? visibleTodos[0] ?? null; + + useEffect(() => { + if (selected && selected.id !== selectedId) setSelectedId(selected.id); + }, [selected, selectedId]); + + useEffect(() => { + function onKeyDown(event: KeyboardEvent) { + const target = event.target as HTMLElement | null; + const isTyping = target?.tagName === "INPUT" || target?.tagName === "TEXTAREA"; + if (event.key === "/" && !isTyping) { + event.preventDefault(); + searchRef.current?.focus(); + } + if (event.key === "n" && !isTyping) { + event.preventDefault(); + quickRef.current?.focus(); + } + if (event.key === "x" && !isTyping && selected) { + event.preventDefault(); + completeSelected(selected.id); + } + if ((event.key === "j" || event.key === "k") && !isTyping) { + event.preventDefault(); + moveSelection(event.key === "j" ? 1 : -1); + } + } + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [selected, visibleTodos]); + + async function addTask(event: FormEvent) { + event.preventDefault(); + const title = quickTitle.trim(); + if (!title) return; + const result = await api.createTodo({ title }); + setTodos((items) => [result.todo, ...items]); + setSelectedId(result.todo.id); + setQuickTitle(""); + quickRef.current?.blur(); + } + + async function completeSelected(id: number) { + const result = await api.completeTodo(id); + setTodos((items) => items.map((todo) => (todo.id === id ? result.todo : todo))); + } + + async function saveSelected(patch: Partial) { + if (!selected) return; + const result = await api.updateTodo(selected.id, patch); + setTodos((items) => items.map((todo) => (todo.id === selected.id ? result.todo : todo))); + } + + function moveSelection(delta: number) { + if (visibleTodos.length === 0) return; + const index = Math.max(0, visibleTodos.findIndex((todo) => todo.id === selected?.id)); + const next = visibleTodos[Math.min(visibleTodos.length - 1, Math.max(0, index + delta))]; + setSelectedId(next.id); + } + + return ( +
+ + +
+
+ setQuickTitle(event.target.value)} + placeholder="Capture a task" + /> + +
+ setQuery(event.target.value)} + placeholder="Search" + /> +
{busy ? "Loading" : `${visibleTodos.length} tasks`}
+
+ {visibleTodos.map((todo) => ( + + ))} +
+
+ + +
+ ); +} + +function TaskDetail({ + todo, + onComplete, + onSave +}: { + todo: Todo | null; + onComplete: (id: number) => void; + onSave: (patch: Partial) => void; +}) { + if (!todo) return ; + + return ( +