From 2a149af644c420ab4cfb27596f5c37ced67bce17 Mon Sep 17 00:00:00 2001 From: Marlon Marcello Date: Wed, 24 Jun 2026 18:09:08 -0700 Subject: [PATCH 1/7] feature: adds timer to CLI --- src/cli/commands/cache-command.ts | 19 ++ src/cli/commands/cache.ts | 14 +- src/cli/commands/config-command.ts | 88 ++++++++ src/cli/commands/settings-command.ts | 12 ++ src/cli/commands/settings.ts | 14 +- src/cli/commands/teamwork-command.ts | 199 ++++++++++++++++++ src/cli/commands/timers.ts | 286 +++++++++++++++++++++++++ src/cli/commands/upgrade-command.ts | 17 ++ src/cli/commands/upgrade.ts | 14 +- src/cli/parser.ts | 227 ++------------------ src/teamwork/task.ts | 23 ++ tests/cli/commands/timers.test.ts | 301 +++++++++++++++++++++++++++ tests/teamwork/task.test.ts | 91 ++++++++ 13 files changed, 1082 insertions(+), 223 deletions(-) create mode 100644 src/cli/commands/cache-command.ts create mode 100644 src/cli/commands/config-command.ts create mode 100644 src/cli/commands/settings-command.ts create mode 100644 src/cli/commands/teamwork-command.ts create mode 100644 src/cli/commands/timers.ts create mode 100644 src/cli/commands/upgrade-command.ts create mode 100644 src/teamwork/task.ts create mode 100644 tests/cli/commands/timers.test.ts create mode 100644 tests/teamwork/task.test.ts diff --git a/src/cli/commands/cache-command.ts b/src/cli/commands/cache-command.ts new file mode 100644 index 0000000..4c2fb8c --- /dev/null +++ b/src/cli/commands/cache-command.ts @@ -0,0 +1,19 @@ +import type { CommandModule } from "yargs"; + +import { cacheClean } from "./cache.ts"; + +const cacheCleanCommand: CommandModule = { + command: "clean", + describe: "Delete all cached data", + handler: () => { + void cacheClean(); + }, +}; + +export const cacheCommand: CommandModule = { + command: "cache", + describe: "Manage local cache", + builder: (yargs) => + yargs.command(cacheCleanCommand).demandCommand(1, "Specify a cache subcommand: clean"), + handler: () => {}, +}; diff --git a/src/cli/commands/cache.ts b/src/cli/commands/cache.ts index be91687..f05bb1c 100644 --- a/src/cli/commands/cache.ts +++ b/src/cli/commands/cache.ts @@ -1,7 +1,15 @@ -import { clearCache } from "../../state/manager.ts"; +import { clearCache as clearCacheFn } from "../../state/manager.ts"; + +interface CacheCleanActions { + clearCache: () => Promise; +} + +const cacheCleanActions: CacheCleanActions = { + clearCache: clearCacheFn, +}; /** Deletes the entire WTC cache directory. */ -export async function cacheClean(): Promise { - await clearCache(); +export async function cacheClean(actions = cacheCleanActions): Promise { + await actions.clearCache(); console.log("Cache cleaned."); } diff --git a/src/cli/commands/config-command.ts b/src/cli/commands/config-command.ts new file mode 100644 index 0000000..bf6ba92 --- /dev/null +++ b/src/cli/commands/config-command.ts @@ -0,0 +1,88 @@ +import type { Argv, CommandModule } from "yargs"; + +import { + CONFIG_AUTH_PROVIDERS, + configAuthDelete, + configAuthSet, + configAuthStatus, + configInit, +} from "./config.ts"; + +const configInitCommand: CommandModule = { + command: "init", + describe: "Create a project config in the current directory", + handler: () => { + void configInit(); + }, +}; + +const configAuthSetCommand: CommandModule<{}, { provider: string; token: string }> = { + command: "set ", + describe: "Store a provider API token", + builder: (yargs) => + yargs + .positional("provider", { + type: "string", + choices: [...CONFIG_AUTH_PROVIDERS], + describe: "Auth provider", + }) + .option("token", { + type: "string", + describe: "API token to store", + demandOption: true, + }) as unknown as Argv<{ provider: string; token: string }>, + handler: (argv) => { + void configAuthSet({ provider: argv.provider ?? "", token: argv.token }); + }, +}; + +const configAuthStatusCommand: CommandModule<{}, { provider: string }> = { + command: "status ", + describe: "Show provider auth status", + builder: (yargs) => + yargs.positional("provider", { + type: "string", + choices: [...CONFIG_AUTH_PROVIDERS], + describe: "Auth provider", + }) as unknown as Argv<{ provider: string }>, + handler: (argv) => { + void configAuthStatus({ provider: argv.provider ?? "" }); + }, +}; + +const configAuthDeleteCommand: CommandModule<{}, { provider: string }> = { + command: "delete ", + describe: "Delete provider auth", + builder: (yargs) => + yargs.positional("provider", { + type: "string", + choices: [...CONFIG_AUTH_PROVIDERS], + describe: "Auth provider", + }) as unknown as Argv<{ provider: string }>, + handler: (argv) => { + void configAuthDelete({ provider: argv.provider ?? "" }); + }, +}; + +const configAuthCommand: CommandModule = { + command: "auth", + describe: "Manage provider credentials", + builder: (yargs) => + yargs + .command(configAuthSetCommand) + .command(configAuthStatusCommand) + .command(configAuthDeleteCommand) + .demandCommand(1, "Specify an auth subcommand: set, status, delete"), + handler: () => {}, +}; + +export const configCommand: CommandModule = { + command: "config", + describe: "Manage WTC config files", + builder: (yargs) => + yargs + .command(configInitCommand) + .command(configAuthCommand) + .demandCommand(1, "Specify a config subcommand: init, auth"), + handler: () => {}, +}; diff --git a/src/cli/commands/settings-command.ts b/src/cli/commands/settings-command.ts new file mode 100644 index 0000000..5090fab --- /dev/null +++ b/src/cli/commands/settings-command.ts @@ -0,0 +1,12 @@ +import type { CommandModule } from "yargs"; + +import { settings } from "./settings.ts"; + +/** `wtc settings` command. */ +export const settingsCommand: CommandModule = { + command: "settings", + describe: "Print resolved config and config file paths", + handler: () => { + void settings(); + }, +}; diff --git a/src/cli/commands/settings.ts b/src/cli/commands/settings.ts index bb23998..b357de3 100644 --- a/src/cli/commands/settings.ts +++ b/src/cli/commands/settings.ts @@ -1,6 +1,14 @@ -import { loadResolvedConfig } from "../../config/manager.ts"; +import { loadResolvedConfig as loadResolvedConfigFn } from "../../config/manager.ts"; import type { ResolvedConfig } from "../../config/schema.ts"; +interface SettingsActions { + loadResolvedConfig: (startDir: string) => Promise; +} + +const settingsActions: SettingsActions = { + loadResolvedConfig: loadResolvedConfigFn, +}; + /** * Formats resolved config for `wtc settings` output. * @@ -25,6 +33,6 @@ export function formatSettingsOutput(config: ResolvedConfig): string { } /** Prints the resolved WTC config and the paths used to build it. */ -export async function settings(startDir = process.cwd()): Promise { - console.log(formatSettingsOutput(await loadResolvedConfig(startDir))); +export async function settings(startDir = process.cwd(), actions = settingsActions): Promise { + console.log(formatSettingsOutput(await actions.loadResolvedConfig(startDir))); } diff --git a/src/cli/commands/teamwork-command.ts b/src/cli/commands/teamwork-command.ts new file mode 100644 index 0000000..6d8ce96 --- /dev/null +++ b/src/cli/commands/teamwork-command.ts @@ -0,0 +1,199 @@ +import type { Argv, CommandModule } from "yargs"; + +import { + teamworkTaskListPinned, + teamworkTaskListPin, + teamworkTaskListUnpin, + teamworkTaskOpen, +} from "./teamwork.ts"; +import { + teamworkTimerDiscard, + teamworkTimerList, + teamworkTimerStart, + teamworkTimerStop, + teamworkTimerSubmit, + teamworkTimesheetOpen, +} from "./timers.ts"; + +const taskListPinnedCommand: CommandModule<{}, { json: boolean }> = { + command: "pinned", + describe: "List pinned Teamwork task lists and tasks", + builder: (yargs) => + yargs.option("json", { + type: "boolean", + describe: "Print JSON output", + default: false, + }) as unknown as Argv<{ json: boolean }>, + handler: (argv) => { + void teamworkTaskListPinned({ json: argv.json ?? false }); + }, +}; + +const taskListPinCommand: CommandModule<{}, { taskListId: number; name: string }> = { + command: "pin ", + describe: "Pin a Teamwork task list in project config", + builder: (yargs) => + yargs + .positional("taskListId", { + type: "number", + describe: "Teamwork task list ID", + }) + .option("name", { + type: "string", + describe: "Display name for this task list", + demandOption: true, + }) as unknown as Argv<{ taskListId: number; name: string }>, + handler: (argv) => { + void teamworkTaskListPin({ + taskListId: argv.taskListId ?? 0, + name: argv.name ?? "", + }); + }, +}; + +const taskListUnpinCommand: CommandModule<{}, { taskListId: number }> = { + command: "unpin ", + describe: "Unpin a Teamwork task list from project config", + builder: (yargs) => + yargs.positional("taskListId", { + type: "number", + describe: "Teamwork task list ID", + }) as unknown as Argv<{ taskListId: number }>, + handler: (argv) => { + void teamworkTaskListUnpin({ taskListId: argv.taskListId ?? 0 }); + }, +}; + +const taskListCommand: CommandModule = { + command: "task-list", + describe: "Manage Teamwork task lists", + builder: (yargs) => + yargs + .command(taskListPinnedCommand) + .command(taskListPinCommand) + .command(taskListUnpinCommand) + .demandCommand(1, "Specify a task-list subcommand: pinned, pin, unpin"), + handler: () => {}, +}; + +const taskOpenCommand: CommandModule<{}, { task: string }> = { + command: "open ", + describe: "Open a Teamwork task in the browser", + builder: (yargs) => + yargs.positional("task", { + type: "string", + describe: "Teamwork task ID or URL", + }) as unknown as Argv<{ task: string }>, + handler: (argv) => { + void teamworkTaskOpen({ task: argv.task ?? "" }); + }, +}; + +const taskCommand: CommandModule = { + command: "task", + describe: "Manage Teamwork tasks", + builder: (yargs) => + yargs.command(taskOpenCommand).demandCommand(1, "Specify a task subcommand: open"), + handler: () => {}, +}; + +const timerListCommand: CommandModule<{}, { json: boolean }> = { + command: "list", + describe: "List local timers", + builder: (yargs) => + yargs.option("json", { + type: "boolean", + describe: "Print JSON output", + default: false, + }) as unknown as Argv<{ json: boolean }>, + handler: (argv) => { + void teamworkTimerList({ json: argv.json ?? false }); + }, +}; + +const timerStartCommand: CommandModule<{}, { task: string }> = { + command: "start ", + describe: "Start a timer for a Teamwork task", + builder: (yargs) => + yargs.positional("task", { + type: "string", + describe: "Teamwork task ID or URL", + }) as unknown as Argv<{ task: string }>, + handler: (argv) => { + void teamworkTimerStart({ task: argv.task ?? "" }); + }, +}; + +const timerStopCommand: CommandModule<{}, { task: string }> = { + command: "stop ", + describe: "Stop a running timer for a Teamwork task", + builder: (yargs) => + yargs.positional("task", { + type: "string", + describe: "Teamwork task ID or URL", + }) as unknown as Argv<{ task: string }>, + handler: (argv) => { + void teamworkTimerStop({ task: argv.task ?? "" }); + }, +}; + +const timerSubmitCommand: CommandModule<{}, { task: string }> = { + command: "submit ", + describe: "Submit a local timer as a Teamwork time entry", + builder: (yargs) => + yargs.positional("task", { + type: "string", + describe: "Teamwork task ID or URL", + }) as unknown as Argv<{ task: string }>, + handler: (argv) => { + void teamworkTimerSubmit({ task: argv.task ?? "" }); + }, +}; + +const timerDiscardCommand: CommandModule<{}, { task: string }> = { + command: "discard ", + describe: "Discard a local timer without submitting", + builder: (yargs) => + yargs.positional("task", { + type: "string", + describe: "Teamwork task ID or URL", + }) as unknown as Argv<{ task: string }>, + handler: (argv) => { + void teamworkTimerDiscard({ task: argv.task ?? "" }); + }, +}; + +const timerCommand: CommandModule = { + command: "timer", + describe: "Manage local timers", + builder: (yargs) => + yargs + .command(timerListCommand) + .command(timerStartCommand) + .command(timerStopCommand) + .command(timerSubmitCommand) + .command(timerDiscardCommand) + .demandCommand(1, "Specify a timer subcommand: list, start, stop, submit, discard"), + handler: () => {}, +}; + +const timesheetCommand: CommandModule = { + command: "timesheet", + describe: "Open the Teamwork timesheet in the browser", + handler: () => { + void teamworkTimesheetOpen(); + }, +}; + +export const teamworkCommand: CommandModule = { + command: "teamwork", + describe: "Manage Teamwork workflows", + builder: (yargs) => + yargs + .command(taskListCommand) + .command(taskCommand) + .command(timerCommand) + .command(timesheetCommand) + .demandCommand(1, "Specify a teamwork subcommand: task-list, task, timer, timesheet"), + handler: () => {}, +}; diff --git a/src/cli/commands/timers.ts b/src/cli/commands/timers.ts new file mode 100644 index 0000000..a437a99 --- /dev/null +++ b/src/cli/commands/timers.ts @@ -0,0 +1,286 @@ +import { TEAMWORK_TIMESHEET_URL } from "../../teamwork/consts.ts"; +import { getTeamworkTaskReference, type TeamworkTaskReference } from "../../teamwork/tasks.ts"; +import { getTeamworkTaskById } from "../../teamwork/task.ts"; +import { getLocalTimerElapsedMs, formatTimerDuration } from "../../teamwork/timers/local.ts"; +import type { LocalTimerEntry } from "../../teamwork/timers/local.ts"; +import { createTaskTimeEntry, type TeamworkTaskTimeEntryInput } from "../../teamwork/timers.ts"; +import { openUrlInBrowser } from "../../utils/browser.ts"; + +interface TimerListActions { + loadLocalTimers: () => Promise; +} + +interface TimerHandleActions { + getTeamworkTaskReference: (value: string) => TeamworkTaskReference; + loadLocalTimers: () => Promise; +} + +interface TimerStartActions { + getTeamworkTaskReference: (value: string) => TeamworkTaskReference; + getTeamworkTaskById: (id: number) => Promise<{ id: number; name: string }>; + startLocalTimer: (taskId: number, taskName: string) => Promise; +} + +interface TimerStopActions extends TimerHandleActions { + stopLocalTimer: () => Promise; +} + +interface TimerSubmitActions extends TimerHandleActions { + stopLocalTimer: () => Promise; + createTaskTimeEntry: (input: TeamworkTaskTimeEntryInput) => Promise; + removeLocalTimer: (id: string) => Promise; +} + +interface TimerDiscardActions extends TimerHandleActions { + removeLocalTimer: (id: string) => Promise; +} + +interface TimesheetOpenActions { + openUrlInBrowser: (url: string) => Promise; +} + +const timerListActions: TimerListActions = { + loadLocalTimers: async () => { + const { loadLocalTimers } = await import("../../teamwork/timers/local.ts"); + return loadLocalTimers(); + }, +}; + +const timerHandleActions: TimerHandleActions = { + getTeamworkTaskReference, + loadLocalTimers: async () => { + const { loadLocalTimers } = await import("../../teamwork/timers/local.ts"); + return loadLocalTimers(); + }, +}; + +const timerStartActions: TimerStartActions = { + getTeamworkTaskReference, + getTeamworkTaskById, + startLocalTimer: async (taskId, taskName) => { + const { startLocalTimer } = await import("../../teamwork/timers/local.ts"); + return (await startLocalTimer(taskId, taskName)).timer; + }, +}; + +const timerStopActions: TimerStopActions = { + ...timerHandleActions, + stopLocalTimer: async () => { + const { stopLocalTimer } = await import("../../teamwork/timers/local.ts"); + return stopLocalTimer(); + }, +}; + +const timerSubmitActions: TimerSubmitActions = { + ...timerHandleActions, + stopLocalTimer: async () => { + const { stopLocalTimer } = await import("../../teamwork/timers/local.ts"); + return stopLocalTimer(); + }, + createTaskTimeEntry, + removeLocalTimer: async (id) => { + const { removeLocalTimer } = await import("../../teamwork/timers/local.ts"); + return removeLocalTimer(id); + }, +}; + +const timerDiscardActions: TimerDiscardActions = { + ...timerHandleActions, + removeLocalTimer: async (id) => { + const { removeLocalTimer } = await import("../../teamwork/timers/local.ts"); + return removeLocalTimer(id); + }, +}; + +const timesheetOpenActions: TimesheetOpenActions = { + openUrlInBrowser, +}; + +function findTimerByTaskId( + timers: readonly LocalTimerEntry[], + taskId: number, +): LocalTimerEntry | null { + const matches = timers.filter((t) => t.taskId === taskId); + if (matches.length === 0) return null; + + const running = matches.find((t) => t.status === "running"); + if (running) return running; + + return ( + matches.sort((a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime())[0] ?? + null + ); +} + +/** + * Formats existing timers as a hint block appended to "no timer found" messages. + * + * Example output: + * ``` + * Existing timers: + * General | Code Review (#1) — 1h 23m — running ⏱ + * Update README (#2) — 30m — stopped + * ``` + */ +function formatExistingTimersHint(timers: readonly LocalTimerEntry[]): string { + if (timers.length === 0) return ""; + const now = new Date(); + const lines = timers.map((t) => ` ${formatTimerEntry(t, now)}`); + return `\nExisting timers:\n${lines.join("\n")}`; +} + +/** + * Formats a single timer entry line for display. + * + * Example output: + * ``` + * General | Code Review (#1597639) — 1h 23m — running ⏱ + * ``` + */ +function formatTimerEntry(timer: LocalTimerEntry, now: Date): string { + const elapsed = getLocalTimerElapsedMs(timer, now); + const duration = formatTimerDuration(elapsed); + const statusSymbol = timer.status === "running" ? " ⏱" : ""; + return `${timer.taskName} (#${timer.taskId}) — ${duration} — ${timer.status}${statusSymbol}`; +} + +/** + * Formats the full timer list for CLI display. + * + * Running timers are sorted to the top, then sorted by most recent start time. + * + * Example output: + * ``` + * Local timers: + * General | Code Review (#1) — 1h 23m — running ⏱ + * Update README (#2) — 30m — stopped + * ``` + */ +export function formatTimerListOutput( + timers: readonly LocalTimerEntry[], + options: { json: boolean }, +): string { + if (options.json) return JSON.stringify(timers, null, 2); + + const now = new Date(); + if (!timers.length) return "No local timers."; + + const sorted = [...timers].sort((a, b) => { + if (a.status === "running" && b.status !== "running") return -1; + if (a.status !== "running" && b.status === "running") return 1; + return new Date(b.startTime).getTime() - new Date(a.startTime).getTime(); + }); + + const lines = ["Local timers:"]; + for (const timer of sorted) { + lines.push(` ${formatTimerEntry(timer, now)}`); + } + return lines.join("\n"); +} + +export async function teamworkTimerList( + args: { json: boolean }, + actions = timerListActions, +): Promise { + const timers = await actions.loadLocalTimers(); + console.log(formatTimerListOutput(timers, { json: args.json })); +} + +export async function teamworkTimerStart( + args: { task: string }, + actions = timerStartActions, +): Promise { + const ref = actions.getTeamworkTaskReference(args.task); + const task = await actions.getTeamworkTaskById(ref.id); + await actions.startLocalTimer(task.id, task.name); + console.log(`Timer started for: ${task.name} (#${task.id})`); +} + +export async function teamworkTimerStop( + args: { task: string }, + actions = timerStopActions, +): Promise { + const ref = actions.getTeamworkTaskReference(args.task); + const timers = await actions.loadLocalTimers(); + const match = findTimerByTaskId(timers, ref.id); + + if (!match) { + console.log(`No local timer found for task: #${ref.id}${formatExistingTimersHint(timers)}`); + return; + } + + if (match.status !== "running") { + console.log(`Timer already stopped for: ${match.taskName} (#${match.taskId})`); + return; + } + + await actions.stopLocalTimer(); + console.log(`Timer stopped for: ${match.taskName} (#${match.taskId})`); +} + +export async function teamworkTimerSubmit( + args: { task: string }, + actions = timerSubmitActions, +): Promise { + const ref = actions.getTeamworkTaskReference(args.task); + const timers = await actions.loadLocalTimers(); + const match = findTimerByTaskId(timers, ref.id); + + if (!match) { + console.log(`No local timer found for task: #${ref.id}${formatExistingTimersHint(timers)}`); + return; + } + + const timerToSubmit = match.status === "running" ? await actions.stopLocalTimer() : match; + if (!timerToSubmit) { + console.log("No timer found to submit."); + return; + } + + 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 actions.removeLocalTimer(timerToSubmit.id); + console.log(`Timer submitted for: ${timerToSubmit.taskName} (#${timerToSubmit.taskId})`); +} + +export async function teamworkTimerDiscard( + args: { task: string }, + actions = timerDiscardActions, +): Promise { + const ref = actions.getTeamworkTaskReference(args.task); + const timers = await actions.loadLocalTimers(); + const match = findTimerByTaskId(timers, ref.id); + + if (!match) { + console.log(`No local timer found for task: #${ref.id}${formatExistingTimersHint(timers)}`); + return; + } + + const taskTimers = timers.filter((t) => t.taskId === ref.id); + if (taskTimers.length > 1) { + console.log( + `Multiple local timers found for task #${ref.id}. Use submit or discard the timer IDs directly.`, + ); + return; + } + + await actions.removeLocalTimer(match.id); + console.log(`Timer discarded for: ${match.taskName} (#${match.taskId})`); +} + +export async function teamworkTimesheetOpen( + _args: Record = {}, + actions = timesheetOpenActions, +): Promise { + await actions.openUrlInBrowser(TEAMWORK_TIMESHEET_URL); + console.log(`Opened Teamwork timesheet: ${TEAMWORK_TIMESHEET_URL}`); +} diff --git a/src/cli/commands/upgrade-command.ts b/src/cli/commands/upgrade-command.ts new file mode 100644 index 0000000..93ed90a --- /dev/null +++ b/src/cli/commands/upgrade-command.ts @@ -0,0 +1,17 @@ +import type { Argv, CommandModule } from "yargs"; + +import { upgrade } from "./upgrade.ts"; + +export const upgradeCommand: CommandModule<{}, { check: boolean }> = { + command: "upgrade", + describe: "Check for updates", + builder: (yargs) => + yargs.option("check", { + alias: "c", + type: "boolean", + description: "Only check for updates", + }) as unknown as Argv<{ check: boolean }>, + handler: (argv) => { + void upgrade({ check: argv.check ?? false }); + }, +}; diff --git a/src/cli/commands/upgrade.ts b/src/cli/commands/upgrade.ts index fbbe7d3..544ca4a 100644 --- a/src/cli/commands/upgrade.ts +++ b/src/cli/commands/upgrade.ts @@ -1,16 +1,24 @@ import { APP_VERSION } from "../../config/consts.ts"; -import { checkForUpdate } from "../../utils/update-check.ts"; +import { checkForUpdate as checkForUpdateFn, type UpdateInfo } from "../../utils/update-check.ts"; const REPO = "wethegit/wtc"; +interface UpgradeActions { + checkForUpdate: () => Promise; +} + +const upgradeActions: UpgradeActions = { + checkForUpdate: checkForUpdateFn, +}; + /** * Checks GitHub Releases and prints the install command when an update exists. * * The `_args` shape is already wired for future upgrade modes. Today only * `--check` is accepted, so this command never mutates the local installation. */ -export async function upgrade(_args: { check: boolean }): Promise { - const info = await checkForUpdate(); +export async function upgrade(_args: { check: boolean }, actions = upgradeActions): Promise { + const info = await actions.checkForUpdate(); if (!info.updateAvailable) { console.log(`You're up to date (v${APP_VERSION}).`); diff --git a/src/cli/parser.ts b/src/cli/parser.ts index 3aefbff..8bbaf0a 100644 --- a/src/cli/parser.ts +++ b/src/cli/parser.ts @@ -1,225 +1,24 @@ import yargs from "yargs"; import { hideBin } from "yargs/helpers"; -import { cacheClean } from "./commands/cache.ts"; -import { - CONFIG_AUTH_PROVIDERS, - configAuthDelete, - configAuthSet, - configAuthStatus, - configInit, -} from "./commands/config.ts"; -import { settings } from "./commands/settings.ts"; -import { - teamworkTaskOpen, - teamworkTaskListPin, - teamworkTaskListPinned, - teamworkTaskListUnpin, -} from "./commands/teamwork.ts"; -import { upgrade } from "./commands/upgrade.ts"; + import { APP_VERSION } from "../config/consts.ts"; -/** - * Runs the yargs-powered CLI parser for non-interactive commands. - * - * The top-level entrypoint only imports this module when arguments are present, - * which keeps simple CLI commands independent from TUI startup cost and OpenTUI - * renderer initialization. - */ -export async function runCli(): Promise { - const currentVersion = APP_VERSION; +import { cacheCommand } from "./commands/cache-command.ts"; +import { configCommand } from "./commands/config-command.ts"; +import { settingsCommand } from "./commands/settings-command.ts"; +import { teamworkCommand } from "./commands/teamwork-command.ts"; +import { upgradeCommand } from "./commands/upgrade-command.ts"; +export async function runCli(): Promise { const parser = yargs(hideBin(Bun.argv)) .scriptName("wtc") - .version(currentVersion) + .version(APP_VERSION) .help() - .command( - "settings", - "Print resolved config and config file paths", - (yargs) => yargs, - async () => { - await settings(); - }, - ) - .command( - "config", - "Manage WTC config files", - (yargs) => - yargs - .command( - "init", - "Create a project config in the current directory", - () => {}, - async () => { - await configInit(); - }, - ) - .command( - "auth", - "Manage provider credentials", - (yargs) => - yargs - .command( - "set ", - "Store a provider API token", - (yargs) => - yargs - .positional("provider", { - type: "string", - choices: CONFIG_AUTH_PROVIDERS, - describe: "Auth provider", - }) - .option("token", { - type: "string", - describe: "API token to store", - demandOption: true, - }), - async (argv) => { - await configAuthSet({ provider: argv.provider ?? "", token: argv.token }); - }, - ) - .command( - "status ", - "Show provider auth status", - (yargs) => - yargs.positional("provider", { - type: "string", - choices: CONFIG_AUTH_PROVIDERS, - describe: "Auth provider", - }), - async (argv) => { - await configAuthStatus({ provider: argv.provider ?? "" }); - }, - ) - .command( - "delete ", - "Delete provider auth", - (yargs) => - yargs.positional("provider", { - type: "string", - choices: CONFIG_AUTH_PROVIDERS, - describe: "Auth provider", - }), - async (argv) => { - await configAuthDelete({ provider: argv.provider ?? "" }); - }, - ) - .demandCommand(1, "Specify an auth subcommand: set, status, delete"), - () => {}, - ) - .demandCommand(1, "Specify a config subcommand: init, auth"), - () => {}, - ) - .command( - "teamwork", - "Manage Teamwork workflows", - (yargs) => - yargs - .command( - "task-list", - "Manage Teamwork task lists", - (yargs) => - yargs - .command( - "pinned", - "List pinned Teamwork task lists and tasks", - (yargs) => - yargs.option("json", { - type: "boolean", - describe: "Print JSON output", - default: false, - }), - async (argv) => { - await teamworkTaskListPinned({ json: argv.json ?? false }); - }, - ) - .command( - "pin ", - "Pin a Teamwork task list in project config", - (yargs) => - yargs - .positional("taskListId", { - type: "number", - describe: "Teamwork task list ID", - }) - .option("name", { - type: "string", - describe: "Display name for this task list", - demandOption: true, - }), - async (argv) => { - await teamworkTaskListPin({ - taskListId: argv.taskListId ?? 0, - name: argv.name ?? "", - }); - }, - ) - .command( - "unpin ", - "Unpin a Teamwork task list from project config", - (yargs) => - yargs.positional("taskListId", { - type: "number", - describe: "Teamwork task list ID", - }), - async (argv) => { - await teamworkTaskListUnpin({ taskListId: argv.taskListId ?? 0 }); - }, - ) - .demandCommand(1, "Specify a task-list subcommand: pinned, pin, unpin"), - () => {}, - ) - .command( - "task", - "Manage Teamwork tasks", - (yargs) => - yargs - .command( - "open ", - "Open a Teamwork task in the browser", - (yargs) => - yargs.positional("task", { - type: "string", - describe: "Teamwork task ID or URL", - }), - async (argv) => { - await teamworkTaskOpen({ task: argv.task ?? "" }); - }, - ) - .demandCommand(1, "Specify a task subcommand: open"), - () => {}, - ) - .demandCommand(1, "Specify a teamwork subcommand: task-list, task"), - () => {}, - ) - .command( - "upgrade", - "Check for updates", - (yargs) => - yargs.option("check", { - alias: "c", - type: "boolean", - description: "Only check for updates", - }), - async (argv) => { - await upgrade({ check: argv.check ?? false }); - }, - ) - .command( - "cache", - "Manage local cache", - (yargs) => - yargs - .command( - "clean", - "Delete all cached data", - () => {}, - async () => { - await cacheClean(); - }, - ) - .demandCommand(1, "Specify a cache subcommand: clean"), - () => {}, - ); + .command(settingsCommand) + .command(configCommand) + .command(teamworkCommand) + .command(upgradeCommand) + .command(cacheCommand); await parser.parse(); } diff --git a/src/teamwork/task.ts b/src/teamwork/task.ts new file mode 100644 index 0000000..f75d204 --- /dev/null +++ b/src/teamwork/task.ts @@ -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 }; +} diff --git a/tests/cli/commands/timers.test.ts b/tests/cli/commands/timers.test.ts new file mode 100644 index 0000000..fd5ed2e --- /dev/null +++ b/tests/cli/commands/timers.test.ts @@ -0,0 +1,301 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import type { LocalTimerEntry } from "../../../src/teamwork/timers/local.ts"; + +const { + formatTimerListOutput, + teamworkTimerDiscard, + teamworkTimerList, + teamworkTimerStart, + teamworkTimerStop, + teamworkTimerSubmit, + teamworkTimesheetOpen, +} = await import("../../../src/cli/commands/timers.ts"); + +const originalLog = console.log; +let logs: string[]; + +const timerA: LocalTimerEntry = { + id: "timer-1", + taskId: 1, + taskName: "General | Code Review", + startTime: "2026-06-24T10:00:00Z", + endTime: null, + status: "running", +}; + +const timerB: LocalTimerEntry = { + id: "timer-2", + taskId: 2, + taskName: "Update README", + startTime: "2026-06-24T09:00:00Z", + endTime: "2026-06-24T09:30:00Z", + status: "stopped", +}; + +const timerC: LocalTimerEntry = { + id: "timer-3", + taskId: 1, + taskName: "General | Code Review", + startTime: "2026-06-23T14:00:00Z", + endTime: "2026-06-23T15:00:00Z", + status: "stopped", +}; + +describe("teamwork timer commands", () => { + beforeEach(() => { + logs = []; + console.log = (message?: unknown) => { + logs.push(String(message)); + }; + }); + + afterEach(() => { + console.log = originalLog; + }); + + describe("formatTimerListOutput", () => { + test("formats timer list as text", () => { + const output = formatTimerListOutput([timerA, timerB], { json: false }); + expect(output).toContain("Local timers:"); + expect(output).toContain("General | Code Review"); + expect(output).toContain("Update README"); + expect(output).toContain("running"); + expect(output).toContain("stopped"); + }); + + test("formats timer list as JSON", () => { + const output = formatTimerListOutput([timerA], { json: true }); + const parsed = JSON.parse(output); + expect(parsed).toHaveLength(1); + expect(parsed[0]?.taskId).toBe(1); + }); + + test("prints empty state", () => { + const output = formatTimerListOutput([], { json: false }); + expect(output).toBe("No local timers."); + }); + }); + + describe("teamworkTimerList", () => { + test("lists timers with default actions", async () => { + const actions = { + loadLocalTimers: async () => [timerA, timerB], + }; + + await teamworkTimerList({ json: false }, actions); + expect(logs[0]).toContain("Local timers:"); + }); + + test("lists timers as JSON", async () => { + const actions = { + loadLocalTimers: async () => [timerA, timerB], + }; + + await teamworkTimerList({ json: true }, actions); + const parsed = JSON.parse(logs[0] ?? ""); + expect(parsed).toHaveLength(2); + }); + }); + + describe("teamworkTimerStart", () => { + test("starts a timer for a task", async () => { + const actions = { + getTeamworkTaskReference: (value: string) => ({ + id: Number(value), + url: `https://teamwork.com/app/tasks/${value}`, + }), + getTeamworkTaskById: async (id: number) => ({ id, name: "General | Code Review" }), + startLocalTimer: async (taskId: number, taskName: string) => + ({ + id: "new-timer", + taskId, + taskName, + startTime: "2026-06-24T12:00:00Z", + endTime: null, + status: "running", + }) as LocalTimerEntry, + }; + + await teamworkTimerStart({ task: "12345" }, actions); + expect(logs[0]).toContain("Timer started for"); + expect(logs[0]).toContain("General | Code Review"); + }); + }); + + describe("teamworkTimerStop", () => { + test("stops a running timer for a task", async () => { + const actions = { + getTeamworkTaskReference: (value: string) => ({ + id: 1, + url: `https://teamwork.com/app/tasks/${value}`, + }), + loadLocalTimers: async () => [timerA, timerB], + stopLocalTimer: async () => ({ + ...timerA, + status: "stopped" as const, + endTime: new Date().toISOString(), + }), + }; + + await teamworkTimerStop({ task: "1" }, actions); + expect(logs[0]).toContain("Timer stopped for"); + expect(logs[0]).toContain("General | Code Review"); + }); + + test("reports when timer is already stopped", async () => { + const actions = { + getTeamworkTaskReference: (value: string) => ({ + id: 2, + url: `https://teamwork.com/app/tasks/${value}`, + }), + loadLocalTimers: async () => [timerA, timerB], + stopLocalTimer: async () => null, + }; + + await teamworkTimerStop({ task: "2" }, actions); + expect(logs[0]).toContain("already stopped"); + }); + + test("reports when no timer exists for the task, showing existing timers", async () => { + const actions = { + getTeamworkTaskReference: (value: string) => ({ + id: 99, + url: `https://teamwork.com/app/tasks/${value}`, + }), + loadLocalTimers: async () => [timerA, timerB], + stopLocalTimer: async () => null, + }; + + await teamworkTimerStop({ task: "99" }, actions); + expect(logs[0]).toContain("No local timer found"); + expect(logs[0]).toContain("Existing timers:"); + expect(logs[0]).toContain("General | Code Review"); + expect(logs[0]).toContain("Update README"); + }); + }); + + describe("teamworkTimerSubmit", () => { + test("submits a stopped timer", async () => { + const actions = { + getTeamworkTaskReference: (value: string) => ({ + id: 2, + url: `https://teamwork.com/app/tasks/${value}`, + }), + loadLocalTimers: async () => [timerA, timerB], + stopLocalTimer: async () => null, + createTaskTimeEntry: async () => 42, + removeLocalTimer: async () => {}, + }; + + await teamworkTimerSubmit({ task: "2" }, actions); + expect(logs[0]).toContain("Timer submitted"); + expect(logs[0]).toContain("Update README"); + }); + + test("submits a running timer (stops first)", async () => { + let stopped = false; + const actions = { + getTeamworkTaskReference: (value: string) => ({ + id: 1, + url: `https://teamwork.com/app/tasks/${value}`, + }), + loadLocalTimers: async () => [timerA, timerB], + stopLocalTimer: async () => { + stopped = true; + return { ...timerA, status: "stopped" as const, endTime: new Date().toISOString() }; + }, + createTaskTimeEntry: async () => 42, + removeLocalTimer: async () => {}, + }; + + await teamworkTimerSubmit({ task: "1" }, actions); + expect(stopped).toBe(true); + expect(logs[0]).toContain("Timer submitted"); + }); + + test("reports when no timer exists, showing existing timers", async () => { + const actions = { + getTeamworkTaskReference: (value: string) => ({ + id: 99, + url: `https://teamwork.com/app/tasks/${value}`, + }), + loadLocalTimers: async () => [timerA, timerB], + stopLocalTimer: async () => null, + createTaskTimeEntry: async () => 42, + removeLocalTimer: async () => {}, + }; + + await teamworkTimerSubmit({ task: "99" }, actions); + expect(logs[0]).toContain("No local timer found"); + expect(logs[0]).toContain("Existing timers:"); + expect(logs[0]).toContain("General | Code Review"); + expect(logs[0]).toContain("Update README"); + }); + }); + + describe("teamworkTimerDiscard", () => { + test("discards a timer for a task", async () => { + let removedId = ""; + const actions = { + getTeamworkTaskReference: (value: string) => ({ + id: 2, + url: `https://teamwork.com/app/tasks/${value}`, + }), + loadLocalTimers: async () => [timerA, timerB], + removeLocalTimer: async (id: string) => { + removedId = id; + }, + }; + + await teamworkTimerDiscard({ task: "2" }, actions); + expect(removedId).toBe("timer-2"); + expect(logs[0]).toContain("Timer discarded"); + }); + + test("reports when no timer exists, showing existing timers", async () => { + const actions = { + getTeamworkTaskReference: (value: string) => ({ + id: 99, + url: `https://teamwork.com/app/tasks/${value}`, + }), + loadLocalTimers: async () => [timerA, timerB], + removeLocalTimer: async () => {}, + }; + + await teamworkTimerDiscard({ task: "99" }, actions); + expect(logs[0]).toContain("No local timer found"); + expect(logs[0]).toContain("Existing timers:"); + expect(logs[0]).toContain("General | Code Review"); + expect(logs[0]).toContain("Update README"); + }); + + test("rejects ambiguous multiple timers for the same task", async () => { + const actions = { + getTeamworkTaskReference: (value: string) => ({ + id: 1, + url: `https://teamwork.com/app/tasks/${value}`, + }), + loadLocalTimers: async () => [timerA, timerC], + removeLocalTimer: async () => {}, + }; + + await teamworkTimerDiscard({ task: "1" }, actions); + expect(logs[0]).toContain("Multiple local timers found"); + }); + }); + + describe("teamworkTimesheetOpen", () => { + test("opens timesheet in browser", async () => { + let openedUrl = ""; + const actions = { + openUrlInBrowser: async (url: string) => { + openedUrl = url; + }, + }; + + await teamworkTimesheetOpen({}, actions); + expect(openedUrl).toContain("teamwork.com"); + expect(logs[0]).toContain("Opened Teamwork timesheet"); + }); + }); +}); diff --git a/tests/teamwork/task.test.ts b/tests/teamwork/task.test.ts new file mode 100644 index 0000000..5ffad6c --- /dev/null +++ b/tests/teamwork/task.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, mock, test, afterEach } from "bun:test"; + +import { createMockFetch, mockTeamworkAuthModule } from "../helpers/teamwork.ts"; + +mock.module("../../src/teamwork/auth.ts", mockTeamworkAuthModule); + +const { getTeamworkTaskById } = await import("../../src/teamwork/task.ts"); + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe("getTeamworkTaskById", () => { + test("fetches a task by ID and returns id and name", async () => { + globalThis.fetch = createMockFetch( + () => + new Response( + JSON.stringify({ + task: { id: 12345, name: "Fix login bug", status: "active" }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + ); + + const result = await getTeamworkTaskById(12345); + + expect(result).toEqual({ id: 12345, name: "Fix login bug" }); + }); + + test("falls back to title when name is missing", async () => { + globalThis.fetch = createMockFetch( + () => + new Response( + JSON.stringify({ + task: { id: 12345, title: "Fix login bug" }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + ); + + const result = await getTeamworkTaskById(12345); + + expect(result).toEqual({ id: 12345, name: "Fix login bug" }); + }); + + test("falls back to content when name and title are missing", async () => { + globalThis.fetch = createMockFetch( + () => + new Response( + JSON.stringify({ + task: { id: 12345, content: "Fix login bug" }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + ); + + const result = await getTeamworkTaskById(12345); + + expect(result).toEqual({ id: 12345, name: "Fix login bug" }); + }); + + test("throws when response lacks any task name", async () => { + globalThis.fetch = createMockFetch( + () => + new Response( + JSON.stringify({ + task: { id: 12345, status: "active" }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + ); + + expect(getTeamworkTaskById(12345)).rejects.toThrow( + "Teamwork task response did not include a task name", + ); + }); +}); From 0fc1e392b1d8d7a842e5b3bfc165bdf3a7538c93 Mon Sep 17 00:00:00 2001 From: Marlon Marcello Date: Wed, 24 Jun 2026 18:10:36 -0700 Subject: [PATCH 2/7] chore: changeset --- .changeset/salty-seals-wonder.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/salty-seals-wonder.md diff --git a/.changeset/salty-seals-wonder.md b/.changeset/salty-seals-wonder.md new file mode 100644 index 0000000..7b4dd1b --- /dev/null +++ b/.changeset/salty-seals-wonder.md @@ -0,0 +1,5 @@ +--- +"wtc": patch +--- + +Adds timers feature to CLI From 09a11c9173c22ba7c5089b768229f00007d37f25 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Jun 2026 20:40:51 +0000 Subject: [PATCH 3/7] fix: address PR review feedback in timer and upgrade commands --- src/cli/commands/timers.ts | 2 +- src/cli/commands/upgrade-command.ts | 2 +- tests/cli/commands/timers.test.ts | 17 ++++++++--------- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/src/cli/commands/timers.ts b/src/cli/commands/timers.ts index a437a99..29fa0f4 100644 --- a/src/cli/commands/timers.ts +++ b/src/cli/commands/timers.ts @@ -268,7 +268,7 @@ export async function teamworkTimerDiscard( const taskTimers = timers.filter((t) => t.taskId === ref.id); if (taskTimers.length > 1) { console.log( - `Multiple local timers found for task #${ref.id}. Use submit or discard the timer IDs directly.`, + `Multiple local timers found for task #${ref.id}. Keep only one timer for this task before submitting or discarding.`, ); return; } diff --git a/src/cli/commands/upgrade-command.ts b/src/cli/commands/upgrade-command.ts index 93ed90a..cc18540 100644 --- a/src/cli/commands/upgrade-command.ts +++ b/src/cli/commands/upgrade-command.ts @@ -9,7 +9,7 @@ export const upgradeCommand: CommandModule<{}, { check: boolean }> = { yargs.option("check", { alias: "c", type: "boolean", - description: "Only check for updates", + describe: "Only check for updates", }) as unknown as Argv<{ check: boolean }>, handler: (argv) => { void upgrade({ check: argv.check ?? false }); diff --git a/tests/cli/commands/timers.test.ts b/tests/cli/commands/timers.test.ts index fd5ed2e..c22f9a1 100644 --- a/tests/cli/commands/timers.test.ts +++ b/tests/cli/commands/timers.test.ts @@ -105,15 +105,14 @@ describe("teamwork timer commands", () => { url: `https://teamwork.com/app/tasks/${value}`, }), getTeamworkTaskById: async (id: number) => ({ id, name: "General | Code Review" }), - startLocalTimer: async (taskId: number, taskName: string) => - ({ - id: "new-timer", - taskId, - taskName, - startTime: "2026-06-24T12:00:00Z", - endTime: null, - status: "running", - }) as LocalTimerEntry, + startLocalTimer: async (taskId: number, taskName: string) => ({ + id: "new-timer", + taskId, + taskName, + startTime: "2026-06-24T12:00:00Z", + endTime: null, + status: "running", + } satisfies LocalTimerEntry), }; await teamworkTimerStart({ task: "12345" }, actions); From 2544c57db35b51ac51297fe28200f78e06069a37 Mon Sep 17 00:00:00 2001 From: Marlon Marcello Date: Thu, 25 Jun 2026 14:06:43 -0700 Subject: [PATCH 4/7] refactor: directory structure with apis --- CONTRIBUTING.md | 18 +++++++ plans/PLAN.md | 20 ++++++-- scripts/inspect-teamwork-task-fields.ts | 2 +- src/api/README.md | 47 +++++++++++++++++++ src/{state => api/cache}/consts.ts | 0 src/api/cache/manager.ts | 10 ++++ src/{ => api}/config/consts.ts | 0 src/{ => api}/config/manager.ts | 0 src/{ => api}/config/schema.ts | 0 src/{ => api}/config/templates.ts | 0 src/{ => api}/state/manager.ts | 10 +--- src/{ => api}/state/schema.ts | 0 src/{ => api}/teamwork/auth.ts | 0 src/{ => api}/teamwork/client.ts | 0 src/{ => api}/teamwork/consts.ts | 0 src/{ => api}/teamwork/project-metadata.ts | 2 +- src/{ => api}/teamwork/task-list-tasks.ts | 0 src/{ => api}/teamwork/task.ts | 0 src/{ => api}/teamwork/tasks.ts | 0 src/{ => api}/teamwork/timers.ts | 0 src/{ => api}/teamwork/timers/local.ts | 2 +- src/{ => api}/teamwork/workflow-stages.ts | 2 +- src/cli/README.md | 41 ++++++++++++++++ src/cli/commands/cache.ts | 2 +- src/cli/commands/config.ts | 4 +- src/cli/commands/settings.ts | 4 +- src/cli/commands/teamwork.ts | 8 ++-- src/cli/commands/timers.ts | 26 +++++----- src/cli/commands/upgrade.ts | 2 +- src/cli/parser.ts | 2 +- src/tui/README.md | 29 ++++++++++++ src/tui/app.tsx | 6 +-- src/tui/components/state-provider.tsx | 4 +- src/tui/components/teamwork/task-list.tsx | 7 ++- src/tui/components/teamwork/task-metadata.tsx | 2 +- src/tui/components/update-dialog.tsx | 2 +- src/tui/pages/dashboard.tsx | 2 +- src/tui/pages/settings.tsx | 8 ++-- .../pages/settings/user-config-section.tsx | 2 +- src/tui/pages/teamwork/project-tab.tsx | 17 ++++--- src/tui/pages/teamwork/timers-tab.tsx | 6 +-- src/utils/update-check.ts | 4 +- tests/{state => api/cache}/consts.test.ts | 2 +- tests/{ => api}/config/consts.test.ts | 2 +- tests/{ => api}/config/manager.test.ts | 2 +- tests/{ => api}/config/schema.test.ts | 2 +- tests/{ => api}/state/manager.test.ts | 5 +- tests/{ => api}/state/schema.test.ts | 2 +- tests/{ => api}/teamwork/auth.test.ts | 2 +- .../teamwork/project-metadata.test.ts | 15 ++++-- .../teamwork/task-list-tasks.test.ts | 14 ++++-- tests/{ => api}/teamwork/task.test.ts | 6 +-- tests/{ => api}/teamwork/tasks.test.ts | 4 +- tests/{ => api}/teamwork/timers-local.test.ts | 4 +- tests/{ => api}/teamwork/timers.test.ts | 10 ++-- .../teamwork/workflow-stages.test.ts | 12 +++-- tests/cli/commands/settings.test.ts | 2 +- tests/cli/commands/teamwork.test.ts | 6 +-- tests/cli/commands/timers.test.ts | 19 ++++---- tests/helpers/teamwork.ts | 2 +- tests/tui/settings.test.ts | 2 +- tests/tui/teamwork.test.ts | 2 +- 62 files changed, 287 insertions(+), 119 deletions(-) create mode 100644 src/api/README.md rename src/{state => api/cache}/consts.ts (100%) create mode 100644 src/api/cache/manager.ts rename src/{ => api}/config/consts.ts (100%) rename src/{ => api}/config/manager.ts (100%) rename src/{ => api}/config/schema.ts (100%) rename src/{ => api}/config/templates.ts (100%) rename src/{ => api}/state/manager.ts (82%) rename src/{ => api}/state/schema.ts (100%) rename src/{ => api}/teamwork/auth.ts (100%) rename src/{ => api}/teamwork/client.ts (100%) rename src/{ => api}/teamwork/consts.ts (100%) rename src/{ => api}/teamwork/project-metadata.ts (98%) rename src/{ => api}/teamwork/task-list-tasks.ts (100%) rename src/{ => api}/teamwork/task.ts (100%) rename src/{ => api}/teamwork/tasks.ts (100%) rename src/{ => api}/teamwork/timers.ts (100%) rename src/{ => api}/teamwork/timers/local.ts (98%) rename src/{ => api}/teamwork/workflow-stages.ts (98%) create mode 100644 src/cli/README.md create mode 100644 src/tui/README.md rename tests/{state => api/cache}/consts.test.ts (88%) rename tests/{ => api}/config/consts.test.ts (88%) rename tests/{ => api}/config/manager.test.ts (99%) rename tests/{ => api}/config/schema.test.ts (76%) rename tests/{ => api}/state/manager.test.ts (93%) rename tests/{ => api}/state/schema.test.ts (88%) rename tests/{ => api}/teamwork/auth.test.ts (74%) rename tests/{ => api}/teamwork/project-metadata.test.ts (82%) rename tests/{ => api}/teamwork/task-list-tasks.test.ts (90%) rename tests/{ => api}/teamwork/task.test.ts (90%) rename tests/{ => api}/teamwork/tasks.test.ts (85%) rename tests/{ => api}/teamwork/timers-local.test.ts (97%) rename tests/{ => api}/teamwork/timers.test.ts (96%) rename tests/{ => api}/teamwork/workflow-stages.test.ts (86%) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cd515f5..197f3df 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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: diff --git a/plans/PLAN.md b/plans/PLAN.md index 2d9ac6c..47a1c7d 100644 --- a/plans/PLAN.md +++ b/plans/PLAN.md @@ -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 @@ -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. @@ -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 diff --git a/scripts/inspect-teamwork-task-fields.ts b/scripts/inspect-teamwork-task-fields.ts index fc61af2..b767291 100644 --- a/scripts/inspect-teamwork-task-fields.ts +++ b/scripts/inspect-teamwork-task-fields.ts @@ -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; diff --git a/src/api/README.md b/src/api/README.md new file mode 100644 index 0000000..ec26546 --- /dev/null +++ b/src/api/README.md @@ -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; + createTaskTimeEntry: (input: TeamworkTaskTimeEntryInput) => Promise; + removeLocalTimer: (id: string) => Promise; +} + +export async function submitTimer( + timer: LocalTimerEntry, + actions: SubmitTimerActions, +): Promise { + // ... +} +``` + +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. diff --git a/src/state/consts.ts b/src/api/cache/consts.ts similarity index 100% rename from src/state/consts.ts rename to src/api/cache/consts.ts diff --git a/src/api/cache/manager.ts b/src/api/cache/manager.ts new file mode 100644 index 0000000..5c5aaff --- /dev/null +++ b/src/api/cache/manager.ts @@ -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 { + const cacheDir = getCacheDir(); + await rm(cacheDir, { recursive: true, force: true }); + await mkdir(cacheDir, { recursive: true }); +} diff --git a/src/config/consts.ts b/src/api/config/consts.ts similarity index 100% rename from src/config/consts.ts rename to src/api/config/consts.ts diff --git a/src/config/manager.ts b/src/api/config/manager.ts similarity index 100% rename from src/config/manager.ts rename to src/api/config/manager.ts diff --git a/src/config/schema.ts b/src/api/config/schema.ts similarity index 100% rename from src/config/schema.ts rename to src/api/config/schema.ts diff --git a/src/config/templates.ts b/src/api/config/templates.ts similarity index 100% rename from src/config/templates.ts rename to src/api/config/templates.ts diff --git a/src/state/manager.ts b/src/api/state/manager.ts similarity index 82% rename from src/state/manager.ts rename to src/api/state/manager.ts index 81104ad..51488af 100644 --- a/src/state/manager.ts +++ b/src/api/state/manager.ts @@ -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"; @@ -49,10 +48,3 @@ export async function saveTuiState(dir: string, partial: Partial) await Bun.write(path, `${JSON.stringify(file, null, 2)}\n`); } - -/** Deletes the entire cache directory. */ -export async function clearCache(): Promise { - const cacheDir = getCacheDir(); - await rm(cacheDir, { recursive: true, force: true }); - await mkdir(cacheDir, { recursive: true }); -} diff --git a/src/state/schema.ts b/src/api/state/schema.ts similarity index 100% rename from src/state/schema.ts rename to src/api/state/schema.ts diff --git a/src/teamwork/auth.ts b/src/api/teamwork/auth.ts similarity index 100% rename from src/teamwork/auth.ts rename to src/api/teamwork/auth.ts diff --git a/src/teamwork/client.ts b/src/api/teamwork/client.ts similarity index 100% rename from src/teamwork/client.ts rename to src/api/teamwork/client.ts diff --git a/src/teamwork/consts.ts b/src/api/teamwork/consts.ts similarity index 100% rename from src/teamwork/consts.ts rename to src/api/teamwork/consts.ts diff --git a/src/teamwork/project-metadata.ts b/src/api/teamwork/project-metadata.ts similarity index 98% rename from src/teamwork/project-metadata.ts rename to src/api/teamwork/project-metadata.ts index 9af41e5..5a71006 100644 --- a/src/teamwork/project-metadata.ts +++ b/src/api/teamwork/project-metadata.ts @@ -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"; diff --git a/src/teamwork/task-list-tasks.ts b/src/api/teamwork/task-list-tasks.ts similarity index 100% rename from src/teamwork/task-list-tasks.ts rename to src/api/teamwork/task-list-tasks.ts diff --git a/src/teamwork/task.ts b/src/api/teamwork/task.ts similarity index 100% rename from src/teamwork/task.ts rename to src/api/teamwork/task.ts diff --git a/src/teamwork/tasks.ts b/src/api/teamwork/tasks.ts similarity index 100% rename from src/teamwork/tasks.ts rename to src/api/teamwork/tasks.ts diff --git a/src/teamwork/timers.ts b/src/api/teamwork/timers.ts similarity index 100% rename from src/teamwork/timers.ts rename to src/api/teamwork/timers.ts diff --git a/src/teamwork/timers/local.ts b/src/api/teamwork/timers/local.ts similarity index 98% rename from src/teamwork/timers/local.ts rename to src/api/teamwork/timers/local.ts index 4768754..ed8f3c2 100644 --- a/src/teamwork/timers/local.ts +++ b/src/api/teamwork/timers/local.ts @@ -1,4 +1,4 @@ -import { getCacheDir } from "../../state/consts.ts"; +import { getCacheDir } from "../../cache/consts.ts"; const LOCAL_TIMERS_CACHE_FILE = "teamwork-local-timers.json"; diff --git a/src/teamwork/workflow-stages.ts b/src/api/teamwork/workflow-stages.ts similarity index 98% rename from src/teamwork/workflow-stages.ts rename to src/api/teamwork/workflow-stages.ts index a8155a2..f8ce526 100644 --- a/src/teamwork/workflow-stages.ts +++ b/src/api/teamwork/workflow-stages.ts @@ -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"; diff --git a/src/cli/README.md b/src/cli/README.md new file mode 100644 index 0000000..56a1094 --- /dev/null +++ b/src/cli/README.md @@ -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 { + 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: () => {}` diff --git a/src/cli/commands/cache.ts b/src/cli/commands/cache.ts index f05bb1c..bb50763 100644 --- a/src/cli/commands/cache.ts +++ b/src/cli/commands/cache.ts @@ -1,4 +1,4 @@ -import { clearCache as clearCacheFn } from "../../state/manager.ts"; +import { clearCache as clearCacheFn } from "../../api/cache/manager.ts"; interface CacheCleanActions { clearCache: () => Promise; diff --git a/src/cli/commands/config.ts b/src/cli/commands/config.ts index 0728979..b534edf 100644 --- a/src/cli/commands/config.ts +++ b/src/cli/commands/config.ts @@ -1,10 +1,10 @@ -import { initProjectConfig } from "../../config/manager.ts"; +import { initProjectConfig } from "../../api/config/manager.ts"; import { deleteTeamworkApiToken, getTeamworkAuthStatus, setTeamworkApiToken, type TeamworkAuthStatus, -} from "../../teamwork/auth.ts"; +} from "../../api/teamwork/auth.ts"; /** Shared with yargs so accepted CLI providers and handler validation stay in sync. */ export const CONFIG_AUTH_PROVIDERS = ["teamwork"] as const; diff --git a/src/cli/commands/settings.ts b/src/cli/commands/settings.ts index b357de3..b729f51 100644 --- a/src/cli/commands/settings.ts +++ b/src/cli/commands/settings.ts @@ -1,5 +1,5 @@ -import { loadResolvedConfig as loadResolvedConfigFn } from "../../config/manager.ts"; -import type { ResolvedConfig } from "../../config/schema.ts"; +import { loadResolvedConfig as loadResolvedConfigFn } from "../../api/config/manager.ts"; +import type { ResolvedConfig } from "../../api/config/schema.ts"; interface SettingsActions { loadResolvedConfig: (startDir: string) => Promise; diff --git a/src/cli/commands/teamwork.ts b/src/cli/commands/teamwork.ts index d302069..f83ab5b 100644 --- a/src/cli/commands/teamwork.ts +++ b/src/cli/commands/teamwork.ts @@ -1,11 +1,11 @@ -import { loadResolvedConfig, saveProjectConfig } from "../../config/manager.ts"; +import { loadResolvedConfig, saveProjectConfig } from "../../api/config/manager.ts"; import { PROJECT_CONFIG_VERSION, type ProjectConfig, type ResolvedConfig, -} from "../../config/schema.ts"; -import { getTeamworkTaskListTasks, type TeamworkTask } from "../../teamwork/task-list-tasks.ts"; -import { getTeamworkTaskReference } from "../../teamwork/tasks.ts"; +} from "../../api/config/schema.ts"; +import { getTeamworkTaskListTasks, type TeamworkTask } from "../../api/teamwork/task-list-tasks.ts"; +import { getTeamworkTaskReference } from "../../api/teamwork/tasks.ts"; import { openUrlInBrowser } from "../../utils/browser.ts"; interface PinnedTaskListResult { diff --git a/src/cli/commands/timers.ts b/src/cli/commands/timers.ts index 29fa0f4..a9a43f7 100644 --- a/src/cli/commands/timers.ts +++ b/src/cli/commands/timers.ts @@ -1,9 +1,9 @@ -import { TEAMWORK_TIMESHEET_URL } from "../../teamwork/consts.ts"; -import { getTeamworkTaskReference, type TeamworkTaskReference } from "../../teamwork/tasks.ts"; -import { getTeamworkTaskById } from "../../teamwork/task.ts"; -import { getLocalTimerElapsedMs, formatTimerDuration } from "../../teamwork/timers/local.ts"; -import type { LocalTimerEntry } from "../../teamwork/timers/local.ts"; -import { createTaskTimeEntry, type TeamworkTaskTimeEntryInput } from "../../teamwork/timers.ts"; +import { TEAMWORK_TIMESHEET_URL } from "../../api/teamwork/consts.ts"; +import { getTeamworkTaskReference, type TeamworkTaskReference } from "../../api/teamwork/tasks.ts"; +import { getTeamworkTaskById } from "../../api/teamwork/task.ts"; +import { getLocalTimerElapsedMs, formatTimerDuration } from "../../api/teamwork/timers/local.ts"; +import type { LocalTimerEntry } from "../../api/teamwork/timers/local.ts"; +import { createTaskTimeEntry, type TeamworkTaskTimeEntryInput } from "../../api/teamwork/timers.ts"; import { openUrlInBrowser } from "../../utils/browser.ts"; interface TimerListActions { @@ -41,7 +41,7 @@ interface TimesheetOpenActions { const timerListActions: TimerListActions = { loadLocalTimers: async () => { - const { loadLocalTimers } = await import("../../teamwork/timers/local.ts"); + const { loadLocalTimers } = await import("../../api/teamwork/timers/local.ts"); return loadLocalTimers(); }, }; @@ -49,7 +49,7 @@ const timerListActions: TimerListActions = { const timerHandleActions: TimerHandleActions = { getTeamworkTaskReference, loadLocalTimers: async () => { - const { loadLocalTimers } = await import("../../teamwork/timers/local.ts"); + const { loadLocalTimers } = await import("../../api/teamwork/timers/local.ts"); return loadLocalTimers(); }, }; @@ -58,7 +58,7 @@ const timerStartActions: TimerStartActions = { getTeamworkTaskReference, getTeamworkTaskById, startLocalTimer: async (taskId, taskName) => { - const { startLocalTimer } = await import("../../teamwork/timers/local.ts"); + const { startLocalTimer } = await import("../../api/teamwork/timers/local.ts"); return (await startLocalTimer(taskId, taskName)).timer; }, }; @@ -66,7 +66,7 @@ const timerStartActions: TimerStartActions = { const timerStopActions: TimerStopActions = { ...timerHandleActions, stopLocalTimer: async () => { - const { stopLocalTimer } = await import("../../teamwork/timers/local.ts"); + const { stopLocalTimer } = await import("../../api/teamwork/timers/local.ts"); return stopLocalTimer(); }, }; @@ -74,12 +74,12 @@ const timerStopActions: TimerStopActions = { const timerSubmitActions: TimerSubmitActions = { ...timerHandleActions, stopLocalTimer: async () => { - const { stopLocalTimer } = await import("../../teamwork/timers/local.ts"); + const { stopLocalTimer } = await import("../../api/teamwork/timers/local.ts"); return stopLocalTimer(); }, createTaskTimeEntry, removeLocalTimer: async (id) => { - const { removeLocalTimer } = await import("../../teamwork/timers/local.ts"); + const { removeLocalTimer } = await import("../../api/teamwork/timers/local.ts"); return removeLocalTimer(id); }, }; @@ -87,7 +87,7 @@ const timerSubmitActions: TimerSubmitActions = { const timerDiscardActions: TimerDiscardActions = { ...timerHandleActions, removeLocalTimer: async (id) => { - const { removeLocalTimer } = await import("../../teamwork/timers/local.ts"); + const { removeLocalTimer } = await import("../../api/teamwork/timers/local.ts"); return removeLocalTimer(id); }, }; diff --git a/src/cli/commands/upgrade.ts b/src/cli/commands/upgrade.ts index 544ca4a..76ef90d 100644 --- a/src/cli/commands/upgrade.ts +++ b/src/cli/commands/upgrade.ts @@ -1,4 +1,4 @@ -import { APP_VERSION } from "../../config/consts.ts"; +import { APP_VERSION } from "../../api/config/consts.ts"; import { checkForUpdate as checkForUpdateFn, type UpdateInfo } from "../../utils/update-check.ts"; const REPO = "wethegit/wtc"; diff --git a/src/cli/parser.ts b/src/cli/parser.ts index 8bbaf0a..e010338 100644 --- a/src/cli/parser.ts +++ b/src/cli/parser.ts @@ -1,7 +1,7 @@ import yargs from "yargs"; import { hideBin } from "yargs/helpers"; -import { APP_VERSION } from "../config/consts.ts"; +import { APP_VERSION } from "../api/config/consts.ts"; import { cacheCommand } from "./commands/cache-command.ts"; import { configCommand } from "./commands/config-command.ts"; diff --git a/src/tui/README.md b/src/tui/README.md new file mode 100644 index 0000000..5fcb8bb --- /dev/null +++ b/src/tui/README.md @@ -0,0 +1,29 @@ +# `src/tui/` — TUI presentation layer + +Solid JSX components rendered via `@opentui/solid`. Never contains business logic — calls `src/api/` functions for that. + +## Structure + +| Path | Purpose | +| ---------------------- | --------------------------------------------------------------- | +| `app.tsx` | Root component: routes, providers | +| `pages/` | Top-level route pages (dashboard, teamwork, settings, github) | +| `components/` | Reusable UI components | +| `components/layout/` | Page layout primitives (Card, Page, ListItem, AccordionSection) | +| `components/teamwork/` | Teamwork-specific components (TimerBadge, TaskList) | +| `components/forms/` | Form field components (TextField, ActionButton, DynamicList) | +| `tokens.ts` | Design tokens (colors, spacing) | + +## Conventions + +### Calling API functions + +Components call `src/api/` functions directly — no additional wrapper layer needed. Side effects (HTTP, file I/O) happen in event handlers or `createEffect`, not during render. + +### User messaging + +Components use `setMessage()` (from the status bar context) for user-facing feedback instead of `console.log`. Error states render inline in the component. + +### Testing + +Test logic, not layout. Pure helpers and API functions are tested directly. TUI rendering (box position, styling) is the framework's job and should not be tested. diff --git a/src/tui/app.tsx b/src/tui/app.tsx index b8a97c9..736378f 100644 --- a/src/tui/app.tsx +++ b/src/tui/app.tsx @@ -5,9 +5,9 @@ import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"; import { KeymapProvider, useBindings, useKeymap } from "@opentui/keymap/solid"; import { checkForUpdate } from "../utils/update-check.ts"; -import { loadTuiState } from "../state/manager.ts"; -import type { Route, TuiStateEntry } from "../state/schema.ts"; -import { TEAMWORK_TIMESHEET_URL } from "../teamwork/consts.ts"; +import { loadTuiState } from "../api/state/manager.ts"; +import type { Route, TuiStateEntry } from "../api/state/schema.ts"; +import { TEAMWORK_TIMESHEET_URL } from "../api/teamwork/consts.ts"; import { openUrlInBrowser } from "../utils/browser.ts"; import { DialogProvider, useDialog } from "./components/dialog.tsx"; diff --git a/src/tui/components/state-provider.tsx b/src/tui/components/state-provider.tsx index 107b531..67c2e24 100644 --- a/src/tui/components/state-provider.tsx +++ b/src/tui/components/state-provider.tsx @@ -1,7 +1,7 @@ import { createContext, createSignal, useContext, type ParentProps } from "solid-js"; -import { saveTuiState } from "../../state/manager.ts"; -import type { TuiStateEntry } from "../../state/schema.ts"; +import { saveTuiState } from "../../api/state/manager.ts"; +import type { TuiStateEntry } from "../../api/state/schema.ts"; /** TUI state context: provides current state snapshot and a persist-to-disk updater. */ export interface StateContextValue { diff --git a/src/tui/components/teamwork/task-list.tsx b/src/tui/components/teamwork/task-list.tsx index 0099186..cd27c88 100644 --- a/src/tui/components/teamwork/task-list.tsx +++ b/src/tui/components/teamwork/task-list.tsx @@ -1,7 +1,10 @@ import { For } from "solid-js"; -import type { TeamworkTask } from "../../../teamwork/task-list-tasks.ts"; -import { type LocalTimerEntry, getLocalTimerElapsedMs } from "../../../teamwork/timers/local.ts"; +import type { TeamworkTask } from "../../../api/teamwork/task-list-tasks.ts"; +import { + type LocalTimerEntry, + getLocalTimerElapsedMs, +} from "../../../api/teamwork/timers/local.ts"; import { tokens } from "../../tokens.ts"; diff --git a/src/tui/components/teamwork/task-metadata.tsx b/src/tui/components/teamwork/task-metadata.tsx index b86f385..4a25498 100644 --- a/src/tui/components/teamwork/task-metadata.tsx +++ b/src/tui/components/teamwork/task-metadata.tsx @@ -1,4 +1,4 @@ -import type { TeamworkTask } from "../../../teamwork/task-list-tasks.ts"; +import type { TeamworkTask } from "../../../api/teamwork/task-list-tasks.ts"; /** Builds an array of description strings for a task's metadata row. */ export function buildTaskMetadata(task: TeamworkTask): string[] { diff --git a/src/tui/components/update-dialog.tsx b/src/tui/components/update-dialog.tsx index a8e1f0d..cf3b29d 100644 --- a/src/tui/components/update-dialog.tsx +++ b/src/tui/components/update-dialog.tsx @@ -1,7 +1,7 @@ import { TextAttributes } from "@opentui/core"; import { useBindings } from "@opentui/keymap/solid"; -import { APP_VERSION, REPO } from "../../config/consts.ts"; +import { APP_VERSION, REPO } from "../../api/config/consts.ts"; import { tokens } from "../tokens.ts"; diff --git a/src/tui/pages/dashboard.tsx b/src/tui/pages/dashboard.tsx index a66881c..7804a14 100644 --- a/src/tui/pages/dashboard.tsx +++ b/src/tui/pages/dashboard.tsx @@ -1,6 +1,6 @@ import { TextAttributes } from "@opentui/core"; -import { APP_VERSION } from "../../config/consts.ts"; +import { APP_VERSION } from "../../api/config/consts.ts"; import { tokens } from "../tokens.ts"; diff --git a/src/tui/pages/settings.tsx b/src/tui/pages/settings.tsx index 6d418ec..ed98582 100644 --- a/src/tui/pages/settings.tsx +++ b/src/tui/pages/settings.tsx @@ -1,10 +1,10 @@ import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"; import { useBindings } from "@opentui/keymap/solid"; -import { loadResolvedConfig, saveProjectConfig, saveUserConfig } from "../../config/manager.ts"; -import type { ProjectConfig, ResolvedConfig, UserConfig } from "../../config/schema.ts"; -import { getTeamworkAuthStatus, setTeamworkApiToken } from "../../teamwork/auth.ts"; -import type { TeamworkAuthStatus } from "../../teamwork/auth.ts"; +import { loadResolvedConfig, saveProjectConfig, saveUserConfig } from "../../api/config/manager.ts"; +import type { ProjectConfig, ResolvedConfig, UserConfig } from "../../api/config/schema.ts"; +import { getTeamworkAuthStatus, setTeamworkApiToken } from "../../api/teamwork/auth.ts"; +import type { TeamworkAuthStatus } from "../../api/teamwork/auth.ts"; import { ActionButton } from "../components/forms/action-button.tsx"; import { Page } from "../components/layout/page.tsx"; import { useStatusBar } from "../components/status-bar.tsx"; diff --git a/src/tui/pages/settings/user-config-section.tsx b/src/tui/pages/settings/user-config-section.tsx index 9cfa789..6a90098 100644 --- a/src/tui/pages/settings/user-config-section.tsx +++ b/src/tui/pages/settings/user-config-section.tsx @@ -1,4 +1,4 @@ -import type { TeamworkAuthStatus } from "../../../teamwork/auth.ts"; +import type { TeamworkAuthStatus } from "../../../api/teamwork/auth.ts"; import { TextField } from "../../components/forms/text-field.tsx"; import { AccordionSection } from "../../components/layout/accordion-section.tsx"; import { tokens } from "../../tokens.ts"; diff --git a/src/tui/pages/teamwork/project-tab.tsx b/src/tui/pages/teamwork/project-tab.tsx index b905584..0854c91 100644 --- a/src/tui/pages/teamwork/project-tab.tsx +++ b/src/tui/pages/teamwork/project-tab.tsx @@ -1,21 +1,24 @@ import { createEffect, createSignal, For, onCleanup, onMount } from "solid-js"; import { useBindings } from "@opentui/keymap/solid"; -import { loadResolvedConfig } from "../../../config/manager.ts"; -import type { ResolvedConfig } from "../../../config/schema.ts"; -import { getTeamworkAuthStatus, type TeamworkAuthStatus } from "../../../teamwork/auth.ts"; +import { loadResolvedConfig } from "../../../api/config/manager.ts"; +import type { ResolvedConfig } from "../../../api/config/schema.ts"; +import { getTeamworkAuthStatus, type TeamworkAuthStatus } from "../../../api/teamwork/auth.ts"; import { getTeamworkProjectMetadata, type TeamworkProjectMetadataResult, -} from "../../../teamwork/project-metadata.ts"; -import { getTeamworkTaskListTasks, type TeamworkTask } from "../../../teamwork/task-list-tasks.ts"; +} from "../../../api/teamwork/project-metadata.ts"; +import { + getTeamworkTaskListTasks, + type TeamworkTask, +} from "../../../api/teamwork/task-list-tasks.ts"; import { loadLocalTimers, startLocalTimer, stopLocalTimer, type LocalTimerEntry, -} from "../../../teamwork/timers/local.ts"; -import { getTeamworkTaskReference } from "../../../teamwork/tasks.ts"; +} from "../../../api/teamwork/timers/local.ts"; +import { getTeamworkTaskReference } from "../../../api/teamwork/tasks.ts"; import { openUrlInBrowser } from "../../../utils/browser.ts"; import { Card } from "../../components/layout/card.tsx"; import { ConfirmDialog } from "../../components/confirm-dialog.tsx"; diff --git a/src/tui/pages/teamwork/timers-tab.tsx b/src/tui/pages/teamwork/timers-tab.tsx index 3277909..3c8a12a 100644 --- a/src/tui/pages/teamwork/timers-tab.tsx +++ b/src/tui/pages/teamwork/timers-tab.tsx @@ -7,9 +7,9 @@ import { removeLocalTimer, stopLocalTimer, type LocalTimerEntry, -} from "../../../teamwork/timers/local.ts"; -import { createTaskTimeEntry } from "../../../teamwork/timers.ts"; -import { TEAMWORK_TIMESHEET_URL } from "../../../teamwork/consts.ts"; +} from "../../../api/teamwork/timers/local.ts"; +import { createTaskTimeEntry } from "../../../api/teamwork/timers.ts"; +import { TEAMWORK_TIMESHEET_URL } from "../../../api/teamwork/consts.ts"; import { openUrlInBrowser } from "../../../utils/browser.ts"; import { Card } from "../../components/layout/card.tsx"; import { ListItem } from "../../components/layout/list-item.tsx"; diff --git a/src/utils/update-check.ts b/src/utils/update-check.ts index 096e6c8..c3e39c8 100644 --- a/src/utils/update-check.ts +++ b/src/utils/update-check.ts @@ -1,5 +1,5 @@ -import { APP_VERSION } from "../config/consts"; -import { getCacheDir } from "../state/consts.ts"; +import { APP_VERSION } from "../api/config/consts.ts"; +import { getCacheDir } from "../api/cache/consts.ts"; /** How long a successful GitHub release lookup remains fresh. */ const CACHE_TTL_MS = 24 * 60 * 60 * 1000; diff --git a/tests/state/consts.test.ts b/tests/api/cache/consts.test.ts similarity index 88% rename from tests/state/consts.test.ts rename to tests/api/cache/consts.test.ts index 84ed841..32cb53c 100644 --- a/tests/state/consts.test.ts +++ b/tests/api/cache/consts.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { getCacheDir } from "../../src/state/consts.ts"; +import { getCacheDir } from "../../../src/api/cache/consts.ts"; const TEST_CACHE = `/tmp/wtc-state-consts-tests-${process.pid}`; diff --git a/tests/config/consts.test.ts b/tests/api/config/consts.test.ts similarity index 88% rename from tests/config/consts.test.ts rename to tests/api/config/consts.test.ts index f5e6e70..5977498 100644 --- a/tests/config/consts.test.ts +++ b/tests/api/config/consts.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { getUserConfigDir } from "../../src/config/consts.ts"; +import { getUserConfigDir } from "../../../src/api/config/consts.ts"; const TEST_DIR = `/tmp/wtc-config-consts-tests-${process.pid}`; diff --git a/tests/config/manager.test.ts b/tests/api/config/manager.test.ts similarity index 99% rename from tests/config/manager.test.ts rename to tests/api/config/manager.test.ts index c18bbb4..b53b609 100644 --- a/tests/config/manager.test.ts +++ b/tests/api/config/manager.test.ts @@ -11,7 +11,7 @@ import { loadUserConfig, saveProjectConfig, saveUserConfig, -} from "../../src/config/manager.ts"; +} from "../../../src/api/config/manager.ts"; const TEST_ROOT = `${Bun.env.TMPDIR ?? "/tmp"}/wtc-config-tests-${process.pid}`; const USER_CONFIG_DIR = `${TEST_ROOT}/user-config`; diff --git a/tests/config/schema.test.ts b/tests/api/config/schema.test.ts similarity index 76% rename from tests/config/schema.test.ts rename to tests/api/config/schema.test.ts index 5e52632..16042aa 100644 --- a/tests/config/schema.test.ts +++ b/tests/api/config/schema.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { ProjectConfigSchema, UserConfigSchema } from "../../src/config/schema.ts"; +import { ProjectConfigSchema, UserConfigSchema } from "../../../src/api/config/schema.ts"; describe("config schemas", () => { test("rejects unsupported config versions", () => { diff --git a/tests/state/manager.test.ts b/tests/api/state/manager.test.ts similarity index 93% rename from tests/state/manager.test.ts rename to tests/api/state/manager.test.ts index 2395fb2..5595ff4 100644 --- a/tests/state/manager.test.ts +++ b/tests/api/state/manager.test.ts @@ -1,8 +1,9 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { rm } from "node:fs/promises"; -import { clearCache, loadTuiState, saveTuiState } from "../../src/state/manager.ts"; -import { getCacheDir } from "../../src/state/consts.ts"; +import { clearCache } from "../../../src/api/cache/manager.ts"; +import { loadTuiState, saveTuiState } from "../../../src/api/state/manager.ts"; +import { getCacheDir } from "../../../src/api/cache/consts.ts"; const TEST_CACHE = `/tmp/wtc-state-tests-${process.pid}`; diff --git a/tests/state/schema.test.ts b/tests/api/state/schema.test.ts similarity index 88% rename from tests/state/schema.test.ts rename to tests/api/state/schema.test.ts index fb79422..fb693cc 100644 --- a/tests/state/schema.test.ts +++ b/tests/api/state/schema.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { TuiStateEntrySchema, TuiStateFileSchema } from "../../src/state/schema.ts"; +import { TuiStateEntrySchema, TuiStateFileSchema } from "../../../src/api/state/schema.ts"; describe("TuiStateEntrySchema", () => { test("accepts extra unknown fields for forward compat", () => { diff --git a/tests/teamwork/auth.test.ts b/tests/api/teamwork/auth.test.ts similarity index 74% rename from tests/teamwork/auth.test.ts rename to tests/api/teamwork/auth.test.ts index ae12574..3d54368 100644 --- a/tests/teamwork/auth.test.ts +++ b/tests/api/teamwork/auth.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { createTeamworkAuthorizationHeader } from "../../src/teamwork/auth.ts"; +import { createTeamworkAuthorizationHeader } from "../../../src/api/teamwork/auth.ts"; describe("teamwork auth", () => { test("builds basic authorization header from API token", () => { diff --git a/tests/teamwork/project-metadata.test.ts b/tests/api/teamwork/project-metadata.test.ts similarity index 82% rename from tests/teamwork/project-metadata.test.ts rename to tests/api/teamwork/project-metadata.test.ts index 349f332..f4596c2 100644 --- a/tests/teamwork/project-metadata.test.ts +++ b/tests/api/teamwork/project-metadata.test.ts @@ -1,12 +1,17 @@ import { describe, expect, mock, test, afterEach } from "bun:test"; -import { createMockFetch, mockTeamworkAuthModule, useTempCacheDir } from "../helpers/teamwork.ts"; -import { TEAMWORK_API_BASE_URL } from "../../src/teamwork/consts.ts"; +import { + createMockFetch, + mockTeamworkAuthModule, + useTempCacheDir, +} from "../../helpers/teamwork.ts"; +import { TEAMWORK_API_BASE_URL } from "../../../src/api/teamwork/consts.ts"; -mock.module("../../src/teamwork/auth.ts", mockTeamworkAuthModule); +mock.module("../../../src/api/teamwork/auth.ts", mockTeamworkAuthModule); -const { createTeamworkAuthorizationHeader } = await import("../../src/teamwork/auth.ts"); -const { getTeamworkProjectMetadata } = await import("../../src/teamwork/project-metadata.ts"); +const { createTeamworkAuthorizationHeader } = await import("../../../src/api/teamwork/auth.ts"); +const { getTeamworkProjectMetadata } = + await import("../../../src/api/teamwork/project-metadata.ts"); const originalFetch = globalThis.fetch; diff --git a/tests/teamwork/task-list-tasks.test.ts b/tests/api/teamwork/task-list-tasks.test.ts similarity index 90% rename from tests/teamwork/task-list-tasks.test.ts rename to tests/api/teamwork/task-list-tasks.test.ts index 33a291f..3767949 100644 --- a/tests/teamwork/task-list-tasks.test.ts +++ b/tests/api/teamwork/task-list-tasks.test.ts @@ -1,12 +1,16 @@ import { describe, expect, mock, test, afterEach } from "bun:test"; -import { createMockFetch, mockTeamworkAuthModule, useTempCacheDir } from "../helpers/teamwork.ts"; -import { TEAMWORK_API_BASE_URL } from "../../src/teamwork/consts.ts"; +import { + createMockFetch, + mockTeamworkAuthModule, + useTempCacheDir, +} from "../../helpers/teamwork.ts"; +import { TEAMWORK_API_BASE_URL } from "../../../src/api/teamwork/consts.ts"; -mock.module("../../src/teamwork/auth.ts", mockTeamworkAuthModule); +mock.module("../../../src/api/teamwork/auth.ts", mockTeamworkAuthModule); -const { createTeamworkAuthorizationHeader } = await import("../../src/teamwork/auth.ts"); -const { getTeamworkTaskListTasks } = await import("../../src/teamwork/task-list-tasks.ts"); +const { createTeamworkAuthorizationHeader } = await import("../../../src/api/teamwork/auth.ts"); +const { getTeamworkTaskListTasks } = await import("../../../src/api/teamwork/task-list-tasks.ts"); const originalFetch = globalThis.fetch; diff --git a/tests/teamwork/task.test.ts b/tests/api/teamwork/task.test.ts similarity index 90% rename from tests/teamwork/task.test.ts rename to tests/api/teamwork/task.test.ts index 5ffad6c..1010c2e 100644 --- a/tests/teamwork/task.test.ts +++ b/tests/api/teamwork/task.test.ts @@ -1,10 +1,10 @@ import { describe, expect, mock, test, afterEach } from "bun:test"; -import { createMockFetch, mockTeamworkAuthModule } from "../helpers/teamwork.ts"; +import { createMockFetch, mockTeamworkAuthModule } from "../../helpers/teamwork.ts"; -mock.module("../../src/teamwork/auth.ts", mockTeamworkAuthModule); +mock.module("../../../src/api/teamwork/auth.ts", mockTeamworkAuthModule); -const { getTeamworkTaskById } = await import("../../src/teamwork/task.ts"); +const { getTeamworkTaskById } = await import("../../../src/api/teamwork/task.ts"); const originalFetch = globalThis.fetch; diff --git a/tests/teamwork/tasks.test.ts b/tests/api/teamwork/tasks.test.ts similarity index 85% rename from tests/teamwork/tasks.test.ts rename to tests/api/teamwork/tasks.test.ts index 3a719c9..fa44b7e 100644 --- a/tests/teamwork/tasks.test.ts +++ b/tests/api/teamwork/tasks.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; -import { TEAMWORK_BASE_URL } from "../../src/teamwork/consts.ts"; -import { getTeamworkTaskReference } from "../../src/teamwork/tasks.ts"; +import { TEAMWORK_BASE_URL } from "../../../src/api/teamwork/consts.ts"; +import { getTeamworkTaskReference } from "../../../src/api/teamwork/tasks.ts"; describe("teamwork task references", () => { test("builds task reference from numeric ID", () => { diff --git a/tests/teamwork/timers-local.test.ts b/tests/api/teamwork/timers-local.test.ts similarity index 97% rename from tests/teamwork/timers-local.test.ts rename to tests/api/teamwork/timers-local.test.ts index 0257b19..33f138f 100644 --- a/tests/teamwork/timers-local.test.ts +++ b/tests/api/teamwork/timers-local.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { useTempCacheDir } from "../helpers/teamwork.ts"; +import { useTempCacheDir } from "../../helpers/teamwork.ts"; useTempCacheDir(); @@ -12,7 +12,7 @@ const { removeLocalTimer, getLocalTimerElapsedMs, formatTimerDuration, -} = await import("../../src/teamwork/timers/local.ts"); +} = await import("../../../src/api/teamwork/timers/local.ts"); describe("getRunningTimer", () => { test("returns null when no timers are running", () => { diff --git a/tests/teamwork/timers.test.ts b/tests/api/teamwork/timers.test.ts similarity index 96% rename from tests/teamwork/timers.test.ts rename to tests/api/teamwork/timers.test.ts index 4281006..61c1120 100644 --- a/tests/teamwork/timers.test.ts +++ b/tests/api/teamwork/timers.test.ts @@ -1,13 +1,13 @@ import { describe, expect, mock, test, afterEach } from "bun:test"; -import { createMockFetch, mockTeamworkAuthModule } from "../helpers/teamwork.ts"; -import { TEAMWORK_API_BASE_URL } from "../../src/teamwork/consts.ts"; +import { createMockFetch, mockTeamworkAuthModule } from "../../helpers/teamwork.ts"; +import { TEAMWORK_API_BASE_URL } from "../../../src/api/teamwork/consts.ts"; -mock.module("../../src/teamwork/auth.ts", mockTeamworkAuthModule); +mock.module("../../../src/api/teamwork/auth.ts", mockTeamworkAuthModule); -const { createTeamworkAuthorizationHeader } = await import("../../src/teamwork/auth.ts"); +const { createTeamworkAuthorizationHeader } = await import("../../../src/api/teamwork/auth.ts"); const { createTaskTimeEntry, getTimers, pauseTimer, startTimer } = - await import("../../src/teamwork/timers.ts"); + await import("../../../src/api/teamwork/timers.ts"); const originalFetch = globalThis.fetch; diff --git a/tests/teamwork/workflow-stages.test.ts b/tests/api/teamwork/workflow-stages.test.ts similarity index 86% rename from tests/teamwork/workflow-stages.test.ts rename to tests/api/teamwork/workflow-stages.test.ts index 330440c..99d8b84 100644 --- a/tests/teamwork/workflow-stages.test.ts +++ b/tests/api/teamwork/workflow-stages.test.ts @@ -1,11 +1,15 @@ import { afterEach, describe, expect, mock, test } from "bun:test"; -import { createMockFetch, mockTeamworkAuthModule, useTempCacheDir } from "../helpers/teamwork.ts"; -import { getCacheDir } from "../../src/state/consts.ts"; +import { + createMockFetch, + mockTeamworkAuthModule, + useTempCacheDir, +} from "../../helpers/teamwork.ts"; +import { getCacheDir } from "../../../src/api/cache/consts.ts"; -mock.module("../../src/teamwork/auth.ts", mockTeamworkAuthModule); +mock.module("../../../src/api/teamwork/auth.ts", mockTeamworkAuthModule); -const { getWorkflowStageNames } = await import("../../src/teamwork/workflow-stages.ts"); +const { getWorkflowStageNames } = await import("../../../src/api/teamwork/workflow-stages.ts"); const originalFetch = globalThis.fetch; diff --git a/tests/cli/commands/settings.test.ts b/tests/cli/commands/settings.test.ts index 386a165..69f3bed 100644 --- a/tests/cli/commands/settings.test.ts +++ b/tests/cli/commands/settings.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { formatSettingsOutput } from "../../../src/cli/commands/settings.ts"; -import type { ResolvedConfig } from "../../../src/config/schema.ts"; +import type { ResolvedConfig } from "../../../src/api/config/schema.ts"; describe("settings command", () => { test("formats resolved config with project config path", () => { diff --git a/tests/cli/commands/teamwork.test.ts b/tests/cli/commands/teamwork.test.ts index dda2c63..43a05cd 100644 --- a/tests/cli/commands/teamwork.test.ts +++ b/tests/cli/commands/teamwork.test.ts @@ -7,9 +7,9 @@ import { teamworkTaskListUnpin, teamworkTaskOpen, } from "../../../src/cli/commands/teamwork.ts"; -import type { ProjectConfig, ResolvedConfig } from "../../../src/config/schema.ts"; -import { TEAMWORK_BASE_URL } from "../../../src/teamwork/consts.ts"; -import type { TeamworkTask } from "../../../src/teamwork/task-list-tasks.ts"; +import type { ProjectConfig, ResolvedConfig } from "../../../src/api/config/schema.ts"; +import { TEAMWORK_BASE_URL } from "../../../src/api/teamwork/consts.ts"; +import type { TeamworkTask } from "../../../src/api/teamwork/task-list-tasks.ts"; const resolvedConfig: ResolvedConfig = { user: { version: 1, workspaceName: "WTC" }, diff --git a/tests/cli/commands/timers.test.ts b/tests/cli/commands/timers.test.ts index c22f9a1..b6d7fe7 100644 --- a/tests/cli/commands/timers.test.ts +++ b/tests/cli/commands/timers.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import type { LocalTimerEntry } from "../../../src/teamwork/timers/local.ts"; +import type { LocalTimerEntry } from "../../../src/api/teamwork/timers/local.ts"; const { formatTimerListOutput, @@ -105,14 +105,15 @@ describe("teamwork timer commands", () => { url: `https://teamwork.com/app/tasks/${value}`, }), getTeamworkTaskById: async (id: number) => ({ id, name: "General | Code Review" }), - startLocalTimer: async (taskId: number, taskName: string) => ({ - id: "new-timer", - taskId, - taskName, - startTime: "2026-06-24T12:00:00Z", - endTime: null, - status: "running", - } satisfies LocalTimerEntry), + startLocalTimer: async (taskId: number, taskName: string) => + ({ + id: "new-timer", + taskId, + taskName, + startTime: "2026-06-24T12:00:00Z", + endTime: null, + status: "running", + }) satisfies LocalTimerEntry, }; await teamworkTimerStart({ task: "12345" }, actions); diff --git a/tests/helpers/teamwork.ts b/tests/helpers/teamwork.ts index 6e88905..9ff2ee4 100644 --- a/tests/helpers/teamwork.ts +++ b/tests/helpers/teamwork.ts @@ -3,7 +3,7 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -/** Factory for `mock.module("../../src/teamwork/auth.ts", ...)`. */ +/** Factory for `mock.module("../../src/api/teamwork/auth.ts", ...)`. */ export function mockTeamworkAuthModule() { return { createTeamworkAuthorizationHeader(token: string) { diff --git a/tests/tui/settings.test.ts b/tests/tui/settings.test.ts index bcebd20..d635342 100644 --- a/tests/tui/settings.test.ts +++ b/tests/tui/settings.test.ts @@ -12,7 +12,7 @@ import { validateSettingsForm, type SettingsFormState, } from "../../src/tui/pages/settings.tsx"; -import type { ResolvedConfig } from "../../src/config/schema.ts"; +import type { ResolvedConfig } from "../../src/api/config/schema.ts"; describe("settings page helpers", () => { const resolvedConfig: ResolvedConfig = { diff --git a/tests/tui/teamwork.test.ts b/tests/tui/teamwork.test.ts index c8d8cfb..8fafd72 100644 --- a/tests/tui/teamwork.test.ts +++ b/tests/tui/teamwork.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import type { LocalTimerEntry } from "../../src/teamwork/timers/local.ts"; +import type { LocalTimerEntry } from "../../src/api/teamwork/timers/local.ts"; import { getNextTeamworkTab } from "../../src/tui/pages/teamwork.tsx"; import { getNextPinnedTaskSelection, From 82d85c4ae855980787e3f2f3e562d6d3658a5c6a Mon Sep 17 00:00:00 2001 From: Marlon Marcello Date: Thu, 25 Jun 2026 14:21:29 -0700 Subject: [PATCH 5/7] refactor: moves shared api interface from tui and cli --- src/api/teamwork/task-list-tasks.ts | 45 ++++++++ src/api/teamwork/timers/local.ts | 44 ++++++++ src/cli/commands/teamwork.ts | 40 +++----- src/cli/commands/timers.ts | 41 +++----- src/tui/pages/teamwork/project-tab.tsx | 31 ++---- src/tui/pages/teamwork/timers-tab.tsx | 22 +--- tests/api/teamwork/task-list-tasks.test.ts | 114 ++++++++++++++++++++- tests/api/teamwork/timers-local.test.ts | 69 +++++++++++++ tests/cli/commands/timers.test.ts | 9 -- 9 files changed, 307 insertions(+), 108 deletions(-) diff --git a/src/api/teamwork/task-list-tasks.ts b/src/api/teamwork/task-list-tasks.ts index 45849eb..668262d 100644 --- a/src/api/teamwork/task-list-tasks.ts +++ b/src/api/teamwork/task-list-tasks.ts @@ -204,3 +204,48 @@ export async function getTeamworkTaskListTasks(taskListId: number): Promise Promise; +} + +/** + * 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 { + 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; +} diff --git a/src/api/teamwork/timers/local.ts b/src/api/teamwork/timers/local.ts index ed8f3c2..ae30742 100644 --- a/src/api/teamwork/timers/local.ts +++ b/src/api/teamwork/timers/local.ts @@ -1,4 +1,5 @@ import { getCacheDir } from "../../cache/consts.ts"; +import type { TeamworkTaskTimeEntryInput } from "../timers.ts"; const LOCAL_TIMERS_CACHE_FILE = "teamwork-local-timers.json"; @@ -122,3 +123,46 @@ export async function removeLocalTimer(id: string): Promise { 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; +} + +/** + * 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 { + 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 }; +} diff --git a/src/cli/commands/teamwork.ts b/src/cli/commands/teamwork.ts index f83ab5b..39066ba 100644 --- a/src/cli/commands/teamwork.ts +++ b/src/cli/commands/teamwork.ts @@ -4,20 +4,20 @@ import { type ProjectConfig, type ResolvedConfig, } from "../../api/config/schema.ts"; -import { getTeamworkTaskListTasks, type TeamworkTask } from "../../api/teamwork/task-list-tasks.ts"; +import { + getPinnedTaskListTasks, + getTeamworkTaskListTasks, +} from "../../api/teamwork/task-list-tasks.ts"; +import type { + TeamworkTask, + PinnedTaskListFetchResult, +} from "../../api/teamwork/task-list-tasks.ts"; import { getTeamworkTaskReference } from "../../api/teamwork/tasks.ts"; import { openUrlInBrowser } from "../../utils/browser.ts"; -interface PinnedTaskListResult { - id: number; - name: string; - tasks: TeamworkTask[]; - error: string | null; -} - interface PinnedTaskListsResult { projectConfigPath: string | null; - taskLists: PinnedTaskListResult[]; + taskLists: PinnedTaskListFetchResult[]; } interface TeamworkTaskListPinnedActions { @@ -123,27 +123,11 @@ export async function teamworkTaskListPinned( const taskLists = config.project?.teamwork.pinnedTaskLists ?? []; const result: PinnedTaskListsResult = { projectConfigPath: config.paths.projectConfigPath, - taskLists: [], + taskLists: await getPinnedTaskListTasks(taskLists, { + getTeamworkTaskListTasks: actions.getTeamworkTaskListTasks, + }), }; - for (const taskList of taskLists) { - try { - result.taskLists.push({ - id: taskList.id, - name: taskList.name, - tasks: await actions.getTeamworkTaskListTasks(taskList.id), - error: null, - }); - } catch (error) { - result.taskLists.push({ - id: taskList.id, - name: taskList.name, - tasks: [], - error: error instanceof Error ? error.message : "Failed to load task list.", - }); - } - } - console.log(formatTeamworkTaskListPinnedOutput(result, { json: args.json })); } diff --git a/src/cli/commands/timers.ts b/src/cli/commands/timers.ts index a9a43f7..93f999d 100644 --- a/src/cli/commands/timers.ts +++ b/src/cli/commands/timers.ts @@ -1,7 +1,11 @@ import { TEAMWORK_TIMESHEET_URL } from "../../api/teamwork/consts.ts"; import { getTeamworkTaskReference, type TeamworkTaskReference } from "../../api/teamwork/tasks.ts"; import { getTeamworkTaskById } from "../../api/teamwork/task.ts"; -import { getLocalTimerElapsedMs, formatTimerDuration } from "../../api/teamwork/timers/local.ts"; +import { + getLocalTimerElapsedMs, + formatTimerDuration, + submitLocalTimer, +} from "../../api/teamwork/timers/local.ts"; import type { LocalTimerEntry } from "../../api/teamwork/timers/local.ts"; import { createTaskTimeEntry, type TeamworkTaskTimeEntryInput } from "../../api/teamwork/timers.ts"; import { openUrlInBrowser } from "../../utils/browser.ts"; @@ -26,9 +30,7 @@ interface TimerStopActions extends TimerHandleActions { } interface TimerSubmitActions extends TimerHandleActions { - stopLocalTimer: () => Promise; createTaskTimeEntry: (input: TeamworkTaskTimeEntryInput) => Promise; - removeLocalTimer: (id: string) => Promise; } interface TimerDiscardActions extends TimerHandleActions { @@ -73,15 +75,7 @@ const timerStopActions: TimerStopActions = { const timerSubmitActions: TimerSubmitActions = { ...timerHandleActions, - stopLocalTimer: async () => { - const { stopLocalTimer } = await import("../../api/teamwork/timers/local.ts"); - return stopLocalTimer(); - }, createTaskTimeEntry, - removeLocalTimer: async (id) => { - const { removeLocalTimer } = await import("../../api/teamwork/timers/local.ts"); - return removeLocalTimer(id); - }, }; const timerDiscardActions: TimerDiscardActions = { @@ -231,25 +225,14 @@ export async function teamworkTimerSubmit( return; } - const timerToSubmit = match.status === "running" ? await actions.stopLocalTimer() : match; - if (!timerToSubmit) { - console.log("No timer found to submit."); - return; + try { + const result = await submitLocalTimer(match, { + createTaskTimeEntry: actions.createTaskTimeEntry, + }); + console.log(`Timer submitted for: ${result.taskName} (#${result.taskId})`); + } catch (error) { + console.log(error instanceof Error ? error.message : "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 actions.removeLocalTimer(timerToSubmit.id); - console.log(`Timer submitted for: ${timerToSubmit.taskName} (#${timerToSubmit.taskId})`); } export async function teamworkTimerDiscard( diff --git a/src/tui/pages/teamwork/project-tab.tsx b/src/tui/pages/teamwork/project-tab.tsx index 0854c91..1cffe8e 100644 --- a/src/tui/pages/teamwork/project-tab.tsx +++ b/src/tui/pages/teamwork/project-tab.tsx @@ -8,10 +8,8 @@ import { getTeamworkProjectMetadata, type TeamworkProjectMetadataResult, } from "../../../api/teamwork/project-metadata.ts"; -import { - getTeamworkTaskListTasks, - type TeamworkTask, -} from "../../../api/teamwork/task-list-tasks.ts"; +import { getPinnedTaskListTasks } from "../../../api/teamwork/task-list-tasks.ts"; +import type { TeamworkTask } from "../../../api/teamwork/task-list-tasks.ts"; import { loadLocalTimers, startLocalTimer, @@ -107,24 +105,13 @@ export function ProjectTab() { const metadata = await getTeamworkProjectMetadata(projectId); setProjectMetadata(metadata); - const nextPinnedTaskLists: PinnedTaskListState[] = []; - for (const taskList of config.project.teamwork.pinnedTaskLists) { - try { - nextPinnedTaskLists.push({ - id: taskList.id, - name: taskList.name, - tasks: await getTeamworkTaskListTasks(taskList.id), - message: null, - }); - } catch (error) { - nextPinnedTaskLists.push({ - id: taskList.id, - name: taskList.name, - tasks: [], - message: error instanceof Error ? error.message : "Failed to load task list.", - }); - } - } + const results = await getPinnedTaskListTasks(config.project.teamwork.pinnedTaskLists); + const nextPinnedTaskLists: PinnedTaskListState[] = results.map((r) => ({ + id: r.id, + name: r.name, + tasks: r.tasks, + message: r.error, + })); setPinnedTaskLists(nextPinnedTaskLists); setSelectedTask(getNextPinnedTaskSelection(nextPinnedTaskLists, selectedTask(), 1)); diff --git a/src/tui/pages/teamwork/timers-tab.tsx b/src/tui/pages/teamwork/timers-tab.tsx index 3c8a12a..0741378 100644 --- a/src/tui/pages/teamwork/timers-tab.tsx +++ b/src/tui/pages/teamwork/timers-tab.tsx @@ -6,6 +6,7 @@ import { loadLocalTimers, removeLocalTimer, stopLocalTimer, + submitLocalTimer, type LocalTimerEntry, } from "../../../api/teamwork/timers/local.ts"; import { createTaskTimeEntry } from "../../../api/teamwork/timers.ts"; @@ -118,26 +119,9 @@ export function TimersTab() { confirmLabel="submit" onConfirm={async () => { try { - const timerToSubmit = timer.status === "running" ? await stopLocalTimer() : timer; - if (!timerToSubmit) { - setMessage("No running timer found to submit."); - return; - } - - const finalMinutes = Math.max( - 1, - Math.ceil(getLocalTimerElapsedMs(timerToSubmit, new Date()) / 60_000), - ); - await createTaskTimeEntry({ - taskId: timerToSubmit.taskId, - date: timerToSubmit.startTime.slice(0, 10), - hours: Math.floor(finalMinutes / 60), - minutes: finalMinutes % 60, - description: timerToSubmit.taskName, - }); - await removeLocalTimer(timerToSubmit.id); + const result = await submitLocalTimer(timer, { createTaskTimeEntry }); await refreshTimers(); - setMessage(`Timer submitted: ${timerToSubmit.taskName}`); + setMessage(`Timer submitted: ${result.taskName}`); } catch (error) { setMessage(error instanceof Error ? error.message : "Failed to submit timer."); } diff --git a/tests/api/teamwork/task-list-tasks.test.ts b/tests/api/teamwork/task-list-tasks.test.ts index 3767949..e6f28d9 100644 --- a/tests/api/teamwork/task-list-tasks.test.ts +++ b/tests/api/teamwork/task-list-tasks.test.ts @@ -10,7 +10,8 @@ import { TEAMWORK_API_BASE_URL } from "../../../src/api/teamwork/consts.ts"; mock.module("../../../src/api/teamwork/auth.ts", mockTeamworkAuthModule); const { createTeamworkAuthorizationHeader } = await import("../../../src/api/teamwork/auth.ts"); -const { getTeamworkTaskListTasks } = await import("../../../src/api/teamwork/task-list-tasks.ts"); +const { getTeamworkTaskListTasks, getPinnedTaskListTasks } = + await import("../../../src/api/teamwork/task-list-tasks.ts"); const originalFetch = globalThis.fetch; @@ -135,3 +136,114 @@ describe("teamwork task list tasks", () => { expect(authorization).toBe(createTeamworkAuthorizationHeader("token-123")); }); }); + +describe("getPinnedTaskListTasks", () => { + test("fetches all pinned task lists with error isolation", async () => { + const actions = { + getTeamworkTaskListTasks: async (id: number) => { + if (id === 1) + return [ + { + id: 10, + name: "Task A", + status: null, + url: null, + assignees: [], + dueDate: null, + boardColumn: null, + priority: null, + }, + ]; + if (id === 2) return []; + return []; + }, + }; + + const results = await getPinnedTaskListTasks( + [ + { id: 1, name: "Active" }, + { id: 2, name: "Empty" }, + ], + actions, + ); + + expect(results).toEqual([ + { + id: 1, + name: "Active", + tasks: [ + { + id: 10, + name: "Task A", + status: null, + url: null, + assignees: [], + dueDate: null, + boardColumn: null, + priority: null, + }, + ], + error: null, + }, + { id: 2, name: "Empty", tasks: [], error: null }, + ]); + }); + + test("isolates errors per list", async () => { + const actions = { + getTeamworkTaskListTasks: async (id: number) => { + if (id === 1) throw new Error("Network error"); + return [ + { + id: 20, + name: "Task B", + status: null, + url: null, + assignees: [], + dueDate: null, + boardColumn: null, + priority: null, + }, + ]; + }, + }; + + const results = await getPinnedTaskListTasks( + [ + { id: 1, name: "Failing" }, + { id: 2, name: "Working" }, + ], + actions, + ); + + expect(results).toEqual([ + { id: 1, name: "Failing", tasks: [], error: "Network error" }, + { + id: 2, + name: "Working", + tasks: [ + { + id: 20, + name: "Task B", + status: null, + url: null, + assignees: [], + dueDate: null, + boardColumn: null, + priority: null, + }, + ], + error: null, + }, + ]); + }); + + test("returns empty array when no pinned task lists are configured", async () => { + const actions = { + getTeamworkTaskListTasks: async () => [], + }; + + const results = await getPinnedTaskListTasks([], actions); + expect(results).toEqual([]); + }); +}); diff --git a/tests/api/teamwork/timers-local.test.ts b/tests/api/teamwork/timers-local.test.ts index 33f138f..13ff643 100644 --- a/tests/api/teamwork/timers-local.test.ts +++ b/tests/api/teamwork/timers-local.test.ts @@ -12,6 +12,7 @@ const { removeLocalTimer, getLocalTimerElapsedMs, formatTimerDuration, + submitLocalTimer, } = await import("../../../src/api/teamwork/timers/local.ts"); describe("getRunningTimer", () => { @@ -182,3 +183,71 @@ describe("removeLocalTimer", () => { expect((await loadLocalTimers()).length).toBe(1); }); }); + +describe("submitLocalTimer", () => { + test("submits a stopped timer and removes it", async () => { + const { timer } = await startLocalTimer(42, "Test Task"); + await stopLocalTimer(); + + const result = await submitLocalTimer( + { ...timer, status: "stopped" }, + { createTaskTimeEntry: async () => 99 }, + ); + + expect(result.taskName).toBe("Test Task"); + expect(result.taskId).toBe(42); + expect(result.elapsedMs).toBeGreaterThan(0); + + expect((await loadLocalTimers()).length).toBe(0); + }); + + test("stops and submits a running timer", async () => { + const { timer } = await startLocalTimer(42, "Test Task"); + + const result = await submitLocalTimer(timer, { + createTaskTimeEntry: async () => 99, + }); + + expect(result.taskName).toBe("Test Task"); + expect((await loadLocalTimers()).length).toBe(0); + }); + + test("throws when stopLocalTimer returns null for a running timer", async () => { + const timer = { + id: "nonexistent", + taskId: 1, + taskName: "Test", + startTime: new Date().toISOString(), + endTime: null, + status: "running" as const, + }; + + try { + await submitLocalTimer(timer, { createTaskTimeEntry: async () => 99 }); + expect(true).toBe(false); + } catch (error) { + expect((error as Error).message).toBe("No timer found to submit."); + } + }); + + test("forwards API errors from createTaskTimeEntry", async () => { + const { timer } = await startLocalTimer(42, "Test Task"); + await stopLocalTimer(); + + try { + await submitLocalTimer( + { ...timer, status: "stopped" }, + { + createTaskTimeEntry: async () => { + throw new Error("API rejected"); + }, + }, + ); + expect(true).toBe(false); + } catch (error) { + expect((error as Error).message).toBe("API rejected"); + } + + expect((await loadLocalTimers()).length).toBe(1); + }); +}); diff --git a/tests/cli/commands/timers.test.ts b/tests/cli/commands/timers.test.ts index b6d7fe7..2de6a52 100644 --- a/tests/cli/commands/timers.test.ts +++ b/tests/cli/commands/timers.test.ts @@ -182,9 +182,7 @@ describe("teamwork timer commands", () => { url: `https://teamwork.com/app/tasks/${value}`, }), loadLocalTimers: async () => [timerA, timerB], - stopLocalTimer: async () => null, createTaskTimeEntry: async () => 42, - removeLocalTimer: async () => {}, }; await teamworkTimerSubmit({ task: "2" }, actions); @@ -193,23 +191,16 @@ describe("teamwork timer commands", () => { }); test("submits a running timer (stops first)", async () => { - let stopped = false; const actions = { getTeamworkTaskReference: (value: string) => ({ id: 1, url: `https://teamwork.com/app/tasks/${value}`, }), loadLocalTimers: async () => [timerA, timerB], - stopLocalTimer: async () => { - stopped = true; - return { ...timerA, status: "stopped" as const, endTime: new Date().toISOString() }; - }, createTaskTimeEntry: async () => 42, - removeLocalTimer: async () => {}, }; await teamworkTimerSubmit({ task: "1" }, actions); - expect(stopped).toBe(true); expect(logs[0]).toContain("Timer submitted"); }); From 31c67e5334f1da07d49eec8f06c18813a8dd182a Mon Sep 17 00:00:00 2001 From: Marlon Marcello Date: Thu, 25 Jun 2026 14:29:40 -0700 Subject: [PATCH 6/7] fix: missing mocks for tests --- tests/api/teamwork/timers-local.test.ts | 17 ++++++++++------- tests/cli/commands/timers.test.ts | 6 ++++++ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/tests/api/teamwork/timers-local.test.ts b/tests/api/teamwork/timers-local.test.ts index 13ff643..aeff747 100644 --- a/tests/api/teamwork/timers-local.test.ts +++ b/tests/api/teamwork/timers-local.test.ts @@ -186,17 +186,20 @@ describe("removeLocalTimer", () => { describe("submitLocalTimer", () => { test("submits a stopped timer and removes it", async () => { - const { timer } = await startLocalTimer(42, "Test Task"); - await stopLocalTimer(); + await startLocalTimer(42, "Test Task"); + const stopped = await stopLocalTimer(); + if (!stopped) { + expect(true).toBe(false); + return; + } - const result = await submitLocalTimer( - { ...timer, status: "stopped" }, - { createTaskTimeEntry: async () => 99 }, - ); + const result = await submitLocalTimer(stopped, { + createTaskTimeEntry: async () => 99, + }); expect(result.taskName).toBe("Test Task"); expect(result.taskId).toBe(42); - expect(result.elapsedMs).toBeGreaterThan(0); + expect(result.elapsedMs).toBeGreaterThanOrEqual(0); expect((await loadLocalTimers()).length).toBe(0); }); diff --git a/tests/cli/commands/timers.test.ts b/tests/cli/commands/timers.test.ts index 2de6a52..b7409b4 100644 --- a/tests/cli/commands/timers.test.ts +++ b/tests/cli/commands/timers.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import type { LocalTimerEntry } from "../../../src/api/teamwork/timers/local.ts"; +import { useTempCacheDir } from "../../helpers/teamwork.ts"; const { formatTimerListOutput, @@ -42,6 +43,8 @@ const timerC: LocalTimerEntry = { }; describe("teamwork timer commands", () => { + useTempCacheDir(); + beforeEach(() => { logs = []; console.log = (message?: unknown) => { @@ -191,6 +194,9 @@ describe("teamwork timer commands", () => { }); test("submits a running timer (stops first)", async () => { + const { startLocalTimer } = await import("../../../src/api/teamwork/timers/local.ts"); + await startLocalTimer(1, "General | Code Review"); + const actions = { getTeamworkTaskReference: (value: string) => ({ id: 1, From 3519c584857ed109a3d55686df6ca28fa281f9ce Mon Sep 17 00:00:00 2001 From: Marlon Marcello Date: Thu, 25 Jun 2026 14:43:26 -0700 Subject: [PATCH 7/7] fix: yargs handlers --- src/cli/commands/cache-command.ts | 4 +-- src/cli/commands/config-command.ts | 16 +++-------- src/cli/commands/settings-command.ts | 4 +-- src/cli/commands/teamwork-command.ts | 43 ++++++++-------------------- src/cli/commands/upgrade-command.ts | 4 +-- 5 files changed, 19 insertions(+), 52 deletions(-) diff --git a/src/cli/commands/cache-command.ts b/src/cli/commands/cache-command.ts index 4c2fb8c..5f9a55f 100644 --- a/src/cli/commands/cache-command.ts +++ b/src/cli/commands/cache-command.ts @@ -5,9 +5,7 @@ import { cacheClean } from "./cache.ts"; const cacheCleanCommand: CommandModule = { command: "clean", describe: "Delete all cached data", - handler: () => { - void cacheClean(); - }, + handler: () => cacheClean(), }; export const cacheCommand: CommandModule = { diff --git a/src/cli/commands/config-command.ts b/src/cli/commands/config-command.ts index bf6ba92..c3fc651 100644 --- a/src/cli/commands/config-command.ts +++ b/src/cli/commands/config-command.ts @@ -11,9 +11,7 @@ import { const configInitCommand: CommandModule = { command: "init", describe: "Create a project config in the current directory", - handler: () => { - void configInit(); - }, + handler: () => configInit(), }; const configAuthSetCommand: CommandModule<{}, { provider: string; token: string }> = { @@ -31,9 +29,7 @@ const configAuthSetCommand: CommandModule<{}, { provider: string; token: string describe: "API token to store", demandOption: true, }) as unknown as Argv<{ provider: string; token: string }>, - handler: (argv) => { - void configAuthSet({ provider: argv.provider ?? "", token: argv.token }); - }, + handler: (argv) => configAuthSet({ provider: argv.provider ?? "", token: argv.token }), }; const configAuthStatusCommand: CommandModule<{}, { provider: string }> = { @@ -45,9 +41,7 @@ const configAuthStatusCommand: CommandModule<{}, { provider: string }> = { choices: [...CONFIG_AUTH_PROVIDERS], describe: "Auth provider", }) as unknown as Argv<{ provider: string }>, - handler: (argv) => { - void configAuthStatus({ provider: argv.provider ?? "" }); - }, + handler: (argv) => configAuthStatus({ provider: argv.provider ?? "" }), }; const configAuthDeleteCommand: CommandModule<{}, { provider: string }> = { @@ -59,9 +53,7 @@ const configAuthDeleteCommand: CommandModule<{}, { provider: string }> = { choices: [...CONFIG_AUTH_PROVIDERS], describe: "Auth provider", }) as unknown as Argv<{ provider: string }>, - handler: (argv) => { - void configAuthDelete({ provider: argv.provider ?? "" }); - }, + handler: (argv) => configAuthDelete({ provider: argv.provider ?? "" }), }; const configAuthCommand: CommandModule = { diff --git a/src/cli/commands/settings-command.ts b/src/cli/commands/settings-command.ts index 5090fab..8e3f367 100644 --- a/src/cli/commands/settings-command.ts +++ b/src/cli/commands/settings-command.ts @@ -6,7 +6,5 @@ import { settings } from "./settings.ts"; export const settingsCommand: CommandModule = { command: "settings", describe: "Print resolved config and config file paths", - handler: () => { - void settings(); - }, + handler: () => settings(), }; diff --git a/src/cli/commands/teamwork-command.ts b/src/cli/commands/teamwork-command.ts index 6d8ce96..11562a4 100644 --- a/src/cli/commands/teamwork-command.ts +++ b/src/cli/commands/teamwork-command.ts @@ -24,9 +24,7 @@ const taskListPinnedCommand: CommandModule<{}, { json: boolean }> = { describe: "Print JSON output", default: false, }) as unknown as Argv<{ json: boolean }>, - handler: (argv) => { - void teamworkTaskListPinned({ json: argv.json ?? false }); - }, + handler: (argv) => teamworkTaskListPinned({ json: argv.json ?? false }), }; const taskListPinCommand: CommandModule<{}, { taskListId: number; name: string }> = { @@ -43,12 +41,11 @@ const taskListPinCommand: CommandModule<{}, { taskListId: number; name: string } describe: "Display name for this task list", demandOption: true, }) as unknown as Argv<{ taskListId: number; name: string }>, - handler: (argv) => { - void teamworkTaskListPin({ + handler: (argv) => + teamworkTaskListPin({ taskListId: argv.taskListId ?? 0, name: argv.name ?? "", - }); - }, + }), }; const taskListUnpinCommand: CommandModule<{}, { taskListId: number }> = { @@ -59,9 +56,7 @@ const taskListUnpinCommand: CommandModule<{}, { taskListId: number }> = { type: "number", describe: "Teamwork task list ID", }) as unknown as Argv<{ taskListId: number }>, - handler: (argv) => { - void teamworkTaskListUnpin({ taskListId: argv.taskListId ?? 0 }); - }, + handler: (argv) => teamworkTaskListUnpin({ taskListId: argv.taskListId ?? 0 }), }; const taskListCommand: CommandModule = { @@ -84,9 +79,7 @@ const taskOpenCommand: CommandModule<{}, { task: string }> = { type: "string", describe: "Teamwork task ID or URL", }) as unknown as Argv<{ task: string }>, - handler: (argv) => { - void teamworkTaskOpen({ task: argv.task ?? "" }); - }, + handler: (argv) => teamworkTaskOpen({ task: argv.task ?? "" }), }; const taskCommand: CommandModule = { @@ -106,9 +99,7 @@ const timerListCommand: CommandModule<{}, { json: boolean }> = { describe: "Print JSON output", default: false, }) as unknown as Argv<{ json: boolean }>, - handler: (argv) => { - void teamworkTimerList({ json: argv.json ?? false }); - }, + handler: (argv) => teamworkTimerList({ json: argv.json ?? false }), }; const timerStartCommand: CommandModule<{}, { task: string }> = { @@ -119,9 +110,7 @@ const timerStartCommand: CommandModule<{}, { task: string }> = { type: "string", describe: "Teamwork task ID or URL", }) as unknown as Argv<{ task: string }>, - handler: (argv) => { - void teamworkTimerStart({ task: argv.task ?? "" }); - }, + handler: (argv) => teamworkTimerStart({ task: argv.task ?? "" }), }; const timerStopCommand: CommandModule<{}, { task: string }> = { @@ -132,9 +121,7 @@ const timerStopCommand: CommandModule<{}, { task: string }> = { type: "string", describe: "Teamwork task ID or URL", }) as unknown as Argv<{ task: string }>, - handler: (argv) => { - void teamworkTimerStop({ task: argv.task ?? "" }); - }, + handler: (argv) => teamworkTimerStop({ task: argv.task ?? "" }), }; const timerSubmitCommand: CommandModule<{}, { task: string }> = { @@ -145,9 +132,7 @@ const timerSubmitCommand: CommandModule<{}, { task: string }> = { type: "string", describe: "Teamwork task ID or URL", }) as unknown as Argv<{ task: string }>, - handler: (argv) => { - void teamworkTimerSubmit({ task: argv.task ?? "" }); - }, + handler: (argv) => teamworkTimerSubmit({ task: argv.task ?? "" }), }; const timerDiscardCommand: CommandModule<{}, { task: string }> = { @@ -158,9 +143,7 @@ const timerDiscardCommand: CommandModule<{}, { task: string }> = { type: "string", describe: "Teamwork task ID or URL", }) as unknown as Argv<{ task: string }>, - handler: (argv) => { - void teamworkTimerDiscard({ task: argv.task ?? "" }); - }, + handler: (argv) => teamworkTimerDiscard({ task: argv.task ?? "" }), }; const timerCommand: CommandModule = { @@ -180,9 +163,7 @@ const timerCommand: CommandModule = { const timesheetCommand: CommandModule = { command: "timesheet", describe: "Open the Teamwork timesheet in the browser", - handler: () => { - void teamworkTimesheetOpen(); - }, + handler: () => teamworkTimesheetOpen(), }; export const teamworkCommand: CommandModule = { diff --git a/src/cli/commands/upgrade-command.ts b/src/cli/commands/upgrade-command.ts index cc18540..bc5033a 100644 --- a/src/cli/commands/upgrade-command.ts +++ b/src/cli/commands/upgrade-command.ts @@ -11,7 +11,5 @@ export const upgradeCommand: CommandModule<{}, { check: boolean }> = { type: "boolean", describe: "Only check for updates", }) as unknown as Argv<{ check: boolean }>, - handler: (argv) => { - void upgrade({ check: argv.check ?? false }); - }, + handler: (argv) => upgrade({ check: argv.check ?? false }), };