Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/salty-seals-wonder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"wtc": patch
---
Comment thread
marlonmarcello marked this conversation as resolved.

Adds timers feature to CLI
18 changes: 18 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,24 @@ bun run build # Build standalone binary
bun run changeset # Add a release changeset
```

## Project Architecture

Source code is split into three layers:

| Layer | Directory | Responsibility |
| ------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------- |
| **API** | `src/api/` | Business logic + domain data types. Organized by domain: `teamwork/`, `config/`, `state/`, `cache/`. No rendering or stdout. |
| **CLI** | `src/cli/` | Command-line interface. yargs parsing + stdout output. Calls `src/api/`. |
| **TUI** | `src/tui/` | Terminal UI. Solid JSX components via OpenTUI. Calls `src/api/`. |

The API layer is the only layer that both CLI and TUI import from. Each subdirectory has a `README.md` with detailed conventions:

- [`src/api/README.md`](src/api/README.md) — shared business logic conventions
- [`src/cli/README.md`](src/cli/README.md) — CLI handler patterns
- [`src/tui/README.md`](src/tui/README.md) — TUI component conventions

Tests mirror the source structure under `tests/`.

## Building Binaries

Build a standalone binary for your current platform:
Expand Down
20 changes: 15 additions & 5 deletions plans/PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

A terminal UI tool for developers to manage GitHub repos, AWS Amplify projects, and Teamwork tasks.

- **Status:** Phase 5 Teamwork Foundation (5.1-5.2 complete, 5.4 in progress)
- **Status:** Phase 5 Teamwork (5.1-5.2 complete, 5.3-5.4 in progress)
- **Package Manager:** Bun
- **Runtime:** Bun (standalone binary distribution)
- **TUI:** @opentui/solid + solid-js
Expand Down Expand Up @@ -31,6 +31,16 @@ A terminal UI tool for developers to manage GitHub repos, AWS Amplify projects,

## Architecture

Source code is organised in three layers:

| Layer | Directory | Responsibility |
| ------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------- |
| **API** | `src/api/` | Business logic and domain data types. Organised by domain (`teamwork/`, `config/`, `state/`, `cache/`). No rendering or stdout. |
| **CLI** | `src/cli/` | Command-line interface. yargs parsing and stdout output. Calls `src/api/`. |
| **TUI** | `src/tui/` | Terminal UI. Solid JSX components via OpenTUI. Calls `src/api/`. |

The API layer is the only layer that both CLI and TUI import from. Each directory has a `README.md` with detailed conventions.

### File Organization Conventions

- Keep helpers scoped to the smallest place that needs them.
Expand All @@ -41,13 +51,13 @@ A terminal UI tool for developers to manage GitHub repos, AWS Amplify projects,
- Do not export helpers only for tests unless they represent meaningful domain behavior.
- Comments should explain why code exists or why a tradeoff was chosen, not restate obvious mechanics.
- Add comments to TypeScript interfaces/types when they clarify domain meaning or intended usage.
- Format-producing functions include a JSDoc `Example output:` block showing the expected output format.

Examples:

- `getCacheDir()` lives in `src/state/consts.ts` because it is shared and owns `WTC_CACHE_DIR`.
- `getUserConfigDir()` lives in `src/config/consts.ts` because it owns `WTC_CONFIG_DIR`.
- `STATE_FILE = "tui-state.json"` stays in `src/state/manager.ts` because it is state-manager-only.
- `getStatePath()` should not exist if it only appends `STATE_FILE` to `getCacheDir()` in one module.
- `getCacheDir()` lives in `src/api/cache/consts.ts` because it owns `WTC_CACHE_DIR`.
- `getUserConfigDir()` lives in `src/api/config/consts.ts` because it owns `WTC_CONFIG_DIR`.
- `STATE_FILE = "tui-state.json"` stays in `src/api/state/manager.ts` because it is state-manager-only.

## Phases

Expand Down
2 changes: 1 addition & 1 deletion scripts/inspect-teamwork-task-fields.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { fetchTeamworkApiJson } from "../src/teamwork/client.ts";
import { fetchTeamworkApiJson } from "../src/api/teamwork/client.ts";

const DEFAULT_TASK_IDS = [26523243, 26751525, 26751526] as const;
const DEFAULT_TASK_LIST_IDS = [1691926] as const;
Expand Down
47 changes: 47 additions & 0 deletions src/api/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# `src/api/` — Shared business logic layer

All business logic lives here, organized by domain. Both `src/cli/` and `src/tui/` import from `src/api/` — never the reverse.

## Domains

| Directory | Responsibility |
| ----------- | -------------------------------------------------------- |
| `teamwork/` | Teamwork API wrappers, local timers, task/data types |
| `config/` | Config file loading, saving, validation, schemas |
| `state/` | Per-directory TUI state persistence (route memory) |
| `cache/` | Cache directory management (`getCacheDir`, `clearCache`) |

## Conventions

### No presentation concerns

Functions in `src/api/` do not write to stdout, render JSX, or call `setMessage`. They take input and return data. Formatting output strings for display is the caller's responsibility (CLI handlers or TUI components).

### Dependency injection for side effects

Functions that touch I/O (filesystem, network, secrets) accept an optional `actions` parameter with default wiring to real implementations. This allows tests to inject mocks without module-level mocking.

```typescript
interface SubmitTimerActions {
stopLocalTimer: () => Promise<LocalTimerEntry | null>;
createTaskTimeEntry: (input: TeamworkTaskTimeEntryInput) => Promise<number>;
removeLocalTimer: (id: string) => Promise<void>;
}

