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,
+ }
+})