feat: smart timer safety net - save dialog on stop without project
This commit is contained in:
11
src/App.vue
11
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()"
|
||||
/>
|
||||
<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="announcer" class="sr-only" aria-live="assertive" aria-atomic="true">{{ announcement }}</div>
|
||||
<TourOverlay />
|
||||
|
||||
707
src/stores/timer.ts
Normal file
707
src/stores/timer.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user