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
This commit is contained in:
Your Name
2026-02-21 01:15:57 +02:00
parent ef6255042d
commit ee82abe63e
144 changed files with 13351 additions and 3456 deletions

View File

@@ -17,6 +17,8 @@ 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'
@@ -26,6 +28,8 @@ 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 ''
@@ -39,7 +43,10 @@ function getProjectColor(projectId?: number): string {
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()
@@ -72,6 +79,8 @@ async function registerShortcuts() {
})
} catch (e) {
console.error('Failed to register shortcuts:', e)
} finally {
shortcutRegistering = false
}
}
@@ -89,6 +98,39 @@ function applyTheme() {
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
@@ -184,15 +226,94 @@ onMounted(async () => {
const invoicesStore = useInvoicesStore()
await invoicesStore.fetchInvoices()
await invoicesStore.checkOverdue()
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()
// Auto-backup on window close
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 () => {
win.onCloseRequested(async (event) => {
if (settingsStore.settings.auto_backup === 'true' && settingsStore.settings.backup_path) {
try {
await invoke('auto_backup', { backupDir: settingsStore.settings.backup_path })
@@ -200,6 +321,10 @@ onMounted(async () => {
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)
@@ -320,4 +445,6 @@ watch(() => settingsStore.settings.persistent_notifications, (val) => {
<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>