Files
zeroclock/src/App.vue
Your Name ee82abe63e feat: tooltips, two-column timer, font selector, tray behavior, icons, readme
- Custom tooltip directive (WCAG AAA) on every button in the app
- Two-column timer layout with sticky hero and recent entries sidebar
- Timer font selector with 16 monospace Google Fonts and live preview
- UI font selector with 15+ Google Fonts
- Close-to-tray and minimize-to-tray settings
- New app icons (no-glow variants), platform icon set
- Mini timer pop-out window
- Favorites strip with drag-reorder and inline actions
- Comprehensive README with feature documentation
- Remove tracked files that belong in gitignore
2026-02-21 01:15:57 +02:00

451 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()
}
})
} 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>