diff --git a/_specs/timers-200-ok.json b/_specs/timers-200-ok.json new file mode 100644 index 0000000..84386e8 --- /dev/null +++ b/_specs/timers-200-ok.json @@ -0,0 +1,710 @@ +{ + "included": { + "companies": { + "additionalProp": { + "accounts": 0, + "addressOne": "string", + "addressTwo": "string", + "budgetDistribution": [ + { + "color": "string", + "companyId": 0, + "count": 0, + "from": 0, + "to": 0 + } + ], + "canSeePrivate": true, + "cid": "string", + "city": "string", + "clientManagedBy": { + "id": 0, + "meta": {}, + "type": "string" + }, + "clients": 0, + "collaborators": 0, + "companyDomains": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "companyNameUrl": "string", + "companyUpdate": { + "id": 0, + "meta": {}, + "type": "string" + }, + "contacts": 0, + "countryCode": "string", + "createdAt": "string", + "currency": { + "id": 0, + "meta": {}, + "type": "string" + }, + "emailOne": "string", + "emailThree": "string", + "emailTwo": "string", + "fax": "string", + "financialBudgetSummary": { + "totalCapacity": 0, + "totalCapacityUsed": 0 + }, + "id": 0, + "industry": { + "id": 0, + "meta": {}, + "type": "string" + }, + "industryId": 0, + "isOwner": true, + "logoUrl": "string", + "name": "string", + "phone": "string", + "privateNotes": "string", + "privateNotesText": "string", + "profileText": "string", + "profitability": { + "billable": 0, + "billableTime": 0, + "companyCount": 0, + "cost": 0, + "expenses": 0, + "expensesBillableTotal": 0, + "loggedTime": 0, + "nonBillableTime": 0, + "ownerCount": 0, + "profit": 0, + "profitPercentage": 0, + "profitTargetPercentage": 0, + "targetCostsDollars": 0, + "targetProfitDollars": 0 + }, + "rates": [ + { + "createdAt": "string", + "createdByUser": { + "id": 0, + "meta": {}, + "type": "string" + }, + "rate": { + "amount": 0, + "currency": { + "id": 0, + "meta": {}, + "type": "string" + } + }, + "role": { + "id": 0, + "meta": {}, + "type": "string" + }, + "updatedAt": "string", + "updatedByUser": { + "id": 0, + "meta": {}, + "type": "string" + } + } + ], + "state": "string", + "stats": { + "projectCount": 0, + "taskCompleteCount": 0, + "taskCount": 0, + "unreadEmailCount": 0 + }, + "status": "string", + "tags": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "timeBudgetSummary": { + "totalCapacity": 0, + "totalCapacityUsed": 0 + }, + "updatedAt": "string", + "website": "string", + "zip": "string" + } + }, + "projects": { + "additionalProp": { + "activePages": { + "billing": true, + "board": true, + "comments": true, + "files": true, + "finance": true, + "forms": true, + "gantt": true, + "links": true, + "list": true, + "messages": true, + "milestones": true, + "notebooks": true, + "proofs": true, + "riskRegister": true, + "schedule": true, + "table": true, + "tasks": true, + "tickets": true, + "time": true, + "timeline": true + }, + "allowNotifyAnyone": true, + "announcement": "string", + "archivedAt": "string", + "archivedBy": 0, + "category": { + "id": 0, + "meta": {}, + "type": "string" + }, + "categoryId": 0, + "company": { + "id": 0, + "meta": {}, + "type": "string" + }, + "companyId": 0, + "completedAt": "string", + "completedBy": 0, + "createdAt": "string", + "createdBy": 0, + "customFieldValueIds": [0], + "customfieldValues": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "defaultPrivacy": "string", + "deletedAt": "string", + "deletedBy": 0, + "description": "string", + "directFileUploadsEnabled": true, + "endAt": "string", + "endDate": "string", + "financialBudget": { + "id": 0, + "meta": {}, + "type": "string" + }, + "financialBudgetId": 0, + "harvestTimersEnabled": true, + "id": 0, + "integrations": { + "googleDrive": { + "access": "string", + "enabled": true, + "folder": "string", + "folderName": "string" + }, + "oneDriveBusiness": { + "account": "string", + "enabled": true, + "folder": "string", + "folderName": "string" + }, + "sharepoint": { + "account": "string", + "enabled": true, + "folder": "string", + "folderName": "string" + }, + "xero": { + "baseCurrency": "string", + "connected": true, + "countryCode": "string", + "enabled": true, + "organisation": "string" + } + }, + "isBillable": true, + "isOnBoardingProject": true, + "isProjectAdmin": true, + "isSampleProject": true, + "isStarred": true, + "lastWorkedOn": "string", + "latestActivity": { + "id": 0, + "meta": {}, + "type": "string" + }, + "logo": "string", + "logoColor": "string", + "logoIcon": "string", + "minMaxAvailableDates": { + "deadlinesFound": true, + "maxEndDate": "string", + "minStartDate": "string", + "suggestedEndDate": "string", + "suggestedStartDate": "string" + }, + "name": "string", + "notifyCommentIncludeCreator": true, + "notifyEveryone": true, + "notifyTaskAssignee": true, + "overviewStartPage": "string", + "ownedBy": 0, + "ownerId": 0, + "portfolioCards": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "privacyEnabled": true, + "projectOwner": { + "id": 0, + "meta": {}, + "type": "string" + }, + "projectOwnerId": 0, + "replyByEmailEnabled": true, + "showAnnouncement": true, + "skipWeekends": true, + "startAt": "string", + "startDate": "string", + "startPage": "string", + "status": "active", + "subStatus": "current", + "tagIds": [0], + "tags": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "tasksStartPage": "string", + "timeBudget": { + "id": 0, + "meta": {}, + "type": "string" + }, + "timeBudgetId": 0, + "timelogRequiresTask": true, + "type": "normal", + "update": { + "id": 0, + "meta": {}, + "type": "string" + }, + "updateId": 0, + "updatedAt": "string", + "updatedBy": 0, + "users": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "workflowIds": [0], + "workflows": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ] + } + }, + "tasklists": { + "additionalProp": { + "calculatedDueDate": "string", + "calculatedStartDate": "string", + "createdAt": "string", + "defaultTask": { + "id": 0, + "meta": {}, + "type": "string" + }, + "defaultTaskId": 0, + "description": "string", + "displayOrder": 0, + "icon": "string", + "id": 0, + "isBillable": true, + "isPinned": true, + "isPrivate": true, + "lockdownId": 0, + "milestone": { + "id": 0, + "meta": {}, + "type": "string" + }, + "milestoneId": 0, + "name": "string", + "project": { + "id": 0, + "meta": {}, + "type": "string" + }, + "projectId": 0, + "status": "string", + "tasklistBudget": { + "id": 0, + "meta": {}, + "type": "string" + }, + "updatedAt": "string" + } + }, + "tasks": { + "additionalProp": { + "accumulatedEstimatedMinutes": 0, + "assigneeCompanies": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "assigneeCompanyIds": [0], + "assigneeJobRoleIds": [0], + "assigneeJobRoles": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "assigneeTeamIds": [0], + "assigneeTeams": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "assigneeUserIds": [0], + "assigneeUsers": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "assignees": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "attachments": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "capacities": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "changeFollowers": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "column": { + "id": 0, + "meta": {}, + "type": "string" + }, + "commentFollowers": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "completeFollowers": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "completedAt": "string", + "completedBy": 0, + "completedOn": "string", + "createdAt": "string", + "createdBy": 0, + "createdByUserId": 0, + "crmDealIds": [0], + "dateUpdated": "string", + "decimalDisplayOrder": 0, + "deletedAt": "string", + "deletedBy": 0, + "dependencyIds": [0], + "description": "string", + "descriptionContentType": "string", + "displayOrder": 0, + "dueDate": {}, + "dueDateBase": {}, + "dueDateFromMilestone": true, + "dueDateOffset": 0, + "estimateMinutes": 0, + "hasDeskTickets": true, + "hasReminders": true, + "hasTimeblocks": true, + "id": 0, + "isArchived": true, + "isBlocked": true, + "isPrivate": 0, + "latestUpdates": [ + { + "after": "Unknown Type: any", + "before": "Unknown Type: any", + "field": "string" + } + ], + "lockdown": { + "id": 0, + "meta": {}, + "type": "string" + }, + "meta": {}, + "name": "string", + "notify": true, + "originalDueDate": "2026-06-22", + "outOfSequence": true, + "parentTask": { + "id": 0, + "meta": {}, + "type": "string" + }, + "parentTaskId": 0, + "predecessorIds": [0], + "predecessors": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "priority": "string", + "progress": 0, + "proofs": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "sequence": { + "id": 0, + "meta": {}, + "type": "string" + }, + "sequenceDueDate": "2026-06-22", + "sequenceId": 0, + "sequenceRootTask": true, + "sequenceStartDate": "2026-06-22", + "startDate": {}, + "startDateOffset": 0, + "status": "string", + "subTaskIds": [0], + "tagIds": [0], + "tags": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "tasklist": { + "id": 0, + "meta": {}, + "type": "string" + }, + "tasklistId": 0, + "templateRoleName": "string", + "timer": { + "id": 0, + "meta": {}, + "type": "string" + }, + "updatedAt": "string", + "updatedBy": 0, + "userPermissions": { + "canAddSubtasks": true, + "canComplete": true, + "canEdit": true, + "canLogTime": true, + "canViewEstTime": true + }, + "workflowStages": [ + { + "stageId": 0, + "stageTaskDisplayOrder": 0, + "workflowId": 0 + } + ] + } + }, + "users": { + "additionalProp": { + "avatarUrl": "string", + "canAccessPortfolio": true, + "canAddProjects": true, + "canManagePortfolio": true, + "company": { + "id": 0, + "meta": {}, + "type": "string" + }, + "companyId": 0, + "companyRoleId": 0, + "createdAt": "string", + "createdBy": { + "id": 0, + "meta": {}, + "type": "string" + }, + "deleted": true, + "email": "string", + "firstName": "string", + "id": 0, + "isAdmin": true, + "isClientUser": true, + "isPlaceholderResource": true, + "isServiceAccount": true, + "jobRoles": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "lastLogin": "string", + "lastName": "string", + "lengthOfDay": 0, + "meta": {}, + "skills": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "teams": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "timezone": "string", + "title": "string", + "type": "string", + "updatedAt": "string", + "updatedBy": { + "id": 0, + "meta": {}, + "type": "string" + }, + "userCost": 0, + "userRate": 0, + "userRates": { + "additionalProp": { + "amount": 0, + "currency": { + "id": 0, + "meta": {}, + "type": "string" + } + } + }, + "workingHour": { + "id": 0, + "meta": {}, + "type": "string" + }, + "workingHoursId": 0 + } + } + }, + "meta": { + "averageSpend": 0, + "data": {}, + "limit": 0, + "nextCursor": "string", + "page": { + "count": 0, + "hasMore": true, + "pageOffset": 0, + "pageSize": 0 + }, + "prevCursor": "string", + "totalCapacity": 0 + }, + "timers": [ + { + "billable": true, + "createdAt": "string", + "deleted": true, + "deletedAt": "string", + "description": "string", + "duration": 0, + "id": 0, + "intervals": [ + { + "duration": 0, + "from": "string", + "id": 0, + "to": "string" + } + ], + "lastStartedAt": "string", + "project": { + "id": 0, + "meta": {}, + "type": "string" + }, + "projectId": 0, + "running": true, + "serverTime": "string", + "task": { + "id": 0, + "meta": {}, + "type": "string" + }, + "taskId": 0, + "timeLogId": 0, + "timelog": { + "id": 0, + "meta": {}, + "type": "string" + }, + "timerLastIntervalEnd": "string", + "updatedAt": "string", + "user": { + "id": 0, + "meta": {}, + "type": "string" + }, + "userId": 0 + } + ] +} diff --git a/plans/PLAN.md b/plans/PLAN.md index 95eb586..2d9ac6c 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 in progress +- **Status:** Phase 5 Teamwork Foundation (5.1-5.2 complete, 5.4 in progress) - **Package Manager:** Bun - **Runtime:** Bun (standalone binary distribution) - **TUI:** @opentui/solid + solid-js @@ -120,7 +120,7 @@ Teamwork work is split into detailed subphase plans under [`plans/teamwork/`](te Auth, project config, Teamwork route, project metadata, project links, and shared Teamwork HTTP client. -#### Phase 5.2 — [Pinned Project Task Lists](teamwork/5.2-pinned-project-task-lists.md) 🔨 +#### Phase 5.2 — [Pinned Project Task Lists](teamwork/5.2-pinned-project-task-lists.md) ✅ Project-configured Teamwork task lists for recurring project tasks such as code review, meetings, miscellaneous work, project management, and documentation. diff --git a/plans/teamwork/5.4-timers-and-time-tracking.md b/plans/teamwork/5.4-timers-and-time-tracking.md index 6335404..8a8c76a 100644 --- a/plans/teamwork/5.4-timers-and-time-tracking.md +++ b/plans/teamwork/5.4-timers-and-time-tracking.md @@ -1,77 +1,86 @@ -# 5.4 Timers and Time Tracking +# 5.4 Timers and Time Tracking ✅ (in progress) ## Goal -Make time tracking fast from the TUI, especially for recurring project tasks and assigned tasks. +Make time tracking fast from the TUI, especially for recurring project tasks and assigned tasks. Timers are managed **locally first** — no API calls when starting/stopping. Timers are only submitted to Teamwork when the user explicitly sends them. -## Timer Tab - -Add a Teamwork Timers tab for timer management. - -Initial actions: +## Architecture Decision: Local-First -- Show active/running timers. -- Show paused/recent timers if Teamwork exposes them usefully. -- Pause a running timer. -- Start or resume a timer. -- Delete a timer. -- Confirm, submit, or close tracked time once Teamwork terminology is confirmed. -- Open the user's timesheet in the browser. +- **Local timer manager** (`src/teamwork/timers/local.ts`): start, stop, list, remove. Persisted to `wtc_cache_dir/teamwork-local-timers.json`. +- **Teamwork API layer** (`src/teamwork/timers.ts`): remote timer reads/mutations plus explicit local timer submission via task time entries. +- **No network calls** on start/stop. All state managed in cache file with `version: 1` schema. ## Start Timer From Task -From a selected task: +- `ctrl+t` on a selected task toggles a local timer. +- If a timer is already running for that task, it stops. If another timer is running, it pauses the previous one and starts a new one. +- Status messages shown in the project header. +- Task metadata shows a `⏱` indicator next to tasks with timers. -- If no timer is running, start a timer for that task. -- If the selected task already has a running timer, show a message instead of starting another timer. -- If another timer is running, ask whether to pause the current timer and start a timer for the selected task. -- Surface a clear success or failure message. +## Timer Tab -## Visual Indicators +A dedicated Teamwork timers tab shows local timer entries: -- Show a task-level indicator when a task has a timer associated with it. -- Show a stronger indicator when the timer is currently running for that task. -- Prefer terminal-safe symbols after testing. Possible options include `⏱`, `[timer]`, or `*`. +- Running timer listed first (with flashing `⏱` indicator) +- Stopped/pending timers below, sorted by start time (newest first) +- `↑`/`↓` selects timers. +- `ctrl+t` stops the selected running timer. +- `ctrl+s` submits the selected timer to Teamwork and removes it locally after success. Running timers are stopped first after confirmation. +- `ctrl+d` discards the selected local timer after confirmation. +- `ctrl+o` opens the Teamwork timesheet in the browser. -## API Shape +## Visual Indicators -- Use `getTeamworkTimers()` or a more specific read function once the Teamwork timer API is confirmed. -- Mutation APIs can use imperative names such as `startTeamworkTimer()`, `pauseTeamworkTimer()`, and `deleteTeamworkTimer()`. -- Timer mutations should not be hidden behind a `get*` API because they change remote state. +- Running timer: flashing `⏱` (toggles `fg` between bright and dim every 800ms via `setInterval`) +- Stopped timer: dim `⏱` +- No timer: nothing shown -## Dialogs +## Implementation -- Use the existing dialog system for confirmation flows. -- Confirm before pausing another running timer. -- Confirm before deleting a timer. -- Use concise messages because timer workflows should be fast. +- `src/teamwork/timers/local.ts` — LocalTimerEntry type, startLocalTimer, stopLocalTimer, loadLocalTimers, removeLocalTimer, getRunningTimer +- `src/teamwork/timers.ts` — Teamwork API layer for remote timer reads/mutations and task time-entry submission +- `src/tui/pages/teamwork/timers-tab.tsx` — Timer tab with sorted list, selection, local stop/discard actions, timesheet access, duration display, and flash animation +- `src/tui/pages/teamwork/project-tab.tsx` — `ctrl+t` binding, local timer toggle, timer indicator +- `src/tui/components/teamwork/task-list.tsx` — Passes timer props (timerTaskIds, runningTaskId, flashOn) +- `src/tui/components/teamwork/task-metadata.tsx` — Renders `⏱` indicator with flash animation ## CLI Scope -Add timer CLI commands after timer state, conflict handling, and task-level timer actions are proven in the TUI. +Timer CLI commands should mirror the local-first behavior and use task references as the primary user-facing handle: ```bash wtc teamwork timer list -wtc teamwork timer start -wtc teamwork timer start --switch -wtc teamwork timer pause -wtc teamwork timer resume -wtc teamwork timer delete +wtc teamwork timer start --name +wtc teamwork timer stop +wtc teamwork timer submit +wtc teamwork timer discard wtc teamwork timesheet open ``` -- `timer start` starts a timer for a known task ID or task URL. -- If another timer is running, default to failing with a clear message. -- `--switch` explicitly pauses the current timer and starts the new one. -- `timer pause` without an ID pauses the current running timer. -- `timesheet open` opens the user's Teamwork timesheet in the browser. -- TTY prompts can come later, but scriptable flags should exist first. - -## Open Questions - -- What does Teamwork call the final action for tracked time: stop, complete, submit, log, confirm, or close? -- Can Teamwork have more than one running timer for a user? -- Does the timer API expose task IDs directly enough to show task-level indicators? -- Should timesheet open use a fixed Teamwork URL or a URL returned by the API? -- Which key should start a timer from a selected task? `ctrl+s` is intuitive for start but also commonly means save in Settings. -- Should `timer start` accept task URLs, numeric IDs, or both from the first implementation? +- `timer start` creates a local timer and does not call Teamwork. +- `timer stop` stops the local running timer for the task. +- `timer submit` submits a local timer for the task to Teamwork and removes it locally only after success. +- `timer discard` removes a local timer without submitting. +- If multiple local timers match the same task, fail clearly and add a disambiguation option only when that becomes necessary. + +## Cache Format + +```json +{ + "version": 1, + "timers": [ + { + "id": "uuid", + "taskId": 12345, + "taskName": "My Task", + "startTime": "2026-06-23T00:48:01Z", + "endTime": null, + "status": "running" + } + ] +} +``` + +## Branch/PR (future) + +- Branch and PR workflow with timer prompts (moved to Phase 5.5) diff --git a/scripts/inspect-teamwork-task-fields.ts b/scripts/inspect-teamwork-task-fields.ts index 5b62b54..fc61af2 100644 --- a/scripts/inspect-teamwork-task-fields.ts +++ b/scripts/inspect-teamwork-task-fields.ts @@ -49,6 +49,23 @@ interface TaskListTasksEndpointReport { included: IncludedSummary; } +interface TimerSummary { + id: unknown; + running: unknown; + description: unknown; + taskId: unknown; + projectId: unknown; + duration: unknown; + lastStartedAt: unknown; + timeLogId: unknown; +} + +interface TimersEndpointReport { + endpoint: string; + timerCount: number; + timers: TimerSummary[]; +} + interface WorkflowEndpointReport { endpoint: string; workflow: JsonObject | null; @@ -109,6 +126,19 @@ function summarizeIncluded(root: JsonObject): IncludedSummary { }; } +function summarizeTimer(timer: JsonObject): TimerSummary { + return { + id: timer.id, + running: timer.running, + description: timer.description, + taskId: timer.taskId, + projectId: timer.projectId, + duration: timer.duration, + lastStartedAt: timer.lastStartedAt, + timeLogId: timer.timeLogId, + }; +} + function summarizeTask(task: JsonObject): TaskFieldSummary { return { id: task.id, @@ -179,6 +209,20 @@ async function inspectTaskListTasks( }; } +async function inspectTimers(): Promise { + const endpoint = `/me/timers.json`; + const root = await fetchTeamworkApiJson(endpoint); + if (!isObject(root)) throw new Error(`Unexpected Teamwork response for ${endpoint}.`); + + const timers = getArray(root, "timers").filter(isObject); + + return { + endpoint, + timerCount: timers.length, + timers: timers.map(summarizeTimer), + }; +} + async function inspectWorkflow( workflowId: number, include = false, @@ -227,6 +271,7 @@ async function inspectSafely( } const report = { + timers: await inspectSafely("timers", () => inspectTimers()), workflows: await Promise.all( workflowIds.map((workflowId) => inspectSafely(`workflow ${workflowId}`, () => inspectWorkflow(workflowId, true)), diff --git a/src/teamwork/consts.ts b/src/teamwork/consts.ts index 5b50cca..bd5d441 100644 --- a/src/teamwork/consts.ts +++ b/src/teamwork/consts.ts @@ -2,5 +2,7 @@ export const TEAMWORK_SITE_NAME = "wethecollective"; /** Human-facing Teamwork site URL. */ export const TEAMWORK_BASE_URL = `https://${TEAMWORK_SITE_NAME}.teamwork.com`; +/** Human-facing Teamwork timesheet URL. */ +export const TEAMWORK_TIMESHEET_URL = `${TEAMWORK_BASE_URL}/app/time`; /** Base URL for the Teamwork v3 API. */ export const TEAMWORK_API_BASE_URL = `${TEAMWORK_BASE_URL}/projects/api/v3`; diff --git a/src/teamwork/timers.ts b/src/teamwork/timers.ts new file mode 100644 index 0000000..7e9d916 --- /dev/null +++ b/src/teamwork/timers.ts @@ -0,0 +1,132 @@ +/** + * Teamwork API layer for timers. + * + * These functions call the Teamwork API directly and are reserved for the + * explicit submit action (sending completed local timers to Teamwork). + * Start/stop actions in the TUI use the local timer manager instead. + */ + +import { z } from "zod"; + +import { fetchTeamworkApiJson } from "./client.ts"; + +/** A Teamwork timer for the current user. */ +export interface TeamworkTimer { + id: number; + running: boolean; + description: string; + taskId: number | null; + projectId: number | null; + duration: number; + lastStartedAt: string | null; +} + +const TeamworkTaskTimeEntryInputSchema = z.object({ + taskId: z.number().int().positive(), + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + hours: z.number().int().nonnegative(), + minutes: z.number().int().min(0).max(59), + description: z.string(), +}); + +const TeamworkTaskTimeEntryResponseSchema = z.object({ + timelog: z.object({ + id: z.number().int().positive(), + }), +}); + +/** Fields needed to create a Teamwork time entry linked to a task. */ +export type TeamworkTaskTimeEntryInput = z.infer; + +/** Starts a timer for the given task. Returns the new timer. */ +export async function startTimer(taskId: number): Promise { + const root = await fetchTeamworkApiJson("/me/timers.json", { + method: "POST", + body: JSON.stringify({ timer: { taskId } }), + headers: { "Content-Type": "application/json" }, + }); + + const parsed = root as Record; + const timer = parsed?.timer as Record | undefined; + if (!timer || typeof timer.id !== "number") { + throw new Error("Teamwork timer response did not include a timer."); + } + + return { + id: timer.id, + running: !!timer.running, + description: typeof timer.description === "string" ? timer.description : "", + taskId: typeof timer.taskId === "number" ? timer.taskId : null, + projectId: typeof timer.projectId === "number" ? timer.projectId : null, + duration: typeof timer.duration === "number" ? timer.duration : 0, + lastStartedAt: typeof timer.lastStartedAt === "string" ? timer.lastStartedAt : null, + }; +} + +/** Pauses a running timer by ID. Returns the paused timer. */ +export async function pauseTimer(timerId: number): Promise { + const root = await fetchTeamworkApiJson(`/me/timers/${timerId}/pause.json`, { + method: "PUT", + }); + + const parsed = root as Record; + const timer = parsed?.timer as Record | undefined; + if (!timer || typeof timer.id !== "number") { + throw new Error("Teamwork timer pause response did not include a timer."); + } + + return { + id: timer.id, + running: !!timer.running, + description: typeof timer.description === "string" ? timer.description : "", + taskId: typeof timer.taskId === "number" ? timer.taskId : null, + projectId: typeof timer.projectId === "number" ? timer.projectId : null, + duration: typeof timer.duration === "number" ? timer.duration : 0, + lastStartedAt: typeof timer.lastStartedAt === "string" ? timer.lastStartedAt : null, + }; +} + +/** Lists timers for the current user. */ +export async function getTimers(): Promise { + const root = await fetchTeamworkApiJson("/me/timers.json"); + const parsed = root as Record; + const timers = parsed?.timers; + if (!Array.isArray(timers)) { + throw new Error("Teamwork timers response did not include a timers array."); + } + + return timers.map((timer: Record) => ({ + id: typeof timer.id === "number" ? timer.id : 0, + running: !!timer.running, + description: typeof timer.description === "string" ? timer.description : "", + taskId: typeof timer.taskId === "number" ? timer.taskId : null, + projectId: typeof timer.projectId === "number" ? timer.projectId : null, + duration: typeof timer.duration === "number" ? timer.duration : 0, + lastStartedAt: typeof timer.lastStartedAt === "string" ? timer.lastStartedAt : null, + })); +} + +/** Creates a submitted Teamwork time entry for a task. */ +export async function createTaskTimeEntry(input: TeamworkTaskTimeEntryInput): Promise { + const parsedInput = TeamworkTaskTimeEntryInputSchema.parse(input); + const parsed = TeamworkTaskTimeEntryResponseSchema.parse( + await fetchTeamworkApiJson(`/tasks/${parsedInput.taskId}/time.json`, { + method: "POST", + body: JSON.stringify({ + timelog: { + taskId: parsedInput.taskId, + isUtc: true, + date: parsedInput.date, + hours: parsedInput.hours, + minutes: parsedInput.minutes, + description: parsedInput.description, + }, + timelogOptions: {}, + tags: [], + }), + headers: { "Content-Type": "application/json" }, + }), + ); + + return parsed.timelog.id; +} diff --git a/src/teamwork/timers/local.ts b/src/teamwork/timers/local.ts new file mode 100644 index 0000000..4768754 --- /dev/null +++ b/src/teamwork/timers/local.ts @@ -0,0 +1,124 @@ +import { getCacheDir } from "../../state/consts.ts"; + +const LOCAL_TIMERS_CACHE_FILE = "teamwork-local-timers.json"; + +/** A locally-managed timer entry that has not been submitted to the Teamwork API yet. */ +export interface LocalTimerEntry { + id: string; + taskId: number; + taskName: string; + startTime: string; + endTime: string | null; + status: "running" | "stopped"; +} + +interface LocalTimersFile { + version: 1; + timers: LocalTimerEntry[]; +} + +function getLocalTimersPath(): string { + return `${getCacheDir()}/${LOCAL_TIMERS_CACHE_FILE}`; +} + +/** Loads all local timers from the cache file. Returns an empty array if the file is missing or corrupt. */ +export async function loadLocalTimers(): Promise { + try { + const parsed = JSON.parse(await Bun.file(getLocalTimersPath()).text()) as LocalTimersFile; + + if (!parsed || parsed.version !== 1 || !Array.isArray(parsed.timers)) return []; + + return parsed.timers; + } catch { + return []; + } +} + +async function saveLocalTimers(timers: LocalTimerEntry[]): Promise { + const file: LocalTimersFile = { version: 1, timers }; + await Bun.write(getLocalTimersPath(), `${JSON.stringify(file, null, 2)}\n`); +} + +/** Generates a unique local timer ID. */ +function generateTimerId(): string { + return crypto.randomUUID(); +} + +/** Returns the currently running timer from a list of entries, or null if none is running. */ +export function getRunningTimer(timers: LocalTimerEntry[]): LocalTimerEntry | null { + return timers.find((timer) => timer.status === "running") ?? null; +} + +/** Returns elapsed milliseconds for a local timer, using `now` for running timers. */ +export function getLocalTimerElapsedMs(timer: LocalTimerEntry, now: Date): number { + const start = new Date(timer.startTime).getTime(); + const end = timer.endTime ? new Date(timer.endTime).getTime() : now.getTime(); + + return Math.max(0, end - start); +} + +/** Formats a timer duration as compact text for the TUI. */ +export function formatTimerDuration(durationMs: number): string { + const totalSeconds = Math.max(0, Math.floor(durationMs / 1000)); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + if (hours > 0) { + return `${hours}h ${minutes.toString().padStart(2, "0")}m ${seconds + .toString() + .padStart(2, "0")}s`; + } + + return `${minutes}m ${seconds.toString().padStart(2, "0")}s`; +} + +/** Starts a local timer for the given task. If another timer is running, it is stopped first. Returns the new timer entry. */ +export async function startLocalTimer( + taskId: number, + taskName: string, +): Promise<{ timer: LocalTimerEntry; stoppedPrevious: boolean }> { + const timers = await loadLocalTimers(); + const running = getRunningTimer(timers); + let stoppedPrevious = false; + + if (running) { + running.endTime = new Date().toISOString(); + running.status = "stopped"; + stoppedPrevious = true; + } + + const timer: LocalTimerEntry = { + id: generateTimerId(), + taskId, + taskName, + startTime: new Date().toISOString(), + endTime: null, + status: "running", + }; + + timers.push(timer); + await saveLocalTimers(timers); + + return { timer, stoppedPrevious }; +} + +/** Stops the currently running timer. Returns the stopped entry or null if no timer was running. */ +export async function stopLocalTimer(): Promise { + const timers = await loadLocalTimers(); + const running = getRunningTimer(timers); + + if (!running) return null; + + running.endTime = new Date().toISOString(); + running.status = "stopped"; + await saveLocalTimers(timers); + + return running; +} + +/** Removes a local timer by ID without submitting. */ +export async function removeLocalTimer(id: string): Promise { + const timers = await loadLocalTimers(); + await saveLocalTimers(timers.filter((timer) => timer.id !== id)); +} diff --git a/src/tui/app.tsx b/src/tui/app.tsx index 14b2480..053b3d6 100644 --- a/src/tui/app.tsx +++ b/src/tui/app.tsx @@ -7,6 +7,8 @@ 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 { openUrlInBrowser } from "../utils/browser.ts"; import { DialogProvider, useDialog } from "./components/dialog.tsx"; import { UpdateDialog } from "./components/update-dialog.tsx"; @@ -100,6 +102,26 @@ function Home() { dialog.clear(); }, }, + { + name: "teamwork.timers.open", + title: "Open Teamwork Timers", + desc: "Local timer tracking", + category: "Navigation", + run: () => { + navigate({ page: "teamwork", tab: "timers" }); + dialog.clear(); + }, + }, + { + name: "teamwork.timesheet.open", + title: "Open Teamwork Timesheet", + desc: "Open Teamwork time tracking in browser", + category: "Navigation", + run: () => { + dialog.clear(); + void openUrlInBrowser(TEAMWORK_TIMESHEET_URL); + }, + }, { name: "settings.open", title: "Open Settings", diff --git a/src/tui/components/confirm-dialog.tsx b/src/tui/components/confirm-dialog.tsx new file mode 100644 index 0000000..5daa52d --- /dev/null +++ b/src/tui/components/confirm-dialog.tsx @@ -0,0 +1,69 @@ +import { TextAttributes } from "@opentui/core"; +import { useBindings } from "@opentui/keymap/solid"; + +import { tokens } from "../tokens.ts"; + +import { useDialog } from "./dialog.tsx"; + +/** Props for a simple confirmation dialog. */ +export interface ConfirmDialogProps { + title: string; + message: string; + confirmLabel?: string; + cancelLabel?: string; + onConfirm: () => void | Promise; +} + +/** Small reusable confirmation modal for destructive or workflow-switching actions. */ +export function ConfirmDialog(props: ConfirmDialogProps) { + const dialog = useDialog(); + + const confirm = async () => { + try { + await props.onConfirm(); + } catch (error) { + console.error(error); + } finally { + dialog.clear(); + } + }; + + useBindings(() => ({ + bindings: [ + { + key: "return", + desc: "Confirm", + group: "Dialog", + cmd: () => { + void confirm(); + }, + }, + ], + })); + + return ( + + + {props.title} + dialog.clear()}> + esc + + + {props.message} + + dialog.clear()}> + {props.cancelLabel ?? "cancel"} + + { + void confirm(); + }} + > + {props.confirmLabel ?? "confirm"} + + + + ); +} diff --git a/src/tui/components/layout/section.tsx b/src/tui/components/layout/section.tsx index 20607c3..5e59800 100644 --- a/src/tui/components/layout/section.tsx +++ b/src/tui/components/layout/section.tsx @@ -1,4 +1,4 @@ -import type { ParentProps } from "solid-js"; +import { children, type ParentProps, Show } from "solid-js"; import { TextAttributes } from "@opentui/core"; import { tokens } from "../../tokens.ts"; @@ -9,11 +9,12 @@ export interface SectionProps extends ParentProps { title: string; /** Optional secondary lines shown beneath the title. */ description?: string | readonly string[]; + active?: boolean; } function descriptions(value: SectionProps["description"]): readonly string[] { if (!value) return []; - return typeof value === "string" ? [value] : value; + return typeof value === "string" ? [value] : value.filter(Boolean); } /** @@ -23,26 +24,30 @@ function descriptions(value: SectionProps["description"]): readonly string[] { * so headings and detail text remain consistent across pages. */ export function Section(props: SectionProps) { + const resolved = children(() => props.children); + return ( - + {props.title} {descriptions(props.description).map((description) => ( {description} ))} - - {props.children} - + + + {resolved()} + + ); diff --git a/src/tui/components/teamwork/task-list.tsx b/src/tui/components/teamwork/task-list.tsx index fc117f1..8822428 100644 --- a/src/tui/components/teamwork/task-list.tsx +++ b/src/tui/components/teamwork/task-list.tsx @@ -1,9 +1,12 @@ -import { For } from "solid-js"; -import { TextAttributes } from "@opentui/core"; +import { For, Show } from "solid-js"; import type { TeamworkTask } from "../../../teamwork/task-list-tasks.ts"; + import { tokens } from "../../tokens.ts"; -import { TaskMetadata } from "./task-metadata.tsx"; + +import { buildTaskMetadata } from "./task-metadata.tsx"; +import { TimerIndicator } from "./timer-indicator.tsx"; +import { Section } from "../layout/section.tsx"; /** Renders a list of tasks with name, status, and styled metadata row. Supports keyboard selection highlight. */ export function TaskList(props: { @@ -11,23 +14,37 @@ export function TaskList(props: { tasks: readonly TeamworkTask[]; emptyMessage: string; selectedTaskId?: number | null; + timerTaskIds?: readonly number[]; + runningTaskId?: number | null; + flashOn?: boolean; }) { return props.tasks.length ? ( - - {(task) => ( - - - {props.selectedTaskId === task.id ? "> " : " "} - {task.name} - {task.status ? ` [${task.status}]` : ""} - - - - )} - + + + {(task) => { + const timerStatus = () => + props.timerTaskIds?.includes(task.id) + ? props.runningTaskId === task.id + ? "running" + : "stopped" + : null; + + return ( + +
+ + + +
+
+ ); + }} +
+
) : ( {props.emptyMessage} ); diff --git a/src/tui/components/teamwork/task-metadata.tsx b/src/tui/components/teamwork/task-metadata.tsx index cbbb77a..b86f385 100644 --- a/src/tui/components/teamwork/task-metadata.tsx +++ b/src/tui/components/teamwork/task-metadata.tsx @@ -1,49 +1,21 @@ -import { Show } from "solid-js"; -import { t, bold, fg } from "@opentui/core"; - import type { TeamworkTask } from "../../../teamwork/task-list-tasks.ts"; -import { tokens, palette } from "../../tokens.ts"; -function priorityColor(priority: string): string { - switch (priority) { - case "urgent": - case "high": - return palette.red; - case "medium": - return palette.yellow75; - case "low": - return palette.green75; - default: - return palette.black50; - } -} +/** Builds an array of description strings for a task's metadata row. */ +export function buildTaskMetadata(task: TeamworkTask): string[] { + const parts: string[] = []; -/** Renders a task's metadata (assignees, due date, board column, priority) as a row of styled inline fields. */ -export function TaskMetadata(props: { task: TeamworkTask }) { - const task = () => props.task; + if (task.assignees.length > 0) { + parts.push(`Assignees: ${task.assignees.join(", ")}`); + } + if (task.dueDate) { + parts.push(`Due: ${task.dueDate}`); + } + if (task.boardColumn) { + parts.push(`Board: ${task.boardColumn.name}`); + } + if (task.priority) { + parts.push(`Priority: ${task.priority}`); + } - return ( - 0 || task().dueDate || task().boardColumn || task().priority} - > - - 0}> - {t`${bold("assignee:")} ${task().assignees.join(", ")}`} - - - {t`${bold("due:")} ${task().dueDate ?? ""}`} - - - {t`${bold("board:")} ${fg(task().boardColumn?.color ?? tokens.textDim)(task().boardColumn?.name ?? "")}`} - - - {t`${bold("priority:")} ${fg(priorityColor(task().priority ?? ""))(task().priority ?? "")}`} - - - - ); + return parts; } diff --git a/src/tui/components/teamwork/timer-indicator.tsx b/src/tui/components/teamwork/timer-indicator.tsx new file mode 100644 index 0000000..d8558ee --- /dev/null +++ b/src/tui/components/teamwork/timer-indicator.tsx @@ -0,0 +1,17 @@ +import { tokens } from "../../tokens.ts"; + +/** Visual state for a local timer indicator. */ +export type TimerIndicatorStatus = "running" | "stopped"; + +/** Shared local timer indicator used anywhere task timers are shown. */ +export function TimerIndicator(props: { status: TimerIndicatorStatus; flashOn?: boolean }) { + return ( + + ⏱ {props.status === "running" ? "Running" : "Stopped"} + + ); +} diff --git a/src/tui/pages/teamwork.tsx b/src/tui/pages/teamwork.tsx index 43ac5a5..4ca7a2e 100644 --- a/src/tui/pages/teamwork.tsx +++ b/src/tui/pages/teamwork.tsx @@ -8,8 +8,9 @@ import { tokens } from "../tokens.ts"; import { MyWorkTab } from "./teamwork/my-work-tab.tsx"; import { ProjectTab } from "./teamwork/project-tab.tsx"; +import { TimersTab } from "./teamwork/timers-tab.tsx"; -const TEAMWORK_TABS = ["my-work", "project"] as const; +const TEAMWORK_TABS = ["my-work", "project", "timers"] as const; /** Valid Teamwork page tab identifiers. */ export type TeamworkTab = (typeof TEAMWORK_TABS)[number]; @@ -17,6 +18,7 @@ export type TeamworkTab = (typeof TEAMWORK_TABS)[number]; const TABS = [ { id: "my-work", label: "My Work" }, { id: "project", label: "Project" }, + { id: "timers", label: "Timers" }, ] as const; /** Cycles to the next or previous Teamwork tab, wrapping around. */ @@ -64,34 +66,49 @@ export function TeamworkPage(props: { })); createEffect(() => { + const tab = activeTab(); setHints( - activeTab() === "project" + tab === "project" ? [ { key: "ctrl+←/→", label: "tabs" }, { key: "↑/↓", label: "tasks" }, { key: "enter/ctrl+o", label: "open" }, + { key: "ctrl+t", label: "timer" }, ] - : [{ key: "ctrl+←/→", label: "tabs" }], + : tab === "timers" + ? [ + { key: "ctrl+←/→", label: "tabs" }, + { key: "↑/↓", label: "timers" }, + { key: "ctrl+t", label: "stop" }, + { key: "ctrl+s", label: "submit" }, + { key: "ctrl+d", label: "discard" }, + { key: "ctrl+o", label: "timesheet" }, + ] + : [{ key: "ctrl+←/→", label: "tabs" }], ); }); onCleanup(() => setHints([])); return ( - {activeTab() === "project" ? "project" : "my work"}} - > - + {activeTab()}}> + {(tab) => ( - - {activeTab() === tab.id ? `[${tab.label}]` : tab.label} - + + {tab.label} + + )} @@ -100,6 +117,9 @@ export function TeamworkPage(props: { + + + diff --git a/src/tui/pages/teamwork/project-tab.tsx b/src/tui/pages/teamwork/project-tab.tsx index fdfebd8..1156a65 100644 --- a/src/tui/pages/teamwork/project-tab.tsx +++ b/src/tui/pages/teamwork/project-tab.tsx @@ -1,4 +1,4 @@ -import { createEffect, createSignal, For, onMount } from "solid-js"; +import { createEffect, createSignal, For, onCleanup, onMount } from "solid-js"; import { TextAttributes } from "@opentui/core"; import { useBindings } from "@opentui/keymap/solid"; @@ -10,10 +10,17 @@ import { type TeamworkProjectMetadataResult, } from "../../../teamwork/project-metadata.ts"; import { getTeamworkTaskListTasks, type TeamworkTask } from "../../../teamwork/task-list-tasks.ts"; +import { + loadLocalTimers, + startLocalTimer, + stopLocalTimer, + type LocalTimerEntry, +} from "../../../teamwork/timers/local.ts"; import { getTeamworkTaskReference } from "../../../teamwork/tasks.ts"; import { openUrlInBrowser } from "../../../utils/browser.ts"; +import { ConfirmDialog } from "../../components/confirm-dialog.tsx"; import { TaskList } from "../../components/teamwork/task-list.tsx"; -import { Section } from "../../components/layout/section.tsx"; +import { useDialog } from "../../components/dialog.tsx"; import { usePageScroll } from "../../components/layout/scroll-context.tsx"; import { tokens } from "../../tokens.ts"; @@ -33,13 +40,16 @@ export interface PinnedTaskSelection { /** Teamwork project tab showing project metadata, links, and pinned task lists with keyboard navigation. */ export function ProjectTab() { const [resolved, setResolved] = createSignal(null); - const [teamworkAuthStatus, setTeamworkAuthStatus] = createSignal("missing"); + const [_teamworkAuthStatus, setTeamworkAuthStatus] = createSignal("missing"); const [projectMetadata, setProjectMetadata] = createSignal( null, ); const [pinnedTaskLists, setPinnedTaskLists] = createSignal([]); const [selectedTask, setSelectedTask] = createSignal(null); + const [localTimers, setLocalTimers] = createSignal([]); + const [flashOn, setFlashOn] = createSignal(true); const [projectMessage, setProjectMessage] = createSignal("Loading project context..."); + const dialog = useDialog(); const scroll = usePageScroll(); createEffect(() => { @@ -142,6 +152,53 @@ export function ProjectTab() { } }; + const refreshLocalTimers = async () => { + setLocalTimers(await loadLocalTimers()); + }; + + const toggleTimer = async () => { + const task = selectedTeamworkTask(); + if (!task) { + setProjectMessage("No pinned task selected."); + return; + } + + try { + const timers = localTimers(); + const runningTimer = timers.find((t) => t.status === "running"); + + if (runningTimer?.taskId === task.id) { + const stopped = await stopLocalTimer(); + if (stopped) { + setProjectMessage(`Timer stopped for task: ${task.name}`); + await refreshLocalTimers(); + } + } else { + if (runningTimer) { + dialog.replace(() => ( + { + await startLocalTimer(task.id, task.name); + await refreshLocalTimers(); + setProjectMessage(`Timer started for task: ${task.name} (previous paused)`); + }} + /> + )); + return; + } + + await startLocalTimer(task.id, task.name); + await refreshLocalTimers(); + setProjectMessage(`Timer started for task: ${task.name}`); + } + } catch (error) { + setProjectMessage(error instanceof Error ? error.message : "Failed to toggle timer."); + } + }; + useBindings(() => ({ bindings: [ { @@ -172,87 +229,102 @@ export function ProjectTab() { group: "Teamwork", cmd: openSelectedTask, }, + { + key: "ctrl+t", + desc: "Start/pause local timer", + group: "Teamwork", + cmd: toggleTimer, + }, ], })); onMount(() => { void loadProjectContext(); + void refreshLocalTimers(); + + const flashInterval = setInterval(() => { + setFlashOn((prev) => !prev); + }, 800); + + onCleanup(() => clearInterval(flashInterval)); }); return ( -
- - - - Project config: {resolved()?.paths.projectConfigPath ?? "not found"} - - - Teamwork project ID: {resolved()?.project?.teamwork.projectId ?? "not configured"} - - Teamwork auth: {teamworkAuthStatus()} - - + {projectMetadata() ? ( - - Teamwork project: {projectMetadata()?.project.name} + + {projectMetadata()?.project.name} {projectMessage()} ) : ( {projectMessage()} )} + - + {resolved()?.project?.project.links.length > 0 && ( + Project links - {resolved()?.project?.project.links.length ? ( - - {(link) => ( - - {link.name}: {link.url} - - )} - - ) : ( - No project links configured. - )} + + {(link) => ( + + {link.name}: {link.url} + + )} + + )} - - - Pinned task lists - - {resolved()?.project?.teamwork.pinnedTaskLists.length ? ( - - {(taskList) => ( - - - {taskList.name} ({taskList.id}) - - {taskList.message ? ( - {taskList.message} - ) : ( - - )} - - )} - - ) : ( - No pinned task lists configured. - )} + {resolved()?.project?.teamwork.pinnedTaskLists.length > 0 && ( + + + {(taskList) => ( + + {taskList.message ? ( + {taskList.message} + ) : ( + t.taskId)} + runningTaskId={ + localTimers().find((t) => t.status === "running")?.taskId ?? null + } + flashOn={flashOn()} + /> + )} + + )} + - -
+ )} + ); } diff --git a/src/tui/pages/teamwork/timers-tab.tsx b/src/tui/pages/teamwork/timers-tab.tsx new file mode 100644 index 0000000..0ebb7e2 --- /dev/null +++ b/src/tui/pages/teamwork/timers-tab.tsx @@ -0,0 +1,263 @@ +import { createEffect, createSignal, For, onCleanup, onMount } from "solid-js"; +import { useBindings } from "@opentui/keymap/solid"; + +import { + formatTimerDuration, + getLocalTimerElapsedMs, + loadLocalTimers, + removeLocalTimer, + stopLocalTimer, + type LocalTimerEntry, +} from "../../../teamwork/timers/local.ts"; +import { createTaskTimeEntry } from "../../../teamwork/timers.ts"; +import { TEAMWORK_TIMESHEET_URL } from "../../../teamwork/consts.ts"; +import { openUrlInBrowser } from "../../../utils/browser.ts"; +import { ConfirmDialog } from "../../components/confirm-dialog.tsx"; +import { TimerIndicator } from "../../components/teamwork/timer-indicator.tsx"; +import { useDialog } from "../../components/dialog.tsx"; +import { Section } from "../../components/layout/section.tsx"; +import { tokens } from "../../tokens.ts"; + +/** Cycles through local timer IDs in display order, wrapping around. */ +export function getNextLocalTimerSelection( + timers: readonly LocalTimerEntry[], + currentId: string | null, + direction: 1 | -1, +): string | null { + if (!timers.length) return null; + + const currentIndex = currentId ? timers.findIndex((timer) => timer.id === currentId) : -1; + const fallbackIndex = direction === 1 ? 0 : timers.length - 1; + const nextIndex = + currentIndex === -1 + ? fallbackIndex + : (currentIndex + direction + timers.length) % timers.length; + + return timers[nextIndex]?.id ?? null; +} + +/** Teamwork timers tab showing local timers (running first, then stopped/pending). */ +export function TimersTab() { + const [localTimers, setLocalTimers] = createSignal([]); + const [selectedTimerId, setSelectedTimerId] = createSignal(null); + const [flashOn, setFlashOn] = createSignal(true); + const [now, setNow] = createSignal(new Date()); + const [message, setMessage] = createSignal("Local timers stay on this machine until submitted."); + const dialog = useDialog(); + + const refreshTimers = async () => { + setLocalTimers(await loadLocalTimers()); + }; + + const sortedTimers = () => { + const timers = localTimers(); + return [...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 selectedTimer = () => + sortedTimers().find((timer) => timer.id === selectedTimerId()) ?? null; + + const stopSelectedTimer = async () => { + const timer = selectedTimer(); + if (!timer) { + setMessage("No local timer selected."); + return; + } + + if (timer.status !== "running") { + setMessage(`Timer is already stopped: ${timer.taskName}`); + return; + } + + await stopLocalTimer(); + await refreshTimers(); + setMessage(`Timer stopped: ${timer.taskName}`); + }; + + const discardSelectedTimer = () => { + const timer = selectedTimer(); + if (!timer) { + setMessage("No local timer selected."); + return; + } + + dialog.replace(() => ( + { + await removeLocalTimer(timer.id); + await refreshTimers(); + setMessage(`Timer discarded: ${timer.taskName}`); + }} + /> + )); + }; + + const submitSelectedTimer = () => { + const timer = selectedTimer(); + if (!timer) { + setMessage("No local timer selected."); + return; + } + + const totalMinutes = Math.max(1, Math.ceil(getLocalTimerElapsedMs(timer, now()) / 60_000)); + const duration = formatTimerDuration(totalMinutes * 60_000); + const action = timer.status === "running" ? "Stop and submit" : "Submit"; + + dialog.replace(() => ( + { + 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); + await refreshTimers(); + setMessage(`Timer submitted: ${timerToSubmit.taskName}`); + } catch (error) { + setMessage(error instanceof Error ? error.message : "Failed to submit timer."); + } + }} + /> + )); + }; + + const openTimesheet = async () => { + try { + await openUrlInBrowser(TEAMWORK_TIMESHEET_URL); + setMessage(`Opened Teamwork timesheet: ${TEAMWORK_TIMESHEET_URL}`); + } catch (error) { + setMessage(error instanceof Error ? error.message : "Failed to open Teamwork timesheet."); + } + }; + + useBindings(() => ({ + bindings: [ + { + key: "down", + desc: "Next local timer", + group: "Teamwork Timers", + cmd: () => { + setSelectedTimerId((current) => getNextLocalTimerSelection(sortedTimers(), current, 1)); + }, + }, + { + key: "up", + desc: "Previous local timer", + group: "Teamwork Timers", + cmd: () => { + setSelectedTimerId((current) => getNextLocalTimerSelection(sortedTimers(), current, -1)); + }, + }, + { + key: "ctrl+t", + desc: "Stop selected local timer", + group: "Teamwork Timers", + cmd: () => { + void stopSelectedTimer(); + }, + }, + { + key: "ctrl+d", + desc: "Discard selected local timer", + group: "Teamwork Timers", + cmd: discardSelectedTimer, + }, + { + key: "ctrl+s", + desc: "Submit selected local timer", + group: "Teamwork Timers", + cmd: submitSelectedTimer, + }, + { + key: "ctrl+o", + desc: "Open Teamwork timesheet", + group: "Teamwork Timers", + cmd: () => { + void openTimesheet(); + }, + }, + ], + })); + + createEffect(() => { + const timers = sortedTimers(); + const selected = selectedTimerId(); + if (!timers.length) { + setSelectedTimerId(null); + } else if (!selected || !timers.some((timer) => timer.id === selected)) { + setSelectedTimerId(timers[0]?.id ?? null); + } + }); + + onMount(() => { + void refreshTimers(); + + const flashInterval = setInterval(() => { + setFlashOn((prev) => !prev); + }, 800); + const durationInterval = setInterval(() => { + setNow(new Date()); + }, 1000); + + onCleanup(() => { + clearInterval(flashInterval); + clearInterval(durationInterval); + }); + }); + + return ( + + {localTimers().length > 0 ? "Local Timers" : "No timers"} + {message()} + + + {(timer) => ( +
+ +
+ )} +
+
+ ); +} diff --git a/tests/teamwork/timers-local.test.ts b/tests/teamwork/timers-local.test.ts new file mode 100644 index 0000000..0257b19 --- /dev/null +++ b/tests/teamwork/timers-local.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, test } from "bun:test"; + +import { useTempCacheDir } from "../helpers/teamwork.ts"; + +useTempCacheDir(); + +const { + loadLocalTimers, + startLocalTimer, + stopLocalTimer, + getRunningTimer, + removeLocalTimer, + getLocalTimerElapsedMs, + formatTimerDuration, +} = await import("../../src/teamwork/timers/local.ts"); + +describe("getRunningTimer", () => { + test("returns null when no timers are running", () => { + expect(getRunningTimer([])).toBeNull(); + + expect( + getRunningTimer([ + { + id: "1", + taskId: 1, + taskName: "Test", + startTime: "2026-01-01T00:00:00Z", + endTime: "2026-01-01T01:00:00Z", + status: "stopped", + }, + ]), + ).toBeNull(); + }); + + test("returns the running timer when one exists", () => { + const timer = getRunningTimer([ + { + id: "1", + taskId: 1, + taskName: "Test", + startTime: "2026-01-01T00:00:00Z", + endTime: null, + status: "running", + }, + ]); + + expect(timer).toBeDefined(); + expect(timer?.id).toBe("1"); + }); +}); + +describe("getLocalTimerElapsedMs", () => { + test("uses current time for running timers", () => { + expect( + getLocalTimerElapsedMs( + { + id: "1", + taskId: 1, + taskName: "Test", + startTime: "2026-01-01T00:00:00Z", + endTime: null, + status: "running", + }, + new Date("2026-01-01T00:01:30Z"), + ), + ).toBe(90_000); + }); + + test("uses end time for stopped timers", () => { + expect( + getLocalTimerElapsedMs( + { + id: "1", + taskId: 1, + taskName: "Test", + startTime: "2026-01-01T00:00:00Z", + endTime: "2026-01-01T00:02:05Z", + status: "stopped", + }, + new Date("2026-01-01T00:10:00Z"), + ), + ).toBe(125_000); + }); + + test("never returns negative durations", () => { + expect( + getLocalTimerElapsedMs( + { + id: "1", + taskId: 1, + taskName: "Test", + startTime: "2026-01-01T00:01:00Z", + endTime: "2026-01-01T00:00:00Z", + status: "stopped", + }, + new Date("2026-01-01T00:10:00Z"), + ), + ).toBe(0); + }); +}); + +describe("formatTimerDuration", () => { + test("formats sub-hour durations", () => { + expect(formatTimerDuration(0)).toBe("0m 00s"); + expect(formatTimerDuration(65_000)).toBe("1m 05s"); + }); + + test("formats hour durations", () => { + expect(formatTimerDuration(3_723_000)).toBe("1h 02m 03s"); + }); +}); + +describe("startLocalTimer", () => { + test("starts a local timer", async () => { + const { timer, stoppedPrevious } = await startLocalTimer(42, "My Task"); + + expect(timer.taskId).toBe(42); + expect(timer.taskName).toBe("My Task"); + expect(timer.status).toBe("running"); + expect(timer.endTime).toBeNull(); + expect(timer.id).toBeTruthy(); + expect(timer.startTime).toBeTruthy(); + expect(stoppedPrevious).toBe(false); + }); + + test("stops previous running timer when starting a new one", async () => { + const { timer: first } = await startLocalTimer(1, "First"); + const { timer: second, stoppedPrevious } = await startLocalTimer(2, "Second"); + + expect(stoppedPrevious).toBe(true); + + const timers = await loadLocalTimers(); + const firstEntry = timers.find((t) => t.id === first.id); + expect(firstEntry).toBeDefined(); + expect(firstEntry?.status).toBe("stopped"); + expect(firstEntry?.endTime).not.toBeNull(); + expect(second.status).toBe("running"); + }); + + test("persists to cache", async () => { + await startLocalTimer(42, "My Task"); + + const timers = await loadLocalTimers(); + expect(timers).toHaveLength(1); + expect(timers[0]?.taskId).toBe(42); + }); +}); + +describe("stopLocalTimer", () => { + test("stops the running timer", async () => { + await startLocalTimer(42, "My Task"); + const stopped = await stopLocalTimer(); + + expect(stopped).toBeDefined(); + expect(stopped?.status).toBe("stopped"); + expect(stopped?.endTime).not.toBeNull(); + + const timers = await loadLocalTimers(); + const entry = timers.find((t) => t.id === stopped?.id); + expect(entry).toBeDefined(); + expect(entry?.status).toBe("stopped"); + }); + + test("returns null when no timer is running", async () => { + const result = await stopLocalTimer(); + expect(result).toBeNull(); + }); +}); + +describe("removeLocalTimer", () => { + test("removes a timer by id", async () => { + const { timer } = await startLocalTimer(42, "My Task"); + expect((await loadLocalTimers()).length).toBe(1); + + await removeLocalTimer(timer.id); + expect((await loadLocalTimers()).length).toBe(0); + }); + + test("does nothing when id does not exist", async () => { + await startLocalTimer(42, "My Task"); + await removeLocalTimer("nonexistent"); + expect((await loadLocalTimers()).length).toBe(1); + }); +}); diff --git a/tests/teamwork/timers.test.ts b/tests/teamwork/timers.test.ts new file mode 100644 index 0000000..bd82271 --- /dev/null +++ b/tests/teamwork/timers.test.ts @@ -0,0 +1,320 @@ +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"; + +mock.module("../../src/teamwork/auth.ts", mockTeamworkAuthModule); + +const { createTeamworkAuthorizationHeader } = await import("../../src/teamwork/auth.ts"); +const { createTaskTimeEntry, getTimers, pauseTimer, startTimer } = + await import("../../src/teamwork/timers.ts"); + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe("startTimer", () => { + test("starts a timer for a task and returns it", async () => { + let requestUrl = ""; + let requestBody = ""; + let requestMethod = ""; + let authorization = ""; + + globalThis.fetch = createMockFetch((url, init) => { + requestUrl = url; + requestMethod = init?.method ?? "GET"; + requestBody = typeof init?.body === "string" ? init.body : ""; + authorization = new Headers(init?.headers).get("Authorization") ?? ""; + + return new Response( + JSON.stringify({ + timer: { + id: 42, + running: true, + description: "", + taskId: 12345, + projectId: 67890, + duration: 0, + lastStartedAt: "2026-06-23T00:48:01Z", + }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ); + }); + + const timer = await startTimer(12345); + + expect(timer).toEqual({ + id: 42, + running: true, + description: "", + taskId: 12345, + projectId: 67890, + duration: 0, + lastStartedAt: "2026-06-23T00:48:01Z", + }); + + expect(requestUrl).toBe(`${TEAMWORK_API_BASE_URL}/me/timers.json`); + expect(requestMethod).toBe("POST"); + expect(requestBody).toBe(JSON.stringify({ timer: { taskId: 12345 } })); + expect(authorization).toBe(createTeamworkAuthorizationHeader("token-123")); + }); + + test("throws if response has no timer", async () => { + globalThis.fetch = createMockFetch( + () => + new Response(JSON.stringify({}), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + try { + await startTimer(12345); + expect(true).toBe(false); + } catch (error) { + expect((error as Error).message).toBe("Teamwork timer response did not include a timer."); + } + }); + + test("throws if timer has no id", async () => { + globalThis.fetch = createMockFetch( + () => + new Response(JSON.stringify({ timer: { running: true } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + try { + await startTimer(12345); + expect(true).toBe(false); + } catch (error) { + expect((error as Error).message).toBe("Teamwork timer response did not include a timer."); + } + }); +}); + +describe("pauseTimer", () => { + test("pauses a running timer and returns it", async () => { + let requestUrl = ""; + let requestMethod = ""; + + globalThis.fetch = createMockFetch((url, init) => { + requestUrl = url; + requestMethod = init?.method ?? "GET"; + + return new Response( + JSON.stringify({ + timer: { + id: 42, + running: false, + description: "", + taskId: 12345, + projectId: 67890, + duration: 3600, + lastStartedAt: null, + }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ); + }); + + const timer = await pauseTimer(42); + + expect(timer).toEqual({ + id: 42, + running: false, + description: "", + taskId: 12345, + projectId: 67890, + duration: 3600, + lastStartedAt: null, + }); + + expect(requestUrl).toBe(`${TEAMWORK_API_BASE_URL}/me/timers/42/pause.json`); + expect(requestMethod).toBe("PUT"); + }); + + test("throws if response has no timer", async () => { + globalThis.fetch = createMockFetch( + () => + new Response(JSON.stringify({}), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + try { + await pauseTimer(42); + expect(true).toBe(false); + } catch (error) { + expect((error as Error).message).toBe( + "Teamwork timer pause response did not include a timer.", + ); + } + }); +}); + +describe("getTimers", () => { + test("lists timers for the current user", async () => { + let requestUrl = ""; + + globalThis.fetch = createMockFetch((url) => { + requestUrl = url; + + return new Response( + JSON.stringify({ + timers: [ + { + id: 42, + running: true, + description: "Working on task", + taskId: 12345, + projectId: 67890, + duration: 0, + lastStartedAt: "2026-06-23T00:48:01Z", + }, + ], + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ); + }); + + const timers = await getTimers(); + + expect(timers).toHaveLength(1); + expect(timers[0]).toEqual({ + id: 42, + running: true, + description: "Working on task", + taskId: 12345, + projectId: 67890, + duration: 0, + lastStartedAt: "2026-06-23T00:48:01Z", + }); + + expect(requestUrl).toBe(`${TEAMWORK_API_BASE_URL}/me/timers.json`); + }); + + test("throws if response has no timers array", async () => { + globalThis.fetch = createMockFetch( + () => + new Response(JSON.stringify({}), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + try { + await getTimers(); + expect(true).toBe(false); + } catch (error) { + expect((error as Error).message).toBe( + "Teamwork timers response did not include a timers array.", + ); + } + }); +}); + +describe("createTaskTimeEntry", () => { + test("creates a submitted time entry for a task", async () => { + let requestUrl = ""; + let requestMethod = ""; + let requestBody = ""; + + globalThis.fetch = createMockFetch((url, init) => { + requestUrl = url; + requestMethod = init?.method ?? "GET"; + requestBody = typeof init?.body === "string" ? init.body : ""; + + return new Response(JSON.stringify({ timelog: { id: 99 } }), { + status: 201, + headers: { "Content-Type": "application/json" }, + }); + }); + + const id = await createTaskTimeEntry({ + taskId: 12345, + date: "2026-06-24", + hours: 1, + minutes: 15, + description: "Code review", + }); + + expect(id).toBe(99); + expect(requestUrl).toBe(`${TEAMWORK_API_BASE_URL}/tasks/12345/time.json`); + expect(requestMethod).toBe("POST"); + expect(JSON.parse(requestBody)).toEqual({ + timelog: { + taskId: 12345, + isUtc: true, + date: "2026-06-24", + hours: 1, + minutes: 15, + description: "Code review", + }, + timelogOptions: {}, + tags: [], + }); + }); + + test("throws if response has no timelog id", async () => { + globalThis.fetch = createMockFetch( + () => + new Response(JSON.stringify({}), { + status: 201, + headers: { "Content-Type": "application/json" }, + }), + ); + + try { + await createTaskTimeEntry({ + taskId: 12345, + date: "2026-06-24", + hours: 0, + minutes: 30, + description: "", + }); + expect(true).toBe(false); + } catch (error) { + expect(error).toBeInstanceOf(Error); + } + }); + + test("validates input before calling Teamwork", async () => { + let called = false; + globalThis.fetch = createMockFetch(() => { + called = true; + return new Response(JSON.stringify({ timelog: { id: 99 } }), { + status: 201, + headers: { "Content-Type": "application/json" }, + }); + }); + + try { + await createTaskTimeEntry({ + taskId: 12345, + date: "2026/06/24", + hours: 0, + minutes: 30, + description: "Invalid date", + }); + expect(true).toBe(false); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect(called).toBe(false); + } + }); +}); diff --git a/tests/tui/teamwork.test.ts b/tests/tui/teamwork.test.ts index 8869a52..c8d8cfb 100644 --- a/tests/tui/teamwork.test.ts +++ b/tests/tui/teamwork.test.ts @@ -1,16 +1,20 @@ import { describe, expect, test } from "bun:test"; +import type { LocalTimerEntry } from "../../src/teamwork/timers/local.ts"; import { getNextTeamworkTab } from "../../src/tui/pages/teamwork.tsx"; import { getNextPinnedTaskSelection, getPinnedTaskSelectionOrder, type PinnedTaskSelection, } from "../../src/tui/pages/teamwork/project-tab.tsx"; +import { getNextLocalTimerSelection } from "../../src/tui/pages/teamwork/timers-tab.tsx"; describe("teamwork page helpers", () => { test("cycles Teamwork tabs", () => { expect(getNextTeamworkTab("my-work", 1)).toBe("project"); - expect(getNextTeamworkTab("project", 1)).toBe("my-work"); + expect(getNextTeamworkTab("project", 1)).toBe("timers"); + expect(getNextTeamworkTab("timers", 1)).toBe("my-work"); + expect(getNextTeamworkTab("timers", -1)).toBe("project"); expect(getNextTeamworkTab("project", -1)).toBe("my-work"); }); @@ -50,4 +54,32 @@ describe("teamwork page helpers", () => { }); expect(getNextPinnedTaskSelection([], current, 1)).toBeNull(); }); + + test("cycles local timer selection", () => { + const timers: LocalTimerEntry[] = [ + { + id: "timer-1", + taskId: 1, + taskName: "First", + startTime: "2026-01-01T00:00:00Z", + endTime: null, + status: "running", + }, + { + id: "timer-2", + taskId: 2, + taskName: "Second", + startTime: "2026-01-01T00:01:00Z", + endTime: "2026-01-01T00:02:00Z", + status: "stopped", + }, + ]; + + expect(getNextLocalTimerSelection(timers, null, 1)).toBe("timer-1"); + expect(getNextLocalTimerSelection(timers, null, -1)).toBe("timer-2"); + expect(getNextLocalTimerSelection(timers, "timer-1", 1)).toBe("timer-2"); + expect(getNextLocalTimerSelection(timers, "timer-1", -1)).toBe("timer-2"); + expect(getNextLocalTimerSelection(timers, "missing", 1)).toBe("timer-1"); + expect(getNextLocalTimerSelection([], "timer-1", 1)).toBeNull(); + }); });