From d767df30887a15d1ff2d4b44cf4a6498e7d1dd9c Mon Sep 17 00:00:00 2001 From: Isaac Johnson Date: Mon, 1 Jun 2026 18:34:20 -0500 Subject: [PATCH] tests, add and view time --- src/api.ts | 61 ++++++++++++++ src/index.ts | 3 + src/tui.ts | 181 +++++++++++++++++++++++++++++++++++++--- src/types.ts | 10 ++- test-api-add.js | 14 ++++ test-api-issue-times.js | 10 +++ test-api-times.js | 10 +++ test-api.js | 10 +++ 8 files changed, 288 insertions(+), 11 deletions(-) create mode 100644 test-api-add.js create mode 100644 test-api-issue-times.js create mode 100644 test-api-times.js create mode 100644 test-api.js diff --git a/src/api.ts b/src/api.ts index 75e6e3a..c791c82 100644 --- a/src/api.ts +++ b/src/api.ts @@ -128,8 +128,16 @@ export async function fetchIssues( color: l.color, })), pull_request: item.pull_request, + total_tracked_time: 0, })); + // Fetch tracked times concurrently for all issues + await Promise.all( + issues.map(async (issue) => { + issue.total_tracked_time = await fetchIssueTrackedTimeTotal(config, issue.number); + }) + ); + return { issues, totalCount }; } catch (error: any) { if (error.response) { @@ -150,6 +158,7 @@ export async function fetchIssue( try { const response = await client.get(`/repos/${config.owner}/${config.repo}/issues/${issueNumber}`); const item = response.data; + const trackedTime = await fetchIssueTrackedTimeTotal(config, issueNumber); return { id: item.id, number: item.number, @@ -170,6 +179,7 @@ export async function fetchIssue( color: l.color, })), pull_request: item.pull_request, + total_tracked_time: trackedTime, }; } catch (error: any) { if (error.response) { @@ -424,3 +434,54 @@ export async function editIssue( throw new Error(`Network Error: ${error.message}`); } } + +/** + * Adds time to an issue. + * @param time Time in seconds + */ +export async function addIssueTime( + config: Config, + issueNumber: number, + time: number +): Promise { + const client = createAxiosInstance(config); + try { + const response = await client.post(`/repos/${config.owner}/${config.repo}/issues/${issueNumber}/times`, { + time, + }); + return response.data; + } catch (error: any) { + if (error.response) { + if (error.response.status === 401) { + throw new Error('Unauthorized: You must be logged in with a token to add time.'); + } + throw new Error(`Failed to add time: ${error.response.data?.message || error.message}`); + } + throw new Error(`Network Error: ${error.message}`); + } +} + +/** + * Fetches the total tracked time for a specific issue. + * @returns Total time in seconds + */ +export async function fetchIssueTrackedTimeTotal( + config: Config, + issueNumber: number +): Promise { + const client = createAxiosInstance(config); + try { + const response = await client.get(`/repos/${config.owner}/${config.repo}/issues/${issueNumber}/times`); + const times: any[] = response.data; + let total = 0; + for (const t of times) { + if (t && typeof t.time === 'number') { + total += t.time; + } + } + return total; + } catch (error: any) { + // If times are not supported or missing, just return 0 + return 0; + } +} diff --git a/src/index.ts b/src/index.ts index 7a1fabe..002b482 100644 --- a/src/index.ts +++ b/src/index.ts @@ -81,6 +81,9 @@ async function bootstrap() { body: '', activeField: 'title', }, + addTimeForm: { + timeInput: '', + }, }; // If parameters are provided, try direct connection first diff --git a/src/tui.ts b/src/tui.ts index 22104b1..e045da3 100644 --- a/src/tui.ts +++ b/src/tui.ts @@ -1,7 +1,7 @@ import readline from 'readline'; import chalk from 'chalk'; import { AppState, Issue, Comment } from './types.js'; -import { fetchIssues, fetchIssue, fetchIssueComments, validateConnection, normalizeUrl, authenticateAndFetchRepos, createIssue, createIssueComment, editIssue } from './api.js'; +import { fetchIssues, fetchIssue, fetchIssueComments, validateConnection, normalizeUrl, authenticateAndFetchRepos, createIssue, createIssueComment, editIssue, addIssueTime } from './api.js'; import { saveGlobalConfig } from './config.js'; // Setup readline for stdin keypress events @@ -73,12 +73,20 @@ export function formatDate(dateStr: string): string { } } -/** - * Helper to truncate strings to a specific length. - */ +// Utility to safely truncate strings function truncate(str: string, length: number): string { if (str.length <= length) return str; - return str.substring(0, length - 3) + '...'; + return str.substring(0, length - 1) + '…'; +} + +// Utility to format seconds into h m format +function formatTime(seconds?: number): string { + if (!seconds || seconds <= 0) return '0m'; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + if (h > 0 && m > 0) return `${h}h ${m}m`; + if (h > 0) return `${h}h`; + return `${m}m`; } /** @@ -281,6 +289,8 @@ export class TuiEngine { this.handleAddCommentKeypress(str, key); } else if (this.state.screen === 'edit-issue') { this.handleEditIssueKeypress(str, key); + } else if (this.state.screen === 'add-time') { + this.handleAddTimeKeypress(str, key); } } @@ -727,6 +737,16 @@ export class TuiEngine { return; } + if (str === 't' || str === 'T') { + if (this.state.selectedIssue) { + this.state.screen = 'add-time'; + this.state.addTimeForm.timeInput = ''; + this.state.error = null; + this.render(); + } + return; + } + if ((key && (key.name === 'escape' || key.name === 'backspace')) || str === '\u001b' || str === 'q' || str === 'Q') { this.state.screen = 'list'; this.state.selectedIssue = null; @@ -759,6 +779,8 @@ export class TuiEngine { this.renderAddCommentScreen(cols, rows); } else if (this.state.screen === 'edit-issue') { this.renderEditIssueScreen(cols, rows); + } else if (this.state.screen === 'add-time') { + this.renderAddTimeScreen(cols, rows); } } @@ -943,8 +965,9 @@ export class TuiEngine { const authorWidth = 14; const createdWidth = 12; const commentsWidth = 6; + const timeWidth = 7; // Title takes all remaining space - const titleWidth = Math.max(20, cols - idWidth - typeWidth - stateWidth - authorWidth - createdWidth - commentsWidth - 8); + const titleWidth = Math.max(20, cols - idWidth - typeWidth - stateWidth - authorWidth - createdWidth - commentsWidth - timeWidth - 9); // Render Table Header const padHeader = (title: string, w: number) => chalk.bold.white(title.padEnd(w)); @@ -958,7 +981,8 @@ export class TuiEngine { padHeader('Title', titleWidth) + borderCh + ' ' + padHeader('Author', authorWidth) + borderCh + ' ' + padHeader('Created', createdWidth) + borderCh + ' ' + - padHeader('Coms', commentsWidth) + borderCh + padHeader('Coms', commentsWidth) + borderCh + ' ' + + padHeader('Time', timeWidth) + borderCh ); console.log(chalk.bold.hex('#4A90E2')('├' + '─'.repeat(cols - 2) + '┤')); @@ -995,6 +1019,7 @@ export class TuiEngine { const authorStr = truncate(issue.user.login, authorWidth).padEnd(authorWidth); const createdStr = formatDate(issue.created_at).substring(0, 10).padEnd(createdWidth); const commentsStr = String(issue.comments).padEnd(commentsWidth); + const timeStr = formatTime(issue.total_tracked_time).padEnd(timeWidth); let rowText = ' ' + idStr + borderCh + @@ -1003,7 +1028,8 @@ export class TuiEngine { ' ' + (issue.pull_request ? chalk.magenta(titleStr) : titleStr) + borderCh + ' ' + authorStr + borderCh + ' ' + createdStr + borderCh + - ' ' + commentsStr + borderCh; + ' ' + commentsStr + borderCh + + ' ' + timeStr + borderCh; if (isSelected) { rowText = chalk.bgHex('#2E4E7E').white.bold(rowText); @@ -1092,8 +1118,9 @@ export class TuiEngine { } const labels = issue.labels.map(l => chalk.bgHex('#' + l.color).black(` ${l.name} `)).join(' '); + const timeStr = formatTime(issue.total_tracked_time); - console.log(borderCh + ` State: ${stateLabel} Author: ${chalk.cyan(issue.user.login)} Created: ${formatDate(issue.created_at)} Updated: ${formatDate(issue.updated_at)}`.padEnd(cols - 2) + borderCh); + console.log(borderCh + ` State: ${stateLabel} Author: ${chalk.cyan(issue.user.login)} Created: ${formatDate(issue.created_at)} Updated: ${formatDate(issue.updated_at)} Time: ${chalk.yellow(timeStr)}`.padEnd(cols - 2 + (issue.state === 'open' ? 10 : 9) + 10 + 10) + borderCh); if (labels) { console.log(borderCh + ` Labels: ${labels}`.padEnd(cols - 2 + labels.length - issue.labels.map(l=>l.name.length + 10).reduce((a,b)=>a+b, 0)) + borderCh); // offset for raw ANSI chars } @@ -1160,7 +1187,7 @@ export class TuiEngine { scrollHelp += chalk.yellow(` (${pct}%)`); } - const helpLine = chalk.gray(` [Esc/Backspace] Back to List [C] Add Comment [E] Edit [R] Reload [O] Settings ${scrollHelp}`); + const helpLine = chalk.gray(` [Esc/Backspace] Back to List [C] Add Comment [E] Edit [T] Add Time [R] Reload [O] Settings ${scrollHelp}`); process.stdout.write(helpLine); } @@ -1708,5 +1735,139 @@ export class TuiEngine { this.render(); } } + + private renderAddTimeScreen(cols: number, rows: number) { + const borderCh = chalk.bold.hex('#4A90E2')('│'); + + // Header + const title = ' Add Time '; + const leftLen = stripAnsi(title).length; + let leftHeader = chalk.bold.hex('#4A90E2')(title); + + console.log(leftHeader + ' '.repeat(Math.max(1, cols - leftLen - 1))); + console.log(chalk.bold.hex('#4A90E2')('┌' + '─'.repeat(cols - 2) + '┐')); + + if (this.state.error) { + console.log(borderCh + chalk.red(` Error: ${this.state.error}`).padEnd(cols - 2) + borderCh); + console.log(chalk.bold.hex('#4A90E2')('├' + '─'.repeat(cols - 2) + '┤')); + } + + const form = this.state.addTimeForm; + + // Time Field + const timeLabel = chalk.yellow.bold(' Time Spent (e.g. 1h 30m, 45m): '); + console.log(borderCh + timeLabel + ' '.repeat(Math.max(0, cols - stripAnsi(timeLabel).length - 2)) + borderCh); + console.log(chalk.bold.hex('#4A90E2')('├' + '─'.repeat(cols - 2) + '┤')); + + // Input Box + let visLine = form.timeInput; + if (visLine.length > cols - 5) { + visLine = visLine.substring(visLine.length - (cols - 5)); + } + + visLine = visLine + chalk.inverse(' ') + ' '.repeat(Math.max(0, cols - 5 - stripAnsi(visLine).length)); + + console.log(borderCh + ' ' + visLine + ' ' + borderCh); + + console.log(chalk.bold.hex('#4A90E2')('└' + '─'.repeat(cols - 2) + '┘')); + + // Help line + const helpLine = chalk.gray(' [Ctrl+S] Submit [Esc] Cancel'); + const issueStr = this.state.selectedIssue ? chalk.bold.cyan(` Issue #${this.state.selectedIssue.number} `) : ''; + const issueLen = stripAnsi(issueStr).length; + + const maxHelpLen = cols - issueLen - 1; + let helpLinePlain = helpLine; + if (stripAnsi(helpLinePlain).length > maxHelpLen) { + helpLinePlain = chalk.gray(stripAnsi(helpLinePlain).substring(0, maxHelpLen - 3) + '...'); + } + + const spaces = cols - stripAnsi(helpLinePlain).length - issueLen - 1; + process.stdout.write(helpLinePlain + ' '.repeat(Math.max(0, spaces)) + issueStr); + } + + private async handleAddTimeKeypress(str: string, key: any) { + if (key && key.name === 'escape') { + this.state.screen = 'details'; + this.state.error = null; + this.render(); + return; + } + + if (key && key.ctrl && key.name === 's') { + const input = this.state.addTimeForm.timeInput.trim(); + if (!input) { + this.state.error = 'Time input cannot be empty.'; + this.render(); + return; + } + + if (!this.state.selectedIssue) { + this.state.error = 'No issue selected.'; + this.render(); + return; + } + + // Parse time input (e.g. 1h 30m, 45m, 2h) + let totalSeconds = 0; + const hMatch = input.match(/(\d+)\s*h/); + const mMatch = input.match(/(\d+)\s*m/); + + if (hMatch) { + totalSeconds += parseInt(hMatch[1], 10) * 3600; + } + if (mMatch) { + totalSeconds += parseInt(mMatch[1], 10) * 60; + } + + // If neither h nor m matched, but it's just a number, assume minutes + if (!hMatch && !mMatch && /^\d+$/.test(input)) { + totalSeconds += parseInt(input, 10) * 60; + } else if (!hMatch && !mMatch) { + this.state.error = "Invalid format. Use '1h 30m', '45m', or just minutes like '45'."; + this.render(); + return; + } + + if (totalSeconds <= 0) { + this.state.error = "Time must be greater than 0."; + this.render(); + return; + } + + this.state.error = null; + this.state.loading = true; + this.render(); + + try { + await addIssueTime(this.state.config, this.state.selectedIssue.number, totalSeconds); + this.state.screen = 'details'; + this.state.loading = false; + // Reload to show the time tracked + this.reloadSingleIssue(); + } catch (err: any) { + this.state.error = err.message; + this.state.loading = false; + this.render(); + } + return; + } + + let val = this.state.addTimeForm.timeInput; + + if (key && key.name === 'backspace') { + if (val.length > 0) { + this.state.addTimeForm.timeInput = val.slice(0, -1); + this.render(); + } + return; + } + + if (str && str.length === 1 && !key.ctrl && !key.meta && str !== '\n' && str !== '\r') { + this.state.error = null; + this.state.addTimeForm.timeInput += str; + this.render(); + } + } } diff --git a/src/types.ts b/src/types.ts index d82c92f..a34d193 100644 --- a/src/types.ts +++ b/src/types.ts @@ -30,6 +30,7 @@ export interface Issue { comments: number; labels: Label[]; pull_request?: any | null; + total_tracked_time?: number; // Total time in seconds } export interface Comment { @@ -64,7 +65,11 @@ export interface EditIssueForm { activeField: 'title' | 'body'; } -export type ScreenType = 'setup' | 'repo-picker' | 'list' | 'details' | 'create-issue' | 'add-comment' | 'edit-issue'; +export interface AddTimeForm { + timeInput: string; +} + +export type ScreenType = 'setup' | 'repo-picker' | 'list' | 'details' | 'create-issue' | 'add-comment' | 'edit-issue' | 'add-time'; export interface RepoItem { @@ -116,5 +121,8 @@ export interface AppState { // Edit Issue Form State editIssueForm: EditIssueForm; + + // Add Time Form State + addTimeForm: AddTimeForm; } diff --git a/test-api-add.js b/test-api-add.js new file mode 100644 index 0000000..306987d --- /dev/null +++ b/test-api-add.js @@ -0,0 +1,14 @@ +import fs from 'fs'; +import os from 'os'; +import axios from 'axios'; + +const configPath = os.homedir() + '/.config/fjtui/fjtui.json'; +const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + +axios.post(config.url + '/api/v1/repos/' + config.repo + '/issues/1/times', { time: 3600 }, { + headers: { 'Authorization': 'token ' + config.token } +}).then(r => console.log('Added time')).catch(e => console.error(e.message)); + +axios.get(config.url + '/api/v1/repos/' + config.repo + '/issues/1', { + headers: { 'Authorization': 'token ' + config.token } +}).then(r => console.log(Object.keys(r.data))).catch(e => console.error(e.message)); diff --git a/test-api-issue-times.js b/test-api-issue-times.js new file mode 100644 index 0000000..c08aaab --- /dev/null +++ b/test-api-issue-times.js @@ -0,0 +1,10 @@ +import fs from 'fs'; +import os from 'os'; +import axios from 'axios'; + +const configPath = os.homedir() + '/.config/fjtui/fjtui.json'; +const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + +axios.get(config.url + '/api/v1/repos/' + config.repo + '/issues/1/times', { + headers: { 'Authorization': 'token ' + config.token } +}).then(r => console.log(JSON.stringify(r.data, null, 2))).catch(e => console.error(e.message)); diff --git a/test-api-times.js b/test-api-times.js new file mode 100644 index 0000000..04ed72e --- /dev/null +++ b/test-api-times.js @@ -0,0 +1,10 @@ +import fs from 'fs'; +import os from 'os'; +import axios from 'axios'; + +const configPath = os.homedir() + '/.config/fjtui/fjtui.json'; +const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + +axios.get(config.url + '/api/v1/repos/' + config.repo + '/times', { + headers: { 'Authorization': 'token ' + config.token } +}).then(r => console.log(JSON.stringify(r.data.slice(0, 2), null, 2))).catch(e => console.error(e.message)); diff --git a/test-api.js b/test-api.js new file mode 100644 index 0000000..8dade09 --- /dev/null +++ b/test-api.js @@ -0,0 +1,10 @@ +import fs from 'fs'; +import os from 'os'; +import axios from 'axios'; + +const configPath = os.homedir() + '/.config/fjtui/fjtui.json'; +const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + +axios.get(config.url + '/api/v1/repos/' + config.repo + '/issues', { + headers: { 'Authorization': 'token ' + config.token } +}).then(r => console.log(JSON.stringify(r.data[0], null, 2))).catch(e => console.error(e.message));