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 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
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