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

63 KiB

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)

<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

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
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)
<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:

<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:

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:

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():

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:

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:

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:

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:

// 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:

#[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:

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:

<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:
// 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:

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:

<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:

#[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:

#[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
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:

<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):

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:

#[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:

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:

<!-- 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:

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:

#[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:
<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:

<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:

<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:

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:

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:

<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:

<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:

#[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):

<!-- 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:

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:

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:

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.