export async function submitTimer(
timer: LocalTimerEntry,
actions: SubmitTimerActions,
): Promise<SubmitResult> {
// ...
}
```

Tests pass custom `actions` objects; production callers rely on the defaults.

### Pure functions stay pure

Utilities like `formatTimerDuration`, `getLocalTimerElapsedMs`, and Zod schemas are pure exports — no DI needed.

### Format comments

Functions that produce a formatted string for CLI/TUI display include a JSDoc `@example` or `Example output:` block showing the expected format. See `formatTimerListOutput` or `formatTeamworkTaskListPinnedOutput` for the pattern.
File renamed without changes.
10 changes: 10 additions & 0 deletions src/api/cache/manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { mkdir, rm } from "node:fs/promises";

import { getCacheDir } from "./consts.ts";

/** Deletes the entire cache directory. */
export async function clearCache(): Promise<void> {
const cacheDir = getCacheDir();
await rm(cacheDir, { recursive: true, force: true });
await mkdir(cacheDir, { recursive: true });
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
10 changes: 1 addition & 9 deletions src/state/manager.ts → src/api/state/manager.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { mkdir, rm } from "node:fs/promises";
import { resolve } from "node:path";

import { getCacheDir } from "./consts.ts";
import { getCacheDir } from "../cache/consts.ts";
import { TuiStateFileSchema, type TuiStateEntry, type TuiStateFile } from "./schema.ts";

const STATE_FILE = "tui-state.json";
Expand Down Expand Up @@ -49,10 +48,3 @@ export async function saveTuiState(dir: string, partial: Partial<TuiStateEntry>)

await Bun.write(path, `${JSON.stringify(file, null, 2)}\n`);
}

/** Deletes the entire cache directory. */
export async function clearCache(): Promise<void> {
const cacheDir = getCacheDir();
await rm(cacheDir, { recursive: true, force: true });
await mkdir(cacheDir, { recursive: true });
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { z } from "zod";

import { getCacheDir } from "../state/consts.ts";
import { getCacheDir } from "../cache/consts.ts";

import { fetchTeamworkApiJson } from "./client.ts";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,3 +204,48 @@ export async function getTeamworkTaskListTasks(taskListId: number): Promise<Team

return tasks;
}

/** A pinned task list with its fetched tasks (or an error message). */
export interface PinnedTaskListFetchResult {
id: number;
name: string;
tasks: TeamworkTask[];
error: string | null;
}

/** External actions injected by callers that want testable fetching. */
export interface GetPinnedTaskListTasksActions {
getTeamworkTaskListTasks: (taskListId: number) => Promise<TeamworkTask[]>;
}

/**
* Fetches tasks for each pinned task list with per-list error isolation.
* A single failing list does not prevent the others from loading.
*/
export async function getPinnedTaskListTasks(
pinnedTaskLists: readonly { id: number; name: string }[],
actions?: GetPinnedTaskListTasksActions,
): Promise<PinnedTaskListFetchResult[]> {
const fetchTasks = actions?.getTeamworkTaskListTasks ?? getTeamworkTaskListTasks;
const results: PinnedTaskListFetchResult[] = [];

for (const taskList of pinnedTaskLists) {
try {
results.push({
id: taskList.id,
name: taskList.name,
tasks: await fetchTasks(taskList.id),
error: null,
});
} catch (error) {
results.push({
id: taskList.id,
name: taskList.name,
tasks: [],
error: error instanceof Error ? error.message : "Failed to load task list.",
});
}
}

return results;
}
23 changes: 23 additions & 0 deletions src/api/teamwork/task.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { z } from "zod";

import { fetchTeamworkApiJson } from "./client.ts";

const TeamworkTaskByIdResponseSchema = z.object({
task: z.object({
id: z.union([z.number().int().positive(), z.string().regex(/^\d+$/).transform(Number)]),
name: z.string().optional(),
title: z.string().optional(),
content: z.string().optional(),
}),
});

/** Fetches a single Teamwork task by ID and returns its id and name. */
export async function getTeamworkTaskById(taskId: number): Promise<{ id: number; name: string }> {
const parsed = TeamworkTaskByIdResponseSchema.parse(
await fetchTeamworkApiJson(`/tasks/${taskId}.json`),
);
const name = parsed.task.name ?? parsed.task.title ?? parsed.task.content;
if (!name) throw new Error("Teamwork task response did not include a task name.");

return { id: parsed.task.id, name };
}
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getCacheDir } from "../../state/consts.ts";
import { getCacheDir } from "../../cache/consts.ts";
import type { TeamworkTaskTimeEntryInput } from "../timers.ts";

const LOCAL_TIMERS_CACHE_FILE = "teamwork-local-timers.json";

Expand Down Expand Up @@ -122,3 +123,46 @@ export async function removeLocalTimer(id: string): Promise<void> {
const timers = await loadLocalTimers();
await saveLocalTimers(timers.filter((timer) => timer.id !== id));
}

/** Result metadata from a successful local-timer submission. */
export interface SubmitLocalTimerResult {
taskName: string;
taskId: number;
elapsedMs: number;
}

/** External actions needed by {@link submitLocalTimer}. */
export interface SubmitLocalTimerActions {
createTaskTimeEntry: (input: TeamworkTaskTimeEntryInput) => Promise<number>;
}

/**
* Stops the timer if running, creates a Teamwork time entry, and removes the
* local timer. Throws if the timer vanishes mid-stop.
*
* Caller is responsible for identifying the timer. Only the cross-module HTTP
* call is injected via actions; local timer operations are called directly.
*/
export async function submitLocalTimer(
timer: LocalTimerEntry,
actions: SubmitLocalTimerActions,
): Promise<SubmitLocalTimerResult> {
const timerToSubmit = timer.status === "running" ? await stopLocalTimer() : timer;
if (!timerToSubmit) {
throw new Error("No timer found to submit.");
}

const elapsedMs = getLocalTimerElapsedMs(timerToSubmit, new Date());
const totalMinutes = Math.max(1, Math.ceil(elapsedMs / 60_000));

await actions.createTaskTimeEntry({
taskId: timerToSubmit.taskId,
date: timerToSubmit.startTime.slice(0, 10),
hours: Math.floor(totalMinutes / 60),
minutes: totalMinutes % 60,
description: timerToSubmit.taskName,
});

await removeLocalTimer(timerToSubmit.id);
return { taskName: timerToSubmit.taskName, taskId: timerToSubmit.taskId, elapsedMs };
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { z } from "zod";

import { getCacheDir } from "../state/consts.ts";
import { getCacheDir } from "../cache/consts.ts";
import { fetchTeamworkApiJson } from "./client.ts";

const WORKFLOW_STAGES_CACHE_FILE = "teamwork-workflow-stages.json";
Expand Down
41 changes: 41 additions & 0 deletions src/cli/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# `src/cli/` — CLI presentation layer

Parses CLI arguments via yargs and prints output to stdout. Never contains business logic — calls `src/api/` functions for that.

## Structure

| Path | Purpose |
| ----------------------- | ------------------------------------------------------------------ |
| `parser.ts` | yargs setup: registers all top-level commands |
| `commands/*-command.ts` | yargs command wiring (builder, handler) for each top-level command |
| `commands/*.ts` | CLI handler functions: call api/, format output, print |

## Conventions

### Handler pattern

Each handler function accepts a plain args object and an optional `actions` parameter for dependency injection:

```typescript
export async function teamworkTimerList(
args: { json: boolean },
actions = timerListActions,
): Promise<void> {
const timers = await actions.loadLocalTimers();
console.log(formatTimerListOutput(timers, { json: args.json }));
}
```

The default `actions` object wires real implementations. Tests pass custom stubs.

### Output

All user-facing output goes through `console.log`. Pure formatting functions live alongside the handler and include a format example in their doc comment.

### Yargs wiring

Command modules in `*-command.ts` files define the yargs tree. Each module:

- Declares positional args and options in `builder`
- Calls the handler function in `handler`, passing destructured args
- Parent commands with subcommands get `handler: () => {}`
17 changes: 17 additions & 0 deletions src/cli/commands/cache-command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { CommandModule } from "yargs";

import { cacheClean } from "./cache.ts";

const cacheCleanCommand: CommandModule = {
command: "clean",
describe: "Delete all cached data",
handler: () => cacheClean(),
};

export const cacheCommand: CommandModule = {
command: "cache",
describe: "Manage local cache",
builder: (yargs) =>
yargs.command(cacheCleanCommand).demandCommand(1, "Specify a cache subcommand: clean"),
handler: () => {},
};
14 changes: 11 additions & 3 deletions src/cli/commands/cache.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import { clearCache } from "../../state/manager.ts";
import { clearCache as clearCacheFn } from "../../api/cache/manager.ts";

interface CacheCleanActions {
clearCache: () => Promise<void>;
}

const cacheCleanActions: CacheCleanActions = {
clearCache: clearCacheFn,
};

/** Deletes the entire WTC cache directory. */
export async function cacheClean(): Promise<void> {
await clearCache();
export async function cacheClean(actions = cacheCleanActions): Promise<void> {
await actions.clearCache();
console.log("Cache cleaned.");
}
Loading
Loading