feat: smart timer safety net - save dialog on stop without project

This commit is contained in:
Your Name
2026-02-20 14:58:02 +02:00
parent 115bdd33db
commit fb38d98612
2 changed files with 717 additions and 1 deletions

View File

@@ -15,12 +15,14 @@ import { audioEngine, DEFAULT_EVENTS } from './utils/audio'
import type { SoundEvent } from './utils/audio' import type { SoundEvent } from './utils/audio'
import TourOverlay from './components/TourOverlay.vue' import TourOverlay from './components/TourOverlay.vue'
import RecurringPromptDialog from './components/RecurringPromptDialog.vue' import RecurringPromptDialog from './components/RecurringPromptDialog.vue'
import TimerSaveDialog from './components/TimerSaveDialog.vue'
import { useOnboardingStore } from './stores/onboarding' import { useOnboardingStore } from './stores/onboarding'
import { useProjectsStore } from './stores/projects' import { useProjectsStore } from './stores/projects'
import { useInvoicesStore } from './stores/invoices' import { useInvoicesStore } from './stores/invoices'
const settingsStore = useSettingsStore() const settingsStore = useSettingsStore()
const recurringStore = useRecurringStore() const recurringStore = useRecurringStore()
const timerStore = useTimerStore()
const { announcement } = useAnnouncer() const { announcement } = useAnnouncer()
function getProjectName(projectId?: number): string { function getProjectName(projectId?: number): string {
@@ -98,7 +100,6 @@ onMounted(async () => {
const onboardingStore = useOnboardingStore() const onboardingStore = useOnboardingStore()
await onboardingStore.load() await onboardingStore.load()
const timerStore = useTimerStore()
await timerStore.restoreState() await timerStore.restoreState()
const zoom = parseInt(settingsStore.settings.ui_zoom) || 100 const zoom = parseInt(settingsStore.settings.ui_zoom) || 100
@@ -275,6 +276,14 @@ watch(() => settingsStore.settings.persistent_notifications, (val) => {
@snooze="recurringStore.snoozePrompt()" @snooze="recurringStore.snoozePrompt()"
@skip="recurringStore.skipPrompt()" @skip="recurringStore.skipPrompt()"
/> />
<TimerSaveDialog
:show="timerStore.showSaveDialog"
:elapsed-seconds="timerStore.pendingStopDuration"
:mode="timerStore.saveDialogMode"
@save="timerStore.handleSaveDialogSave"
@discard="timerStore.handleSaveDialogDiscard"
@cancel="timerStore.handleSaveDialogCancel"
/>
<div id="route-announcer" class="sr-only" aria-live="polite" aria-atomic="true"></div> <div id="route-announcer" class="sr-only" aria-live="polite" aria-atomic="true"></div>
<div id="announcer" class="sr-only" aria-live="assertive" aria-atomic="true">{{ announcement }}</div> <div id="announcer" class="sr-only" aria-live="assertive" aria-atomic="true">{{ announcement }}</div>
<TourOverlay /> <TourOverlay />

707
src/stores/timer.ts Normal file
View File

@@ -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<TimerState>('STOPPED')
const startTime = ref<Date | null>(null)
const currentEntry = ref<TimeEntry | null>(null)
const selectedProjectId = ref<number | null>(null)
const selectedTaskId = ref<number | null>(null)
const description = ref('')
const billable = ref(1)
const elapsedSeconds = ref(0)
// Pause tracking
const pausedAt = ref<number | null>(null)
const totalPausedMs = ref(0)
const idleStartedAt = ref<number | null>(null)
// Tracked apps for current project
const trackedApps = ref<TrackedApp[]>([])
// 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<TrackedApp[]>('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<number>('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<Array<{ exe_name: string; exe_path: string; title: string }>>('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<number>('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<Array<{ exe_path: string }>>('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<number>('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,
}
})