diff --git a/docs/plans/2026-02-20-enhancement-round2-plan.md b/docs/plans/2026-02-20-enhancement-round2-plan.md new file mode 100644 index 0000000..f876545 --- /dev/null +++ b/docs/plans/2026-02-20-enhancement-round2-plan.md @@ -0,0 +1,1744 @@ +# 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.