# Enhancement Round 2 Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Implement 15 feature enhancements across polish/reliability, power-user productivity, and data/insights - all WCAG 2.2 AAA compliant. **Architecture:** Vue 3 Composition API with Pinia stores, Tauri v2 Rust backend with SQLite (rusqlite), Tailwind CSS v4 design tokens. No test framework - verification via `npx vue-tsc --noEmit`, `npx vite build`, and `cargo build`. **Tech Stack:** Vue 3, TypeScript, Pinia, Tauri v2, Rust, SQLite, Tailwind CSS v4, Chart.js, lucide-vue-next icons. --- ## Context for the Implementer ### Design tokens All UI uses semantic tokens: `bg-bg-base`, `bg-bg-surface`, `bg-bg-elevated`, `bg-bg-inset`, `text-text-primary`, `text-text-secondary`, `text-text-tertiary`, `border-border-subtle`, `border-border-visible`, `text-accent-text`, `bg-accent`, `bg-accent-hover`, `bg-accent-muted`, `text-status-running`, `text-status-error`, `text-status-warning`. ### Typography pattern Labels: `text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em]` Section titles: `text-[0.8125rem] text-text-primary` Descriptions: `text-[0.6875rem] text-text-tertiary mt-0.5` Headings: `text-[1.25rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary` ### Settings pattern All settings stored as key-value strings via `settingsStore.updateSetting(key, value)`. Access via `settingsStore.settings.key_name`. ### Focus visible pattern All interactive elements: `focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent` ### Toggle switch pattern (used in Settings.vue) ```html ``` ### Dialog pattern Overlay: `fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50` Dialog body: `bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-xl p-6` Attributes: `role="dialog" aria-modal="true" aria-labelledby="dialog-title-id"` Import `useFocusTrap` from `../utils/focusTrap` - call `activate(el, { onDeactivate })` and `deactivate()`. ### Announcer pattern ```ts import { useAnnouncer } from '../composables/useAnnouncer' const { announce } = useAnnouncer() announce('Message for screen reader') ``` ### Backend command registration Add commands to `src-tauri/src/lib.rs` in the `generate_handler!` macro (lines 43-133). Add new function in `src-tauri/src/commands.rs`. Database migrations in `src-tauri/src/database.rs`. ### Verification commands After every task: `npx vue-tsc --noEmit && npx vite build`. After Rust changes: `cd src-tauri && cargo build`. --- ## Phase 1: Polish and Reliability (Features 1-5) --- ### Task 1: Toast auto-dismiss with undo - store changes **Files:** - Modify: `src/stores/toast.ts` **Step 1:** Read `src/stores/toast.ts` (42 lines). **Step 2:** Replace the entire file with the enhanced version. Changes: - Add `duration`, `onUndo`, `paused`, and `timerId` fields to `Toast` interface - Add auto-dismiss logic with configurable timeouts per type - Add `pauseToast(id)` and `resumeToast(id)` functions - Read persistent_notifications setting from settingsStore ```ts import { defineStore } from 'pinia' import { ref } from 'vue' export interface Toast { id: number message: string type: 'success' | 'error' | 'info' exiting?: boolean duration: number onUndo?: () => void paused?: boolean timerId?: number } const DURATIONS: Record = { success: 4000, error: 8000, info: 6000, } export const useToastStore = defineStore('toast', () => { const toasts = ref([]) let nextId = 0 let persistentNotifications = false function setPersistentNotifications(val: boolean) { persistentNotifications = val } function addToast(message: string, type: Toast['type'] = 'info', options?: { onUndo?: () => void; duration?: number }) { const id = nextId++ const duration = options?.duration ?? DURATIONS[type] const toast: Toast = { id, message, type, duration, onUndo: options?.onUndo } toasts.value.push(toast) if (!persistentNotifications) { startDismissTimer(toast) } // Cap at 5, oldest gets exiting state if (toasts.value.length > 5) { const oldest = toasts.value.find(t => !t.exiting) if (oldest) removeToast(oldest.id) } } function startDismissTimer(toast: Toast) { if (toast.timerId) clearTimeout(toast.timerId) toast.timerId = window.setTimeout(() => { removeToast(toast.id) }, toast.duration) } function pauseToast(id: number) { const toast = toasts.value.find(t => t.id === id) if (toast && toast.timerId) { clearTimeout(toast.timerId) toast.timerId = undefined toast.paused = true } } function resumeToast(id: number) { const toast = toasts.value.find(t => t.id === id) if (toast && toast.paused && !persistentNotifications) { toast.paused = false startDismissTimer(toast) } } function undoToast(id: number) { const toast = toasts.value.find(t => t.id === id) if (toast?.onUndo) { toast.onUndo() removeToast(id) } } function removeToast(id: number) { const toast = toasts.value.find(t => t.id === id) if (toast) { if (toast.timerId) clearTimeout(toast.timerId) toast.exiting = true setTimeout(() => { toasts.value = toasts.value.filter(t => t.id !== id) }, 150) } } function success(message: string, options?: { onUndo?: () => void }) { addToast(message, 'success', options) } function error(message: string, options?: { onUndo?: () => void }) { addToast(message, 'error', options) } function info(message: string, options?: { onUndo?: () => void }) { addToast(message, 'info', options) } return { toasts, addToast, removeToast, pauseToast, resumeToast, undoToast, success, error, info, setPersistentNotifications } }) ``` **Step 3:** Verify: `npx vue-tsc --noEmit` **Step 4:** Commit: `git add src/stores/toast.ts && git commit -m "feat: toast auto-dismiss with undo and pause support"` --- ### Task 2: Toast component - auto-dismiss UI, undo button, hover/focus pause **Files:** - Modify: `src/components/ToastNotification.vue` **Step 1:** Read `src/components/ToastNotification.vue` (46 lines). **Step 2:** Replace with enhanced version that adds: - `@mouseenter` / `@mouseleave` for hover pause/resume - `@focusin` / `@focusout` for keyboard focus pause/resume - Undo button when `toast.onUndo` exists - Progress bar showing remaining time (visual only, not critical info) ```vue ``` **Step 3:** Verify: `npx vue-tsc --noEmit` **Step 4:** Commit: `git add src/components/ToastNotification.vue && git commit -m "feat: toast undo button and hover/focus pause"` --- ### Task 3: Toast persistent notifications setting **Files:** - Modify: `src/views/Settings.vue` (General tab, after "Getting Started checklist" section around line 349) - Modify: `src/App.vue` (initialize persistent setting on mount) **Step 1:** Read `src/views/Settings.vue` lines 315-355 and `src/App.vue` lines 94-140. **Step 2:** In Settings.vue, add a `persistentNotifications` ref and toggle after the "Getting Started checklist" section (after line 349, before `` of general tab at line 350). Add: ```html

