230 lines
7.3 KiB
TypeScript
230 lines
7.3 KiB
TypeScript
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<RecurringEntry[]>([])
|
|
const pendingPrompt = ref<RecurringEntry | null>(null)
|
|
const snoozedUntil = ref<Map<number, number>>(new Map())
|
|
|
|
async function fetchEntries() {
|
|
try {
|
|
entries.value = await invoke<RecurringEntry[]>('get_recurring_entries')
|
|
} catch (e) {
|
|
handleInvokeError(e, 'Failed to fetch recurring entries', () => fetchEntries())
|
|
}
|
|
}
|
|
|
|
async function createEntry(entry: RecurringEntry) {
|
|
try {
|
|
const id = await invoke<number>('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 }
|
|
})
|