tests, add and view time

This commit is contained in:
Isaac Johnson 2026-06-01 18:34:20 -05:00
parent d3676e635f
commit d767df3088
8 changed files with 288 additions and 11 deletions

View File

@ -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<any> {
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<number> {
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;
}
}

View File

@ -81,6 +81,9 @@ async function bootstrap() {
body: '',
activeField: 'title',
},
addTimeForm: {
timeInput: '',
},
};
// If parameters are provided, try direct connection first

View File

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

View File

@ -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;
}

14
test-api-add.js Normal file
View File

@ -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));

10
test-api-issue-times.js Normal file
View File

@ -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));

10
test-api-times.js Normal file
View File

@ -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));

10
test-api.js Normal file
View File

@ -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));