Persistent notifications

Disable auto-dismiss for toast messages

``` In the script section, add: ```ts const persistentNotifications = ref(false) async function togglePersistentNotifications() { persistentNotifications.value = !persistentNotifications.value await settingsStore.updateSetting('persistent_notifications', persistentNotifications.value ? 'true' : 'false') toastStore.setPersistentNotifications(persistentNotifications.value) } ``` And in `onMounted`, after settings fetch: ```ts persistentNotifications.value = settingsStore.settings.persistent_notifications === 'true' toastStore.setPersistentNotifications(persistentNotifications.value) ``` **Step 3:** In App.vue, add after line 136 (after audio engine setup), before `recurringStore.fetchEntries()`: ```ts import { useToastStore } from './stores/toast' const toastStore = useToastStore() toastStore.setPersistentNotifications(settingsStore.settings.persistent_notifications === 'true') ``` **Step 4:** Verify: `npx vue-tsc --noEmit && npx vite build` **Step 5:** Commit: `git add src/views/Settings.vue src/App.vue && git commit -m "feat: persistent notifications toggle in settings"` --- ### Task 4: Unified error handler utility **Files:** - Create: `src/utils/errorHandler.ts` **Step 1:** Create the file: ```ts import { useToastStore } from '../stores/toast' export function handleInvokeError(error: unknown, context: string, retryFn?: () => Promise) { const toastStore = useToastStore() const message = error instanceof Error ? error.message : String(error) console.error(`${context}:`, message) const isTransient = /database is locked|connection|busy|timeout|network/i.test(message) if (isTransient && retryFn) { toastStore.error(`${context}. Tap Retry to try again.`, { onUndo: async () => { try { await retryFn() toastStore.success('Operation completed successfully') } catch (retryError) { handleInvokeError(retryError, context) } }, }) } else { toastStore.error(context) } } ``` **Step 2:** Verify: `npx vue-tsc --noEmit` **Step 3:** Commit: `git add src/utils/errorHandler.ts && git commit -m "feat: unified error handler with retry for transient errors"` --- ### Task 5: Standardize error handling in entries store **Files:** - Modify: `src/stores/entries.ts` **Step 1:** Read `src/stores/entries.ts` (119 lines). **Step 2:** Add import at line 3: ```ts import { handleInvokeError } from '../utils/errorHandler' ``` **Step 3:** Replace each `console.error(...)` in catch blocks with `handleInvokeError()`: - `fetchEntriesPaginated` catch (line 34-35): Replace `console.error('Failed to fetch time entries:', error)` with `handleInvokeError(error, 'Failed to fetch time entries', () => fetchEntriesPaginated(startDate, endDate))` - `fetchMore` catch (line 52-53): Replace with `handleInvokeError(error, 'Failed to load more entries', () => fetchMore(startDate, endDate))` - `fetchEntries` catch (line 64-65): Replace with `handleInvokeError(error, 'Failed to fetch time entries', () => fetchEntries(startDate, endDate))` - `createEntry` catch (line 77-78): Replace `console.error` with `handleInvokeError(error, 'Failed to create time entry')` and keep the `throw error` - `updateEntry` catch (line 91-92): Replace with `handleInvokeError(error, 'Failed to update time entry')` and keep `throw error` - `deleteEntry` catch (line 102-103): Replace with `handleInvokeError(error, 'Failed to delete time entry')` and keep `throw error` **Step 4:** Verify: `npx vue-tsc --noEmit` **Step 5:** Commit: `git add src/stores/entries.ts && git commit -m "feat: use unified error handler in entries store"` --- ### Task 6: Standardize error handling in remaining stores **Files:** - Modify: all stores that use `console.error` in catch blocks **Step 1:** Search for all stores with `console.error` patterns: - `src/stores/timer.ts` - `src/stores/settings.ts` - `src/stores/invoices.ts` - `src/stores/projects.ts` (if it exists) - `src/stores/expenses.ts` (if it exists) - `src/stores/tags.ts` (if it exists) - `src/stores/recurring.ts` (if it exists) - `src/stores/onboarding.ts` **Step 2:** For each store, add `import { handleInvokeError } from '../utils/errorHandler'` and replace `console.error(...)` in catch blocks with `handleInvokeError(error, 'Context message', retryFn?)`. Use `handleInvokeError` for user-facing operations only - keep `console.error` for internal/background operations like `persistState()` in timer.ts, `detectCompletions()` in onboarding.ts, and other operations where showing a toast would be disruptive. **Step 3:** Verify: `npx vue-tsc --noEmit && npx vite build` **Step 4:** Commit: `git add src/stores/ && git commit -m "feat: standardize error handling across all stores"` --- ### Task 7: Onboarding detection resilience **Files:** - Modify: `src/stores/onboarding.ts` (lines 83-98, `detectCompletions` function) - Modify: `src/App.vue` (add periodic re-check) **Step 1:** Read `src/stores/onboarding.ts` lines 75-105. **Step 2:** Refactor `detectCompletions()` to wrap each invoke in its own try/catch: ```ts async function detectCompletions() { try { const clients = await invoke('get_clients') if (clients.length > 0) markComplete('create_client') } catch { /* individual failure - continue */ } try { const projects = await invoke('get_projects') if (projects.length > 0) markComplete('create_project') } catch { /* individual failure - continue */ } try { const entries = await invoke('get_time_entries', { startDate: null, endDate: null }) if (entries.length > 0) markComplete('track_time') } catch { /* individual failure - continue */ } try { const invoices = await invoke('get_invoices') if (invoices.length > 0) markComplete('create_invoice') } catch { /* individual failure - continue */ } } ``` **Step 3:** In `src/App.vue`, add after the recurring check interval (line 140), before calendar sync: ```ts // Periodic onboarding re-check const onboardingInterval = setInterval(() => onboardingStore.detectCompletions(), 5 * 60000) ``` Note: No cleanup needed since App.vue lives for the entire app lifetime. **Step 4:** Verify: `npx vue-tsc --noEmit` **Step 5:** Commit: `git add src/stores/onboarding.ts src/App.vue && git commit -m "fix: independent try/catch per onboarding detection call"` --- ### Task 8: Invoice items batch save - backend command **Files:** - Modify: `src-tauri/src/commands.rs` (add `save_invoice_items_batch` after existing invoice item functions around line 560) - Modify: `src-tauri/src/lib.rs` (register command) **Step 1:** Read `src-tauri/src/commands.rs` lines 519-570 to see existing invoice item functions. **Step 2:** Add the batch command after `delete_invoice_items`: ```rust #[tauri::command] pub fn save_invoice_items_batch( state: State, invoice_id: i64, items: Vec, ) -> Result, String> { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute("BEGIN TRANSACTION", []).map_err(|e| e.to_string())?; // Delete existing items first if let Err(e) = conn.execute("DELETE FROM invoice_items WHERE invoice_id = ?1", params![invoice_id]) { let _ = conn.execute("ROLLBACK", []); return Err(e.to_string()); } let mut ids = Vec::new(); for item in &items { let description = item.get("description").and_then(|v| v.as_str()).unwrap_or(""); let quantity = item.get("quantity").and_then(|v| v.as_f64()).unwrap_or(0.0); let unit_price = item.get("unit_price").and_then(|v| v.as_f64()).unwrap_or(0.0); let amount = quantity * unit_price; let time_entry_id = item.get("time_entry_id").and_then(|v| v.as_i64()); match conn.execute( "INSERT INTO invoice_items (invoice_id, description, quantity, rate, amount, time_entry_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", params![invoice_id, description, quantity, unit_price, amount, time_entry_id], ) { Ok(_) => ids.push(conn.last_insert_rowid()), Err(e) => { let _ = conn.execute("ROLLBACK", []); return Err(format!("Failed to save item: {}", e)); } } } conn.execute("COMMIT", []).map_err(|e| e.to_string())?; Ok(ids) } ``` **Step 3:** Register in `src-tauri/src/lib.rs` - add `commands::save_invoice_items_batch` in the `generate_handler!` macro near the other invoice commands (around line 68). **Step 4:** Verify: `cd src-tauri && cargo build` **Step 5:** Commit: `git add src-tauri/src/commands.rs src-tauri/src/lib.rs && git commit -m "feat: batch invoice items save with transaction"` --- ### Task 9: Invoice items batch save - frontend **Files:** - Modify: `src/stores/invoices.ts` (replace `saveInvoiceItems` loop with batch call) **Step 1:** Read `src/stores/invoices.ts` lines 110-130. **Step 2:** Replace the `saveInvoiceItems` function: ```ts async function saveInvoiceItems(invoiceId: number, items: Array<{ description: string; quantity: number; unit_price: number; time_entry_id?: number }>) { try { await invoke('save_invoice_items_batch', { invoiceId, items: items.map(item => ({ description: item.description, quantity: item.quantity, unit_price: item.unit_price, time_entry_id: item.time_entry_id || null, })), }) } catch (error) { handleInvokeError(error, 'Failed to save invoice items') throw error } } ``` Add import: `import { handleInvokeError } from '../utils/errorHandler'` **Step 3:** Verify: `npx vue-tsc --noEmit && npx vite build` **Step 4:** Commit: `git add src/stores/invoices.ts && git commit -m "feat: use batch save for invoice items"` --- ### Task 10: Smart timer safety net - dialog component **Files:** - Create: `src/components/TimerSaveDialog.vue` **Step 1:** Create the component: ```vue ``` **Step 2:** Verify: `npx vue-tsc --noEmit` **Step 3:** Commit: `git add src/components/TimerSaveDialog.vue && git commit -m "feat: timer save dialog for no-project and long-timer scenarios"` --- ### Task 11: Smart timer safety net - integration **Files:** - Modify: `src/stores/timer.ts` (stop method, lines 382-447) - Modify: `src/App.vue` (mount dialog) **Step 1:** Read `src/stores/timer.ts` lines 380-450. **Step 2:** In `timer.ts`, modify the `stop` function to emit an event instead of silently discarding when no project is selected: - Add a new ref: `showSaveDialog = ref(false)` and `saveDialogMode = ref<'no-project' | 'long-timer'>('no-project')` - Add `pendingStopDuration = ref(0)` to hold the duration for the dialog - Before the `if (selectedProjectId.value)` block (line 408), add the safety net check: ```ts // Safety net: if no project and there's tracked time, show save dialog if (!selectedProjectId.value && duration > 0) { pendingStopDuration.value = duration saveDialogMode.value = 'no-project' showSaveDialog.value = true // Don't reset state yet - dialog will handle it return } // Safety net: long timer confirmation (8+ hours) const LONG_TIMER_THRESHOLD = 8 * 3600 if (duration > LONG_TIMER_THRESHOLD && !options?.confirmed) { pendingStopDuration.value = duration saveDialogMode.value = 'long-timer' showSaveDialog.value = true return } ``` Add to the stop function signature: `options?: { subtractIdleTime?: boolean; confirmed?: boolean }` Add new functions: ```ts async function handleSaveDialogSave(projectId: number, desc: string) { showSaveDialog.value = false const oldProjectId = selectedProjectId.value const oldDescription = description.value selectedProjectId.value = projectId description.value = desc await stop({ confirmed: true }) selectedProjectId.value = oldProjectId description.value = oldDescription } function handleSaveDialogDiscard() { showSaveDialog.value = false // Reset without saving stopDisplayInterval() stopMonitorInterval() timerState.value = 'STOPPED' startTime.value = null pausedAt.value = null totalPausedMs.value = 0 idleStartedAt.value = null elapsedSeconds.value = 0 showIdlePrompt.value = false showAppPrompt.value = false emitTimerSync() announce('Timer stopped - entry discarded') audioEngine.play('timer_stop') const settingsStore = useSettingsStore() settingsStore.updateSetting('timer_state_backup', '') } function handleSaveDialogCancel() { showSaveDialog.value = false // Timer keeps running } ``` Export the new refs and functions. **Step 3:** In `src/App.vue`, add the TimerSaveDialog mount after the RecurringPromptDialog: ```html ``` Add import: `import TimerSaveDialog from './components/TimerSaveDialog.vue'` Add: `const timerStore = useTimerStore()` (may already be available - check line 100). **Step 4:** Verify: `npx vue-tsc --noEmit && npx vite build` **Step 5:** Commit: `git add src/stores/timer.ts src/App.vue && git commit -m "feat: smart timer safety net - save dialog on stop without project"` --- ### Task 12: Phase 1 verification **Step 1:** Run: `npx vue-tsc --noEmit` **Step 2:** Run: `npx vite build` **Step 3:** Run: `cd src-tauri && cargo build` **Step 4:** Commit if any fixes needed. --- ## Phase 2: Power User Productivity (Features 6-10) --- ### Task 13: Client cascade awareness - backend **Files:** - Modify: `src-tauri/src/commands.rs` (add `get_client_dependents`, update `delete_client`) - Modify: `src-tauri/src/lib.rs` (register command) **Step 1:** Read `src-tauri/src/commands.rs` lines 110-120 (existing `delete_client`). **Step 2:** Add `get_client_dependents` command: ```rust #[tauri::command] pub fn get_client_dependents(state: State, client_id: i64) -> Result { let conn = state.db.lock().map_err(|e| e.to_string())?; let project_count: i64 = conn.query_row( "SELECT COUNT(*) FROM projects WHERE client_id = ?1", params![client_id], |row| row.get(0) ).map_err(|e| e.to_string())?; let invoice_count: i64 = conn.query_row( "SELECT COUNT(*) FROM invoices WHERE client_id = ?1", params![client_id], |row| row.get(0) ).map_err(|e| e.to_string())?; let expense_count: i64 = conn.query_row( "SELECT COUNT(*) FROM expenses WHERE client_id = ?1", params![client_id], |row| row.get(0) ).map_err(|e| e.to_string())?; Ok(serde_json::json!({ "projects": project_count, "invoices": invoice_count, "expenses": expense_count })) } ``` **Step 3:** Update `delete_client` to cascade inside a transaction: ```rust #[tauri::command] pub fn delete_client(state: State, id: i64) -> Result<(), String> { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute("BEGIN TRANSACTION", []).map_err(|e| e.to_string())?; // Get all project IDs for this client let project_ids: Vec = { let mut stmt = conn.prepare("SELECT id FROM projects WHERE client_id = ?1").map_err(|e| e.to_string())?; let rows = stmt.query_map(params![id], |row| row.get(0)).map_err(|e| e.to_string())?; rows.filter_map(|r| r.ok()).collect() }; for pid in &project_ids { // Delete dependent data for each project let _ = conn.execute("DELETE FROM entry_tags WHERE entry_id IN (SELECT id FROM time_entries WHERE project_id = ?1)", params![pid]); let _ = conn.execute("DELETE FROM time_entries WHERE project_id = ?1", params![pid]); let _ = conn.execute("DELETE FROM tasks WHERE project_id = ?1", params![pid]); let _ = conn.execute("DELETE FROM tracked_apps WHERE project_id = ?1", params![pid]); let _ = conn.execute("DELETE FROM favorites WHERE project_id = ?1", params![pid]); let _ = conn.execute("DELETE FROM recurring_entries WHERE project_id = ?1", params![pid]); let _ = conn.execute("DELETE FROM timeline_events WHERE project_id = ?1", params![pid]); } let _ = conn.execute("DELETE FROM expenses WHERE client_id = ?1", params![id]); // Delete invoice items for this client's invoices let _ = conn.execute("DELETE FROM invoice_items WHERE invoice_id IN (SELECT id FROM invoices WHERE client_id = ?1)", params![id]); let _ = conn.execute("DELETE FROM invoices WHERE client_id = ?1", params![id]); let _ = conn.execute("DELETE FROM projects WHERE client_id = ?1", params![id]); match conn.execute("DELETE FROM clients WHERE id = ?1", params![id]) { Ok(_) => { conn.execute("COMMIT", []).map_err(|e| e.to_string())?; Ok(()) } Err(e) => { let _ = conn.execute("ROLLBACK", []); Err(e.to_string()) } } } ``` **Step 4:** Register `get_client_dependents` in `lib.rs` near other client commands. **Step 5:** Verify: `cd src-tauri && cargo build` **Step 6:** Commit: `git add src-tauri/src/commands.rs src-tauri/src/lib.rs && git commit -m "feat: client cascade delete with dependency counts"` --- ### Task 14: Client cascade awareness - frontend **Files:** - Modify: `src/views/Clients.vue` (wire AppCascadeDeleteDialog) **Step 1:** Read `src/views/Clients.vue` fully. Find the existing `confirmDelete` function and the delete dialog/flow. **Step 2:** Import `AppCascadeDeleteDialog` and wire it in. Follow the same pattern as Projects.vue. Add: - A `deleteCandidate` ref for the client being deleted - A `deleteImpacts` ref for the dependency counts - Call `invoke('get_client_dependents', { clientId })` before showing the dialog - Wire `AppCascadeDeleteDialog` with the impacts ```ts import AppCascadeDeleteDialog from '../components/AppCascadeDeleteDialog.vue' const deleteCandidate = ref(null) const deleteImpacts = ref>([]) const showCascadeDelete = ref(false) async function confirmDelete(client: any) { try { const deps = await invoke<{ projects: number; invoices: number; expenses: number }>('get_client_dependents', { clientId: client.id }) const impacts: Array<{ label: string; count: number }> = [] if (deps.projects > 0) impacts.push({ label: 'Projects', count: deps.projects }) if (deps.invoices > 0) impacts.push({ label: 'Invoices', count: deps.invoices }) if (deps.expenses > 0) impacts.push({ label: 'Expenses', count: deps.expenses }) deleteCandidate.value = client deleteImpacts.value = impacts showCascadeDelete.value = true } catch (error) { handleInvokeError(error, 'Failed to check client dependencies') } } async function executeDelete() { if (!deleteCandidate.value) return try { await invoke('delete_client', { id: deleteCandidate.value.id }) // Remove from local state // ... (depends on existing client store pattern) toastStore.success('Client deleted') } catch (error) { handleInvokeError(error, 'Failed to delete client') } finally { showCascadeDelete.value = false deleteCandidate.value = null } } ``` Add in template: ```html ``` **Step 3:** Verify: `npx vue-tsc --noEmit && npx vite build` **Step 4:** Commit: `git add src/views/Clients.vue && git commit -m "feat: cascade delete dialog for clients with dependency counts"` --- ### Task 15: Entry templates - backend **Files:** - Modify: `src-tauri/src/database.rs` (add `entry_templates` table) - Modify: `src-tauri/src/commands.rs` (add CRUD commands) - Modify: `src-tauri/src/lib.rs` (register commands) **Step 1:** Read `src-tauri/src/database.rs` to find where to add the migration. **Step 2:** Add table creation after the last CREATE TABLE (before settings seed): ```rust conn.execute( "CREATE TABLE IF NOT EXISTS entry_templates ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, project_id INTEGER NOT NULL REFERENCES projects(id), task_id INTEGER REFERENCES tasks(id), description TEXT, duration INTEGER NOT NULL DEFAULT 0, billable INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT (datetime('now')) )", [], )?; ``` **Step 3:** Add CRUD commands in `commands.rs`: ```rust #[tauri::command] pub fn get_entry_templates(state: State) -> Result, String> { let conn = state.db.lock().map_err(|e| e.to_string())?; let mut stmt = conn.prepare("SELECT id, name, project_id, task_id, description, duration, billable, created_at FROM entry_templates ORDER BY name").map_err(|e| e.to_string())?; let rows = stmt.query_map([], |row| { Ok(serde_json::json!({ "id": row.get::<_, i64>(0)?, "name": row.get::<_, String>(1)?, "project_id": row.get::<_, i64>(2)?, "task_id": row.get::<_, Option>(3)?, "description": row.get::<_, Option>(4)?, "duration": row.get::<_, i64>(5)?, "billable": row.get::<_, i64>(6)?, "created_at": row.get::<_, String>(7)?, })) }).map_err(|e| e.to_string())?; Ok(rows.filter_map(|r| r.ok()).collect()) } #[tauri::command] pub fn create_entry_template(state: State, template: serde_json::Value) -> Result { let conn = state.db.lock().map_err(|e| e.to_string())?; let name = template.get("name").and_then(|v| v.as_str()).unwrap_or("Untitled"); let project_id = template.get("project_id").and_then(|v| v.as_i64()).ok_or("project_id required")?; let task_id = template.get("task_id").and_then(|v| v.as_i64()); let description = template.get("description").and_then(|v| v.as_str()); let duration = template.get("duration").and_then(|v| v.as_i64()).unwrap_or(0); let billable = template.get("billable").and_then(|v| v.as_i64()).unwrap_or(1); conn.execute( "INSERT INTO entry_templates (name, project_id, task_id, description, duration, billable) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", params![name, project_id, task_id, description, duration, billable], ).map_err(|e| e.to_string())?; Ok(conn.last_insert_rowid()) } #[tauri::command] pub fn delete_entry_template(state: State, id: i64) -> Result<(), String> { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute("DELETE FROM entry_templates WHERE id = ?1", params![id]).map_err(|e| e.to_string())?; Ok(()) } ``` **Step 4:** Register all three commands in `lib.rs`. **Step 5:** Verify: `cd src-tauri && cargo build` **Step 6:** Commit: `git add src-tauri/ && git commit -m "feat: entry templates CRUD backend"` --- ### Task 16: Entry templates - store **Files:** - Create: `src/stores/entryTemplates.ts` **Step 1:** Create the store: ```ts import { defineStore } from 'pinia' import { ref } from 'vue' import { invoke } from '@tauri-apps/api/core' import { handleInvokeError } from '../utils/errorHandler' export interface EntryTemplate { id?: number name: string project_id: number task_id?: number description?: string duration: number billable: number created_at?: string } export const useEntryTemplatesStore = defineStore('entryTemplates', () => { const templates = ref([]) const loading = ref(false) async function fetchTemplates() { loading.value = true try { templates.value = await invoke('get_entry_templates') } catch (error) { handleInvokeError(error, 'Failed to fetch entry templates') } finally { loading.value = false } } async function createTemplate(template: Omit): Promise { try { const id = await invoke('create_entry_template', { template }) templates.value.push({ ...template, id: Number(id) }) return Number(id) } catch (error) { handleInvokeError(error, 'Failed to create template') return null } } async function deleteTemplate(id: number) { try { await invoke('delete_entry_template', { id }) templates.value = templates.value.filter(t => t.id !== id) } catch (error) { handleInvokeError(error, 'Failed to delete template') } } return { templates, loading, fetchTemplates, createTemplate, deleteTemplate } }) ``` **Step 2:** Verify: `npx vue-tsc --noEmit` **Step 3:** Commit: `git add src/stores/entryTemplates.ts && git commit -m "feat: entry templates pinia store"` --- ### Task 17: Entry templates - picker dialog and Entries.vue integration **Files:** - Create: `src/components/EntryTemplatePicker.vue` - Modify: `src/views/Entries.vue` (add "From Template" button, "Save as Template" in edit dialog, duplicate button per row) **Step 1:** Create the picker dialog. This is a listbox dialog showing all saved templates. Clicking one emits the template data. Uses focus trap, keyboard navigation (ArrowUp/Down, Enter to select), and Escape to close. **Step 2:** In Entries.vue, add: - A "From Template" button in the filters bar (near the "Copy Yesterday" / "Copy Last Week" buttons) - When clicked, opens EntryTemplatePicker dialog - On template selection, pre-fills the edit dialog in create mode - A "Save as Template" button in the edit dialog (visible when editing an existing entry) - The existing duplicate button on entry rows (already present per lines 181-185) should have its `aria-label` enhanced to include project name and date **Step 3:** Verify: `npx vue-tsc --noEmit && npx vite build` **Step 4:** Commit: `git add src/components/EntryTemplatePicker.vue src/views/Entries.vue && git commit -m "feat: entry template picker and save-as-template in entries view"` --- ### Task 18: Entry templates - Settings management **Files:** - Modify: `src/views/Settings.vue` (Timer tab, add template management section) **Step 1:** Read Settings.vue Timer tab. Find where to insert (after Recurring Entries section, before the end of Timer tab panel, around line 775). **Step 2:** Add a "Saved Templates" section: ```html

