UNPKG

@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
"use strict"; 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); }