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.
1745 lines
63 KiB
Markdown
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.
|