Entry Templates

{{ tpl.name }}

{{ getProjectName(tpl.project_id) }} - {{ formatDuration(tpl.duration) }}

No saved templates

``` Add script setup code for fetching and deleting templates via `useEntryTemplatesStore`. **Step 3:** Verify: `npx vue-tsc --noEmit` **Step 4:** Commit: `git add src/views/Settings.vue && git commit -m "feat: entry template management in settings"` --- ### Task 19: Timesheet smart row persistence - backend **Files:** - Modify: `src-tauri/src/database.rs` (add `timesheet_rows` table) - Modify: `src-tauri/src/commands.rs` (add 3 commands) - Modify: `src-tauri/src/lib.rs` (register) **Step 1:** Add table: ```rust conn.execute( "CREATE TABLE IF NOT EXISTS timesheet_rows ( id INTEGER PRIMARY KEY AUTOINCREMENT, week_start TEXT NOT NULL, project_id INTEGER NOT NULL REFERENCES projects(id), task_id INTEGER REFERENCES tasks(id), sort_order INTEGER NOT NULL DEFAULT 0 )", [], )?; ``` **Step 2:** Add commands: ```rust #[tauri::command] pub fn get_timesheet_rows(state: State, week_start: String) -> Result, String> { ... } #[tauri::command] pub fn save_timesheet_rows(state: State, week_start: String, rows: Vec) -> Result<(), String> { ... } #[tauri::command] pub fn get_previous_week_structure(state: State, current_week_start: String) -> Result, String> { // Calculate previous Monday (current - 7 days) // Return rows from that week } ``` **Step 3:** Register in `lib.rs`. **Step 4:** Verify: `cd src-tauri && cargo build` **Step 5:** Commit: `git add src-tauri/ && git commit -m "feat: timesheet row persistence backend"` --- ### Task 20: Timesheet smart row persistence - frontend **Files:** - Modify: `src/views/TimesheetView.vue` **Step 1:** Read `TimesheetView.vue` fully. **Step 2:** Add: - On week navigation, call `get_timesheet_rows` for the new week - If no rows exist, auto-populate from previous week structure via `get_previous_week_structure` - When user adds/removes rows, call `save_timesheet_rows` to persist - Add "Copy Last Week" button that copies both structure and values - Confirmation dialog before overwriting existing data **Step 3:** Verify: `npx vue-tsc --noEmit && npx vite build` **Step 4:** Commit: `git add src/views/TimesheetView.vue && git commit -m "feat: timesheet row persistence and copy last week"` --- ### Task 21: Global quick entry - dialog component **Files:** - Create: `src/components/QuickEntryDialog.vue` **Step 1:** Create the dialog with: - Project picker (AppSelect) - Task picker filtered by project - Description input - Date picker (defaults to today) - Duration input (supports H:MM format) - Tag picker (AppTagInput) - Save and Cancel buttons - Focus trap, `role="dialog"`, `aria-modal="true"`, Escape closes - Pre-fill with last-used project from settings This component follows the same dialog pattern as the edit entry dialog in Entries.vue. **Step 2:** Verify: `npx vue-tsc --noEmit` **Step 3:** Commit: `git add src/components/QuickEntryDialog.vue && git commit -m "feat: global quick entry dialog component"` --- ### Task 22: Global quick entry - shortcut integration **Files:** - Modify: `src/App.vue` (mount dialog, register shortcut) - Modify: `src/views/Settings.vue` (add shortcut recorder for quick entry) **Step 1:** In App.vue: - Import and mount QuickEntryDialog - Add a `showQuickEntry` ref - In `registerShortcuts()`, add a third shortcut for the configurable quick entry key - When shortcut fires, set `showQuickEntry = true` **Step 2:** In Settings.vue Timer tab, add a shortcut recorder for "Quick Entry" after the existing keyboard shortcuts section (after line 544). **Step 3:** Verify: `npx vue-tsc --noEmit && npx vite build` **Step 4:** Commit: `git add src/App.vue src/views/Settings.vue && git commit -m "feat: global shortcut for quick entry dialog"` --- ### Task 23: Expenses receipt management - lightbox component **Files:** - Create: `src/components/ReceiptLightbox.vue` **Step 1:** Create a lightbox component for full-size receipt viewing: - `role="dialog"`, `aria-modal="true"`, `aria-label="Receipt image for [description]"` - Focus trap with Escape to close - Image display with zoom controls (+/- buttons, keyboard-accessible) - `alt` text: "Receipt for [category] expense on [date], [amount]" - Close button with keyboard focus **Step 2:** Verify: `npx vue-tsc --noEmit` **Step 3:** Commit: `git add src/components/ReceiptLightbox.vue && git commit -m "feat: receipt lightbox component"` --- ### Task 24: Expenses receipt management - Entries.vue integration **Files:** - Modify: `src/views/Entries.vue` (expense tab) **Step 1:** Read Entries.vue expense table section (lines 332-416). **Step 2:** Add to each expense row: - Receipt thumbnail (small image) if `receipt_path` exists - "No receipt" indicator (icon + text) if no `receipt_path` - Click thumbnail to open ReceiptLightbox - In the expense edit dialog, add a file picker button using `@tauri-apps/plugin-dialog` open() - Add a drop zone div with `@dragover`, `@drop` handlers and keyboard activation (Enter/Space opens file picker) - Drop zone: `role="button"`, `aria-label="Drop receipt file here or press Enter to browse"` **Step 3:** Verify: `npx vue-tsc --noEmit && npx vite build` **Step 4:** Commit: `git add src/views/Entries.vue && git commit -m "feat: receipt thumbnails, lightbox, and file picker for expenses"` --- ### Task 25: Phase 2 verification **Step 1:** Run: `npx vue-tsc --noEmit` **Step 2:** Run: `npx vite build` **Step 3:** Run: `cd src-tauri && cargo build` **Step 4:** Commit if any fixes needed. --- ## Phase 3: Data and Insights (Features 11-15) --- ### Task 26: Dashboard weekly comparison **Files:** - Modify: `src/views/Dashboard.vue` **Step 1:** Read Dashboard.vue fully (especially the stats dl section lines 45-62 and onMounted). **Step 2:** Add: - `lastWeekStats` ref fetched via `invoke('get_reports', { startDate: lastWeekStart, endDate: lastWeekEnd })` - For each stat card (Today, This Week, This Month), add a comparison indicator: ```html
Compared to last week:
``` - Add sparkline section below weekly chart: 4 mini bar groups (last 4 weeks), each showing 7 bars. Use `role="img"` wrapper with `aria-label` summarizing all 4 weeks' totals. **Step 3:** Verify: `npx vue-tsc --noEmit && npx vite build` **Step 4:** Commit: `git add src/views/Dashboard.vue && git commit -m "feat: weekly comparison indicators and sparklines on dashboard"` --- ### Task 27: Project health dashboard cards **Files:** - Modify: `src/views/Projects.vue` **Step 1:** Read Projects.vue fully. **Step 2:** Add health badge computation using existing `budgetStatus`: - "On Track": checkmark icon + "On Track" text (green icon) - "At Risk": warning triangle icon + "At Risk" text (yellow icon) - "Over Budget": x-circle icon + "Over Budget" text (red icon) - Logic: percent > 100 = over budget, pace === "behind" && percent > 75 = at risk, else on track - Each uses icon + text label (never color alone) - Badge: `role="status"`, icons `aria-hidden="true"` Add "Attention Needed" section at top of project grid: ```html

