Close button did nothing when "close to tray" was disabled - the onCloseRequested handler lacked an explicit destroy call for the non-tray path. Clockify CSV import threw RangeError because locale-dependent date formats (MM/DD/YYYY, DD.MM.YYYY, 12h time) were passed straight to the Date constructor. Added flexible date/time parsers that handle all Clockify export variants without relying on Date parsing. Added dedicated Clockify mapper that prefers Duration (decimal) column and a new Harvest CSV importer (date + decimal hours, no start/end times). Bump version to 1.0.1.
453 lines
16 KiB
Vue
453 lines
16 KiB
Vue
<script setup lang="ts">
|
|
import { onMounted, ref, watch } from 'vue'
|
|
import { invoke } from '@tauri-apps/api/core'
|
|
import TitleBar from './components/TitleBar.vue'
|
|
import NavRail from './components/NavRail.vue'
|
|
import ToastNotification from './components/ToastNotification.vue'
|
|
import { useSettingsStore } from './stores/settings'
|
|
import { useToastStore } from './stores/toast'
|
|
import { useTimerStore } from './stores/timer'
|
|
import { useRecurringStore } from './stores/recurring'
|
|
import { loadAndApplyTimerFont } from './utils/fonts'
|
|
import { loadAndApplyUIFont } from './utils/uiFonts'
|
|
import { useAnnouncer } from './composables/useAnnouncer'
|
|
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 QuickEntryDialog from './components/QuickEntryDialog.vue'
|
|
import KeyboardShortcutsDialog from './components/KeyboardShortcutsDialog.vue'
|
|
import GlobalSearchDialog from './components/GlobalSearchDialog.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()
|
|
const showQuickEntry = ref(false)
|
|
const showShortcuts = ref(false)
|
|
const showSearch = ref(false)
|
|
|
|
function getProjectName(projectId?: number): string {
|
|
if (!projectId) return ''
|
|
const projectsStore = useProjectsStore()
|
|
return projectsStore.projects.find(p => p.id === projectId)?.name || ''
|
|
}
|
|
|
|
function getProjectColor(projectId?: number): string {
|
|
if (!projectId) return '#6B7280'
|
|
const projectsStore = useProjectsStore()
|
|
return projectsStore.projects.find(p => p.id === projectId)?.color || '#6B7280'
|
|
}
|
|
|
|
let shortcutRegistering = false
|
|
async function registerShortcuts() {
|
|
if (shortcutRegistering) return
|
|
shortcutRegistering = true
|
|
try {
|
|
const { unregisterAll, register } = await import('@tauri-apps/plugin-global-shortcut')
|
|
await unregisterAll()
|
|
const toggleKey = settingsStore.settings.shortcut_toggle_timer || 'CmdOrCtrl+Shift+T'
|
|
const showKey = settingsStore.settings.shortcut_show_app || 'CmdOrCtrl+Shift+Z'
|
|
|
|
await register(toggleKey, () => {
|
|
const timerStore = useTimerStore()
|
|
if (timerStore.isStopped) {
|
|
if (timerStore.selectedProjectId) timerStore.start()
|
|
} else {
|
|
timerStore.stop()
|
|
}
|
|
})
|
|
|
|
await register(showKey, async () => {
|
|
const { getCurrentWindow } = await import('@tauri-apps/api/window')
|
|
const win = getCurrentWindow()
|
|
await win.show()
|
|
await win.setFocus()
|
|
})
|
|
|
|
const quickEntryKey = settingsStore.settings.shortcut_quick_entry || 'CmdOrCtrl+Shift+N'
|
|
await register(quickEntryKey, async () => {
|
|
const { getCurrentWindow } = await import('@tauri-apps/api/window')
|
|
const win = getCurrentWindow()
|
|
await win.show()
|
|
await win.setFocus()
|
|
showQuickEntry.value = true
|
|
})
|
|
} catch (e) {
|
|
console.error('Failed to register shortcuts:', e)
|
|
} finally {
|
|
shortcutRegistering = false
|
|
}
|
|
}
|
|
|
|
function applyTheme() {
|
|
const el = document.documentElement
|
|
const mode = settingsStore.settings.theme_mode || 'dark'
|
|
const accent = settingsStore.settings.accent_color || 'amber'
|
|
|
|
if (mode === 'system') {
|
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
el.setAttribute('data-theme', prefersDark ? 'dark' : 'light')
|
|
} else {
|
|
el.setAttribute('data-theme', mode)
|
|
}
|
|
el.setAttribute('data-accent', accent)
|
|
}
|
|
|
|
function daysDiff(a: string, b: string): number {
|
|
const ms = new Date(b).getTime() - new Date(a).getTime()
|
|
return Math.floor(ms / 86400000)
|
|
}
|
|
|
|
async function checkScheduledBackup() {
|
|
const s = settingsStore.settings
|
|
if (s.auto_backup !== 'true' || !s.backup_path) return
|
|
const lastBackup = s.auto_backup_last || ''
|
|
const frequency = s.auto_backup_frequency || 'daily'
|
|
const retention = parseInt(s.auto_backup_retention || '7')
|
|
const today = new Date().toISOString().split('T')[0]
|
|
|
|
const isDue = !lastBackup || (frequency === 'daily' && lastBackup < today) ||
|
|
(frequency === 'weekly' && daysDiff(lastBackup, today) >= 7)
|
|
if (!isDue) return
|
|
|
|
try {
|
|
await invoke('auto_backup', { backupDir: s.backup_path })
|
|
await settingsStore.updateSetting('auto_backup_last', today)
|
|
const toastStore = useToastStore()
|
|
const files = await invoke<any[]>('list_backup_files', { backupDir: s.backup_path })
|
|
if (files.length > retention) {
|
|
for (const old of files.slice(retention)) {
|
|
await invoke('delete_backup_file', { path: old.path })
|
|
}
|
|
}
|
|
toastStore.success('Auto-backup completed')
|
|
} catch (e) {
|
|
console.error('Scheduled backup failed:', e)
|
|
}
|
|
}
|
|
|
|
function applyMotion() {
|
|
const setting = settingsStore.settings.reduce_motion || 'system'
|
|
const el = document.documentElement
|
|
if (setting === 'on') {
|
|
el.classList.add('reduce-motion')
|
|
} else if (setting === 'off') {
|
|
el.classList.remove('reduce-motion')
|
|
} else {
|
|
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
|
el.classList.add('reduce-motion')
|
|
} else {
|
|
el.classList.remove('reduce-motion')
|
|
}
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await settingsStore.fetchSettings()
|
|
|
|
const onboardingStore = useOnboardingStore()
|
|
await onboardingStore.load()
|
|
|
|
await timerStore.restoreState()
|
|
|
|
const zoom = parseInt(settingsStore.settings.ui_zoom) || 100
|
|
const app = document.getElementById('app')
|
|
if (app) {
|
|
(app.style as any).zoom = `${zoom}%`
|
|
}
|
|
applyTheme()
|
|
applyMotion()
|
|
loadAndApplyTimerFont(settingsStore.settings.timer_font || 'JetBrains Mono')
|
|
|
|
const dyslexiaMode = settingsStore.settings.dyslexia_mode === 'true'
|
|
if (dyslexiaMode) {
|
|
loadAndApplyUIFont('OpenDyslexic')
|
|
} else {
|
|
const uiFont = settingsStore.settings.ui_font
|
|
if (uiFont && uiFont !== 'Inter') {
|
|
loadAndApplyUIFont(uiFont)
|
|
}
|
|
}
|
|
|
|
// Load audio settings
|
|
const soundEnabled = settingsStore.settings.sound_enabled === 'true'
|
|
const soundMode = (settingsStore.settings.sound_mode || 'synthesized') as 'synthesized' | 'system' | 'custom'
|
|
const soundVolume = parseInt(settingsStore.settings.sound_volume) || 70
|
|
let soundEvents: Record<string, boolean> = {}
|
|
try {
|
|
soundEvents = JSON.parse(settingsStore.settings.sound_events || '{}')
|
|
} catch { /* use defaults */ }
|
|
|
|
audioEngine.updateSettings({
|
|
enabled: soundEnabled,
|
|
mode: soundMode,
|
|
volume: soundVolume,
|
|
events: { ...DEFAULT_EVENTS, ...soundEvents } as Record<SoundEvent, boolean>,
|
|
})
|
|
|
|
// Initialize persistent notifications setting
|
|
const toastStore = useToastStore()
|
|
toastStore.setPersistentNotifications(settingsStore.settings.persistent_notifications === 'true')
|
|
|
|
await recurringStore.fetchEntries()
|
|
recurringStore.checkRecurrences()
|
|
setInterval(() => recurringStore.checkRecurrences(), 60000)
|
|
setInterval(() => onboardingStore.detectCompletions(), 5 * 60000)
|
|
|
|
// Background calendar sync
|
|
async function syncCalendars() {
|
|
try {
|
|
const sources = await invoke<any[]>('get_calendar_sources')
|
|
for (const source of sources) {
|
|
if (source.source_type === 'url' && source.enabled && source.url) {
|
|
try {
|
|
const resp = await fetch(source.url)
|
|
if (resp.ok) {
|
|
const content = await resp.text()
|
|
await invoke('import_ics_file', { sourceId: source.id, content })
|
|
}
|
|
} catch (e) {
|
|
console.error('Calendar sync failed for', source.name, e)
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to sync calendars:', e)
|
|
}
|
|
}
|
|
|
|
syncCalendars()
|
|
setInterval(syncCalendars, 30 * 60000)
|
|
|
|
const invoicesStore = useInvoicesStore()
|
|
await invoicesStore.fetchInvoices()
|
|
const overdueCount = await invoicesStore.checkOverdue()
|
|
if (overdueCount > 0) {
|
|
const toastStore = useToastStore()
|
|
toastStore.info(`${overdueCount} invoice(s) now overdue`)
|
|
}
|
|
|
|
await checkScheduledBackup()
|
|
|
|
// End-of-day reminder and weekly summary checks
|
|
const reminderState = { eodShownToday: '', weeklySummaryShownWeek: '' }
|
|
|
|
async function checkReminders() {
|
|
const now = new Date()
|
|
const todayStr = now.toISOString().split('T')[0]
|
|
const currentTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`
|
|
|
|
// End-of-day reminder
|
|
if (settingsStore.settings.eod_reminder_enabled === 'true' && reminderState.eodShownToday !== todayStr) {
|
|
const reminderTime = settingsStore.settings.eod_reminder_time || '17:00'
|
|
if (currentTime >= reminderTime) {
|
|
reminderState.eodShownToday = todayStr
|
|
try {
|
|
const entries = await invoke<any[]>('get_time_entries', { startDate: todayStr, endDate: todayStr })
|
|
const totalSeconds = entries.reduce((sum: number, e: any) => sum + (e.duration || 0), 0)
|
|
const totalHours = totalSeconds / 3600
|
|
const goalHours = parseFloat(settingsStore.settings.daily_goal_hours) || 8
|
|
if (totalHours < goalHours) {
|
|
const remaining = (goalHours - totalHours).toFixed(1)
|
|
const toastStore = useToastStore()
|
|
toastStore.info(`End of day: ${totalHours.toFixed(1)}h logged today, ${remaining}h remaining to reach your ${goalHours}h goal`)
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
}
|
|
|
|
// Weekly summary (Monday check)
|
|
if (settingsStore.settings.weekly_summary_enabled === 'true' && now.getDay() === 1) {
|
|
const weekId = todayStr
|
|
if (reminderState.weeklySummaryShownWeek !== weekId && now.getHours() >= 9) {
|
|
reminderState.weeklySummaryShownWeek = weekId
|
|
try {
|
|
const lastMonday = new Date(now)
|
|
lastMonday.setDate(now.getDate() - 7)
|
|
const lastSunday = new Date(now)
|
|
lastSunday.setDate(now.getDate() - 1)
|
|
const entries = await invoke<any[]>('get_time_entries', {
|
|
startDate: lastMonday.toISOString().split('T')[0],
|
|
endDate: lastSunday.toISOString().split('T')[0],
|
|
})
|
|
const totalSeconds = entries.reduce((sum: number, e: any) => sum + (e.duration || 0), 0)
|
|
const totalHours = totalSeconds / 3600
|
|
const goalHours = parseFloat(settingsStore.settings.weekly_goal_hours) || 40
|
|
const toastStore = useToastStore()
|
|
toastStore.info(`Weekly summary: ${totalHours.toFixed(1)}h logged last week (goal: ${goalHours}h)`)
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
checkReminders()
|
|
setInterval(checkReminders, 60000)
|
|
|
|
registerShortcuts()
|
|
|
|
document.addEventListener('keydown', (e) => {
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
|
e.preventDefault()
|
|
showSearch.value = true
|
|
return
|
|
}
|
|
if (e.key === '?' && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
|
const tag = (e.target as HTMLElement)?.tagName
|
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return
|
|
if ((e.target as HTMLElement)?.isContentEditable) return
|
|
e.preventDefault()
|
|
showShortcuts.value = !showShortcuts.value
|
|
}
|
|
})
|
|
|
|
// Handle window close - backup and optionally hide to tray
|
|
try {
|
|
const { getCurrentWindow } = await import('@tauri-apps/api/window')
|
|
const win = getCurrentWindow()
|
|
win.onCloseRequested(async (event) => {
|
|
if (settingsStore.settings.auto_backup === 'true' && settingsStore.settings.backup_path) {
|
|
try {
|
|
await invoke('auto_backup', { backupDir: settingsStore.settings.backup_path })
|
|
} catch (e) {
|
|
console.error('Auto-backup failed:', e)
|
|
}
|
|
}
|
|
if (settingsStore.settings.close_to_tray === 'true') {
|
|
event.preventDefault()
|
|
await win.hide()
|
|
} else {
|
|
await win.destroy()
|
|
}
|
|
})
|
|
} catch (e) {
|
|
console.error('Failed to register close handler:', e)
|
|
}
|
|
|
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
|
if (settingsStore.settings.theme_mode === 'system') applyTheme()
|
|
})
|
|
|
|
window.matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change', () => {
|
|
if (settingsStore.settings.reduce_motion === 'system' || !settingsStore.settings.reduce_motion) {
|
|
applyMotion()
|
|
}
|
|
})
|
|
})
|
|
|
|
watch(() => [settingsStore.settings.theme_mode, settingsStore.settings.accent_color], () => {
|
|
applyTheme()
|
|
})
|
|
|
|
watch(() => settingsStore.settings.reduce_motion, () => {
|
|
applyMotion()
|
|
})
|
|
|
|
watch(() => settingsStore.settings.timer_font, (newFont) => {
|
|
if (newFont) loadAndApplyTimerFont(newFont)
|
|
})
|
|
|
|
watch(() => settingsStore.settings.dyslexia_mode, (val) => {
|
|
if (val === 'true') {
|
|
loadAndApplyUIFont('OpenDyslexic')
|
|
} else {
|
|
const uiFont = settingsStore.settings.ui_font || 'Inter'
|
|
loadAndApplyUIFont(uiFont)
|
|
}
|
|
})
|
|
|
|
watch(() => settingsStore.settings.ui_font, (newFont) => {
|
|
if (settingsStore.settings.dyslexia_mode !== 'true' && newFont) {
|
|
loadAndApplyUIFont(newFont)
|
|
}
|
|
})
|
|
|
|
watch(() => [settingsStore.settings.shortcut_toggle_timer, settingsStore.settings.shortcut_show_app, settingsStore.settings.shortcut_quick_entry], () => {
|
|
registerShortcuts()
|
|
})
|
|
|
|
watch(() => settingsStore.settings.sound_enabled, (val) => {
|
|
audioEngine.updateSettings({ enabled: val === 'true' })
|
|
})
|
|
|
|
watch(() => settingsStore.settings.sound_mode, (val) => {
|
|
if (val) audioEngine.updateSettings({ mode: val as 'synthesized' | 'system' | 'custom' })
|
|
})
|
|
|
|
watch(() => settingsStore.settings.sound_volume, (val) => {
|
|
audioEngine.updateSettings({ volume: parseInt(val) || 70 })
|
|
})
|
|
|
|
watch(() => settingsStore.settings.sound_events, (val) => {
|
|
if (val) {
|
|
try {
|
|
const parsed = JSON.parse(val)
|
|
audioEngine.updateSettings({ events: { ...DEFAULT_EVENTS, ...parsed } as Record<SoundEvent, boolean> })
|
|
} catch { /* ignore parse errors */ }
|
|
}
|
|
})
|
|
|
|
watch(() => settingsStore.settings.persistent_notifications, (val) => {
|
|
const toastStore = useToastStore()
|
|
toastStore.setPersistentNotifications(val === 'true')
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<a href="#main-content" class="sr-only sr-only-focusable fixed top-0 left-0 z-[200] bg-accent text-white px-4 py-2 rounded-br-lg">
|
|
Skip to main content
|
|
</a>
|
|
<div class="h-full w-full flex flex-col bg-bg-base">
|
|
<TitleBar />
|
|
<div class="flex-1 flex overflow-hidden">
|
|
<NavRail />
|
|
<main id="main-content" class="flex-1 overflow-auto" tabindex="-1">
|
|
<router-view v-slot="{ Component }">
|
|
<Transition name="page" mode="out-in" :duration="{ enter: 250, leave: 150 }">
|
|
<component :is="Component" :key="$route.path" />
|
|
</Transition>
|
|
</router-view>
|
|
</main>
|
|
</div>
|
|
</div>
|
|
<ToastNotification />
|
|
<RecurringPromptDialog
|
|
:show="recurringStore.pendingPrompt !== null"
|
|
:project-name="getProjectName(recurringStore.pendingPrompt?.project_id)"
|
|
:project-color="getProjectColor(recurringStore.pendingPrompt?.project_id)"
|
|
:task-name="''"
|
|
:description="recurringStore.pendingPrompt?.description || ''"
|
|
:duration="recurringStore.pendingPrompt?.duration || 0"
|
|
:time-of-day="recurringStore.pendingPrompt?.time_of_day || ''"
|
|
@confirm="recurringStore.confirmPrompt()"
|
|
@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"
|
|
/>
|
|
<QuickEntryDialog
|
|
:show="showQuickEntry"
|
|
@close="showQuickEntry = false"
|
|
@saved="showQuickEntry = false"
|
|
/>
|
|
<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 />
|
|
<KeyboardShortcutsDialog :show="showShortcuts" @close="showShortcuts = false" />
|
|
<GlobalSearchDialog :show="showSearch" @close="showSearch = false" />
|
|
</template>
|