@latentsearch/timemachine-cli
Version:
CLI tool for TimeMachine API. Generates time entries, lists users/projects, and features an enhanced dry-run output for generation.
320 lines (319 loc) • 15 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.generateEntries = generateEntries;
// Generate entries command will be implemented here
const chalk_1 = __importDefault(require("chalk"));
const inquirer_1 = __importDefault(require("inquirer"));
const ora_1 = __importDefault(require("ora"));
const luxon_1 = require("luxon");
const api_client_1 = require("../utils/api-client");
const resolvers_1 = require("../utils/resolvers");
/**
* Generates multiple time entries based on a pattern
*/
async function generateEntries(options) {
try {
const api = (0, api_client_1.createApiClient)(options);
// Parse all options
const startDate = luxon_1.DateTime.fromISO(options.startDate || "");
const endDate = luxon_1.DateTime.fromISO(options.endDate || "");
const hoursPerDay = parseFloat(options.hours || "8");
const daysOfWeekStr = options.days || "1,2,3,4,5";
const isDryRun = options.dryRun || false;
const isVerbose = options.verbose;
// Validate date inputs
if (!startDate.isValid) {
throw new Error(`Invalid start date "${options.startDate}". Use YYYY-MM-DD format.`);
}
if (!endDate.isValid) {
throw new Error(`Invalid end date "${options.endDate}". Use YYYY-MM-DD format.`);
}
if (endDate < startDate) {
throw new Error("End date must be after start date");
}
if (isNaN(hoursPerDay) || hoursPerDay <= 0) {
throw new Error("Hours must be a positive number");
}
// Parse days of week (1=Monday, 7=Sunday)
const daysOfWeek = daysOfWeekStr.split(",").map(day => parseInt(day.trim(), 10));
if (!daysOfWeek.every(day => day >= 1 && day <= 7)) {
throw new Error("Days must be between 1 (Monday) and 7 (Sunday)");
}
// If user/project are not provided, prompt for them
let userId, projectId;
if (!options.user) {
const usersResponse = await api.get("/api/users");
const users = usersResponse.data.map(user => ({
name: `${user.given_name || ""} ${user.surname || ""} (${user.email || "No email"})`.trim(),
value: user.id
}));
const answer = await inquirer_1.default.prompt([{
type: "list",
name: "userId",
message: "Select a user:",
choices: users,
pageSize: 15
}]);
userId = answer.userId;
}
else {
userId = await (0, resolvers_1.resolveUser)(api, options.user);
}
if (!options.project) {
const projectsResponse = await api.get("/api/projects");
// Group projects by company for better organization
const projectsByCompany = {};
projectsResponse.data.forEach(project => {
const companyName = project.company_name || "No Company";
if (!projectsByCompany[companyName]) {
projectsByCompany[companyName] = [];
}
projectsByCompany[companyName].push(project);
});
// Create a list of project choices with company headers
const projectChoices = [];
Object.entries(projectsByCompany).forEach(([companyName, projects]) => {
// Add company header
projectChoices.push({
name: `--- ${companyName} ---`,
value: undefined,
disabled: true
});
// Add company's projects
projects.forEach(project => {
projectChoices.push({
name: ` ${project.name}`,
value: project.id
});
});
});
const answer = await inquirer_1.default.prompt([{
type: "list",
name: "projectId",
message: "Select a project:",
choices: projectChoices,
pageSize: 20
}]);
projectId = answer.projectId;
}
else {
projectId = await (0, resolvers_1.resolveProject)(api, options.project);
}
// Validate user ID and project ID
if (isNaN(userId) || userId <= 0) {
throw new Error("User ID must be a positive number");
}
if (isNaN(projectId) || projectId <= 0) {
throw new Error("Project ID must be a positive number");
}
// Get user and project details for display
const [userDetails, projectDetails] = await Promise.all([
api.get(`/api/users/${userId}`),
api.get(`/api/projects/${projectId}`)
]);
const userName = userDetails.data.given_name && userDetails.data.surname
? `${userDetails.data.given_name} ${userDetails.data.surname}`
: userDetails.data.username || `ID: ${userId}`;
const projectName = projectDetails.data.name || `ID: ${projectId}`;
// Display configuration
console.log(chalk_1.default.blue("Code Clock Time Entry Generator"));
console.log("----------------------------------------");
console.log(chalk_1.default.cyan("Configuration:"));
console.log(`User: ${chalk_1.default.green(userName)} (ID: ${userId})`);
console.log(`Project: ${chalk_1.default.green(projectName)} (ID: ${projectId})`);
console.log(`Date Range: ${chalk_1.default.green(formatDate(startDate.toISODate() || ""))} to ${chalk_1.default.green(formatDate(endDate.toISODate() || ""))}`);
console.log(`Days Pattern: ${chalk_1.default.green(daysOfWeek.map(d => getDayName(d)).join(", "))}`);
console.log(`Hours per Day: ${chalk_1.default.green(hoursPerDay.toString())}`);
console.log(`Mode: ${isDryRun ? chalk_1.default.yellow("DRY RUN") : chalk_1.default.green("LIVE")}`);
console.log("----------------------------------------");
// Generate list of dates that match our criteria
const datesToProcess = [];
let currentDate = startDate;
while (currentDate <= endDate) {
if (shouldLogTimeOnDate(currentDate, daysOfWeek)) {
const dateStr = currentDate.toISODate();
if (dateStr)
datesToProcess.push(dateStr);
}
currentDate = currentDate.plus({ days: 1 });
}
if (datesToProcess.length === 0) {
console.log(chalk_1.default.yellow("No matching dates found in the specified range and weekday pattern."));
return;
}
console.log(`Found ${chalk_1.default.green(datesToProcess.length.toString())} dates matching your criteria.`);
// Check for existing entries that might conflict
console.log("\nChecking for existing entries...");
const checkSpinner = (0, ora_1.default)("Searching for conflicts").start();
// For each date, check if there's already an entry for this user and project
const existingEntries = {};
let hasConflicts = false;
for (const date of datesToProcess) {
const params = new URLSearchParams({
user_id: userId.toString(),
project_id: projectId.toString(),
date: date
});
const response = await api.get(`/api/time_entries?${params.toString()}`);
if (response.data && response.data.length > 0) {
existingEntries[date] = response.data[0];
hasConflicts = true;
}
}
if (hasConflicts) {
checkSpinner.warn(`Found ${Object.keys(existingEntries).length} existing entries that would conflict.`);
// Display conflicts
console.log(chalk_1.default.yellow("\nExisting entries found:"));
Object.entries(existingEntries).forEach(([date, entry]) => {
console.log(`${formatDate(date)}: ${entry.hours_worked} hours already logged`);
});
// Ask how to handle conflicts
const { conflictAction } = await inquirer_1.default.prompt([{
type: "list",
name: "conflictAction",
message: "How would you like to handle existing entries?",
choices: [
{ name: "Skip existing entries (create only on new dates)", value: "skip" },
{ name: "Replace existing entries", value: "replace" },
{ name: "Cancel operation", value: "cancel" }
]
}]);
if (conflictAction === "cancel") {
console.log(chalk_1.default.yellow("Operation cancelled"));
return;
}
// Filter out dates based on conflict action
if (conflictAction === "skip") {
const originalCount = datesToProcess.length;
datesToProcess.filter(date => !existingEntries[date]);
console.log(`Will skip ${originalCount - datesToProcess.length} existing entries and create ${datesToProcess.length} new ones.`);
}
else {
console.log(`Will replace ${Object.keys(existingEntries).length} existing entries.`);
}
}
else {
checkSpinner.succeed("No conflicts found. All entries will be created as new.");
}
// Confirm before proceeding
if (!isDryRun) {
const { proceed } = await inquirer_1.default.prompt([{
type: "confirm",
name: "proceed",
message: `Create ${datesToProcess.length} time entries?`,
default: true
}]);
if (!proceed) {
console.log(chalk_1.default.yellow("Operation cancelled"));
return;
}
}
if (isDryRun) {
console.log(chalk_1.default.yellow("\nDRY RUN - No actual API calls will be made\n"));
datesToProcess.forEach(date => {
console.log(`Would create: ${formatDate(date)} - ${hoursPerDay} hours for User ${userName} on Project ${projectName}`);
});
console.log(chalk_1.default.green("\nDry run completed successfully"));
return;
}
// Start progress spinner
const spinner = (0, ora_1.default)("Creating time entries...").start();
let created = 0;
let updated = 0;
let failed = 0;
for (let i = 0; i < datesToProcess.length; i++) {
const date = datesToProcess[i];
spinner.text = `Processing entry ${i + 1}/${datesToProcess.length} (${formatDate(date)})`;
try {
const timeEntry = {
user_id: userId,
project_id: projectId,
date: date,
hours_worked: hoursPerDay
};
// Check if entry exists and determine operation (create or update)
if (existingEntries[date]) {
const existingId = existingEntries[date].id;
await api.put(`/api/time_entries/${existingId}`, timeEntry);
updated++;
if (isVerbose) {
spinner.stop();
console.log(chalk_1.default.cyan(`✓ Updated: ${formatDate(date)} - ID: ${existingId}`));
spinner.start();
}
}
else {
const response = await api.post("/api/time_entries", timeEntry);
created++;
if (isVerbose) {
spinner.stop();
console.log(chalk_1.default.green(`✓ Created: ${formatDate(date)} - ID: ${response.data.id}`));
spinner.start();
}
}
}
catch (error) {
failed++;
spinner.stop();
const errorMessage = error.response?.data?.error || error.message || "Unknown error";
console.error(chalk_1.default.red(`✗ Failed for ${formatDate(date)}: ${errorMessage}`));
if (isVerbose && error.response?.data) {
console.error(chalk_1.default.gray(JSON.stringify(error.response.data, null, 2)));
}
spinner.start();
}
// Update spinner text with progress
spinner.text = `Processing: ${i + 1}/${datesToProcess.length} - ${created} created, ${updated} updated, ${failed} failed`;
}
spinner.stop();
// Final summary
console.log("----------------------------------------");
console.log(chalk_1.default.cyan("Summary:"));
console.log(`Total dates processed: ${datesToProcess.length}`);
console.log(`Entries created: ${chalk_1.default.green(created.toString())}`);
console.log(`Entries updated: ${chalk_1.default.blue(updated.toString())}`);
console.log(`Failed operations: ${failed > 0 ? chalk_1.default.red(failed.toString()) : chalk_1.default.green(failed.toString())}`);
console.log("----------------------------------------");
if (created + updated > 0) {
console.log(chalk_1.default.green("Time entries generated successfully!"));
}
else {
console.log(chalk_1.default.red("No time entries were created or updated."));
}
}
catch (error) {
console.error(chalk_1.default.red("Error:"), error.message || "Unknown error occurred");
if (options.verbose && error.response?.data) {
console.error(chalk_1.default.gray(JSON.stringify(error.response.data, null, 2)));
}
process.exit(1);
}
}
/**
* Formats a date string as a readable format
*/
function formatDate(dateStr) {
try {
return luxon_1.DateTime.fromISO(dateStr).toLocaleString(luxon_1.DateTime.DATE_MED_WITH_WEEKDAY);
}
catch (error) {
return dateStr;
}
}
/**
* Returns day name for a weekday number (1=Monday, 7=Sunday)
*/
function getDayName(dayNumber) {
const days = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
return days[dayNumber - 1] || "Unknown";
}
/**
* Helper function to check if a date matches given weekday pattern
*/
function shouldLogTimeOnDate(date, daysOfWeek) {
// Luxon uses 1=Monday, 7=Sunday, which matches our input format
return daysOfWeek.includes(date.weekday);
}