Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ __pycache__/
.ruff_cache/
dist/
build/
node_modules/
.coverage
htmlcov/
.env
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
Expand Up @@ -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 *
22 changes: 20 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
188 changes: 188 additions & 0 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<App api={fakeApi()} />);

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(<App api={fakeApi()} />);

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(<App api={fakeApi()} />);

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(<App api={fakeApi()} />);

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(<App api={api} />);

expect(await screen.findByText("Historical work log")).toBeInTheDocument();
});

test("slash focuses search and filters task list", async () => {
const user = userEvent.setup();
render(<App api={fakeApi()} />);

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();
});
});
Loading
Loading