Files
zeroclock/docs/plans/2026-02-20-enhancement-round2-plan.md
Your Name 6ed462853c docs: enhancement round 2 implementation plan - 34 tasks
Phase 1: Toast auto-dismiss/undo, unified error handling, onboarding
resilience, invoice batch save, smart timer safety net.
Phase 2: Client cascade delete, entry templates, timesheet persistence,
global quick entry, receipt management.
Phase 3: Dashboard comparison, project health, heatmap, rounding
visibility, complete export with auto-backup.
2026-02-20 14:29:25 +02:00

1745 lines
63 KiB
Markdown

# 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
<button @click="toggle()" role="switch" :aria-checked="value" aria-label="Label"
:class="['relative inline-flex h-5 w-9 items-center rounded-full transition-colors duration-150',
value ? 'bg-status-running' : 'bg-bg-elevated']">
<span :class="['inline-block h-3.5 w-3.5 transform rounded-full bg-text-primary transition-transform duration-150',
value ? 'translate-x-[18px]' : 'translate-x-[3px]']" />
</button>
```
### 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<Toast['type'], number> = {
success: 4000,
error: 8000,
info: 6000,
}
export const useToastStore = defineStore('toast', () => {
const toasts = ref<Toast[]>([])
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
<script setup lang="ts">
import { useToastStore } from '../stores/toast'
import { Check, AlertCircle, Info, X, Undo2 } from 'lucide-vue-next'
const toastStore = useToastStore()
</script>
<template>
<div
role="region"
aria-label="Notifications"
aria-live="polite"
aria-atomic="false"
class="fixed top-4 left-1/2 -translate-x-1/2 z-[100] flex flex-col gap-2 pointer-events-none"
style="margin-left: 24px;"
>
<div
v-for="toast in toastStore.toasts"
:key="toast.id"
:role="toast.type === 'error' ? 'alert' : 'status'"
tabindex="0"
@keydown.escape="toastStore.removeToast(toast.id)"
@mouseenter="toastStore.pauseToast(toast.id)"
@mouseleave="toastStore.resumeToast(toast.id)"
@focusin="toastStore.pauseToast(toast.id)"
@focusout="toastStore.resumeToast(toast.id)"
class="w-80 flex items-center gap-3 px-4 py-3 bg-bg-surface border border-border-subtle rounded-lg shadow-lg pointer-events-auto border-l-[3px]"
:class="[
toast.exiting ? 'animate-toast-exit' : 'animate-toast-enter',
toast.type === 'success' ? 'border-l-status-running' : '',
toast.type === 'error' ? 'border-l-status-error' : '',
toast.type === 'info' ? 'border-l-accent' : ''
]"
>
<Check v-if="toast.type === 'success'" class="w-4 h-4 text-status-running shrink-0" aria-hidden="true" :stroke-width="2" />
<AlertCircle v-if="toast.type === 'error'" class="w-4 h-4 text-status-error shrink-0" aria-hidden="true" :stroke-width="2" />
<Info v-if="toast.type === 'info'" class="w-4 h-4 text-accent shrink-0" aria-hidden="true" :stroke-width="2" />
<span class="sr-only">{{ toast.type === 'success' ? 'Success:' : toast.type === 'error' ? 'Error:' : 'Info:' }}</span>
<span class="text-sm text-text-primary flex-1">{{ toast.message }}</span>
<button
v-if="toast.onUndo"
@click.stop="toastStore.undoToast(toast.id)"
class="shrink-0 px-2 py-0.5 text-[0.6875rem] font-medium text-accent-text bg-accent-muted rounded hover:bg-accent/20 transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
aria-label="Undo"
>
<span class="flex items-center gap-1">
<Undo2 class="w-3 h-3" aria-hidden="true" :stroke-width="2" />
Undo
</span>
</button>
<button
aria-label="Dismiss"
@click.stop="toastStore.removeToast(toast.id)"
class="shrink-0 p-1 text-text-tertiary hover:text-text-primary transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
<X class="w-3.5 h-3.5" aria-hidden="true" :stroke-width="2" />
</button>
</div>
</div>
</template>
```
**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 `</div>` of general tab at line 350). Add:
```html
<div class="border-t border-border-subtle mt-5 pt-5">
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">Persistent notifications</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Disable auto-dismiss for toast messages</p>
</div>
<button
@click="togglePersistentNotifications"
role="switch"
:aria-checked="persistentNotifications"
aria-label="Persistent notifications"
:class="[
'relative inline-flex h-5 w-9 items-center rounded-full transition-colors duration-150',
persistentNotifications ? 'bg-status-running' : 'bg-bg-elevated'
]"
>
<span
:class="[
'inline-block h-3.5 w-3.5 transform rounded-full bg-text-primary transition-transform duration-150',
persistentNotifications ? 'translate-x-[18px]' : 'translate-x-[3px]'
]"
/>
</button>
</div>
</div>
```
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<void>) {
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<any[]>('get_clients')
if (clients.length > 0) markComplete('create_client')
} catch { /* individual failure - continue */ }
try {
const projects = await invoke<any[]>('get_projects')
if (projects.length > 0) markComplete('create_project')
} catch { /* individual failure - continue */ }
try {
const entries = await invoke<any[]>('get_time_entries', { startDate: null, endDate: null })
if (entries.length > 0) markComplete('track_time')
} catch { /* individual failure - continue */ }
try {
const invoices = await invoke<any[]>('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<AppState>,
invoice_id: i64,
items: Vec<serde_json::Value>,
) -> Result<Vec<i64>, 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
<script setup lang="ts">
import { ref, computed, watch, nextTick, onUnmounted } from 'vue'
import { useFocusTrap } from '../utils/focusTrap'
import { useAnnouncer } from '../composables/useAnnouncer'
import AppSelect from './AppSelect.vue'
import { useProjectsStore } from '../stores/projects'
import { X } from 'lucide-vue-next'
const props = defineProps<{
show: boolean
elapsedSeconds: number
mode: 'no-project' | 'long-timer'
}>()
const emit = defineEmits<{
save: [projectId: number, description: string]
discard: []
cancel: []
}>()
const projectsStore = useProjectsStore()
const { activate: activateTrap, deactivate: deactivateTrap } = useFocusTrap()
const { announce } = useAnnouncer()
const dialogRef = ref<HTMLElement | null>(null)
const selectedProjectId = ref<number | null>(null)
const description = ref('')
const formattedDuration = computed(() => {
const h = Math.floor(props.elapsedSeconds / 3600)
const m = Math.floor((props.elapsedSeconds % 3600) / 60)
const parts: string[] = []
if (h > 0) parts.push(`${h} hour${h !== 1 ? 's' : ''}`)
if (m > 0) parts.push(`${m} minute${m !== 1 ? 's' : ''}`)
return parts.join(' and ') || 'less than a minute'
})
const spokenDuration = computed(() => formattedDuration.value)
watch(() => props.show, async (val) => {
if (val) {
selectedProjectId.value = null
description.value = ''
await nextTick()
if (dialogRef.value) {
activateTrap(dialogRef.value, { onDeactivate: () => emit('cancel') })
}
if (props.mode === 'no-project') {
announce(`Timer stopped. Select a project to save ${spokenDuration.value} of tracked time.`)
} else {
announce(`Timer has been running for ${spokenDuration.value}. Stop and save?`)
}
} else {
deactivateTrap()
}
})
onUnmounted(() => deactivateTrap())
function handleSave() {
if (!selectedProjectId.value) return
emit('save', selectedProjectId.value, description.value)
}
</script>
<template>
<Teleport to="body">
<Transition name="modal">
<div
v-if="show"
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
@click.self="$emit('cancel')"
>
<div
ref="dialogRef"
role="alertdialog"
aria-modal="true"
aria-labelledby="timer-save-title"
aria-describedby="timer-save-desc"
class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-md p-6"
>
<div class="flex items-center justify-between mb-4">
<h2 id="timer-save-title" class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary">
{{ mode === 'no-project' ? 'Save Entry' : 'Long Timer' }}
</h2>
<button
@click="$emit('cancel')"
class="p-1 text-text-tertiary hover:text-text-primary transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
aria-label="Close"
>
<X class="w-4 h-4" aria-hidden="true" />
</button>
</div>
<p id="timer-save-desc" class="text-[0.8125rem] text-text-secondary mb-4">
<template v-if="mode === 'no-project'">
No project was selected. Choose a project to save your tracked time.
</template>
<template v-else>
The timer has been running for a long time. Would you like to stop and save?
</template>
</p>
<div class="mb-4 px-3 py-2 bg-bg-inset rounded-lg">
<span class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em]">Duration</span>
<p class="text-[1.25rem] font-[family-name:var(--font-timer)] text-accent-text font-medium" :aria-label="spokenDuration">
{{ formattedDuration }}
</p>
</div>
<div v-if="mode === 'no-project'" class="space-y-3 mb-6">
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Project *</label>
<AppSelect
v-model="selectedProjectId"
:options="projectsStore.projects"
label-key="name"
value-key="id"
placeholder="Select project"
/>
</div>
<div>
<label for="timer-save-desc-input" class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Description</label>
<input
id="timer-save-desc-input"
v-model="description"
type="text"
class="w-full px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary focus:outline-none focus:border-border-visible"
placeholder="What did you work on?"
/>
</div>
</div>
<div class="flex justify-end gap-2">
<button
@click="$emit('discard')"
class="px-4 py-2 text-[0.8125rem] text-text-secondary border border-border-subtle rounded-lg hover:bg-bg-elevated transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
Discard
</button>
<button
v-if="mode === 'long-timer'"
@click="$emit('cancel')"
class="px-4 py-2 text-[0.8125rem] text-text-secondary border border-border-subtle rounded-lg hover:bg-bg-elevated transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
Keep Running
</button>
<button
@click="handleSave"
:disabled="mode === 'no-project' && !selectedProjectId"
class="px-4 py-2 text-[0.8125rem] bg-accent text-bg-base rounded-lg hover:bg-accent-hover transition-colors disabled:opacity-40 disabled:cursor-not-allowed focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
{{ mode === 'long-timer' ? 'Stop & Save' : 'Save' }}
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
```
**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
<TimerSaveDialog
:show="timerStore.showSaveDialog"
:elapsed-seconds="timerStore.pendingStopDuration"
:mode="timerStore.saveDialogMode"
@save="timerStore.handleSaveDialogSave"
@discard="timerStore.handleSaveDialogDiscard"
@cancel="timerStore.handleSaveDialogCancel"
/>
```
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<AppState>, client_id: i64) -> Result<serde_json::Value, String> {
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<AppState>, 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<i64> = {
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<any>(null)
const deleteImpacts = ref<Array<{ label: string; count: number }>>([])
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
<AppCascadeDeleteDialog
:show="showCascadeDelete"
entity-type="client"
:entity-name="deleteCandidate?.name || ''"
:impacts="deleteImpacts"
@confirm="executeDelete"
@cancel="showCascadeDelete = false"
/>
```
**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<AppState>) -> Result<Vec<serde_json::Value>, 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<i64>>(3)?,
"description": row.get::<_, Option<String>>(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<AppState>, template: serde_json::Value) -> Result<i64, String> {
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<AppState>, 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<EntryTemplate[]>([])
const loading = ref(false)
async function fetchTemplates() {
loading.value = true
try {
templates.value = await invoke<EntryTemplate[]>('get_entry_templates')
} catch (error) {
handleInvokeError(error, 'Failed to fetch entry templates')
} finally {
loading.value = false
}
}
async function createTemplate(template: Omit<EntryTemplate, 'id' | 'created_at'>): Promise<number | null> {
try {
const id = await invoke<number>('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
<!-- Divider -->
<div class="border-t border-border-subtle" />
<!-- Entry Templates -->
<h3 class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Entry Templates</h3>
<div v-if="entryTemplates.length > 0" class="space-y-2">
<div
v-for="tpl in entryTemplates"
:key="tpl.id"
class="flex items-center justify-between py-2 px-3 bg-bg-inset rounded-lg"
>
<div class="flex-1 min-w-0">
<p class="text-[0.8125rem] text-text-primary truncate">{{ tpl.name }}</p>
<p class="text-[0.6875rem] text-text-tertiary">
{{ getProjectName(tpl.project_id) }} - {{ formatDuration(tpl.duration) }}
</p>
</div>
<button
@click="deleteEntryTemplate(tpl.id!)"
class="text-text-tertiary hover:text-status-error transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
:aria-label="'Delete template ' + tpl.name"
>
<Trash2 class="w-3.5 h-3.5" />
</button>
</div>
</div>
<p v-else class="text-[0.75rem] text-text-tertiary">No saved templates</p>
```
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<AppState>, week_start: String) -> Result<Vec<serde_json::Value>, String> { ... }
#[tauri::command]
pub fn save_timesheet_rows(state: State<AppState>, week_start: String, rows: Vec<serde_json::Value>) -> Result<(), String> { ... }
#[tauri::command]
pub fn get_previous_week_structure(state: State<AppState>, current_week_start: String) -> Result<Vec<serde_json::Value>, 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
<div class="flex items-center gap-1 mt-1">
<span class="sr-only">Compared to last week:</span>
<ChevronUp v-if="weekDiff > 0" class="w-3 h-3 text-status-running" aria-hidden="true" />
<ChevronDown v-else-if="weekDiff < 0" class="w-3 h-3 text-status-error" aria-hidden="true" />
<span class="text-[0.625rem]"
:class="weekDiff >= 0 ? 'text-status-running' : 'text-status-error'">
{{ formatDuration(Math.abs(weekDiff)) }} {{ weekDiff >= 0 ? 'more' : 'less' }} than last week
</span>
</div>
```
- 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
<div v-if="attentionProjects.length > 0" role="region" aria-label="Projects needing attention" aria-live="polite" class="mb-6">
<h2 class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-3">Attention Needed</h2>
<!-- List at-risk and over-budget projects as clickable items -->
</div>
```
**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
<button
@click="activeTab = 'patterns'; if (!patternsLoaded) computePatterns()"
role="tab"
:aria-selected="activeTab === 'patterns'"
aria-controls="tabpanel-patterns"
id="tab-patterns"
:tabindex="activeTab === 'patterns' ? 0 : -1"
class="px-4 py-2 text-[0.75rem] font-medium rounded-lg transition-colors"
:class="activeTab === 'patterns' ? 'bg-accent text-bg-base' : 'text-text-secondary hover:text-text-primary'"
>
<span class="flex items-center gap-1.5">
<Grid3x3 class="w-3.5 h-3.5" :stroke-width="1.5" aria-hidden="true" />
Patterns
</span>
</button>
```
**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 `<table>` with proper `<th scope="col">` and `<th scope="row">`
- 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 `<td>`, after the duration display, add:
```html
<span
v-if="getRoundedDuration(entry.duration) !== null"
class="ml-1 text-[0.5625rem] text-text-tertiary"
:aria-label="'Duration rounded from ' + formatDuration(entry.duration) + ' to ' + formatDuration(getRoundedDuration(entry.duration)!)"
>
<Clock class="inline w-3 h-3 mr-0.5" aria-hidden="true" />Rounded
</span>
```
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
<div v-if="roundingImpact !== 0" class="flex items-center gap-4 text-[0.75rem] text-text-secondary mb-2">
<span>Rounding {{ roundingImpact > 0 ? 'added' : 'subtracted' }}:
<span class="font-mono text-text-primary">{{ formatHours(Math.abs(roundingImpact)) }}</span>
across {{ roundedEntryCount }} entries
</span>
</div>
```
**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<AppState>, backup_dir: String) -> Result<String, String> {
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 -->
<div v-if="lastExported" class="flex items-center justify-between" role="status">
<div>
<p class="text-[0.8125rem] text-text-primary">Last exported</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">{{ lastExportedFormatted }}</p>
</div>
</div>
<!-- Divider -->
<div class="border-t border-border-subtle" />
<!-- Auto-backup on close -->
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">Auto-backup on close</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Save a backup when the app closes</p>
</div>
<button @click="toggleAutoBackup" role="switch" :aria-checked="autoBackupEnabled" aria-label="Auto-backup on close"
:class="['relative inline-flex h-5 w-9 items-center rounded-full transition-colors duration-150',
autoBackupEnabled ? 'bg-status-running' : 'bg-bg-elevated']">
<span :class="['inline-block h-3.5 w-3.5 transform rounded-full bg-text-primary transition-transform duration-150',
autoBackupEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]']" />
</button>
</div>
<!-- Backup path -->
<div v-if="autoBackupEnabled" class="flex items-center justify-between pl-4 border-l-2 border-border-subtle ml-1">
<div class="flex-1 min-w-0">
<p class="text-[0.8125rem] text-text-primary">Backup directory</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5 truncate">{{ backupPath || 'Not set' }}</p>
</div>
<button @click="chooseBackupPath"
class="px-3 py-1.5 text-[0.75rem] border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
aria-label="Choose backup directory">
{{ backupPath ? 'Change' : 'Choose' }}
</button>
</div>
```
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.