From eb587945559e866d8067d1f369de4b78c4d8790d Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 20 Feb 2026 14:58:02 +0200 Subject: [PATCH] feat: smart timer safety net - save dialog on stop without project --- src/App.vue | 11 +- src/stores/timer.ts | 707 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 717 insertions(+), 1 deletion(-) create mode 100644 src/stores/timer.ts diff --git a/src/App.vue b/src/App.vue index a6700ca..c61842b 100644 --- a/src/App.vue +++ b/src/App.vue @@ -15,12 +15,14 @@ import { audioEngine, DEFAULT_EVENTS } from './utils/audio' import type { SoundEvent } from './utils/audio' import TourOverlay from './components/TourOverlay.vue' import RecurringPromptDialog from './components/RecurringPromptDialog.vue' +import TimerSaveDialog from './components/TimerSaveDialog.vue' import { useOnboardingStore } from './stores/onboarding' import { useProjectsStore } from './stores/projects' import { useInvoicesStore } from './stores/invoices' const settingsStore = useSettingsStore() const recurringStore = useRecurringStore() +const timerStore = useTimerStore() const { announcement } = useAnnouncer() function getProjectName(projectId?: number): string { @@ -98,7 +100,6 @@ onMounted(async () => { const onboardingStore = useOnboardingStore() await onboardingStore.load() - const timerStore = useTimerStore() await timerStore.restoreState() const zoom = parseInt(settingsStore.settings.ui_zoom) || 100 @@ -275,6 +276,14 @@ watch(() => settingsStore.settings.persistent_notifications, (val) => { @snooze="recurringStore.snoozePrompt()" @skip="recurringStore.skipPrompt()" /> +
{{ announcement }}
diff --git a/src/stores/timer.ts b/src/stores/timer.ts new file mode 100644 index 0000000..a3bf57a --- /dev/null +++ b/src/stores/timer.ts @@ -0,0 +1,707 @@ +import { defineStore } from 'pinia' +import { ref, computed, watch } from 'vue' +import { invoke } from '@tauri-apps/api/core' +import { emit, listen } from '@tauri-apps/api/event' +import { useSettingsStore } from './settings' +import { useProjectsStore } from './projects' +import { useAnnouncer } from '../composables/useAnnouncer' +import { audioEngine } from '../utils/audio' +import type { TimeEntry } from './entries' + +export type TimerState = 'STOPPED' | 'RUNNING' | 'PAUSED_IDLE' | 'PAUSED_APP' | 'PAUSED_MANUAL' + +export interface TrackedApp { + id?: number + project_id: number + exe_name: string + exe_path?: string + display_name?: string +} + +export const useTimerStore = defineStore('timer', () => { + // Core state + const timerState = ref('STOPPED') + const startTime = ref(null) + const currentEntry = ref(null) + const selectedProjectId = ref(null) + const selectedTaskId = ref(null) + const description = ref('') + const billable = ref(1) + const elapsedSeconds = ref(0) + + // Pause tracking + const pausedAt = ref(null) + const totalPausedMs = ref(0) + const idleStartedAt = ref(null) + + // Tracked apps for current project + const trackedApps = ref([]) + + // Prompt visibility flags (consumed by Timer.vue) + const showIdlePrompt = ref(false) + const showAppPrompt = ref(false) + const idleDurationSeconds = ref(0) + + // Save dialog state (safety net for no-project / long-timer) + const showSaveDialog = ref(false) + const saveDialogMode = ref<'no-project' | 'long-timer'>('no-project') + const pendingStopDuration = ref(0) + + const { announce } = useAnnouncer() + + let displayIntervalId: number | null = null + let monitorIntervalId: number | null = null + let goalReachedFired = false + let lastBreakReminderAt: number | null = null + + // Timeline recording state + let currentTimelineEventId: number | null = null + let lastTimelineExe: string = '' + let lastTimelineTitle: string = '' + let lastTimelineStartMs: number = 0 + + function isTimelineEnabled(): boolean { + const settingsStore = useSettingsStore() + const globalEnabled = settingsStore.settings.timeline_recording === 'on' + if (selectedProjectId.value) { + const projectsStore = useProjectsStore() + const project = projectsStore.projects.find(p => p.id === selectedProjectId.value) + if (project?.timeline_override === 'on') return true + if (project?.timeline_override === 'off') return false + } + return globalEnabled + } + + async function closeTimelineEvent() { + if (!currentTimelineEventId) return + const now = new Date().toISOString() + const duration = Math.floor((Date.now() - lastTimelineStartMs) / 1000) + try { + await invoke('update_timeline_event_ended', { + id: currentTimelineEventId, + endedAt: now, + duration, + }) + } catch (e) { + console.error('Failed to close timeline event:', e) + } + currentTimelineEventId = null + } + + function resetTimelineState() { + currentTimelineEventId = null + lastTimelineExe = '' + lastTimelineTitle = '' + lastTimelineStartMs = 0 + } + + // Derived + const isRunning = computed(() => timerState.value === 'RUNNING') + const isPaused = computed(() => timerState.value === 'PAUSED_IDLE' || timerState.value === 'PAUSED_APP' || timerState.value === 'PAUSED_MANUAL') + const isStopped = computed(() => timerState.value === 'STOPPED') + + const formattedTime = computed(() => { + const hours = Math.floor(elapsedSeconds.value / 3600) + const minutes = Math.floor((elapsedSeconds.value % 3600) / 60) + const seconds = elapsedSeconds.value % 60 + return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` + }) + + function updateElapsed() { + if (!startTime.value) return + const now = Date.now() + let paused = totalPausedMs.value + if (pausedAt.value) { + paused += now - pausedAt.value + } + elapsedSeconds.value = Math.floor((now - startTime.value.getTime() - paused) / 1000) + emitTimerSync() + + if (timerState.value === 'RUNNING') { + const settingsStore = useSettingsStore() + + // Goal reached check + if (!goalReachedFired && settingsStore.settings.goals_enabled === 'true') { + const dailyGoalHours = parseFloat(settingsStore.settings.daily_goal_hours) || 8 + const elapsedHours = elapsedSeconds.value / 3600 + if (elapsedHours >= dailyGoalHours) { + goalReachedFired = true + audioEngine.play('goal_reached') + announce('Daily goal reached') + } + } + + // Break reminder check + const reminderMin = parseInt(settingsStore.settings.reminder_interval) || 30 + if (reminderMin > 0) { + const nowMs = Date.now() + if (!lastBreakReminderAt) { + lastBreakReminderAt = nowMs + } else if (nowMs - lastBreakReminderAt >= reminderMin * 60000) { + lastBreakReminderAt = nowMs + audioEngine.play('break_reminder') + } + } + } + } + + function emitTimerSync() { + emit('timer-sync', { + projectId: selectedProjectId.value, + timerState: timerState.value, + elapsedSeconds: elapsedSeconds.value, + }).catch(() => {}) + } + + // Fetch tracked apps for the selected project + async function fetchTrackedApps() { + if (!selectedProjectId.value) { + trackedApps.value = [] + return + } + try { + trackedApps.value = await invoke('get_tracked_apps', { projectId: selectedProjectId.value }) + } catch (e) { + console.error('Failed to fetch tracked apps:', e) + trackedApps.value = [] + } + } + + // Watch project changes to reload tracked apps + watch(selectedProjectId, () => { + fetchTrackedApps() + }) + + function startDisplayInterval() { + if (displayIntervalId) return + displayIntervalId = window.setInterval(updateElapsed, 1000) + } + + function stopDisplayInterval() { + if (displayIntervalId) { + clearInterval(displayIntervalId) + displayIntervalId = null + } + } + + function startMonitorInterval() { + if (monitorIntervalId) return + const settingsStore = useSettingsStore() + const intervalSec = parseInt(settingsStore.settings.app_check_interval) || 5 + monitorIntervalId = window.setInterval(() => monitorTick(), intervalSec * 1000) + } + + function stopMonitorInterval() { + if (monitorIntervalId) { + clearInterval(monitorIntervalId) + monitorIntervalId = null + } + } + + async function monitorTick() { + const settingsStore = useSettingsStore() + + if (timerState.value === 'RUNNING') { + // 1. Check idle first (priority) + const idleEnabled = settingsStore.settings.idle_detection !== 'false' + if (idleEnabled) { + const idleTimeoutMin = parseInt(settingsStore.settings.idle_timeout) || 5 + try { + const idleSec = await invoke('get_idle_seconds') + if (idleSec >= idleTimeoutMin * 60) { + pauseForIdle(idleSec) + return + } + } catch (e) { + console.error('Idle check failed:', e) + } + } + + // Fetch visible windows once for both app tracking and timeline + const needWindows = trackedApps.value.length > 0 || isTimelineEnabled() + let visibleWindows: Array<{ exe_name: string; exe_path: string; title: string }> | null = null + if (needWindows) { + try { + visibleWindows = await invoke>('get_visible_windows') + } catch (e) { + console.error('Window check failed:', e) + } + } + + // 2. Check app visibility + if (trackedApps.value.length > 0 && visibleWindows) { + const trackedPaths = trackedApps.value.map(a => (a.exe_path || '').toLowerCase()) + const anyVisible = visibleWindows.some(w => trackedPaths.includes(w.exe_path.toLowerCase())) + if (!anyVisible) { + pauseForApp() + return + } + } + + // 3. Timeline recording + if (isTimelineEnabled() && visibleWindows && visibleWindows.length > 0) { + try { + const fg = visibleWindows[0] + const fgExe = fg.exe_name || '' + const fgTitle = fg.title || '' + if (fgExe !== lastTimelineExe || fgTitle !== lastTimelineTitle) { + // Window changed - close previous event + await closeTimelineEvent() + // Create new event + const projectId = selectedProjectId.value + if (projectId) { + const event = { + project_id: projectId, + exe_name: fgExe || null, + exe_path: fg.exe_path || null, + window_title: fgTitle || null, + started_at: new Date().toISOString(), + } + try { + currentTimelineEventId = await invoke('create_timeline_event', { event }) + lastTimelineExe = fgExe + lastTimelineTitle = fgTitle + lastTimelineStartMs = Date.now() + } catch (e) { + console.error('Failed to create timeline event:', e) + } + } + } + } catch (e) { + console.error('Timeline recording failed:', e) + } + } + } else if (timerState.value === 'PAUSED_APP') { + // Auto-resume check: see if tracked app came back + const mode = settingsStore.settings.app_tracking_mode || 'auto' + if (mode === 'auto' || mode === 'notify') { + try { + const visibleWindows = await invoke>('get_visible_windows') + const trackedPaths = trackedApps.value.map(a => (a.exe_path || '').toLowerCase()) + const anyVisible = visibleWindows.some(w => trackedPaths.includes(w.exe_path.toLowerCase())) + if (anyVisible) { + resumeFromPause() + } + } catch (e) { + console.error('Visibility resume check failed:', e) + } + } + } + // PAUSED_IDLE: no auto-resume, wait for user dialog action + } + + function pauseForIdle(idleSec: number) { + if (timerState.value !== 'RUNNING') return + const now = Date.now() + idleStartedAt.value = now - (idleSec * 1000) + pausedAt.value = idleStartedAt.value + timerState.value = 'PAUSED_IDLE' + idleDurationSeconds.value = idleSec + updateElapsed() + announce('Idle detected - timer paused') + audioEngine.play('idle_alert') + + // Show prompt (bring window to front handled by Timer.vue) + showIdlePrompt.value = true + } + + function pauseForApp() { + if (timerState.value !== 'RUNNING') return + pausedAt.value = Date.now() + timerState.value = 'PAUSED_APP' + updateElapsed() + announce('App not visible - timer paused') + audioEngine.play('timer_pause') + + const settingsStore = useSettingsStore() + const mode = settingsStore.settings.app_tracking_mode || 'auto' + if (mode === 'prompt') { + showAppPrompt.value = true + } else if (mode === 'notify') { + // notification handled by Timer.vue + } + // 'auto' mode: silent, auto-resume check in monitorTick + } + + function resumeFromPause() { + if (!pausedAt.value) return + totalPausedMs.value += Date.now() - pausedAt.value + pausedAt.value = null + idleStartedAt.value = null + timerState.value = 'RUNNING' + showIdlePrompt.value = false + showAppPrompt.value = false + updateElapsed() + announce('Timer resumed') + audioEngine.play('timer_resume') + persistState() + } + + function resumeKeepingIdleTime() { + // Idle period counts as work: don't add to paused time + pausedAt.value = null + idleStartedAt.value = null + timerState.value = 'RUNNING' + showIdlePrompt.value = false + updateElapsed() + announce('Timer resumed') + audioEngine.play('timer_resume') + persistState() + } + + function pauseManual() { + if (timerState.value !== 'RUNNING') return + pausedAt.value = Date.now() + timerState.value = 'PAUSED_MANUAL' + updateElapsed() + announce('Timer paused') + audioEngine.play('timer_pause') + persistState() + } + + // Start timer + function start() { + if (timerState.value !== 'STOPPED') return + + startTime.value = new Date() + totalPausedMs.value = 0 + pausedAt.value = null + idleStartedAt.value = null + elapsedSeconds.value = 0 + timerState.value = 'RUNNING' + showIdlePrompt.value = false + showAppPrompt.value = false + goalReachedFired = false + lastBreakReminderAt = Date.now() + resetTimelineState() + + startDisplayInterval() + startMonitorInterval() + emitTimerSync() + announce('Timer started') + audioEngine.play('timer_start') + persistState() + } + + // Stop timer and save entry + async function stop(options?: { subtractIdleTime?: boolean; confirmed?: boolean }) { + if (timerState.value === 'STOPPED') return + + const endTime = new Date() + let duration: number + + if (options?.subtractIdleTime && idleStartedAt.value) { + // Duration up to when idle started + duration = Math.floor((idleStartedAt.value - startTime.value!.getTime() - totalPausedMs.value) / 1000) + } else if (pausedAt.value) { + // Include paused time in calculation + const finalPaused = totalPausedMs.value + (Date.now() - pausedAt.value) + duration = Math.floor((endTime.getTime() - startTime.value!.getTime() - finalPaused) / 1000) + } else { + duration = Math.floor((endTime.getTime() - startTime.value!.getTime() - totalPausedMs.value) / 1000) + } + + if (duration < 0) duration = 0 + + // Safety net: if no project and there's tracked time, show save dialog + if (!selectedProjectId.value && duration > 0 && !options?.confirmed) { + pendingStopDuration.value = duration + saveDialogMode.value = 'no-project' + showSaveDialog.value = true + 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 + } + + // Now actually stop everything + stopDisplayInterval() + stopMonitorInterval() + await closeTimelineEvent() + resetTimelineState() + + if (selectedProjectId.value) { + const entry: TimeEntry = { + project_id: selectedProjectId.value, + task_id: selectedTaskId.value || undefined, + description: description.value || undefined, + start_time: startTime.value!.toISOString(), + end_time: endTime.toISOString(), + duration, + billable: billable.value, + } + + try { + const id = await invoke('create_time_entry', { entry }) + currentEntry.value = { ...entry, id: Number(id) } + } catch (error) { + console.error('Failed to create time entry:', error) + } + } + + const h = Math.floor(elapsedSeconds.value / 3600) + const m = Math.floor((elapsedSeconds.value % 3600) / 60) + const parts = [] + if (h > 0) parts.push(`${h} hour${h !== 1 ? 's' : ''}`) + if (m > 0) parts.push(`${m} minute${m !== 1 ? 's' : ''}`) + announce(`Timer stopped - ${parts.join(' ') || 'less than a minute'} logged`) + audioEngine.play('timer_stop') + + 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() + + const settingsStore = useSettingsStore() + settingsStore.updateSetting('timer_state_backup', '') + } + + // Idle prompt handlers + function handleIdleContinueKeep() { + resumeKeepingIdleTime() + } + + function handleIdleContinueSubtract() { + resumeFromPause() + } + + async function handleIdleStop() { + await stop({ subtractIdleTime: true }) + } + + // App prompt handlers + function handleAppContinue() { + resumeFromPause() + } + + async function handleAppStop() { + await stop() + } + + // Save dialog handlers (safety net) + 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 + 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 + } + + function setProject(projectId: number | null) { + selectedProjectId.value = projectId + if (timerState.value !== 'STOPPED') persistState() + } + + function setTask(taskId: number | null) { + selectedTaskId.value = taskId + if (timerState.value !== 'STOPPED') persistState() + } + + function setDescription(desc: string) { + description.value = desc + if (timerState.value !== 'STOPPED') persistState() + } + + function setBillable(val: number) { + billable.value = val + if (timerState.value !== 'STOPPED') persistState() + } + + function reset() { + stopDisplayInterval() + stopMonitorInterval() + timerState.value = 'STOPPED' + startTime.value = null + currentEntry.value = null + selectedProjectId.value = null + selectedTaskId.value = null + description.value = '' + billable.value = 1 + elapsedSeconds.value = 0 + totalPausedMs.value = 0 + pausedAt.value = null + idleStartedAt.value = null + showIdlePrompt.value = false + showAppPrompt.value = false + trackedApps.value = [] + goalReachedFired = false + lastBreakReminderAt = null + } + + // Timer state persistence + function persistState() { + try { + const settingsStore = useSettingsStore() + const data = { + timerState: timerState.value, + startTime: startTime.value ? startTime.value.toISOString() : null, + selectedProjectId: selectedProjectId.value, + selectedTaskId: selectedTaskId.value, + description: description.value, + billable: billable.value, + totalPausedMs: totalPausedMs.value, + pausedAt: pausedAt.value, + } + settingsStore.updateSetting('timer_state_backup', JSON.stringify(data)) + .catch(e => console.error('Failed to persist timer state:', e)) + } catch (e) { + console.error('Failed to persist timer state:', e) + } + } + + async function restoreState() { + try { + const settingsStore = useSettingsStore() + const raw = settingsStore.settings.timer_state_backup + if (!raw) return + + const data = JSON.parse(raw) + if (!data.timerState || data.timerState === 'STOPPED') return + + // Validate project still exists + const projectsStore = useProjectsStore() + await projectsStore.fetchProjects() + const project = projectsStore.projects.find(p => p.id === data.selectedProjectId) + if (!project) { + announce('Previous timer session could not be restored') + settingsStore.updateSetting('timer_state_backup', '') + return + } + + // Restore common state + selectedProjectId.value = data.selectedProjectId + selectedTaskId.value = data.selectedTaskId + description.value = data.description || '' + billable.value = data.billable ?? 1 + totalPausedMs.value = data.totalPausedMs || 0 + + if (data.timerState === 'RUNNING') { + startTime.value = new Date(data.startTime) + timerState.value = 'RUNNING' + const now = Date.now() + elapsedSeconds.value = Math.floor((now - startTime.value.getTime() - totalPausedMs.value) / 1000) + if (elapsedSeconds.value < 0) elapsedSeconds.value = 0 + startDisplayInterval() + startMonitorInterval() + } else if (data.timerState === 'PAUSED_MANUAL' || data.timerState === 'PAUSED_IDLE' || data.timerState === 'PAUSED_APP') { + startTime.value = new Date(data.startTime) + pausedAt.value = data.pausedAt + timerState.value = data.timerState as TimerState + if (pausedAt.value && startTime.value) { + elapsedSeconds.value = Math.floor((pausedAt.value - startTime.value.getTime() - totalPausedMs.value) / 1000) + if (elapsedSeconds.value < 0) elapsedSeconds.value = 0 + } + // For app-paused state, start monitor so auto-resume can detect app return + if (data.timerState === 'PAUSED_APP') { + startMonitorInterval() + } + } + + const h = Math.floor(elapsedSeconds.value / 3600) + const m = Math.floor((elapsedSeconds.value % 3600) / 60) + const parts = [] + if (h > 0) parts.push(`${h}h`) + if (m > 0) parts.push(`${m}m`) + announce(`Timer restored: ${project.name}, ${parts.join(' ') || 'less than a minute'}`) + } catch (e) { + console.error('Failed to restore timer state:', e) + } + } + + // Listen for mini-timer events + listen('mini-timer-ready', () => { + emitTimerSync() + }).catch(() => {}) + + listen('mini-timer-stop', () => { + stop() + }).catch(() => {}) + + listen('mini-timer-pause', () => { + pauseManual() + }).catch(() => {}) + + listen('mini-timer-resume', () => { + resumeFromPause() + }).catch(() => {}) + + return { + // State + timerState, + isRunning, + isPaused, + isStopped, + startTime, + currentEntry, + selectedProjectId, + selectedTaskId, + description, + billable, + elapsedSeconds, + formattedTime, + trackedApps, + showIdlePrompt, + showAppPrompt, + idleDurationSeconds, + // Actions + start, + stop, + pauseManual, + resumeFromPause, + setProject, + setTask, + setDescription, + setBillable, + reset, + fetchTrackedApps, + handleIdleContinueKeep, + handleIdleContinueSubtract, + handleIdleStop, + handleAppContinue, + handleAppStop, + showSaveDialog, + saveDialogMode, + pendingStopDuration, + handleSaveDialogSave, + handleSaveDialogDiscard, + handleSaveDialogCancel, + restoreState, + } +})