Attention Needed

``` **Step 3:** Verify: `npx vue-tsc --noEmit && npx vite build` **Step 4:** Commit: `git add src/views/Projects.vue && git commit -m "feat: project health badges and attention section"` --- ### Task 28: Reports time-of-day heatmap - Patterns tab **Files:** - Modify: `src/views/Reports.vue` **Step 1:** Read Reports.vue tab section (lines 52-98). **Step 2:** Add a "Patterns" tab button after the Expenses tab: ```html ``` **Step 3:** Add the Patterns tab panel: - 7x24 grid (days x hours): `role="grid"` with `role="row"` and `role="gridcell"` - Each cell shows numeric value (e.g., "2.5h") and uses background color intensity - Cells with 0 hours are empty - Arrow key navigation between cells - Each cell: `aria-label="Monday 9 AM: 2.5 hours"` - "Data Table" toggle button that switches to a standard `` with proper `
` and `` - Summary stats: "Most productive: Monday 9-10 AM", "Quietest: Sunday" Data computation: ```ts function computePatterns() { const grid: number[][] = Array.from({ length: 7 }, () => Array(24).fill(0)) for (const entry of entriesStore.entries) { const d = new Date(entry.start_time) const day = d.getDay() === 0 ? 6 : d.getDay() - 1 // Mon=0, Sun=6 const hour = d.getHours() grid[day][hour] += entry.duration / 3600 } heatmapData.value = grid patternsLoaded = true } ``` **Step 4:** Verify: `npx vue-tsc --noEmit && npx vite build` **Step 5:** Commit: `git add src/views/Reports.vue && git commit -m "feat: time-of-day heatmap in reports patterns tab"` --- ### Task 29: Rounding visibility - Entries.vue **Files:** - Modify: `src/views/Entries.vue` **Step 1:** Read the duration column in the entries table (around line 161-169). **Step 2:** Import `roundDuration` from `../utils/rounding` and settings store. For each entry, compute if rounding changes the value: ```ts import { roundDuration } from '../utils/rounding' function getRoundedDuration(seconds: number): number | null { if (settingsStore.settings.rounding_enabled !== 'true') return null const increment = parseInt(settingsStore.settings.rounding_increment) || 0 const method = (settingsStore.settings.rounding_method || 'nearest') as 'nearest' | 'up' | 'down' if (increment <= 0) return null const rounded = roundDuration(seconds, increment, method) return rounded !== seconds ? rounded : null } ``` In the duration ``, after the duration display, add: ```html ``` Add a tooltip mechanism using `title` attribute or a custom tooltip with `role="tooltip"` and `aria-describedby` for the actual vs rounded values. **Step 3:** Verify: `npx vue-tsc --noEmit` **Step 4:** Commit: `git add src/views/Entries.vue && git commit -m "feat: rounding visibility indicators on entry rows"` --- ### Task 30: Rounding visibility - Invoices and Reports **Files:** - Modify: `src/views/Invoices.vue` (show actual vs rounded on line items) - Modify: `src/views/Reports.vue` (add rounding impact summary in Hours tab) **Step 1:** In Invoices.vue, where invoice line items are displayed, show both actual and rounded hours when rounding is active. Add a small "+Xm" or "-Xm" text label. **Step 2:** In Reports.vue Hours tab, after the billable split line (around line 124), add a rounding impact summary: ```html
Rounding {{ roundingImpact > 0 ? 'added' : 'subtracted' }}: {{ formatHours(Math.abs(roundingImpact)) }} across {{ roundedEntryCount }} entries
``` **Step 3:** Verify: `npx vue-tsc --noEmit && npx vite build` **Step 4:** Commit: `git add src/views/Invoices.vue src/views/Reports.vue && git commit -m "feat: rounding visibility in invoices and reports"` --- ### Task 31: Export completeness - backend **Files:** - Modify: `src-tauri/src/commands.rs` (expand `export_data`, update `import_json_data`, add `auto_backup`) - Modify: `src-tauri/src/lib.rs` (register) **Step 1:** Read `export_data` (lines 586-669) and `import_json_data` (lines 1501-1609). **Step 2:** Expand `export_data` to include ALL tables: - Add: tasks, tags, entry_tags, tracked_apps, favorites, recurring_entries, expenses, timeline_events, calendar_sources, calendar_events, timesheet_locks, invoice_items, settings, entry_templates, timesheet_rows **Step 3:** Update `import_json_data` to handle expanded format. Import new tables if present in the JSON, skip if not (backward compatible). **Step 4:** Add `auto_backup` command: ```rust #[tauri::command] pub fn auto_backup(state: State, backup_dir: String) -> Result { let data = export_data(state)?; let today = chrono::Local::now().format("%Y-%m-%d").to_string(); let filename = format!("zeroclock-backup-{}.json", today); let path = std::path::Path::new(&backup_dir).join(&filename); let json = serde_json::to_string_pretty(&data).map_err(|e| e.to_string())?; std::fs::write(&path, json).map_err(|e| e.to_string())?; Ok(path.to_string_lossy().to_string()) } ``` Note: You may need to add `chrono` to Cargo.toml dependencies if not already present, or use a simpler date formatting approach. **Step 5:** Register `auto_backup` in `lib.rs`. **Step 6:** Verify: `cd src-tauri && cargo build` **Step 7:** Commit: `git add src-tauri/ && git commit -m "feat: comprehensive export with all tables and auto-backup command"` --- ### Task 32: Export completeness - Settings UI **Files:** - Modify: `src/views/Settings.vue` (Data tab, lines 1114-1200) - Modify: `src/App.vue` (hook into window close event) **Step 1:** In Settings.vue Data tab, add after the Export section (line 1131): ```html

Last exported

{{ lastExportedFormatted }}

Auto-backup on close

Save a backup when the app closes

Backup directory

{{ backupPath || 'Not set' }}

``` Script additions: ```ts const autoBackupEnabled = ref(false) const backupPath = ref('') const lastExported = ref('') // On mount: autoBackupEnabled.value = settingsStore.settings.auto_backup === 'true' backupPath.value = settingsStore.settings.backup_path || '' lastExported.value = settingsStore.settings.last_exported || '' async function toggleAutoBackup() { autoBackupEnabled.value = !autoBackupEnabled.value await settingsStore.updateSetting('auto_backup', autoBackupEnabled.value ? 'true' : 'false') } async function chooseBackupPath() { const { open } = await import('@tauri-apps/plugin-dialog') const selected = await open({ directory: true, title: 'Choose backup directory' }) if (selected && typeof selected === 'string') { backupPath.value = selected await settingsStore.updateSetting('backup_path', selected) } } ``` Update `exportData` to also set `last_exported`: ```ts async function exportData() { // ... existing export logic ... const now = new Date().toISOString() await settingsStore.updateSetting('last_exported', now) lastExported.value = now } ``` **Step 2:** In App.vue, hook into window close event for auto-backup. Add in `onMounted`: ```ts const { getCurrentWindow } = await import('@tauri-apps/api/window') const win = getCurrentWindow() win.onCloseRequested(async () => { if (settingsStore.settings.auto_backup === 'true' && settingsStore.settings.backup_path) { try { await invoke('auto_backup', { backupDir: settingsStore.settings.backup_path }) } catch (e) { console.error('Auto-backup failed:', e) } } }) ``` **Step 3:** Verify: `npx vue-tsc --noEmit && npx vite build` **Step 4:** Commit: `git add src/views/Settings.vue src/App.vue && git commit -m "feat: auto-backup UI and window close hook"` --- ### Task 33: Phase 3 verification **Step 1:** Run: `npx vue-tsc --noEmit` **Step 2:** Run: `npx vite build` **Step 3:** Run: `cd src-tauri && cargo build` **Step 4:** Commit if any fixes needed. --- ### Task 34: Final verification **Step 1:** Run all three verification commands: ``` npx vue-tsc --noEmit && npx vite build && cd src-tauri && cargo build ``` **Step 2:** Ensure no regressions. Fix any type errors or build failures. **Step 3:** Final commit if any cleanup needed.