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..c78af85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ 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 client/ticket picker APIs and 0-duration Halo time-entry-backed work + logs for Todo updates. +- Added Todo work-log history reads from Halo time entries. +- 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..4b19eb0 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,24 @@ 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, customer/ticket pickers, work +logs backed by Halo time entries, 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. + +Todo priority is currently stored as HaloCLI metadata in the backing +appointment `note_html`; it is not mapped to Halo ticket priority or a native +Halo appointment priority field. + ## 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..194b707 --- /dev/null +++ b/frontend/src/App.test.tsx @@ -0,0 +1,188 @@ +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: [], + time_entries: [], + 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: [], + time_entries: [], + 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], + time_entries: [...item.time_entries] + })) + }; + }, + 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: [], + time_entries: [], + 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 }; + }, + async logTime(id, payload) { + const item = state.find((todo) => todo.id === id)!; + const entry = { id: 9001, todo_id: id, duration_minutes: payload.minutes ?? 0, note: payload.note }; + item.time_entries.push(entry); + return { time_entry: entry, todo: item }; + }, + async listTimeEntries(id) { + const item = state.find((todo) => todo.id === id)!; + return { count: item.time_entries.length, items: item.time_entries }; + }, + async searchClients() { + return { items: [{ id: 12, name: "Midtown Technology Group" }] }; + }, + async searchTickets(_query, clientId) { + return { items: [{ id: 12345, summary: "Backup alert", client_id: clientId ?? 12, status: "Open" }] }; + }, + async me() { + return { id: 37, name: "Thomas Bray", client_id: 12, client_name: "Midtown Technology Group" }; + } + }; +} + +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("quick-add can select a customer and related ticket", async () => { + const user = userEvent.setup(); + render(); + + await user.click(await screen.findByRole("button", { name: "Choose customer" })); + await user.click(screen.getAllByRole("option", { name: "Midtown Technology Group" })[0]); + await user.click(screen.getByRole("button", { name: "Choose ticket" })); + await user.click(screen.getAllByRole("option", { name: "Backup alert" })[0]); + await user.type(screen.getByLabelText("Quick add title"), "Customer-linked task"); + await user.click(screen.getByRole("button", { name: "Add task" })); + + expect(await screen.findByText("Customer-linked task")).toBeInTheDocument(); + expect(screen.getByText("Client #12")).toBeInTheDocument(); + expect(screen.getAllByText("Ticket #12345").length).toBeGreaterThan(0); + }); + + test("work log submits a zero-duration time entry", async () => { + const user = userEvent.setup(); + render(); + + await user.type(await screen.findByLabelText("Work log note"), "Reviewed alert context."); + await user.clear(screen.getByLabelText("Minutes")); + await user.type(screen.getByLabelText("Minutes"), "0"); + await user.click(screen.getByRole("button", { name: "Log work" })); + + expect(await screen.findByText("Reviewed alert context.")).toBeInTheDocument(); + expect(screen.getByText("0 min")).toBeInTheDocument(); + }); + + test("loads work log history for the selected task", async () => { + const api = fakeApi(); + const first = todos[0]; + first.time_entries = [{ id: 9003, todo_id: first.id, duration_minutes: 0, note: "Historical work log" }]; + + render(); + + expect(await screen.findByText("Historical work log")).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..cce03da --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,408 @@ +import { FormEvent, useEffect, useMemo, useRef, useState } from "react"; +import type { ClientOption, TicketOption, 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 [clients, setClients] = useState([]); + const [tickets, setTickets] = useState([]); + const [quickClientId, setQuickClientId] = useState(null); + const [quickTicketId, setQuickTicketId] = useState(null); + const [showClients, setShowClients] = useState(false); + const [showTickets, setShowTickets] = useState(false); + 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]); + + useEffect(() => { + async function loadPickers() { + const [clientResult, me] = await Promise.all([api.searchClients(""), api.me()]); + setClients(clientResult.items); + setQuickClientId(me.client_id ?? clientResult.items[0]?.id ?? null); + } + loadPickers(); + }, []); + + useEffect(() => { + async function loadTickets() { + const result = await api.searchTickets("", quickClientId); + setTickets(result.items); + } + loadTickets(); + }, [quickClientId]); + + 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(() => { + async function loadTimeEntries() { + if (!selected) return; + const result = await api.listTimeEntries(selected.id); + setTodos((items) => + items.map((todo) => (todo.id === selected.id ? { ...todo, time_entries: result.items } : todo)) + ); + } + loadTimeEntries(); + }, [selected?.id]); + + 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, client_id: quickClientId, ticket_id: quickTicketId }); + 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" + /> + client.id === quickClientId)?.name ?? "Customer"} + open={showClients} + onToggle={() => setShowClients((value) => !value)} + options={clients.map((client) => ({ id: client.id, label: client.name }))} + onSelect={(id) => { + setQuickClientId(id); + setQuickTicketId(null); + setShowClients(false); + }} + /> + ticket.id === quickTicketId)?.summary ?? "Ticket"} + open={showTickets} + onToggle={() => setShowTickets((value) => !value)} + options={tickets.map((ticket) => ({ id: ticket.id, label: ticket.summary }))} + onSelect={(id) => { + setQuickTicketId(id); + setShowTickets(false); + }} + /> + + + setQuery(event.target.value)} + placeholder="Search" + /> +
{busy ? "Loading" : `${visibleTodos.length} tasks`}
+
+ {visibleTodos.map((todo) => ( + + ))} +
+
+ + { + const result = await api.logTime(id, payload); + setTodos((items) => items.map((todo) => (todo.id === id ? result.todo : todo))); + }} + /> +
+ ); +} + +function Picker({ + label, + value, + open, + options, + onToggle, + onSelect +}: { + label: string; + value: string; + open: boolean; + options: { id: number; label: string }[]; + onToggle: () => void; + onSelect: (id: number) => void; +}) { + return ( +
+ + {open ? ( +
+ {options.map((option) => ( + + ))} +
+ ) : null} +
+ ); +} + +function TaskDetail({ + todo, + clients, + tickets, + onComplete, + onSave, + onLogTime +}: { + todo: Todo | null; + clients: ClientOption[]; + tickets: TicketOption[]; + onComplete: (id: number) => void; + onSave: (patch: Partial) => void; + onLogTime: (id: number, payload: { note: string; minutes: number; client_id?: number | null; ticket_id?: number | null }) => void; +}) { + const [workNote, setWorkNote] = useState(""); + const [minutes, setMinutes] = useState("0"); + if (!todo) return ; + + function submitWorkLog(event: FormEvent) { + event.preventDefault(); + if (!workNote.trim()) return; + onLogTime(todo.id, { + note: workNote, + minutes: Number(minutes || 0), + client_id: todo.client_id, + ticket_id: todo.ticket_id + }); + setWorkNote(""); + setMinutes("0"); + } + + return ( +