import { supabase, getCachedValue, setCachedValue } from '../lib/supabase.js'; import * as p from '@clack/prompts'; import chalk from 'chalk'; import ora from 'ora'; import Table from 'cli-table3'; // Helper to assert authentication async function getAuthenticatedUser() { const { data: { user }, error } = await supabase.auth.getUser(); if (error || !user) { console.log(chalk.red('\nError: You must be logged in to run this command.')); console.log(`Run ${chalk.cyan('velowork login')} to authenticate.\n`); process.exit(1); } return user; } // Get or initialize active workspace export async function getActiveWorkspace(user: any): Promise<{ id: string; name: string } | null> { const cachedId = getCachedValue('activeWorkspaceId'); const { data: workspaces, error } = await supabase .from('workspaces') .select('*') .eq('user_id', user.id); if (error) { console.log(chalk.red(`Failed to fetch workspaces: ${error.message}`)); return null; } // If no workspaces exist, create one by default if (!workspaces || workspaces.length === 0) { const spinner = ora('Creating a default Personal Workspace...').start(); const { data: newWorkspace, error: createError } = await supabase .from('workspaces') .insert({ user_id: user.id, name: 'Personal Workspace' }) .select() .single(); if (createError || !newWorkspace) { spinner.fail(chalk.red('Failed to create default workspace.')); return null; } spinner.succeed(chalk.green('Created default "Personal Workspace"')); setCachedValue('activeWorkspaceId', newWorkspace.id); return { id: newWorkspace.id, name: newWorkspace.name }; } if (cachedId) { const match = workspaces.find(w => w.id === cachedId); if (match) { return { id: match.id, name: match.name }; } } // Default to first and cache it const defaultW = workspaces[0]; setCachedValue('activeWorkspaceId', defaultW.id); return { id: defaultW.id, name: defaultW.name }; } // Command: Switch active workspace export async function selectWorkspace() { const user = await getAuthenticatedUser(); const spinner = ora('Fetching workspaces...').start(); const { data: workspaces, error } = await supabase .from('workspaces') .select('*') .eq('user_id', user.id) .order('name', { ascending: true }); if (error) { spinner.fail(chalk.red(`Error: ${error.message}`)); return; } spinner.stop(); if (!workspaces || workspaces.length === 0) { console.log(chalk.yellow('No workspaces found. Fetching or creating...')); await getActiveWorkspace(user); return; } const currentActive = getCachedValue('activeWorkspaceId'); const options = workspaces.map(w => ({ value: w.id, label: w.id === currentActive ? `${w.name} (active)` : w.name, })); const select = await p.select({ message: 'Select active workspace:', options, }); if (p.isCancel(select)) { return; } setCachedValue('activeWorkspaceId', select as string); const selectedName = workspaces.find(w => w.id === select)?.name; p.outro(`Switched active workspace to: ${chalk.bold.green(selectedName)}`); } // Command: List Tasks export async function listTasks() { const user = await getAuthenticatedUser(); const workspace = await getActiveWorkspace(user); if (!workspace) return; const spinner = ora(`Fetching tasks for ${chalk.cyan(workspace.name)}...`).start(); // Fetch checklists to link tasks const { data: checklists, error: checklistError } = await supabase .from('checklists') .select('*, tasks(*)') .eq('workspace_id', workspace.id) .eq('user_id', user.id); if (checklistError) { spinner.fail(chalk.red(`Failed to fetch tasks: ${checklistError.message}`)); return; } spinner.succeed(chalk.green(`Tasks in workspace: ${chalk.bold(workspace.name)}`)); // Flatten tasks and sort them const allTasks: any[] = []; checklists?.forEach(c => { c.tasks?.forEach((t: any) => { allTasks.push({ ...t, checklistTitle: c.title, }); }); }); if (allTasks.length === 0) { console.log(chalk.yellow('\nNo tasks found. Create a task with "velowork add-task".\n')); return; } // Sort tasks: pending first, then by priority (p0 > p1 > p2 > null), then by due date allTasks.sort((a, b) => { if (a.completed !== b.completed) return a.completed ? 1 : -1; // Priority sort const priorityWeight = { p0: 0, p1: 1, p2: 2, null: 3, undefined: 3 }; const weightA = priorityWeight[a.priority as keyof typeof priorityWeight] ?? 3; const weightB = priorityWeight[b.priority as keyof typeof priorityWeight] ?? 3; if (weightA !== weightB) return weightA - weightB; // Date sort if (!a.due_date) return 1; if (!b.due_date) return -1; return new Date(a.due_date).getTime() - new Date(b.due_date).getTime(); }); const table = new Table({ head: [ chalk.bold.cyan('ID (Short)'), chalk.bold.cyan('Task Text'), chalk.bold.cyan('Priority'), chalk.bold.cyan('Due Date'), chalk.bold.cyan('Status'), chalk.bold.cyan('Checklist') ], colWidths: [12, 35, 10, 12, 12, 20] }); allTasks.forEach(t => { const shortId = t.id.substring(0, 8); const text = t.completed ? chalk.gray.strikethrough(t.text) : t.text; let priorityStr = 'None'; if (t.priority === 'p0') priorityStr = chalk.bold.red('P0'); else if (t.priority === 'p1') priorityStr = chalk.yellow('P1'); else if (t.priority === 'p2') priorityStr = chalk.blue('P2'); const dueDateStr = t.due_date ? t.due_date : 'N/A'; const statusStr = t.completed ? chalk.green('✔ Done') : chalk.yellow('⏳ Pending'); table.push([ shortId, text, priorityStr, dueDateStr, statusStr, t.checklistTitle ]); }); console.log('\n' + table.toString() + '\n'); } // Command: List Checklists export async function listChecklists() { const user = await getAuthenticatedUser(); const workspace = await getActiveWorkspace(user); if (!workspace) return; const spinner = ora(`Fetching checklists for ${chalk.cyan(workspace.name)}...`).start(); const { data: checklists, error } = await supabase .from('checklists') .select('*, tasks(id)') .eq('workspace_id', workspace.id) .eq('user_id', user.id) .order('date', { ascending: false, nullsFirst: false }) .order('created_at', { ascending: true }); if (error) { spinner.fail(chalk.red(`Failed to fetch checklists: ${error.message}`)); return; } spinner.succeed(chalk.green(`Checklists in: ${chalk.bold(workspace.name)}`)); if (!checklists || checklists.length === 0) { console.log(chalk.yellow('\nNo checklists found.\n')); return; } const table = new Table({ head: [ chalk.bold.cyan('ID (Short)'), chalk.bold.cyan('Title'), chalk.bold.cyan('Date'), chalk.bold.cyan('Type'), chalk.bold.cyan('Tasks Count') ], colWidths: [12, 35, 12, 12, 12] }); checklists.forEach(c => { const shortId = c.id.substring(0, 8); const dateStr = c.date ? c.date : 'N/A'; const typeStr = c.is_reusable ? chalk.magenta('Template') : 'Daily List'; const taskCount = c.tasks ? c.tasks.length : 0; table.push([ shortId, c.title, dateStr, typeStr, taskCount ]); }); console.log('\n' + table.toString() + '\n'); } // Command: View Checklist export async function viewChecklist(shortIdInput: string) { const user = await getAuthenticatedUser(); const workspace = await getActiveWorkspace(user); if (!workspace) return; const spinner = ora('Searching checklist...').start(); // Find by full UUID prefix match const { data: checklists, error } = await supabase .from('checklists') .select('*, tasks(*)') .eq('workspace_id', workspace.id) .eq('user_id', user.id); if (error) { spinner.fail(chalk.red(`Query failed: ${error.message}`)); return; } const checklist = checklists?.find(c => c.id.startsWith(shortIdInput)); if (!checklist) { spinner.fail(chalk.red(`Checklist with ID prefix "${shortIdInput}" not found.`)); return; } spinner.succeed(chalk.green(`Checklist: ${chalk.bold(checklist.title)}`)); console.log(chalk.gray(`Full ID: ${checklist.id}`)); if (checklist.description) console.log(chalk.gray(`Desc: ${checklist.description}`)); console.log(chalk.gray(`Date: ${checklist.date || 'N/A'}\n`)); const tasks = checklist.tasks || []; if (tasks.length === 0) { console.log(chalk.yellow('No tasks in this checklist.')); return; } // Sort tasks: pending first, then by priority tasks.sort((a: any, b: any) => { if (a.completed !== b.completed) return a.completed ? 1 : -1; const priorityWeight = { p0: 0, p1: 1, p2: 2, null: 3, undefined: 3 }; const weightA = priorityWeight[a.priority as keyof typeof priorityWeight] ?? 3; const weightB = priorityWeight[b.priority as keyof typeof priorityWeight] ?? 3; return weightA - weightB; }); tasks.forEach((t: any) => { const symbol = t.completed ? chalk.green('✔') : chalk.yellow('☐'); const taskIdStr = chalk.gray(`[${t.id.substring(0, 8)}]`); let priorityStr = ''; if (t.priority === 'p0') priorityStr = chalk.bold.red(' [P0]'); else if (t.priority === 'p1') priorityStr = chalk.yellow(' [P1]'); else if (t.priority === 'p2') priorityStr = chalk.blue(' [P2]'); const taskText = t.completed ? chalk.gray.strikethrough(t.text) : t.text; console.log(` ${symbol} ${taskIdStr} ${taskText}${priorityStr}`); }); console.log(''); } // Command: Add Task export async function addTask() { const user = await getAuthenticatedUser(); const workspace = await getActiveWorkspace(user); if (!workspace) return; p.intro(chalk.bold.cyan('Add New Task')); // Get checklists to select which checklist this task belongs to const { data: checklists, error } = await supabase .from('checklists') .select('id, title, date') .eq('workspace_id', workspace.id) .eq('user_id', user.id); if (error) { p.outro(chalk.red(`Failed to fetch checklists: ${error.message}`)); return; } let checklistId: string; if (!checklists || checklists.length === 0) { // If no checklists, prompt to create one first const createChecklist = await p.confirm({ message: 'You have no checklists in this workspace. Create one now?', initialValue: true, }); if (p.isCancel(createChecklist) || !createChecklist) { p.outro(chalk.yellow('Cancelled.')); return; } const title = await p.text({ message: 'Checklist Title:', validate: v => !v ? 'Title is required' : undefined, }); if (p.isCancel(title)) return; const spinner = ora('Creating checklist...').start(); const todayStr = new Date().toISOString().split('T')[0]; const { data: newC, error: cErr } = await supabase .from('checklists') .insert({ user_id: user.id, workspace_id: workspace.id, title: title as string, date: todayStr, }) .select() .single(); if (cErr || !newC) { spinner.fail(chalk.red('Failed to create checklist.')); return; } spinner.succeed(`Created checklist: ${chalk.green(newC.title)}`); checklistId = newC.id; } else { // Offer existing checklists + option to create a new one const options = [ ...checklists.map(c => ({ value: c.id, label: `${c.title} (${c.date || 'Template'})`, })), { value: 'CREATE_NEW', label: chalk.bold.cyan('+ Create new checklist...') } ]; const checklistSelect = await p.select({ message: 'Select checklist for this task (or choose to create a new one):', options, }); if (p.isCancel(checklistSelect)) return; if (checklistSelect === 'CREATE_NEW') { const title = await p.text({ message: 'Checklist Title:', validate: v => !v ? 'Title is required' : undefined, }); if (p.isCancel(title)) return; const spinner = ora('Creating checklist...').start(); const todayStr = new Date().toISOString().split('T')[0]; const { data: newC, error: cErr } = await supabase .from('checklists') .insert({ user_id: user.id, workspace_id: workspace.id, title: title as string, date: todayStr, }) .select() .single(); if (cErr || !newC) { spinner.fail(chalk.red('Failed to create checklist.')); return; } spinner.succeed(`Created checklist: ${chalk.green(newC.title)}`); checklistId = newC.id; } else { checklistId = checklistSelect as string; } } const text = await p.text({ message: 'Enter task text:', validate: v => !v ? 'Task text is required' : undefined, }); if (p.isCancel(text)) return; const priority = await p.select({ message: 'Priority:', options: [ { value: 'none', label: 'None' }, { value: 'p0', label: 'P0 (High)' }, { value: 'p1', label: 'P1 (Medium)' }, { value: 'p2', label: 'P2 (Low)' }, ], }); if (p.isCancel(priority)) return; const due_date = await p.text({ message: 'Due date (YYYY-MM-DD, optional):', placeholder: 'Leave blank for none', validate(v) { if (v && !/^\d{4}-\d{2}-\d{2}$/.test(v)) return 'Format must be YYYY-MM-DD'; return; } }); if (p.isCancel(due_date)) return; const spinner = ora('Adding task...').start(); const { data: newTask, error: insertError } = await supabase .from('tasks') .insert({ checklist_id: checklistId, user_id: user.id, text: text as string, completed: false, priority: priority === 'none' ? null : (priority as string), due_date: (due_date as string) || null, }) .select() .single(); if (insertError) { spinner.fail(chalk.red(`Failed to add task: ${insertError.message}`)); return; } spinner.succeed(chalk.green('Task successfully created!')); p.outro(`Task: ${chalk.bold(newTask.text)} [ID: ${newTask.id.substring(0, 8)}]`); } // Command: Complete Task export async function completeTask(shortIdInput: string) { const user = await getAuthenticatedUser(); const spinner = ora('Locating task...').start(); // Query all tasks under this user const { data: tasks, error } = await supabase .from('tasks') .select('*') .eq('user_id', user.id); if (error) { spinner.fail(chalk.red(`Query failed: ${error.message}`)); return; } const task = tasks?.find(t => t.id.startsWith(shortIdInput)); if (!task) { spinner.fail(chalk.red(`Task with ID prefix "${shortIdInput}" not found.`)); return; } if (task.completed) { spinner.info(chalk.yellow(`Task "${task.text}" is already completed.`)); return; } spinner.text = `Marking task as complete...`; const { error: updateError } = await supabase .from('tasks') .update({ completed: true }) .eq('id', task.id); if (updateError) { spinner.fail(chalk.red(`Failed to complete task: ${updateError.message}`)); return; } spinner.succeed(chalk.green(`Task marked as complete: "${chalk.bold(task.text)}"`)); } // Command: Today Dashboard Summary export async function viewToday() { const user = await getAuthenticatedUser(); const workspace = await getActiveWorkspace(user); if (!workspace) return; const todayStr = new Date().toISOString().split('T')[0]; const spinner = ora('Aggregating dashboard summary...').start(); // Fetch checklists matching workspace, user, and today's date, or due_date today const [checklistsRes, tasksRes] = await Promise.all([ supabase .from('checklists') .select('*, tasks(*)') .eq('workspace_id', workspace.id) .eq('user_id', user.id) .eq('date', todayStr), supabase .from('tasks') .select('*, checklists(*)') .eq('user_id', user.id) .eq('due_date', todayStr) ]); if (checklistsRes.error || tasksRes.error) { spinner.fail(chalk.red('Failed to fetch dashboard data.')); return; } spinner.succeed(chalk.green('Dashboard stats retrieved!')); // Collect tasks due today or inside today's checklists const todayTasksMap = new Map(); checklistsRes.data?.forEach(c => { c.tasks?.forEach((t: any) => { todayTasksMap.set(t.id, { ...t, checklistTitle: c.title }); }); }); tasksRes.data?.forEach((t: any) => { if (t.checklists?.workspace_id === workspace.id) { todayTasksMap.set(t.id, { ...t, checklistTitle: t.checklists?.title }); } }); // Fetch overdue tasks: not completed, due_date is before today, in this workspace const { data: overdueTasksRaw, error: overdueError } = await supabase .from('tasks') .select('*, checklists(*)') .eq('user_id', user.id) .eq('completed', false) .lt('due_date', todayStr); const overdueTasks = overdueTasksRaw?.filter(t => t.checklists?.workspace_id === workspace.id) || []; const todayTasks = Array.from(todayTasksMap.values()); const total = todayTasks.length; const completed = todayTasks.filter(t => t.completed).length; const pending = total - completed; const pct = total > 0 ? Math.round((completed / total) * 100) : 0; console.log('\n' + chalk.bold.cyan(`=== VeloWork Today's Dashboard ===`)); console.log(`Workspace: ${chalk.bold.green(workspace.name)}`); console.log(`Date: ${chalk.bold(new Date().toDateString())}`); console.log('----------------------------------'); console.log(`Completion: ${pct}% (${completed}/${total} completed)`); console.log(`Pending: ${chalk.yellow(pending)} tasks`); console.log(`Overdue: ${overdueTasks.length > 0 ? chalk.bold.red(overdueTasks.length) : chalk.green('0')}`); console.log('----------------------------------'); // Display Today's tasks list console.log(`\n${chalk.bold('Today\'s Tasks:')}`); if (todayTasks.length === 0) { console.log(chalk.gray(' No tasks scheduled or due today.')); } else { todayTasks.forEach(t => { const symbol = t.completed ? chalk.green('✔') : chalk.yellow('☐'); const priorityStr = t.priority ? ` [${t.priority.toUpperCase()}]` : ''; const checklistStr = t.checklistTitle ? chalk.gray(` (in ${t.checklistTitle})`) : ''; console.log(` ${symbol} [${t.id.substring(0, 8)}] ${t.text}${priorityStr}${checklistStr}`); }); } // Display Overdue tasks list if (overdueTasks.length > 0) { console.log(`\n${chalk.bold.red('Overdue Tasks:')}`); overdueTasks.forEach(t => { const priorityStr = t.priority ? ` [${t.priority.toUpperCase()}]` : ''; console.log(` ${chalk.red('✕')} [${t.id.substring(0, 8)}] ${t.text}${priorityStr} ${chalk.gray(`(due ${t.due_date})`)}`); }); } console.log('\n'); } // Command: Productivity Heatmap export async function viewHeatmap(yearInput?: string) { const user = await getAuthenticatedUser(); const workspace = await getActiveWorkspace(user); if (!workspace) return; const currentYear = new Date().getFullYear(); const year = yearInput ? parseInt(yearInput) : currentYear; if (isNaN(year) || year < 2000 || year > 2100) { console.log(chalk.red('\nError: Invalid year specified. Please specify a year between 2000 and 2100.\n')); return; } const spinner = ora(`Fetching checklist completions for ${chalk.cyan(workspace.name)}...`).start(); const { data: checklists, error } = await supabase .from('checklists') .select('*, tasks(completed)') .eq('workspace_id', workspace.id) .eq('user_id', user.id); if (error) { spinner.fail(chalk.red(`Failed to fetch data: ${error.message}`)); return; } spinner.succeed(chalk.green(`Productivity Heatmap for ${chalk.bold(year)} (Workspace: ${chalk.bold(workspace.name)})`)); // Aggregate completion counts by date (YYYY-MM-DD) const completionsByDate: Record = {}; checklists?.forEach(c => { if (!c.date) return; const completedCount = c.tasks?.filter((t: any) => t.completed).length || 0; if (!completionsByDate[c.date]) { completionsByDate[c.date] = 0; } completionsByDate[c.date] += completedCount; }); const monthNames = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December' ]; // Helper to format a single month's lines const getMonthLines = (monthIndex: number): string[] => { const lines: string[] = []; // Month Name Header (centered in 20 chars) const monthHeader = `${monthNames[monthIndex]} ${year}`; const paddingLength = Math.max(0, 20 - monthHeader.length); const leftPadding = ' '.repeat(Math.floor(paddingLength / 2)); const rightPadding = ' '.repeat(Math.ceil(paddingLength / 2)); lines.push(chalk.bold.underline(leftPadding + monthHeader + rightPadding)); // Weekday Header lines.push(chalk.dim('Su Mo Tu We Th Fr Sa')); // Days Grid const firstDay = new Date(year, monthIndex, 1).getDay(); const totalDays = new Date(year, monthIndex + 1, 0).getDate(); const dayItems: string[] = []; // Empty padding for weekdays before the 1st for (let i = 0; i < firstDay; i++) { dayItems.push(' '); } // Days list for (let d = 1; d <= totalDays; d++) { const paddedDay = String(d).padStart(2, ' '); const monthStr = String(monthIndex + 1).padStart(2, '0'); const dayStr = String(d).padStart(2, '0'); const dateKey = `${year}-${monthStr}-${dayStr}`; const count = completionsByDate[dateKey] || 0; let coloredDay = paddedDay; if (count === 0) { coloredDay = chalk.gray(paddedDay); } else if (count <= 2) { coloredDay = chalk.green(paddedDay); } else if (count <= 5) { coloredDay = chalk.green.bold(paddedDay); } else { coloredDay = chalk.bgGreen.black.bold(paddedDay); } dayItems.push(coloredDay); } // Chunk dayItems into weeks of 7 const weeks: string[][] = []; for (let i = 0; i < dayItems.length; i += 7) { weeks.push(dayItems.slice(i, i + 7)); } while (weeks.length < 6) { weeks.push([]); } weeks.forEach(week => { if (week.length === 0) { lines.push(' '.repeat(20)); return; } const paddedWeek = [...week]; while (paddedWeek.length < 7) { paddedWeek.push(' '); } lines.push(paddedWeek.join(' ')); }); return lines; }; // Print months in rows of 3 console.log(''); for (let row = 0; row < 4; row++) { const m1 = row * 3; const m2 = m1 + 1; const m3 = m1 + 2; const lines1 = getMonthLines(m1); const lines2 = getMonthLines(m2); const lines3 = getMonthLines(m3); for (let i = 0; i < 8; i++) { console.log(`${lines1[i]} ${lines2[i]} ${lines3[i]}`); } console.log(''); } // Legend console.log(chalk.bold('Legend (completed tasks per day):')); console.log(` ${chalk.gray('00')} (Gray): 0 tasks`); console.log(` ${chalk.green('01')} (Green): 1-2 tasks`); console.log(` ${chalk.green.bold('01')} (Bold Green): 3-5 tasks`); console.log(` ${chalk.bgGreen.black.bold('01')} (Inverted Green): 6+ tasks\n`); }