From 35e97cbe7bfc03d3f6dfcaae9822588c3cec14a2 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 20 Feb 2026 14:46:56 +0200 Subject: [PATCH] feat: standardize error handling across all stores --- src/stores/clients.ts | 9 +- src/stores/expenses.ts | 123 +++++++++++++++++++++ src/stores/favorites.ts | 9 +- src/stores/invoices.ts | 42 ++++++-- src/stores/projects.ts | 30 ++++-- src/stores/recurring.ts | 229 ++++++++++++++++++++++++++++++++++++++++ src/stores/settings.ts | 38 +++++++ src/stores/tags.ts | 13 +-- 8 files changed, 463 insertions(+), 30 deletions(-) create mode 100644 src/stores/expenses.ts create mode 100644 src/stores/recurring.ts create mode 100644 src/stores/settings.ts diff --git a/src/stores/clients.ts b/src/stores/clients.ts index 508b551..9ba3614 100644 --- a/src/stores/clients.ts +++ b/src/stores/clients.ts @@ -1,6 +1,7 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import { invoke } from '@tauri-apps/api/core' +import { handleInvokeError } from '../utils/errorHandler' export interface Client { id?: number @@ -23,7 +24,7 @@ export const useClientsStore = defineStore('clients', () => { try { clients.value = await invoke('get_clients') } catch (error) { - console.error('Failed to fetch clients:', error) + handleInvokeError(error, 'Failed to fetch clients', () => fetchClients()) } finally { loading.value = false } @@ -35,7 +36,7 @@ export const useClientsStore = defineStore('clients', () => { clients.value.push({ ...client, id: Number(id) }) return Number(id) } catch (error) { - console.error('Failed to create client:', error) + handleInvokeError(error, 'Failed to create client') return null } } @@ -49,7 +50,7 @@ export const useClientsStore = defineStore('clients', () => { } return true } catch (error) { - console.error('Failed to update client:', error) + handleInvokeError(error, 'Failed to update client') return false } } @@ -60,7 +61,7 @@ export const useClientsStore = defineStore('clients', () => { clients.value = clients.value.filter(c => c.id !== id) return true } catch (error) { - console.error('Failed to delete client:', error) + handleInvokeError(error, 'Failed to delete client') return false } } diff --git a/src/stores/expenses.ts b/src/stores/expenses.ts new file mode 100644 index 0000000..9daee16 --- /dev/null +++ b/src/stores/expenses.ts @@ -0,0 +1,123 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { invoke } from '@tauri-apps/api/core' +import { handleInvokeError } from '../utils/errorHandler' + +export interface Expense { + id?: number + project_id: number + client_id?: number + category: string + description?: string + amount: number + date: string + receipt_path?: string + invoiced?: number +} + +export const EXPENSE_CATEGORIES = [ + { label: 'Materials', value: 'materials' }, + { label: 'Software', value: 'software' }, + { label: 'Travel', value: 'travel' }, + { label: 'Meals', value: 'meals' }, + { label: 'Equipment', value: 'equipment' }, + { label: 'Subcontractor', value: 'subcontractor' }, + { label: 'Communication', value: 'communication' }, + { label: 'Office', value: 'office' }, + { label: 'Other', value: 'other' }, +] + +export const useExpensesStore = defineStore('expenses', () => { + const expenses = ref([]) + const loading = ref(false) + + async function fetchExpenses(projectId?: number, startDate?: string, endDate?: string) { + loading.value = true + try { + expenses.value = await invoke('get_expenses', { + projectId: projectId || null, + startDate: startDate || null, + endDate: endDate || null + }) + } catch (error) { + handleInvokeError(error, 'Failed to fetch expenses', () => fetchExpenses(projectId, startDate, endDate)) + } finally { + loading.value = false + } + } + + async function createExpense(expense: Expense): Promise { + try { + const id = await invoke('create_expense', { expense }) + expenses.value.unshift({ ...expense, id: Number(id) }) + return Number(id) + } catch (error) { + handleInvokeError(error, 'Failed to create expense') + return null + } + } + + async function updateExpense(expense: Expense): Promise { + try { + await invoke('update_expense', { expense }) + const index = expenses.value.findIndex(e => e.id === expense.id) + if (index !== -1) { + expenses.value[index] = expense + } + return true + } catch (error) { + handleInvokeError(error, 'Failed to update expense') + return false + } + } + + async function deleteExpense(id: number): Promise { + try { + await invoke('delete_expense', { id }) + expenses.value = expenses.value.filter(e => e.id !== id) + return true + } catch (error) { + handleInvokeError(error, 'Failed to delete expense') + return false + } + } + + async function fetchUninvoiced(projectId?: number, clientId?: number): Promise { + try { + return await invoke('get_uninvoiced_expenses', { + projectId: projectId || null, + clientId: clientId || null + }) + } catch (error) { + handleInvokeError(error, 'Failed to fetch uninvoiced expenses') + return [] + } + } + + async function markInvoiced(ids: number[]): Promise { + try { + await invoke('mark_expenses_invoiced', { ids }) + for (const id of ids) { + const index = expenses.value.findIndex(e => e.id === id) + if (index !== -1) { + expenses.value[index].invoiced = 1 + } + } + return true + } catch (error) { + handleInvokeError(error, 'Failed to mark expenses as invoiced') + return false + } + } + + return { + expenses, + loading, + fetchExpenses, + createExpense, + updateExpense, + deleteExpense, + fetchUninvoiced, + markInvoiced + } +}) diff --git a/src/stores/favorites.ts b/src/stores/favorites.ts index 22fecee..4860d2a 100644 --- a/src/stores/favorites.ts +++ b/src/stores/favorites.ts @@ -1,6 +1,7 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import { invoke } from '@tauri-apps/api/core' +import { handleInvokeError } from '../utils/errorHandler' export interface Favorite { id?: number @@ -17,7 +18,7 @@ export const useFavoritesStore = defineStore('favorites', () => { try { favorites.value = await invoke('get_favorites') } catch (error) { - console.error('Failed to fetch favorites:', error) + handleInvokeError(error, 'Failed to fetch favorites', () => fetchFavorites()) } } @@ -27,7 +28,7 @@ export const useFavoritesStore = defineStore('favorites', () => { favorites.value.push({ ...fav, id: Number(id) }) return Number(id) } catch (error) { - console.error('Failed to create favorite:', error) + handleInvokeError(error, 'Failed to create favorite') return null } } @@ -38,7 +39,7 @@ export const useFavoritesStore = defineStore('favorites', () => { favorites.value = favorites.value.filter(f => f.id !== id) return true } catch (error) { - console.error('Failed to delete favorite:', error) + handleInvokeError(error, 'Failed to delete favorite') return false } } @@ -48,7 +49,7 @@ export const useFavoritesStore = defineStore('favorites', () => { await invoke('reorder_favorites', { ids }) return true } catch (error) { - console.error('Failed to reorder favorites:', error) + handleInvokeError(error, 'Failed to reorder favorites') return false } } diff --git a/src/stores/invoices.ts b/src/stores/invoices.ts index a9760c4..5d59eba 100644 --- a/src/stores/invoices.ts +++ b/src/stores/invoices.ts @@ -1,6 +1,7 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import { invoke } from '@tauri-apps/api/core' +import { handleInvokeError } from '../utils/errorHandler' export interface Invoice { id?: number @@ -37,7 +38,7 @@ export const useInvoicesStore = defineStore('invoices', () => { try { invoices.value = await invoke('get_invoices') } catch (error) { - console.error('Failed to fetch invoices:', error) + handleInvokeError(error, 'Failed to fetch invoices', () => fetchInvoices()) } finally { loading.value = false } @@ -49,7 +50,7 @@ export const useInvoicesStore = defineStore('invoices', () => { invoices.value.unshift({ ...invoice, id: Number(id) }) return Number(id) } catch (error) { - console.error('Failed to create invoice:', error) + handleInvokeError(error, 'Failed to create invoice') return null } } @@ -63,7 +64,7 @@ export const useInvoicesStore = defineStore('invoices', () => { } return true } catch (error) { - console.error('Failed to update invoice:', error) + handleInvokeError(error, 'Failed to update invoice') return false } } @@ -77,7 +78,7 @@ export const useInvoicesStore = defineStore('invoices', () => { } return true } catch (error) { - console.error('Failed to update invoice template:', error) + handleInvokeError(error, 'Failed to update invoice template') return false } } @@ -88,7 +89,7 @@ export const useInvoicesStore = defineStore('invoices', () => { invoices.value = invoices.value.filter(i => i.id !== id) return true } catch (error) { - console.error('Failed to delete invoice:', error) + handleInvokeError(error, 'Failed to delete invoice') return false } } @@ -97,7 +98,7 @@ export const useInvoicesStore = defineStore('invoices', () => { try { return await invoke('get_invoice_items', { invoiceId }) } catch (error) { - console.error('Failed to fetch invoice items:', error) + handleInvokeError(error, 'Failed to fetch invoice items') return [] } } @@ -106,7 +107,7 @@ export const useInvoicesStore = defineStore('invoices', () => { try { return await invoke('create_invoice_item', { item }) } catch (error) { - console.error('Failed to create invoice item:', error) + handleInvokeError(error, 'Failed to create invoice item') return null } } @@ -123,6 +124,29 @@ export const useInvoicesStore = defineStore('invoices', () => { } } + async function updateStatus(id: number, status: string): Promise { + try { + await invoke('update_invoice_status', { id, status }) + const idx = invoices.value.findIndex(i => i.id === id) + if (idx !== -1) invoices.value[idx].status = status + return true + } catch (error) { + handleInvokeError(error, 'Failed to update invoice status') + return false + } + } + + async function checkOverdue(): Promise { + try { + const today = new Date().toISOString().split('T')[0] + const count = await invoke('check_overdue_invoices', { today }) + if (count > 0) await fetchInvoices() + return count + } catch { + return 0 + } + } + return { invoices, loading, @@ -133,6 +157,8 @@ export const useInvoicesStore = defineStore('invoices', () => { deleteInvoice, getInvoiceItems, createInvoiceItem, - saveInvoiceItems + saveInvoiceItems, + updateStatus, + checkOverdue } }) diff --git a/src/stores/projects.ts b/src/stores/projects.ts index 4b37ede..8a6bddf 100644 --- a/src/stores/projects.ts +++ b/src/stores/projects.ts @@ -1,6 +1,7 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import { invoke } from '@tauri-apps/api/core' +import { handleInvokeError } from '../utils/errorHandler' export interface Project { id?: number @@ -12,12 +13,14 @@ export interface Project { budget_hours?: number | null budget_amount?: number | null rounding_override?: number | null + timeline_override?: string | null } export interface Task { id?: number project_id: number name: string + estimated_hours?: number | null } export const useProjectsStore = defineStore('projects', () => { @@ -29,7 +32,7 @@ export const useProjectsStore = defineStore('projects', () => { try { projects.value = await invoke('get_projects') } catch (error) { - console.error('Failed to fetch projects:', error) + handleInvokeError(error, 'Failed to fetch projects', () => fetchProjects()) } finally { loading.value = false } @@ -41,7 +44,7 @@ export const useProjectsStore = defineStore('projects', () => { projects.value.push({ ...project, id: Number(id) }) return Number(id) } catch (error) { - console.error('Failed to create project:', error) + handleInvokeError(error, 'Failed to create project') return null } } @@ -55,7 +58,7 @@ export const useProjectsStore = defineStore('projects', () => { } return true } catch (error) { - console.error('Failed to update project:', error) + handleInvokeError(error, 'Failed to update project') return false } } @@ -66,7 +69,7 @@ export const useProjectsStore = defineStore('projects', () => { projects.value = projects.value.filter(p => p.id !== id) return true } catch (error) { - console.error('Failed to delete project:', error) + handleInvokeError(error, 'Failed to delete project') return false } } @@ -75,7 +78,7 @@ export const useProjectsStore = defineStore('projects', () => { try { return await invoke('get_tasks', { projectId }) } catch (error) { - console.error('Failed to fetch tasks:', error) + handleInvokeError(error, 'Failed to fetch tasks') return [] } } @@ -84,7 +87,7 @@ export const useProjectsStore = defineStore('projects', () => { try { return await invoke('create_task', { task }) } catch (error) { - console.error('Failed to create task:', error) + handleInvokeError(error, 'Failed to create task') return null } } @@ -94,7 +97,17 @@ export const useProjectsStore = defineStore('projects', () => { await invoke('delete_task', { id }) return true } catch (error) { - console.error('Failed to delete task:', error) + handleInvokeError(error, 'Failed to delete task') + return false + } + } + + async function updateTask(task: Task): Promise { + try { + await invoke('update_task', { task }) + return true + } catch (error) { + handleInvokeError(error, 'Failed to update task') return false } } @@ -108,6 +121,7 @@ export const useProjectsStore = defineStore('projects', () => { deleteProject, fetchTasks, createTask, - deleteTask + deleteTask, + updateTask } }) diff --git a/src/stores/recurring.ts b/src/stores/recurring.ts new file mode 100644 index 0000000..912f91a --- /dev/null +++ b/src/stores/recurring.ts @@ -0,0 +1,229 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { invoke } from '@tauri-apps/api/core' +import { useToastStore } from './toast' +import { useTimerStore } from './timer' +import { handleInvokeError } from '../utils/errorHandler' + +export interface RecurringEntry { + id?: number + project_id: number + task_id?: number + description?: string + duration: number + recurrence_rule: string // "daily", "weekdays", "weekly:mon,wed,fri", "monthly:15" + time_of_day: string // "09:00" + mode: 'auto_create' | 'prompt' | 'prefill_timer' + enabled?: number // 1 = enabled, 0 = disabled + last_triggered?: string // ISO date string +} + +export const useRecurringStore = defineStore('recurring', () => { + const entries = ref([]) + const pendingPrompt = ref(null) + const snoozedUntil = ref>(new Map()) + + async function fetchEntries() { + try { + entries.value = await invoke('get_recurring_entries') + } catch (e) { + handleInvokeError(e, 'Failed to fetch recurring entries', () => fetchEntries()) + } + } + + async function createEntry(entry: RecurringEntry) { + try { + const id = await invoke('create_recurring_entry', { entry }) + await fetchEntries() + return id + } catch (e) { + handleInvokeError(e, 'Failed to create recurring entry') + throw e + } + } + + async function updateEntry(entry: RecurringEntry) { + try { + await invoke('update_recurring_entry', { entry }) + await fetchEntries() + } catch (e) { + handleInvokeError(e, 'Failed to update recurring entry') + throw e + } + } + + async function deleteEntry(id: number) { + try { + await invoke('delete_recurring_entry', { id }) + await fetchEntries() + } catch (e) { + handleInvokeError(e, 'Failed to delete recurring entry') + throw e + } + } + + async function toggleEnabled(entry: RecurringEntry) { + const updated = { ...entry, enabled: entry.enabled === 1 ? 0 : 1 } + await updateEntry(updated) + } + + // Core recurrence check engine + async function checkRecurrences() { + if (entries.value.length === 0) return + + const now = new Date() + const todayStr = now.toISOString().split('T')[0] // YYYY-MM-DD + const currentTime = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}` + const dayOfWeek = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'][now.getDay()] + const dayOfMonth = now.getDate() + + for (const entry of entries.value) { + if (entry.enabled === 0) continue + + // Check if already triggered today + if (entry.last_triggered) { + const lastDate = entry.last_triggered.split('T')[0] + if (lastDate === todayStr) continue + } + + // Check if current time has passed the scheduled time + if (currentTime < entry.time_of_day) continue + + // Check recurrence rule + const rule = entry.recurrence_rule + let shouldFire = false + + if (rule === 'daily') { + shouldFire = true + } else if (rule === 'weekdays') { + shouldFire = !['sat', 'sun'].includes(dayOfWeek) + } else if (rule.startsWith('weekly:')) { + const days = rule.replace('weekly:', '').split(',') + shouldFire = days.includes(dayOfWeek) + } else if (rule.startsWith('monthly:')) { + const targetDay = parseInt(rule.replace('monthly:', '')) + shouldFire = dayOfMonth === targetDay + } + + if (!shouldFire) continue + + // Check snooze: skip if snoozed and not yet expired + if (entry.id && snoozedUntil.value.has(entry.id)) { + if (Date.now() < snoozedUntil.value.get(entry.id)!) continue + snoozedUntil.value.delete(entry.id) + } + + // Fire the recurrence based on mode + await fireRecurrence(entry) + } + } + + async function fireRecurrence(entry: RecurringEntry) { + const toastStore = useToastStore() + + if (entry.mode === 'auto_create') { + // Silently create a time entry + const now = new Date() + const startTime = new Date(now) + const [h, m] = entry.time_of_day.split(':').map(Number) + startTime.setHours(h, m, 0, 0) + const endTime = new Date(startTime.getTime() + entry.duration * 1000) + + try { + await invoke('create_time_entry', { + entry: { + project_id: entry.project_id, + task_id: entry.task_id || null, + description: entry.description || null, + start_time: startTime.toISOString(), + end_time: endTime.toISOString(), + duration: entry.duration, + billable: 1, + } + }) + toastStore.success('Recurring entry created') + } catch (e) { + console.error('Failed to auto-create recurring entry:', e) + } + } else if (entry.mode === 'prompt') { + pendingPrompt.value = entry + } else if (entry.mode === 'prefill_timer') { + // Pre-fill the timer with project/task/description + const timerStore = useTimerStore() + if (timerStore.isStopped) { + timerStore.setProject(entry.project_id) + if (entry.task_id) timerStore.setTask(entry.task_id) + if (entry.description) timerStore.setDescription(entry.description) + toastStore.info('Timer pre-filled from recurring entry') + } + } + + // Mark as triggered today (skip for prompt mode - user action handlers do it) + if (entry.mode !== 'prompt' && entry.id) { + await markTriggered(entry.id) + } + } + + async function markTriggered(id: number) { + try { + await invoke('update_recurring_last_triggered', { + id, + lastTriggered: new Date().toISOString(), + }) + const idx = entries.value.findIndex(e => e.id === id) + if (idx !== -1) { + entries.value[idx].last_triggered = new Date().toISOString() + } + } catch (e) { + console.error('Failed to update last_triggered:', e) + } + } + + function snoozePrompt() { + const entry = pendingPrompt.value + if (entry?.id) { + snoozedUntil.value.set(entry.id, Date.now() + 30 * 60 * 1000) + } + pendingPrompt.value = null + } + + async function confirmPrompt() { + if (!pendingPrompt.value) return + const entry = pendingPrompt.value + const now = new Date() + const [h, m] = entry.time_of_day.split(':').map(Number) + const startTime = new Date(now) + startTime.setHours(h, m, 0, 0) + const endTime = new Date(startTime.getTime() + entry.duration * 1000) + + try { + await invoke('create_time_entry', { + entry: { + project_id: entry.project_id, + task_id: entry.task_id || null, + description: entry.description || null, + start_time: startTime.toISOString(), + end_time: endTime.toISOString(), + duration: entry.duration, + billable: 1, + } + }) + const toastStore = useToastStore() + toastStore.success('Recurring entry created') + if (entry.id) await markTriggered(entry.id) + } catch (e) { + handleInvokeError(e, 'Failed to create recurring entry') + } + pendingPrompt.value = null + } + + async function skipPrompt() { + const entry = pendingPrompt.value + if (entry?.id) { + await markTriggered(entry.id) + } + pendingPrompt.value = null + } + + return { entries, pendingPrompt, fetchEntries, createEntry, updateEntry, deleteEntry, toggleEnabled, checkRecurrences, snoozePrompt, confirmPrompt, skipPrompt } +}) diff --git a/src/stores/settings.ts b/src/stores/settings.ts new file mode 100644 index 0000000..9bffd3d --- /dev/null +++ b/src/stores/settings.ts @@ -0,0 +1,38 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { invoke } from '@tauri-apps/api/core' +import { handleInvokeError } from '../utils/errorHandler' + +export const useSettingsStore = defineStore('settings', () => { + const settings = ref>({}) + const loading = ref(false) + + async function fetchSettings() { + loading.value = true + try { + settings.value = await invoke>('get_settings') + } catch (error) { + handleInvokeError(error, 'Failed to fetch settings', () => fetchSettings()) + } finally { + loading.value = false + } + } + + async function updateSetting(key: string, value: string): Promise { + try { + await invoke('update_settings', { key, value }) + settings.value[key] = value + return true + } catch (error) { + console.error('Failed to update setting:', error) + return false + } + } + + return { + settings, + loading, + fetchSettings, + updateSetting + } +}) diff --git a/src/stores/tags.ts b/src/stores/tags.ts index 9cafd7b..065bb00 100644 --- a/src/stores/tags.ts +++ b/src/stores/tags.ts @@ -1,6 +1,7 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import { invoke } from '@tauri-apps/api/core' +import { handleInvokeError } from '../utils/errorHandler' export interface Tag { id?: number @@ -15,7 +16,7 @@ export const useTagsStore = defineStore('tags', () => { try { tags.value = await invoke('get_tags') } catch (error) { - console.error('Failed to fetch tags:', error) + handleInvokeError(error, 'Failed to fetch tags', () => fetchTags()) } } @@ -25,7 +26,7 @@ export const useTagsStore = defineStore('tags', () => { tags.value.push({ ...tag, id: Number(id) }) return Number(id) } catch (error) { - console.error('Failed to create tag:', error) + handleInvokeError(error, 'Failed to create tag') return null } } @@ -37,7 +38,7 @@ export const useTagsStore = defineStore('tags', () => { if (index !== -1) tags.value[index] = tag return true } catch (error) { - console.error('Failed to update tag:', error) + handleInvokeError(error, 'Failed to update tag') return false } } @@ -48,7 +49,7 @@ export const useTagsStore = defineStore('tags', () => { tags.value = tags.value.filter(t => t.id !== id) return true } catch (error) { - console.error('Failed to delete tag:', error) + handleInvokeError(error, 'Failed to delete tag') return false } } @@ -57,7 +58,7 @@ export const useTagsStore = defineStore('tags', () => { try { return await invoke('get_entry_tags', { entryId }) } catch (error) { - console.error('Failed to get entry tags:', error) + handleInvokeError(error, 'Failed to get entry tags') return [] } } @@ -67,7 +68,7 @@ export const useTagsStore = defineStore('tags', () => { await invoke('set_entry_tags', { entryId, tagIds }) return true } catch (error) { - console.error('Failed to set entry tags:', error) + handleInvokeError(error, 'Failed to set entry tags') return false } }