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
- 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 66d12e8c5c
commit c4703dfe98
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>

View File

@@ -0,0 +1,115 @@
<script setup lang="ts">
import { ref, watch, nextTick, onUnmounted } from 'vue'
import { AlertTriangle } from 'lucide-vue-next'
import { useFocusTrap } from '../utils/focusTrap'
const props = defineProps<{
show: boolean
entityType: string
entityName: string
impacts: { label: string; count: number }[]
}>()
const emit = defineEmits<{
confirm: []
cancel: []
}>()
const dialogRef = ref<HTMLElement | null>(null)
const deleteReady = ref(false)
const countdown = ref(3)
const liveAnnouncement = ref('')
let countdownTimer: number | null = null
const { activate, deactivate } = useFocusTrap()
watch(() => props.show, async (val) => {
if (val) {
deleteReady.value = false
countdown.value = 3
liveAnnouncement.value = `Delete ${props.entityName}? This will also remove related data. Delete button available in 3 seconds.`
await nextTick()
if (dialogRef.value) activate(dialogRef.value, { onDeactivate: () => emit('cancel') })
countdownTimer = window.setInterval(() => {
countdown.value--
if (countdown.value <= 0) {
deleteReady.value = true
if (countdownTimer) clearInterval(countdownTimer)
}
}, 1000)
} else {
deactivate()
if (countdownTimer) clearInterval(countdownTimer)
}
})
onUnmounted(() => {
if (countdownTimer) clearInterval(countdownTimer)
})
</script>
<template>
<Teleport to="#app">
<Transition name="modal">
<div
v-if="show"
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-[60]"
@click.self="emit('cancel')"
>
<div
ref="dialogRef"
role="alertdialog"
aria-modal="true"
aria-labelledby="cascade-delete-title"
aria-describedby="cascade-delete-desc"
class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-sm p-6"
>
<div class="flex items-start gap-3 mb-4">
<AlertTriangle class="w-5 h-5 text-status-error shrink-0 mt-0.5" :stroke-width="2" aria-hidden="true" />
<div>
<h2 id="cascade-delete-title" class="text-[0.875rem] font-semibold text-text-primary">
Delete {{ entityName }}?
</h2>
<p id="cascade-delete-desc" class="text-[0.75rem] text-text-secondary mt-1">
This will permanently delete the {{ entityType }} and all related data:
</p>
</div>
</div>
<ul class="space-y-1.5 mb-4 pl-8" role="list" aria-label="Data that will be deleted">
<li
v-for="impact in impacts.filter(i => i.count > 0)"
:key="impact.label"
class="text-[0.75rem] text-text-secondary"
>
{{ impact.count }} {{ impact.label }}
</li>
</ul>
<div class="flex items-center justify-end gap-2">
<button
@click="emit('cancel')"
class="px-3 py-1.5 text-[0.75rem] text-text-secondary hover:text-text-primary transition-colors duration-150 rounded-md focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
Cancel
</button>
<button
@click="deleteReady && emit('confirm')"
:disabled="!deleteReady"
:aria-disabled="!deleteReady"
:aria-label="'Permanently delete ' + entityName + ' and all related data'"
class="px-3 py-1.5 text-[0.75rem] font-medium rounded-md transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
:class="deleteReady
? 'bg-status-error text-white hover:bg-red-600'
: 'bg-bg-elevated text-text-tertiary cursor-not-allowed'"
>
{{ deleteReady ? 'Delete Everything' : `Wait ${countdown}s...` }}
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
<div class="sr-only" aria-live="assertive" aria-atomic="true">{{ liveAnnouncement }}</div>
</template>

View File

@@ -2,6 +2,7 @@
import { ref, computed, watch, onBeforeUnmount, nextTick } from 'vue'
import { Pipette } from 'lucide-vue-next'
import { computeDropdownPosition } from '../utils/dropdown'
import { useFocusTrap } from '../utils/focusTrap'
interface Props {
modelValue: string
@@ -16,6 +17,8 @@ const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const { activate: activateTrap, deactivate: deactivateTrap } = useFocusTrap()
const isOpen = ref(false)
const triggerRef = ref<HTMLButtonElement | null>(null)
const panelRef = ref<HTMLDivElement | null>(null)
@@ -245,7 +248,7 @@ function onHuePointerUp() {
function updatePosition() {
if (!triggerRef.value) return
panelStyle.value = computeDropdownPosition(triggerRef.value, { minWidth: 260, estimatedHeight: 330 })
panelStyle.value = computeDropdownPosition(triggerRef.value, { minWidth: 260, estimatedHeight: 330, panelEl: panelRef.value })
}
// ── Open / Close ────────────────────────────────────────────────────
@@ -261,15 +264,18 @@ function open() {
updatePosition()
nextTick(() => {
updatePosition()
drawGradient()
drawHueStrip()
document.addEventListener('click', onClickOutside, true)
document.addEventListener('scroll', onScrollOrResize, true)
window.addEventListener('resize', onScrollOrResize)
if (panelRef.value) activateTrap(panelRef.value)
})
}
function close() {
deactivateTrap()
isOpen.value = false
document.removeEventListener('click', onClickOutside, true)
document.removeEventListener('scroll', onScrollOrResize, true)
@@ -296,6 +302,34 @@ onBeforeUnmount(() => {
document.removeEventListener('scroll', onScrollOrResize, true)
window.removeEventListener('resize', onScrollOrResize)
})
// ── Keyboard Handlers for Accessibility ─────────────────────────────
function onGradientKeydown(e: KeyboardEvent) {
const step = 5
let handled = false
if (e.key === 'ArrowRight') { saturation.value = Math.min(100, saturation.value + step); handled = true }
else if (e.key === 'ArrowLeft') { saturation.value = Math.max(0, saturation.value - step); handled = true }
else if (e.key === 'ArrowUp') { brightness.value = Math.min(100, brightness.value + step); handled = true }
else if (e.key === 'ArrowDown') { brightness.value = Math.max(0, brightness.value - step); handled = true }
if (handled) {
e.preventDefault()
emitColor()
}
}
function onHueKeydown(e: KeyboardEvent) {
const step = 5
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
e.preventDefault()
hue.value = Math.min(360, hue.value + step)
emitColor()
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
e.preventDefault()
hue.value = Math.max(0, hue.value - step)
emitColor()
}
}
</script>
<template>
@@ -305,6 +339,9 @@ onBeforeUnmount(() => {
ref="triggerRef"
type="button"
@click="toggle"
aria-label="Color picker"
:aria-expanded="isOpen"
aria-haspopup="dialog"
class="w-full flex items-center gap-2.5 px-3 py-2 bg-bg-inset border border-border-subtle rounded-xl text-[0.8125rem] text-left cursor-pointer transition-colors"
:style="
isOpen
@@ -317,20 +354,26 @@ onBeforeUnmount(() => {
"
>
<span
role="img"
:aria-label="'Current color: ' + (modelValue?.toUpperCase() || '#000000')"
class="w-5 h-5 rounded-md border border-border-subtle shrink-0"
:style="{ backgroundColor: modelValue }"
/>
<span class="text-text-primary font-mono text-[0.75rem] tracking-wide">{{ modelValue?.toUpperCase() || '#000000' }}</span>
<Pipette class="w-4 h-4 text-text-secondary shrink-0 ml-auto" :stroke-width="2" />
<Pipette class="w-4 h-4 text-text-secondary shrink-0 ml-auto" :stroke-width="2" aria-hidden="true" />
</button>
<!-- Color picker popover -->
<Teleport to="body">
<Teleport to="#app">
<Transition name="dropdown">
<div
v-if="isOpen"
ref="panelRef"
:style="panelStyle"
role="dialog"
aria-modal="true"
aria-label="Choose color"
@keydown.escape.prevent="close"
class="bg-bg-surface border border-border-visible rounded-xl shadow-[0_4px_24px_rgba(0,0,0,0.4)] overflow-hidden"
>
<!-- Preset swatches -->
@@ -341,7 +384,9 @@ onBeforeUnmount(() => {
:key="c"
type="button"
@click="selectPreset(c)"
class="w-6 h-6 rounded-full border-2 transition-colors cursor-pointer hover:scale-110"
:aria-label="'Color preset ' + c"
:aria-pressed="currentHex === c.toUpperCase()"
class="w-8 h-8 rounded-full border-2 transition-colors cursor-pointer hover:scale-110"
:class="currentHex === c.toUpperCase() ? 'border-text-primary' : 'border-transparent'"
:style="{ backgroundColor: c }"
/>
@@ -351,11 +396,15 @@ onBeforeUnmount(() => {
<!-- Saturation/Brightness gradient -->
<div class="px-3 pb-2">
<div
role="application"
aria-label="Saturation and brightness"
tabindex="0"
class="relative rounded-lg overflow-hidden cursor-crosshair"
style="touch-action: none;"
@pointerdown="onGradientPointerDown"
@pointermove="onGradientPointerMove"
@pointerup="onGradientPointerUp"
@keydown="onGradientKeydown"
>
<canvas
ref="gradientRef"
@@ -378,11 +427,18 @@ onBeforeUnmount(() => {
<!-- Hue slider -->
<div class="px-3 pb-2">
<div
role="slider"
aria-label="Hue"
:aria-valuenow="Math.round(hue)"
aria-valuemin="0"
aria-valuemax="360"
tabindex="0"
class="relative rounded-md overflow-hidden cursor-pointer"
style="touch-action: none;"
@pointerdown="onHuePointerDown"
@pointermove="onHuePointerMove"
@pointerup="onHuePointerUp"
@keydown="onHueKeydown"
>
<canvas
ref="hueRef"
@@ -404,6 +460,8 @@ onBeforeUnmount(() => {
<!-- Hex input + preview -->
<div class="px-3 pb-3 flex items-center gap-2">
<span
role="img"
:aria-label="'Selected color: ' + currentHex"
class="w-8 h-8 rounded-lg border border-border-subtle shrink-0"
:style="{ backgroundColor: currentHex }"
/>
@@ -412,6 +470,7 @@ onBeforeUnmount(() => {
@input="onHexInput"
type="text"
maxlength="7"
aria-label="Hex color value"
class="flex-1 px-2.5 py-1.5 bg-bg-inset border border-border-subtle rounded-lg text-[0.75rem] text-text-primary font-mono tracking-wide placeholder-text-tertiary focus:outline-none focus:border-border-visible"
placeholder="#D97706"
/>

View File

@@ -2,6 +2,8 @@
import { ref, computed, watch, onBeforeUnmount, nextTick } from 'vue'
import { Calendar, ChevronLeft, ChevronRight } from 'lucide-vue-next'
import { getLocaleCode } from '../utils/locale'
import { getFixedPositionMapping } from '../utils/dropdown'
import { useFocusTrap } from '../utils/focusTrap'
interface Props {
modelValue: string
@@ -24,6 +26,8 @@ const emit = defineEmits<{
'update:minute': [value: number]
}>()
const { activate: activateTrap, deactivate: deactivateTrap } = useFocusTrap()
const isOpen = ref(false)
const triggerRef = ref<HTMLButtonElement | null>(null)
const panelRef = ref<HTMLDivElement | null>(null)
@@ -51,6 +55,9 @@ const displayText = computed(() => {
return datePart
})
// ── Reduced motion check ────────────────────────────────────────────
const scrollBehavior = window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' as const : 'smooth' as const
// ── Time wheel ──────────────────────────────────────────────────────
const WHEEL_ITEM_H = 36
const WHEEL_VISIBLE = 5
@@ -102,7 +109,7 @@ function onHourWheel(e: WheelEvent) {
const dir = e.deltaY > 0 ? 1 : -1
const cur = Math.round(hourWheelRef.value.scrollTop / WHEEL_ITEM_H)
const next = Math.min(23, Math.max(0, cur + dir))
hourWheelRef.value.scrollTo({ top: next * WHEEL_ITEM_H, behavior: 'smooth' })
hourWheelRef.value.scrollTo({ top: next * WHEEL_ITEM_H, behavior: scrollBehavior })
}
function onMinuteWheel(e: WheelEvent) {
@@ -111,7 +118,29 @@ function onMinuteWheel(e: WheelEvent) {
const dir = e.deltaY > 0 ? 1 : -1
const cur = Math.round(minuteWheelRef.value.scrollTop / WHEEL_ITEM_H)
const next = Math.min(59, Math.max(0, cur + dir))
minuteWheelRef.value.scrollTo({ top: next * WHEEL_ITEM_H, behavior: 'smooth' })
minuteWheelRef.value.scrollTo({ top: next * WHEEL_ITEM_H, behavior: scrollBehavior })
}
// Keyboard support for time wheels
function onWheelKeydown(e: KeyboardEvent, type: 'hour' | 'minute') {
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return
e.preventDefault()
const dir = e.key === 'ArrowUp' ? -1 : 1
if (type === 'hour') {
const next = Math.min(23, Math.max(0, internalHour.value + dir))
internalHour.value = next
emit('update:hour', next)
if (hourWheelRef.value) {
hourWheelRef.value.scrollTo({ top: next * WHEEL_ITEM_H, behavior: scrollBehavior })
}
} else {
const next = Math.min(59, Math.max(0, internalMinute.value + dir))
internalMinute.value = next
emit('update:minute', next)
if (minuteWheelRef.value) {
minuteWheelRef.value.scrollTo({ top: next * WHEEL_ITEM_H, behavior: scrollBehavior })
}
}
}
// Click-and-drag support
@@ -141,7 +170,7 @@ function onWheelPointerUp(e: PointerEvent) {
el.releasePointerCapture(e.pointerId)
// Snap to nearest item
const index = Math.round(el.scrollTop / WHEEL_ITEM_H)
el.scrollTo({ top: index * WHEEL_ITEM_H, behavior: 'smooth' })
el.scrollTo({ top: index * WHEEL_ITEM_H, behavior: scrollBehavior })
}
function scrollWheelsToTime() {
@@ -234,33 +263,28 @@ const dayCells = computed<DayCell[]>(() => {
const dayHeaders = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
// ── Positioning ─────────────────────────────────────────────────────
function getZoomFactor(): number {
const app = document.getElementById('app')
if (!app) return 1
const zoom = (app.style as any).zoom
return zoom ? parseFloat(zoom) / 100 : 1
}
function updatePosition() {
if (!triggerRef.value) return
const rect = triggerRef.value.getBoundingClientRect()
const zoom = getZoomFactor()
const panelWidth = props.showTime ? 390 : 280
const renderedWidth = panelWidth * zoom
const { scaleX, scaleY, offsetX, offsetY } = getFixedPositionMapping()
const gap = 4
let leftViewport = rect.left
if (leftViewport + renderedWidth > window.innerWidth) {
leftViewport = window.innerWidth - renderedWidth - 8
const panelWidth = props.showTime ? 390 : 280
const estW = panelWidth * scaleX
const vpW = window.innerWidth
let leftVP = rect.left
if (leftVP + estW > vpW - gap) {
leftVP = vpW - estW - gap
}
if (leftViewport < 0) leftViewport = 0
if (leftVP < gap) leftVP = gap
panelStyle.value = {
position: 'fixed',
top: `${(rect.bottom + 4) / zoom}px`,
left: `${leftViewport / zoom}px`,
top: `${(rect.bottom + gap - offsetY) / scaleY}px`,
left: `${(leftVP - offsetX) / scaleX}px`,
width: `${panelWidth}px`,
zIndex: '9999',
zoom: `${zoom * 100}%`,
}
}
@@ -295,10 +319,12 @@ function open() {
if (props.showTime) {
scrollWheelsToTime()
}
if (panelRef.value) activateTrap(panelRef.value)
})
}
function close() {
deactivateTrap()
isOpen.value = false
document.removeEventListener('click', onClickOutside, true)
document.removeEventListener('scroll', onScrollOrResize, true)
@@ -385,6 +411,8 @@ onBeforeUnmount(() => {
ref="triggerRef"
type="button"
@click="toggle"
:aria-expanded="isOpen"
aria-haspopup="dialog"
class="w-full flex items-center justify-between gap-2 px-3 py-2 bg-bg-inset border border-border-subtle rounded-xl text-[0.8125rem] text-left cursor-pointer transition-colors"
:style="
isOpen
@@ -403,18 +431,23 @@ onBeforeUnmount(() => {
{{ displayText ?? placeholder }}
</span>
<Calendar
aria-hidden="true"
class="w-4 h-4 text-text-secondary shrink-0"
:stroke-width="2"
/>
</button>
<!-- Calendar popover -->
<Teleport to="body">
<Teleport to="#app">
<Transition name="dropdown">
<div
v-if="isOpen"
ref="panelRef"
:style="panelStyle"
role="dialog"
aria-modal="true"
aria-label="Date picker"
@keydown.escape.prevent="close"
class="bg-bg-surface border border-border-visible rounded-xl shadow-[0_4px_24px_rgba(0,0,0,0.4)] overflow-hidden"
>
<!-- Month/year header -->
@@ -422,9 +455,11 @@ onBeforeUnmount(() => {
<button
type="button"
@click="prevMonthNav"
aria-label="Previous month"
v-tooltip="'Previous month'"
class="p-1 rounded-lg hover:bg-bg-elevated transition-colors cursor-pointer text-text-secondary hover:text-text-primary"
>
<ChevronLeft class="w-4 h-4" :stroke-width="2" />
<ChevronLeft aria-hidden="true" class="w-4 h-4" :stroke-width="2" />
</button>
<span class="text-[0.8125rem] font-medium text-text-primary select-none">
{{ viewMonthLabel }}
@@ -432,9 +467,11 @@ onBeforeUnmount(() => {
<button
type="button"
@click="nextMonthNav"
aria-label="Next month"
v-tooltip="'Next month'"
class="p-1 rounded-lg hover:bg-bg-elevated transition-colors cursor-pointer text-text-secondary hover:text-text-primary"
>
<ChevronRight class="w-4 h-4" :stroke-width="2" />
<ChevronRight aria-hidden="true" class="w-4 h-4" :stroke-width="2" />
</button>
</div>
@@ -443,10 +480,11 @@ onBeforeUnmount(() => {
<!-- Calendar column -->
<div class="flex-1 min-w-0">
<!-- Day-of-week headers -->
<div class="grid grid-cols-7 px-2">
<div class="grid grid-cols-7 px-2" role="row">
<div
v-for="header in dayHeaders"
:key="header"
role="columnheader"
class="text-center text-[0.6875rem] font-medium text-text-tertiary py-1 select-none"
>
{{ header }}
@@ -454,13 +492,15 @@ onBeforeUnmount(() => {
</div>
<!-- Day grid -->
<div class="grid grid-cols-7 px-2 pb-2">
<div class="grid grid-cols-7 px-2 pb-2" role="grid" aria-label="Calendar days">
<button
v-for="(cell, index) in dayCells"
:key="index"
type="button"
:disabled="!cell.isCurrentMonth"
@click="selectDay(cell)"
:aria-label="new Date(cell.year, cell.month, cell.date).toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })"
:aria-selected="cell.isCurrentMonth ? cell.dateString === modelValue : undefined"
class="relative flex items-center justify-center h-8 w-full text-[0.75rem] rounded-lg transition-colors select-none"
:class="[
!cell.isCurrentMonth
@@ -493,10 +533,17 @@ onBeforeUnmount(() => {
<!-- Scrollable wheel -->
<div
ref="hourWheelRef"
tabindex="0"
role="spinbutton"
:aria-valuenow="internalHour"
aria-valuemin="0"
aria-valuemax="23"
aria-label="Hour"
class="absolute inset-0 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden cursor-grab active:cursor-grabbing"
style="touch-action: none; -webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 25%, black 75%, transparent 100%); mask-image: linear-gradient(to bottom, transparent 0%, black 25%, black 75%, transparent 100%);"
@scroll="onHourScroll"
@wheel.prevent="onHourWheel"
@keydown="onWheelKeydown($event, 'hour')"
@pointerdown.prevent="onWheelPointerDown"
@pointermove="onWheelPointerMove"
@pointerup="onWheelPointerUp"
@@ -530,10 +577,17 @@ onBeforeUnmount(() => {
<!-- Scrollable wheel -->
<div
ref="minuteWheelRef"
tabindex="0"
role="spinbutton"
:aria-valuenow="internalMinute"
aria-valuemin="0"
aria-valuemax="59"
aria-label="Minute"
class="absolute inset-0 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden cursor-grab active:cursor-grabbing"
style="touch-action: none; -webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 25%, black 75%, transparent 100%); mask-image: linear-gradient(to bottom, transparent 0%, black 25%, black 75%, transparent 100%);"
@scroll="onMinuteScroll"
@wheel.prevent="onMinuteWheel"
@keydown="onWheelKeydown($event, 'minute')"
@pointerdown.prevent="onWheelPointerDown"
@pointermove="onWheelPointerMove"
@pointerup="onWheelPointerUp"

View File

@@ -0,0 +1,169 @@
<template>
<div
role="group"
aria-label="Date range presets"
class="flex flex-wrap gap-1.5"
@keydown="onKeydown"
>
<button
v-for="(preset, index) in presets"
:key="preset.label"
type="button"
:aria-pressed="isActive(preset)"
:tabindex="index === focusedIndex ? 0 : -1"
:ref="(el) => { if (el) buttonRefs[index] = el as HTMLButtonElement }"
class="px-3 py-1 text-[0.6875rem] font-medium rounded-full border transition-colors duration-150"
:class="isActive(preset)
? 'bg-accent text-bg-base border-accent'
: 'border-border-subtle text-text-secondary hover:text-text-primary hover:border-border-visible'"
@click="selectPreset(preset)"
>
{{ preset.label }}
</button>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const props = defineProps<{
startDate?: string
endDate?: string
}>()
const emit = defineEmits<{
select: [payload: { start: string; end: string }]
}>()
const focusedIndex = ref(0)
const buttonRefs = ref<HTMLButtonElement[]>([])
interface Preset {
label: string
getRange: () => { start: string; end: string }
}
function fmt(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const dd = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${dd}`
}
function getMonday(d: Date): Date {
const result = new Date(d)
const day = result.getDay()
const diff = day === 0 ? -6 : 1 - day
result.setDate(result.getDate() + diff)
return result
}
const presets: Preset[] = [
{
label: 'Today',
getRange: () => {
const today = fmt(new Date())
return { start: today, end: today }
},
},
{
label: 'This Week',
getRange: () => {
const now = new Date()
const monday = getMonday(now)
const sunday = new Date(monday)
sunday.setDate(monday.getDate() + 6)
return { start: fmt(monday), end: fmt(sunday) }
},
},
{
label: 'Last Week',
getRange: () => {
const now = new Date()
const thisMonday = getMonday(now)
const lastMonday = new Date(thisMonday)
lastMonday.setDate(thisMonday.getDate() - 7)
const lastSunday = new Date(lastMonday)
lastSunday.setDate(lastMonday.getDate() + 6)
return { start: fmt(lastMonday), end: fmt(lastSunday) }
},
},
{
label: 'This Month',
getRange: () => {
const now = new Date()
const first = new Date(now.getFullYear(), now.getMonth(), 1)
const last = new Date(now.getFullYear(), now.getMonth() + 1, 0)
return { start: fmt(first), end: fmt(last) }
},
},
{
label: 'Last Month',
getRange: () => {
const now = new Date()
const first = new Date(now.getFullYear(), now.getMonth() - 1, 1)
const last = new Date(now.getFullYear(), now.getMonth(), 0)
return { start: fmt(first), end: fmt(last) }
},
},
{
label: 'This Quarter',
getRange: () => {
const now = new Date()
const qMonth = Math.floor(now.getMonth() / 3) * 3
const first = new Date(now.getFullYear(), qMonth, 1)
const last = new Date(now.getFullYear(), qMonth + 3, 0)
return { start: fmt(first), end: fmt(last) }
},
},
{
label: 'Last 30 Days',
getRange: () => {
const now = new Date()
const start = new Date(now)
start.setDate(now.getDate() - 29)
return { start: fmt(start), end: fmt(now) }
},
},
{
label: 'This Year',
getRange: () => {
const now = new Date()
const first = new Date(now.getFullYear(), 0, 1)
return { start: fmt(first), end: fmt(now) }
},
},
]
function isActive(preset: Preset): boolean {
if (!props.startDate || !props.endDate) return false
const range = preset.getRange()
return range.start === props.startDate && range.end === props.endDate
}
function selectPreset(preset: Preset) {
const range = preset.getRange()
emit('select', range)
}
function onKeydown(e: KeyboardEvent) {
let next = focusedIndex.value
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
e.preventDefault()
next = (focusedIndex.value + 1) % presets.length
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault()
next = (focusedIndex.value - 1 + presets.length) % presets.length
} else if (e.key === 'Home') {
e.preventDefault()
next = 0
} else if (e.key === 'End') {
e.preventDefault()
next = presets.length - 1
} else {
return
}
focusedIndex.value = next
buttonRefs.value[next]?.focus()
}
</script>

View File

@@ -1,33 +1,49 @@
<script setup lang="ts">
defineProps<{ show: boolean }>()
defineEmits<{
import { watch, ref } from 'vue'
import { useFocusTrap } from '../utils/focusTrap'
const props = defineProps<{ show: boolean }>()
const emit = defineEmits<{
cancel: []
discard: []
}>()
const { activate, deactivate } = useFocusTrap()
const dialogRef = ref<HTMLElement | null>(null)
watch(() => props.show, (val) => {
if (val) {
setTimeout(() => {
if (dialogRef.value) activate(dialogRef.value, { onDeactivate: () => emit('cancel') })
}, 50)
} else {
deactivate()
}
})
</script>
<template>
<Transition name="modal">
<div
v-if="show"
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-[60]"
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
@click.self="$emit('cancel')"
>
<div class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-xs p-6">
<h2 class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-2">Unsaved Changes</h2>
<p class="text-[0.75rem] text-text-secondary mb-6">
<div ref="dialogRef" role="alertdialog" aria-modal="true" aria-labelledby="discard-title" aria-describedby="discard-desc" class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-xs p-6">
<h2 id="discard-title" class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-2">Unsaved Changes</h2>
<p id="discard-desc" class="text-[0.75rem] text-text-secondary mb-6">
You have unsaved changes. Do you want to discard them?
</p>
<div class="flex justify-end gap-3">
<button
@click="$emit('cancel')"
class="px-4 py-2 border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated transition-colors duration-150"
class="px-4 py-2 text-[0.8125rem] border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated transition-colors duration-150"
>
Keep Editing
</button>
<button
@click="$emit('discard')"
class="px-4 py-2 border border-status-error text-status-error font-medium rounded-lg hover:bg-status-error/10 transition-colors duration-150"
class="px-4 py-2 text-[0.8125rem] border border-status-error text-status-error font-medium rounded-lg hover:bg-status-error/10 transition-colors duration-150"
>
Discard
</button>

View File

@@ -10,6 +10,8 @@ interface Props {
precision?: number
prefix?: string
suffix?: string
label?: string
compact?: boolean
}
const props = withDefaults(defineProps<Props>(), {
@@ -19,6 +21,8 @@ const props = withDefaults(defineProps<Props>(), {
precision: 0,
prefix: '',
suffix: '',
label: 'Number input',
compact: false,
})
const emit = defineEmits<{
@@ -84,24 +88,39 @@ function cancelEdit() {
</script>
<template>
<div class="flex items-center gap-2">
<div
class="flex items-center"
:class="compact ? 'gap-1' : 'gap-2'"
role="group"
:aria-label="label"
>
<button
type="button"
aria-label="Decrease value"
v-tooltip="'Decrease'"
@mousedown.prevent="startHold(decrement)"
@mouseup="stopHold"
@mouseleave="stopHold"
@touchstart.prevent="startHold(decrement)"
@touchend="stopHold"
class="w-8 h-8 flex items-center justify-center border border-border-visible rounded-lg text-text-secondary hover:text-text-primary hover:bg-bg-elevated transition-colors disabled:opacity-40 disabled:cursor-not-allowed shrink-0"
class="flex items-center justify-center border border-border-visible rounded-lg text-text-secondary hover:text-text-primary hover:bg-bg-elevated transition-colors disabled:opacity-40 disabled:cursor-not-allowed shrink-0"
:class="compact ? 'w-6 h-6' : 'w-8 h-8'"
:disabled="modelValue <= min"
>
<Minus class="w-3.5 h-3.5" :stroke-width="2" />
<Minus :class="compact ? 'w-3 h-3' : 'w-3.5 h-3.5'" aria-hidden="true" :stroke-width="2" />
</button>
<div
v-if="!isEditing"
@click="startEdit"
class="min-w-[4rem] text-center text-[0.8125rem] font-mono text-text-primary cursor-text select-none"
@keydown.enter="startEdit"
@keydown.space.prevent="startEdit"
tabindex="0"
role="button"
:aria-label="'Edit value: ' + displayValue"
class="text-center font-mono text-text-primary cursor-text select-none"
:class="compact ? 'min-w-[2.5rem] text-[0.75rem]' : 'min-w-[4rem] text-[0.8125rem]'"
aria-live="polite"
>
<span v-if="prefix" class="text-text-tertiary">{{ prefix }}</span>
{{ displayValue }}
@@ -113,7 +132,9 @@ function cancelEdit() {
v-model="editValue"
type="text"
inputmode="decimal"
class="w-20 text-center px-1 py-0.5 bg-bg-inset border border-accent rounded-lg text-[0.8125rem] font-mono text-text-primary focus:outline-none"
:aria-label="label"
class="text-center px-1 py-0.5 bg-bg-inset border border-accent rounded-lg font-mono text-text-primary focus:outline-none"
:class="compact ? 'w-16 text-[0.75rem]' : 'w-20 text-[0.8125rem]'"
@blur="commitEdit"
@keydown.enter="commitEdit"
@keydown.escape="cancelEdit"
@@ -121,15 +142,18 @@ function cancelEdit() {
<button
type="button"
aria-label="Increase value"
v-tooltip="'Increase'"
@mousedown.prevent="startHold(increment)"
@mouseup="stopHold"
@mouseleave="stopHold"
@touchstart.prevent="startHold(increment)"
@touchend="stopHold"
class="w-8 h-8 flex items-center justify-center border border-border-visible rounded-lg text-text-secondary hover:text-text-primary hover:bg-bg-elevated transition-colors disabled:opacity-40 disabled:cursor-not-allowed shrink-0"
class="flex items-center justify-center border border-border-visible rounded-lg text-text-secondary hover:text-text-primary hover:bg-bg-elevated transition-colors disabled:opacity-40 disabled:cursor-not-allowed shrink-0"
:class="compact ? 'w-6 h-6' : 'w-8 h-8'"
:disabled="modelValue >= max"
>
<Plus class="w-3.5 h-3.5" :stroke-width="2" />
<Plus :class="compact ? 'w-3 h-3' : 'w-3.5 h-3.5'" aria-hidden="true" :stroke-width="2" />
</button>
</div>
</template>

View File

@@ -12,6 +12,7 @@ interface Props {
disabled?: boolean
placeholderValue?: any
searchable?: boolean
ariaLabelledby?: string
}
const props = withDefaults(defineProps<Props>(), {
@@ -27,6 +28,7 @@ const emit = defineEmits<{
'update:modelValue': [value: any]
}>()
const listboxId = 'appselect-lb-' + Math.random().toString(36).slice(2, 9)
const isOpen = ref(false)
const highlightedIndex = ref(-1)
const triggerRef = ref<HTMLButtonElement | null>(null)
@@ -80,7 +82,10 @@ function isSelected(item: any): boolean {
function updatePosition() {
if (!triggerRef.value) return
panelStyle.value = computeDropdownPosition(triggerRef.value, { estimatedHeight: 280 })
panelStyle.value = computeDropdownPosition(triggerRef.value, {
estimatedHeight: 280,
panelEl: panelRef.value,
})
}
function toggle() {
@@ -108,6 +113,8 @@ function open() {
nextTick(() => {
scrollHighlightedIntoView()
// Reposition with actual panel height (fixes above-flip offset)
updatePosition()
})
document.addEventListener('click', onClickOutside, true)
@@ -220,6 +227,12 @@ onBeforeUnmount(() => {
<button
ref="triggerRef"
type="button"
role="combobox"
:aria-expanded="isOpen"
aria-haspopup="listbox"
:aria-activedescendant="isOpen && highlightedIndex >= 0 ? 'appselect-option-' + highlightedIndex : undefined"
:aria-labelledby="ariaLabelledby"
:aria-controls="isOpen ? listboxId : undefined"
:disabled="disabled"
@click="toggle"
@keydown="onKeydown"
@@ -238,13 +251,16 @@ onBeforeUnmount(() => {
: {}
"
>
<span
:class="isPlaceholderSelected ? 'text-text-tertiary' : 'text-text-primary'"
class="truncate"
>
{{ selectedLabel ?? placeholder }}
</span>
<slot name="selected" :label="selectedLabel ?? placeholder" :is-placeholder="isPlaceholderSelected">
<span
:class="isPlaceholderSelected ? 'text-text-tertiary' : 'text-text-primary'"
class="truncate"
>
{{ selectedLabel ?? placeholder }}
</span>
</slot>
<ChevronDown
aria-hidden="true"
class="w-4 h-4 text-text-secondary shrink-0 transition-transform duration-200"
:class="{ 'rotate-180': isOpen }"
:stroke-width="2"
@@ -252,7 +268,7 @@ onBeforeUnmount(() => {
</button>
<!-- Dropdown panel -->
<Teleport to="body">
<Teleport to="#app">
<Transition name="dropdown">
<div
v-if="isOpen"
@@ -265,15 +281,19 @@ onBeforeUnmount(() => {
ref="searchInputRef"
v-model="searchQuery"
type="text"
aria-label="Search options"
class="w-full px-2.5 py-1.5 bg-bg-inset border border-border-subtle rounded-lg text-[0.75rem] text-text-primary placeholder-text-tertiary focus:outline-none focus:border-border-visible"
placeholder="Search..."
@keydown="onSearchKeydown"
/>
</div>
<div class="max-h-[240px] overflow-y-auto py-1">
<div class="max-h-[240px] overflow-y-auto py-1" role="listbox" :id="listboxId">
<div
v-for="(item, index) in filteredItems"
:key="item._isPlaceholder ? '__placeholder__' : item[valueKey]"
role="option"
:id="'appselect-option-' + index"
:aria-selected="isSelected(item)"
data-option
@click="select(item)"
@mouseenter="highlightedIndex = index"
@@ -284,9 +304,12 @@ onBeforeUnmount(() => {
'text-text-primary': !item._isPlaceholder,
}"
>
<span class="truncate">{{ getOptionLabel(item) }}</span>
<slot name="option" :item="item" :label="getOptionLabel(item)" :selected="isSelected(item)">
<span class="truncate">{{ getOptionLabel(item) }}</span>
</slot>
<Check
v-if="isSelected(item)"
aria-hidden="true"
class="w-4 h-4 text-accent shrink-0"
:stroke-width="2"
/>

View File

@@ -0,0 +1,161 @@
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue'
import { X } from 'lucide-vue-next'
interface Props {
modelValue: string
label?: string
}
const props = withDefaults(defineProps<Props>(), {
label: '',
})
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const recording = ref(false)
const announcement = ref('')
const recorderRef = ref<HTMLDivElement | null>(null)
const isMac = navigator.platform.toUpperCase().includes('MAC')
const keyChips = computed(() => {
if (!props.modelValue) return []
return props.modelValue.split('+').map(k =>
k === 'CmdOrCtrl' ? (isMac ? 'Cmd' : 'Ctrl') : k
)
})
function startRecording() {
recording.value = true
announcement.value = 'Recording. Press your key combination.'
nextTick(() => {
recorderRef.value?.focus()
})
}
function cancelRecording() {
recording.value = false
announcement.value = ''
}
function onKeydown(e: KeyboardEvent) {
if (!recording.value) return
e.preventDefault()
e.stopPropagation()
if (e.key === 'Escape') {
cancelRecording()
return
}
// Ignore standalone modifier keys
const modifierKeys = ['Control', 'Shift', 'Alt', 'Meta']
if (modifierKeys.includes(e.key)) return
// Must have at least one modifier
const hasModifier = e.ctrlKey || e.metaKey || e.shiftKey || e.altKey
if (!hasModifier) return
// Build the shortcut string
const parts: string[] = []
if (e.ctrlKey || e.metaKey) {
parts.push('CmdOrCtrl')
}
if (e.shiftKey) {
parts.push('Shift')
}
if (e.altKey) {
parts.push('Alt')
}
// Normalize the key name
let key = e.key
if (key === ' ') {
key = 'Space'
} else if (key.length === 1) {
key = key.toUpperCase()
}
parts.push(key)
const combo = parts.join('+')
recording.value = false
emit('update:modelValue', combo)
announcement.value = `Shortcut set to ${combo}`
}
function clearShortcut() {
emit('update:modelValue', '')
announcement.value = 'Shortcut cleared'
}
</script>
<template>
<div role="group" :aria-label="label || 'Keyboard shortcut'" class="inline-flex items-center gap-2">
<!-- Key chips display -->
<div v-if="!recording && modelValue" class="flex items-center gap-1" aria-hidden="true">
<template v-for="(chip, index) in keyChips" :key="index">
<span
class="px-1.5 py-0.5 bg-bg-elevated border border-border-subtle rounded text-text-secondary font-mono text-[0.6875rem]"
>{{ chip }}</span>
<span v-if="index < keyChips.length - 1" class="text-text-tertiary text-[0.6875rem]">+</span>
</template>
</div>
<!-- Screen reader text -->
<span class="sr-only">Current shortcut: {{ modelValue || 'None' }}</span>
<!-- Recording capture area (focused div) -->
<div
v-if="recording"
ref="recorderRef"
tabindex="0"
role="application"
aria-label="Press your key combination"
@keydown="onKeydown"
@blur="cancelRecording"
class="flex items-center gap-2 px-3 py-1 border border-accent rounded-lg bg-bg-inset focus:outline-none focus:ring-1 focus:ring-accent"
>
<span class="text-[0.75rem] text-accent motion-safe:animate-pulse">Press keys...</span>
<button
type="button"
aria-label="Cancel recording"
@mousedown.prevent="cancelRecording"
class="text-[0.6875rem] text-text-tertiary hover:text-text-primary transition-colors"
>
Cancel
</button>
</div>
<!-- Record button -->
<button
v-if="!recording"
type="button"
aria-label="Record shortcut"
@click="startRecording"
class="px-2.5 py-1 text-[0.6875rem] font-medium border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated hover:text-text-primary transition-colors"
>
Record
</button>
<!-- Clear button -->
<button
v-if="!recording && modelValue"
type="button"
aria-label="Clear shortcut"
v-tooltip="'Clear shortcut'"
@click="clearShortcut"
class="w-5 h-5 flex items-center justify-center text-text-tertiary hover:text-text-primary transition-colors"
>
<X class="w-3.5 h-3.5" :stroke-width="2" aria-hidden="true" />
</button>
<!-- aria-live region for announcements -->
<div class="sr-only" aria-live="assertive" aria-atomic="true">{{ announcement }}</div>
</div>
</template>

View File

@@ -21,6 +21,7 @@ const triggerRef = ref<HTMLDivElement | null>(null)
const panelRef = ref<HTMLDivElement | null>(null)
const panelStyle = ref<Record<string, string>>({})
const inputRef = ref<HTMLInputElement | null>(null)
const highlightedIndex = ref(-1)
const selectedTags = computed(() => {
return tagsStore.tags.filter(t => t.id && props.modelValue.includes(t.id))
@@ -69,14 +70,17 @@ async function createAndAdd() {
function updatePosition() {
if (!triggerRef.value) return
panelStyle.value = computeDropdownPosition(triggerRef.value, { minWidth: 200, estimatedHeight: 200 })
panelStyle.value = computeDropdownPosition(triggerRef.value, { minWidth: 200, estimatedHeight: 200, panelEl: panelRef.value })
}
function open() {
isOpen.value = true
searchQuery.value = ''
updatePosition()
nextTick(() => inputRef.value?.focus())
nextTick(() => {
updatePosition()
inputRef.value?.focus()
})
document.addEventListener('click', onClickOutside, true)
document.addEventListener('scroll', onScrollOrResize, true)
window.addEventListener('resize', onScrollOrResize)
@@ -99,6 +103,30 @@ function onScrollOrResize() {
if (isOpen.value) updatePosition()
}
function onSearchKeydown(e: KeyboardEvent) {
const total = filteredTags.value.length + (showCreateOption.value ? 1 : 0)
if (e.key === 'ArrowDown') {
e.preventDefault()
highlightedIndex.value = Math.min(highlightedIndex.value + 1, total - 1)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
highlightedIndex.value = Math.max(highlightedIndex.value - 1, -1)
} else if (e.key === 'Enter' && highlightedIndex.value >= 0) {
e.preventDefault()
if (highlightedIndex.value < filteredTags.value.length) {
const tag = filteredTags.value[highlightedIndex.value]
toggleTag(tag.id!)
searchQuery.value = ''
} else if (showCreateOption.value) {
createAndAdd()
}
highlightedIndex.value = -1
} else if (e.key === 'Escape') {
e.preventDefault()
close()
}
}
onBeforeUnmount(() => {
document.removeEventListener('click', onClickOutside, true)
document.removeEventListener('scroll', onScrollOrResize, true)
@@ -116,30 +144,34 @@ onBeforeUnmount(() => {
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[0.6875rem] text-text-primary"
:style="{ backgroundColor: tag.color + '22', borderColor: tag.color + '44', border: '1px solid' }"
>
<span class="w-1.5 h-1.5 rounded-full" :style="{ backgroundColor: tag.color }" />
<span class="w-1.5 h-1.5 rounded-full" :style="{ backgroundColor: tag.color }" aria-hidden="true" />
{{ tag.name }}
<button @click.stop="removeTag(tag.id!)" class="ml-0.5 hover:text-status-error">
<X class="w-2.5 h-2.5" />
<button @click.stop="removeTag(tag.id!)" :aria-label="'Remove tag ' + tag.name" v-tooltip="'Remove tag'" class="ml-0.5 min-w-[24px] min-h-[24px] flex items-center justify-center hover:text-status-error">
<X class="w-2.5 h-2.5" aria-hidden="true" />
</button>
</span>
<button
key="__add_btn__"
type="button"
@click="isOpen ? close() : open()"
aria-label="Add tag"
:aria-expanded="isOpen"
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[0.6875rem] text-text-tertiary border border-border-subtle hover:text-text-secondary hover:border-border-visible transition-colors"
>
<Plus class="w-3 h-3" />
<Plus class="w-3 h-3" aria-hidden="true" />
Tag
</button>
</TransitionGroup>
<!-- Dropdown -->
<Teleport to="body">
<Teleport to="#app">
<Transition name="dropdown">
<div
v-if="isOpen"
ref="panelRef"
:style="panelStyle"
role="listbox"
aria-label="Tag options"
class="bg-bg-surface border border-border-visible rounded-xl shadow-[0_4px_24px_rgba(0,0,0,0.4)] overflow-hidden"
>
<div class="px-2 pt-2 pb-1">
@@ -147,26 +179,33 @@ onBeforeUnmount(() => {
ref="inputRef"
v-model="searchQuery"
type="text"
aria-label="Search or create tag"
@keydown="onSearchKeydown"
class="w-full px-2.5 py-1.5 bg-bg-inset border border-border-subtle rounded-lg text-[0.75rem] text-text-primary placeholder-text-tertiary focus:outline-none focus:border-border-visible"
placeholder="Search or create tag..."
/>
</div>
<div class="max-h-[160px] overflow-y-auto py-1">
<div
v-for="tag in filteredTags"
v-for="(tag, index) in filteredTags"
:key="tag.id"
:id="'tag-option-' + tag.id"
role="option"
@click="toggleTag(tag.id!); searchQuery = ''"
class="flex items-center gap-2 px-3 py-1.5 cursor-pointer hover:bg-bg-elevated transition-colors"
:class="{ 'bg-bg-elevated': index === highlightedIndex }"
>
<span class="w-2 h-2 rounded-full" :style="{ backgroundColor: tag.color }" />
<span class="w-2 h-2 rounded-full" :style="{ backgroundColor: tag.color }" aria-hidden="true" />
<span class="text-[0.75rem] text-text-primary">{{ tag.name }}</span>
</div>
<div
v-if="showCreateOption"
role="option"
@click="createAndAdd"
class="flex items-center gap-2 px-3 py-1.5 cursor-pointer hover:bg-bg-elevated transition-colors text-accent-text"
:class="{ 'bg-bg-elevated': filteredTags.length === highlightedIndex }"
>
<Plus class="w-3 h-3" />
<Plus class="w-3 h-3" aria-hidden="true" />
<span class="text-[0.75rem]">Create "{{ searchQuery.trim() }}"</span>
</div>
<div v-if="filteredTags.length === 0 && !showCreateOption" class="px-3 py-3 text-center text-[0.75rem] text-text-tertiary">

View File

@@ -0,0 +1,376 @@
<script setup lang="ts">
import { ref, computed, watch, onBeforeUnmount, nextTick } from 'vue'
import { Clock } from 'lucide-vue-next'
import { getFixedPositionMapping } from '../utils/dropdown'
interface Props {
hour: number
minute: number
placeholder?: string
}
const props = withDefaults(defineProps<Props>(), {
placeholder: 'Select time',
})
const emit = defineEmits<{
'update:hour': [value: number]
'update:minute': [value: number]
}>()
const isOpen = ref(false)
const triggerRef = ref<HTMLButtonElement | null>(null)
const panelRef = ref<HTMLDivElement | null>(null)
const panelStyle = ref<Record<string, string>>({})
// Reduced motion check
const scrollBehavior = window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' as const : 'smooth' as const
// Time wheel constants
const WHEEL_ITEM_H = 36
const WHEEL_VISIBLE = 5
const WHEEL_HEIGHT = WHEEL_ITEM_H * WHEEL_VISIBLE
const WHEEL_PAD = WHEEL_ITEM_H * 2
const internalHour = ref(props.hour)
const internalMinute = ref(props.minute)
const hourWheelRef = ref<HTMLDivElement | null>(null)
const minuteWheelRef = ref<HTMLDivElement | null>(null)
watch(() => props.hour, (v) => { internalHour.value = v })
watch(() => props.minute, (v) => { internalMinute.value = v })
const displayText = computed(() => {
const hh = String(props.hour).padStart(2, '0')
const mm = String(props.minute).padStart(2, '0')
return `${hh}:${mm}`
})
// Debounced scroll handlers
let hourScrollTimer: ReturnType<typeof setTimeout> | null = null
let minuteScrollTimer: ReturnType<typeof setTimeout> | null = null
function onHourScroll() {
if (hourScrollTimer) clearTimeout(hourScrollTimer)
hourScrollTimer = setTimeout(() => {
if (!hourWheelRef.value) return
const index = Math.round(hourWheelRef.value.scrollTop / WHEEL_ITEM_H)
const clamped = Math.min(23, Math.max(0, index))
if (internalHour.value !== clamped) {
internalHour.value = clamped
emit('update:hour', clamped)
}
}, 60)
}
function onMinuteScroll() {
if (minuteScrollTimer) clearTimeout(minuteScrollTimer)
minuteScrollTimer = setTimeout(() => {
if (!minuteWheelRef.value) return
const index = Math.round(minuteWheelRef.value.scrollTop / WHEEL_ITEM_H)
const clamped = Math.min(59, Math.max(0, index))
if (internalMinute.value !== clamped) {
internalMinute.value = clamped
emit('update:minute', clamped)
}
}, 60)
}
// Mouse wheel: one item per tick
function onHourWheel(e: WheelEvent) {
e.preventDefault()
if (!hourWheelRef.value) return
const dir = e.deltaY > 0 ? 1 : -1
const cur = Math.round(hourWheelRef.value.scrollTop / WHEEL_ITEM_H)
const next = Math.min(23, Math.max(0, cur + dir))
hourWheelRef.value.scrollTo({ top: next * WHEEL_ITEM_H, behavior: scrollBehavior })
}
function onMinuteWheel(e: WheelEvent) {
e.preventDefault()
if (!minuteWheelRef.value) return
const dir = e.deltaY > 0 ? 1 : -1
const cur = Math.round(minuteWheelRef.value.scrollTop / WHEEL_ITEM_H)
const next = Math.min(59, Math.max(0, cur + dir))
minuteWheelRef.value.scrollTo({ top: next * WHEEL_ITEM_H, behavior: scrollBehavior })
}
// Keyboard support
function onWheelKeydown(e: KeyboardEvent, type: 'hour' | 'minute') {
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return
e.preventDefault()
const dir = e.key === 'ArrowUp' ? -1 : 1
if (type === 'hour') {
const next = Math.min(23, Math.max(0, internalHour.value + dir))
internalHour.value = next
emit('update:hour', next)
if (hourWheelRef.value) {
hourWheelRef.value.scrollTo({ top: next * WHEEL_ITEM_H, behavior: scrollBehavior })
}
} else {
const next = Math.min(59, Math.max(0, internalMinute.value + dir))
internalMinute.value = next
emit('update:minute', next)
if (minuteWheelRef.value) {
minuteWheelRef.value.scrollTo({ top: next * WHEEL_ITEM_H, behavior: scrollBehavior })
}
}
}
// Click-and-drag support
let dragEl: HTMLElement | null = null
let dragStartY = 0
let dragStartScrollTop = 0
function onWheelPointerDown(e: PointerEvent) {
const el = e.currentTarget as HTMLElement
dragEl = el
dragStartY = e.clientY
dragStartScrollTop = el.scrollTop
el.setPointerCapture(e.pointerId)
}
function onWheelPointerMove(e: PointerEvent) {
if (!dragEl) return
e.preventDefault()
const delta = dragStartY - e.clientY
dragEl.scrollTop = dragStartScrollTop + delta
}
function onWheelPointerUp(e: PointerEvent) {
if (!dragEl) return
const el = dragEl
dragEl = null
el.releasePointerCapture(e.pointerId)
const index = Math.round(el.scrollTop / WHEEL_ITEM_H)
el.scrollTo({ top: index * WHEEL_ITEM_H, behavior: scrollBehavior })
}
function scrollWheelsToTime() {
if (hourWheelRef.value) {
hourWheelRef.value.scrollTop = internalHour.value * WHEEL_ITEM_H
}
if (minuteWheelRef.value) {
minuteWheelRef.value.scrollTop = internalMinute.value * WHEEL_ITEM_H
}
}
// Positioning
function updatePosition() {
if (!triggerRef.value) return
const rect = triggerRef.value.getBoundingClientRect()
const { scaleX, scaleY, offsetX, offsetY } = getFixedPositionMapping()
const gap = 4
const panelWidth = 120
const estW = panelWidth * scaleX
const vpW = window.innerWidth
const vpH = window.innerHeight
let leftVP = rect.left
if (leftVP + estW > vpW - gap) {
leftVP = vpW - estW - gap
}
if (leftVP < gap) leftVP = gap
let topVP = rect.bottom + gap
// Use offsetHeight (unaffected by CSS transition transforms)
if (panelRef.value) {
const panelH = panelRef.value.offsetHeight * scaleY
if (topVP + panelH > vpH && rect.top - gap - panelH >= 0) {
topVP = rect.top - gap - panelH
}
}
panelStyle.value = {
position: 'fixed',
top: `${(topVP - offsetY) / scaleY}px`,
left: `${(leftVP - offsetX) / scaleX}px`,
zIndex: '9999',
}
}
// Open / Close
function toggle() {
if (isOpen.value) {
close()
} else {
open()
}
}
function open() {
isOpen.value = true
updatePosition()
nextTick(() => {
// Reposition with actual panel height (fixes above-flip offset)
updatePosition()
document.addEventListener('click', onClickOutside, true)
document.addEventListener('scroll', onScrollOrResize, true)
window.addEventListener('resize', onScrollOrResize)
scrollWheelsToTime()
})
}
function close() {
isOpen.value = false
document.removeEventListener('click', onClickOutside, true)
document.removeEventListener('scroll', onScrollOrResize, true)
window.removeEventListener('resize', onScrollOrResize)
}
function onClickOutside(e: MouseEvent) {
const target = e.target as Node
if (
triggerRef.value?.contains(target) ||
panelRef.value?.contains(target)
) {
return
}
close()
}
function onScrollOrResize() {
if (isOpen.value) {
updatePosition()
}
}
onBeforeUnmount(() => {
document.removeEventListener('click', onClickOutside, true)
document.removeEventListener('scroll', onScrollOrResize, true)
window.removeEventListener('resize', onScrollOrResize)
})
</script>
<template>
<div class="relative">
<!-- Trigger button -->
<button
ref="triggerRef"
type="button"
@click="toggle"
:aria-expanded="isOpen"
aria-haspopup="dialog"
class="w-full flex items-center justify-between gap-2 px-3 py-2 bg-bg-inset border border-border-subtle rounded-xl text-[0.8125rem] text-left cursor-pointer transition-colors"
:style="
isOpen
? {
borderColor: 'var(--color-accent)',
boxShadow: '0 0 0 2px var(--color-accent-muted)',
outline: 'none',
}
: {}
"
>
<span class="text-text-primary font-mono">
{{ displayText }}
</span>
<Clock
aria-hidden="true"
class="w-4 h-4 text-text-secondary shrink-0"
:stroke-width="2"
/>
</button>
<!-- Time picker popover -->
<Teleport to="#app">
<Transition name="dropdown">
<div
v-if="isOpen"
ref="panelRef"
:style="panelStyle"
role="dialog"
aria-modal="true"
aria-label="Time picker"
@keydown.escape.prevent="close"
class="bg-bg-surface border border-border-visible rounded-xl shadow-[0_4px_24px_rgba(0,0,0,0.4)] overflow-hidden p-3"
>
<div class="flex items-center gap-1.5">
<!-- Hour wheel -->
<div
class="relative overflow-hidden rounded-lg"
:style="{ height: WHEEL_HEIGHT + 'px', width: '42px' }"
>
<div
class="absolute inset-x-0 rounded-lg pointer-events-none bg-bg-elevated border border-border-subtle"
:style="{ top: WHEEL_PAD + 'px', height: WHEEL_ITEM_H + 'px' }"
/>
<div
ref="hourWheelRef"
tabindex="0"
role="spinbutton"
:aria-valuenow="internalHour"
aria-valuemin="0"
aria-valuemax="23"
aria-label="Hour"
class="absolute inset-0 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden cursor-grab active:cursor-grabbing"
style="touch-action: none; -webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 25%, black 75%, transparent 100%); mask-image: linear-gradient(to bottom, transparent 0%, black 25%, black 75%, transparent 100%);"
@scroll="onHourScroll"
@wheel.prevent="onHourWheel"
@keydown="onWheelKeydown($event, 'hour')"
@pointerdown.prevent="onWheelPointerDown"
@pointermove="onWheelPointerMove"
@pointerup="onWheelPointerUp"
>
<div :style="{ height: WHEEL_PAD + 'px' }" class="shrink-0" />
<div
v-for="h in 24"
:key="h"
class="shrink-0 flex items-center justify-center text-[0.875rem] font-mono select-none"
:style="{ height: WHEEL_ITEM_H + 'px' }"
:class="internalHour === h - 1 ? 'text-text-primary font-semibold' : 'text-text-tertiary'"
>
{{ String(h - 1).padStart(2, '0') }}
</div>
<div :style="{ height: WHEEL_PAD + 'px' }" class="shrink-0" />
</div>
</div>
<span class="text-text-secondary text-sm font-mono font-semibold select-none">:</span>
<!-- Minute wheel -->
<div
class="relative overflow-hidden rounded-lg"
:style="{ height: WHEEL_HEIGHT + 'px', width: '42px' }"
>
<div
class="absolute inset-x-0 rounded-lg pointer-events-none bg-bg-elevated border border-border-subtle"
:style="{ top: WHEEL_PAD + 'px', height: WHEEL_ITEM_H + 'px' }"
/>
<div
ref="minuteWheelRef"
tabindex="0"
role="spinbutton"
:aria-valuenow="internalMinute"
aria-valuemin="0"
aria-valuemax="59"
aria-label="Minute"
class="absolute inset-0 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden cursor-grab active:cursor-grabbing"
style="touch-action: none; -webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 25%, black 75%, transparent 100%); mask-image: linear-gradient(to bottom, transparent 0%, black 25%, black 75%, transparent 100%);"
@scroll="onMinuteScroll"
@wheel.prevent="onMinuteWheel"
@keydown="onWheelKeydown($event, 'minute')"
@pointerdown.prevent="onWheelPointerDown"
@pointermove="onWheelPointerMove"
@pointerup="onWheelPointerUp"
>
<div :style="{ height: WHEEL_PAD + 'px' }" class="shrink-0" />
<div
v-for="m in 60"
:key="m"
class="shrink-0 flex items-center justify-center text-[0.875rem] font-mono select-none"
:style="{ height: WHEEL_ITEM_H + 'px' }"
:class="internalMinute === m - 1 ? 'text-text-primary font-semibold' : 'text-text-tertiary'"
>
{{ String(m - 1).padStart(2, '0') }}
</div>
<div :style="{ height: WHEEL_PAD + 'px' }" class="shrink-0" />
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>

View File

@@ -1,14 +1,30 @@
<script setup lang="ts">
import { watch, ref } from 'vue'
import { useFocusTrap } from '../utils/focusTrap'
interface Props {
show: boolean
}
defineProps<Props>()
const props = defineProps<Props>()
const emit = defineEmits<{
continueTimer: []
stopTimer: []
}>()
const { activate, deactivate } = useFocusTrap()
const dialogRef = ref<HTMLElement | null>(null)
watch(() => props.show, (val) => {
if (val) {
setTimeout(() => {
if (dialogRef.value) activate(dialogRef.value, { onDeactivate: () => emit('stopTimer') })
}, 50)
} else {
deactivate()
}
})
</script>
<template>
@@ -17,9 +33,9 @@ const emit = defineEmits<{
v-if="show"
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
>
<div class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-sm p-6">
<h2 class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-2">Tracked app not visible</h2>
<p class="text-[0.75rem] text-text-secondary mb-6">
<div ref="dialogRef" role="alertdialog" aria-modal="true" aria-labelledby="tracking-title" aria-describedby="tracking-desc" class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-sm p-6">
<h2 id="tracking-title" class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-2">Tracked app not visible</h2>
<p id="tracking-desc" class="text-[0.75rem] text-text-secondary mb-6">
None of your tracked apps are currently visible on screen. The timer has been paused.
</p>
<div class="flex flex-col gap-2.5">
@@ -31,7 +47,7 @@ const emit = defineEmits<{
</button>
<button
@click="emit('stopTimer')"
class="w-full px-4 py-2.5 border border-status-error text-status-error text-[0.8125rem] font-medium rounded-lg hover:bg-status-error/10 transition-colors duration-150"
class="w-full px-4 py-2.5 border border-status-error text-status-error-text text-[0.8125rem] font-medium rounded-lg hover:bg-status-error/10 transition-colors duration-150"
>
Stop &amp; Save
</button>

View File

@@ -0,0 +1,123 @@
<script setup lang="ts">
import { watch, ref, computed } from 'vue'
import { useFocusTrap } from '../utils/focusTrap'
import type { TimeEntry } from '../stores/entries'
const props = defineProps<{
show: boolean
entry: TimeEntry | null
}>()
const emit = defineEmits<{
close: []
split: [payload: { splitSeconds: number; descriptionB: string }]
}>()
const { activate, deactivate } = useFocusTrap()
const dialogRef = ref<HTMLElement | null>(null)
const splitSeconds = ref(0)
const descriptionB = ref('')
const minSplit = 60
const maxSplit = computed(() => {
if (!props.entry) return 60
return props.entry.duration - 60
})
const durationA = computed(() => splitSeconds.value)
const durationB = computed(() => {
if (!props.entry) return 0
return props.entry.duration - splitSeconds.value
})
function formatDuration(sec: number): string {
const h = Math.floor(sec / 3600)
const m = Math.floor((sec % 3600) / 60)
return h > 0 ? `${h}h ${m}m` : `${m}m`
}
watch(() => props.show, (val) => {
if (val && props.entry) {
splitSeconds.value = Math.floor(props.entry.duration / 2 / 60) * 60
descriptionB.value = props.entry.description || ''
setTimeout(() => {
if (dialogRef.value) activate(dialogRef.value, { onDeactivate: () => emit('close') })
}, 50)
} else {
deactivate()
}
})
function confirm() {
emit('split', { splitSeconds: splitSeconds.value, descriptionB: descriptionB.value })
}
</script>
<template>
<Transition name="modal">
<div
v-if="show && entry"
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
@click.self="$emit('close')"
>
<div
ref="dialogRef"
role="dialog"
aria-modal="true"
aria-labelledby="split-title"
class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-sm p-6"
>
<h2 id="split-title" class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-4">Split Entry</h2>
<p class="text-[0.75rem] text-text-secondary mb-4">
Total duration: <span class="font-medium text-text-primary">{{ formatDuration(entry.duration) }}</span>
</p>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Split point</label>
<input
type="range"
v-model.number="splitSeconds"
:min="minSplit"
:max="maxSplit"
:step="60"
class="w-full h-2 bg-bg-elevated rounded-lg appearance-none cursor-pointer accent-accent mb-4"
:aria-label="'Split at ' + formatDuration(splitSeconds)"
/>
<div class="grid grid-cols-2 gap-3 mb-4">
<div class="p-3 bg-bg-elevated rounded-lg">
<p class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Entry A</p>
<p class="text-[0.9375rem] font-medium text-text-primary font-[family-name:var(--font-timer)]">{{ formatDuration(durationA) }}</p>
</div>
<div class="p-3 bg-bg-elevated rounded-lg">
<p class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Entry B</p>
<p class="text-[0.9375rem] font-medium text-text-primary font-[family-name:var(--font-timer)]">{{ formatDuration(durationB) }}</p>
</div>
</div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Description for Entry B</label>
<input
v-model="descriptionB"
type="text"
class="w-full bg-bg-base border border-border-subtle rounded-lg px-3 py-2 text-[0.8125rem] text-text-primary placeholder-text-tertiary outline-none focus:border-accent transition-colors duration-150 mb-5"
placeholder="Description..."
/>
<div class="flex justify-end gap-3">
<button
@click="$emit('close')"
class="px-4 py-2 text-[0.8125rem] border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated transition-colors duration-150"
>
Cancel
</button>
<button
@click="confirm"
class="px-4 py-2 text-[0.8125rem] bg-accent text-bg-base font-medium rounded-lg hover:bg-accent-hover transition-colors duration-150"
>
Split
</button>
</div>
</div>
</div>
</Transition>
</template>

View File

@@ -4,7 +4,7 @@ import { useFocusTrap } from '../utils/focusTrap'
import { useAnnouncer } from '../composables/useAnnouncer'
import { useEntryTemplatesStore, type EntryTemplate } from '../stores/entryTemplates'
import { useProjectsStore } from '../stores/projects'
import { X, FileText } from 'lucide-vue-next'
import { X, FileText, Pencil, Trash2 } from 'lucide-vue-next'
const props = defineProps<{
show: boolean
@@ -68,10 +68,46 @@ function onKeydown(e: KeyboardEvent) {
function selectTemplate(tpl: EntryTemplate) {
emit('select', tpl)
}
const editingId = ref<number | null>(null)
const editForm = ref({ name: '', project_id: 0, duration: 0 })
const confirmDeleteId = ref<number | null>(null)
function startEdit(tpl: EntryTemplate) {
editingId.value = tpl.id!
editForm.value = { name: tpl.name, project_id: tpl.project_id, duration: tpl.duration || 0 }
confirmDeleteId.value = null
}
function cancelEdit() {
editingId.value = null
}
async function saveEdit(tpl: EntryTemplate) {
await templatesStore.updateTemplate({
...tpl,
name: editForm.value.name,
project_id: editForm.value.project_id,
duration: editForm.value.duration,
})
editingId.value = null
announce('Template updated')
}
function confirmDelete(id: number) {
confirmDeleteId.value = id
editingId.value = null
}
async function executeDelete(id: number) {
await templatesStore.deleteTemplate(id)
confirmDeleteId.value = null
announce('Template deleted')
}
</script>
<template>
<Teleport to="body">
<Teleport to="#app">
<Transition name="modal">
<div
v-if="show"
@@ -94,29 +130,102 @@ function selectTemplate(tpl: EntryTemplate) {
@click="$emit('cancel')"
class="p-1 text-text-tertiary hover:text-text-primary transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
aria-label="Close"
v-tooltip="'Close'"
>
<X class="w-4 h-4" aria-hidden="true" />
</button>
</div>
<div v-if="templatesStore.templates.length > 0" class="space-y-1 max-h-64 overflow-y-auto" role="listbox" aria-label="Entry templates">
<button
<div
v-for="(tpl, i) in templatesStore.templates"
:key="tpl.id"
@click="selectTemplate(tpl)"
role="option"
:aria-selected="i === activeIndex"
:class="[
'w-full text-left px-3 py-2 rounded-lg transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',
i === activeIndex ? 'bg-accent-muted' : 'hover:bg-bg-elevated'
]"
>
<p class="text-[0.8125rem] text-text-primary">{{ tpl.name }}</p>
<p class="text-[0.6875rem] text-text-tertiary">
{{ getProjectName(tpl.project_id) }}
<span v-if="tpl.duration"> - {{ formatDuration(tpl.duration) }}</span>
</p>
</button>
<!-- Delete confirmation -->
<div v-if="confirmDeleteId === tpl.id" class="px-3 py-2 rounded-lg bg-status-error/10 border border-status-error/20">
<p class="text-[0.8125rem] text-text-primary mb-2">Delete this template?</p>
<div class="flex gap-2">
<button
@click="executeDelete(tpl.id!)"
class="px-3 py-1.5 text-[0.75rem] font-medium bg-status-error text-white rounded-lg hover:bg-status-error/90 transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
Delete
</button>
<button
@click="confirmDeleteId = null"
class="px-3 py-1.5 text-[0.75rem] font-medium text-text-secondary hover:text-text-primary transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
Cancel
</button>
</div>
</div>
<!-- Edit mode -->
<div v-else-if="editingId === tpl.id" class="px-3 py-2 rounded-lg bg-bg-elevated space-y-2">
<input
v-model="editForm.name"
class="w-full px-2 py-1.5 text-[0.8125rem] bg-bg-base border border-border-subtle rounded-lg text-text-primary focus:outline-2 focus:outline-accent"
placeholder="Template name"
/>
<select
v-model="editForm.project_id"
class="w-full px-2 py-1.5 text-[0.8125rem] bg-bg-base border border-border-subtle rounded-lg text-text-primary focus:outline-2 focus:outline-accent"
>
<option v-for="p in projectsStore.activeProjects" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
<div class="flex gap-2">
<button
@click="saveEdit(tpl)"
class="px-3 py-1.5 text-[0.75rem] font-medium bg-accent text-white rounded-lg hover:bg-accent/90 transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
Save
</button>
<button
@click="cancelEdit"
class="px-3 py-1.5 text-[0.75rem] font-medium text-text-secondary hover:text-text-primary transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
Cancel
</button>
</div>
</div>
<!-- Normal display -->
<div v-else class="flex items-center group">
<button
@click="selectTemplate(tpl)"
role="option"
:aria-selected="i === activeIndex"
:class="[
'flex-1 text-left px-3 py-2 rounded-lg transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',
i === activeIndex ? 'bg-accent-muted' : 'hover:bg-bg-elevated'
]"
>
<p class="text-[0.8125rem] text-text-primary">{{ tpl.name }}</p>
<p class="text-[0.6875rem] text-text-tertiary">
{{ getProjectName(tpl.project_id) }}
<span v-if="tpl.duration"> - {{ formatDuration(tpl.duration) }}</span>
</p>
</button>
<div class="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0 pr-1">
<button
@click.stop="startEdit(tpl)"
class="p-1.5 text-text-tertiary hover:text-text-primary transition-colors rounded-lg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
aria-label="Edit template"
v-tooltip="'Edit'"
>
<Pencil class="w-3.5 h-3.5" aria-hidden="true" :stroke-width="1.5" />
</button>
<button
@click.stop="confirmDelete(tpl.id!)"
class="p-1.5 text-text-tertiary hover:text-status-error transition-colors rounded-lg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
aria-label="Delete template"
v-tooltip="'Delete'"
>
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" :stroke-width="1.5" />
</button>
</div>
</div>
</div>
</div>
<div v-else class="py-8 text-center">

View File

@@ -0,0 +1,164 @@
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { ChevronDown, ChevronUp, Check, ArrowRight, Eye, PartyPopper } from 'lucide-vue-next'
import { useOnboardingStore } from '../stores/onboarding'
import { useTourStore } from '../stores/tour'
import { TOURS } from '../utils/tours'
const router = useRouter()
const onboardingStore = useOnboardingStore()
const tourStore = useTourStore()
const collapsed = ref(false)
const progressPct = computed(() =>
onboardingStore.totalCount > 0
? (onboardingStore.completedCount / onboardingStore.totalCount) * 100
: 0
)
function goThere(route: string) {
router.push(route)
}
async function showMe(tourId: string, route: string) {
await router.push(route)
await nextTick()
setTimeout(() => {
const tour = TOURS[tourId]
if (tour) {
tourStore.start(tour)
}
}, 400)
}
</script>
<template>
<div
v-if="onboardingStore.isVisible"
class="mb-8 bg-bg-surface border border-border-subtle rounded-lg overflow-hidden"
role="region"
aria-labelledby="checklist-heading"
>
<!-- Header -->
<button
@click="collapsed = !collapsed"
:aria-expanded="!collapsed"
aria-controls="checklist-body"
class="w-full flex items-center justify-between px-4 py-3 hover:bg-bg-elevated transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-accent"
>
<div class="flex items-center gap-3">
<h2 id="checklist-heading" class="text-[0.8125rem] font-medium text-text-primary">Getting Started</h2>
<span class="text-[0.6875rem] text-text-tertiary" aria-label="Completed {{ onboardingStore.completedCount }} of {{ onboardingStore.totalCount }} steps">
{{ onboardingStore.completedCount }} / {{ onboardingStore.totalCount }}
</span>
</div>
<component
:is="collapsed ? ChevronDown : ChevronUp"
class="w-4 h-4 text-text-tertiary transition-transform duration-200"
:stroke-width="2"
aria-hidden="true"
/>
</button>
<!-- Progress bar -->
<div class="px-4">
<div class="w-full bg-bg-elevated rounded-full h-1">
<div
class="h-1 rounded-full bg-accent progress-bar"
:style="{ width: progressPct + '%' }"
role="progressbar"
:aria-valuenow="onboardingStore.completedCount"
:aria-valuemin="0"
:aria-valuemax="onboardingStore.totalCount"
aria-label="Getting started progress"
/>
</div>
</div>
<!-- Checklist items -->
<Transition name="expand">
<div v-if="!collapsed" id="checklist-body" class="px-4 py-3">
<!-- All complete message -->
<div v-if="onboardingStore.allComplete" class="flex items-center gap-3 py-3" role="status">
<PartyPopper class="w-5 h-5 text-accent" :stroke-width="1.5" aria-hidden="true" />
<div>
<p class="text-[0.8125rem] text-text-primary font-medium">All done!</p>
<p class="text-[0.6875rem] text-text-tertiary">You have explored all the basics. Happy tracking!</p>
</div>
</div>
<!-- Items -->
<ul v-else class="space-y-1" aria-label="Onboarding steps">
<li
v-for="item in onboardingStore.items"
:key="item.key"
class="flex items-center gap-3 py-2 group"
>
<!-- Checkbox indicator -->
<div
class="w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors duration-200"
:class="item.completed
? 'border-accent bg-accent'
: 'border-border-visible'"
role="img"
:aria-label="item.completed ? 'Completed' : 'Not completed'"
>
<Check
v-if="item.completed"
class="w-3 h-3 text-bg-base"
:stroke-width="3"
aria-hidden="true"
/>
</div>
<!-- Label -->
<div class="flex-1 min-w-0">
<p
class="text-[0.8125rem] transition-colors duration-200"
:class="item.completed ? 'text-text-tertiary line-through' : 'text-text-primary'"
>
{{ item.label }}
</p>
<p class="text-[0.6875rem] text-text-tertiary">{{ item.description }}</p>
</div>
<!-- Action buttons (always focusable, visually hidden until hover/focus) -->
<div
v-if="!item.completed"
class="flex items-center gap-1.5 shrink-0"
>
<button
@click="goThere(item.route)"
:aria-label="'Go to ' + item.label"
class="flex items-center gap-1 px-2 py-1 text-[0.6875rem] text-text-secondary border border-border-subtle rounded-md hover:bg-bg-elevated hover:text-text-primary transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent focus-visible:opacity-100"
>
<ArrowRight class="w-3 h-3" :stroke-width="2" aria-hidden="true" />
Go there
</button>
<button
@click="showMe(item.tourId, item.route)"
:aria-label="'Show me how to ' + item.label.toLowerCase()"
class="flex items-center gap-1 px-2 py-1 text-[0.6875rem] text-accent-text border border-accent/30 rounded-md hover:bg-accent/10 transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent focus-visible:opacity-100"
>
<Eye class="w-3 h-3" :stroke-width="2" aria-hidden="true" />
Show me
</button>
</div>
</li>
</ul>
<!-- Dismiss link -->
<div class="mt-3 pt-2 border-t border-border-subtle">
<button
@click="onboardingStore.dismiss()"
class="text-[0.6875rem] text-text-tertiary hover:text-text-secondary transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
Dismiss checklist
</button>
</div>
</div>
</Transition>
</div>
</template>

View File

@@ -0,0 +1,192 @@
<script setup lang="ts">
import { watch, ref, computed, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { invoke } from '@tauri-apps/api/core'
import { useFocusTrap } from '../utils/focusTrap'
import { useProjectsStore } from '../stores/projects'
import { useInvoicesStore } from '../stores/invoices'
import { Search, FolderKanban, Users, Clock, FileText } from 'lucide-vue-next'
const props = defineProps<{ show: boolean }>()
const emit = defineEmits<{ close: [] }>()
const router = useRouter()
const projectsStore = useProjectsStore()
const invoicesStore = useInvoicesStore()
const { activate, deactivate } = useFocusTrap()
const dialogRef = ref<HTMLElement | null>(null)
const inputRef = ref<HTMLInputElement | null>(null)
const query = ref('')
const activeIndex = ref(0)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
interface SearchResult {
type: 'project' | 'client' | 'entry' | 'invoice'
id: number
label: string
sublabel: string
color?: string
route: string
}
const entryResults = ref<SearchResult[]>([])
const searching = ref(false)
const localResults = computed((): SearchResult[] => {
const q = query.value.toLowerCase().trim()
if (!q) return []
const results: SearchResult[] = []
for (const p of projectsStore.projects) {
if (results.length >= 5) break
if (p.name.toLowerCase().includes(q)) {
results.push({ type: 'project', id: p.id!, label: p.name, sublabel: p.archived ? 'Archived' : 'Active', color: p.color, route: '/projects' })
}
}
for (const inv of invoicesStore.invoices) {
if (results.length >= 10) break
if (inv.invoice_number.toLowerCase().includes(q)) {
results.push({ type: 'invoice', id: inv.id!, label: inv.invoice_number, sublabel: inv.status, route: '/invoices' })
}
}
return results
})
const allResults = computed(() => [...localResults.value, ...entryResults.value])
async function searchEntries(q: string) {
if (!q.trim()) {
entryResults.value = []
return
}
searching.value = true
try {
const rows = await invoke<any[]>('search_entries', { query: q, limit: 5 })
entryResults.value = rows.map(r => ({
type: 'entry' as const,
id: r.id,
label: r.description || '(no description)',
sublabel: r.project_name || 'Unknown project',
color: r.project_color,
route: '/entries',
}))
} catch {
entryResults.value = []
} finally {
searching.value = false
}
}
function onInput() {
activeIndex.value = 0
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => searchEntries(query.value), 200)
}
function navigate(result: SearchResult) {
router.push(result.route)
emit('close')
}
function onKeydown(e: KeyboardEvent) {
const total = allResults.value.length
if (e.key === 'ArrowDown') {
e.preventDefault()
activeIndex.value = (activeIndex.value + 1) % Math.max(total, 1)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
activeIndex.value = (activeIndex.value - 1 + Math.max(total, 1)) % Math.max(total, 1)
} else if (e.key === 'Enter' && allResults.value[activeIndex.value]) {
e.preventDefault()
navigate(allResults.value[activeIndex.value])
} else if (e.key === 'Escape') {
emit('close')
}
}
const typeIcon: Record<string, any> = {
project: FolderKanban,
client: Users,
entry: Clock,
invoice: FileText,
}
watch(() => props.show, (val) => {
if (val) {
query.value = ''
entryResults.value = []
activeIndex.value = 0
nextTick(() => {
if (dialogRef.value) activate(dialogRef.value, { onDeactivate: () => emit('close') })
inputRef.value?.focus()
})
} else {
deactivate()
}
})
</script>
<template>
<Transition name="modal">
<div
v-if="show"
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-start justify-center pt-[15vh] p-4 z-50"
@click.self="$emit('close')"
>
<div
ref="dialogRef"
role="dialog"
aria-modal="true"
aria-label="Search"
class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-md overflow-hidden"
@keydown="onKeydown"
>
<div class="flex items-center gap-3 px-4 py-3 border-b border-border-subtle">
<Search class="w-4 h-4 text-text-tertiary shrink-0" :stroke-width="1.5" aria-hidden="true" />
<input
ref="inputRef"
v-model="query"
@input="onInput"
type="text"
class="flex-1 bg-transparent text-[0.875rem] text-text-primary placeholder-text-tertiary outline-none"
placeholder="Search projects, entries, invoices..."
aria-label="Search"
/>
<kbd class="text-[0.625rem] text-text-tertiary border border-border-subtle rounded px-1.5 py-0.5">Esc</kbd>
</div>
<div v-if="!query.trim()" class="px-4 py-8 text-center text-[0.75rem] text-text-tertiary">
Type to search...
</div>
<div v-else-if="allResults.length === 0 && !searching" class="px-4 py-8 text-center text-[0.75rem] text-text-tertiary">
No results for "{{ query }}"
</div>
<ul v-else class="max-h-80 overflow-y-auto py-2" role="listbox">
<li
v-for="(result, idx) in allResults"
:key="result.type + '-' + result.id"
role="option"
:aria-selected="idx === activeIndex"
class="flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors duration-100"
:class="idx === activeIndex ? 'bg-accent/10' : 'hover:bg-bg-elevated'"
@click="navigate(result)"
@mouseenter="activeIndex = idx"
>
<component :is="typeIcon[result.type]" class="w-4 h-4 text-text-tertiary shrink-0" :stroke-width="1.5" aria-hidden="true" />
<span v-if="result.color" class="w-2 h-2 rounded-full shrink-0" :style="{ backgroundColor: result.color }" aria-hidden="true" />
<div class="flex-1 min-w-0">
<p class="text-[0.8125rem] text-text-primary truncate">{{ result.label }}</p>
<p class="text-[0.6875rem] text-text-tertiary truncate">{{ result.sublabel }}</p>
</div>
<span class="text-[0.5625rem] text-text-tertiary uppercase tracking-wider shrink-0">{{ result.type }}</span>
</li>
</ul>
</div>
</div>
</Transition>
</template>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, watch, ref } from 'vue'
import { useFocusTrap } from '../utils/focusTrap'
interface Props {
show: boolean
@@ -14,6 +15,9 @@ const emit = defineEmits<{
stopTimer: []
}>()
const { activate, deactivate } = useFocusTrap()
const dialogRef = ref<HTMLElement | null>(null)
const idleFormatted = computed(() => {
const mins = Math.floor(props.idleSeconds / 60)
const secs = props.idleSeconds % 60
@@ -22,6 +26,16 @@ const idleFormatted = computed(() => {
}
return `${secs}s`
})
watch(() => props.show, (val) => {
if (val) {
setTimeout(() => {
if (dialogRef.value) activate(dialogRef.value, { onDeactivate: () => emit('stopTimer') })
}, 50)
} else {
deactivate()
}
})
</script>
<template>
@@ -30,9 +44,9 @@ const idleFormatted = computed(() => {
v-if="show"
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
>
<div class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-sm p-6">
<h2 class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-2">You've been idle</h2>
<p class="text-[0.75rem] text-text-secondary mb-6">
<div ref="dialogRef" role="alertdialog" aria-modal="true" aria-labelledby="idle-title" aria-describedby="idle-desc" class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-sm p-6">
<h2 id="idle-title" class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-2">You've been idle</h2>
<p id="idle-desc" class="text-[0.75rem] text-text-secondary mb-6">
No keyboard or mouse input detected for <span class="font-mono font-medium text-text-primary">{{ idleFormatted }}</span>.
</p>
<div class="flex flex-col gap-2.5">
@@ -44,13 +58,13 @@ const idleFormatted = computed(() => {
</button>
<button
@click="emit('continueSubtract')"
class="w-full px-4 py-2.5 border border-border-visible text-text-primary text-[0.8125rem] rounded-lg hover:bg-bg-elevated transition-colors duration-150"
class="w-full px-4 py-2.5 border border-border-subtle text-text-primary text-[0.8125rem] rounded-lg hover:bg-bg-elevated transition-colors duration-150"
>
Continue (subtract {{ idleFormatted }})
</button>
<button
@click="emit('stopTimer')"
class="w-full px-4 py-2.5 border border-status-error text-status-error text-[0.8125rem] font-medium rounded-lg hover:bg-status-error/10 transition-colors duration-150"
class="w-full px-4 py-2.5 border border-status-error text-status-error-text text-[0.8125rem] font-medium rounded-lg hover:bg-status-error/10 transition-colors duration-150"
>
Stop &amp; Save
</button>

View File

@@ -0,0 +1,212 @@
<script setup lang="ts">
import { ref, computed, onBeforeUnmount } from 'vue'
import { useInvoicesStore, type Invoice } from '../stores/invoices'
import { useToastStore } from '../stores/toast'
import { formatCurrency, formatDate } from '../utils/locale'
import { GripVertical } from 'lucide-vue-next'
const emit = defineEmits<{ open: [id: number] }>()
const invoicesStore = useInvoicesStore()
const toastStore = useToastStore()
const columns = ['draft', 'sent', 'overdue', 'paid'] as const
const columnLabels: Record<string, string> = { draft: 'Draft', sent: 'Sent', overdue: 'Overdue', paid: 'Paid' }
function columnTotal(status: string): string {
const items = invoicesStore.groupedByStatus[status] || []
const sum = items.reduce((acc, inv) => acc + inv.total, 0)
return formatCurrency(sum)
}
// Pointer-based drag (works in Tauri webview unlike HTML5 DnD)
const dragInv = ref<Invoice | null>(null)
const dragStartX = ref(0)
const dragStartY = ref(0)
const dragX = ref(0)
const dragY = ref(0)
const isDragging = ref(false)
const dragOverCol = ref<string | null>(null)
const columnRefs = ref<Record<string, HTMLElement>>({})
const cardWidth = ref(200)
const DRAG_THRESHOLD = 6
function setColumnRef(col: string, el: HTMLElement | null) {
if (el) columnRefs.value[col] = el
}
function onPointerDown(inv: Invoice, e: PointerEvent) {
// Only primary button
if (e.button !== 0) return
dragInv.value = inv
dragStartX.value = e.clientX
dragStartY.value = e.clientY
dragX.value = e.clientX
dragY.value = e.clientY
isDragging.value = false
// Measure the source card width for the ghost
const el = (e.currentTarget as HTMLElement)
if (el) cardWidth.value = el.offsetWidth
document.addEventListener('pointermove', onPointerMove)
document.addEventListener('pointerup', onPointerUp)
}
function onPointerMove(e: PointerEvent) {
if (!dragInv.value) return
const dx = e.clientX - dragStartX.value
const dy = e.clientY - dragStartY.value
// Start drag only after threshold
if (!isDragging.value) {
if (Math.abs(dx) + Math.abs(dy) < DRAG_THRESHOLD) return
isDragging.value = true
}
// Track position for the ghost
dragX.value = e.clientX
dragY.value = e.clientY
// Hit-test which column the pointer is over
// The ghost has pointer-events:none so elementFromPoint sees through it
const hit = document.elementFromPoint(e.clientX, e.clientY)
if (hit) {
let found: string | null = null
for (const [col, el] of Object.entries(columnRefs.value)) {
if (el.contains(hit)) {
found = col
break
}
}
dragOverCol.value = found
}
}
async function onPointerUp() {
document.removeEventListener('pointermove', onPointerMove)
document.removeEventListener('pointerup', onPointerUp)
const inv = dragInv.value
const targetCol = dragOverCol.value
const wasDragging = isDragging.value
dragInv.value = null
dragOverCol.value = null
isDragging.value = false
if (!inv) return
// If we were dragging and landed on a different column, move the invoice
if (wasDragging && targetCol && targetCol !== inv.status) {
const oldStatus = inv.status
const ok = await invoicesStore.updateStatus(inv.id!, targetCol)
if (ok) {
toastStore.success(`Moved ${inv.invoice_number} to ${columnLabels[targetCol]}`, {
onUndo: async () => {
await invoicesStore.updateStatus(inv.id!, oldStatus)
}
})
}
return
}
// If we didn't drag (just clicked), open the invoice
if (!wasDragging) {
emit('open', inv.id!)
}
}
onBeforeUnmount(() => {
document.removeEventListener('pointermove', onPointerMove)
document.removeEventListener('pointerup', onPointerUp)
})
const reducedMotion = computed(() => window.matchMedia('(prefers-reduced-motion: reduce)').matches)
</script>
<template>
<div class="grid grid-cols-4 gap-4 select-none">
<div
v-for="col in columns"
:key="col"
:ref="(el) => setColumnRef(col, el as HTMLElement)"
class="flex flex-col min-h-[300px] bg-bg-elevated rounded-lg overflow-hidden transition-all duration-150"
:class="[
dragOverCol === col && isDragging ? 'ring-2 ring-accent bg-accent/5' : '',
col === 'overdue' ? 'border-t-2 border-status-error' : ''
]"
:aria-label="columnLabels[col] + ' invoices'"
>
<div class="px-3 py-2.5 border-b border-border-subtle">
<div class="flex items-center justify-between">
<span class="text-[0.75rem] font-medium text-text-primary">{{ columnLabels[col] }}</span>
<span class="text-[0.625rem] text-text-tertiary bg-bg-base rounded-full px-2 py-0.5">
{{ (invoicesStore.groupedByStatus[col] || []).length }}
</span>
</div>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">{{ columnTotal(col) }}</p>
</div>
<div class="flex-1 overflow-y-auto p-2 space-y-2" role="list">
<div
v-for="inv in (invoicesStore.groupedByStatus[col] || [])"
:key="inv.id"
role="listitem"
tabindex="0"
class="bg-bg-surface border border-border-subtle rounded-lg p-3 transition-all duration-150 hover:border-accent/50 group"
:class="[
isDragging && dragInv?.id === inv.id ? 'opacity-40 scale-95 cursor-grabbing' : 'cursor-grab',
!reducedMotion ? 'hover:shadow-sm' : ''
]"
@pointerdown="onPointerDown(inv, $event)"
@keydown.enter="emit('open', inv.id!)"
>
<div class="flex items-start gap-2">
<GripVertical
class="w-3.5 h-3.5 text-text-tertiary opacity-0 group-hover:opacity-100 transition-opacity shrink-0 mt-0.5"
:stroke-width="1.5"
aria-hidden="true"
/>
<div class="flex-1 min-w-0">
<p class="text-[0.8125rem] font-medium text-text-primary">{{ inv.invoice_number }}</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">{{ formatDate(inv.date) }}</p>
<p class="text-[0.8125rem] font-medium text-text-primary mt-1">
{{ formatCurrency(inv.total) }}
</p>
</div>
</div>
</div>
<!-- Drop zone placeholder when column is empty or drag active -->
<div
v-if="!(invoicesStore.groupedByStatus[col] || []).length"
class="flex items-center justify-center h-20 text-[0.6875rem] text-text-tertiary border-2 border-dashed rounded-lg transition-colors"
:class="isDragging && dragOverCol === col ? 'border-accent text-accent' : 'border-border-subtle'"
>
{{ isDragging ? 'Drop here' : 'No invoices' }}
</div>
</div>
</div>
</div>
<!-- Floating ghost tile that follows the cursor during drag -->
<Teleport to="#app">
<div
v-if="isDragging && dragInv"
class="fixed z-[200] pointer-events-none"
:style="{
left: dragX + 'px',
top: dragY + 'px',
width: cardWidth + 'px',
transform: 'translate(-50%, -60%) rotate(-2deg)',
}"
>
<div class="bg-bg-surface border-2 border-accent rounded-lg p-3 shadow-lg shadow-black/30 opacity-90">
<p class="text-[0.8125rem] font-medium text-text-primary">{{ dragInv.invoice_number }}</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">{{ formatDate(dragInv.date) }}</p>
<p class="text-[0.8125rem] font-medium text-text-primary mt-1">{{ formatCurrency(dragInv.total) }}</p>
</div>
</div>
</Teleport>
</template>

View File

@@ -58,6 +58,7 @@ void clientAddress
<img
v-if="biz?.logo"
:src="biz.logo"
alt="Business logo"
:style="{ width: '28px', height: '28px', objectFit: 'contain', marginBottom: '4px', display: 'block' }"
/>
<div :style="{ fontSize: '13px', fontWeight: '600', color: c.headerText }">
@@ -102,10 +103,10 @@ void clientAddress
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '12px' }">
<thead>
<tr :style="{ backgroundColor: c.tableHeaderBg }">
<th :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
<th scope="col" :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
</tr>
</thead>
<tbody>
@@ -172,7 +173,7 @@ void clientAddress
</div>
</div>
<div :style="{ textAlign: 'right' }">
<img v-if="biz?.logo" :src="biz.logo" :style="{ width: '24px', height: '24px', objectFit: 'contain', marginBottom: '2px', marginLeft: 'auto', display: 'block' }" />
<img v-if="biz?.logo" :src="biz.logo" alt="Business logo" :style="{ width: '24px', height: '24px', objectFit: 'contain', marginBottom: '2px', marginLeft: 'auto', display: 'block' }" />
<div :style="{ fontSize: '12px', fontWeight: '600' }">{{ biz?.name || 'Your Business' }}</div>
</div>
</div>
@@ -201,10 +202,10 @@ void clientAddress
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '12px' }">
<thead>
<tr :style="{ backgroundColor: c.tableHeaderBg }">
<th :style="{ textAlign: 'left', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
<th scope="col" :style="{ textAlign: 'left', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
</tr>
</thead>
<tbody>
@@ -268,7 +269,7 @@ void clientAddress
</div>
<!-- Biz info outside the block -->
<div :style="{ flex: 1, padding: '5% 5%', display: 'flex', flexDirection: 'column', justifyContent: 'center' }">
<img v-if="biz?.logo" :src="biz.logo" :style="{ width: '22px', height: '22px', objectFit: 'contain', marginBottom: '3px' }" />
<img v-if="biz?.logo" :src="biz.logo" alt="Business logo" :style="{ width: '22px', height: '22px', objectFit: 'contain', marginBottom: '3px' }" />
<div :style="{ fontSize: '9px', fontWeight: '600', color: c.headerText }">{{ biz?.name || 'Your Business' }}</div>
<div v-for="(line, i) in bizAddressLines" :key="'blf'+i" :style="{ fontSize: '7px' }">{{ line }}</div>
<div v-if="biz?.email" :style="{ fontSize: '7px' }">{{ biz.email }}</div>
@@ -303,10 +304,10 @@ void clientAddress
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '12px' }">
<thead>
<tr :style="{ backgroundColor: c.tableHeaderBg }">
<th :style="{ textAlign: 'left', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
<th scope="col" :style="{ textAlign: 'left', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
</tr>
</thead>
<tbody>
@@ -364,7 +365,7 @@ void clientAddress
<div :style="{ padding: '6%' }">
<!-- Centered header -->
<div :style="{ textAlign: 'center', marginBottom: '10px' }">
<img v-if="biz?.logo" :src="biz.logo" :style="{ width: '26px', height: '26px', objectFit: 'contain', margin: '0 auto 6px', display: 'block' }" />
<img v-if="biz?.logo" :src="biz.logo" alt="Business logo" :style="{ width: '26px', height: '26px', objectFit: 'contain', margin: '0 auto 6px', display: 'block' }" />
<div :style="{ height: '1px', backgroundColor: '#e4e4e7', marginBottom: '8px' }" />
<div :style="{ fontSize: '20px', fontWeight: '700', color: '#18181b', letterSpacing: '0.04em' }">INVOICE</div>
<div :style="{ fontSize: '9px', fontWeight: '600', color: '#18181b', marginTop: '2px' }">{{ biz?.name || 'Your Business' }}</div>
@@ -399,10 +400,10 @@ void clientAddress
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '12px' }">
<thead>
<tr>
<th :style="{ textAlign: 'left', padding: '6px 4px', fontSize: '7px', fontWeight: '700', textTransform: 'uppercase', color: '#18181b', letterSpacing: '0.03em' }">Description</th>
<th :style="{ textAlign: 'right', padding: '6px 4px', fontSize: '7px', fontWeight: '700', textTransform: 'uppercase', color: '#18181b', width: '42px' }">Qty</th>
<th :style="{ textAlign: 'right', padding: '6px 4px', fontSize: '7px', fontWeight: '700', textTransform: 'uppercase', color: '#18181b', width: '50px' }">Rate</th>
<th :style="{ textAlign: 'right', padding: '6px 4px', fontSize: '7px', fontWeight: '700', textTransform: 'uppercase', color: '#18181b', width: '58px' }">Amount</th>
<th scope="col" :style="{ textAlign: 'left', padding: '6px 4px', fontSize: '7px', fontWeight: '700', textTransform: 'uppercase', color: '#18181b', letterSpacing: '0.03em' }">Description</th>
<th scope="col" :style="{ textAlign: 'right', padding: '6px 4px', fontSize: '7px', fontWeight: '700', textTransform: 'uppercase', color: '#18181b', width: '42px' }">Qty</th>
<th scope="col" :style="{ textAlign: 'right', padding: '6px 4px', fontSize: '7px', fontWeight: '700', textTransform: 'uppercase', color: '#18181b', width: '50px' }">Rate</th>
<th scope="col" :style="{ textAlign: 'right', padding: '6px 4px', fontSize: '7px', fontWeight: '700', textTransform: 'uppercase', color: '#18181b', width: '58px' }">Amount</th>
</tr>
</thead>
<tbody>
@@ -461,7 +462,7 @@ void clientAddress
<!-- Traditional two-column header -->
<div :style="{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '6px' }">
<div>
<img v-if="biz?.logo" :src="biz.logo" :style="{ width: '24px', height: '24px', objectFit: 'contain', marginBottom: '3px' }" />
<img v-if="biz?.logo" :src="biz.logo" alt="Business logo" :style="{ width: '24px', height: '24px', objectFit: 'contain', marginBottom: '3px' }" />
<div :style="{ fontSize: '11px', fontWeight: '600', color: c.headerText }">{{ biz?.name || 'Your Business' }}</div>
<div v-for="(line, i) in bizAddressLines" :key="'clf'+i">{{ line }}</div>
<div v-if="biz?.email">{{ biz.email }}</div>
@@ -491,10 +492,10 @@ void clientAddress
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '12px', border: '1px solid ' + c.tableBorder }">
<thead>
<tr :style="{ backgroundColor: c.tableHeaderBg }">
<th :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, border: '1px solid ' + c.tableBorder }">Description</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px', border: '1px solid ' + c.tableBorder }">Qty</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px', border: '1px solid ' + c.tableBorder }">Rate</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px', border: '1px solid ' + c.tableBorder }">Amount</th>
<th scope="col" :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, border: '1px solid ' + c.tableBorder }">Description</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px', border: '1px solid ' + c.tableBorder }">Qty</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px', border: '1px solid ' + c.tableBorder }">Rate</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px', border: '1px solid ' + c.tableBorder }">Amount</th>
</tr>
</thead>
<tbody>
@@ -560,7 +561,7 @@ void clientAddress
<div v-if="invoice.due_date" :style="{ fontSize: '7.5px', color: c.bodyText }">Due {{ formatDate(invoice.due_date) }}</div>
</div>
<div :style="{ textAlign: 'right' }">
<img v-if="biz?.logo" :src="biz.logo" :style="{ width: '22px', height: '22px', objectFit: 'contain', marginBottom: '2px', marginLeft: 'auto', display: 'block' }" />
<img v-if="biz?.logo" :src="biz.logo" alt="Business logo" :style="{ width: '22px', height: '22px', objectFit: 'contain', marginBottom: '2px', marginLeft: 'auto', display: 'block' }" />
<div :style="{ fontSize: '10px', fontWeight: '600', color: c.headerText }">{{ biz?.name || 'Your Business' }}</div>
<div v-for="(line, i) in bizAddressLines" :key="'modf'+i" :style="{ fontSize: '7px' }">{{ line }}</div>
<div v-if="biz?.email" :style="{ fontSize: '7px' }">{{ biz.email }}</div>
@@ -591,10 +592,10 @@ void clientAddress
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '12px' }">
<thead>
<tr>
<th :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, borderBottom: '1px solid ' + c.primary }">Description</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px', borderBottom: '1px solid ' + c.primary }">Qty</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px', borderBottom: '1px solid ' + c.primary }">Rate</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px', borderBottom: '1px solid ' + c.primary }">Amount</th>
<th scope="col" :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, borderBottom: '1px solid ' + c.primary }">Description</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px', borderBottom: '1px solid ' + c.primary }">Qty</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px', borderBottom: '1px solid ' + c.primary }">Rate</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px', borderBottom: '1px solid ' + c.primary }">Amount</th>
</tr>
</thead>
<tbody>
@@ -659,7 +660,7 @@ void clientAddress
<!-- Centered header -->
<div :style="{ textAlign: 'center', marginBottom: '8px' }">
<img v-if="biz?.logo" :src="biz.logo" :style="{ width: '24px', height: '24px', objectFit: 'contain', margin: '0 auto 4px', display: 'block' }" />
<img v-if="biz?.logo" :src="biz.logo" alt="Business logo" :style="{ width: '24px', height: '24px', objectFit: 'contain', margin: '0 auto 4px', display: 'block' }" />
<div :style="{ fontSize: '20px', fontWeight: '700', color: c.headerText, letterSpacing: '0.06em' }">INVOICE</div>
<div :style="{ fontSize: '10px', fontWeight: '600', color: c.headerText, marginTop: '2px' }">{{ biz?.name || 'Your Business' }}</div>
<div :style="{ fontSize: '7.5px', marginTop: '2px' }">
@@ -697,20 +698,20 @@ void clientAddress
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '12px' }">
<thead>
<tr>
<th :colspan="4" :style="{ padding: 0, height: '0' }">
<th scope="col" :colspan="4" :style="{ padding: 0, height: '0' }">
<div :style="{ height: '1px', backgroundColor: c.primary }" />
<div :style="{ height: '1.5px' }" />
<div :style="{ height: '1px', backgroundColor: c.primary }" />
</th>
</tr>
<tr>
<th :style="{ textAlign: 'left', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
<th scope="col" :style="{ textAlign: 'left', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
</tr>
<tr>
<th :colspan="4" :style="{ padding: 0, height: '0' }">
<th scope="col" :colspan="4" :style="{ padding: 0, height: '0' }">
<div :style="{ height: '1px', backgroundColor: c.primary }" />
<div :style="{ height: '1.5px' }" />
<div :style="{ height: '1px', backgroundColor: c.primary }" />
@@ -776,7 +777,7 @@ void clientAddress
<div :style="{ paddingLeft: '7%', paddingRight: '5%', paddingTop: '5%', paddingBottom: '5%' }">
<!-- Logo + Title -->
<div :style="{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '10px' }">
<img v-if="biz?.logo" :src="biz.logo" :style="{ width: '24px', height: '24px', objectFit: 'contain' }" />
<img v-if="biz?.logo" :src="biz.logo" alt="Business logo" :style="{ width: '24px', height: '24px', objectFit: 'contain' }" />
<div>
<div :style="{ fontSize: '20px', fontWeight: '700', color: c.primary }">INVOICE</div>
<div :style="{ fontSize: '7.5px' }">#{{ invoice.invoice_number }}</div>
@@ -909,10 +910,10 @@ void clientAddress
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '10px' }">
<thead>
<tr :style="{ backgroundColor: c.tableHeaderBg }">
<th :style="{ textAlign: 'left', padding: '3px 3px', fontSize: '6.5px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th :style="{ textAlign: 'right', padding: '3px 3px', fontSize: '6.5px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '38px' }">Qty</th>
<th :style="{ textAlign: 'right', padding: '3px 3px', fontSize: '6.5px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '46px' }">Rate</th>
<th :style="{ textAlign: 'right', padding: '3px 3px', fontSize: '6.5px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '52px' }">Amount</th>
<th scope="col" :style="{ textAlign: 'left', padding: '3px 3px', fontSize: '6.5px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th scope="col" :style="{ textAlign: 'right', padding: '3px 3px', fontSize: '6.5px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '38px' }">Qty</th>
<th scope="col" :style="{ textAlign: 'right', padding: '3px 3px', fontSize: '6.5px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '46px' }">Rate</th>
<th scope="col" :style="{ textAlign: 'right', padding: '3px 3px', fontSize: '6.5px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '52px' }">Amount</th>
</tr>
</thead>
<tbody>
@@ -975,7 +976,7 @@ void clientAddress
<div :style="{ width: '40px', height: '1px', backgroundColor: c.primary, marginTop: '4px' }" />
</div>
<div :style="{ textAlign: 'right' }">
<img v-if="biz?.logo" :src="biz.logo" :style="{ width: '22px', height: '22px', objectFit: 'contain', marginBottom: '2px', marginLeft: 'auto', display: 'block' }" />
<img v-if="biz?.logo" :src="biz.logo" alt="Business logo" :style="{ width: '22px', height: '22px', objectFit: 'contain', marginBottom: '2px', marginLeft: 'auto', display: 'block' }" />
<div :style="{ fontSize: '10px', fontWeight: '600', color: c.headerText }">{{ biz?.name || 'Your Business' }}</div>
</div>
</div>
@@ -1008,10 +1009,10 @@ void clientAddress
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '12px' }">
<thead>
<tr :style="{ backgroundColor: c.tableHeaderBg }">
<th :style="{ textAlign: 'left', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
<th scope="col" :style="{ textAlign: 'left', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
</tr>
</thead>
<tbody>
@@ -1077,7 +1078,7 @@ void clientAddress
</div>
</div>
<div :style="{ textAlign: 'right' }">
<img v-if="biz?.logo" :src="biz.logo" :style="{ width: '22px', height: '22px', objectFit: 'contain', marginBottom: '2px', marginLeft: 'auto', display: 'block' }" />
<img v-if="biz?.logo" :src="biz.logo" alt="Business logo" :style="{ width: '22px', height: '22px', objectFit: 'contain', marginBottom: '2px', marginLeft: 'auto', display: 'block' }" />
<div :style="{ fontSize: '11px', fontWeight: '600' }">{{ biz?.name || 'Your Business' }}</div>
<div v-if="biz?.email" :style="{ fontSize: '7px', opacity: 0.9 }">{{ biz.email }}</div>
</div>
@@ -1106,10 +1107,10 @@ void clientAddress
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '12px' }">
<thead>
<tr :style="{ backgroundColor: c.tableHeaderBg }">
<th :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
<th scope="col" :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
</tr>
</thead>
<tbody>
@@ -1167,7 +1168,7 @@ void clientAddress
<!-- Deep blue band -->
<div :style="{ backgroundColor: c.headerBg, color: c.headerText, padding: '4% 6% 3%', minHeight: '12%', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }">
<div>
<img v-if="biz?.logo" :src="biz.logo" :style="{ width: '22px', height: '22px', objectFit: 'contain', marginBottom: '2px' }" />
<img v-if="biz?.logo" :src="biz.logo" alt="Business logo" :style="{ width: '22px', height: '22px', objectFit: 'contain', marginBottom: '2px' }" />
<div :style="{ fontSize: '12px', fontWeight: '600' }">{{ biz?.name || 'Your Business' }}</div>
</div>
<div :style="{ fontSize: '22px', fontWeight: '700' }">INVOICE</div>
@@ -1204,10 +1205,10 @@ void clientAddress
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '12px', border: '1px solid ' + c.tableBorder }">
<thead>
<tr :style="{ backgroundColor: c.tableHeaderBg }">
<th :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, border: '1px solid ' + c.tableBorder }">Description</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px', border: '1px solid ' + c.tableBorder }">Qty</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px', border: '1px solid ' + c.tableBorder }">Rate</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px', border: '1px solid ' + c.tableBorder }">Amount</th>
<th scope="col" :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, border: '1px solid ' + c.tableBorder }">Description</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px', border: '1px solid ' + c.tableBorder }">Qty</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px', border: '1px solid ' + c.tableBorder }">Rate</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px', border: '1px solid ' + c.tableBorder }">Amount</th>
</tr>
</thead>
<tbody>
@@ -1266,7 +1267,7 @@ void clientAddress
<!-- Header with logo left, watermark invoice number right -->
<div :style="{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '12px', position: 'relative' }">
<div>
<img v-if="biz?.logo" :src="biz.logo" :style="{ width: '26px', height: '26px', objectFit: 'contain', marginBottom: '3px' }" />
<img v-if="biz?.logo" :src="biz.logo" alt="Business logo" :style="{ width: '26px', height: '26px', objectFit: 'contain', marginBottom: '3px' }" />
<div :style="{ fontSize: '11px', fontWeight: '600', color: c.headerText }">{{ biz?.name || 'Your Business' }}</div>
<div v-for="(line, i) in bizAddressLines" :key="'frf'+i" :style="{ fontSize: '7px' }">{{ line }}</div>
<div v-if="biz?.email" :style="{ fontSize: '7px' }">{{ biz.email }}</div>
@@ -1307,10 +1308,10 @@ void clientAddress
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '12px' }">
<thead>
<tr :style="{ backgroundColor: c.tableHeaderBg }">
<th :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
<th scope="col" :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
</tr>
</thead>
<tbody>
@@ -1369,7 +1370,7 @@ void clientAddress
<!-- Header -->
<div :style="{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '12px' }">
<div>
<img v-if="biz?.logo" :src="biz.logo" :style="{ width: '24px', height: '24px', objectFit: 'contain', marginBottom: '3px' }" />
<img v-if="biz?.logo" :src="biz.logo" alt="Business logo" :style="{ width: '24px', height: '24px', objectFit: 'contain', marginBottom: '3px' }" />
<div :style="{ fontSize: '20px', fontWeight: '700', color: c.primary }">INVOICE</div>
</div>
<div :style="{ textAlign: 'right' }">
@@ -1407,10 +1408,10 @@ void clientAddress
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '12px' }">
<thead>
<tr :style="{ backgroundColor: c.tableHeaderBg }">
<th :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
<th scope="col" :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
</tr>
</thead>
<tbody>
@@ -1482,7 +1483,7 @@ void clientAddress
<!-- Biz info -->
<div :style="{ marginBottom: '10px' }">
<img v-if="biz?.logo" :src="biz.logo" :style="{ width: '22px', height: '22px', objectFit: 'contain', marginBottom: '3px' }" />
<img v-if="biz?.logo" :src="biz.logo" alt="Business logo" :style="{ width: '22px', height: '22px', objectFit: 'contain', marginBottom: '3px' }" />
<div :style="{ fontSize: '9px', fontWeight: '600', color: c.headerText }">{{ biz?.name || 'Your Business' }}</div>
<div v-for="(line, i) in bizAddressLines" :key="'sf'+i" :style="{ fontSize: '7px' }">{{ line }}</div>
<div v-if="biz?.email" :style="{ fontSize: '7px' }">{{ biz.email }}</div>
@@ -1509,10 +1510,10 @@ void clientAddress
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '12px' }">
<thead>
<tr>
<th :style="{ textAlign: 'left', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
<th scope="col" :style="{ textAlign: 'left', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
</tr>
</thead>
<tbody>

View File

@@ -93,16 +93,18 @@ function selectTemplate(id: string) {
style="height: 480px"
>
<!-- Left panel: Template list -->
<div class="w-[30%] border-r border-border-subtle overflow-y-auto bg-bg-surface">
<div class="w-[30%] border-r border-border-subtle overflow-y-auto bg-bg-surface" role="radiogroup" aria-label="Invoice templates">
<div v-for="cat in TEMPLATE_CATEGORIES" :key="cat.id">
<div
class="text-[0.5625rem] text-text-tertiary uppercase tracking-[0.1em] font-medium px-3 pt-3 pb-1"
class="text-[0.5625rem] text-text-tertiary uppercase tracking-[0.08em] font-medium px-3 pt-3 pb-1"
>
{{ cat.label }}
</div>
<button
v-for="tmpl in getTemplatesByCategory(cat.id)"
:key="tmpl.id"
role="radio"
:aria-checked="tmpl.id === modelValue"
class="w-full flex items-center gap-2 px-3 py-1.5 text-[0.75rem] transition-colors"
:class="
tmpl.id === modelValue
@@ -114,6 +116,7 @@ function selectTemplate(id: string) {
<span
class="w-2.5 h-2.5 rounded-full shrink-0 border border-black/10"
:style="{ backgroundColor: tmpl.colors.primary }"
aria-hidden="true"
/>
<span class="truncate">{{ tmpl.name }}</span>
</button>
@@ -121,7 +124,7 @@ function selectTemplate(id: string) {
</div>
<!-- Right panel: Live preview -->
<div class="w-[70%] bg-bg-inset p-4 flex items-start justify-center overflow-y-auto">
<div class="w-[70%] bg-bg-inset p-4 flex items-start justify-center overflow-y-auto" aria-label="Template preview" aria-live="polite">
<div class="w-full max-w-sm">
<InvoicePreview
:template="selectedTemplate"

View File

@@ -0,0 +1,192 @@
<script setup lang="ts">
import { watch, ref, computed } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { open } from '@tauri-apps/plugin-dialog'
import { readTextFile } from '@tauri-apps/plugin-fs'
import { useFocusTrap } from '../utils/focusTrap'
import { useToastStore } from '../stores/toast'
import { Upload, ChevronLeft, ChevronRight, Loader2 } from 'lucide-vue-next'
const props = defineProps<{ show: boolean }>()
const emit = defineEmits<{ close: []; imported: [] }>()
const toastStore = useToastStore()
const { activate, deactivate } = useFocusTrap()
const dialogRef = ref<HTMLElement | null>(null)
const step = ref(1)
const filePath = ref('')
const parsedData = ref<Record<string, any[]> | null>(null)
const entityCounts = ref<{ key: string; count: number; selected: boolean }[]>([])
const importing = ref(false)
const entityLabels: Record<string, string> = {
clients: 'Clients',
projects: 'Projects',
tasks: 'Tasks',
time_entries: 'Time Entries',
tags: 'Tags',
invoices: 'Invoices',
invoice_items: 'Invoice Items',
expenses: 'Expenses',
favorites: 'Favorites',
recurring_entries: 'Recurring Entries',
entry_templates: 'Entry Templates',
settings: 'Settings',
}
async function pickFile() {
const selected = await open({
multiple: false,
filters: [{ name: 'JSON', extensions: ['json'] }],
})
if (selected) {
filePath.value = selected as string
try {
const text = await readTextFile(selected as string)
parsedData.value = JSON.parse(text)
entityCounts.value = Object.entries(parsedData.value!)
.filter(([, arr]) => Array.isArray(arr) && arr.length > 0)
.map(([key, arr]) => ({ key, count: arr.length, selected: true }))
step.value = 2
} catch {
toastStore.error('Failed to parse JSON file')
}
}
}
const selectedCount = computed(() => entityCounts.value.filter(e => e.selected).length)
async function runImport() {
if (!parsedData.value) return
importing.value = true
try {
const data: Record<string, any[]> = {}
for (const entity of entityCounts.value) {
if (entity.selected) {
data[entity.key] = parsedData.value[entity.key]
}
}
await invoke('import_json_data', { data: JSON.stringify(data) })
const totalItems = entityCounts.value.filter(e => e.selected).reduce((sum, e) => sum + e.count, 0)
toastStore.success(`Imported ${totalItems} items`)
emit('imported')
emit('close')
} catch (e) {
toastStore.error('Import failed: ' + String(e))
} finally {
importing.value = false
}
}
watch(() => props.show, (val) => {
if (val) {
step.value = 1
filePath.value = ''
parsedData.value = null
entityCounts.value = []
setTimeout(() => {
if (dialogRef.value) activate(dialogRef.value, { onDeactivate: () => emit('close') })
}, 50)
} else {
deactivate()
}
})
</script>
<template>
<Transition name="modal">
<div
v-if="show"
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
@click.self="$emit('close')"
>
<div
ref="dialogRef"
role="dialog"
aria-modal="true"
aria-labelledby="import-title"
class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-md p-6"
>
<h2 id="import-title" class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-4">
Restore from Backup
</h2>
<!-- Step 1: File selection -->
<div v-if="step === 1" class="text-center py-4">
<button
@click="pickFile"
class="inline-flex items-center gap-2 px-6 py-3 bg-accent text-bg-base font-medium rounded-lg hover:bg-accent-hover transition-colors duration-150"
>
<Upload class="w-4 h-4" :stroke-width="1.5" aria-hidden="true" />
Select JSON File
</button>
<p class="text-[0.6875rem] text-text-tertiary mt-3">Choose a ZeroClock backup .json file</p>
</div>
<!-- Step 2: Preview and select -->
<div v-else-if="step === 2">
<p class="text-[0.75rem] text-text-secondary mb-3">
Found {{ entityCounts.length }} data types. Select which to import:
</p>
<div class="space-y-1.5 max-h-60 overflow-y-auto mb-4">
<label
v-for="entity in entityCounts"
:key="entity.key"
class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-bg-elevated transition-colors duration-100 cursor-pointer"
>
<input
type="checkbox"
v-model="entity.selected"
class="w-4 h-4 rounded border-border-subtle text-accent focus:ring-accent"
/>
<span class="flex-1 text-[0.8125rem] text-text-primary">{{ entityLabels[entity.key] || entity.key }}</span>
<span class="text-[0.6875rem] text-text-tertiary">{{ entity.count }}</span>
</label>
</div>
</div>
<!-- Step 3: Importing -->
<div v-else-if="step === 3" class="flex items-center justify-center gap-3 py-8">
<Loader2 class="w-5 h-5 text-accent animate-spin" :stroke-width="1.5" aria-hidden="true" />
<span class="text-[0.8125rem] text-text-secondary">Importing data...</span>
</div>
<div v-if="step === 2" class="flex justify-between mt-4">
<button
@click="step = 1"
class="inline-flex items-center gap-1 px-3 py-2 text-[0.8125rem] text-text-secondary hover:text-text-primary transition-colors duration-150"
>
<ChevronLeft class="w-3.5 h-3.5" :stroke-width="2" aria-hidden="true" />
Back
</button>
<div class="flex gap-3">
<button
@click="$emit('close')"
class="px-4 py-2 text-[0.8125rem] border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated transition-colors duration-150"
>
Cancel
</button>
<button
@click="step = 3; runImport()"
:disabled="selectedCount === 0"
class="inline-flex items-center gap-1 px-4 py-2 text-[0.8125rem] bg-accent text-bg-base font-medium rounded-lg hover:bg-accent-hover transition-colors duration-150 disabled:opacity-50"
>
Import
<ChevronRight class="w-3.5 h-3.5" :stroke-width="2" aria-hidden="true" />
</button>
</div>
</div>
<div v-if="step === 1" class="flex justify-end mt-4">
<button
@click="$emit('close')"
class="px-4 py-2 text-[0.8125rem] border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated transition-colors duration-150"
>
Cancel
</button>
</div>
</div>
</div>
</Transition>
</template>

View File

@@ -0,0 +1,95 @@
<script setup lang="ts">
import { watch, ref } from 'vue'
import { useFocusTrap } from '../utils/focusTrap'
const props = defineProps<{ show: boolean }>()
const emit = defineEmits<{ close: [] }>()
const { activate, deactivate } = useFocusTrap()
const dialogRef = ref<HTMLElement | null>(null)
watch(() => props.show, (val) => {
if (val) {
setTimeout(() => {
if (dialogRef.value) activate(dialogRef.value, { onDeactivate: () => emit('close') })
}, 50)
} else {
deactivate()
}
})
const groups = [
{
label: 'Global',
shortcuts: [
{ keys: '?', description: 'Show keyboard shortcuts' },
{ keys: 'Ctrl+Shift+T', description: 'Toggle timer' },
{ keys: 'Ctrl+Shift+Z', description: 'Show/focus app' },
{ keys: 'Ctrl+Shift+N', description: 'Quick entry' },
]
},
{
label: 'Timer',
shortcuts: [
{ keys: 'Space', description: 'Start/stop timer (when focused)' },
]
},
{
label: 'Navigation',
shortcuts: [
{ keys: 'Arrow keys', description: 'Navigate tabs, calendar, lists' },
{ keys: 'Enter', description: 'Open/select focused item' },
{ keys: 'Escape', description: 'Close dialog/popover' },
]
},
]
</script>
<template>
<Transition name="modal">
<div
v-if="show"
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
@click.self="emit('close')"
>
<div
ref="dialogRef"
role="dialog"
aria-modal="true"
aria-labelledby="shortcuts-title"
class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-md p-6"
>
<h2 id="shortcuts-title" class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-4">
Keyboard Shortcuts
</h2>
<div class="space-y-4">
<div v-for="group in groups" :key="group.label">
<h3 class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium mb-2">
{{ group.label }}
</h3>
<div class="space-y-1.5">
<div
v-for="shortcut in group.shortcuts"
:key="shortcut.keys"
class="flex items-center justify-between text-[0.8125rem]"
>
<span class="text-text-secondary">{{ shortcut.description }}</span>
<kbd class="px-2 py-0.5 bg-bg-elevated border border-border-subtle rounded text-[0.6875rem] font-mono text-text-primary">
{{ shortcut.keys }}
</kbd>
</div>
</div>
</div>
</div>
<div class="mt-6 flex justify-end">
<button
@click="emit('close')"
class="px-4 py-2 border border-border-subtle text-text-secondary text-[0.8125rem] rounded-lg hover:bg-bg-elevated transition-colors duration-150"
>
Close
</button>
</div>
</div>
</div>
</Transition>
</template>

View File

@@ -1,7 +1,9 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ref, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useTimerStore } from '../stores/timer'
import { useSettingsStore } from '../stores/settings'
import { useInvoicesStore } from '../stores/invoices'
import {
LayoutDashboard,
Clock,
@@ -12,78 +14,250 @@ import {
Grid3X3,
BarChart3,
FileText,
Settings
Settings,
PanelLeftOpen,
PanelLeftClose
} from 'lucide-vue-next'
const route = useRoute()
const router = useRouter()
const timerStore = useTimerStore()
const settingsStore = useSettingsStore()
const invoicesStore = useInvoicesStore()
const navItems = [
{ name: 'Dashboard', path: '/', icon: LayoutDashboard },
{ name: 'Timer', path: '/timer', icon: Clock },
{ name: 'Clients', path: '/clients', icon: Users },
{ name: 'Projects', path: '/projects', icon: FolderKanban },
{ name: 'Entries', path: '/entries', icon: List },
{ name: 'Calendar', path: '/calendar', icon: CalendarDays },
{ name: 'Timesheet', path: '/timesheet', icon: Grid3X3 },
{ name: 'Invoices', path: '/invoices', icon: FileText },
{ name: 'Reports', path: '/reports', icon: BarChart3 },
{ name: 'Settings', path: '/settings', icon: Settings }
const expanded = ref(false)
interface NavItem {
name: string
path: string
icon: any
}
interface NavGroup {
label: string
items: NavItem[]
}
const groups: NavGroup[] = [
{
label: 'Track',
items: [
{ name: 'Dashboard', path: '/', icon: LayoutDashboard },
{ name: 'Timer', path: '/timer', icon: Clock },
],
},
{
label: 'Manage',
items: [
{ name: 'Clients', path: '/clients', icon: Users },
{ name: 'Projects', path: '/projects', icon: FolderKanban },
{ name: 'Entries', path: '/entries', icon: List },
],
},
{
label: 'Views',
items: [
{ name: 'Calendar', path: '/calendar', icon: CalendarDays },
{ name: 'Timesheet', path: '/timesheet', icon: Grid3X3 },
],
},
{
label: 'Business',
items: [
{ name: 'Invoices', path: '/invoices', icon: FileText },
{ name: 'Reports', path: '/reports', icon: BarChart3 },
],
},
]
const settingsItem: NavItem = { name: 'Settings', path: '/settings', icon: Settings }
const currentPath = computed(() => route.path)
const activeIndex = computed(() => {
return navItems.findIndex(item => item.path === currentPath.value)
})
// Only show tooltips when nav is collapsed (labels are hidden)
function tip(text: string) {
return expanded.value ? '' : text
}
function navigate(path: string) {
router.push(path)
}
async function toggleExpanded() {
expanded.value = !expanded.value
await settingsStore.updateSetting('nav_expanded', expanded.value ? 'true' : 'false')
}
// Watch for settings to load (NavRail mounts before App.vue's fetchSettings resolves)
watch(() => settingsStore.settings.nav_expanded, (val) => {
if (val !== undefined) {
expanded.value = val === 'true'
}
}, { immediate: true })
</script>
<template>
<nav class="w-12 flex flex-col items-center bg-bg-surface border-r border-border-subtle shrink-0">
<div class="relative flex-1 flex flex-col items-center pt-2 gap-1">
<!-- Sliding active indicator -->
<nav
aria-label="Main navigation"
class="flex flex-col bg-bg-surface border-r border-border-subtle shrink-0 transition-[width] duration-200 ease-out overflow-hidden"
:class="expanded ? 'w-[180px]' : 'w-12'"
>
<!-- Scrollable nav items -->
<div class="relative flex-1 flex flex-col pt-1 overflow-y-auto overflow-x-hidden">
<div
v-if="activeIndex >= 0"
class="absolute left-0 w-[2px] bg-accent transition-all duration-300"
:style="{ top: `${activeIndex * 52 + 8 + 8}px`, height: '36px' }"
style="transition-timing-function: cubic-bezier(0.22, 1, 0.36, 1);"
/>
<button
v-for="item in navItems"
:key="item.path"
@click="navigate(item.path)"
class="relative w-12 h-[52px] flex items-center justify-center transition-colors duration-150 group"
:class="currentPath === item.path
? 'text-text-primary'
: 'text-text-tertiary hover:text-text-secondary'"
:title="item.name"
v-for="(group, groupIndex) in groups"
:key="group.label"
>
<component :is="item.icon" class="w-[18px] h-[18px]" :stroke-width="1.5" />
<!-- Tooltip -->
<div class="absolute left-full ml-2 px-2 py-1 bg-bg-elevated border border-border-subtle rounded-lg text-[0.6875rem] text-text-primary whitespace-nowrap opacity-0 pointer-events-none group-hover:opacity-100 transition-opacity duration-150 z-50">
<div class="absolute -left-1 top-1/2 -translate-y-1/2 w-0 h-0 border-y-4 border-y-transparent border-r-4" style="border-right-color: var(--color-bg-elevated)"></div>
{{ item.name }}
<!-- Section header (expanded only) -->
<div
class="overflow-hidden transition-[max-height,opacity] duration-200 ease-out"
:class="expanded ? 'max-h-8 opacity-100' : 'max-h-0 opacity-0'"
>
<p
class="px-3 pt-3 pb-1 text-[0.5625rem] text-text-tertiary uppercase tracking-[0.08em] font-medium truncate"
aria-hidden="true"
>
{{ group.label }}
</p>
</div>
</button>
<!-- Divider (collapsed only, between groups) -->
<div
class="mx-2.5 border-t border-border-subtle overflow-hidden transition-[max-height,opacity,margin] duration-200 ease-out"
:class="!expanded && groupIndex > 0 ? 'max-h-2 opacity-100 my-1' : 'max-h-0 opacity-0 my-0'"
aria-hidden="true"
/>
<!-- Items -->
<button
v-for="item in group.items"
:key="item.path"
v-tooltip.right="tip(item.name)"
@click="navigate(item.path)"
class="nav-item relative w-full flex items-center h-11 transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-accent"
:class="currentPath === item.path
? 'text-text-primary'
: 'text-text-tertiary hover:text-text-secondary'"
:aria-label="item.name"
:aria-current="currentPath === item.path ? 'page' : undefined"
>
<!-- Active indicator -->
<div
class="absolute left-0 top-1 bottom-1 w-[2px] rounded-full transition-opacity duration-200"
:class="currentPath === item.path ? 'bg-accent opacity-100' : 'opacity-0'"
aria-hidden="true"
/>
<!-- Fixed-width icon container - always centered in 48px -->
<div class="relative w-12 flex items-center justify-center shrink-0">
<component :is="item.icon" aria-hidden="true" class="w-[18px] h-[18px]" :stroke-width="1.5" />
<span
v-if="item.path === '/invoices' && invoicesStore.overdueCount > 0"
class="absolute top-[-2px] right-1.5 min-w-[1rem] h-4 flex items-center justify-center text-[0.5625rem] font-bold text-white bg-status-error rounded-full px-1"
:aria-label="`${invoicesStore.overdueCount} overdue invoices`"
>
{{ invoicesStore.overdueCount }}
</span>
</div>
<!-- Label (fades in with expand) -->
<span
class="text-[0.75rem] truncate whitespace-nowrap transition-opacity duration-200"
:class="expanded ? 'opacity-100' : 'opacity-0 pointer-events-none'"
>
{{ item.name }}
</span>
</button>
</div>
</div>
<!-- Timer status indicator (bottom) -->
<div class="pb-4">
<!-- Bottom section: Settings + toggle + timer -->
<div class="flex flex-col pb-1.5 border-t border-border-subtle">
<!-- Settings -->
<button
v-tooltip.right="tip(settingsItem.name)"
@click="navigate(settingsItem.path)"
class="nav-item relative w-full flex items-center h-11 transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-accent"
:class="currentPath === settingsItem.path
? 'text-text-primary'
: 'text-text-tertiary hover:text-text-secondary'"
:aria-label="settingsItem.name"
:aria-current="currentPath === settingsItem.path ? 'page' : undefined"
>
<!-- Active indicator -->
<div
class="absolute left-0 top-1 bottom-1 w-[2px] rounded-full transition-opacity duration-200"
:class="currentPath === settingsItem.path ? 'bg-accent opacity-100' : 'opacity-0'"
aria-hidden="true"
/>
<div class="w-12 flex items-center justify-center shrink-0">
<component :is="settingsItem.icon" aria-hidden="true" class="w-[18px] h-[18px]" :stroke-width="1.5" />
</div>
<span
class="text-[0.75rem] truncate whitespace-nowrap transition-opacity duration-200"
:class="expanded ? 'opacity-100' : 'opacity-0 pointer-events-none'"
>
{{ settingsItem.name }}
</span>
</button>
<!-- Expand/collapse toggle -->
<button
v-tooltip.right="tip(expanded ? 'Collapse' : 'Expand')"
@click="toggleExpanded"
class="relative w-full flex items-center h-11 transition-colors duration-150 text-text-tertiary hover:text-text-secondary focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-accent"
:aria-label="expanded ? 'Collapse navigation' : 'Expand navigation'"
:aria-expanded="expanded"
>
<div class="w-12 flex items-center justify-center shrink-0">
<component
:is="expanded ? PanelLeftClose : PanelLeftOpen"
aria-hidden="true"
class="w-[16px] h-[16px]"
:stroke-width="1.5"
/>
</div>
<span
class="text-[0.6875rem] truncate whitespace-nowrap transition-opacity duration-200"
:class="expanded ? 'opacity-100' : 'opacity-0 pointer-events-none'"
>
Collapse
</span>
</button>
<!-- Timer status indicator (only when running/paused) -->
<div
v-if="timerStore.isRunning"
class="w-2 h-2 rounded-full bg-status-running animate-pulse-dot"
/>
<div
v-else-if="timerStore.isPaused"
class="w-2 h-2 rounded-full bg-status-warning animate-pulse-dot"
/>
v-if="timerStore.isRunning || timerStore.isPaused"
class="flex items-center h-7"
role="status"
>
<span class="sr-only">
{{ timerStore.isRunning ? 'Timer running' : 'Timer paused' }}
</span>
<div class="w-12 flex items-center justify-center shrink-0">
<div
v-if="timerStore.isRunning"
class="w-2 h-2 rounded-full bg-status-running animate-pulse-dot"
aria-hidden="true"
/>
<div
v-else
class="w-2 h-2 rounded-full bg-status-warning animate-pulse-dot"
aria-hidden="true"
/>
</div>
<span
class="text-[0.6875rem] truncate whitespace-nowrap transition-opacity duration-200"
:class="[
expanded ? 'opacity-100' : 'opacity-0',
timerStore.isRunning ? 'text-status-running' : 'text-status-warning'
]"
>
{{ timerStore.isRunning ? 'Running' : 'Paused' }}
</span>
</div>
</div>
</nav>
</template>

View File

@@ -4,6 +4,7 @@ import { useFocusTrap } from '../utils/focusTrap'
import { useAnnouncer } from '../composables/useAnnouncer'
import AppSelect from './AppSelect.vue'
import AppDatePicker from './AppDatePicker.vue'
import AppTimePicker from './AppTimePicker.vue'
import { useProjectsStore, type Task } from '../stores/projects'
import { useEntriesStore } from '../stores/entries'
import { useSettingsStore } from '../stores/settings'
@@ -31,14 +32,15 @@ const selectedProjectId = ref<number | null>(null)
const selectedTaskId = ref<number | null>(null)
const description = ref('')
const entryDate = ref(new Date().toISOString().split('T')[0])
const durationInput = ref('')
const durationHour = ref(1)
const durationMinute = ref(0)
const billable = ref(1)
const tasks = ref<Task[]>([])
const saving = ref(false)
const availableProjects = computed(() => projectsStore.projects.filter(p => !p.archived))
const availableProjects = computed(() => projectsStore.activeProjects)
const canSave = computed(() => !!selectedProjectId.value && !!durationInput.value.trim() && !saving.value)
const canSave = computed(() => !!selectedProjectId.value && (durationHour.value > 0 || durationMinute.value > 0) && !saving.value)
watch(selectedProjectId, async (projectId) => {
selectedTaskId.value = null
@@ -62,7 +64,8 @@ watch(() => props.show, async (val) => {
selectedTaskId.value = null
description.value = ''
entryDate.value = new Date().toISOString().split('T')[0]
durationInput.value = ''
durationHour.value = 1
durationMinute.value = 0
billable.value = 1
saving.value = false
@@ -78,27 +81,9 @@ watch(() => props.show, async (val) => {
onUnmounted(() => deactivateTrap())
function parseDuration(input: string): number | null {
const trimmed = input.trim()
if (!trimmed) return null
// H:MM format
if (trimmed.includes(':')) {
const [h, m] = trimmed.split(':').map(Number)
if (isNaN(h) || isNaN(m) || h < 0 || m < 0 || m > 59) return null
return h * 3600 + m * 60
}
// Decimal hours
const num = parseFloat(trimmed)
if (isNaN(num) || num < 0 || num > 24) return null
return Math.round(num * 3600)
}
async function handleSave() {
if (!selectedProjectId.value || !durationInput.value) return
const duration = parseDuration(durationInput.value)
if (duration === null || duration <= 0) return
const duration = durationHour.value * 3600 + durationMinute.value * 60
if (!selectedProjectId.value || duration <= 0) return
saving.value = true
@@ -132,7 +117,7 @@ async function handleSave() {
</script>
<template>
<Teleport to="body">
<Teleport to="#app">
<Transition name="modal">
<div
v-if="show"
@@ -154,6 +139,7 @@ async function handleSave() {
@click="$emit('close')"
class="p-1 text-text-tertiary hover:text-text-primary transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
aria-label="Close"
v-tooltip="'Close'"
>
<X class="w-4 h-4" aria-hidden="true" />
</button>
@@ -201,13 +187,11 @@ async function handleSave() {
<AppDatePicker v-model="entryDate" />
</div>
<div>
<label for="quick-entry-duration" class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Duration *</label>
<input
id="quick-entry-duration"
v-model="durationInput"
type="text"
class="w-full px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary font-mono focus:outline-none focus:border-border-visible"
placeholder="1:30 or 1.5"
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Duration *</label>
<AppTimePicker
v-model:hour="durationHour"
v-model:minute="durationMinute"
placeholder="Duration"
/>
</div>
</div>

View File

@@ -53,7 +53,7 @@ function onKeydown(e: KeyboardEvent) {
</script>
<template>
<Teleport to="body">
<Teleport to="#app">
<Transition name="modal">
<div
v-if="show"
@@ -74,6 +74,7 @@ function onKeydown(e: KeyboardEvent) {
@click="zoomOut"
class="p-2 bg-bg-surface/80 rounded-lg text-text-primary hover:bg-bg-surface transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
aria-label="Zoom out"
v-tooltip="'Zoom out'"
>
<ZoomOut class="w-4 h-4" aria-hidden="true" />
</button>
@@ -84,6 +85,7 @@ function onKeydown(e: KeyboardEvent) {
@click="zoomIn"
class="p-2 bg-bg-surface/80 rounded-lg text-text-primary hover:bg-bg-surface transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
aria-label="Zoom in"
v-tooltip="'Zoom in'"
>
<ZoomIn class="w-4 h-4" aria-hidden="true" />
</button>
@@ -91,6 +93,7 @@ function onKeydown(e: KeyboardEvent) {
@click="$emit('close')"
class="p-2 bg-bg-surface/80 rounded-lg text-text-primary hover:bg-bg-surface transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
aria-label="Close lightbox"
v-tooltip="'Close'"
>
<X class="w-4 h-4" aria-hidden="true" />
</button>

View File

@@ -0,0 +1,137 @@
<script setup lang="ts">
import { watch, ref, computed } from 'vue'
import { Clock, Calendar } from 'lucide-vue-next'
import { useFocusTrap } from '../utils/focusTrap'
interface Props {
show: boolean
projectName: string
projectColor?: string
taskName?: string
description?: string
duration: number
timeOfDay: string
}
const props = withDefaults(defineProps<Props>(), {
projectColor: '#6B7280',
taskName: '',
description: '',
})
const emit = defineEmits<{
confirm: []
snooze: []
skip: []
}>()
const { activate, deactivate } = useFocusTrap()
const dialogRef = ref<HTMLElement | null>(null)
const announcement = ref('')
const formattedDuration = computed(() => {
const totalSeconds = props.duration
const h = Math.floor(totalSeconds / 3600)
const m = Math.floor((totalSeconds % 3600) / 60)
if (h === 0) return `${m}m`
return `${h}h ${m}m`
})
watch(() => props.show, (val) => {
if (val) {
announcement.value = `Recurring entry ready: ${props.projectName} - ${props.description || 'Scheduled task'}`
setTimeout(() => {
if (dialogRef.value) activate(dialogRef.value, { onDeactivate: () => emit('skip') })
}, 50)
} else {
deactivate()
announcement.value = ''
}
})
</script>
<template>
<Teleport to="#app">
<Transition name="modal">
<div
v-if="show"
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-[60]"
@click.self="$emit('skip')"
>
<div
ref="dialogRef"
role="alertdialog"
aria-modal="true"
aria-labelledby="recurring-prompt-title"
class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-sm p-6"
>
<h2
id="recurring-prompt-title"
class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-4"
>
Recurring Entry Ready
</h2>
<div class="space-y-3 mb-6">
<!-- Project -->
<div class="flex items-center gap-2">
<span
class="w-2.5 h-2.5 rounded-full shrink-0"
:style="{ backgroundColor: projectColor }"
aria-hidden="true"
/>
<span class="text-[0.8125rem] font-medium text-text-primary">{{ projectName }}</span>
</div>
<!-- Task name -->
<div v-if="taskName" class="text-[0.75rem] text-text-secondary pl-[1.125rem]">
{{ taskName }}
</div>
<!-- Description -->
<div v-if="description" class="text-[0.75rem] text-text-secondary pl-[1.125rem]">
{{ description }}
</div>
<!-- Duration -->
<div class="flex items-center gap-2 text-[0.75rem] text-text-secondary">
<Clock class="w-3.5 h-3.5 shrink-0" :stroke-width="1.5" aria-hidden="true" />
<span>{{ formattedDuration }}</span>
</div>
<!-- Time of day -->
<div v-if="timeOfDay" class="flex items-center gap-2 text-[0.75rem] text-text-secondary">
<Calendar class="w-3.5 h-3.5 shrink-0" :stroke-width="1.5" aria-hidden="true" />
<span>{{ timeOfDay }}</span>
</div>
</div>
<div class="flex justify-end gap-2">
<button
@click="$emit('skip')"
class="px-3 py-1.5 text-[0.75rem] text-text-tertiary hover:text-text-secondary transition-colors"
>
Skip
</button>
<button
@click="$emit('snooze')"
class="px-3 py-1.5 text-[0.75rem] font-medium border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated transition-colors"
>
Snooze 30min
</button>
<button
@click="$emit('confirm')"
class="px-3 py-1.5 text-[0.75rem] font-medium bg-accent text-bg-base rounded-lg hover:bg-accent-hover transition-colors"
>
Create Entry
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
<!-- aria-live announcement -->
<div class="sr-only" aria-live="assertive" aria-atomic="true">{{ announcement }}</div>
</template>

View File

@@ -61,7 +61,7 @@ async function fetchProcesses() {
function updatePosition() {
if (!triggerRef.value) return
panelStyle.value = computeDropdownPosition(triggerRef.value, { minWidth: 280, estimatedHeight: 290 })
panelStyle.value = computeDropdownPosition(triggerRef.value, { minWidth: 280, estimatedHeight: 290, panelEl: panelRef.value })
}
function toggle() {
@@ -77,6 +77,7 @@ async function open() {
updatePosition()
highlightedIndex.value = 0
searchQuery.value = ''
nextTick(() => updatePosition())
await fetchProcesses()
nextTick(() => searchInputRef.value?.focus())
document.addEventListener('click', onClickOutside, true)
@@ -144,13 +145,15 @@ onBeforeUnmount(() => {
ref="triggerRef"
type="button"
@click="toggle"
:aria-expanded="isOpen"
aria-haspopup="listbox"
class="flex items-center gap-1.5 px-2.5 py-1.5 border border-border-subtle text-text-secondary text-[0.75rem] rounded-lg hover:bg-bg-elevated hover:text-text-primary transition-colors"
>
<Monitor class="w-3.5 h-3.5" :stroke-width="1.5" />
<Monitor class="w-3.5 h-3.5" :stroke-width="1.5" aria-hidden="true" />
From Running Apps
</button>
<Teleport to="body">
<Teleport to="#app">
<Transition name="dropdown">
<div
v-if="isOpen"
@@ -165,20 +168,22 @@ onBeforeUnmount(() => {
type="text"
class="flex-1 px-2.5 py-1.5 bg-bg-inset border border-border-subtle rounded-lg text-[0.75rem] text-text-primary placeholder-text-tertiary focus:outline-none focus:border-border-visible"
placeholder="Search apps..."
aria-label="Search running applications"
@keydown="onSearchKeydown"
/>
<button
type="button"
@click="fetchProcesses"
class="p-1.5 text-text-tertiary hover:text-text-secondary transition-colors"
title="Refresh"
aria-label="Refresh application list"
v-tooltip="'Refresh'"
>
<RefreshCw class="w-3.5 h-3.5" :class="{ 'animate-spin': loading }" :stroke-width="1.5" />
<RefreshCw class="w-3.5 h-3.5" :class="{ 'animate-spin': loading }" :stroke-width="1.5" aria-hidden="true" />
</button>
</div>
<div class="max-h-[240px] overflow-y-auto py-1">
<div v-if="loading && filteredProcesses.length === 0" class="px-3 py-4 text-center text-[0.75rem] text-text-tertiary">
<div class="max-h-[240px] overflow-y-auto py-1" role="listbox" aria-label="Running applications">
<div v-if="loading && filteredProcesses.length === 0" class="px-3 py-4 text-center text-[0.75rem] text-text-tertiary" aria-live="polite">
Loading...
</div>
<div v-else-if="filteredProcesses.length === 0" class="px-3 py-4 text-center text-[0.75rem] text-text-tertiary">
@@ -187,6 +192,9 @@ onBeforeUnmount(() => {
<div
v-for="(app, index) in filteredProcesses"
:key="app.exe_path"
role="option"
:id="'app-option-' + index"
:aria-selected="highlightedIndex === index"
@click="selectApp(app)"
@mouseenter="highlightedIndex = index"
class="flex items-center gap-2.5 px-3 py-2 cursor-pointer transition-colors"

View File

@@ -63,7 +63,7 @@ function handleSave() {
</script>
<template>
<Teleport to="body">
<Teleport to="#app">
<Transition name="modal">
<div
v-if="show"
@@ -86,6 +86,7 @@ function handleSave() {
@click="$emit('cancel')"
class="p-1 text-text-tertiary hover:text-text-primary transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
aria-label="Close"
v-tooltip="'Close'"
>
<X class="w-4 h-4" aria-hidden="true" />
</button>

View File

@@ -3,11 +3,13 @@ import { getCurrentWindow } from '@tauri-apps/api/window'
import { ref, onMounted } from 'vue'
import { useTimerStore } from '../stores/timer'
import { useProjectsStore } from '../stores/projects'
import { useSettingsStore } from '../stores/settings'
const appWindow = getCurrentWindow()
const isMaximized = ref(false)
const timerStore = useTimerStore()
const projectsStore = useProjectsStore()
const settingsStore = useSettingsStore()
onMounted(async () => {
isMaximized.value = await appWindow.isMaximized()
@@ -20,7 +22,11 @@ function getProjectName(projectId: number | null): string {
}
async function minimize() {
await appWindow.minimize()
if (settingsStore.settings.minimize_to_tray === 'true') {
await appWindow.hide()
} else {
await appWindow.minimize()
}
}
async function toggleMaximize() {
@@ -43,7 +49,7 @@ async function handleDoubleClick() {
<template>
<header
class="h-10 flex items-center justify-between px-4 bg-bg-surface border-b border-border-subtle select-none shrink-0"
class="h-11 flex items-center justify-between px-4 bg-bg-surface border-b border-border-subtle select-none shrink-0"
@mousedown.left="startDrag"
@dblclick="handleDoubleClick"
>
@@ -58,11 +64,18 @@ async function handleDoubleClick() {
<!-- Center: Running timer status -->
<div
role="status"
aria-live="off"
class="flex items-center gap-3 transition-opacity duration-150"
:class="timerStore.isRunning ? 'opacity-100' : 'opacity-0 pointer-events-none'"
:class="timerStore.isRunning || timerStore.isPaused ? 'opacity-100' : 'opacity-0 pointer-events-none'"
>
<!-- Pulsing green dot -->
<div class="w-2 h-2 rounded-full bg-status-running animate-pulse-dot" />
<!-- Status dot -->
<div
aria-hidden="true"
class="w-2 h-2 rounded-full"
:class="timerStore.isRunning ? 'bg-status-running animate-pulse-dot' : 'bg-status-warning'"
/>
<span class="sr-only">{{ timerStore.isRunning ? 'Timer running' : timerStore.isPaused ? 'Timer paused' : 'Timer stopped' }}</span>
<!-- Project name -->
<span class="text-[0.6875rem] text-text-secondary">
@@ -70,18 +83,46 @@ async function handleDoubleClick() {
</span>
<!-- Elapsed time -->
<span class="text-[0.75rem] font-mono text-text-primary tracking-wider">
<span class="text-[0.75rem] font-[family-name:var(--font-timer)] text-text-primary tracking-wider">
{{ timerStore.formattedTime }}
</span>
<!-- Pause / Resume button -->
<button
v-if="timerStore.isRunning"
v-tooltip.bottom="'Pause timer'"
@click="timerStore.pauseManual()"
@mousedown.stop
class="w-11 h-11 flex items-center justify-center text-text-tertiary hover:text-status-warning transition-colors duration-150"
aria-label="Pause timer"
>
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-3 h-3">
<rect x="3" y="2" width="3.5" height="12" rx="1" />
<rect x="9.5" y="2" width="3.5" height="12" rx="1" />
</svg>
</button>
<button
v-else-if="timerStore.timerState === 'PAUSED_MANUAL'"
v-tooltip.bottom="'Resume timer'"
@click="timerStore.resumeFromPause()"
@mousedown.stop
class="w-11 h-11 flex items-center justify-center text-text-tertiary hover:text-accent-text transition-colors duration-150"
aria-label="Resume timer"
>
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-3 h-3">
<path d="M4 2.5a.5.5 0 01.77-.42l9 5.5a.5.5 0 010 .84l-9 5.5A.5.5 0 014 13.5V2.5z" />
</svg>
</button>
<!-- Stop button -->
<button
v-tooltip.bottom="'Stop timer'"
@click="timerStore.stop()"
@mousedown.stop
class="w-5 h-5 flex items-center justify-center text-text-tertiary hover:text-status-error transition-colors duration-150"
title="Stop timer"
class="w-11 h-11 flex items-center justify-center text-text-tertiary hover:text-status-error transition-colors duration-150"
aria-label="Stop timer"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-3 h-3">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-3 h-3">
<rect x="3" y="3" width="10" height="10" rx="1" />
</svg>
</button>
@@ -90,35 +131,38 @@ async function handleDoubleClick() {
<!-- Right: Window controls -->
<div class="flex items-center" @mousedown.stop>
<button
v-tooltip.bottom="'Minimize'"
@click="minimize"
class="w-10 h-10 flex items-center justify-center text-text-tertiary hover:text-text-secondary transition-colors duration-150"
title="Minimize"
class="w-11 h-11 flex items-center justify-center text-text-tertiary hover:text-text-secondary transition-colors duration-150"
aria-label="Minimize"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" class="w-3.5 h-3.5">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" class="w-3.5 h-3.5">
<path d="M5 12h14" stroke="currentColor" stroke-width="2" />
</svg>
</button>
<button
v-tooltip.bottom="isMaximized ? 'Restore' : 'Maximize'"
@click="toggleMaximize"
class="w-10 h-10 flex items-center justify-center text-text-tertiary hover:text-text-secondary transition-colors duration-150"
:title="isMaximized ? 'Restore' : 'Maximize'"
class="w-11 h-11 flex items-center justify-center text-text-tertiary hover:text-text-secondary transition-colors duration-150"
:aria-label="isMaximized ? 'Restore' : 'Maximize'"
>
<svg v-if="!isMaximized" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-3 h-3">
<svg v-if="!isMaximized" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-3 h-3">
<rect x="4" y="4" width="16" height="16" rx="1" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-3 h-3">
<svg v-else aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-3 h-3">
<rect x="3" y="7" width="14" height="14" rx="1" />
<path d="M7 7V5a1 1 0 011-1h12a1 1 0 011 1v12a1 1 0 01-1 1h-2" />
</svg>
</button>
<button
v-tooltip.bottom="'Close'"
@click="close"
class="w-10 h-10 flex items-center justify-center text-text-tertiary hover:text-status-error transition-colors duration-150"
title="Close"
class="w-11 h-11 flex items-center justify-center text-text-tertiary hover:text-status-error transition-colors duration-150"
aria-label="Close"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-3.5 h-3.5">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-3.5 h-3.5">
<path fill-rule="evenodd" d="M5.47 5.47a.75.75 0 011.06 0L12 10.94l5.47-5.47a.75.75 0 111.06 1.06L13.06 12l5.47 5.47a.75.75 0 11-1.06 1.06L12 13.06l-5.47 5.47a.75.75 0 01-1.06-1.06L10.94 12 5.47 6.53a.75.75 0 010-1.06z" clip-rule="evenodd" />
</svg>
</button>

View File

@@ -11,8 +11,7 @@ const toastStore = useToastStore()
aria-label="Notifications"
aria-live="polite"
aria-atomic="false"
class="fixed top-4 left-1/2 -translate-x-1/2 z-[100] flex flex-col gap-2 pointer-events-none"
style="margin-left: 24px;"
class="fixed top-4 left-1/2 -translate-x-1/2 ml-6 z-[100] flex flex-col gap-2 pointer-events-none"
>
<div
v-for="toast in toastStore.toasts"
@@ -24,7 +23,7 @@ const toastStore = useToastStore()
@mouseleave="toastStore.resumeToast(toast.id)"
@focusin="toastStore.pauseToast(toast.id)"
@focusout="toastStore.resumeToast(toast.id)"
class="w-80 flex items-center gap-3 px-4 py-3 bg-bg-surface border border-border-subtle rounded-lg shadow-lg pointer-events-auto border-l-[3px]"
class="relative w-80 flex items-center gap-3 px-4 py-3 bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] pointer-events-auto border-l-[3px] overflow-hidden"
:class="[
toast.exiting ? 'animate-toast-exit' : 'animate-toast-enter',
toast.type === 'success' ? 'border-l-status-running' : '',
@@ -36,22 +35,38 @@ const toastStore = useToastStore()
<AlertCircle v-if="toast.type === 'error'" class="w-4 h-4 text-status-error shrink-0" aria-hidden="true" :stroke-width="2" />
<Info v-if="toast.type === 'info'" class="w-4 h-4 text-accent shrink-0" aria-hidden="true" :stroke-width="2" />
<span class="sr-only">{{ toast.type === 'success' ? 'Success:' : toast.type === 'error' ? 'Error:' : 'Info:' }}</span>
<span class="text-sm text-text-primary flex-1">{{ toast.message }}</span>
<span class="text-[0.875rem] text-text-primary flex-1">{{ toast.message }}</span>
<button
v-if="toast.onUndo"
aria-label="Undo"
v-tooltip="'Undo'"
@click.stop="toastStore.undoToast(toast.id)"
class="shrink-0 p-1 text-accent hover:text-accent/80 transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent rounded"
class="shrink-0 p-1 text-accent hover:text-accent/80 transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent rounded-lg"
>
<Undo2 class="w-3.5 h-3.5" aria-hidden="true" :stroke-width="2" />
</button>
<button
aria-label="Dismiss"
v-tooltip="'Dismiss'"
@click.stop="toastStore.removeToast(toast.id)"
class="shrink-0 p-1 text-text-tertiary hover:text-text-primary transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent rounded"
class="shrink-0 p-1 text-text-tertiary hover:text-text-primary transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent rounded-lg"
>
<X class="w-3.5 h-3.5" aria-hidden="true" :stroke-width="2" />
</button>
<div
v-if="!toast.exiting"
aria-hidden="true"
class="absolute bottom-0 left-0 h-0.5 rounded-b-lg"
:class="[
toast.type === 'success' ? 'bg-status-running' : '',
toast.type === 'error' ? 'bg-status-error' : '',
toast.type === 'info' ? 'bg-accent' : ''
]"
:style="{
animation: `toast-progress ${toast.duration}ms linear forwards`,
animationPlayState: toast.paused ? 'paused' : 'running',
}"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,208 @@
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { useTourStore } from '../stores/tour'
import { getZoomFactor } from '../utils/dropdown'
const tourStore = useTourStore()
const spotlightStyle = ref<Record<string, string>>({})
const tooltipStyle = ref<Record<string, string>>({})
const tooltipRef = ref<HTMLElement | null>(null)
function positionElements() {
const step = tourStore.currentStep
if (!step) return
const el = document.querySelector(step.target) as HTMLElement | null
if (!el) {
if (!tourStore.isLastStep) {
tourStore.next()
} else {
tourStore.stop()
}
return
}
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
setTimeout(() => {
const rect = el.getBoundingClientRect()
const padding = 6
const zoom = getZoomFactor()
spotlightStyle.value = {
top: `${(rect.top - padding) / zoom}px`,
left: `${(rect.left - padding) / zoom}px`,
width: `${(rect.width + padding * 2) / zoom}px`,
height: `${(rect.height + padding * 2) / zoom}px`,
}
const placement = step.placement || 'bottom'
const tooltipWidth = 280
const tooltipGap = 12
let top = 0
let left = 0
if (placement === 'bottom') {
top = rect.bottom + tooltipGap
left = rect.left + rect.width / 2 - tooltipWidth * zoom / 2
} else if (placement === 'top') {
top = rect.top - tooltipGap
left = rect.left + rect.width / 2 - tooltipWidth * zoom / 2
} else if (placement === 'right') {
top = rect.top + rect.height / 2
left = rect.right + tooltipGap
} else if (placement === 'left') {
top = rect.top + rect.height / 2
left = rect.left - tooltipGap - tooltipWidth * zoom
}
left = Math.max(12, Math.min(left, window.innerWidth - tooltipWidth * zoom - 12))
tooltipStyle.value = {
top: placement === 'top' ? 'auto' : `${top / zoom}px`,
bottom: placement === 'top' ? `${(window.innerHeight - top) / zoom}px` : 'auto',
left: `${left / zoom}px`,
width: `${tooltipWidth}px`,
}
// Focus the tooltip for screen readers
nextTick(() => {
if (tooltipRef.value) {
tooltipRef.value.focus()
}
})
}, 350)
}
watch(() => tourStore.currentStepIndex, () => {
if (tourStore.isActive) {
nextTick(() => positionElements())
}
})
watch(() => tourStore.isActive, (active) => {
if (active) {
nextTick(() => positionElements())
}
})
function onKeydown(e: KeyboardEvent) {
if (!tourStore.isActive) return
if (e.key === 'Escape') {
e.preventDefault()
tourStore.stop()
} else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
e.preventDefault()
tourStore.next()
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault()
if (!tourStore.isFirstStep) tourStore.back()
} else if (e.key === 'Tab') {
// Trap focus within the tooltip
if (!tooltipRef.value) return
const focusable = tooltipRef.value.querySelectorAll('button')
if (focusable.length === 0) return
const first = focusable[0] as HTMLElement
const last = focusable[focusable.length - 1] as HTMLElement
if (e.shiftKey && document.activeElement === first) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
}
function onResize() {
if (tourStore.isActive) {
positionElements()
}
}
onMounted(() => {
document.addEventListener('keydown', onKeydown)
window.addEventListener('resize', onResize)
})
onUnmounted(() => {
document.removeEventListener('keydown', onKeydown)
window.removeEventListener('resize', onResize)
})
</script>
<template>
<Teleport to="#app">
<Transition name="tour-fade">
<div
v-if="tourStore.isActive"
class="fixed inset-0 z-[9998]"
role="dialog"
aria-modal="true"
:aria-label="'Feature tour: step ' + (tourStore.currentStepIndex + 1) + ' of ' + tourStore.totalSteps"
>
<!-- Backdrop - click to dismiss -->
<div
class="absolute inset-0"
@click="tourStore.stop()"
aria-hidden="true"
/>
<!-- Spotlight cutout -->
<div
class="absolute rounded-md pointer-events-none transition-all duration-300 ease-out"
:style="spotlightStyle"
style="box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.6);"
aria-hidden="true"
/>
<!-- Tooltip card -->
<div
ref="tooltipRef"
tabindex="-1"
class="absolute z-[9999] bg-bg-surface border border-border-subtle rounded-lg shadow-lg p-4 pointer-events-auto transition-all duration-300 ease-out focus:outline-none"
:style="tooltipStyle"
>
<p class="text-[0.8125rem] font-medium text-text-primary mb-1" aria-live="polite">
{{ tourStore.currentStep?.title }}
</p>
<p class="text-[0.75rem] text-text-secondary leading-relaxed mb-3">
{{ tourStore.currentStep?.content }}
</p>
<div class="flex items-center justify-between">
<span class="text-[0.6875rem] text-text-tertiary" aria-hidden="true">
{{ tourStore.currentStepIndex + 1 }} / {{ tourStore.totalSteps }}
</span>
<div class="flex items-center gap-2" role="group" aria-label="Tour navigation">
<button
@click="tourStore.stop()"
class="px-2.5 py-1 text-[0.6875rem] text-text-tertiary hover:text-text-secondary transition-colors duration-150 rounded focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
Skip
</button>
<button
v-if="!tourStore.isFirstStep"
@click="tourStore.back()"
class="px-2.5 py-1 text-[0.6875rem] border border-border-subtle text-text-secondary rounded-md hover:bg-bg-elevated transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
Back
</button>
<button
@click="tourStore.next()"
class="px-3 py-1 text-[0.6875rem] bg-accent text-bg-base font-medium rounded-md hover:bg-accent-hover transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"
>
{{ tourStore.isLastStep ? 'Done' : 'Next' }}
</button>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>

View File

@@ -0,0 +1,15 @@
import { ref } from 'vue'
const announcement = ref('')
export function useAnnouncer() {
function announce(message: string) {
// Clear then set to ensure repeated identical messages are announced
announcement.value = ''
requestAnimationFrame(() => {
announcement.value = message
})
}
return { announcement, announce }
}

256
src/directives/tooltip.ts Normal file
View File

@@ -0,0 +1,256 @@
import type { Directive, DirectiveBinding } from 'vue'
import { getZoomFactor } from '../utils/dropdown'
interface TooltipState {
el: HTMLElement
tooltipEl: HTMLElement | null
text: string
showTimeout: number | null
hideTimeout: number | null
placement: 'top' | 'bottom' | 'left' | 'right' | 'auto'
onMouseEnter: () => void
onMouseLeave: () => void
onFocus: () => void
onBlur: () => void
onKeyDown: (e: KeyboardEvent) => void
onPointerDown: () => void
}
const stateMap = new WeakMap<HTMLElement, TooltipState>()
const SHOW_DELAY = 400
const HIDE_DELAY = 100
function createTooltipEl(): HTMLElement {
const tip = document.createElement('div')
tip.setAttribute('role', 'tooltip')
tip.className = [
'fixed z-[9999] px-2 py-1 rounded-lg pointer-events-none',
'bg-bg-elevated border border-border-subtle',
'text-[0.6875rem] text-text-primary whitespace-nowrap',
'opacity-0 transition-opacity duration-150',
].join(' ')
const arrow = document.createElement('div')
arrow.setAttribute('aria-hidden', 'true')
arrow.dataset.arrow = ''
arrow.className = 'absolute w-0 h-0'
tip.appendChild(arrow)
const content = document.createElement('span')
content.dataset.content = ''
tip.appendChild(content)
return tip
}
function positionTooltip(state: TooltipState) {
const tip = state.tooltipEl
if (!tip) return
const rect = state.el.getBoundingClientRect()
const zoom = getZoomFactor()
const margin = 6
const arrowSize = 4
// Measure tooltip
tip.style.left = '-9999px'
tip.style.top = '-9999px'
tip.style.opacity = '0'
const tipRect = tip.getBoundingClientRect()
const tipW = tipRect.width / zoom
const tipH = tipRect.height / zoom
const elTop = rect.top / zoom
const elLeft = rect.left / zoom
const elW = rect.width / zoom
const elH = rect.height / zoom
const vpW = window.innerWidth / zoom
const vpH = window.innerHeight / zoom
// Determine placement
let placement = state.placement
if (placement === 'auto') {
const spaceAbove = elTop
const spaceBelow = vpH - elTop - elH
const spaceRight = vpW - elLeft - elW
const spaceLeft = elLeft
// Prefer top, then bottom, then right, then left
if (spaceAbove >= tipH + margin + arrowSize) placement = 'top'
else if (spaceBelow >= tipH + margin + arrowSize) placement = 'bottom'
else if (spaceRight >= tipW + margin + arrowSize) placement = 'right'
else if (spaceLeft >= tipW + margin + arrowSize) placement = 'left'
else placement = 'top'
}
let top = 0
let left = 0
const arrow = tip.querySelector('[data-arrow]') as HTMLElement
// Reset arrow classes
arrow.className = 'absolute w-0 h-0'
switch (placement) {
case 'top':
top = elTop - tipH - margin
left = elLeft + elW / 2 - tipW / 2
arrow.className = 'absolute w-0 h-0 border-x-4 border-x-transparent border-t-4 left-1/2 -translate-x-1/2'
arrow.style.cssText = 'bottom: -4px; border-top-color: var(--color-bg-elevated);'
break
case 'bottom':
top = elTop + elH + margin
left = elLeft + elW / 2 - tipW / 2
arrow.className = 'absolute w-0 h-0 border-x-4 border-x-transparent border-b-4 left-1/2 -translate-x-1/2'
arrow.style.cssText = 'top: -4px; border-bottom-color: var(--color-bg-elevated);'
break
case 'right':
top = elTop + elH / 2 - tipH / 2
left = elLeft + elW + margin
arrow.className = 'absolute w-0 h-0 border-y-4 border-y-transparent border-r-4 top-1/2 -translate-y-1/2'
arrow.style.cssText = 'left: -4px; border-right-color: var(--color-bg-elevated);'
break
case 'left':
top = elTop + elH / 2 - tipH / 2
left = elLeft - tipW - margin
arrow.className = 'absolute w-0 h-0 border-y-4 border-y-transparent border-l-4 top-1/2 -translate-y-1/2'
arrow.style.cssText = 'right: -4px; border-left-color: var(--color-bg-elevated);'
break
}
// Clamp to viewport
left = Math.max(4, Math.min(left, vpW - tipW - 4))
top = Math.max(4, Math.min(top, vpH - tipH - 4))
tip.style.left = `${left}px`
tip.style.top = `${top}px`
tip.style.opacity = '1'
}
function show(state: TooltipState) {
if (state.hideTimeout) {
clearTimeout(state.hideTimeout)
state.hideTimeout = null
}
if (state.tooltipEl) return
if (!state.text) return
state.showTimeout = window.setTimeout(() => {
const tip = createTooltipEl()
const content = tip.querySelector('[data-content]') as HTMLElement
content.textContent = state.text
// WCAG: link tooltip to trigger via aria-describedby
const id = 'tooltip-' + Math.random().toString(36).slice(2, 9)
tip.id = id
state.el.setAttribute('aria-describedby', id)
const container = document.getElementById('app') || document.body
container.appendChild(tip)
state.tooltipEl = tip
// Position after DOM insertion (need measured size)
requestAnimationFrame(() => positionTooltip(state))
}, SHOW_DELAY)
}
function hide(state: TooltipState) {
if (state.showTimeout) {
clearTimeout(state.showTimeout)
state.showTimeout = null
}
if (!state.tooltipEl) return
state.hideTimeout = window.setTimeout(() => {
state.el.removeAttribute('aria-describedby')
state.tooltipEl?.remove()
state.tooltipEl = null
state.hideTimeout = null
}, HIDE_DELAY)
}
function parseBinding(binding: DirectiveBinding): { text: string; placement: 'top' | 'bottom' | 'left' | 'right' | 'auto' } {
let text = ''
let placement: 'top' | 'bottom' | 'left' | 'right' | 'auto' = 'auto'
if (typeof binding.value === 'string') {
text = binding.value
} else if (binding.value && typeof binding.value === 'object') {
text = binding.value.text || ''
placement = binding.value.placement || 'auto'
}
// Modifier overrides
if (binding.modifiers.top) placement = 'top'
if (binding.modifiers.bottom) placement = 'bottom'
if (binding.modifiers.left) placement = 'left'
if (binding.modifiers.right) placement = 'right'
return { text, placement }
}
export const vTooltip: Directive = {
mounted(el: HTMLElement, binding: DirectiveBinding) {
const { text, placement } = parseBinding(binding)
const state: TooltipState = {
el,
tooltipEl: null,
text,
showTimeout: null,
hideTimeout: null,
placement,
onMouseEnter: () => show(state),
onMouseLeave: () => hide(state),
onFocus: () => show(state),
onBlur: () => hide(state),
onKeyDown: (e: KeyboardEvent) => {
if (e.key === 'Escape' && state.tooltipEl) {
hide(state)
}
},
onPointerDown: () => hide(state),
}
el.addEventListener('mouseenter', state.onMouseEnter)
el.addEventListener('mouseleave', state.onMouseLeave)
el.addEventListener('focus', state.onFocus)
el.addEventListener('blur', state.onBlur)
el.addEventListener('keydown', state.onKeyDown)
el.addEventListener('pointerdown', state.onPointerDown)
stateMap.set(el, state)
},
updated(el: HTMLElement, binding: DirectiveBinding) {
const state = stateMap.get(el)
if (!state) return
const { text, placement } = parseBinding(binding)
state.text = text
state.placement = placement
// Update live tooltip text if visible
if (state.tooltipEl) {
const content = state.tooltipEl.querySelector('[data-content]')
if (content) content.textContent = text
}
},
beforeUnmount(el: HTMLElement) {
const state = stateMap.get(el)
if (!state) return
if (state.showTimeout) clearTimeout(state.showTimeout)
if (state.hideTimeout) clearTimeout(state.hideTimeout)
state.el.removeAttribute('aria-describedby')
state.tooltipEl?.remove()
el.removeEventListener('mouseenter', state.onMouseEnter)
el.removeEventListener('mouseleave', state.onMouseLeave)
el.removeEventListener('focus', state.onFocus)
el.removeEventListener('blur', state.onBlur)
el.removeEventListener('keydown', state.onKeyDown)
el.removeEventListener('pointerdown', state.onPointerDown)
stateMap.delete(el)
},
}

View File

@@ -3,6 +3,7 @@ import { createPinia } from 'pinia'
import { MotionPlugin } from '@vueuse/motion'
import router from './router'
import App from './App.vue'
import { vTooltip } from './directives/tooltip'
import './styles/main.css'
const app = createApp(App)
@@ -11,4 +12,5 @@ const pinia = createPinia()
app.use(pinia)
app.use(router)
app.use(MotionPlugin)
app.directive('tooltip', vTooltip)
app.mount('#app')

5
src/mini-timer-entry.ts Normal file
View File

@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import MiniTimer from './views/MiniTimer.vue'
import './styles/main.css'
createApp(MiniTimer).mount('#app')

View File

@@ -23,6 +23,11 @@ const router = createRouter({
name: 'Projects',
component: () => import('../views/Projects.vue')
},
{
path: '/projects/:id',
name: 'ProjectDetail',
component: () => import('../views/ProjectDetail.vue')
},
{
path: '/entries',
name: 'Entries',
@@ -61,4 +66,31 @@ const router = createRouter({
]
})
router.afterEach(async (to) => {
const name = (to.name as string) || 'ZeroClock'
document.title = `${name} - ZeroClock`
setTimeout(() => {
const main = document.getElementById('main-content')
if (main) {
main.focus()
}
const announcer = document.getElementById('route-announcer')
if (announcer) {
announcer.textContent = `Navigated to ${name}`
}
}, 300)
// Track page visits for onboarding checklist
try {
const { useOnboardingStore } = await import('../stores/onboarding')
const onboardingStore = useOnboardingStore()
if (onboardingStore.loaded) {
onboardingStore.onRouteVisit(to.path)
}
} catch {
// Onboarding store not ready yet - ignore
}
})
export default router

View File

@@ -13,6 +13,7 @@ export interface Client {
tax_id?: string
payment_terms?: string
notes?: string
currency?: string
}
export const useClientsStore = defineStore('clients', () => {

View File

@@ -40,6 +40,20 @@ export const useEntryTemplatesStore = defineStore('entryTemplates', () => {
}
}
async function updateTemplate(template: EntryTemplate): Promise<boolean> {
try {
await invoke('update_entry_template', { template })
const index = templates.value.findIndex(t => t.id === template.id)
if (index !== -1) {
templates.value[index] = template
}
return true
} catch (error) {
handleInvokeError(error, 'Failed to update template')
return false
}
}
async function deleteTemplate(id: number) {
try {
await invoke('delete_entry_template', { id })
@@ -49,5 +63,5 @@ export const useEntryTemplatesStore = defineStore('entryTemplates', () => {
}
}
return { templates, loading, fetchTemplates, createTemplate, deleteTemplate }
return { templates, loading, fetchTemplates, createTemplate, updateTemplate, deleteTemplate }
})

View File

@@ -45,10 +45,15 @@ export const useFavoritesStore = defineStore('favorites', () => {
}
async function reorderFavorites(ids: number[]): Promise<boolean> {
const oldOrder = [...favorites.value]
favorites.value = ids
.map(id => favorites.value.find(f => f.id === id))
.filter((f): f is Favorite => f !== undefined)
try {
await invoke('reorder_favorites', { ids })
return true
} catch (error) {
favorites.value = oldOrder
handleInvokeError(error, 'Failed to reorder favorites')
return false
}

View File

@@ -1,5 +1,5 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { ref, computed } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { handleInvokeError } from '../utils/errorHandler'
@@ -152,9 +152,22 @@ export const useInvoicesStore = defineStore('invoices', () => {
}
}
const overdueCount = computed(() => invoices.value.filter(i => i.status === 'overdue').length)
const groupedByStatus = computed(() => {
const groups: Record<string, Invoice[]> = { draft: [], sent: [], overdue: [], paid: [] }
for (const inv of invoices.value) {
if (groups[inv.status]) groups[inv.status].push(inv)
else groups.draft.push(inv)
}
return groups
})
return {
invoices,
loading,
overdueCount,
groupedByStatus,
fetchInvoices,
createInvoice,
updateInvoice,

View File

@@ -1,5 +1,5 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { ref, computed } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { handleInvokeError } from '../utils/errorHandler'
@@ -14,6 +14,8 @@ export interface Project {
budget_amount?: number | null
rounding_override?: number | null
timeline_override?: string | null
notes?: string | null
currency?: string | null
}
export interface Task {
@@ -21,6 +23,7 @@ export interface Task {
project_id: number
name: string
estimated_hours?: number | null
hourly_rate?: number | null
}
export const useProjectsStore = defineStore('projects', () => {
@@ -112,9 +115,14 @@ export const useProjectsStore = defineStore('projects', () => {
}
}
const activeProjects = computed(() => projects.value.filter(p => !p.archived))
const archivedProjects = computed(() => projects.value.filter(p => p.archived))
return {
projects,
loading,
activeProjects,
archivedProjects,
fetchProjects,
createProject,
updateProject,

View File

@@ -24,7 +24,7 @@ export const useSettingsStore = defineStore('settings', () => {
settings.value[key] = value
return true
} catch (error) {
console.error('Failed to update setting:', error)
handleInvokeError(error, 'Failed to save setting')
return false
}
}

View File

@@ -6,6 +6,7 @@ import { useSettingsStore } from './settings'
import { useProjectsStore } from './projects'
import { useAnnouncer } from '../composables/useAnnouncer'
import { audioEngine } from '../utils/audio'
import { handleInvokeError } from '../utils/errorHandler'
import type { TimeEntry } from './entries'
export type TimerState = 'STOPPED' | 'RUNNING' | 'PAUSED_IDLE' | 'PAUSED_APP' | 'PAUSED_MANUAL'
@@ -441,7 +442,7 @@ export const useTimerStore = defineStore('timer', () => {
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)
handleInvokeError(error, 'Failed to save time entry')
}
}
@@ -580,9 +581,9 @@ export const useTimerStore = defineStore('timer', () => {
pausedAt: pausedAt.value,
}
settingsStore.updateSetting('timer_state_backup', JSON.stringify(data))
.catch(e => console.error('Failed to persist timer state:', e))
.catch(e => handleInvokeError(e, 'Failed to save timer state'))
} catch (e) {
console.error('Failed to persist timer state:', e)
handleInvokeError(e, 'Failed to save timer state')
}
}
@@ -641,7 +642,7 @@ export const useTimerStore = defineStore('timer', () => {
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)
handleInvokeError(e, 'Failed to restore timer state')
}
}

View File

@@ -26,6 +26,7 @@
--color-status-running: #34D399;
--color-status-warning: #EAB308;
--color-status-error: #EF4444;
--color-status-error-text: #F87171;
--color-status-info: #3B82F6;
/* Fonts */
@@ -53,7 +54,7 @@
--color-accent: #8B5CF6;
--color-accent-hover: #7C3AED;
--color-accent-muted: rgba(139, 92, 246, 0.25);
--color-accent-text: #A78BFA;
--color-accent-text: #C4B5FD;
}
[data-accent="green"] {
--color-accent: #10B981;
@@ -91,6 +92,7 @@
--color-text-tertiary: #57524D;
--color-border-subtle: #E7E5E4;
--color-border-visible: #D6D3D1;
--color-status-error-text: #B91C1C;
}
@layer base {
@@ -231,6 +233,11 @@
animation: toast-exit 150ms ease-in forwards;
}
@keyframes toast-progress {
from { width: 100%; }
to { width: 0%; }
}
/* Pulse animation for timer colon */
@keyframes pulse-colon {
0%, 100% { opacity: 0.4; }
@@ -538,6 +545,26 @@
animation: none !important;
}
.reduce-motion .btn-primary:hover,
.reduce-motion .btn-primary:active,
.reduce-motion .btn-icon:active,
.reduce-motion .btn-icon-delete:active,
.reduce-motion .card-hover:hover,
.reduce-motion .card-hover:active {
transform: none !important;
}
@media (prefers-reduced-motion: reduce) {
.btn-primary:hover,
.btn-primary:active,
.btn-icon:active,
.btn-icon-delete:active,
.card-hover:hover,
.card-hover:active {
transform: none !important;
}
}
@media (prefers-contrast: more) {
:root {
--color-text-secondary: #D0D0C8;
@@ -558,3 +585,68 @@
outline: 2px solid Highlight;
}
}
@media print {
header,
nav,
.fixed,
.sticky,
[role="tooltip"],
[role="status"],
.animate-pulse-dot,
.animate-float,
.sr-only-focusable,
button[aria-label="Minimize"],
button[aria-label="Maximize"],
button[aria-label="Restore"],
button[aria-label="Close"] {
display: none !important;
}
body,
[data-theme="dark"],
[data-theme="light"] {
background: white !important;
color: black !important;
}
* {
color: black !important;
background: transparent !important;
box-shadow: none !important;
text-shadow: none !important;
}
table {
border-collapse: collapse;
}
th, td {
border: 1px solid #ccc;
padding: 4px 8px;
}
h1, h2, h3 {
page-break-after: avoid;
}
tr {
page-break-inside: avoid;
}
a[href]::after {
content: " (" attr(href) ")";
font-size: 0.75em;
color: #666 !important;
}
main {
width: 100% !important;
overflow: visible !important;
}
#app {
overflow: visible !important;
height: auto !important;
}
}

228
src/utils/audio.ts Normal file
View File

@@ -0,0 +1,228 @@
export type SoundEvent =
| 'timer_start'
| 'timer_stop'
| 'timer_pause'
| 'timer_resume'
| 'idle_alert'
| 'goal_reached'
| 'break_reminder'
export const SOUND_EVENTS: { key: SoundEvent; label: string }[] = [
{ key: 'timer_start', label: 'Timer start' },
{ key: 'timer_stop', label: 'Timer stop' },
{ key: 'timer_pause', label: 'Pause' },
{ key: 'timer_resume', label: 'Resume' },
{ key: 'idle_alert', label: 'Idle alert' },
{ key: 'goal_reached', label: 'Goal reached' },
{ key: 'break_reminder', label: 'Break reminder' },
]
export interface AudioSettings {
enabled: boolean
mode: 'synthesized' | 'system' | 'custom'
volume: number
events: Record<SoundEvent, boolean>
}
export const DEFAULT_EVENTS: Record<SoundEvent, boolean> = {
timer_start: true,
timer_stop: true,
timer_pause: true,
timer_resume: true,
idle_alert: true,
goal_reached: true,
break_reminder: true,
}
const DEFAULT_SETTINGS: AudioSettings = {
enabled: false,
mode: 'synthesized',
volume: 70,
events: { ...DEFAULT_EVENTS },
}
class AudioEngine {
private ctx: AudioContext | null = null
private settings: AudioSettings = { ...DEFAULT_SETTINGS, events: { ...DEFAULT_SETTINGS.events } }
private ensureContext(): AudioContext {
if (!this.ctx) {
this.ctx = new AudioContext()
}
if (this.ctx.state === 'suspended') {
this.ctx.resume()
}
return this.ctx
}
private get gain(): number {
return this.settings.volume / 100
}
updateSettings(partial: Partial<AudioSettings>) {
if (partial.enabled !== undefined) this.settings.enabled = partial.enabled
if (partial.mode !== undefined) this.settings.mode = partial.mode
if (partial.volume !== undefined) this.settings.volume = partial.volume
if (partial.events !== undefined) this.settings.events = { ...partial.events }
}
getSettings(): AudioSettings {
return { ...this.settings, events: { ...this.settings.events } }
}
play(event: SoundEvent) {
if (!this.settings.enabled) return
if (!this.settings.events[event]) return
if (this.settings.mode !== 'synthesized') return
this.synthesize(event)
}
playTest(event: SoundEvent) {
this.synthesize(event)
}
private synthesize(event: SoundEvent) {
switch (event) {
case 'timer_start':
this.playTimerStart()
break
case 'timer_stop':
this.playTimerStop()
break
case 'timer_pause':
this.playTimerPause()
break
case 'timer_resume':
this.playTimerResume()
break
case 'idle_alert':
this.playIdleAlert()
break
case 'goal_reached':
this.playGoalReached()
break
case 'break_reminder':
this.playBreakReminder()
break
}
}
// Quick ascending two-note chime: C5 then E5
private playTimerStart() {
const ctx = this.ensureContext()
const t = ctx.currentTime
const vol = this.gain
// Note 1: C5 (523Hz) for 100ms
this.playTone(ctx, t, 523, 0.100, vol, 0.010, 0.050, 3)
// Note 2: E5 (659Hz) for 150ms
this.playTone(ctx, t + 0.110, 659, 0.150, vol, 0.010, 0.050, 3)
}
// Descending resolve: G5 sliding to C5 over 250ms
private playTimerStop() {
const ctx = this.ensureContext()
const t = ctx.currentTime
const vol = this.gain
const duration = 0.250
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.type = 'sine'
osc.frequency.setValueAtTime(784, t)
osc.frequency.linearRampToValueAtTime(523, t + duration)
gain.gain.setValueAtTime(0, t)
gain.gain.linearRampToValueAtTime(vol, t + 0.010)
gain.gain.setValueAtTime(vol, t + duration - 0.100)
gain.gain.linearRampToValueAtTime(0, t + duration)
osc.connect(gain)
gain.connect(ctx.destination)
osc.start(t)
osc.stop(t + duration)
}
// Single soft tone: A4 (440Hz) for 120ms
private playTimerPause() {
const ctx = this.ensureContext()
const t = ctx.currentTime
this.playTone(ctx, t, 440, 0.120, this.gain, 0.005, 0.060)
}
// Single bright tone: C5 (523Hz) for 120ms
private playTimerResume() {
const ctx = this.ensureContext()
const t = ctx.currentTime
this.playTone(ctx, t, 523, 0.120, this.gain, 0.005, 0.060)
}
// Two quick pulses at A5 (880Hz), each 80ms with 60ms gap
private playIdleAlert() {
const ctx = this.ensureContext()
const t = ctx.currentTime
const vol = this.gain
this.playTone(ctx, t, 880, 0.080, vol, 0.005, 0.030)
this.playTone(ctx, t + 0.140, 880, 0.080, vol, 0.005, 0.030)
}
// Ascending three-note fanfare: C5, E5, G5
private playGoalReached() {
const ctx = this.ensureContext()
const t = ctx.currentTime
const vol = this.gain
this.playTone(ctx, t, 523, 0.120, vol, 0.010, 0.040, 3)
this.playTone(ctx, t + 0.130, 659, 0.120, vol, 0.010, 0.040, 3)
this.playTone(ctx, t + 0.260, 784, 0.120, vol, 0.010, 0.040, 3)
}
// Gentle single chime at E5 (659Hz), 200ms, long release
private playBreakReminder() {
const ctx = this.ensureContext()
const t = ctx.currentTime
this.playTone(ctx, t, 659, 0.200, this.gain, 0.020, 0.100)
}
// Helper: play a single tone with ADSR-style envelope
// Optional detuneCents adds a second oscillator slightly detuned for warmth
private playTone(
ctx: AudioContext,
startAt: number,
freq: number,
duration: number,
vol: number,
attack: number,
release: number,
detuneCents?: number
) {
const endAt = startAt + duration
const gainNode = ctx.createGain()
gainNode.gain.setValueAtTime(0, startAt)
gainNode.gain.linearRampToValueAtTime(vol, startAt + attack)
gainNode.gain.setValueAtTime(vol, endAt - release)
gainNode.gain.linearRampToValueAtTime(0, endAt)
gainNode.connect(ctx.destination)
const osc1 = ctx.createOscillator()
osc1.type = 'sine'
osc1.frequency.setValueAtTime(freq, startAt)
osc1.connect(gainNode)
osc1.start(startAt)
osc1.stop(endAt)
if (detuneCents) {
const osc2 = ctx.createOscillator()
osc2.type = 'sine'
osc2.frequency.setValueAtTime(freq, startAt)
osc2.detune.setValueAtTime(detuneCents, startAt)
osc2.connect(gainNode)
osc2.start(startAt)
osc2.stop(endAt)
}
}
}
export const audioEngine = new AudioEngine()

43
src/utils/chartTheme.ts Normal file
View File

@@ -0,0 +1,43 @@
export function getChartTheme() {
const style = getComputedStyle(document.documentElement)
return {
accent: style.getPropertyValue('--color-accent').trim(),
accentMuted: style.getPropertyValue('--color-accent-muted').trim(),
textPrimary: style.getPropertyValue('--color-text-primary').trim(),
textSecondary: style.getPropertyValue('--color-text-secondary').trim(),
textTertiary: style.getPropertyValue('--color-text-tertiary').trim(),
gridColor: style.getPropertyValue('--color-border-subtle').trim(),
bgSurface: style.getPropertyValue('--color-bg-surface').trim(),
}
}
export type ChartTheme = ReturnType<typeof getChartTheme>
export function buildBarChartOptions(theme: ChartTheme) {
return {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: theme.bgSurface,
titleColor: theme.textPrimary,
bodyColor: theme.textSecondary,
borderColor: theme.gridColor,
borderWidth: 1,
},
},
scales: {
x: {
ticks: { color: theme.textTertiary, font: { size: 11 } },
grid: { display: false },
border: { display: false },
},
y: {
ticks: { color: theme.textTertiary, font: { size: 11 } },
grid: { color: theme.gridColor },
border: { display: false },
},
},
}
}

44
src/utils/csvExport.ts Normal file
View File

@@ -0,0 +1,44 @@
import { save } from '@tauri-apps/plugin-dialog'
import { invoke } from '@tauri-apps/api/core'
function escapeCSV(val: string): string {
if (val.includes(',') || val.includes('"') || val.includes('\n')) {
return '"' + val.replace(/"/g, '""') + '"'
}
return val
}
function toCSVRow(values: string[]): string {
return values.map(escapeCSV).join(',')
}
export function entriesToCSV(
entries: { id?: number; start_time: string; duration: number; description?: string; billable?: number; project_id: number; task_id?: number }[],
getProjectName: (id: number) => string,
getClientName: (projectId: number) => string,
getTaskName: (id?: number) => string,
getTagNames: (entryId: number) => string
): string {
const headers = ['Date', 'Project', 'Client', 'Task', 'Description', 'Duration (hours)', 'Billable', 'Tags']
const rows = entries.map(e => toCSVRow([
e.start_time.split('T')[0],
getProjectName(e.project_id),
getClientName(e.project_id),
getTaskName(e.task_id),
e.description || '',
(e.duration / 3600).toFixed(2),
e.billable === 1 ? 'Yes' : 'No',
getTagNames(e.id || 0),
]))
return [toCSVRow(headers), ...rows].join('\n')
}
export async function downloadCSV(content: string, defaultName: string): Promise<boolean> {
const path = await save({
defaultPath: defaultName,
filters: [{ name: 'CSV', extensions: ['csv'] }],
})
if (!path) return false
await invoke('save_binary_file', { path, data: Array.from(new TextEncoder().encode(content)) })
return true
}

86
src/utils/dropdown.ts Normal file
View File

@@ -0,0 +1,86 @@
export function getZoomFactor(): number {
const app = document.getElementById('app')
if (!app) return 1
const zoom = (app.style as any).zoom
return zoom ? parseFloat(zoom) / 100 : 1
}
// Cached probe results for fixed-position coordinate mapping inside #app.
// CSS zoom behavior with position:fixed varies across Chromium versions,
// so we detect the actual behavior at runtime with a probe element.
let _probeCache: { scaleX: number; scaleY: number; offsetX: number; offsetY: number; zoomKey: string } | null = null
export function getFixedPositionMapping(): { scaleX: number; scaleY: number; offsetX: number; offsetY: number } {
const app = document.getElementById('app')
if (!app) return { scaleX: 1, scaleY: 1, offsetX: 0, offsetY: 0 }
const zoomKey = (app.style as any).zoom || ''
if (_probeCache && _probeCache.zoomKey === zoomKey) {
return _probeCache
}
const probe = document.createElement('div')
probe.style.cssText = 'position:fixed;top:0;left:0;width:100px;height:100px;visibility:hidden;pointer-events:none'
app.appendChild(probe)
const r0 = probe.getBoundingClientRect()
probe.style.top = '100px'
probe.style.left = '100px'
const r1 = probe.getBoundingClientRect()
app.removeChild(probe)
const result = {
scaleX: (r1.left - r0.left) / 100,
scaleY: (r1.top - r0.top) / 100,
offsetX: r0.left,
offsetY: r0.top,
zoomKey,
}
_probeCache = result
return result
}
export function resetPositionCache() {
_probeCache = null
}
export function computeDropdownPosition(
triggerEl: HTMLElement,
opts?: { minWidth?: number; estimatedHeight?: number; panelEl?: HTMLElement | null }
): Record<string, string> {
const rect = triggerEl.getBoundingClientRect()
const { scaleX, scaleY, offsetX, offsetY } = getFixedPositionMapping()
const gap = 4
const minW = (opts?.minWidth ?? 0) * scaleX
const vpH = window.innerHeight
const vpW = window.innerWidth
// Default: position below trigger
let topVP = rect.bottom + gap
// Use offsetHeight (unaffected by CSS transition transforms like scale(0.95))
// and multiply by scaleY to get viewport pixels.
const panelEl = opts?.panelEl
if (panelEl) {
const panelH = panelEl.offsetHeight * scaleY
if (topVP + panelH > vpH && rect.top - gap - panelH >= 0) {
topVP = rect.top - gap - panelH
}
}
let leftVP = rect.left
const widthVP = Math.max(rect.width, minW)
if (leftVP + widthVP > vpW - gap) {
leftVP = vpW - gap - widthVP
}
if (leftVP < gap) leftVP = gap
// Convert viewport pixels to CSS pixels for position:fixed inside #app
return {
position: 'fixed',
top: `${(topVP - offsetY) / scaleY}px`,
left: `${(leftVP - offsetX) / scaleX}px`,
width: `${widthVP / scaleX}px`,
zIndex: '9999',
}
}

71
src/utils/focusTrap.ts Normal file
View File

@@ -0,0 +1,71 @@
import { ref, onUnmounted } from 'vue'
export function useFocusTrap() {
const trapElement = ref<HTMLElement | null>(null)
let previouslyFocused: HTMLElement | null = null
let isActive = false
function getFocusableElements(el: HTMLElement): HTMLElement[] {
const selectors = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
return Array.from(el.querySelectorAll<HTMLElement>(selectors)).filter(e => e.offsetParent !== null)
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault()
deactivate()
return
}
if (e.key !== 'Tab' || !trapElement.value) return
const focusable = getFocusableElements(trapElement.value)
if (focusable.length === 0) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault()
last.focus()
}
} else {
if (document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
}
let onDeactivate: (() => void) | null = null
function activate(el: HTMLElement, opts?: { onDeactivate?: () => void, initialFocus?: HTMLElement }) {
if (isActive) deactivate()
isActive = true
trapElement.value = el
previouslyFocused = document.activeElement as HTMLElement
onDeactivate = opts?.onDeactivate || null
document.addEventListener('keydown', onKeydown)
const target = opts?.initialFocus || getFocusableElements(el)[0]
if (target) {
requestAnimationFrame(() => {
if (trapElement.value === el) target.focus()
})
}
}
function deactivate() {
if (!isActive) return
isActive = false
document.removeEventListener('keydown', onKeydown)
trapElement.value = null
if (onDeactivate) onDeactivate()
if (previouslyFocused) {
previouslyFocused.focus()
previouslyFocused = null
}
}
onUnmounted(() => {
if (isActive) deactivate()
})
return { activate, deactivate }
}

47
src/utils/fonts.ts Normal file
View File

@@ -0,0 +1,47 @@
export interface TimerFont {
label: string
value: string
}
export const TIMER_FONTS: TimerFont[] = [
{ label: 'JetBrains Mono', value: 'JetBrains Mono' },
{ label: 'Fira Code', value: 'Fira Code' },
{ label: 'Source Code Pro', value: 'Source Code Pro' },
{ label: 'IBM Plex Mono', value: 'IBM Plex Mono' },
{ label: 'Roboto Mono', value: 'Roboto Mono' },
{ label: 'Space Mono', value: 'Space Mono' },
{ label: 'Ubuntu Mono', value: 'Ubuntu Mono' },
{ label: 'Inconsolata', value: 'Inconsolata' },
{ label: 'Red Hat Mono', value: 'Red Hat Mono' },
{ label: 'DM Mono', value: 'DM Mono' },
{ label: 'Azeret Mono', value: 'Azeret Mono' },
{ label: 'Martian Mono', value: 'Martian Mono' },
{ label: 'Share Tech Mono', value: 'Share Tech Mono' },
{ label: 'Anonymous Pro', value: 'Anonymous Pro' },
{ label: 'Victor Mono', value: 'Victor Mono' },
{ label: 'Overpass Mono', value: 'Overpass Mono' },
]
const loadedFonts = new Set<string>()
export function loadGoogleFont(fontName: string): void {
if (loadedFonts.has(fontName)) return
loadedFonts.add(fontName)
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(fontName)}:wght@400;500;600&display=swap`
document.head.appendChild(link)
}
export function applyTimerFont(fontName: string): void {
document.documentElement.style.setProperty(
'--font-timer',
`'${fontName}', monospace`
)
}
export function loadAndApplyTimerFont(fontName: string): void {
loadGoogleFont(fontName)
applyTimerFont(fontName)
}

33
src/utils/formGuard.ts Normal file
View File

@@ -0,0 +1,33 @@
import { ref } from 'vue'
export function useFormGuard() {
const _snapshot = ref('')
const showDiscardDialog = ref(false)
let _pendingClose: (() => void) | null = null
function snapshot(data: unknown) {
_snapshot.value = JSON.stringify(data)
}
function tryClose(data: unknown, closeFn: () => void) {
if (JSON.stringify(data) !== _snapshot.value) {
_pendingClose = closeFn
showDiscardDialog.value = true
} else {
closeFn()
}
}
function confirmDiscard() {
showDiscardDialog.value = false
_pendingClose?.()
_pendingClose = null
}
function cancelDiscard() {
showDiscardDialog.value = false
_pendingClose = null
}
return { showDiscardDialog, snapshot, tryClose, confirmDiscard, cancelDiscard }
}

View File

@@ -1,4 +1,5 @@
import { marked } from 'marked'
import DOMPurify from 'dompurify'
marked.setOptions({
breaks: true,
@@ -7,7 +8,8 @@ marked.setOptions({
export function renderMarkdown(text: string): string {
if (!text) return ''
return marked.parseInline(text) as string
const raw = marked.parseInline(text) as string
return DOMPurify.sanitize(raw, { ALLOWED_TAGS: ['strong', 'em', 'code', 'a', 'br'], ALLOWED_ATTR: ['href', 'target', 'rel'] })
}
export function stripMarkdown(text: string): string {

481
src/utils/reportPdf.ts Normal file
View File

@@ -0,0 +1,481 @@
import { jsPDF } from 'jspdf'
import { formatCurrency, formatNumber } from './locale'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface ReportProjectRow {
name: string
color: string
hours: number
rate: number
earnings: number
percentage: number
}
export interface HoursReportData {
dateRange: { start: string; end: string }
totalHours: number
totalEarnings: number
billableHours: number
nonBillableHours: number
projects: ReportProjectRow[]
}
export interface ProfitabilityReportRow {
project_name: string
client_name: string | null
total_hours: number
hourly_rate: number
revenue: number
expenses: number
net_profit: number
budget_hours: number | null
budget_used_pct: number | null
}
export interface ProfitabilityReportData {
dateRange: { start: string; end: string }
totalRevenue: number
totalExpenses: number
totalNet: number
totalHours: number
avgRate: number
rows: ProfitabilityReportRow[]
}
export interface ExpenseReportData {
dateRange: { start: string; end: string }
totalAmount: number
invoicedAmount: number
uninvoicedAmount: number
byCategory: { category: string; amount: number; percentage: number }[]
byProject: { name: string; color: string; amount: number; percentage: number }[]
}
export interface PatternsReportData {
dateRange: { start: string; end: string }
heatmap: number[][]
peakDay: string
peakHour: string
}
// ---------------------------------------------------------------------------
// Shared PDF helpers
// ---------------------------------------------------------------------------
function hexToRgb(hex: string): [number, number, number] {
const h = hex.replace('#', '')
return [
parseInt(h.substring(0, 2), 16),
parseInt(h.substring(2, 4), 16),
parseInt(h.substring(4, 6), 16),
]
}
function setText(doc: jsPDF, hex: string) {
const [r, g, b] = hexToRgb(hex)
doc.setTextColor(r, g, b)
}
function setFill(doc: jsPDF, hex: string) {
const [r, g, b] = hexToRgb(hex)
doc.setFillColor(r, g, b)
}
const PAGE_W = 210
const PAGE_H = 297
const MARGIN = 15
const CONTENT_W = PAGE_W - MARGIN * 2
function drawHeader(doc: jsPDF, title: string, dateRange: { start: string; end: string }): number {
let y = MARGIN
doc.setFontSize(18)
setText(doc, '#E5E7EB')
doc.text(title, MARGIN, y + 6)
y += 10
doc.setFontSize(9)
setText(doc, '#9CA3AF')
doc.text(`${dateRange.start} to ${dateRange.end}`, MARGIN, y + 4)
y += 10
setFill(doc, '#374151')
doc.rect(MARGIN, y, CONTENT_W, 0.3, 'F')
y += 6
return y
}
function drawStatBox(doc: jsPDF, x: number, y: number, w: number, label: string, value: string) {
setFill(doc, '#1F2937')
doc.roundedRect(x, y, w, 18, 2, 2, 'F')
doc.setFontSize(7)
setText(doc, '#9CA3AF')
doc.text(label.toUpperCase(), x + 4, y + 6)
doc.setFontSize(11)
setText(doc, '#F9FAFB')
doc.text(value, x + 4, y + 14)
}
function drawTableHeader(doc: jsPDF, y: number, cols: { label: string; x: number; w: number; align?: string }[]): number {
setFill(doc, '#1F2937')
doc.rect(MARGIN, y, CONTENT_W, 7, 'F')
doc.setFontSize(6.5)
setText(doc, '#9CA3AF')
for (const col of cols) {
if (col.align === 'right') {
doc.text(col.label.toUpperCase(), col.x + col.w - 2, y + 5, { align: 'right' })
} else {
doc.text(col.label.toUpperCase(), col.x + 2, y + 5)
}
}
return y + 7
}
function checkPageBreak(doc: jsPDF, y: number, needed: number): number {
if (y + needed > PAGE_H - MARGIN) {
doc.addPage()
setFill(doc, '#0D1117')
doc.rect(0, 0, PAGE_W, PAGE_H, 'F')
return MARGIN
}
return y
}
// ---------------------------------------------------------------------------
// Hours Report
// ---------------------------------------------------------------------------
function renderHoursReport(doc: jsPDF, data: HoursReportData) {
let y = drawHeader(doc, 'Hours Report', data.dateRange)
const boxW = (CONTENT_W - 6) / 4
drawStatBox(doc, MARGIN, y, boxW, 'Total Hours', formatNumber(data.totalHours, 1) + 'h')
drawStatBox(doc, MARGIN + boxW + 2, y, boxW, 'Earnings', formatCurrency(data.totalEarnings))
drawStatBox(doc, MARGIN + (boxW + 2) * 2, y, boxW, 'Billable', formatNumber(data.billableHours, 1) + 'h')
drawStatBox(doc, MARGIN + (boxW + 2) * 3, y, boxW, 'Non-Billable', formatNumber(data.nonBillableHours, 1) + 'h')
y += 24
// Bar chart
if (data.projects.length > 0) {
const barAreaH = Math.min(data.projects.length * 10, 80)
y = checkPageBreak(doc, y, barAreaH + 10)
doc.setFontSize(9)
setText(doc, '#E5E7EB')
doc.text('Hours by Project', MARGIN, y + 4)
y += 8
const maxHours = Math.max(...data.projects.map(p => p.hours))
const barMaxW = CONTENT_W - 50
for (const proj of data.projects) {
y = checkPageBreak(doc, y, 10)
const barW = maxHours > 0 ? (proj.hours / maxHours) * barMaxW : 0
doc.setFontSize(7)
setText(doc, '#D1D5DB')
doc.text(proj.name.substring(0, 25), MARGIN, y + 4)
setFill(doc, proj.color || '#F59E0B')
doc.roundedRect(MARGIN + 48, y, Math.max(barW, 1), 5, 1, 1, 'F')
setText(doc, '#9CA3AF')
doc.text(formatNumber(proj.hours, 1) + 'h', MARGIN + 50 + barW, y + 4)
y += 8
}
y += 4
}
// Project table
y = checkPageBreak(doc, y, 20)
doc.setFontSize(9)
setText(doc, '#E5E7EB')
doc.text('Project Breakdown', MARGIN, y + 4)
y += 8
const cols = [
{ label: 'Project', x: MARGIN, w: 60 },
{ label: 'Hours', x: MARGIN + 60, w: 30, align: 'right' },
{ label: 'Rate', x: MARGIN + 90, w: 35, align: 'right' },
{ label: 'Earnings', x: MARGIN + 125, w: 35, align: 'right' },
{ label: '%', x: MARGIN + 160, w: 20, align: 'right' },
]
y = drawTableHeader(doc, y, cols)
doc.setFontSize(7)
for (let i = 0; i < data.projects.length; i++) {
y = checkPageBreak(doc, y, 7)
const p = data.projects[i]
if (i % 2 === 0) {
setFill(doc, '#111827')
doc.rect(MARGIN, y, CONTENT_W, 7, 'F')
}
setFill(doc, p.color || '#F59E0B')
doc.circle(MARGIN + 4, y + 3.5, 1.5, 'F')
setText(doc, '#E5E7EB')
doc.text(p.name.substring(0, 30), MARGIN + 8, y + 5)
setText(doc, '#D1D5DB')
doc.text(formatNumber(p.hours, 1), MARGIN + 60 + 30 - 2, y + 5, { align: 'right' })
doc.text(formatCurrency(p.rate), MARGIN + 90 + 35 - 2, y + 5, { align: 'right' })
doc.text(formatCurrency(p.earnings), MARGIN + 125 + 35 - 2, y + 5, { align: 'right' })
setText(doc, '#9CA3AF')
doc.text(formatNumber(p.percentage, 0) + '%', MARGIN + 160 + 20 - 2, y + 5, { align: 'right' })
y += 7
}
// Totals row
y = checkPageBreak(doc, y, 8)
setFill(doc, '#1F2937')
doc.rect(MARGIN, y, CONTENT_W, 7, 'F')
doc.setFontSize(7)
setText(doc, '#F9FAFB')
doc.text('Total', MARGIN + 8, y + 5)
doc.text(formatNumber(data.totalHours, 1), MARGIN + 60 + 30 - 2, y + 5, { align: 'right' })
doc.text(formatCurrency(data.totalEarnings), MARGIN + 125 + 35 - 2, y + 5, { align: 'right' })
}
// ---------------------------------------------------------------------------
// Profitability Report
// ---------------------------------------------------------------------------
function renderProfitabilityReport(doc: jsPDF, data: ProfitabilityReportData) {
let y = drawHeader(doc, 'Profitability Report', data.dateRange)
const boxW = (CONTENT_W - 8) / 5
drawStatBox(doc, MARGIN, y, boxW, 'Revenue', formatCurrency(data.totalRevenue))
drawStatBox(doc, MARGIN + boxW + 2, y, boxW, 'Expenses', formatCurrency(data.totalExpenses))
drawStatBox(doc, MARGIN + (boxW + 2) * 2, y, boxW, 'Net Profit', formatCurrency(data.totalNet))
drawStatBox(doc, MARGIN + (boxW + 2) * 3, y, boxW, 'Hours', formatNumber(data.totalHours, 1) + 'h')
drawStatBox(doc, MARGIN + (boxW + 2) * 4, y, boxW, 'Avg Rate', formatCurrency(data.avgRate) + '/hr')
y += 24
const cols = [
{ label: 'Project', x: MARGIN, w: 35 },
{ label: 'Client', x: MARGIN + 35, w: 30 },
{ label: 'Hours', x: MARGIN + 65, w: 18, align: 'right' },
{ label: 'Rate', x: MARGIN + 83, w: 22, align: 'right' },
{ label: 'Revenue', x: MARGIN + 105, w: 25, align: 'right' },
{ label: 'Expenses', x: MARGIN + 130, w: 22, align: 'right' },
{ label: 'Net', x: MARGIN + 152, w: 22, align: 'right' },
{ label: 'Budget', x: MARGIN + 174, w: 16, align: 'right' },
]
y = drawTableHeader(doc, y, cols)
doc.setFontSize(6.5)
for (let i = 0; i < data.rows.length; i++) {
y = checkPageBreak(doc, y, 7)
const r = data.rows[i]
if (i % 2 === 0) {
setFill(doc, '#111827')
doc.rect(MARGIN, y, CONTENT_W, 7, 'F')
}
setText(doc, '#E5E7EB')
doc.text(r.project_name.substring(0, 18), MARGIN + 2, y + 5)
setText(doc, '#D1D5DB')
doc.text((r.client_name || '-').substring(0, 16), MARGIN + 37, y + 5)
doc.text(formatNumber(r.total_hours, 1), MARGIN + 65 + 18 - 2, y + 5, { align: 'right' })
doc.text(formatCurrency(r.hourly_rate), MARGIN + 83 + 22 - 2, y + 5, { align: 'right' })
doc.text(formatCurrency(r.revenue), MARGIN + 105 + 25 - 2, y + 5, { align: 'right' })
setText(doc, r.expenses > 0 ? '#FCA5A5' : '#D1D5DB')
doc.text(formatCurrency(r.expenses), MARGIN + 130 + 22 - 2, y + 5, { align: 'right' })
setText(doc, r.net_profit >= 0 ? '#86EFAC' : '#FCA5A5')
doc.text(formatCurrency(r.net_profit), MARGIN + 152 + 22 - 2, y + 5, { align: 'right' })
setText(doc, '#9CA3AF')
doc.text(r.budget_used_pct != null ? formatNumber(r.budget_used_pct, 0) + '%' : '-', MARGIN + 174 + 16 - 2, y + 5, { align: 'right' })
y += 7
}
// Totals row
y = checkPageBreak(doc, y, 8)
setFill(doc, '#1F2937')
doc.rect(MARGIN, y, CONTENT_W, 7, 'F')
doc.setFontSize(6.5)
setText(doc, '#F9FAFB')
doc.text('Total', MARGIN + 2, y + 5)
doc.text(formatNumber(data.totalHours, 1), MARGIN + 65 + 18 - 2, y + 5, { align: 'right' })
doc.text(formatCurrency(data.totalRevenue), MARGIN + 105 + 25 - 2, y + 5, { align: 'right' })
doc.text(formatCurrency(data.totalExpenses), MARGIN + 130 + 22 - 2, y + 5, { align: 'right' })
setText(doc, data.totalNet >= 0 ? '#86EFAC' : '#FCA5A5')
doc.text(formatCurrency(data.totalNet), MARGIN + 152 + 22 - 2, y + 5, { align: 'right' })
}
// ---------------------------------------------------------------------------
// Expenses Report
// ---------------------------------------------------------------------------
function renderExpensesReport(doc: jsPDF, data: ExpenseReportData) {
let y = drawHeader(doc, 'Expenses Report', data.dateRange)
const boxW = (CONTENT_W - 4) / 3
drawStatBox(doc, MARGIN, y, boxW, 'Total', formatCurrency(data.totalAmount))
drawStatBox(doc, MARGIN + boxW + 2, y, boxW, 'Invoiced', formatCurrency(data.invoicedAmount))
drawStatBox(doc, MARGIN + (boxW + 2) * 2, y, boxW, 'Uninvoiced', formatCurrency(data.uninvoicedAmount))
y += 24
// By category
doc.setFontSize(9)
setText(doc, '#E5E7EB')
doc.text('By Category', MARGIN, y + 4)
y += 8
const catCols = [
{ label: 'Category', x: MARGIN, w: 80 },
{ label: 'Amount', x: MARGIN + 80, w: 50, align: 'right' },
{ label: '%', x: MARGIN + 130, w: 30, align: 'right' },
]
y = drawTableHeader(doc, y, catCols)
doc.setFontSize(7)
for (let i = 0; i < data.byCategory.length; i++) {
y = checkPageBreak(doc, y, 7)
const c = data.byCategory[i]
if (i % 2 === 0) {
setFill(doc, '#111827')
doc.rect(MARGIN, y, CONTENT_W, 7, 'F')
}
setText(doc, '#E5E7EB')
doc.text(c.category, MARGIN + 2, y + 5)
setText(doc, '#D1D5DB')
doc.text(formatCurrency(c.amount), MARGIN + 80 + 50 - 2, y + 5, { align: 'right' })
setText(doc, '#9CA3AF')
doc.text(formatNumber(c.percentage, 0) + '%', MARGIN + 130 + 30 - 2, y + 5, { align: 'right' })
y += 7
}
y += 8
// By project
y = checkPageBreak(doc, y, 20)
doc.setFontSize(9)
setText(doc, '#E5E7EB')
doc.text('By Project', MARGIN, y + 4)
y += 8
const projCols = [
{ label: 'Project', x: MARGIN, w: 80 },
{ label: 'Amount', x: MARGIN + 80, w: 50, align: 'right' },
{ label: '%', x: MARGIN + 130, w: 30, align: 'right' },
]
y = drawTableHeader(doc, y, projCols)
doc.setFontSize(7)
for (let i = 0; i < data.byProject.length; i++) {
y = checkPageBreak(doc, y, 7)
const p = data.byProject[i]
if (i % 2 === 0) {
setFill(doc, '#111827')
doc.rect(MARGIN, y, CONTENT_W, 7, 'F')
}
setFill(doc, p.color || '#F59E0B')
doc.circle(MARGIN + 4, y + 3.5, 1.5, 'F')
setText(doc, '#E5E7EB')
doc.text(p.name.substring(0, 30), MARGIN + 8, y + 5)
setText(doc, '#D1D5DB')
doc.text(formatCurrency(p.amount), MARGIN + 80 + 50 - 2, y + 5, { align: 'right' })
setText(doc, '#9CA3AF')
doc.text(formatNumber(p.percentage, 0) + '%', MARGIN + 130 + 30 - 2, y + 5, { align: 'right' })
y += 7
}
}
// ---------------------------------------------------------------------------
// Patterns Report
// ---------------------------------------------------------------------------
function renderPatternsReport(doc: jsPDF, data: PatternsReportData) {
let y = drawHeader(doc, 'Work Patterns Report', data.dateRange)
const boxW = (CONTENT_W - 2) / 2
drawStatBox(doc, MARGIN, y, boxW, 'Peak Day', data.peakDay)
drawStatBox(doc, MARGIN + boxW + 2, y, boxW, 'Peak Hour', data.peakHour)
y += 24
doc.setFontSize(9)
setText(doc, '#E5E7EB')
doc.text('Activity Heatmap', MARGIN, y + 4)
y += 8
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
const labelW = 12
const cellW = (CONTENT_W - labelW) / 24
const cellH = 8
// Hour labels
doc.setFontSize(5)
setText(doc, '#6B7280')
for (let h = 0; h < 24; h++) {
if (h % 3 === 0) {
doc.text(String(h).padStart(2, '0'), MARGIN + labelW + h * cellW + cellW / 2, y, { align: 'center' })
}
}
y += 4
const maxVal = Math.max(...data.heatmap.flat(), 0.01)
for (let d = 0; d < 7; d++) {
setText(doc, '#9CA3AF')
doc.setFontSize(6)
doc.text(days[d], MARGIN, y + cellH / 2 + 1.5)
for (let h = 0; h < 24; h++) {
const val = data.heatmap[d]?.[h] || 0
const intensity = Math.min(val / maxVal, 1)
if (intensity > 0) {
const r = Math.round(245 * intensity + 31 * (1 - intensity))
const g = Math.round(158 * intensity + 41 * (1 - intensity))
const b = Math.round(11 * intensity + 55 * (1 - intensity))
doc.setFillColor(r, g, b)
} else {
setFill(doc, '#1F2937')
}
doc.roundedRect(MARGIN + labelW + h * cellW + 0.5, y + 0.5, cellW - 1, cellH - 1, 0.5, 0.5, 'F')
}
y += cellH
}
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export function generateHoursReportPdf(data: HoursReportData): jsPDF {
const doc = new jsPDF({ unit: 'mm', format: 'a4' })
setFill(doc, '#0D1117')
doc.rect(0, 0, PAGE_W, PAGE_H, 'F')
renderHoursReport(doc, data)
return doc
}
export function generateProfitabilityReportPdf(data: ProfitabilityReportData): jsPDF {
const doc = new jsPDF({ unit: 'mm', format: 'a4' })
setFill(doc, '#0D1117')
doc.rect(0, 0, PAGE_W, PAGE_H, 'F')
renderProfitabilityReport(doc, data)
return doc
}
export function generateExpensesReportPdf(data: ExpenseReportData): jsPDF {
const doc = new jsPDF({ unit: 'mm', format: 'a4' })
setFill(doc, '#0D1117')
doc.rect(0, 0, PAGE_W, PAGE_H, 'F')
renderExpensesReport(doc, data)
return doc
}
export function generatePatternsReportPdf(data: PatternsReportData): jsPDF {
const doc = new jsPDF({ unit: 'mm', format: 'a4' })
setFill(doc, '#0D1117')
doc.rect(0, 0, PAGE_W, PAGE_H, 'F')
renderPatternsReport(doc, data)
return doc
}

100
src/utils/tours.ts Normal file
View File

@@ -0,0 +1,100 @@
import type { TourDefinition } from '../stores/tour'
export const TOURS: Record<string, TourDefinition> = {
clients: {
id: 'clients',
route: '/clients',
steps: [
{
target: '[data-tour-id="new-client"]',
title: 'Add a client',
content: 'Click here to create your first client. Add their name, email, company, and payment terms.',
placement: 'bottom',
},
],
},
projects: {
id: 'projects',
route: '/projects',
steps: [
{
target: '[data-tour-id="new-project"]',
title: 'Create a project',
content: 'Projects organize your time entries. Set an hourly rate, pick a color, and assign a client.',
placement: 'bottom',
},
],
},
timer: {
id: 'timer',
route: '/timer',
steps: [
{
target: '[data-tour-id="timer-project"]',
title: 'Pick a project',
content: 'Select which project you are working on. This links your time to the right place.',
placement: 'bottom',
},
{
target: '[data-tour-id="timer-start"]',
title: 'Start tracking',
content: 'Hit this button to start the clock. Press it again to stop and save your entry.',
placement: 'top',
},
{
target: '[data-tour-id="timer-description"]',
title: 'Describe your work',
content: 'Add a note about what you are working on. This shows up in entries and reports.',
placement: 'top',
},
],
},
entries: {
id: 'entries',
route: '/entries',
steps: [
{
target: '[data-tour-id="entries-tabs"]',
title: 'Browse your entries',
content: 'Switch between list and timeline views to see your tracked time different ways.',
placement: 'bottom',
},
],
},
invoices: {
id: 'invoices',
route: '/invoices',
steps: [
{
target: '[data-tour-id="new-invoice"]',
title: 'Create an invoice',
content: 'Generate invoices from your tracked time. Pick a client, date range, and template.',
placement: 'bottom',
},
],
},
reports: {
id: 'reports',
route: '/reports',
steps: [
{
target: '[data-tour-id="reports-daterange"]',
title: 'Set your date range',
content: 'Pick a start and end date for your report. The default is the current month.',
placement: 'bottom',
},
{
target: '[data-tour-id="reports-generate"]',
title: 'Generate the report',
content: 'Click Generate to see hours, earnings, and project breakdowns for the selected period.',
placement: 'bottom',
},
{
target: '[data-tour-id="reports-tabs"]',
title: 'Explore report types',
content: 'Switch between Hours, Profitability, and Expenses views for different insights.',
placement: 'bottom',
},
],
},
}

44
src/utils/uiFonts.ts Normal file
View File

@@ -0,0 +1,44 @@
export interface UIFont {
label: string
value: string
category: 'default' | 'accessibility' | 'general'
}
export const UI_FONTS: UIFont[] = [
{ label: 'Inter (Default)', value: 'Inter', category: 'default' },
{ label: 'OpenDyslexic', value: 'OpenDyslexic', category: 'accessibility' },
{ label: 'Atkinson Hyperlegible', value: 'Atkinson Hyperlegible', category: 'accessibility' },
{ label: 'Lexie Readable', value: 'Lexie Readable', category: 'accessibility' },
{ label: 'Comic Neue', value: 'Comic Neue', category: 'general' },
{ label: 'Nunito', value: 'Nunito', category: 'general' },
{ label: 'Lato', value: 'Lato', category: 'general' },
{ label: 'Source Sans 3', value: 'Source Sans 3', category: 'general' },
]
const loadedUIFonts = new Set<string>()
export function loadUIFont(fontName: string): void {
if (fontName === 'Inter' || loadedUIFonts.has(fontName)) return
loadedUIFonts.add(fontName)
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(fontName)}:wght@400;500;600;700&display=swap`
document.head.appendChild(link)
}
export function applyUIFont(fontName: string): void {
const el = document.documentElement
if (fontName === 'Inter') {
el.style.removeProperty('--font-ui')
document.body.style.fontFamily = ''
} else {
const fontValue = `'${fontName}', system-ui, sans-serif`
el.style.setProperty('--font-ui', fontValue)
document.body.style.fontFamily = `var(--font-ui)`
}
}
export function loadAndApplyUIFont(fontName: string): void {
loadUIFont(fontName)
applyUIFont(fontName)
}

File diff suppressed because it is too large Load Diff

View File

@@ -32,11 +32,12 @@
<p v-if="client.company" class="text-xs text-text-secondary mt-0.5 truncate">{{ client.company }}</p>
<p v-if="client.email" class="text-xs text-text-tertiary mt-0.5 truncate">{{ client.email }}</p>
</div>
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity duration-100 shrink-0 ml-2">
<div class="flex items-center gap-1 shrink-0 ml-2">
<button
@click.stop="openEditDialog(client)"
class="p-1.5 text-text-tertiary hover:text-text-secondary transition-colors duration-150"
aria-label="Edit"
v-tooltip="'Edit'"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
@@ -46,6 +47,7 @@
@click.stop="confirmDelete(client)"
class="p-1.5 text-text-tertiary hover:text-status-error transition-colors duration-150"
aria-label="Delete"
v-tooltip="'Delete'"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
@@ -173,6 +175,18 @@
/>
</div>
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Currency</label>
<AppSelect
v-model="formData.currency"
:options="currencyOptions"
label-key="label"
value-key="value"
placeholder="Default (global setting)"
:placeholder-value="undefined"
/>
</div>
<div>
<label for="client-notes" class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Notes</label>
<textarea
@@ -221,23 +235,29 @@
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ref, reactive, computed, onMounted } from 'vue'
import { Users } from 'lucide-vue-next'
import AppDiscardDialog from '../components/AppDiscardDialog.vue'
import AppCascadeDeleteDialog from '../components/AppCascadeDeleteDialog.vue'
import AppSelect from '../components/AppSelect.vue'
import { invoke } from '@tauri-apps/api/core'
import { handleInvokeError } from '../utils/errorHandler'
import { useClientsStore, type Client } from '../stores/clients'
import { useToastStore } from '../stores/toast'
import { useFormGuard } from '../utils/formGuard'
import { getCurrencies } from '../utils/locale'
const clientsStore = useClientsStore()
const toastStore = useToastStore()
const { showDiscardDialog, snapshot: snapshotForm, tryClose: tryCloseForm, confirmDiscard, cancelDiscard } = useFormGuard()
const currencyOptions = computed(() =>
getCurrencies().map(c => ({ label: `${c.code} - ${c.name}`, value: c.code }))
)
function getFormData() {
return { name: formData.name, email: formData.email, phone: formData.phone, address: formData.address, company: formData.company, tax_id: formData.tax_id, payment_terms: formData.payment_terms, notes: formData.notes }
return { name: formData.name, email: formData.email, phone: formData.phone, address: formData.address, company: formData.company, tax_id: formData.tax_id, payment_terms: formData.payment_terms, notes: formData.notes, currency: formData.currency }
}
function tryCloseDialog() {
@@ -261,6 +281,7 @@ const formData = reactive<Client>({
tax_id: undefined,
payment_terms: undefined,
notes: undefined,
currency: undefined,
})
// Open create dialog
@@ -274,6 +295,7 @@ function openCreateDialog() {
formData.tax_id = undefined
formData.payment_terms = undefined
formData.notes = undefined
formData.currency = undefined
snapshotForm(getFormData())
showDialog.value = true
}
@@ -290,6 +312,7 @@ function openEditDialog(client: Client) {
formData.tax_id = client.tax_id
formData.payment_terms = client.payment_terms
formData.notes = client.notes
formData.currency = client.currency
snapshotForm(getFormData())
showDialog.value = true
}

View File

@@ -4,17 +4,23 @@
<!-- Loading skeleton -->
<div v-if="loading" class="space-y-6" aria-busy="true" aria-label="Loading dashboard data">
<div class="h-6 w-48 bg-bg-elevated rounded animate-pulse" />
<div class="grid grid-cols-4 gap-4">
<div v-for="i in 4" :key="i" class="bg-bg-surface rounded-lg p-4">
<div v-for="i in 4" :key="i" class="bg-bg-surface border border-border-subtle rounded-lg p-4">
<div class="h-3 w-16 bg-bg-elevated rounded animate-pulse mb-2" />
<div class="h-6 w-24 bg-bg-elevated rounded animate-pulse" />
</div>
</div>
<div class="bg-bg-surface rounded-lg p-4">
<div class="h-48 bg-bg-elevated rounded animate-pulse" />
</div>
<div class="bg-bg-surface rounded-lg p-4 space-y-3">
<div v-for="i in 3" :key="i" class="h-10 bg-bg-elevated rounded animate-pulse" />
<div class="bg-bg-surface border border-border-subtle rounded-lg p-4"><div class="h-20 bg-bg-elevated rounded animate-pulse" /></div>
<div class="grid grid-cols-2 gap-4">
<div class="bg-bg-surface border border-border-subtle rounded-lg p-4"><div class="h-48 bg-bg-elevated rounded animate-pulse" /></div>
<div class="bg-bg-surface border border-border-subtle rounded-lg p-4 space-y-2">
<div v-for="i in 5" :key="i" class="h-8 bg-bg-elevated rounded animate-pulse" />
</div>
<div class="bg-bg-surface border border-border-subtle rounded-lg p-4"><div class="h-20 bg-bg-elevated rounded animate-pulse" /></div>
<div class="bg-bg-surface border border-border-subtle rounded-lg p-4 space-y-2">
<div v-for="i in 3" :key="i" class="h-12 bg-bg-elevated rounded animate-pulse" />
</div>
</div>
</div>
@@ -35,19 +41,62 @@
<!-- Main content -->
<Transition name="fade" appear>
<div v-if="!loading && !isEmpty">
<!-- Running timer indicator -->
<div
v-if="timerStore.timerState === 'RUNNING'"
class="flex items-center gap-3 px-4 py-2.5 mb-4 bg-status-running/10 border border-status-running/20 rounded-lg"
role="status"
aria-live="polite"
>
<span class="relative flex h-2.5 w-2.5 shrink-0" aria-hidden="true">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-status-running opacity-75" />
<span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-status-running" />
</span>
<div class="flex items-center gap-2 min-w-0 flex-1">
<span class="text-[0.8125rem] font-medium text-text-primary">Now tracking</span>
<div
v-if="timerProjectColor"
class="w-2 h-2 rounded-full shrink-0"
:style="{ backgroundColor: timerProjectColor }"
aria-hidden="true"
/>
<span class="text-[0.75rem] text-text-secondary truncate">{{ timerProjectName }}</span>
<span v-if="timerStore.description" class="text-[0.6875rem] text-text-tertiary truncate">- {{ timerStore.description }}</span>
</div>
<span class="text-[0.8125rem] font-mono text-status-running shrink-0">{{ formatDuration(timerStore.elapsedSeconds) }}</span>
</div>
<!-- Greeting header -->
<div class="mb-8">
<div class="mb-6">
<h1 class="text-xl font-[family-name:var(--font-heading)] text-text-secondary">{{ greeting }}</h1>
<p class="text-xs text-text-tertiary mt-1">{{ formattedDate }}</p>
</div>
<!-- Stats row - 4 columns -->
<dl class="grid grid-cols-4 gap-6 mb-8">
<div>
<!-- Stats row - 4 card columns -->
<dl class="grid grid-cols-4 gap-4 mb-6">
<div class="bg-bg-surface border border-border-subtle rounded-lg p-4">
<dt class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Today</dt>
<dd class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ formatDuration(todayStats.totalSeconds) }}</dd>
<!-- Today's breakdown bar -->
<div
v-if="todayBreakdown.length > 0"
class="flex w-full h-2 rounded-full overflow-hidden mt-2 gap-px"
role="img"
:aria-label="todayBreakdownLabel"
>
<div
v-for="seg in todayBreakdown"
:key="seg.projectId"
class="h-full rounded-sm first:rounded-l-full last:rounded-r-full transition-all duration-300 relative group"
:style="{ width: seg.percent + '%', backgroundColor: seg.color, minWidth: seg.percent > 0 ? '3px' : '0' }"
>
<div class="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 px-2 py-1 bg-bg-elevated border border-border-subtle rounded text-[0.625rem] text-text-primary whitespace-nowrap opacity-0 group-hover:opacity-100 pointer-events-none z-10 transition-opacity">
{{ seg.name }} - {{ formatNumber(seg.hours, 1) }}h
</div>
</div>
</div>
</div>
<div>
<div class="bg-bg-surface border border-border-subtle rounded-lg p-4">
<dt class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">This Week</dt>
<dd class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ formatDuration(weekStats.totalSeconds) }}</dd>
<div v-if="lastWeekSeconds > 0" class="flex items-center gap-1 mt-1">
@@ -56,27 +105,27 @@
<ChevronDown v-else-if="weekDiff < 0" class="w-3 h-3 text-status-error" aria-hidden="true" />
<Minus v-else class="w-3 h-3 text-text-tertiary" aria-hidden="true" />
<span class="text-[0.625rem]"
:class="weekDiff >= 0 ? 'text-status-running' : 'text-status-error'">
:class="weekDiff >= 0 ? 'text-status-running' : 'text-status-error-text'">
{{ formatDuration(Math.abs(weekDiff)) }} {{ weekDiff >= 0 ? 'more' : 'less' }}
</span>
</div>
</div>
<div>
<div class="bg-bg-surface border border-border-subtle rounded-lg p-4">
<dt class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">This Month</dt>
<dd class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ formatDuration(monthStats.totalSeconds) }}</dd>
</div>
<div>
<div class="bg-bg-surface border border-border-subtle rounded-lg p-4">
<dt class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Active Projects</dt>
<dd class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ activeProjectsCount }}</dd>
</div>
</dl>
<!-- Goal Progress -->
<div v-if="goalProgress" class="mt-6">
<h2 class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-3">Goals</h2>
<div class="grid grid-cols-3 gap-4">
<div v-if="goalProgress" class="bg-bg-surface border border-border-subtle rounded-lg p-4 mb-6">
<h2 class="text-[0.8125rem] font-medium text-text-primary mb-3">Goals</h2>
<div class="grid grid-cols-[1fr_1fr_auto] gap-4 items-center">
<div>
<div class="flex items-center justify-between mb-1">
<div class="flex items-center justify-between mb-1.5">
<span class="text-[0.75rem] text-text-secondary">Today</span>
<span class="text-[0.75rem] font-mono text-accent-text">{{ formatGoalHours(goalProgress.today_seconds) }}</span>
</div>
@@ -88,7 +137,7 @@
</div>
</div>
<div>
<div class="flex items-center justify-between mb-1">
<div class="flex items-center justify-between mb-1.5">
<span class="text-[0.75rem] text-text-secondary">This Week</span>
<span class="text-[0.75rem] font-mono text-accent-text">{{ formatGoalHours(goalProgress.week_seconds) }}</span>
</div>
@@ -99,123 +148,138 @@
/>
</div>
</div>
<div class="flex flex-col items-center justify-center">
<span class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ goalProgress.streak_days }}</span>
<span class="text-[0.6875rem] text-text-tertiary">day streak</span>
<div class="flex flex-col items-center px-3">
<span class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium leading-none">{{ goalProgress.streak_days }}</span>
<span class="text-[0.6875rem] text-text-tertiary mt-0.5">day streak</span>
</div>
</div>
</div>
<!-- Weekly chart -->
<div class="mb-8">
<h2 class="text-[1.25rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-4">Weekly Hours</h2>
<div class="h-48" aria-label="Weekly hours bar chart">
<Bar v-if="chartData" :data="chartData" :options="chartOptions" />
</div>
</div>
<!-- 4-week sparkline -->
<div v-if="weeklySparkline.length > 0" class="mb-8 bg-bg-surface rounded-lg p-4 border border-border-subtle">
<h2 class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-3">Last 4 Weeks</h2>
<div class="flex items-end gap-3 h-16" role="img" :aria-label="sparklineLabel">
<div
v-for="(week, i) in weeklySparkline"
:key="i"
class="flex-1 flex flex-col items-center gap-1"
>
<div
class="w-full rounded-sm transition-all duration-300"
:class="i === weeklySparkline.length - 1 ? 'bg-accent' : 'bg-accent-muted'"
:style="{ height: `${week.percent}%`, minHeight: week.hours > 0 ? '4px' : '0px' }"
/>
<span class="text-[0.5625rem] text-text-tertiary">{{ week.label }}</span>
</div>
</div>
</div>
<!-- Recent entries -->
<div>
<div class="flex items-center justify-between mb-4">
<h2 class="text-[1.25rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary">Recent Entries</h2>
<router-link to="/entries" class="text-xs text-text-tertiary hover:text-text-secondary transition-colors" aria-label="View all time entries">View all</router-link>
</div>
<div v-if="recentEntries.length > 0">
<div
v-for="entry in recentEntries"
:key="entry.id"
class="flex items-center justify-between py-3 border-b border-border-subtle last:border-0"
>
<div class="flex items-center gap-3">
<div
class="w-2 h-2 rounded-full shrink-0"
:style="{ backgroundColor: getProjectColor(entry.project_id) }"
aria-hidden="true"
/>
<span class="text-[0.75rem] text-text-primary">{{ getProjectName(entry.project_id) }}</span>
</div>
<span class="text-[0.75rem] font-mono text-text-secondary">{{ formatDuration(entry.duration) }}</span>
</div>
</div>
<p v-else class="text-[0.75rem] text-text-tertiary py-8">
No entries yet. Start tracking your time.
</p>
</div>
<!-- Budget Alerts -->
<div v-if="budgetAlerts.length > 0" class="mt-6">
<h2 class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-3">Budget Alerts</h2>
<div class="space-y-2">
<!-- Budget Alerts (full width, high visibility) -->
<div v-if="budgetAlerts.length > 0" class="bg-bg-surface border border-border-subtle rounded-lg p-4 mb-6">
<h2 class="text-[0.8125rem] font-medium text-text-primary mb-3">Budget Alerts</h2>
<div class="grid grid-cols-2 gap-2">
<div
v-for="alert in budgetAlerts"
:key="alert.id"
class="flex items-center justify-between py-2 px-3 rounded-lg"
:class="alert.pct > 90 ? 'bg-status-error/10' : 'bg-status-warning/10'"
>
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full" :style="{ backgroundColor: alert.color }" aria-hidden="true" />
<span class="text-[0.75rem] text-text-primary">{{ alert.name }}</span>
<div class="flex items-center gap-2 min-w-0">
<div class="w-2 h-2 rounded-full shrink-0" :style="{ backgroundColor: alert.color }" aria-hidden="true" />
<span class="text-[0.75rem] text-text-primary truncate">{{ alert.name }}</span>
</div>
<span class="text-[0.75rem] font-mono" :class="alert.pct > 90 ? 'text-status-error' : 'text-status-warning'">
{{ alert.pct.toFixed(0) }}%
<span class="text-[0.75rem] font-mono shrink-0 ml-2" :class="alert.pct > 90 ? 'text-status-error-text' : 'text-status-warning'">
{{ formatNumber(alert.pct, 0) }}%
</span>
<span class="sr-only">{{ alert.pct > 90 ? 'Over budget' : 'Approaching budget limit' }}</span>
</div>
</div>
</div>
<!-- Project Forecasts -->
<div v-if="projectForecasts.length > 0" class="mt-6">
<h2 class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-3">Project Forecasts</h2>
<div class="space-y-3">
<div
v-for="forecast in projectForecasts"
:key="forecast.id"
class="px-3 py-2.5 bg-bg-surface border border-border-subtle rounded-lg"
>
<div class="flex items-center justify-between mb-1.5">
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full" :style="{ backgroundColor: forecast.color }" aria-hidden="true" />
<span class="text-[0.8125rem] text-text-primary">{{ forecast.name }}</span>
<!-- 2x2 grid: charts + lists -->
<div class="grid grid-cols-2 gap-4">
<!-- Weekly chart -->
<div class="bg-bg-surface border border-border-subtle rounded-lg p-4">
<h2 class="text-[0.8125rem] font-medium text-text-primary mb-3">Weekly Hours</h2>
<div class="h-44" aria-label="Weekly hours bar chart">
<Bar v-if="chartData" :data="chartData" :options="chartOptions" />
</div>
</div>
<!-- Recent entries -->
<div class="bg-bg-surface border border-border-subtle rounded-lg p-4">
<div class="flex items-center justify-between mb-3">
<h2 class="text-[0.8125rem] font-medium text-text-primary">Recent Entries</h2>
<router-link to="/entries" class="text-[0.6875rem] text-text-tertiary hover:text-text-secondary transition-colors" aria-label="View all time entries">View all</router-link>
</div>
<div v-if="recentEntries.length > 0" class="space-y-0">
<div
v-for="entry in recentEntries"
:key="entry.id"
class="group flex items-center justify-between py-2 border-b border-border-subtle last:border-0"
>
<div class="flex items-center gap-2 min-w-0">
<div
class="w-2 h-2 rounded-full shrink-0"
:style="{ backgroundColor: getProjectColor(entry.project_id) }"
aria-hidden="true"
/>
<span class="text-[0.75rem] text-text-primary truncate">{{ getProjectName(entry.project_id) }}</span>
</div>
<div class="flex items-center gap-1.5 shrink-0 ml-2">
<button
@click="replayEntry(entry)"
class="p-1 text-text-tertiary hover:text-status-running transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100"
aria-label="Start timer with this entry"
v-tooltip="'Replay entry'"
>
<Play class="h-3 w-3" :stroke-width="2" fill="currentColor" aria-hidden="true" />
</button>
<span class="text-[0.75rem] font-mono text-text-secondary">{{ formatDuration(entry.duration) }}</span>
</div>
<span class="text-[0.6875rem] font-mono" :class="paceColor(forecast.pace)">
{{ forecast.paceLabel }}
</span>
</div>
<div class="flex items-center justify-between mb-1">
<span class="text-[0.625rem] text-text-tertiary">{{ forecast.hoursUsed.toFixed(0) }}h / {{ forecast.budgetHours }}h</span>
<span class="text-[0.625rem] text-text-tertiary">{{ forecast.dailyAvg.toFixed(1) }}h/day</span>
</div>
<div class="w-full bg-bg-elevated rounded-full h-1" role="progressbar" :aria-valuenow="Math.round(forecast.pct)" aria-valuemin="0" aria-valuemax="100">
</div>
<p v-else class="text-[0.75rem] text-text-tertiary py-6 text-center">
No entries yet. Start tracking your time.
</p>
</div>
<!-- 4-week sparkline -->
<div v-if="weeklySparkline.length > 0" class="bg-bg-surface border border-border-subtle rounded-lg p-4">
<h2 class="text-[0.8125rem] font-medium text-text-primary mb-3">Last 4 Weeks</h2>
<div class="flex items-end gap-3 h-16" role="img" :aria-label="sparklineLabel">
<div
v-for="(week, i) in weeklySparkline"
:key="i"
class="flex-1 flex flex-col items-center gap-1"
>
<div
class="h-1 rounded-full progress-bar"
:class="forecast.pct > 90 ? 'bg-status-error' : forecast.pct > 75 ? 'bg-status-warning' : 'bg-accent'"
:style="{ width: Math.min(forecast.pct, 100) + '%' }"
class="w-full rounded-sm transition-all duration-300"
:class="i === weeklySparkline.length - 1 ? 'bg-accent' : 'bg-accent-muted'"
:style="{ height: `${week.percent}%`, minHeight: week.hours > 0 ? '4px' : '0px' }"
/>
<span class="text-[0.5625rem] text-text-tertiary">{{ week.label }}</span>
</div>
</div>
</div>
<!-- Project Forecasts -->
<div v-if="projectForecasts.length > 0" class="bg-bg-surface border border-border-subtle rounded-lg p-4">
<h2 class="text-[0.8125rem] font-medium text-text-primary mb-3">Project Forecasts</h2>
<div class="space-y-3">
<div
v-for="forecast in projectForecasts"
:key="forecast.id"
class="px-3 py-2 bg-bg-base rounded-lg"
>
<div class="flex items-center justify-between mb-1">
<div class="flex items-center gap-2 min-w-0">
<div class="w-2 h-2 rounded-full shrink-0" :style="{ backgroundColor: forecast.color }" aria-hidden="true" />
<span class="text-[0.75rem] text-text-primary truncate">{{ forecast.name }}</span>
</div>
<span class="text-[0.6875rem] font-mono shrink-0 ml-2" :class="paceColor(forecast.pace)">
{{ forecast.paceLabel }}
</span>
</div>
<div class="flex items-center justify-between mb-1">
<span class="text-[0.625rem] text-text-tertiary">{{ formatNumber(forecast.hoursUsed, 0) }}h / {{ forecast.budgetHours }}h</span>
<span class="text-[0.625rem] text-text-tertiary">{{ formatNumber(forecast.dailyAvg, 1) }}h/day</span>
</div>
<div class="w-full bg-bg-elevated rounded-full h-1" role="progressbar" :aria-valuenow="Math.round(forecast.pct)" aria-valuemin="0" aria-valuemax="100" :aria-label="forecast.name + ' budget: ' + Math.round(forecast.pct) + '%'">
<div
class="h-1 rounded-full progress-bar"
:class="forecast.pct > 90 ? 'bg-status-error' : forecast.pct > 75 ? 'bg-status-warning' : 'bg-accent'"
:style="{ width: Math.min(forecast.pct, 100) + '%' }"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</Transition>
@@ -235,13 +299,16 @@ import {
Tooltip,
Legend
} from 'chart.js'
import { Clock, ChevronUp, ChevronDown, Minus } from 'lucide-vue-next'
import { Clock, ChevronUp, ChevronDown, Minus, Play } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import { useToastStore } from '../stores/toast'
import GettingStartedChecklist from '../components/GettingStartedChecklist.vue'
import { useOnboardingStore } from '../stores/onboarding'
import { useEntriesStore } from '../stores/entries'
import { useProjectsStore } from '../stores/projects'
import { useSettingsStore } from '../stores/settings'
import { formatDateLong } from '../utils/locale'
import { useTimerStore } from '../stores/timer'
import { formatDateLong, formatNumber } from '../utils/locale'
import { getChartTheme, buildBarChartOptions } from '../utils/chartTheme'
import type { TimeEntry } from '../stores/entries'
@@ -252,6 +319,9 @@ const entriesStore = useEntriesStore()
const projectsStore = useProjectsStore()
const settingsStore = useSettingsStore()
const onboardingStore = useOnboardingStore()
const timerStore = useTimerStore()
const router = useRouter()
const toastStore = useToastStore()
const loading = ref(true)
@@ -288,7 +358,7 @@ const weekDiff = ref(0)
// 4-week sparkline data
const weeklySparkline = ref<Array<{ hours: number; percent: number; label: string }>>([])
const sparklineLabel = computed(() =>
weeklySparkline.value.map(w => `${w.label}: ${w.hours.toFixed(1)} hours`).join(', ')
weeklySparkline.value.map(w => `${w.label}: ${formatNumber(w.hours, 1)} hours`).join(', ')
)
// Greeting based on time of day
@@ -344,6 +414,19 @@ function formatDuration(seconds: number): string {
return `${minutes}m`
}
function replayEntry(entry: TimeEntry) {
if (!timerStore.isStopped) {
toastStore.info('Timer is already running. Stop it first.')
return
}
timerStore.setProject(entry.project_id)
timerStore.setTask(entry.task_id || null)
timerStore.setDescription(entry.description || '')
timerStore.setBillable(entry.billable ?? 1)
timerStore.start()
router.push('/timer')
}
// Get project name by ID
function getProjectName(projectId: number): string {
const project = projectsStore.projects.find(p => p.id === projectId)
@@ -361,6 +444,48 @@ const activeProjectsCount = computed(() => {
return projectsStore.projects.filter(p => !p.archived).length
})
// Running timer helpers
const timerProjectName = computed(() => {
if (!timerStore.selectedProjectId) return ''
return getProjectName(timerStore.selectedProjectId)
})
const timerProjectColor = computed(() => {
if (!timerStore.selectedProjectId) return ''
return getProjectColor(timerStore.selectedProjectId)
})
// Today's breakdown - stacked bar showing time by project
interface BreakdownSegment {
projectId: number
name: string
color: string
seconds: number
hours: number
percent: number
}
const todayBreakdown = computed<BreakdownSegment[]>(() => {
const byProject = todayStats.value.byProject as Array<{ project_id: number; total_seconds: number }> | undefined
if (!byProject || byProject.length === 0) return []
const total = todayStats.value.totalSeconds
if (total <= 0) return []
return byProject.map(bp => {
const p = projectsStore.projects.find(proj => proj.id === bp.project_id)
return {
projectId: bp.project_id,
name: p?.name || 'Unknown',
color: p?.color || '#6B7280',
seconds: bp.total_seconds,
hours: bp.total_seconds / 3600,
percent: (bp.total_seconds / total) * 100,
}
}).filter(s => s.percent > 0)
})
const todayBreakdownLabel = computed(() =>
todayBreakdown.value.map(s => `${s.name}: ${formatNumber(s.hours, 1)} hours`).join(', ')
)
// Budget status
interface DashboardBudgetStatus {
hours_used: number
@@ -441,7 +566,7 @@ const projectForecasts = computed<ForecastData[]>(() => {
function paceColor(pace: string | null): string {
if (pace === 'ahead') return 'text-status-running'
if (pace === 'on_track') return 'text-accent-text'
if (pace === 'behind') return 'text-status-error'
if (pace === 'behind') return 'text-status-error-text'
if (pace === 'complete') return 'text-status-running'
return 'text-text-tertiary'
}
@@ -510,7 +635,7 @@ const chartOptions = computed(() => {
callbacks: {
label: (context: { raw: unknown }) => {
const hours = context.raw as number
return `${hours.toFixed(1)} hours`
return `${formatNumber(hours, 1)} hours`
}
}
}

View File

@@ -1,6 +1,28 @@
<template>
<div class="p-6">
<h1 class="text-[1.75rem] font-bold font-[family-name:var(--font-heading)] tracking-tight text-text-primary mb-6">Entries</h1>
<h1 class="text-[1.75rem] font-bold font-[family-name:var(--font-heading)] tracking-tight text-text-primary mb-4">Entries</h1>
<!-- Running timer indicator -->
<div
v-if="timerStore.timerState === 'RUNNING'"
class="flex items-center gap-3 px-4 py-2 mb-4 bg-status-running/10 border border-status-running/20 rounded-lg"
role="status"
aria-live="polite"
>
<span class="relative flex h-2 w-2 shrink-0" aria-hidden="true">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-status-running opacity-75" />
<span class="relative inline-flex rounded-full h-2 w-2 bg-status-running" />
</span>
<span class="text-[0.75rem] font-medium text-text-primary">Currently tracking</span>
<div
v-if="timerStore.selectedProjectId"
class="w-2 h-2 rounded-full shrink-0"
:style="{ backgroundColor: projectsStore.projects.find(p => p.id === timerStore.selectedProjectId)?.color || '#6B7280' }"
aria-hidden="true"
/>
<span class="text-[0.75rem] text-text-secondary truncate">{{ projectsStore.projects.find(p => p.id === timerStore.selectedProjectId)?.name || '' }}</span>
<span class="text-[0.75rem] font-mono text-status-running ml-auto shrink-0">{{ formatRunningDuration(timerStore.elapsedSeconds) }}</span>
</div>
<!-- Tab Toggle: Time | Expenses -->
<div data-tour-id="entries-tabs" class="flex items-center gap-2 mb-4" role="tablist" aria-label="Entry type" @keydown="onMainTabKeydown">
@@ -14,7 +36,10 @@
class="px-4 py-2 text-[0.75rem] font-medium rounded-lg transition-colors"
:class="activeTab === 'time' ? 'bg-accent text-bg-base' : 'text-text-secondary hover:text-text-primary'"
>
Time
<span class="flex items-center gap-1.5">
<Clock class="w-3.5 h-3.5" :stroke-width="1.5" aria-hidden="true" />
Time
</span>
</button>
<button
id="tab-expenses"
@@ -69,36 +94,78 @@
:placeholder-value="null"
/>
</div>
<button
@click="applyFilters"
class="px-4 py-2 bg-accent text-bg-base text-xs font-medium rounded-lg hover:bg-accent-hover transition-colors duration-150"
>
Apply
</button>
<button
@click="clearFilters"
class="text-text-secondary text-xs hover:text-text-primary transition-colors"
>
Clear
</button>
<div class="ml-auto flex gap-2">
<div class="flex items-center gap-3">
<button
@click="copyPreviousDay"
class="text-text-secondary text-xs hover:text-text-primary transition-colors border border-border-subtle rounded-lg px-3 py-1.5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
@click="applyFilters"
class="px-4 py-2 bg-accent text-bg-base text-xs font-medium rounded-lg hover:bg-accent-hover transition-colors duration-150"
>
Copy Yesterday
Apply
</button>
<button
@click="copyPreviousWeek"
class="text-text-secondary text-xs hover:text-text-primary transition-colors border border-border-subtle rounded-lg px-3 py-1.5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
@click="clearFilters"
class="text-text-secondary text-xs hover:text-text-primary transition-colors"
>
Copy Last Week
Clear
</button>
</div>
<div class="ml-auto flex items-center gap-2">
<!-- Add entries dropdown -->
<div class="relative">
<button
@click.stop="showActionsMenu = !showActionsMenu"
class="inline-flex items-center gap-1.5 text-text-secondary text-xs hover:text-text-primary transition-colors border border-border-subtle rounded-lg px-3 py-1.5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
aria-haspopup="menu"
:aria-expanded="showActionsMenu"
>
<Plus class="w-3 h-3" :stroke-width="2" aria-hidden="true" />
Add
<ChevronDown class="w-3 h-3" :stroke-width="2" aria-hidden="true" />
</button>
<Transition name="dropdown">
<div
v-if="showActionsMenu"
class="absolute right-0 top-full mt-1 w-44 bg-bg-surface border border-border-visible rounded-xl shadow-[0_4px_24px_rgba(0,0,0,0.4)] overflow-hidden z-30 py-1"
role="menu"
@click.stop
>
<button
role="menuitem"
@click="copyPreviousDay(); showActionsMenu = false"
class="w-full text-left px-3 py-2 text-[0.8125rem] text-text-primary hover:bg-bg-elevated transition-colors"
>
Copy Yesterday
</button>
<button
role="menuitem"
@click="copyPreviousWeek(); showActionsMenu = false"
class="w-full text-left px-3 py-2 text-[0.8125rem] text-text-primary hover:bg-bg-elevated transition-colors"
>
Copy Last Week
</button>
<div class="border-t border-border-subtle my-1" />
<button
role="menuitem"
@click="showTemplatePicker = true; showActionsMenu = false"
class="w-full text-left px-3 py-2 text-[0.8125rem] text-text-primary hover:bg-bg-elevated transition-colors"
>
From Template
</button>
<button
role="menuitem"
@click="openBulkAdd(); showActionsMenu = false"
class="w-full text-left px-3 py-2 text-[0.8125rem] text-text-primary hover:bg-bg-elevated transition-colors"
>
Bulk Add
</button>
</div>
</Transition>
</div>
<button
@click="showTemplatePicker = true"
class="text-text-secondary text-xs hover:text-text-primary transition-colors border border-border-subtle rounded-lg px-3 py-1.5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
@click="exportEntriesCSV"
class="text-text-secondary text-xs hover:text-text-primary transition-colors border border-border-subtle rounded-lg px-3 py-1.5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent inline-flex items-center gap-1"
>
From Template
<Download class="w-3 h-3" :stroke-width="2" aria-hidden="true" />
Export CSV
</button>
</div>
</div>
@@ -114,7 +181,7 @@
<thead>
<tr class="border-b border-border-subtle bg-bg-surface">
<th class="px-4 py-3 w-10">
<input type="checkbox" :checked="allSelected" :indeterminate="someSelected" @change="toggleSelectAll" aria-label="Select all entries" class="w-4 h-4 rounded border-border-visible accent-accent" />
<input type="checkbox" :checked="allSelected" :indeterminate="someSelected" @change="toggleSelectAll" v-tooltip="'Select all'" aria-label="Select all entries" class="w-4 h-4 rounded border-border-visible accent-accent" />
</th>
<th class="px-4 py-3 text-left text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Date</th>
<th class="px-4 py-3 text-left text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Project</th>
@@ -169,13 +236,11 @@
<span
v-if="entry.billable === 0"
class="text-[0.5625rem] font-sans font-medium text-text-tertiary bg-bg-elevated px-1 py-0.5 rounded"
title="Non-billable"
>NB</span>
><span class="sr-only">Non-billable</span><span aria-hidden="true">NB</span></span>
{{ formatDuration(entry.duration) }}
<span
v-if="getRoundedDuration(entry.duration) !== null"
class="ml-1 text-[0.5625rem] font-sans text-text-tertiary"
:title="'Actual: ' + formatDuration(entry.duration) + ' - Rounded: ' + formatDuration(getRoundedDuration(entry.duration)!)"
>
<Clock class="inline w-3 h-3 mr-0.5" aria-hidden="true" :stroke-width="1.5" />
<span class="sr-only">Duration rounded from {{ formatDuration(entry.duration) }} to {{ formatDuration(getRoundedDuration(entry.duration)!) }}</span>
@@ -186,21 +251,41 @@
<td class="px-4 py-3">
<div class="flex items-center justify-end gap-1">
<template v-if="isEntryLocked(entry)">
<span class="flex items-center gap-1 text-[0.6875rem] text-amber-500" title="This entry is in a locked week">
<span class="flex items-center gap-1 text-[0.6875rem] text-amber-500">
<Lock class="h-3.5 w-3.5" :stroke-width="2" aria-hidden="true" />
<span class="sr-only">This entry is in a locked week</span>
</span>
</template>
<template v-else>
<div class="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity duration-100">
<div class="flex items-center justify-end gap-1 ">
<button
@click="replayEntry(entry)"
v-tooltip="'Replay entry'"
class="p-1.5 text-text-tertiary hover:text-status-running transition-colors duration-150"
aria-label="Start timer with this entry"
>
<Play class="h-3.5 w-3.5" :stroke-width="2" fill="currentColor" aria-hidden="true" />
</button>
<button
@click="duplicateEntry(entry)"
v-tooltip="'Duplicate'"
class="p-1.5 text-text-tertiary hover:text-text-secondary transition-colors duration-150"
aria-label="Duplicate entry"
>
<Copy class="h-3.5 w-3.5" :stroke-width="2" aria-hidden="true" />
</button>
<button
v-if="entry.duration > 120"
@click="openSplitDialog(entry)"
v-tooltip="'Split entry'"
class="p-1.5 text-text-tertiary hover:text-text-secondary transition-colors duration-150"
aria-label="Split entry"
>
<Scissors class="h-3.5 w-3.5" :stroke-width="2" aria-hidden="true" />
</button>
<button
@click="openEditDialog(entry)"
v-tooltip="'Edit'"
class="p-1.5 text-text-tertiary hover:text-text-secondary transition-colors duration-150"
aria-label="Edit entry"
>
@@ -210,6 +295,7 @@
</button>
<button
@click="confirmDelete(entry)"
v-tooltip="'Delete'"
class="p-1.5 text-text-tertiary hover:text-status-error transition-colors duration-150"
aria-label="Delete entry"
>
@@ -249,8 +335,24 @@
<button @click="bulkToggleBillable" class="px-3 py-1.5 text-[0.6875rem] border border-border-subtle text-text-secondary rounded-md hover:bg-bg-elevated transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent">
Toggle Billable
</button>
<div class="relative">
<button @click="showBulkProjectPicker = !showBulkProjectPicker" class="px-3 py-1.5 text-[0.6875rem] border border-border-subtle text-text-secondary rounded-md hover:bg-bg-elevated transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent">
Change Project
</button>
<div v-if="showBulkProjectPicker" class="absolute bottom-full left-0 mb-2 w-56 z-20">
<AppSelect
:model-value="null"
:options="projectsStore.projects"
label-key="name"
value-key="id"
placeholder="Select project..."
searchable
@update:model-value="bulkChangeProject($event)"
/>
</div>
</div>
<template v-if="showBulkDeleteConfirm">
<span class="text-[0.6875rem] text-status-error font-medium">Delete {{ selectedIds.size }} entries?</span>
<span class="text-[0.6875rem] text-status-error-text font-medium">Delete {{ selectedIds.size }} entries?</span>
<button @click="executeBulkDelete" class="px-3 py-1.5 text-[0.6875rem] bg-status-error text-white rounded-md hover:bg-red-600 transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent">
Confirm
</button>
@@ -285,6 +387,12 @@
<div id="tabpanel-expenses" role="tabpanel" aria-labelledby="tab-expenses">
<!-- Expense Filters -->
<div class="bg-bg-surface rounded-lg p-4 mb-6 flex flex-wrap items-end gap-4">
<AppDateRangePresets
:start-date="expStartDate"
:end-date="expEndDate"
@select="({ start, end }) => { expStartDate = start; expEndDate = end; applyExpenseFilters() }"
class="mb-3 w-full"
/>
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Start Date</label>
<AppDatePicker
@@ -321,18 +429,20 @@
:placeholder-value="null"
/>
</div>
<button
@click="applyExpenseFilters"
class="px-4 py-2 bg-accent text-bg-base text-xs font-medium rounded-lg hover:bg-accent-hover transition-colors duration-150"
>
Apply
</button>
<button
@click="clearExpenseFilters"
class="text-text-secondary text-xs hover:text-text-primary transition-colors"
>
Clear
</button>
<div class="flex items-center gap-3">
<button
@click="applyExpenseFilters"
class="px-4 py-2 bg-accent text-bg-base text-xs font-medium rounded-lg hover:bg-accent-hover transition-colors duration-150"
>
Apply
</button>
<button
@click="clearExpenseFilters"
class="text-text-secondary text-xs hover:text-text-primary transition-colors"
>
Clear
</button>
</div>
<div class="ml-auto">
<button
@click="openAddExpenseDialog"
@@ -409,9 +519,10 @@
</span>
</td>
<td class="px-4 py-3">
<div class="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity duration-100">
<div class="flex items-center justify-end gap-1 ">
<button
@click="openEditExpenseDialog(expense)"
v-tooltip="'Edit'"
class="p-1.5 text-text-tertiary hover:text-text-secondary transition-colors duration-150"
aria-label="Edit expense"
>
@@ -419,6 +530,7 @@
</button>
<button
@click="confirmDeleteExpense(expense)"
v-tooltip="'Delete'"
class="p-1.5 text-text-tertiary hover:text-status-error transition-colors duration-150"
aria-label="Delete expense"
>
@@ -491,7 +603,6 @@
<button
type="button"
@click="editForm.billable = editForm.billable === 1 ? 0 : 1"
:title="editForm.billable === 1 ? 'Billable' : 'Non-billable'"
:aria-label="editForm.billable === 1 ? 'Mark as non-billable' : 'Mark as billable'"
class="flex items-center gap-2 px-3 py-2 rounded-lg text-[0.8125rem] transition-colors"
:class="editForm.billable === 1
@@ -579,7 +690,7 @@
</button>
<button
@click="handleDelete"
class="px-4 py-2 border border-status-error text-status-error font-medium rounded-lg hover:bg-status-error/10 transition-colors duration-150"
class="px-4 py-2 border border-status-error text-status-error-text font-medium rounded-lg hover:bg-status-error/10 transition-colors duration-150"
>
Delete
</button>
@@ -712,7 +823,7 @@
</button>
<button
@click="handleDeleteExpense"
class="px-4 py-2 border border-status-error text-status-error font-medium rounded-lg hover:bg-status-error/10 transition-colors duration-150"
class="px-4 py-2 border border-status-error text-status-error-text font-medium rounded-lg hover:bg-status-error/10 transition-colors duration-150"
>
Delete
</button>
@@ -727,6 +838,117 @@
@cancel="showTemplatePicker = false"
/>
<EntrySplitDialog
:show="showSplitDialog"
:entry="splitTarget"
@close="showSplitDialog = false; splitTarget = null"
@split="handleSplit"
/>
<!-- Bulk Add Dialog -->
<Transition name="modal">
<div
v-if="showBulkAdd"
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
@click.self="showBulkAdd = false"
>
<div class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-3xl p-6 max-h-[calc(100vh-2rem)] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="bulk-add-title">
<h2 id="bulk-add-title" class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-4">Bulk Add Entries</h2>
<p class="text-[0.75rem] text-text-tertiary mb-4">Add multiple time entries at once. Each row creates one entry.</p>
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b border-border-subtle">
<th class="px-2 py-2 text-left text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium w-28">Date</th>
<th class="px-2 py-2 text-left text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Project</th>
<th class="px-2 py-2 text-left text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Description</th>
<th class="px-2 py-2 text-right text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium w-20">Hours</th>
<th class="px-2 py-2 w-8"></th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, i) in bulkRows"
:key="i"
class="border-b border-border-subtle last:border-0"
>
<td class="px-2 py-1.5">
<AppDatePicker v-model="row.date" placeholder="Date" />
</td>
<td class="px-2 py-1.5">
<AppSelect
v-model="row.project_id"
:options="projectsStore.activeProjects"
label-key="name"
value-key="id"
placeholder="Project"
:placeholder-value="0"
/>
</td>
<td class="px-2 py-1.5">
<input
v-model="row.description"
type="text"
class="w-full px-2 py-1 bg-bg-inset border border-border-subtle rounded text-[0.75rem] text-text-primary focus:outline-none focus:border-border-visible"
placeholder="Description"
/>
</td>
<td class="px-2 py-1.5">
<AppNumberInput
v-model="row.hours"
:min="0"
:step="0.25"
:precision="2"
compact
/>
</td>
<td class="px-2 py-1.5">
<button
@click="bulkRows.splice(i, 1)"
v-tooltip="'Remove row'"
class="p-1 text-text-tertiary hover:text-status-error transition-colors"
aria-label="Remove row"
>
<Trash2 class="w-3 h-3" :stroke-width="1.5" aria-hidden="true" />
</button>
</td>
</tr>
</tbody>
</table>
</div>
<button
@click="bulkRows.push({ date: new Date().toISOString().split('T')[0], project_id: 0, description: '', hours: 0 })"
class="mt-3 text-[0.75rem] text-accent-text hover:underline"
>
+ Add Row
</button>
<div class="flex items-center justify-between mt-4 pt-4 border-t border-border-subtle">
<p class="text-[0.75rem] text-text-secondary">
{{ bulkRows.filter(r => r.project_id && r.hours > 0).length }} valid entries, {{ formatNumber(bulkRows.reduce((s, r) => s + (r.hours || 0), 0), 1) }}h total
</p>
<div class="flex gap-3">
<button
@click="showBulkAdd = false"
class="px-4 py-2 border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated transition-colors duration-150"
>
Cancel
</button>
<button
@click="submitBulkEntries"
:disabled="bulkRows.filter(r => r.project_id && r.hours > 0).length === 0"
class="px-4 py-2 bg-accent text-bg-base font-medium rounded-lg hover:bg-accent-hover transition-colors duration-150 disabled:opacity-40 disabled:cursor-not-allowed"
>
Create Entries
</button>
</div>
</div>
</div>
</div>
</Transition>
<AppDiscardDialog :show="showDiscardDialog" @cancel="cancelDiscard" @discard="confirmDiscard" />
<ReceiptLightbox
@@ -742,8 +964,9 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { List as ListIcon, Copy, DollarSign, Receipt, Plus, Pencil, Trash2, Lock, Image, Upload, Clock } from 'lucide-vue-next'
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
import { List as ListIcon, Copy, DollarSign, Receipt, Plus, Pencil, Trash2, Lock, Image, Upload, Clock, Scissors, Download, Play, ChevronDown } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import { invoke, convertFileSrc } from '@tauri-apps/api/core'
import AppNumberInput from '../components/AppNumberInput.vue'
import AppSelect from '../components/AppSelect.vue'
@@ -752,6 +975,7 @@ import AppDateRangePresets from '../components/AppDateRangePresets.vue'
import AppDiscardDialog from '../components/AppDiscardDialog.vue'
import AppTagInput from '../components/AppTagInput.vue'
import EntryTemplatePicker from '../components/EntryTemplatePicker.vue'
import EntrySplitDialog from '../components/EntrySplitDialog.vue'
import ReceiptLightbox from '../components/ReceiptLightbox.vue'
import { useEntriesStore, type TimeEntry } from '../stores/entries'
import { useExpensesStore, EXPENSE_CATEGORIES, type Expense } from '../stores/expenses'
@@ -759,10 +983,12 @@ import { useProjectsStore } from '../stores/projects'
import { useTagsStore } from '../stores/tags'
import { useToastStore } from '../stores/toast'
import { useEntryTemplatesStore } from '../stores/entryTemplates'
import { formatDate, formatCurrency, getCurrencySymbol } from '../utils/locale'
import { useTimerStore } from '../stores/timer'
import { formatDate, formatCurrency, getCurrencySymbol, formatNumber } from '../utils/locale'
import { useSettingsStore } from '../stores/settings'
import { useFormGuard } from '../utils/formGuard'
import { renderMarkdown } from '../utils/markdown'
import { entriesToCSV, downloadCSV } from '../utils/csvExport'
const entriesStore = useEntriesStore()
const expensesStore = useExpensesStore()
@@ -771,6 +997,21 @@ const tagsStore = useTagsStore()
const toast = useToastStore()
const entryTemplatesStore = useEntryTemplatesStore()
const settingsStore = useSettingsStore()
const timerStore = useTimerStore()
const router = useRouter()
function formatRunningDuration(seconds: number): string {
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = seconds % 60
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
}
const showActionsMenu = ref(false)
function closeActionsMenu() { showActionsMenu.value = false }
onMounted(() => document.addEventListener('click', closeActionsMenu))
onBeforeUnmount(() => document.removeEventListener('click', closeActionsMenu))
const showTemplatePicker = ref(false)
const showReceiptLightbox = ref(false)
const receiptLightboxData = ref({ imageUrl: '', description: '', category: '', date: '', amount: '' })
@@ -1101,6 +1342,151 @@ async function duplicateEntry(entry: TimeEntry) {
await loadTaskMap()
}
function replayEntry(entry: TimeEntry) {
if (!timerStore.isStopped) {
toast.info('Timer is already running. Stop it first.')
return
}
timerStore.setProject(entry.project_id)
timerStore.setTask(entry.task_id || null)
timerStore.setDescription(entry.description || '')
timerStore.setBillable(entry.billable ?? 1)
timerStore.start()
router.push('/timer')
}
// Entry split
const splitTarget = ref<TimeEntry | null>(null)
const showSplitDialog = ref(false)
function openSplitDialog(entry: TimeEntry) {
splitTarget.value = entry
showSplitDialog.value = true
}
async function handleSplit({ splitSeconds, descriptionB }: { splitSeconds: number; descriptionB: string }) {
const entry = splitTarget.value!
const startMs = new Date(entry.start_time).getTime()
const entryA: TimeEntry = {
...entry,
duration: splitSeconds,
end_time: new Date(startMs + splitSeconds * 1000).toISOString(),
}
const entryB: TimeEntry = {
project_id: entry.project_id,
task_id: entry.task_id,
description: descriptionB,
start_time: new Date(startMs + splitSeconds * 1000).toISOString(),
end_time: entry.end_time,
duration: entry.duration - splitSeconds,
billable: entry.billable,
}
await entriesStore.updateEntry(entryA)
const newId = await entriesStore.createEntry(entryB)
if (newId && entry.id) {
const tags = entryTags.value[entry.id]
if (tags?.length) {
await tagsStore.setEntryTags(newId, tags)
}
}
toast.success('Entry split into two')
showSplitDialog.value = false
splitTarget.value = null
await entriesStore.fetchEntriesPaginated(startDate.value || undefined, endDate.value || undefined)
await loadEntryTags()
}
// CSV export
async function exportEntriesCSV() {
const csv = entriesToCSV(
entriesStore.entries,
(id) => getProjectName(id),
(projectId) => {
const project = projectsStore.projects.find(p => p.id === projectId)
if (!project?.client_id) return ''
return ''
},
(taskId) => getTaskName(taskId),
(entryId) => {
const tags = entryTags.value[entryId]
if (!tags?.length) return ''
return tags.map(id => tagsStore.tags.find(t => t.id === id)?.name || '').filter(Boolean).join('; ')
},
)
const ok = await downloadCSV(csv, `zeroclock-entries-${new Date().toISOString().split('T')[0]}.csv`)
if (ok) toast.success('Entries exported')
}
// Bulk change project
const showBulkProjectPicker = ref(false)
async function bulkChangeProject(projectId: number | null) {
if (!projectId) return
const ids = Array.from(selectedIds.value)
try {
await invoke('bulk_update_entries_project', { ids, projectId })
for (const entry of entriesStore.entries) {
if (entry.id && ids.includes(entry.id)) entry.project_id = projectId
}
toast.success(`Updated ${ids.length} entries`)
clearSelection()
showBulkProjectPicker.value = false
} catch {
toast.error('Failed to change project')
}
}
// =============================================
// BULK ADD
// =============================================
const showBulkAdd = ref(false)
const bulkRows = ref<Array<{ date: string; project_id: number; description: string; hours: number }>>([])
function openBulkAdd() {
const today = new Date().toISOString().split('T')[0]
bulkRows.value = [
{ date: today, project_id: 0, description: '', hours: 0 },
{ date: today, project_id: 0, description: '', hours: 0 },
{ date: today, project_id: 0, description: '', hours: 0 },
]
showBulkAdd.value = true
}
async function submitBulkEntries() {
const valid = bulkRows.value.filter(r => r.project_id && r.hours > 0)
if (valid.length === 0) return
let created = 0
for (const row of valid) {
const rowStart = new Date(`${row.date}T09:00:00`)
const durationSeconds = Math.round(row.hours * 3600)
try {
await entriesStore.createEntry({
project_id: row.project_id,
description: row.description || undefined,
start_time: rowStart.toISOString(),
end_time: new Date(rowStart.getTime() + durationSeconds * 1000).toISOString(),
duration: durationSeconds,
billable: 1,
})
created++
} catch (error) {
const msg = String(error)
if (msg.includes('locked week')) {
toast.error('Cannot add entries to a locked week')
} else {
toast.error('Failed to create entry')
}
}
}
if (created > 0) {
toast.success(`Created ${created} entries`)
await entriesStore.fetchEntriesPaginated(startDate.value || undefined, endDate.value || undefined)
await loadEntryTags()
await loadTaskMap()
}
showBulkAdd.value = false
}
// Copy yesterday's entries to today
async function copyPreviousDay() {
const yesterday = new Date()

View File

@@ -33,6 +33,113 @@
>
Create
</button>
<button
id="tab-recurring"
role="tab"
:aria-selected="view === 'recurring'"
aria-controls="tabpanel-recurring"
:tabindex="view === 'recurring' ? 0 : -1"
@click="view = 'recurring'; loadRecurringInvoices()"
class="pb-2 text-[0.8125rem] transition-colors duration-150 -mb-px"
:class="view === 'recurring'
? 'text-text-primary border-b-2 border-accent'
: 'text-text-tertiary hover:text-text-secondary'"
>
Recurring
</button>
<button
id="tab-pipeline"
role="tab"
:aria-selected="view === 'pipeline'"
aria-controls="tabpanel-pipeline"
:tabindex="view === 'pipeline' ? 0 : -1"
@click="view = 'pipeline'"
class="pb-2 text-[0.8125rem] transition-colors duration-150 -mb-px"
:class="view === 'pipeline'
? 'text-text-primary border-b-2 border-accent'
: 'text-text-tertiary hover:text-text-secondary'"
>
Pipeline
</button>
</div>
<!-- Recurring View -->
<div v-if="view === 'recurring'" id="tabpanel-recurring" role="tabpanel" aria-labelledby="tab-recurring">
<div class="flex items-center justify-between mb-4">
<p class="text-[0.75rem] text-text-secondary">Automatically create draft invoices on a schedule.</p>
<button
@click="openRecurringDialog(null)"
class="px-3 py-1.5 bg-accent text-bg-base text-xs font-medium rounded-lg hover:bg-accent-hover transition-colors duration-150"
>
+ Add
</button>
</div>
<div v-if="recurringInvoices.length > 0" class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b border-border-subtle bg-bg-surface">
<th class="px-4 py-3 text-left text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Client</th>
<th class="px-4 py-3 text-left text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Frequency</th>
<th class="px-4 py-3 text-left text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Next Due</th>
<th class="px-4 py-3 text-center text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Status</th>
<th class="px-4 py-3 w-20"><span class="sr-only">Actions</span></th>
</tr>
</thead>
<tbody>
<tr
v-for="ri in recurringInvoices"
:key="ri.id"
class="border-b border-border-subtle last:border-0"
>
<td class="px-4 py-3 text-[0.75rem] text-text-primary">{{ getClientName(ri.client_id) }}</td>
<td class="px-4 py-3 text-[0.75rem] text-text-secondary capitalize">{{ ri.recurrence_rule }}</td>
<td class="px-4 py-3 text-[0.75rem] text-text-secondary">{{ formatDate(ri.next_due_date) }}</td>
<td class="px-4 py-3 text-center">
<span
class="inline-block px-2 py-0.5 text-[0.6875rem] font-medium rounded-full"
:class="ri.enabled ? 'bg-green-500/10 text-green-400' : 'bg-bg-elevated text-text-tertiary'"
>
{{ ri.enabled ? 'Active' : 'Paused' }}
</span>
</td>
<td class="px-4 py-3">
<div class="flex items-center justify-end gap-1">
<button
@click="openRecurringDialog(ri)"
class="p-1.5 text-text-tertiary hover:text-text-secondary transition-colors"
aria-label="Edit recurring invoice"
v-tooltip="'Edit'"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
@click="deleteRecurring(ri.id)"
class="p-1.5 text-text-tertiary hover:text-status-error transition-colors"
aria-label="Delete recurring invoice"
v-tooltip="'Delete'"
>
<X class="w-3.5 h-3.5" :stroke-width="1.5" aria-hidden="true" />
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="flex flex-col items-center justify-center py-16">
<FileText class="w-12 h-12 text-text-tertiary" :stroke-width="1.5" aria-hidden="true" />
<p class="text-sm text-text-secondary mt-4">No recurring invoices</p>
<p class="text-xs text-text-tertiary mt-2 max-w-xs text-center">Set up automatic invoice creation for clients on a regular schedule.</p>
</div>
</div>
<!-- Pipeline View -->
<div v-if="view === 'pipeline'" id="tabpanel-pipeline" role="tabpanel" aria-labelledby="tab-pipeline">
<InvoicePipelineView @open="openInvoiceFromPipeline" />
</div>
<!-- List View -->
@@ -95,25 +202,35 @@
<div class="flex items-center justify-end gap-1">
<button
v-if="invoice.status === 'draft'"
@click="invoicesStore.updateStatus(invoice.id!, 'sent')"
@click="markInvoiceSent(invoice.id!)"
class="px-2 py-1 text-[0.625rem] font-medium text-blue-400 border border-blue-400/30 rounded hover:bg-blue-400/10 transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
:aria-label="'Mark invoice ' + invoice.invoice_number + ' as sent'"
>
Mark Sent
</button>
<button
v-if="invoice.status === 'sent' || invoice.status === 'overdue'"
@click="invoicesStore.updateStatus(invoice.id!, 'paid')"
v-if="invoice.status === 'sent' || invoice.status === 'overdue' || invoice.status === 'partial'"
@click="openPaymentDialog(invoice)"
class="px-2 py-1 text-[0.625rem] font-medium text-green-400 border border-green-400/30 rounded hover:bg-green-400/10 transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
:aria-label="'Mark invoice ' + invoice.invoice_number + ' as paid'"
:aria-label="'Record payment for invoice ' + invoice.invoice_number"
>
Mark Paid
Record Payment
</button>
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity duration-100">
<div class="flex items-center gap-1">
<button
v-if="invoice.status !== 'draft'"
@click="emailInvoice(invoice)"
class="p-1.5 text-text-tertiary hover:text-text-secondary transition-colors duration-150"
:aria-label="'Email invoice ' + invoice.invoice_number"
v-tooltip="'Email'"
>
<Mail class="h-3.5 w-3.5" :stroke-width="1.5" aria-hidden="true" />
</button>
<button
@click="viewInvoice(invoice)"
class="p-1.5 text-text-tertiary hover:text-text-secondary transition-colors duration-150"
aria-label="View invoice"
v-tooltip="'View'"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
@@ -124,6 +241,7 @@
@click="exportPDF(invoice)"
class="p-1.5 text-text-tertiary hover:text-text-secondary transition-colors duration-150"
aria-label="Export PDF"
v-tooltip="'Export PDF'"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
@@ -133,6 +251,7 @@
@click="confirmDelete(invoice)"
class="p-1.5 text-text-tertiary hover:text-status-error transition-colors duration-150"
aria-label="Delete invoice"
v-tooltip="'Delete'"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
@@ -338,7 +457,7 @@
/>
</div>
<div v-if="importPreview" aria-live="polite" class="text-[0.6875rem] text-text-secondary ml-auto">
{{ importPreview.count }} entries, {{ importPreview.hours.toFixed(1) }} hours, {{ formatCurrency(importPreview.amount) }}
{{ importPreview.count }} entries, {{ formatNumber(importPreview.hours, 1) }} hours, {{ formatCurrency(importPreview.amount) }}
</div>
</div>
</div>
@@ -347,8 +466,8 @@
<thead>
<tr class="border-b border-border-subtle bg-bg-inset">
<th class="px-4 py-2 text-left text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Description</th>
<th class="px-4 py-2 text-right text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium w-20">Qty</th>
<th class="px-4 py-2 text-right text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium w-28">Rate</th>
<th class="px-4 py-2 text-center text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium w-36">Qty</th>
<th class="px-4 py-2 text-center text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium w-40">Rate</th>
<th class="px-4 py-2 text-right text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium w-28">Amount</th>
<th class="px-4 py-2 w-10"></th>
</tr>
@@ -369,23 +488,24 @@
/>
</td>
<td class="px-4 py-1.5">
<input
v-model.number="item.quantity"
type="number"
min="0"
step="0.01"
class="w-full px-2 py-1 bg-transparent border-0 text-[0.75rem] text-text-primary text-right font-mono focus:outline-none focus:bg-bg-inset rounded"
aria-label="Quantity"
<AppNumberInput
v-model="item.quantity"
:min="0"
:step="0.25"
:precision="2"
label="Quantity"
compact
/>
</td>
<td class="px-4 py-1.5">
<input
v-model.number="item.unit_price"
type="number"
min="0"
step="0.01"
class="w-full px-2 py-1 bg-transparent border-0 text-[0.75rem] text-text-primary text-right font-mono focus:outline-none focus:bg-bg-inset rounded"
aria-label="Unit price"
<AppNumberInput
v-model="item.unit_price"
:min="0"
:step="1"
:precision="2"
:prefix="getCurrencySymbol()"
label="Unit price"
compact
/>
</td>
<td class="px-4 py-1.5 text-right text-[0.75rem] font-mono text-text-primary">
@@ -397,6 +517,7 @@
@click="lineItems.splice(i, 1)"
class="p-1 text-text-tertiary hover:text-status-error transition-colors"
:aria-label="'Remove line item ' + (i + 1)"
v-tooltip="'Remove'"
>
<X class="w-3.5 h-3.5" :stroke-width="1.5" aria-hidden="true" />
</button>
@@ -503,6 +624,7 @@
@click="handlePickerBack"
class="p-1.5 text-text-tertiary hover:text-text-secondary transition-colors"
aria-label="Back to invoice list"
v-tooltip="'Back'"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
@@ -536,7 +658,7 @@
<!-- Left: Template list -->
<div class="w-56 border-r border-border-subtle overflow-y-auto bg-bg-surface shrink-0">
<div v-for="cat in TEMPLATE_CATEGORIES" :key="cat.id">
<div class="text-[0.5625rem] text-text-tertiary uppercase tracking-[0.1em] font-medium px-3 pt-3 pb-1">
<div class="text-[0.5625rem] text-text-tertiary uppercase tracking-[0.08em] font-medium px-3 pt-3 pb-1">
{{ cat.label }}
</div>
<button
@@ -591,7 +713,7 @@
</button>
<button
@click="handleDelete"
class="px-4 py-2 border border-status-error text-status-error font-medium rounded-lg hover:bg-status-error/10 transition-colors duration-150"
class="px-4 py-2 border border-status-error text-status-error-text font-medium rounded-lg hover:bg-status-error/10 transition-colors duration-150"
>
Delete
</button>
@@ -599,18 +721,207 @@
</div>
</div>
</Transition>
<!-- Payment Dialog -->
<Transition name="modal">
<div
v-if="showPaymentDialog"
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
@click.self="showPaymentDialog = false"
>
<div class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-md p-6" role="dialog" aria-modal="true" aria-labelledby="payment-dialog-title">
<h2 id="payment-dialog-title" class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-1">Record Payment</h2>
<p class="text-[0.75rem] text-text-secondary mb-4">
{{ paymentInvoice?.invoice_number }} - {{ formatCurrency(paymentInvoice?.total || 0) }} total, {{ formatCurrency(paymentRemaining) }} remaining
</p>
<!-- Existing payments -->
<div v-if="invoicePayments.length > 0" class="mb-4 space-y-1.5">
<p class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em]">Previous payments</p>
<div
v-for="pmt in invoicePayments"
:key="pmt.id"
class="flex items-center justify-between px-3 py-1.5 bg-bg-inset rounded-lg"
>
<div class="flex items-center gap-2">
<span class="text-[0.75rem] font-mono text-accent-text">{{ formatCurrency(pmt.amount) }}</span>
<span class="text-[0.625rem] text-text-tertiary">{{ formatDate(pmt.date) }}</span>
<span v-if="pmt.method" class="text-[0.625rem] text-text-tertiary capitalize">{{ pmt.method }}</span>
</div>
<button
@click="deletePayment(pmt.id)"
class="p-1 text-text-tertiary hover:text-status-error transition-colors"
:aria-label="'Delete payment of ' + formatCurrency(pmt.amount)"
v-tooltip="'Delete'"
>
<X class="w-3 h-3" :stroke-width="1.5" aria-hidden="true" />
</button>
</div>
</div>
<form @submit.prevent="submitPayment" class="space-y-3">
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Amount *</label>
<AppNumberInput
v-model="paymentForm.amount"
:min="0"
:step="1"
:precision="2"
:prefix="getCurrencySymbol()"
/>
<button
v-if="paymentRemaining > 0"
type="button"
@click="paymentForm.amount = paymentRemaining"
class="mt-1 text-[0.625rem] text-accent-text hover:underline"
>
Pay remaining {{ formatCurrency(paymentRemaining) }}
</button>
</div>
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Date</label>
<AppDatePicker v-model="paymentForm.date" placeholder="Payment date" />
</div>
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Method</label>
<AppSelect
v-model="paymentForm.method"
:options="paymentMethods"
label-key="label"
value-key="value"
placeholder="Select method"
:placeholder-value="''"
/>
</div>
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Notes</label>
<input
v-model="paymentForm.notes"
type="text"
class="w-full px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary focus:outline-none focus:border-border-visible"
placeholder="Optional payment note"
/>
</div>
<div class="flex justify-end gap-3 pt-2">
<button
type="button"
@click="showPaymentDialog = false"
class="px-4 py-2 border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated transition-colors duration-150"
>
Close
</button>
<button
type="submit"
:disabled="!paymentForm.amount || paymentForm.amount <= 0"
class="px-4 py-2 bg-accent text-bg-base font-medium rounded-lg hover:bg-accent-hover transition-colors duration-150 disabled:opacity-40 disabled:cursor-not-allowed"
>
Record Payment
</button>
</div>
</form>
</div>
</div>
</Transition>
<!-- Recurring Invoice Dialog -->
<Transition name="modal">
<div
v-if="showRecurringDialog"
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
@click.self="showRecurringDialog = false"
>
<div class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-md p-6" role="dialog" aria-modal="true" aria-labelledby="recurring-dialog-title">
<h2 id="recurring-dialog-title" class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-4">
{{ editingRecurring ? 'Edit Recurring Invoice' : 'New Recurring Invoice' }}
</h2>
<form @submit.prevent="submitRecurring" class="space-y-4">
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Client *</label>
<AppSelect
v-model="recurringForm.client_id"
:options="clientsStore.clients"
label-key="name"
value-key="id"
placeholder="Select a client"
:placeholder-value="0"
/>
</div>
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Frequency *</label>
<AppSelect
v-model="recurringForm.recurrence_rule"
:options="recurrenceOptions"
label-key="label"
value-key="value"
placeholder="Select frequency"
:placeholder-value="''"
/>
</div>
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Next Due Date *</label>
<AppDatePicker v-model="recurringForm.next_due_date" placeholder="Next due date" />
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Tax Rate (%)</label>
<AppNumberInput v-model="recurringForm.tax_rate" :min="0" :max="100" :step="0.5" :precision="2" suffix="%" />
</div>
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Discount</label>
<AppNumberInput v-model="recurringForm.discount" :min="0" :step="1" :precision="2" :prefix="getCurrencySymbol()" />
</div>
</div>
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Notes</label>
<input
v-model="recurringForm.notes"
type="text"
class="w-full px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary focus:outline-none focus:border-border-visible"
placeholder="Optional notes for generated invoices"
/>
</div>
<div class="flex items-center gap-2">
<input
id="recurring-enabled"
v-model="recurringForm.enabled"
type="checkbox"
class="w-4 h-4 rounded border-border-subtle text-accent focus:ring-accent"
/>
<label for="recurring-enabled" class="text-[0.8125rem] text-text-primary">Enabled</label>
</div>
<div class="flex justify-end gap-3 pt-2">
<button
type="button"
@click="showRecurringDialog = false"
class="px-4 py-2 border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated transition-colors duration-150"
>
Cancel
</button>
<button
type="submit"
:disabled="!recurringForm.client_id || !recurringForm.recurrence_rule || !recurringForm.next_due_date"
class="px-4 py-2 bg-accent text-bg-base font-medium rounded-lg hover:bg-accent-hover transition-colors duration-150 disabled:opacity-40 disabled:cursor-not-allowed"
>
{{ editingRecurring ? 'Update' : 'Create' }}
</button>
</div>
</form>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { FileText, X, Receipt, Clock } from 'lucide-vue-next'
import { FileText, X, Receipt, Clock, Mail } from 'lucide-vue-next'
import { invoke } from '@tauri-apps/api/core'
import { save } from '@tauri-apps/plugin-dialog'
import AppNumberInput from '../components/AppNumberInput.vue'
import AppSelect from '../components/AppSelect.vue'
import AppDatePicker from '../components/AppDatePicker.vue'
import InvoicePreview from '../components/InvoicePreview.vue'
import InvoicePipelineView from '../components/InvoicePipelineView.vue'
import { getTemplateById, getTemplatesByCategory, loadTemplates, TEMPLATE_CATEGORIES } from '../utils/invoiceTemplates'
import type { BusinessInfo } from '../utils/invoicePdfRenderer'
import { useInvoicesStore, type Invoice, type InvoiceItem } from '../stores/invoices'
@@ -620,7 +931,7 @@ import { useSettingsStore } from '../stores/settings'
import { useToastStore } from '../stores/toast'
import { generateInvoicePdf } from '../utils/invoicePdf'
import { useExpensesStore } from '../stores/expenses'
import { formatDate, formatCurrency, getCurrencySymbol } from '../utils/locale'
import { formatDate, formatCurrency, formatNumber, getCurrencySymbol } from '../utils/locale'
import { useFocusTrap } from '../utils/focusTrap'
interface LineItem {
@@ -640,7 +951,7 @@ const expensesStore = useExpensesStore()
const importedExpenseIds = ref<number[]>([])
// View state
const view = ref<'list' | 'create' | 'template-picker'>('list')
const view = ref<'list' | 'create' | 'template-picker' | 'pipeline' | 'recurring'>('list')
const pickerInvoiceId = ref<number | null>(null)
// Dialog state
@@ -654,6 +965,7 @@ const statusOptions = [
{ label: 'All', value: 'all' },
{ label: 'Draft', value: 'draft' },
{ label: 'Sent', value: 'sent' },
{ label: 'Partial', value: 'partial' },
{ label: 'Paid', value: 'paid' },
{ label: 'Overdue', value: 'overdue' },
]
@@ -667,6 +979,7 @@ function getStatusClass(status: string): string {
switch (status) {
case 'draft': return 'bg-bg-elevated text-text-tertiary'
case 'sent': return 'bg-blue-500/10 text-blue-400'
case 'partial': return 'bg-amber-500/10 text-amber-400'
case 'paid': return 'bg-green-500/10 text-green-400'
case 'overdue': return 'bg-red-500/10 text-red-400'
default: return 'bg-bg-elevated text-text-tertiary'
@@ -740,6 +1053,10 @@ watch(view, (newView, oldView) => {
})
// Arrow key navigation for tabs
function openInvoiceFromPipeline(_id: number) {
view.value = 'list'
}
function onTabKeydown(e: KeyboardEvent) {
const tabs: Array<'list' | 'create'> = ['list', 'create']
const current = tabs.indexOf(view.value as 'list' | 'create')
@@ -827,7 +1144,7 @@ async function importFromProject() {
if (totalHours > 0) {
lineItems.value.push({
description: `${project.name} - ${totalHours.toFixed(1)}h tracked`,
description: `${project.name} - ${formatNumber(totalHours, 1)}h tracked`,
quantity: parseFloat(totalHours.toFixed(2)),
unit_price: project.hourly_rate
})
@@ -888,7 +1205,16 @@ function calculateTotal(): number {
return calculateSubtotal() + calculateTax() - createForm.discount
}
// View invoice → open template picker
async function markInvoiceSent(id: number) {
try {
await invoicesStore.updateStatus(id, 'sent')
} catch (e) {
console.error('Failed to mark invoice as sent:', e)
toastStore.error('Failed to mark invoice as sent')
}
}
// View invoice -> open template picker
async function viewInvoice(invoice: Invoice) {
selectedInvoice.value = invoice
pickerInvoiceId.value = invoice.id!
@@ -897,6 +1223,7 @@ async function viewInvoice(invoice: Invoice) {
previewItems.value = invoice.id ? await invoicesStore.getInvoiceItems(invoice.id) : []
} catch (e) {
console.error('Failed to load invoice items:', e)
toastStore.error('Failed to load invoice items')
previewItems.value = []
}
view.value = 'template-picker'
@@ -917,6 +1244,224 @@ async function handleDelete() {
invoiceToDelete.value = null
}
// =============================================
// PAYMENTS
// =============================================
interface InvoicePayment {
id: number
invoice_id: number
amount: number
date: string
method: string
notes: string
}
const showPaymentDialog = ref(false)
const paymentInvoice = ref<Invoice | null>(null)
const invoicePayments = ref<InvoicePayment[]>([])
const paymentForm = reactive({
amount: 0,
date: new Date().toISOString().split('T')[0],
method: '',
notes: '',
})
const paymentMethods = [
{ label: 'Bank Transfer', value: 'bank_transfer' },
{ label: 'Credit Card', value: 'credit_card' },
{ label: 'PayPal', value: 'paypal' },
{ label: 'Check', value: 'check' },
{ label: 'Cash', value: 'cash' },
{ label: 'Other', value: 'other' },
]
const paymentRemaining = computed(() => {
if (!paymentInvoice.value) return 0
const paid = invoicePayments.value.reduce((sum, p) => sum + p.amount, 0)
return Math.max(0, paymentInvoice.value.total - paid)
})
async function openPaymentDialog(invoice: Invoice) {
paymentInvoice.value = invoice
paymentForm.amount = 0
paymentForm.date = new Date().toISOString().split('T')[0]
paymentForm.method = ''
paymentForm.notes = ''
try {
invoicePayments.value = await invoke<InvoicePayment[]>('get_invoice_payments', { invoiceId: invoice.id })
} catch {
invoicePayments.value = []
}
showPaymentDialog.value = true
}
async function submitPayment() {
if (!paymentInvoice.value?.id || !paymentForm.amount || paymentForm.amount <= 0) return
try {
await invoke('add_invoice_payment', {
payment: {
id: 0,
invoice_id: paymentInvoice.value.id,
amount: paymentForm.amount,
date: paymentForm.date,
method: paymentForm.method,
notes: paymentForm.notes,
}
})
// Refresh payments and invoice list
invoicePayments.value = await invoke<InvoicePayment[]>('get_invoice_payments', { invoiceId: paymentInvoice.value.id })
await invoicesStore.fetchInvoices()
// Update local reference
const updated = invoicesStore.invoices.find(i => i.id === paymentInvoice.value?.id)
if (updated) paymentInvoice.value = updated
// Reset form for next payment
paymentForm.amount = 0
paymentForm.notes = ''
toastStore.success('Payment recorded')
} catch (e) {
console.error('Failed to record payment:', e)
toastStore.error('Failed to record payment')
}
}
async function deletePayment(paymentId: number) {
if (!paymentInvoice.value?.id) return
try {
await invoke('delete_invoice_payment', { id: paymentId, invoiceId: paymentInvoice.value.id })
invoicePayments.value = await invoke<InvoicePayment[]>('get_invoice_payments', { invoiceId: paymentInvoice.value.id })
await invoicesStore.fetchInvoices()
const updated = invoicesStore.invoices.find(i => i.id === paymentInvoice.value?.id)
if (updated) paymentInvoice.value = updated
toastStore.success('Payment deleted')
} catch (e) {
console.error('Failed to delete payment:', e)
toastStore.error('Failed to delete payment')
}
}
// =============================================
// RECURRING INVOICES
// =============================================
interface RecurringInvoice {
id: number
client_id: number
template_id: string
line_items_json: string
tax_rate: number
discount: number
notes: string
recurrence_rule: string
next_due_date: string
enabled: boolean
}
const recurringInvoices = ref<RecurringInvoice[]>([])
const showRecurringDialog = ref(false)
const editingRecurring = ref<RecurringInvoice | null>(null)
const recurringForm = reactive({
client_id: 0,
recurrence_rule: '',
next_due_date: '',
tax_rate: 0,
discount: 0,
notes: '',
enabled: true,
})
const recurrenceOptions = [
{ label: 'Weekly', value: 'weekly' },
{ label: 'Biweekly', value: 'biweekly' },
{ label: 'Monthly', value: 'monthly' },
{ label: 'Quarterly', value: 'quarterly' },
{ label: 'Yearly', value: 'yearly' },
]
async function loadRecurringInvoices() {
try {
recurringInvoices.value = await invoke<RecurringInvoice[]>('get_recurring_invoices')
} catch {
recurringInvoices.value = []
}
}
function openRecurringDialog(ri: RecurringInvoice | null) {
editingRecurring.value = ri
if (ri) {
recurringForm.client_id = ri.client_id
recurringForm.recurrence_rule = ri.recurrence_rule
recurringForm.next_due_date = ri.next_due_date
recurringForm.tax_rate = ri.tax_rate
recurringForm.discount = ri.discount
recurringForm.notes = ri.notes
recurringForm.enabled = ri.enabled
} else {
recurringForm.client_id = 0
recurringForm.recurrence_rule = ''
recurringForm.next_due_date = ''
recurringForm.tax_rate = 0
recurringForm.discount = 0
recurringForm.notes = ''
recurringForm.enabled = true
}
showRecurringDialog.value = true
}
async function submitRecurring() {
if (!recurringForm.client_id || !recurringForm.recurrence_rule || !recurringForm.next_due_date) return
const payload = {
id: editingRecurring.value?.id || 0,
client_id: recurringForm.client_id,
template_id: editingRecurring.value?.template_id || 'clean',
line_items_json: editingRecurring.value?.line_items_json || '[]',
tax_rate: recurringForm.tax_rate,
discount: recurringForm.discount,
notes: recurringForm.notes,
recurrence_rule: recurringForm.recurrence_rule,
next_due_date: recurringForm.next_due_date,
enabled: recurringForm.enabled,
}
try {
if (editingRecurring.value) {
await invoke('update_recurring_invoice', { invoice: payload })
} else {
await invoke('create_recurring_invoice', { invoice: payload })
}
await loadRecurringInvoices()
showRecurringDialog.value = false
toastStore.success(editingRecurring.value ? 'Recurring invoice updated' : 'Recurring invoice created')
} catch (e) {
console.error('Failed to save recurring invoice:', e)
toastStore.error('Failed to save recurring invoice')
}
}
async function deleteRecurring(id: number) {
try {
await invoke('delete_recurring_invoice', { id })
await loadRecurringInvoices()
toastStore.success('Recurring invoice deleted')
} catch (e) {
console.error('Failed to delete recurring invoice:', e)
toastStore.error('Failed to delete recurring invoice')
}
}
// Handle create
async function handleCreate() {
if (!createForm.client_id) {
@@ -995,12 +1540,17 @@ async function handleCreate() {
// Template picker actions
async function handlePickerSave() {
if (pickerInvoiceId.value) {
await invoicesStore.updateInvoiceTemplate(pickerInvoiceId.value, selectedTemplateId.value)
try {
if (pickerInvoiceId.value) {
await invoicesStore.updateInvoiceTemplate(pickerInvoiceId.value, selectedTemplateId.value)
}
pickerInvoiceId.value = null
selectedInvoice.value = null
view.value = 'list'
} catch (e) {
console.error('Failed to save template:', e)
toastStore.error('Failed to save invoice template')
}
pickerInvoiceId.value = null
selectedInvoice.value = null
view.value = 'list'
}
async function handlePickerExport() {
@@ -1043,6 +1593,32 @@ async function exportPDF(invoice: Invoice) {
}
}
// Email invoice via system mail client
function emailInvoice(invoice: Invoice) {
const client = clientsStore.clients.find(c => c.id === invoice.client_id)
if (!client) {
toastStore.error('Client not found for invoice')
return
}
if (!client.email) {
toastStore.info('No email address set for this client - add one in the Clients page')
return
}
const subject = encodeURIComponent(`Invoice ${invoice.invoice_number}`)
const body = encodeURIComponent(
`Hi ${client.name},\n\n` +
`Please find attached invoice ${invoice.invoice_number} for ${formatCurrency(invoice.total)}.\n\n` +
(invoice.due_date ? `Payment is due by ${invoice.due_date}.\n\n` : '') +
`Thank you for your business.\n\n` +
`${settingsStore.settings.business_name || ''}`
)
window.open(`mailto:${client.email}?subject=${subject}&body=${body}`)
toastStore.info('Mail client opened - export the PDF and attach it to the email')
}
// Load data on mount
onMounted(async () => {
await Promise.all([
@@ -1052,5 +1628,17 @@ onMounted(async () => {
settingsStore.fetchSettings(),
loadTemplates(),
])
// Auto-create any due recurring invoices
try {
const today = new Date().toISOString().split('T')[0]
const created = await invoke<number>('check_recurring_invoices', { today })
if (created > 0) {
await invoicesStore.fetchInvoices()
toastStore.info(`${created} recurring invoice(s) created`)
}
} catch {
// Ignore - recurring check is best-effort
}
})
</script>

View File

@@ -1,25 +1,120 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { useTimerStore } from '../stores/timer'
import { useProjectsStore } from '../stores/projects'
import { emit, listen } from '@tauri-apps/api/event'
import type { UnlistenFn } from '@tauri-apps/api/event'
import { Square, Maximize2 } from 'lucide-vue-next'
import { loadAndApplyTimerFont } from '../utils/fonts'
import { loadAndApplyUIFont } from '../utils/uiFonts'
import { useAnnouncer } from '../composables/useAnnouncer'
const timerStore = useTimerStore()
const projectsStore = useProjectsStore()
interface TimerSyncPayload {
projectId: number | null
timerState: string
elapsedSeconds: number
}
const projectId = ref<number | null>(null)
const timerState = ref('STOPPED')
const elapsedSeconds = ref(0)
const projects = ref<Array<{ id: number; name: string; color: string }>>([])
const theme = ref<{ theme_mode?: string; accent_color?: string }>({})
const { announcement: miniAnnouncement, announce } = useAnnouncer()
let previousTimerState = 'STOPPED'
let unlisten: UnlistenFn | null = null
const isRunning = computed(() => timerState.value === 'RUNNING')
const isPausedManual = computed(() => timerState.value === 'PAUSED_MANUAL')
const isActive = computed(() => timerState.value !== 'STOPPED')
const formattedTime = computed(() => {
const h = Math.floor(elapsedSeconds.value / 3600)
const m = Math.floor((elapsedSeconds.value % 3600) / 60)
const s = elapsedSeconds.value % 60
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
})
const projectName = computed(() => {
if (!timerStore.selectedProjectId) return 'No project'
return projectsStore.projects.find(p => p.id === timerStore.selectedProjectId)?.name || 'Unknown'
if (!projectId.value) return 'No project'
return projects.value.find(p => p.id === projectId.value)?.name || 'Unknown'
})
const projectColor = computed(() => {
if (!timerStore.selectedProjectId) return '#6B7280'
return projectsStore.projects.find(p => p.id === timerStore.selectedProjectId)?.color || '#6B7280'
if (!projectId.value) return '#6B7280'
return projects.value.find(p => p.id === projectId.value)?.color || '#6B7280'
})
onMounted(async () => {
// Fetch projects and settings for display
try {
const [p, s] = await Promise.all([
invoke<Array<{ id: number; name: string; color: string }>>('get_projects'),
invoke<Record<string, string>>('get_settings'),
])
projects.value = p
theme.value = s
loadAndApplyTimerFont(s.timer_font || 'JetBrains Mono')
const dyslexia = s.dyslexia_mode === 'true'
const uiFont = dyslexia ? 'OpenDyslexic' : (s.ui_font || 'Inter')
if (uiFont !== 'Inter') loadAndApplyUIFont(uiFont)
} catch (e) {
console.error('Mini-timer init error:', e)
}
// Apply theme
const el = document.documentElement
const mode = theme.value.theme_mode || 'dark'
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', theme.value.accent_color || 'amber')
// Listen for timer state from main window
unlisten = await listen<TimerSyncPayload>('timer-sync', (event) => {
const newState = event.payload.timerState
if (newState !== previousTimerState) {
if (newState === 'RUNNING' && previousTimerState !== 'STOPPED') {
announce('Timer resumed')
} else if (newState === 'RUNNING') {
announce('Timer started')
} else if (newState === 'PAUSED_MANUAL') {
announce('Timer paused')
} else if (newState === 'PAUSED_IDLE') {
announce('Idle detected - timer paused')
} else if (newState === 'PAUSED_APP') {
announce('App not visible - timer paused')
} else if (newState === 'STOPPED' && previousTimerState !== 'STOPPED') {
announce('Timer stopped')
}
previousTimerState = newState
}
projectId.value = event.payload.projectId
timerState.value = event.payload.timerState
elapsedSeconds.value = event.payload.elapsedSeconds
})
// Request current state
await emit('mini-timer-ready')
})
onUnmounted(() => {
if (unlisten) unlisten()
})
async function stopTimer() {
await timerStore.stop()
await emit('mini-timer-stop')
}
async function pauseTimer() {
await emit('mini-timer-pause')
}
async function resumeTimer() {
await emit('mini-timer-resume')
}
async function expandToMain() {
@@ -33,23 +128,54 @@ async function expandToMain() {
<template>
<div class="h-full w-full flex items-center gap-3 px-4 bg-bg-base select-none" style="-webkit-app-region: drag">
<span class="w-2.5 h-2.5 rounded-full shrink-0" :style="{ backgroundColor: projectColor }" />
<span class="w-2.5 h-2.5 rounded-full shrink-0" :style="{ backgroundColor: projectColor }" aria-hidden="true" />
<span class="text-[0.75rem] text-text-primary truncate flex-1">{{ projectName }}</span>
<span class="text-[1rem] font-mono text-accent-text font-medium tracking-tight">{{ timerStore.formattedTime }}</span>
<span role="timer" :aria-label="'Elapsed time: ' + formattedTime" class="text-[1rem] font-[family-name:var(--font-timer)] text-accent-text font-medium tracking-tight">{{ formattedTime }}</span>
<div class="flex items-center gap-1" style="-webkit-app-region: no-drag">
<!-- Pause button (when running) -->
<button
v-if="timerStore.isRunning"
v-if="isRunning"
@click="pauseTimer"
class="w-6 h-6 flex items-center justify-center rounded text-text-tertiary hover:text-status-warning transition-colors"
aria-label="Pause"
v-tooltip="'Pause'"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-3 h-3" aria-hidden="true">
<rect x="3" y="2" width="3.5" height="12" rx="1" />
<rect x="9.5" y="2" width="3.5" height="12" rx="1" />
</svg>
</button>
<!-- Resume button (when manually paused) -->
<button
v-else-if="isPausedManual"
@click="resumeTimer"
class="w-6 h-6 flex items-center justify-center rounded text-text-tertiary hover:text-accent-text transition-colors"
aria-label="Resume"
v-tooltip="'Resume'"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-3 h-3" aria-hidden="true">
<path d="M4 2.5a.5.5 0 01.77-.42l9 5.5a.5.5 0 010 .84l-9 5.5A.5.5 0 014 13.5V2.5z" />
</svg>
</button>
<!-- Stop button -->
<button
v-if="isActive"
@click="stopTimer"
class="w-6 h-6 flex items-center justify-center rounded-full bg-status-error hover:bg-status-error/80 transition-colors"
aria-label="Stop timer"
v-tooltip="'Stop'"
>
<Square class="w-2.5 h-2.5 text-white" fill="white" />
<Square class="w-2.5 h-2.5 text-white" fill="white" aria-hidden="true" />
</button>
<button
@click="expandToMain"
class="w-6 h-6 flex items-center justify-center rounded text-text-tertiary hover:text-text-secondary transition-colors"
aria-label="Expand to main window"
v-tooltip="'Expand'"
>
<Maximize2 class="w-3 h-3" />
<Maximize2 class="w-3 h-3" aria-hidden="true" />
</button>
</div>
<div class="sr-only" aria-live="assertive" aria-atomic="true">{{ miniAnnouncement }}</div>
</div>
</template>

320
src/views/ProjectDetail.vue Normal file
View File

@@ -0,0 +1,320 @@
<template>
<div class="p-6">
<!-- Back link + Header -->
<div class="flex items-center gap-3 mb-6">
<router-link
to="/projects"
class="p-1.5 text-text-tertiary hover:text-text-primary transition-colors rounded-lg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
aria-label="Back to projects"
v-tooltip="'Back to projects'"
>
<ArrowLeft class="w-4 h-4" :stroke-width="2" aria-hidden="true" />
</router-link>
<div
class="w-3 h-3 rounded-full shrink-0"
:style="{ backgroundColor: project?.color || '#6B7280' }"
aria-hidden="true"
/>
<h1 class="text-[1.75rem] font-bold font-[family-name:var(--font-heading)] tracking-tight text-text-primary">
{{ project?.name || 'Project' }}
</h1>
<span
v-if="project?.archived"
class="text-[0.625rem] font-medium text-text-tertiary bg-bg-elevated px-2 py-0.5 rounded-full"
>
Archived
</span>
<div class="ml-auto flex items-center gap-2">
<button
@click="$router.push({ path: '/projects', query: { edit: String(project?.id) } })"
class="px-3 py-1.5 text-[0.75rem] border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
Edit Project
</button>
</div>
</div>
<div v-if="!project" class="flex flex-col items-center justify-center py-16">
<FolderKanban class="w-12 h-12 text-text-tertiary" :stroke-width="1.5" aria-hidden="true" />
<p class="text-sm text-text-secondary mt-4">Project not found</p>
<router-link to="/projects" class="mt-4 px-4 py-2 bg-accent text-bg-base text-xs font-medium rounded-lg hover:bg-accent-hover transition-colors">
Back to Projects
</router-link>
</div>
<template v-else>
<!-- Summary Cards -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div class="bg-bg-surface border border-border-subtle rounded-lg p-4">
<p class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Client</p>
<p class="text-[0.9375rem] font-semibold text-text-primary">{{ clientName }}</p>
</div>
<div class="bg-bg-surface border border-border-subtle rounded-lg p-4">
<p class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Rate</p>
<p class="text-[0.9375rem] font-semibold text-text-primary">
{{ project.budget_amount ? formatCurrency(project.budget_amount) + ' fixed' : formatCurrency(project.hourly_rate) + '/hr' }}
</p>
</div>
<div class="bg-bg-surface border border-border-subtle rounded-lg p-4">
<p class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Total Hours</p>
<p class="text-[0.9375rem] font-semibold text-text-primary">{{ formatNumber(totalHours, 1) }}h</p>
</div>
<div class="bg-bg-surface border border-border-subtle rounded-lg p-4">
<p class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Revenue</p>
<p class="text-[0.9375rem] font-semibold text-text-primary">{{ formatCurrency(totalRevenue) }}</p>
</div>
</div>
<!-- Budget Progress -->
<div v-if="project.budget_hours && budgetData" class="bg-bg-surface border border-border-subtle rounded-lg p-4 mb-6">
<div class="flex items-center justify-between mb-2">
<p class="text-[0.8125rem] font-medium text-text-primary">Budget Progress</p>
<span class="text-[0.75rem]" :class="budgetPct > 90 ? 'text-status-error-text' : budgetPct > 75 ? 'text-status-warning' : 'text-text-secondary'">
{{ formatNumber(budgetData.hours_used, 1) }}h / {{ project.budget_hours }}h ({{ formatNumber(budgetPct, 0) }}%)
</span>
</div>
<div class="w-full bg-bg-elevated rounded-full h-2" role="progressbar" :aria-valuenow="Math.round(budgetPct)" aria-valuemin="0" aria-valuemax="100" :aria-label="'Budget progress: ' + formatNumber(budgetPct, 0) + '%'">
<div
class="h-2 rounded-full transition-all duration-500"
:class="budgetPct > 90 ? 'bg-status-error' : budgetPct > 75 ? 'bg-status-warning' : 'bg-accent'"
:style="{ width: Math.min(budgetPct, 100) + '%' }"
/>
</div>
<p v-if="budgetData.hours_remaining !== null" class="text-[0.6875rem] text-text-tertiary mt-1.5">
{{ formatNumber(budgetData.hours_remaining, 1) }}h remaining
<template v-if="budgetData.pace"> - {{ budgetData.pace === 'ahead' ? 'ahead of schedule' : budgetData.pace === 'on_track' ? 'on track' : budgetData.pace === 'behind' ? 'behind schedule' : budgetData.pace }}</template>
</p>
</div>
<!-- Two-column: Tasks + Notes -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<!-- Tasks / Estimates vs Actuals -->
<div class="bg-bg-surface border border-border-subtle rounded-lg p-4">
<h2 class="text-[0.8125rem] font-medium text-text-primary mb-3">Tasks</h2>
<div v-if="taskActualsData.length > 0" class="space-y-2">
<div
v-for="ta in taskActualsData"
:key="ta.task_id"
class="flex items-center gap-3 px-3 py-2 bg-bg-inset rounded-lg"
>
<span class="text-[0.75rem] text-text-primary truncate flex-1 min-w-0">{{ ta.task_name }}</span>
<span class="text-[0.6875rem] font-mono text-accent-text shrink-0">{{ formatNumber(ta.actual_hours, 1) }}h</span>
<template v-if="ta.estimated_hours">
<span class="text-[0.6875rem] text-text-tertiary shrink-0">of {{ formatNumber(ta.estimated_hours, 1) }}h</span>
<div class="w-16 bg-bg-elevated rounded-full h-1.5 shrink-0" role="progressbar" :aria-valuenow="Math.round(ta.progress || 0)" aria-valuemin="0" aria-valuemax="100">
<div
class="h-1.5 rounded-full"
:class="(ta.progress || 0) > 100 ? 'bg-status-error' : 'bg-accent'"
:style="{ width: Math.min(ta.progress || 0, 100) + '%' }"
/>
</div>
</template>
<span
v-if="ta.hourly_rate"
class="text-[0.625rem] text-text-tertiary shrink-0"
>{{ formatCurrency(ta.hourly_rate) }}/hr</span>
</div>
</div>
<div v-else-if="tasks.length > 0" class="space-y-1.5">
<div v-for="task in tasks" :key="task.id" class="flex items-center gap-2 px-3 py-2 bg-bg-inset rounded-lg">
<span class="text-[0.75rem] text-text-primary truncate flex-1">{{ task.name }}</span>
<span v-if="task.estimated_hours" class="text-[0.625rem] text-text-tertiary">{{ task.estimated_hours }}h est</span>
</div>
</div>
<p v-else class="text-[0.75rem] text-text-tertiary">No tasks defined for this project.</p>
</div>
<!-- Notes -->
<div class="bg-bg-surface border border-border-subtle rounded-lg p-4">
<h2 class="text-[0.8125rem] font-medium text-text-primary mb-3">Notes</h2>
<p v-if="project.notes" class="text-[0.75rem] text-text-secondary whitespace-pre-wrap leading-relaxed">{{ project.notes }}</p>
<p v-else class="text-[0.75rem] text-text-tertiary">No notes for this project.</p>
</div>
</div>
<!-- Recent Entries -->
<div class="bg-bg-surface border border-border-subtle rounded-lg p-4 mb-6">
<div class="flex items-center justify-between mb-3">
<h2 class="text-[0.8125rem] font-medium text-text-primary">Recent Entries</h2>
<span class="text-[0.6875rem] text-text-tertiary">{{ projectEntries.length }} entries</span>
</div>
<div v-if="projectEntries.length > 0" class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b border-border-subtle">
<th class="px-3 py-2 text-left text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Date</th>
<th class="px-3 py-2 text-left text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Task</th>
<th class="px-3 py-2 text-left text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Description</th>
<th class="px-3 py-2 text-right text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Duration</th>
</tr>
</thead>
<tbody>
<tr
v-for="entry in projectEntries.slice(0, 50)"
:key="entry.id"
class="border-b border-border-subtle last:border-0 hover:bg-bg-elevated transition-colors"
>
<td class="px-3 py-2 text-[0.75rem] text-text-primary whitespace-nowrap">{{ formatDate(entry.start_time) }}</td>
<td class="px-3 py-2 text-[0.75rem] text-text-secondary">{{ getTaskName(entry.task_id) || '-' }}</td>
<td class="px-3 py-2 text-[0.75rem] text-text-secondary truncate max-w-[300px]">{{ entry.description || '-' }}</td>
<td class="px-3 py-2 text-right text-[0.75rem] font-mono text-accent-text whitespace-nowrap">{{ formatDuration(entry.duration) }}</td>
</tr>
</tbody>
</table>
</div>
<p v-else class="text-[0.75rem] text-text-tertiary">No time entries for this project yet.</p>
</div>
<!-- Tracked Apps -->
<div v-if="trackedApps.length > 0" class="bg-bg-surface border border-border-subtle rounded-lg p-4">
<h2 class="text-[0.8125rem] font-medium text-text-primary mb-3">Tracked Apps</h2>
<div class="flex flex-wrap gap-2">
<div
v-for="app in trackedApps"
:key="app.id"
class="flex items-center gap-2 px-3 py-1.5 bg-bg-inset rounded-lg"
>
<img v-if="app.icon" :src="app.icon" class="w-4 h-4 shrink-0" alt="" />
<div v-else class="w-4 h-4 shrink-0 rounded bg-bg-elevated" />
<span class="text-[0.75rem] text-text-primary">{{ app.display_name || app.exe_name }}</span>
</div>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ArrowLeft, FolderKanban } from 'lucide-vue-next'
import { invoke } from '@tauri-apps/api/core'
import { useProjectsStore, type Project, type Task } from '../stores/projects'
import { useClientsStore } from '../stores/clients'
import { formatCurrency, formatNumber, formatDate } from '../utils/locale'
const route = useRoute()
const projectsStore = useProjectsStore()
const clientsStore = useClientsStore()
const project = ref<Project | null>(null)
const tasks = ref<Task[]>([])
const projectEntries = ref<Array<{ id: number; task_id?: number; description?: string; start_time: string; duration: number; billable: number }>>([])
const trackedApps = ref<Array<{ id: number; exe_name: string; exe_path?: string; display_name?: string; icon?: string }>>([])
interface TaskActual {
task_id: number
task_name: string
estimated_hours: number | null
hourly_rate: number | null
actual_hours: number
variance: number | null
progress: number | null
}
interface BudgetStatus {
hours_used: number
amount_used: number
budget_hours: number | null
budget_amount: number | null
percent_hours: number | null
percent_amount: number | null
daily_average_hours: number
estimated_completion_days: number | null
hours_remaining: number | null
pace: string | null
}
const taskActualsData = ref<TaskActual[]>([])
const budgetData = ref<BudgetStatus | null>(null)
const clientName = computed(() => {
if (!project.value?.client_id) return 'No client'
const client = clientsStore.clients.find(c => c.id === project.value!.client_id)
return client?.name || 'Unknown client'
})
const totalHours = computed(() => {
return projectEntries.value.reduce((sum, e) => sum + e.duration, 0) / 3600
})
const totalRevenue = computed(() => {
if (!project.value) return 0
if (project.value.budget_amount) return project.value.budget_amount
return totalHours.value * project.value.hourly_rate
})
const budgetPct = computed(() => {
if (!project.value?.budget_hours || !budgetData.value) return 0
return (budgetData.value.hours_used / project.value.budget_hours) * 100
})
const taskMap = ref<Map<number, string>>(new Map())
function getTaskName(taskId?: number): string {
if (!taskId) return ''
return taskMap.value.get(taskId) || ''
}
function formatDuration(seconds: number): string {
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (hours > 0) return `${hours}h ${minutes}m`
return `${minutes}m`
}
onMounted(async () => {
const projectId = Number(route.params.id)
if (!projectId || isNaN(projectId)) return
await Promise.all([
projectsStore.fetchProjects(),
clientsStore.fetchClients(),
])
project.value = projectsStore.projects.find(p => p.id === projectId) || null
if (!project.value) return
// Load tasks
tasks.value = await projectsStore.fetchTasks(projectId)
const map = new Map<number, string>()
for (const t of tasks.value) {
if (t.id) map.set(t.id, t.name)
}
taskMap.value = map
// Load entries for this project
try {
const allEntries = await invoke<any[]>('get_time_entries', {})
projectEntries.value = allEntries
.filter((e: any) => e.project_id === projectId)
.sort((a: any, b: any) => new Date(b.start_time).getTime() - new Date(a.start_time).getTime())
} catch {
projectEntries.value = []
}
// Load task actuals
try {
taskActualsData.value = await invoke<TaskActual[]>('get_task_actuals', { projectId })
} catch {
taskActualsData.value = []
}
// Load budget status
if (project.value.budget_hours) {
try {
const today = new Date().toISOString().split('T')[0]
budgetData.value = await invoke<BudgetStatus>('get_project_budget_status', { projectId, today })
} catch {
budgetData.value = null
}
}
// Load tracked apps
try {
trackedApps.value = await invoke<any[]>('get_tracked_apps', { projectId })
} catch {
trackedApps.value = []
}
})
</script>

File diff suppressed because it is too large Load Diff

View File

@@ -46,6 +46,12 @@
>
Export CSV
</button>
<button
@click="exportPDF"
class="px-4 py-2 border border-border-visible text-text-primary text-xs rounded-lg hover:bg-bg-elevated transition-colors duration-150"
>
Export PDF
</button>
</div>
<!-- Tab Buttons -->
@@ -220,17 +226,25 @@
<template v-if="activeTab === 'profitability'">
<div id="tabpanel-profitability" role="tabpanel" aria-labelledby="tab-profitability" tabindex="0">
<!-- Summary Stats -->
<dl class="grid grid-cols-3 gap-6 mb-8">
<dl class="grid grid-cols-3 lg:grid-cols-5 gap-6 mb-4">
<div>
<dt class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Total Revenue</dt>
<dt class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Revenue</dt>
<dd class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ formatCurrency(profitTotalRevenue) }}</dd>
</div>
<div>
<dt class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Total Hours</dt>
<dd class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ profitTotalHours.toFixed(1) }}h</dd>
<dt class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Expenses</dt>
<dd class="text-[1.25rem] font-[family-name:var(--font-heading)] text-status-error-text font-medium">{{ formatCurrency(profitTotalExpenses) }}</dd>
</div>
<div>
<dt class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Avg Hourly Rate</dt>
<dt class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Net Profit</dt>
<dd class="text-[1.25rem] font-[family-name:var(--font-heading)] font-medium" :class="profitTotalNet >= 0 ? 'text-status-running' : 'text-status-error-text'">{{ formatCurrency(profitTotalNet) }}</dd>
</div>
<div>
<dt class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Hours</dt>
<dd class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ formatNumber(profitTotalHours, 1) }}h</dd>
</div>
<div>
<dt class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Avg Rate</dt>
<dd class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ formatCurrency(profitAvgRate) }}</dd>
</div>
</dl>
@@ -255,13 +269,15 @@
<div v-if="profitabilityData.length > 0" class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b border-border-subtle">
<th class="text-left text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] py-2 pr-4">Project</th>
<th class="text-left text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] py-2 pr-4">Client</th>
<th class="text-right text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] py-2 pr-4">Hours</th>
<th class="text-right text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] py-2 pr-4">Rate</th>
<th class="text-right text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] py-2 pr-4">Revenue</th>
<th class="text-right text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] py-2">Budget %</th>
<tr class="border-b border-border-subtle bg-bg-surface">
<th class="px-4 py-3 text-left text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Project</th>
<th class="px-4 py-3 text-left text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Client</th>
<th class="px-4 py-3 text-right text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Hours</th>
<th class="px-4 py-3 text-right text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Rate</th>
<th class="px-4 py-3 text-right text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Revenue</th>
<th class="px-4 py-3 text-right text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Expenses</th>
<th class="px-4 py-3 text-right text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Net Profit</th>
<th class="px-4 py-3 text-right text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Budget %</th>
</tr>
</thead>
<tbody>
@@ -270,13 +286,15 @@
:key="row.project_name"
class="border-b border-border-subtle last:border-0"
>
<td class="py-3 pr-4 text-[0.8125rem] text-text-primary">{{ row.project_name }}</td>
<td class="py-3 pr-4 text-[0.75rem] text-text-secondary">{{ row.client_name || '-' }}</td>
<td class="py-3 pr-4 text-right text-[0.75rem] font-mono text-text-primary">{{ row.total_hours.toFixed(1) }}h</td>
<td class="py-3 pr-4 text-right text-[0.75rem] font-mono text-text-secondary">{{ formatCurrency(row.hourly_rate) }}</td>
<td class="py-3 pr-4 text-right text-[0.75rem] font-mono text-accent-text">{{ formatCurrency(row.revenue) }}</td>
<td class="py-3 text-right text-[0.75rem] font-mono" :class="row.budget_used_pct != null && row.budget_used_pct > 100 ? 'text-status-error' : 'text-text-secondary'">
{{ row.budget_used_pct != null ? row.budget_used_pct.toFixed(0) + '%' : '-' }}
<td class="px-4 py-3 text-[0.8125rem] text-text-primary">{{ row.project_name }}</td>
<td class="px-4 py-3 text-[0.75rem] text-text-secondary">{{ row.client_name || '-' }}</td>
<td class="px-4 py-3 text-right text-[0.75rem] font-mono text-text-primary">{{ formatNumber(row.total_hours, 1) }}h</td>
<td class="px-4 py-3 text-right text-[0.75rem] font-mono text-text-secondary">{{ formatCurrency(row.hourly_rate) }}</td>
<td class="px-4 py-3 text-right text-[0.75rem] font-mono text-accent-text">{{ formatCurrency(row.revenue) }}</td>
<td class="px-4 py-3 text-right text-[0.75rem] font-mono text-text-secondary">{{ row.expenses > 0 ? formatCurrency(row.expenses) : '-' }}</td>
<td class="px-4 py-3 text-right text-[0.75rem] font-mono" :class="row.net_profit >= 0 ? 'text-status-running' : 'text-status-error-text'">{{ formatCurrency(row.net_profit) }}</td>
<td class="px-4 py-3 text-right text-[0.75rem] font-mono" :class="row.budget_used_pct != null && row.budget_used_pct > 100 ? 'text-status-error-text' : 'text-text-secondary'">
{{ row.budget_used_pct != null ? formatNumber(row.budget_used_pct, 0) + '%' : '-' }}
</td>
</tr>
</tbody>
@@ -334,12 +352,12 @@
v-for="(value, hourIdx) in dayData"
:key="'c-' + dayIdx + '-' + hourIdx"
role="gridcell"
:aria-label="dayNames[dayIdx] + ' ' + hourIdx + ':00 - ' + (value > 0 ? value.toFixed(1) + ' hours' : 'no activity')"
:aria-label="dayNames[dayIdx] + ' ' + hourIdx + ':00 - ' + (value > 0 ? formatNumber(value, 1) + ' hours' : 'no activity')"
class="p-1 text-center text-[0.5625rem] rounded-sm transition-colors"
:style="{ backgroundColor: getHeatColor(value) }"
:class="value > 0 ? 'text-text-primary' : 'text-transparent'"
>
{{ value > 0 ? value.toFixed(1) : '' }}
{{ value > 0 ? formatNumber(value, 1) : '' }}
</div>
</template>
</div>
@@ -359,7 +377,7 @@
<th scope="row" class="px-2 py-1 text-left text-text-secondary">{{ dayNames[dayIdx] }}</th>
<td v-for="(value, hourIdx) in dayData" :key="hourIdx" class="px-1 py-1 text-center"
:class="value > 0 ? 'text-accent-text' : 'text-text-tertiary'">
{{ value > 0 ? value.toFixed(1) : '-' }}
{{ value > 0 ? formatNumber(value, 1) : '-' }}
</td>
</tr>
</tbody>
@@ -368,13 +386,13 @@
<!-- Empty state -->
<div v-if="patternsLoaded && !hasPatternData" class="py-12 text-center">
<Grid3x3 class="w-10 h-10 text-text-tertiary mx-auto mb-3" :stroke-width="1.5" aria-hidden="true" />
<Grid3x3 class="w-12 h-12 text-text-tertiary mx-auto animate-float mb-4" :stroke-width="1.5" aria-hidden="true" />
<p class="text-[0.8125rem] text-text-secondary">No time data for the selected period</p>
</div>
<!-- Loading state before patterns computed -->
<div v-if="!patternsLoaded" class="py-12 text-center">
<Grid3x3 class="w-10 h-10 text-text-tertiary mx-auto mb-3" :stroke-width="1.5" aria-hidden="true" />
<Grid3x3 class="w-12 h-12 text-text-tertiary mx-auto animate-float mb-4" :stroke-width="1.5" aria-hidden="true" />
<p class="text-[0.8125rem] text-text-secondary">Select a date range and click Generate to see patterns</p>
</div>
</div>
@@ -420,7 +438,7 @@
<span class="text-[0.8125rem] text-text-primary capitalize">{{ cat.category }}</span>
<div class="flex items-center gap-4">
<span class="text-[0.75rem] font-mono text-accent-text">{{ formatCurrency(cat.amount) }}</span>
<span class="text-[0.75rem] font-mono text-text-tertiary w-12 text-right">{{ cat.percentage.toFixed(0) }}%</span>
<span class="text-[0.75rem] font-mono text-text-tertiary w-12 text-right">{{ formatNumber(cat.percentage, 0) }}%</span>
</div>
</div>
<div class="w-full bg-bg-elevated rounded-full h-[2px]">
@@ -463,7 +481,7 @@
</div>
<div class="flex items-center gap-4">
<span class="text-[0.75rem] font-mono text-accent-text">{{ formatCurrency(proj.amount) }}</span>
<span class="text-[0.75rem] font-mono text-text-tertiary w-12 text-right">{{ proj.percentage.toFixed(0) }}%</span>
<span class="text-[0.75rem] font-mono text-text-tertiary w-12 text-right">{{ formatNumber(proj.percentage, 0) }}%</span>
</div>
</div>
<div class="w-full bg-bg-elevated rounded-full h-[2px]">
@@ -514,11 +532,22 @@ import { useEntriesStore } from '../stores/entries'
import { useExpensesStore, type Expense } from '../stores/expenses'
import { useProjectsStore } from '../stores/projects'
import { useSettingsStore } from '../stores/settings'
import { formatCurrency } from '../utils/locale'
import { formatCurrency, formatNumber } from '../utils/locale'
import { getChartTheme, buildBarChartOptions } from '../utils/chartTheme'
import { useOnboardingStore } from '../stores/onboarding'
import { useTourStore } from '../stores/tour'
import { TOURS } from '../utils/tours'
import { save } from '@tauri-apps/plugin-dialog'
import {
generateHoursReportPdf,
generateProfitabilityReportPdf,
generateExpensesReportPdf,
generatePatternsReportPdf,
type HoursReportData,
type ProfitabilityReportData,
type ExpenseReportData,
type PatternsReportData,
} from '../utils/reportPdf'
// Register Chart.js components
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend)
@@ -550,6 +579,8 @@ interface ProfitabilityRow {
revenue: number
budget_hours: number | null
budget_used_pct: number | null
expenses: number
net_profit: number
}
const activeTab = ref<'hours' | 'profitability' | 'expenses' | 'patterns'>('hours')
@@ -756,7 +787,7 @@ const chartOptions = computed(() => {
callbacks: {
label: (context: { raw: unknown }) => {
const hours = context.raw as number
return `${hours.toFixed(1)} hours`
return `${formatNumber(hours, 1)} hours`
}
}
}
@@ -848,6 +879,8 @@ async function fetchReport() {
// Profitability summary stats
const profitTotalRevenue = computed(() => profitabilityData.value.reduce((sum, r) => sum + r.revenue, 0))
const profitTotalExpenses = computed(() => profitabilityData.value.reduce((sum, r) => sum + (r.expenses || 0), 0))
const profitTotalNet = computed(() => profitabilityData.value.reduce((sum, r) => sum + (r.net_profit ?? r.revenue), 0))
const profitTotalHours = computed(() => profitabilityData.value.reduce((sum, r) => sum + r.total_hours, 0))
const profitAvgRate = computed(() => profitTotalHours.value > 0 ? profitTotalRevenue.value / profitTotalHours.value : 0)
@@ -1105,6 +1138,97 @@ function exportCSV() {
URL.revokeObjectURL(url)
}
// Export to PDF
async function exportPDF() {
try {
let doc
const dateRange = { start: startDate.value, end: endDate.value }
if (activeTab.value === 'hours') {
if (!reportData.value.byProject || reportData.value.byProject.length === 0) {
toastStore.info('No data to export')
return
}
const data: HoursReportData = {
dateRange,
totalHours: filteredTotalSeconds.value / 3600,
totalEarnings: filteredTotalEarnings.value,
billableHours: billableSeconds.value / 3600,
nonBillableHours: nonBillableSeconds.value / 3600,
projects: filteredByProject.value.map((p: ProjectReport) => ({
name: getProjectName(p.project_id),
color: getProjectColor(p.project_id),
hours: p.total_seconds / 3600,
rate: getProjectRate(p.project_id),
earnings: (p.total_seconds / 3600) * getProjectRate(p.project_id),
percentage: getProjectPercentage(p.total_seconds),
})),
}
doc = generateHoursReportPdf(data)
} else if (activeTab.value === 'profitability') {
if (profitabilityData.value.length === 0) {
toastStore.info('No data to export')
return
}
const data: ProfitabilityReportData = {
dateRange,
totalRevenue: profitTotalRevenue.value,
totalExpenses: profitTotalExpenses.value,
totalNet: profitTotalNet.value,
totalHours: profitTotalHours.value,
avgRate: profitAvgRate.value,
rows: profitabilityData.value,
}
doc = generateProfitabilityReportPdf(data)
} else if (activeTab.value === 'expenses') {
if (expensesData.value.length === 0) {
toastStore.info('No data to export')
return
}
const data: ExpenseReportData = {
dateRange,
totalAmount: expTotalAmount.value,
invoicedAmount: expInvoicedAmount.value,
uninvoicedAmount: expUninvoicedAmount.value,
byCategory: expByCategory.value,
byProject: expByProject.value.map(p => ({
name: getProjectName(p.project_id),
color: getProjectColor(p.project_id),
amount: p.amount,
percentage: p.percentage,
})),
}
doc = generateExpensesReportPdf(data)
} else {
if (!hasPatternData.value) {
toastStore.info('No data to export')
return
}
const data: PatternsReportData = {
dateRange,
heatmap: heatmapData.value,
peakDay: peakDay.value || '-',
peakHour: peakHour.value || '-',
}
doc = generatePatternsReportPdf(data)
}
const tabName = activeTab.value
const filePath = await save({
defaultPath: `${tabName}-report-${startDate.value}-to-${endDate.value}.pdf`,
filters: [{ name: 'PDF', extensions: ['pdf'] }],
})
if (!filePath) return
const pdfBytes = doc.output('arraybuffer')
await invoke('save_binary_file', { path: filePath, data: Array.from(new Uint8Array(pdfBytes)) })
toastStore.success('Report PDF exported')
} catch (e) {
console.error('Failed to export PDF:', e)
toastStore.error('Failed to export PDF')
}
}
// Recreate charts on theme or accent change
watch(() => settingsStore.settings.theme_mode, () => {
nextTick(() => { refreshChartTheme() })

View File

@@ -32,85 +32,93 @@
<!-- Content pane -->
<div class="flex-1 p-6 overflow-y-auto">
<div class="max-w-2xl">
<Transition name="fade" mode="out-in" :duration="{ enter: 200, leave: 150 }">
<div :key="activeTab">
<!-- General -->
<div v-if="activeTab === 'general'" id="tabpanel-general" role="tabpanel" aria-labelledby="tab-general" tabindex="0">
<h2 class="text-[1.25rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-6">General</h2>
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">UI Scale</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Adjust the interface zoom level</p>
</div>
<div class="flex items-center gap-2">
<button
@click="decreaseZoom"
aria-label="Decrease zoom"
class="w-8 h-8 flex items-center justify-center border border-border-visible rounded-lg text-text-secondary hover:text-text-primary hover:bg-bg-elevated transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
:disabled="zoomLevel <= 80"
>
<Minus class="w-3.5 h-3.5" aria-hidden="true" />
</button>
<span class="w-12 text-center text-sm font-mono text-text-primary" role="status" aria-live="polite">{{ zoomLevel }}%</span>
<button
@click="increaseZoom"
aria-label="Increase zoom"
class="w-8 h-8 flex items-center justify-center border border-border-visible rounded-lg text-text-secondary hover:text-text-primary hover:bg-bg-elevated transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
:disabled="zoomLevel >= 150"
>
<Plus class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</div>
</div>
<div class="flex items-center justify-between mt-5">
<div>
<p class="text-[0.8125rem] text-text-primary">Locale</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Date and number formatting</p>
</div>
<div class="w-56">
<AppSelect
v-model="locale"
:options="LOCALES"
label-key="name"
value-key="code"
placeholder="System Default"
:placeholder-value="'system'"
:searchable="true"
@update:model-value="saveLocaleSettings"
/>
</div>
</div>
<div class="flex items-center justify-between mt-5">
<div>
<p class="text-[0.8125rem] text-text-primary">Currency</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Currency symbol and formatting</p>
</div>
<div class="w-56">
<AppSelect
v-model="currency"
:options="currencyOptions"
label-key="name"
value-key="code"
placeholder="US Dollar"
:placeholder-value="'USD'"
:searchable="true"
@update:model-value="saveLocaleSettings"
/>
</div>
</div>
<div class="border-t border-border-subtle mt-5 pt-5">
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">Theme</p>
<p class="text-[0.8125rem] text-text-primary">UI Scale</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Adjust the interface zoom level</p>
</div>
<div class="flex items-center gap-2">
<button
v-tooltip="'Decrease zoom'"
@click="decreaseZoom"
aria-label="Decrease zoom"
class="w-8 h-8 flex items-center justify-center border border-border-visible rounded-lg text-text-secondary hover:text-text-primary hover:bg-bg-elevated transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
:disabled="zoomLevel <= 80"
>
<Minus class="w-3.5 h-3.5" aria-hidden="true" />
</button>
<span class="w-12 text-center text-sm font-mono text-text-primary" role="status" aria-live="polite">{{ zoomLevel }}%</span>
<button
v-tooltip="'Increase zoom'"
@click="increaseZoom"
aria-label="Increase zoom"
class="w-8 h-8 flex items-center justify-center border border-border-visible rounded-lg text-text-secondary hover:text-text-primary hover:bg-bg-elevated transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
:disabled="zoomLevel >= 150"
>
<Plus class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</div>
</div>
<div class="flex items-center justify-between">
<div>
<p id="label-locale" class="text-[0.8125rem] text-text-primary">Locale</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Date and number formatting</p>
</div>
<div class="w-56">
<AppSelect
v-model="locale"
aria-labelledby="label-locale"
:options="LOCALES"
label-key="name"
value-key="code"
placeholder="System Default"
:placeholder-value="'system'"
:searchable="true"
@update:model-value="saveLocaleSettings"
/>
</div>
</div>
<div class="flex items-center justify-between">
<div>
<p id="label-currency" class="text-[0.8125rem] text-text-primary">Currency</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Currency symbol and formatting</p>
</div>
<div class="w-56">
<AppSelect
v-model="currency"
aria-labelledby="label-currency"
:options="currencyOptions"
label-key="name"
value-key="code"
placeholder="US Dollar"
:placeholder-value="'USD'"
:searchable="true"
@update:model-value="saveLocaleSettings"
/>
</div>
</div>
<div class="border-t border-border-subtle" />
<div class="flex items-center justify-between">
<div>
<p id="label-theme" class="text-[0.8125rem] text-text-primary">Theme</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Light or dark appearance</p>
</div>
<div class="w-40">
<div class="w-48">
<AppSelect
v-model="themeMode"
aria-labelledby="label-theme"
:options="themeModes"
label-key="label"
value-key="value"
@@ -121,7 +129,7 @@
</div>
</div>
<div class="flex items-center justify-between mt-5">
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">Accent Color</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Primary interface color</p>
@@ -139,17 +147,18 @@
/>
</div>
</div>
</div>
<div class="border-t border-border-subtle mt-5 pt-5">
<div class="border-t border-border-subtle" />
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">Reduce motion</p>
<p id="label-reduce-motion" class="text-[0.8125rem] text-text-primary">Reduce motion</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Control animation behavior</p>
</div>
<div class="w-40">
<div class="w-48">
<AppSelect
v-model="reduceMotion"
aria-labelledby="label-reduce-motion"
:options="reduceMotionOptions"
label-key="label"
value-key="value"
@@ -159,9 +168,9 @@
/>
</div>
</div>
</div>
<div class="border-t border-border-subtle mt-5 pt-5">
<div class="border-t border-border-subtle" />
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">Dyslexia-friendly mode</p>
@@ -186,14 +195,15 @@
</button>
</div>
<div class="flex items-center justify-between mt-5">
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">UI Font</p>
<p id="label-ui-font" class="text-[0.8125rem] text-text-primary">UI Font</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Choose a font for the interface</p>
</div>
<div class="w-56">
<AppSelect
v-model="uiFont"
aria-labelledby="label-ui-font"
:options="UI_FONTS"
label-key="label"
value-key="value"
@@ -215,9 +225,9 @@
</AppSelect>
</div>
</div>
</div>
<div class="border-t border-border-subtle mt-5 pt-5">
<div class="border-t border-border-subtle" />
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">Sound effects</p>
@@ -242,15 +252,16 @@
</button>
</div>
<div v-if="soundEnabled" class="space-y-5 mt-5">
<div v-if="soundEnabled" class="space-y-4">
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">Sound mode</p>
<p id="label-sound-mode" class="text-[0.8125rem] text-text-primary">Sound mode</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">How sounds are generated</p>
</div>
<div class="w-48">
<AppSelect
v-model="soundMode"
aria-labelledby="label-sound-mode"
:options="soundModes"
label-key="label"
value-key="value"
@@ -310,9 +321,9 @@
</div>
</div>
</div>
</div>
<div class="border-t border-border-subtle mt-5 pt-5">
<div class="border-t border-border-subtle" />
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">Getting Started checklist</p>
@@ -346,9 +357,61 @@
</button>
</div>
</div>
</div>
<div class="border-t border-border-subtle mt-5 pt-5">
<div class="border-t border-border-subtle" />
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">Close to tray</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Hide in system tray instead of quitting when closing the window</p>
</div>
<button
@click="toggleCloseToTray"
role="switch"
:aria-checked="closeToTray"
aria-label="Close to tray"
class="focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
:class="[
'relative inline-flex h-5 w-9 items-center rounded-full transition-colors duration-150',
closeToTray ? 'bg-status-running' : 'bg-bg-elevated'
]"
>
<span
:class="[
'inline-block h-3.5 w-3.5 transform rounded-full bg-text-primary transition-transform duration-150',
closeToTray ? 'translate-x-[18px]' : 'translate-x-[3px]'
]"
/>
</button>
</div>
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">Minimize to tray</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Hide in system tray instead of the taskbar when minimizing</p>
</div>
<button
@click="toggleMinimizeToTray"
role="switch"
:aria-checked="minimizeToTray"
aria-label="Minimize to tray"
class="focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
:class="[
'relative inline-flex h-5 w-9 items-center rounded-full transition-colors duration-150',
minimizeToTray ? 'bg-status-running' : 'bg-bg-elevated'
]"
>
<span
:class="[
'inline-block h-3.5 w-3.5 transform rounded-full bg-text-primary transition-transform duration-150',
minimizeToTray ? 'translate-x-[18px]' : 'translate-x-[3px]'
]"
/>
</button>
</div>
<div class="border-t border-border-subtle" />
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">Persistent notifications</p>
@@ -380,7 +443,7 @@
<div v-if="activeTab === 'timer'" id="tabpanel-timer" role="tabpanel" aria-labelledby="tab-timer" tabindex="0">
<h2 class="text-[1.25rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-6">Timer</h2>
<div class="space-y-5">
<div class="space-y-4">
<!-- Idle Detection toggle -->
<div class="flex items-center justify-between">
<div>
@@ -407,7 +470,7 @@
</div>
<!-- Idle sub-settings - progressive disclosure -->
<div v-if="idleDetection" class="space-y-5 pl-4 border-l-2 border-border-subtle ml-1">
<div v-if="idleDetection" class="space-y-4 pl-4 border-l-2 border-border-subtle ml-1">
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">Idle Timeout</p>
@@ -446,12 +509,13 @@
<!-- App Tracking Mode -->
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">App Tracking Mode</p>
<p id="label-app-tracking" class="text-[0.8125rem] text-text-primary">App Tracking Mode</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">What happens when a tracked app leaves focus</p>
</div>
<div class="w-48">
<AppSelect
v-model="appTrackingMode"
aria-labelledby="label-app-tracking"
:options="appTrackingModes"
label-key="label"
value-key="value"
@@ -513,12 +577,13 @@
<!-- Timer Font -->
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">Timer Font</p>
<p id="label-timer-font" class="text-[0.8125rem] text-text-primary">Timer Font</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Monospace font for timer display</p>
</div>
<div class="w-56">
<AppSelect
v-model="timerFont"
aria-labelledby="label-timer-font"
:options="timerFontOptions"
label-key="label"
value-key="value"
@@ -623,6 +688,75 @@
<!-- Divider -->
<div class="border-t border-border-subtle" />
<!-- Reminders -->
<h3 class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Reminders</h3>
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">End-of-day reminder</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Get a notification to log your time at the end of the day</p>
</div>
<button
@click="toggleEodReminder"
role="switch"
:aria-checked="eodReminderEnabled"
aria-label="End-of-day reminder"
class="focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
:class="[
'relative inline-flex h-5 w-9 items-center rounded-full transition-colors duration-150',
eodReminderEnabled ? 'bg-status-running' : 'bg-bg-elevated'
]"
>
<span
:class="[
'inline-block h-3.5 w-3.5 transform rounded-full bg-text-primary transition-transform duration-150',
eodReminderEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'
]"
/>
</button>
</div>
<div v-if="eodReminderEnabled" class="flex items-center justify-between pl-4 border-l-2 border-border-subtle ml-1">
<div>
<p class="text-[0.8125rem] text-text-primary">Reminder time</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">When to show the reminder</p>
</div>
<input
v-model="eodReminderTime"
type="time"
class="px-3 py-1.5 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary focus:outline-none focus:border-border-visible"
@change="saveReminderSettings"
/>
</div>
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">Weekly summary</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Show a summary of last week's hours on Monday morning</p>
</div>
<button
@click="toggleWeeklySummary"
role="switch"
:aria-checked="weeklySummaryEnabled"
aria-label="Weekly summary"
class="focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
:class="[
'relative inline-flex h-5 w-9 items-center rounded-full transition-colors duration-150',
weeklySummaryEnabled ? 'bg-status-running' : 'bg-bg-elevated'
]"
>
<span
:class="[
'inline-block h-3.5 w-3.5 transform rounded-full bg-text-primary transition-transform duration-150',
weeklySummaryEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'
]"
/>
</button>
</div>
<!-- Divider -->
<div class="border-t border-border-subtle" />
<!-- Recurring Entries -->
<h3 class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Recurring Entries</h3>
@@ -737,10 +871,9 @@
</div>
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Time of Day</label>
<input
v-model="recTimeOfDay"
type="time"
class="w-full px-3 py-2 bg-bg-base border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary focus:outline-none focus:border-border-visible"
<AppTimePicker
v-model:hour="recTimeHour"
v-model:minute="recTimeMinute"
/>
</div>
</div>
@@ -847,89 +980,88 @@
<div v-if="activeTab === 'billing'" id="tabpanel-billing" role="tabpanel" aria-labelledby="tab-billing" tabindex="0">
<h2 class="text-[1.25rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-6">Billing</h2>
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">Default Hourly Rate</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Applied to new projects</p>
</div>
<AppNumberInput
v-model="hourlyRate"
:min="0"
:step="1"
:precision="2"
:prefix="getCurrencySymbol()"
@update:model-value="saveSettings"
/>
</div>
<!-- Divider -->
<div class="border-t border-border-subtle mt-5 pt-5" />
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">Time Rounding</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Round time entries for billing</p>
</div>
<button
@click="toggleRounding"
role="switch"
:aria-checked="roundingEnabled"
aria-label="Time rounding"
:class="[
'relative inline-flex h-5 w-9 items-center rounded-full transition-colors duration-150',
roundingEnabled ? 'bg-status-running' : 'bg-bg-elevated'
]"
>
<span
:class="[
'inline-block h-3.5 w-3.5 transform rounded-full bg-text-primary transition-transform duration-150',
roundingEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'
]"
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">Default Hourly Rate</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Applied to new projects</p>
</div>
<AppNumberInput
v-model="hourlyRate"
:min="0"
:step="1"
:precision="2"
:prefix="getCurrencySymbol()"
@update:model-value="saveSettings"
/>
</button>
</div>
</div>
<div class="border-t border-border-subtle" />
<div v-if="roundingEnabled" class="space-y-5 pl-4 border-l-2 border-border-subtle ml-1 mt-5">
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">Increment</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Round to nearest increment</p>
<p class="text-[0.8125rem] text-text-primary">Time Rounding</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Round time entries for billing</p>
</div>
<div class="w-36">
<AppSelect
v-model="roundingIncrement"
:options="roundingIncrements"
label-key="label"
value-key="value"
@update:model-value="saveRoundingSettings"
<button
@click="toggleRounding"
role="switch"
:aria-checked="roundingEnabled"
aria-label="Time rounding"
:class="[
'relative inline-flex h-5 w-9 items-center rounded-full transition-colors duration-150',
roundingEnabled ? 'bg-status-running' : 'bg-bg-elevated'
]"
>
<span
:class="[
'inline-block h-3.5 w-3.5 transform rounded-full bg-text-primary transition-transform duration-150',
roundingEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'
]"
/>
</button>
</div>
<div v-if="roundingEnabled" class="space-y-4 pl-4 border-l-2 border-border-subtle ml-1">
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">Increment</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Round to nearest increment</p>
</div>
<div class="w-48">
<AppSelect
v-model="roundingIncrement"
:options="roundingIncrements"
label-key="label"
value-key="value"
@update:model-value="saveRoundingSettings"
/>
</div>
</div>
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">Method</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Rounding direction</p>
</div>
<div class="w-48">
<AppSelect
v-model="roundingMethod"
:options="roundingMethods"
label-key="label"
value-key="value"
@update:model-value="saveRoundingSettings"
/>
</div>
</div>
</div>
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">Method</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Rounding direction</p>
</div>
<div class="w-36">
<AppSelect
v-model="roundingMethod"
:options="roundingMethods"
label-key="label"
value-key="value"
@update:model-value="saveRoundingSettings"
/>
</div>
</div>
</div>
<!-- Divider -->
<div class="border-t border-border-subtle mt-5 pt-5" />
<div class="border-t border-border-subtle" />
<!-- Business Identity -->
<h3 class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium mb-4">Business Identity</h3>
<p class="text-[0.6875rem] text-text-tertiary mb-4">This information appears on your invoices.</p>
<!-- Business Identity -->
<h3 class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Business Identity</h3>
<p class="text-[0.6875rem] text-text-tertiary">This information appears on your invoices.</p>
<div class="space-y-4 max-w-md">
<div class="space-y-4 max-w-md">
<div>
<label for="biz-name" class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Business Name</label>
<input
@@ -995,7 +1127,7 @@
<button
v-if="businessLogo"
@click="removeLogo"
class="px-3 py-1.5 text-[0.75rem] border border-border-subtle text-status-error rounded-lg hover:bg-status-error/10 transition-colors"
class="px-3 py-1.5 text-[0.75rem] border border-border-subtle text-status-error-text rounded-lg hover:bg-status-error/10 transition-colors"
>
Remove
</button>
@@ -1003,6 +1135,7 @@
</div>
<p class="text-[0.625rem] text-text-tertiary mt-1">PNG or JPG, max 200x80px. Appears on invoice header.</p>
</div>
</div>
</div>
</div>
@@ -1183,7 +1316,7 @@
<div v-if="activeTab === 'data'" id="tabpanel-data" role="tabpanel" aria-labelledby="tab-data" tabindex="0">
<h2 class="text-[1.25rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-6">Data</h2>
<div class="space-y-6">
<div class="space-y-4">
<!-- Export -->
<div class="flex items-center justify-between">
<div>
@@ -1235,11 +1368,44 @@
</button>
</div>
<!-- Backup frequency and retention -->
<div v-if="autoBackupEnabled" class="pl-4 border-l-2 border-border-subtle ml-1 space-y-3">
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">Frequency</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">How often to run backups</p>
</div>
<div class="w-36">
<AppSelect
v-model="backupFrequency"
:options="[{id:'daily',name:'Daily'},{id:'weekly',name:'Weekly'}]"
label-key="name"
value-key="id"
@update:model-value="saveBackupFrequency"
/>
</div>
</div>
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">Retention</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Keep last N backups</p>
</div>
<div class="w-28">
<AppNumberInput v-model="backupRetention" :min="1" :max="30" @update:model-value="saveBackupRetention" />
</div>
</div>
</div>
<div class="border-t border-border-subtle" />
<!-- Import Data -->
<div class="mb-8">
<h3 class="text-[0.8125rem] font-medium text-text-primary mb-4">Import Data</h3>
<div>
<div class="flex items-center justify-between mb-4">
<h3 class="text-[0.8125rem] font-medium text-text-primary">Import Data</h3>
<button @click="showJsonImportWizard = true" class="px-3 py-1.5 text-[0.6875rem] border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated transition-colors">
Restore from Backup
</button>
</div>
<div class="flex items-end gap-3 mb-4">
<div class="w-48">
@@ -1288,31 +1454,47 @@
</button>
</div>
<p v-if="importStatus" class="text-[0.75rem] mt-2" :class="importStatus.startsWith('Error') ? 'text-status-error' : 'text-status-running'">
<p v-if="importStatus" class="text-[0.75rem] mt-2" :class="importStatus.startsWith('Error') ? 'text-status-error-text' : 'text-status-running'">
{{ importStatus }}
</p>
</div>
<!-- Danger Zone -->
<div class="mt-8 rounded-xl border border-status-error/20 p-5">
<h3 class="text-xs text-status-error uppercase tracking-[0.08em] font-medium mb-4">Danger Zone</h3>
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">Clear All Data</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Permanently delete all entries, projects, clients, and invoices</p>
<div class="rounded-xl border border-status-error/20 p-5">
<h3 class="text-xs text-status-error-text uppercase tracking-[0.08em] font-medium mb-4">Danger Zone</h3>
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">Load Sample Data</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Populate with demo data for screenshots (clears existing data first)</p>
</div>
<button
@click="showSeedDialog = true"
:disabled="isSeedingData"
class="px-4 py-1.5 border border-status-warning text-status-warning text-[0.8125rem] font-medium rounded-lg hover:bg-status-warning/10 transition-colors duration-150 disabled:opacity-40"
>
{{ isSeedingData ? 'Loading...' : 'Load Demo' }}
</button>
</div>
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">Clear All Data</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Permanently delete all entries, projects, clients, and invoices</p>
</div>
<button
@click="showClearDataDialog = true"
class="px-4 py-1.5 border border-status-error text-status-error-text text-[0.8125rem] font-medium rounded-lg hover:bg-status-error/10 transition-colors duration-150"
>
Clear Data
</button>
</div>
<button
@click="showClearDataDialog = true"
class="px-4 py-1.5 border border-status-error text-status-error text-[0.8125rem] font-medium rounded-lg hover:bg-status-error/10 transition-colors duration-150"
>
Clear Data
</button>
</div>
</div>
</div>
</div>
</div>
</Transition>
</div>
</div>
<!-- Clear Data Confirmation Dialog -->
@@ -1327,7 +1509,7 @@
<p id="clear-data-desc" class="text-[0.75rem] text-text-secondary mb-4">
Are you sure? This action cannot be undone.
</p>
<p class="text-[0.6875rem] text-status-error mb-6">
<p class="text-[0.6875rem] text-status-error-text mb-6">
All time entries, projects, clients, and invoices will be permanently deleted.
</p>
<div class="flex justify-end gap-3">
@@ -1347,6 +1529,45 @@
</div>
</div>
</Transition>
<!-- Seed Data Confirmation Dialog -->
<Transition name="modal">
<div
v-if="showSeedDialog"
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
@click.self="showSeedDialog = false"
>
<div role="alertdialog" aria-modal="true" aria-labelledby="seed-data-title" aria-describedby="seed-data-desc" class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-sm p-6">
<h2 id="seed-data-title" class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-2">Load Sample Data</h2>
<p id="seed-data-desc" class="text-[0.75rem] text-text-secondary mb-4">
This will clear all existing data and replace it with demo content.
</p>
<p class="text-[0.6875rem] text-status-warning mb-6">
All current entries, projects, clients, and invoices will be replaced.
</p>
<div class="flex justify-end gap-3">
<button
@click="showSeedDialog = false"
class="px-4 py-2 border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated transition-colors duration-150"
>
Cancel
</button>
<button
@click="seedSampleData"
class="px-4 py-2 bg-status-warning text-white font-medium rounded-lg hover:bg-status-warning/80 transition-colors duration-150"
>
Load Demo Data
</button>
</div>
</div>
</div>
</Transition>
<JsonImportWizard
:show="showJsonImportWizard"
@close="showJsonImportWizard = false"
@imported="showJsonImportWizard = false"
/>
</div>
</template>
@@ -1363,13 +1584,16 @@ import { useEntryTemplatesStore } from '../stores/entryTemplates'
import AppNumberInput from '../components/AppNumberInput.vue'
import AppSelect from '../components/AppSelect.vue'
import AppShortcutRecorder from '../components/AppShortcutRecorder.vue'
import AppTimePicker from '../components/AppTimePicker.vue'
import { LOCALES, getCurrencies, getCurrencySymbol } from '../utils/locale'
import { parseCSV, mapTogglCSV, mapGenericCSV, type ImportEntry } from '../utils/import'
import { TIMER_FONTS, loadGoogleFont, loadAndApplyTimerFont } from '../utils/fonts'
import { UI_FONTS, loadUIFont } from '../utils/uiFonts'
import { useFocusTrap } from '../utils/focusTrap'
import JsonImportWizard from '../components/JsonImportWizard.vue'
import { audioEngine, SOUND_EVENTS, DEFAULT_EVENTS } from '../utils/audio'
import type { SoundEvent } from '../utils/audio'
import { resetPositionCache } from '../utils/dropdown'
const settingsStore = useSettingsStore()
const toastStore = useToastStore()
@@ -1389,6 +1613,8 @@ const recPattern = ref('daily')
const recWeeklyDays = ref<string[]>([])
const recMonthlyDay = ref(1)
const recTimeOfDay = ref('09:00')
const recTimeHour = ref(9)
const recTimeMinute = ref(0)
const recMode = ref('prompt')
const recProjects = ref<Array<{ id: number; name: string; color: string }>>([])
const recTasks = ref<Array<{ id: number; name: string }>>([])
@@ -1473,6 +1699,10 @@ const reduceMotion = ref('system')
const dyslexiaMode = ref(false)
const uiFont = ref('Inter')
// Tray behavior settings
const closeToTray = ref(false)
const minimizeToTray = ref(false)
// Notification settings
const persistentNotifications = ref(false)
@@ -1493,6 +1723,27 @@ const soundModes = [
const dailyGoalHours = ref(8)
const weeklyGoalHours = ref(40)
// Reminder settings
const eodReminderEnabled = ref(false)
const eodReminderTime = ref('17:00')
const weeklySummaryEnabled = ref(false)
async function toggleEodReminder() {
eodReminderEnabled.value = !eodReminderEnabled.value
await saveReminderSettings()
}
async function toggleWeeklySummary() {
weeklySummaryEnabled.value = !weeklySummaryEnabled.value
await saveReminderSettings()
}
async function saveReminderSettings() {
await settingsStore.updateSetting('eod_reminder_enabled', eodReminderEnabled.value ? 'true' : 'false')
await settingsStore.updateSetting('eod_reminder_time', eodReminderTime.value)
await settingsStore.updateSetting('weekly_summary_enabled', weeklySummaryEnabled.value ? 'true' : 'false')
}
// Rounding settings
const roundingEnabled = ref(false)
const roundingIncrement = ref(15)
@@ -1714,6 +1965,9 @@ const showClearDataDialog = ref(false)
const clearDialogRef = ref<HTMLElement | null>(null)
const { activate: activateClearTrap, deactivate: deactivateClearTrap } = useFocusTrap()
const showSeedDialog = ref(false)
const isSeedingData = ref(false)
watch(showClearDataDialog, (val) => {
if (val) {
setTimeout(() => {
@@ -1724,10 +1978,23 @@ watch(showClearDataDialog, (val) => {
}
})
// JSON import wizard
const showJsonImportWizard = ref(false)
// Auto-backup state
const autoBackupEnabled = ref(false)
const backupPath = ref('')
const lastExported = ref('')
const backupFrequency = ref('daily')
const backupRetention = ref(7)
async function saveBackupFrequency(val: string) {
await settingsStore.updateSetting('auto_backup_frequency', val)
}
async function saveBackupRetention(val: number) {
await settingsStore.updateSetting('auto_backup_retention', String(val))
}
const lastExportedFormatted = computed(() => {
if (!lastExported.value) return ''
@@ -1797,6 +2064,7 @@ function applyZoom() {
if (app) {
(app.style as any).zoom = `${zoomLevel.value}%`
}
resetPositionCache()
settingsStore.updateSetting('ui_zoom', zoomLevel.value.toString())
}
@@ -1881,6 +2149,18 @@ async function togglePersistentNotifications() {
await settingsStore.updateSetting('persistent_notifications', persistentNotifications.value ? 'true' : 'false')
}
// Toggle close to tray
async function toggleCloseToTray() {
closeToTray.value = !closeToTray.value
await settingsStore.updateSetting('close_to_tray', closeToTray.value ? 'true' : 'false')
}
// Toggle minimize to tray
async function toggleMinimizeToTray() {
minimizeToTray.value = !minimizeToTray.value
await settingsStore.updateSetting('minimize_to_tray', minimizeToTray.value ? 'true' : 'false')
}
// Save sound settings
async function saveSoundSettings() {
await settingsStore.updateSetting('sound_mode', soundMode.value)
@@ -1904,6 +2184,11 @@ async function saveGoalSettings() {
await settingsStore.updateSetting('weekly_goal_hours', weeklyGoalHours.value.toString())
}
// Sync time picker hour/minute with recTimeOfDay string
watch([recTimeHour, recTimeMinute], ([h, m]) => {
recTimeOfDay.value = String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0')
})
// Watch recurring project to fetch tasks
watch(recProjectId, async (pid) => {
if (pid) {
@@ -1993,6 +2278,8 @@ function resetRecurringForm() {
recWeeklyDays.value = []
recMonthlyDay.value = 1
recTimeOfDay.value = '09:00'
recTimeHour.value = 9
recTimeMinute.value = 0
recMode.value = 'prompt'
}
@@ -2003,6 +2290,9 @@ function editRecurringEntry(rec: RecurringEntry) {
recDescription.value = rec.description || ''
recDurationMinutes.value = Math.round(rec.duration / 60)
recTimeOfDay.value = rec.time_of_day
const [h, m] = rec.time_of_day.split(':').map(Number)
recTimeHour.value = h || 0
recTimeMinute.value = m || 0
recMode.value = rec.mode
// Parse recurrence rule
@@ -2192,6 +2482,21 @@ async function clearAllData() {
}
}
async function seedSampleData() {
isSeedingData.value = true
showSeedDialog.value = false
try {
await invoke('seed_sample_data')
toastStore.success('Sample data loaded successfully')
setTimeout(() => { window.location.reload() }, 500)
} catch (error) {
console.error('Failed to load sample data:', error)
toastStore.error('Failed to load sample data')
} finally {
isSeedingData.value = false
}
}
// Load settings on mount
onMounted(async () => {
await settingsStore.fetchSettings()
@@ -2212,6 +2517,9 @@ onMounted(async () => {
shortcutQuickEntry.value = settingsStore.settings.shortcut_quick_entry || 'CmdOrCtrl+Shift+N'
dailyGoalHours.value = parseFloat(settingsStore.settings.daily_goal_hours) || 8
weeklyGoalHours.value = parseFloat(settingsStore.settings.weekly_goal_hours) || 40
eodReminderEnabled.value = settingsStore.settings.eod_reminder_enabled === 'true'
eodReminderTime.value = settingsStore.settings.eod_reminder_time || '17:00'
weeklySummaryEnabled.value = settingsStore.settings.weekly_summary_enabled === 'true'
roundingEnabled.value = settingsStore.settings.rounding_enabled === 'true'
roundingIncrement.value = parseInt(settingsStore.settings.rounding_increment) || 15
roundingMethod.value = settingsStore.settings.rounding_method || 'nearest'
@@ -2226,6 +2534,10 @@ onMounted(async () => {
dyslexiaMode.value = settingsStore.settings.dyslexia_mode === 'true'
uiFont.value = settingsStore.settings.ui_font || 'Inter'
// Tray behavior settings
closeToTray.value = settingsStore.settings.close_to_tray === 'true'
minimizeToTray.value = settingsStore.settings.minimize_to_tray === 'true'
// Notification settings
persistentNotifications.value = settingsStore.settings.persistent_notifications === 'true'
@@ -2233,6 +2545,8 @@ onMounted(async () => {
autoBackupEnabled.value = settingsStore.settings.auto_backup === 'true'
backupPath.value = settingsStore.settings.backup_path || ''
lastExported.value = settingsStore.settings.last_exported || ''
backupFrequency.value = settingsStore.settings.auto_backup_frequency || 'daily'
backupRetention.value = parseInt(settingsStore.settings.auto_backup_retention || '7')
// Sound settings
soundEnabled.value = settingsStore.settings.sound_enabled === 'true'

View File

@@ -1,140 +1,354 @@
<template>
<div class="p-6">
<!-- Hero timer display -->
<div class="text-center pt-4 pb-8">
<div class="relative inline-block">
<p class="text-[3.5rem] font-medium font-[family-name:var(--font-heading)] tracking-tighter mb-2 transition-colors duration-300" :class="[timerPulseClass, timerStore.isPaused ? 'opacity-60' : '']">
<span :class="timerStore.isPaused ? 'text-text-tertiary' : 'text-text-primary'">{{ timerParts.hours }}</span>
<span :class="timerStore.isRunning ? 'text-accent-text animate-pulse-colon' : timerStore.isPaused ? 'text-text-tertiary' : 'text-text-primary'">:</span>
<span :class="timerStore.isPaused ? 'text-text-tertiary' : 'text-text-primary'">{{ timerParts.minutes }}</span>
<span :class="timerStore.isRunning ? 'text-accent-text animate-pulse-colon' : timerStore.isPaused ? 'text-text-tertiary' : 'text-text-primary'">:</span>
<span :class="timerStore.isPaused ? 'text-text-tertiary' : 'text-text-primary'">{{ timerParts.seconds }}</span>
</p>
<!-- Paused badge - absolutely positioned so it doesn't shift layout -->
<span
v-if="timerStore.isPaused"
class="absolute -bottom-1 left-1/2 -translate-x-1/2 text-[0.625rem] font-semibold uppercase tracking-[0.15em] px-2.5 py-0.5 rounded-full transition-opacity duration-200"
:class="timerStore.timerState === 'PAUSED_IDLE' ? 'text-status-warning bg-status-warning/10' : timerStore.timerState === 'PAUSED_MANUAL' ? 'text-status-warning bg-status-warning/10' : 'text-status-info bg-status-info/10'"
>
{{ timerStore.timerState === 'PAUSED_IDLE' ? 'Idle' : timerStore.timerState === 'PAUSED_MANUAL' ? 'Paused' : 'App hidden' }}
</span>
<button
v-if="!timerStore.isStopped"
@click="openMiniTimer"
class="absolute -right-8 top-1/2 -translate-y-1/2 p-2 text-text-tertiary hover:text-text-secondary transition-colors"
title="Pop out mini timer"
>
<ExternalLink class="w-4 h-4" :stroke-width="1.5" />
</button>
</div>
<div class="p-6 grid grid-cols-1 lg:grid-cols-[1fr_280px] lg:gap-8 lg:items-start">
<!-- Left column -->
<div class="w-full max-w-[38rem] mx-auto">
<h1 class="sr-only">Timer</h1>
<!-- Sticky timer hero -->
<div class="sticky top-0 z-10 bg-bg-base pb-2">
<div class="text-center pt-4 pb-8">
<div class="relative inline-block">
<p class="text-[3.5rem] font-medium font-[family-name:var(--font-timer)] tracking-tighter mb-2 transition-colors duration-300" :class="[timerPulseClass, timerStore.isPaused ? 'opacity-60' : '']" role="timer" aria-live="off" :aria-label="'Elapsed time: ' + timerStore.formattedTime">
<span :class="timerStore.isPaused ? 'text-text-tertiary' : 'text-text-primary'">{{ timerParts.hours }}</span>
<span aria-hidden="true" :class="timerStore.isRunning ? 'text-accent-text animate-pulse-colon' : timerStore.isPaused ? 'text-text-tertiary' : 'text-text-primary'">:</span>
<span :class="timerStore.isPaused ? 'text-text-tertiary' : 'text-text-primary'">{{ timerParts.minutes }}</span>
<span aria-hidden="true" :class="timerStore.isRunning ? 'text-accent-text animate-pulse-colon' : timerStore.isPaused ? 'text-text-tertiary' : 'text-text-primary'">:</span>
<span :class="timerStore.isPaused ? 'text-text-tertiary' : 'text-text-primary'">{{ timerParts.seconds }}</span>
</p>
<!-- Paused badge - absolutely positioned so it doesn't shift layout -->
<span
v-if="timerStore.isPaused"
role="status"
class="absolute -bottom-1 left-1/2 -translate-x-1/2 text-[0.625rem] font-semibold uppercase tracking-[0.15em] px-2.5 py-0.5 rounded-full transition-opacity duration-200"
:class="timerStore.timerState === 'PAUSED_IDLE' ? 'text-status-warning bg-status-warning/10' : timerStore.timerState === 'PAUSED_MANUAL' ? 'text-status-warning bg-status-warning/10' : 'text-status-info bg-status-info/10'"
>
{{ timerStore.timerState === 'PAUSED_IDLE' ? 'Idle' : timerStore.timerState === 'PAUSED_MANUAL' ? 'Paused' : 'App hidden' }}
</span>
<button
v-if="!timerStore.isStopped"
@click="openMiniTimer"
class="absolute -right-8 top-1/2 -translate-y-1/2 p-2 text-text-tertiary hover:text-text-secondary transition-colors"
aria-label="Pop out mini timer"
v-tooltip="'Pop out mini timer'"
>
<ExternalLink class="w-4 h-4" :stroke-width="1.5" aria-hidden="true" />
</button>
</div>
<div class="h-6" />
<div class="h-6" />
<div class="flex items-center justify-center gap-3">
<!-- Pause / Resume button (visible when timer is active) -->
<button
v-if="timerStore.isRunning"
@click="timerStore.pauseManual()"
class="px-6 py-3 text-sm font-medium rounded-lg transition-colors duration-150 bg-status-warning/15 text-status-warning hover:bg-status-warning/25"
>
Pause
</button>
<button
v-else-if="timerStore.timerState === 'PAUSED_MANUAL'"
@click="timerStore.resumeFromPause()"
class="px-6 py-3 text-sm font-medium rounded-lg transition-colors duration-150 bg-accent text-bg-base hover:bg-accent-hover"
>
Resume
</button>
<div class="flex items-center justify-center gap-3">
<!-- Pause / Resume button (visible when timer is active) -->
<button
v-if="timerStore.isRunning"
@click="timerStore.pauseManual()"
class="px-6 py-3 text-sm font-medium rounded-lg transition-colors duration-150 bg-status-warning/15 text-status-warning hover:bg-status-warning/25"
>
Pause
</button>
<button
v-else-if="timerStore.timerState === 'PAUSED_MANUAL'"
@click="timerStore.resumeFromPause()"
class="px-6 py-3 text-sm font-medium rounded-lg transition-colors duration-150 bg-accent text-bg-base hover:bg-accent-hover"
>
Resume
</button>
<!-- Start / Stop button -->
<button
@click="toggleTimer"
class="btn-primary px-10 py-3 text-sm font-medium rounded-lg transition-colors duration-150"
:class="buttonClass"
>
{{ buttonLabel }}
</button>
</div>
</div>
<!-- Quick-switch button -->
<button
v-if="!timerStore.isStopped"
@click="showSwitchPicker = !showSwitchPicker"
class="px-4 py-3 text-sm font-medium rounded-lg transition-colors duration-150 border border-border-subtle text-text-secondary hover:text-text-primary hover:bg-bg-elevated"
aria-label="Switch project"
v-tooltip="'Switch project'"
>
<ArrowLeftRight class="w-4 h-4" :stroke-width="1.5" aria-hidden="true" />
</button>
<!-- Favorites strip -->
<div v-if="favorites.length > 0" class="max-w-[36rem] mx-auto mb-4">
<TransitionGroup tag="div" name="chip" class="flex items-center gap-2 overflow-x-auto pb-1">
<button
v-for="(fav, favIndex) in favorites"
:key="fav.id"
@click="applyFavorite(fav)"
:disabled="!timerStore.isStopped"
class="shrink-0 flex items-center gap-1.5 px-3 py-1.5 rounded-full border border-border-subtle text-[0.6875rem] text-text-secondary hover:text-text-primary hover:border-border-visible transition-colors disabled:opacity-40"
:style="{ transitionDelay: `${favIndex * 50}ms` }"
>
<span class="w-2 h-2 rounded-full" :style="{ backgroundColor: getProjectColor(fav.project_id) }" />
{{ getProjectName(fav.project_id) }}
<span v-if="fav.description" class="text-text-tertiary">&middot; {{ fav.description }}</span>
</button>
</TransitionGroup>
</div>
<!-- Inputs -->
<div class="max-w-[36rem] mx-auto mb-8">
<div class="grid grid-cols-2 gap-4 mb-4">
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Project</label>
<AppSelect
v-model="selectedProject"
:options="activeProjects"
label-key="name"
value-key="id"
placeholder="Select project"
:placeholder-value="null"
:disabled="!timerStore.isStopped"
/>
</div>
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Task</label>
<AppSelect
v-model="selectedTask"
:options="projectTasks"
label-key="name"
value-key="id"
placeholder="Select task"
:placeholder-value="null"
:disabled="!timerStore.isStopped || !selectedProject"
/>
<!-- Start / Stop button -->
<button
data-tour-id="timer-start"
@click="toggleTimer"
class="btn-primary px-10 py-3 text-sm font-medium rounded-lg transition-colors duration-150"
:class="buttonClass"
>
{{ buttonLabel }}
</button>
</div>
<!-- Quick-switch project picker -->
<div v-if="showSwitchPicker" class="mt-3 max-w-xs mx-auto">
<AppSelect
:model-value="null"
:options="projectsStore.activeProjects"
label-key="name"
value-key="id"
placeholder="Switch to project..."
searchable
@update:model-value="switchToProject($event)"
/>
</div>
</div>
</div>
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Description</label>
<div class="flex gap-2">
<input
v-model="description"
type="text"
:disabled="!timerStore.isStopped"
class="flex-1 px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary placeholder-text-tertiary focus:outline-none focus:border-border-visible disabled:opacity-40 disabled:cursor-not-allowed"
placeholder="What are you working on?"
/>
<button
v-if="timerStore.isStopped && selectedProject"
@click="saveAsFavorite"
class="p-2 text-text-tertiary hover:text-accent-text transition-colors"
title="Save as favorite"
<!-- Favorites strip -->
<div v-if="favorites.length > 0" class="mb-4">
<div class="flex flex-wrap items-center gap-y-2" @pointerleave="onFavDragCancel">
<template v-for="(fav, favIndex) in favorites" :key="fav.id">
<!-- Drop indicator / spacer before each chip -->
<div
class="transition-all duration-150 self-stretch flex items-center justify-center"
:class="dragFavIndex !== null && dropTargetIndex === favIndex && dragFavIndex !== favIndex
? 'w-3'
: 'w-1.5'"
@pointerenter="onFavDragOver(favIndex)"
@pointerup="onFavDragEnd"
>
<div
class="w-0.5 h-5 rounded-full transition-opacity duration-150"
:class="dragFavIndex !== null && dropTargetIndex === favIndex && dragFavIndex !== favIndex
? 'bg-accent opacity-100 shadow-[0_0_6px_var(--color-accent)]'
: 'opacity-0'"
/>
</div>
<!-- Chip -->
<div
class="flex items-center rounded-full border border-border-subtle text-[0.6875rem] text-text-secondary hover:text-text-primary hover:border-border-visible transition-colors"
:class="[
dragFavIndex === favIndex ? 'opacity-40' : '',
dragFavIndex !== null && dragFavIndex !== favIndex ? 'cursor-grabbing' : 'cursor-grab'
]"
@pointerdown.prevent="onFavDragStart(favIndex)"
@pointerenter="onFavDragOver(favIndex)"
@pointerup="onFavDragEnd"
@keydown.ctrl.left.prevent="moveFavorite(favIndex, -1)"
@keydown.ctrl.right.prevent="moveFavorite(favIndex, 1)"
>
<span class="flex items-center gap-1.5 pl-2.5 py-1.5 select-none">
<span class="w-2 h-2 rounded-full shrink-0" :style="{ backgroundColor: getProjectColor(fav.project_id) }" aria-hidden="true" />
{{ getProjectName(fav.project_id) }}
<span v-if="fav.description" class="text-text-tertiary">&middot; {{ fav.description }}</span>
</span>
<button
@click.stop="applyFavorite(fav)"
:disabled="!timerStore.isStopped"
class="flex items-center justify-center w-6 h-6 rounded-full text-text-tertiary hover:text-accent disabled:opacity-40 cursor-pointer"
aria-label="Load fields"
v-tooltip="'Load fields'"
>
<ArrowDownToLine class="w-3 h-3" :stroke-width="2" aria-hidden="true" />
</button>
<button
@click.stop="quickStartFavorite(fav)"
:disabled="!timerStore.isStopped"
class="flex items-center justify-center w-6 h-6 rounded-full text-accent hover:text-accent-hover disabled:opacity-40 cursor-pointer"
aria-label="Start timer"
v-tooltip="'Start timer'"
>
<Play class="w-3 h-3" :stroke-width="2" fill="currentColor" aria-hidden="true" />
</button>
<button
@click.stop="removeFavorite(fav.id!)"
class="flex items-center justify-center w-5 h-5 mr-1 rounded-full text-text-tertiary hover:text-status-error cursor-pointer"
aria-label="Delete favorite"
v-tooltip="'Delete favorite'"
>
<X class="w-3 h-3" :stroke-width="2" aria-hidden="true" />
</button>
</div>
</template>
<!-- Drop indicator after last chip -->
<div
class="transition-all duration-150 self-stretch flex items-center justify-center"
:class="dragFavIndex !== null && dropTargetIndex === favorites.length
? 'w-3'
: 'w-0'"
@pointerenter="onFavDragOver(favorites.length)"
@pointerup="onFavDragEnd"
>
<Star class="w-4 h-4" :stroke-width="1.5" />
</button>
<div
class="w-0.5 h-5 rounded-full transition-opacity duration-150"
:class="dragFavIndex !== null && dropTargetIndex === favorites.length
? 'bg-accent opacity-100 shadow-[0_0_6px_var(--color-accent)]'
: 'opacity-0'"
/>
</div>
</div>
</div>
<div class="mt-3">
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Tags</label>
<AppTagInput v-model="selectedTags" />
<!-- Inputs -->
<div class="mb-8">
<div class="grid grid-cols-2 gap-4 mb-4">
<div data-tour-id="timer-project">
<label id="label-timer-project" class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Project</label>
<AppSelect
v-model="selectedProject"
aria-labelledby="label-timer-project"
:options="activeProjects"
label-key="name"
value-key="id"
placeholder="Select project"
:placeholder-value="null"
:disabled="!timerStore.isStopped"
/>
</div>
<div>
<label id="label-timer-task" class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Task</label>
<AppSelect
v-model="selectedTask"
aria-labelledby="label-timer-task"
:options="projectTasks"
label-key="name"
value-key="id"
placeholder="Select task"
:placeholder-value="null"
:disabled="!timerStore.isStopped || !selectedProject"
/>
</div>
</div>
<!-- Budget progress indicator -->
<div v-if="projectBudgetStatus" class="mt-1.5">
<div class="flex items-center justify-between mb-0.5">
<span class="text-[0.5625rem] text-text-tertiary">{{ formatNumber(projectBudgetStatus.hoursUsed, 0) }}h / {{ projectBudgetStatus.budgetHours }}h</span>
<span class="text-[0.5625rem]" :class="projectBudgetStatus.pct > 90 ? 'text-status-error-text' : projectBudgetStatus.pct > 75 ? 'text-status-warning' : 'text-text-tertiary'">{{ formatNumber(projectBudgetStatus.pct, 0) }}%</span>
</div>
<div class="w-full bg-bg-elevated rounded-full h-0.5">
<div
class="h-0.5 rounded-full progress-bar"
:class="projectBudgetStatus.pct > 90 ? 'bg-status-error' : projectBudgetStatus.pct > 75 ? 'bg-status-warning' : 'bg-accent'"
:style="{ width: Math.min(projectBudgetStatus.pct, 100) + '%' }"
/>
</div>
</div>
<div data-tour-id="timer-description">
<label for="timer-description" class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Description</label>
<div class="flex gap-2">
<div class="flex-1 relative">
<input
id="timer-description"
v-model="description"
type="text"
:disabled="!timerStore.isStopped"
class="w-full px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary placeholder-text-tertiary focus:outline-none focus:border-border-visible disabled:opacity-40 disabled:cursor-not-allowed"
placeholder="What are you working on?"
role="combobox"
aria-autocomplete="list"
:aria-expanded="showDescSuggestions"
aria-controls="desc-suggestions"
:aria-activedescendant="activeDescIndex >= 0 ? 'desc-opt-' + activeDescIndex : undefined"
@focus="onDescFocus"
@blur="onDescBlur"
@keydown="onDescKeydown"
autocomplete="off"
/>
<ul
v-if="showDescSuggestions && filteredDescriptions.length > 0"
id="desc-suggestions"
role="listbox"
class="absolute z-20 left-0 right-0 top-full mt-1 max-h-48 overflow-y-auto bg-bg-elevated border border-border-subtle rounded-lg shadow-lg"
>
<li
v-for="(desc, i) in filteredDescriptions"
:id="'desc-opt-' + i"
:key="desc"
role="option"
:aria-selected="i === activeDescIndex"
class="px-3 py-2 text-[0.8125rem] text-text-primary cursor-pointer transition-colors"
:class="i === activeDescIndex ? 'bg-accent/10 text-accent-text' : 'hover:bg-bg-surface'"
@mousedown.prevent="selectDescription(desc)"
>
{{ desc }}
</li>
</ul>
</div>
<button
@click="timerStore.setBillable(timerStore.billable === 1 ? 0 : 1)"
:aria-label="timerStore.billable === 1 ? 'Mark as non-billable' : 'Mark as billable'"
v-tooltip="'Toggle billable'"
class="w-8 h-8 flex items-center justify-center rounded-full transition-colors"
:class="timerStore.billable === 1
? 'bg-accent-muted text-accent-text'
: 'bg-bg-elevated text-text-tertiary'"
>
<DollarSign class="w-4 h-4" aria-hidden="true" />
</button>
<button
v-if="timerStore.isStopped && selectedProject"
@click="saveAsFavorite"
class="p-2 text-text-tertiary hover:text-accent-text transition-colors"
aria-label="Save as favorite"
v-tooltip="'Save as favorite'"
>
<Star class="w-4 h-4" :stroke-width="1.5" aria-hidden="true" />
</button>
</div>
</div>
<div class="mt-3">
<label for="timer-tags" class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Tags</label>
<AppTagInput v-model="selectedTags" />
</div>
</div>
<!-- Timeline Panel -->
<div v-if="timelineEnabled" class="mb-8 border border-border-subtle rounded-lg overflow-hidden">
<button
type="button"
@click="toggleTimelinePanel"
class="w-full flex items-center justify-between px-3 py-2.5 text-[0.8125rem] text-text-primary hover:bg-bg-elevated transition-colors"
>
<span>Timeline</span>
<ChevronDown
class="w-4 h-4 text-text-tertiary transition-transform duration-200"
:class="{ 'rotate-180': timelineExpanded }"
:stroke-width="1.5"
aria-hidden="true"
/>
</button>
<div v-if="timelineExpanded" class="border-t border-border-subtle px-3 py-3">
<div v-if="timelineEvents.length > 0">
<!-- Timeline bar -->
<div class="relative w-full h-8 bg-bg-inset rounded overflow-hidden mb-2">
<div
v-for="seg in timelineSegments"
:key="seg.id"
class="absolute top-0 h-full rounded-sm cursor-default transition-opacity hover:opacity-80"
:style="{
left: seg.left + '%',
width: Math.max(seg.width, 0.3) + '%',
backgroundColor: seg.color,
}"
role="img"
:aria-label="seg.tooltip"
/>
</div>
<!-- Time labels -->
<div class="flex justify-between text-[0.5625rem] text-text-tertiary mb-3">
<span>{{ timelineStartLabel }}</span>
<span>{{ timelineEndLabel }}</span>
</div>
<!-- Legend -->
<div class="flex flex-wrap gap-x-3 gap-y-1 mb-3">
<div v-for="app in timelineApps" :key="app.name" class="flex items-center gap-1.5">
<span class="w-2 h-2 rounded-sm shrink-0" :style="{ backgroundColor: app.color }" />
<span class="text-[0.625rem] text-text-secondary truncate max-w-[120px]">{{ app.name }}</span>
</div>
</div>
<button
@click="clearTodayTimeline"
class="flex items-center gap-1.5 text-[0.6875rem] text-text-tertiary hover:text-status-error transition-colors"
>
<Trash2 class="w-3 h-3" :stroke-width="1.5" aria-hidden="true" />
Clear today
</button>
</div>
<p v-else class="text-[0.6875rem] text-text-tertiary">No timeline events recorded today. Events appear as the timer runs.</p>
</div>
</div>
</div>
<!-- Recent entries -->
<div class="max-w-[36rem] mx-auto">
<!-- Right column -->
<div class="lg:sticky lg:top-6">
<div class="flex items-center justify-between mb-3">
<h2 class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em]">Recent</h2>
<router-link v-if="recentEntries.length > 0" to="/entries" class="text-xs text-text-tertiary hover:text-text-secondary transition-colors">View all</router-link>
<router-link v-if="recentEntries.length > 0" to="/entries" class="text-xs text-text-tertiary hover:text-text-secondary transition-colors" aria-label="View all time entries">View all</router-link>
</div>
<TransitionGroup v-if="recentEntries.length > 0" name="list" tag="div">
@@ -145,17 +359,18 @@
:class="index === 0 ? 'border-l-2 border-l-accent pl-3' : ''"
:style="{ transitionDelay: `${index * 40}ms` }"
>
<div class="flex items-center gap-3">
<div class="flex items-center gap-3 min-w-0">
<div
class="w-2 h-2 rounded-full shrink-0"
:style="{ backgroundColor: getProjectColor(entry.project_id) }"
aria-hidden="true"
/>
<div>
<p class="text-[0.75rem] text-text-primary">{{ getProjectName(entry.project_id) }}</p>
<p class="text-[0.6875rem] text-text-tertiary">{{ entry.description || 'No description' }}</p>
<div class="min-w-0">
<p class="text-[0.75rem] text-text-primary truncate">{{ getProjectName(entry.project_id) }}</p>
<p class="text-[0.6875rem] text-text-tertiary truncate">{{ entry.description || 'No description' }}</p>
</div>
</div>
<div class="flex items-center gap-2">
<div class="flex items-center gap-2 shrink-0">
<div class="text-right">
<p class="text-[0.75rem] font-mono text-text-primary">{{ formatDuration(entry.duration) }}</p>
<p class="text-[0.6875rem] text-text-tertiary">{{ formatDateTime(entry.start_time) }}</p>
@@ -164,9 +379,10 @@
v-if="timerStore.isStopped"
@click="repeatEntry(entry)"
class="p-1 text-text-tertiary hover:text-text-secondary transition-colors"
title="Repeat"
aria-label="Repeat entry"
v-tooltip="'Replay entry'"
>
<RotateCcw class="w-3 h-3" :stroke-width="2" />
<RotateCcw class="w-3 h-3" :stroke-width="2" aria-hidden="true" />
</button>
</div>
</div>
@@ -174,7 +390,7 @@
<!-- Empty state -->
<div v-else class="flex flex-col items-center py-8">
<TimerIcon class="w-10 h-10 text-text-tertiary animate-float" :stroke-width="1.5" />
<TimerIcon class="w-10 h-10 text-text-tertiary animate-float" :stroke-width="1.5" aria-hidden="true" />
<p class="text-sm text-text-secondary mt-3">No entries today</p>
<p class="text-xs text-text-tertiary mt-1">Select a project and hit Start to begin tracking</p>
</div>
@@ -205,13 +421,13 @@ import { useProjectsStore, type Task } from '../stores/projects'
import { useEntriesStore } from '../stores/entries'
import { useSettingsStore } from '../stores/settings'
import { useToastStore } from '../stores/toast'
import { Timer as TimerIcon, RotateCcw, ExternalLink, Star } from 'lucide-vue-next'
import { Timer as TimerIcon, RotateCcw, ExternalLink, Star, DollarSign, ChevronDown, Trash2, Play, ArrowLeftRight, X, ArrowDownToLine } from 'lucide-vue-next'
import { invoke } from '@tauri-apps/api/core'
import AppSelect from '../components/AppSelect.vue'
import AppTagInput from '../components/AppTagInput.vue'
import IdlePromptDialog from '../components/IdlePromptDialog.vue'
import AppTrackingPromptDialog from '../components/AppTrackingPromptDialog.vue'
import { formatDateTime } from '../utils/locale'
import { formatDateTime, formatNumber } from '../utils/locale'
import { useFavoritesStore, type Favorite } from '../stores/favorites'
import { useTagsStore } from '../stores/tags'
@@ -228,10 +444,192 @@ const favorites = computed(() => favoritesStore.favorites)
const selectedProject = ref<number | null>(timerStore.selectedProjectId)
const selectedTask = ref<number | null>(timerStore.selectedTaskId)
const description = ref(timerStore.description)
const recentDescriptions = ref<string[]>([])
const showDescSuggestions = ref(false)
const activeDescIndex = ref(-1)
const filteredDescriptions = computed(() => {
const q = description.value.toLowerCase().trim()
if (q.length < 2) return recentDescriptions.value.slice(0, 10)
return recentDescriptions.value.filter(d => d.toLowerCase().includes(q)).slice(0, 10)
})
async function loadRecentDescriptions() {
try {
recentDescriptions.value = await invoke<string[]>('get_recent_descriptions')
} catch { /* ignore */ }
}
function onDescFocus() {
if (recentDescriptions.value.length > 0 && timerStore.isStopped) {
showDescSuggestions.value = true
activeDescIndex.value = -1
}
}
function onDescBlur() {
// Delay so mousedown on option fires first
setTimeout(() => { showDescSuggestions.value = false }, 150)
}
function onDescKeydown(e: KeyboardEvent) {
if (!showDescSuggestions.value || filteredDescriptions.value.length === 0) return
if (e.key === 'ArrowDown') {
e.preventDefault()
activeDescIndex.value = Math.min(activeDescIndex.value + 1, filteredDescriptions.value.length - 1)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
activeDescIndex.value = Math.max(activeDescIndex.value - 1, -1)
} else if (e.key === 'Enter' && activeDescIndex.value >= 0) {
e.preventDefault()
selectDescription(filteredDescriptions.value[activeDescIndex.value])
} else if (e.key === 'Escape') {
showDescSuggestions.value = false
}
}
function selectDescription(desc: string) {
description.value = desc
showDescSuggestions.value = false
activeDescIndex.value = -1
}
const selectedTags = ref<number[]>([])
const projectTasks = ref<Task[]>([])
const projectBudgetStatus = ref<{ hoursUsed: number; budgetHours: number; pct: number } | null>(null)
const timerPulseClass = ref('')
// Timeline state
interface TimelineEvent {
id: number
project_id: number
exe_name: string | null
exe_path: string | null
window_title: string | null
started_at: string
ended_at: string | null
duration: number | null
}
const timelineExpanded = ref(false)
const timelineEvents = ref<TimelineEvent[]>([])
const timelineEnabled = computed(() => {
const globalOn = settingsStore.settings.timeline_recording === 'on'
if (selectedProject.value) {
const project = projectsStore.projects.find(p => p.id === selectedProject.value)
if (project?.timeline_override === 'on') return true
if (project?.timeline_override === 'off') return false
}
return globalOn
})
function hashColor(str: string): string {
let hash = 0
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash)
}
const hue = Math.abs(hash) % 360
return `hsl(${hue}, 55%, 50%)`
}
const timelineSegments = computed(() => {
if (timelineEvents.value.length === 0) return []
const events = timelineEvents.value
const firstStart = new Date(events[0].started_at).getTime()
const lastEnd = events.reduce((max, e) => {
const end = e.ended_at ? new Date(e.ended_at).getTime() : Date.now()
return Math.max(max, end)
}, firstStart)
const totalSpan = lastEnd - firstStart
if (totalSpan <= 0) return []
return events.map(e => {
const start = new Date(e.started_at).getTime()
const end = e.ended_at ? new Date(e.ended_at).getTime() : Date.now()
const left = ((start - firstStart) / totalSpan) * 100
const width = ((end - start) / totalSpan) * 100
const appName = e.exe_name || 'Unknown'
const dur = e.duration ?? Math.floor((end - start) / 1000)
const durMin = Math.floor(dur / 60)
const durSec = dur % 60
const timeRange = formatTimeHM(e.started_at) + ' - ' + (e.ended_at ? formatTimeHM(e.ended_at) : 'now')
return {
id: e.id,
left,
width,
color: hashColor(appName),
tooltip: `${appName}\n${e.window_title || ''}\n${timeRange}\n${durMin}m ${durSec}s`,
}
})
})
const timelineStartLabel = computed(() => {
if (timelineEvents.value.length === 0) return ''
return formatTimeHM(timelineEvents.value[0].started_at)
})
const timelineEndLabel = computed(() => {
if (timelineEvents.value.length === 0) return ''
const last = timelineEvents.value[timelineEvents.value.length - 1]
return last.ended_at ? formatTimeHM(last.ended_at) : 'now'
})
const timelineApps = computed(() => {
const seen = new Map<string, string>()
for (const e of timelineEvents.value) {
const name = e.exe_name || 'Unknown'
if (!seen.has(name)) {
seen.set(name, hashColor(name))
}
}
return Array.from(seen.entries()).map(([name, color]) => ({ name, color }))
})
function formatTimeHM(iso: string): string {
const d = new Date(iso)
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
async function toggleTimelinePanel() {
timelineExpanded.value = !timelineExpanded.value
if (timelineExpanded.value) {
await fetchTimelineEvents()
}
}
async function fetchTimelineEvents() {
if (!selectedProject.value) {
timelineEvents.value = []
return
}
const today = new Date().toISOString().split('T')[0]
try {
timelineEvents.value = await invoke<TimelineEvent[]>('get_timeline_events', {
projectId: selectedProject.value,
date: today,
})
} catch (e) {
console.error('Failed to fetch timeline events:', e)
timelineEvents.value = []
}
}
async function clearTodayTimeline() {
if (!selectedProject.value) return
const today = new Date().toISOString().split('T')[0]
try {
await invoke('delete_timeline_events', {
projectId: selectedProject.value,
date: today,
})
timelineEvents.value = []
toastStore.success('Timeline cleared')
} catch (e) {
console.error('Failed to clear timeline:', e)
toastStore.error('Failed to clear timeline')
}
}
// Split timer into parts for colon animation
const timerParts = computed(() => {
const time = timerStore.formattedTime
@@ -285,9 +683,24 @@ watch(selectedProject, async (newProjectId) => {
timerStore.setProject(newProjectId)
selectedTask.value = null
projectTasks.value = []
projectBudgetStatus.value = null
if (newProjectId) {
projectTasks.value = await projectsStore.fetchTasks(newProjectId)
const project = projectsStore.projects.find(p => p.id === newProjectId)
if (project?.budget_hours) {
try {
const today = new Date().toISOString().split('T')[0]
const status = await invoke<{ hours_used: number }>('get_project_budget_status', { projectId: newProjectId, today })
projectBudgetStatus.value = {
hoursUsed: status.hours_used,
budgetHours: project.budget_hours,
pct: (status.hours_used / project.budget_hours) * 100,
}
} catch {
projectBudgetStatus.value = null
}
}
}
})
@@ -364,6 +777,21 @@ async function toggleTimer() {
}
}
selectedTags.value = []
// Check for overlapping entries (non-blocking warning)
const latestEntry = entriesStore.entries[0]
if (latestEntry?.start_time && latestEntry?.end_time) {
try {
const overlaps = await invoke<Array<{ project_name: string }>>('check_entry_overlap', {
startTime: latestEntry.start_time,
endTime: latestEntry.end_time,
excludeId: latestEntry.id,
})
if (overlaps.length > 0) {
toastStore.info(`This entry overlaps with ${overlaps.length} other ${overlaps.length === 1 ? 'entry' : 'entries'}`)
}
} catch { /* non-critical */ }
}
}
}
@@ -405,6 +833,91 @@ function applyFavorite(fav: Favorite) {
description.value = fav.description || ''
}
// Quick-start a favorite (starts timer immediately)
async function quickStartFavorite(fav: Favorite) {
if (!timerStore.isStopped) {
timerStore.stop()
await entriesStore.fetchEntries()
}
selectedProject.value = fav.project_id
selectedTask.value = fav.task_id || null
description.value = fav.description || ''
timerStore.setProject(fav.project_id)
timerStore.setTask(fav.task_id || null)
timerStore.setDescription(fav.description || '')
timerStore.start()
toastStore.success('Timer started for ' + getProjectName(fav.project_id))
}
// Quick-switch state
const showSwitchPicker = ref(false)
async function switchToProject(projectId: number | null) {
if (!projectId) return
showSwitchPicker.value = false
timerStore.stop()
await entriesStore.fetchEntries()
selectedProject.value = projectId
selectedTask.value = null
description.value = ''
timerStore.setProject(projectId)
timerStore.setTask(null)
timerStore.setDescription('')
timerStore.start()
toastStore.success('Switched to ' + getProjectName(projectId))
}
// Drag-reorder favorites
const dragFavIndex = ref<number | null>(null)
const dropTargetIndex = ref<number | null>(null)
function onFavDragStart(index: number) {
dragFavIndex.value = index
window.addEventListener('pointerup', onFavDragEnd, { once: true })
}
function onFavDragOver(index: number) {
if (dragFavIndex.value !== null) {
dropTargetIndex.value = index
}
}
function onFavDragCancel() {
if (dragFavIndex.value !== null) {
dropTargetIndex.value = null
}
}
function onFavDragEnd() {
const from = dragFavIndex.value
const to = dropTargetIndex.value
if (from !== null && to !== null && from !== to) {
const reordered = [...favoritesStore.favorites]
const [moved] = reordered.splice(from, 1)
// Adjust insertion index: if dropping after the dragged item's original position,
// the index shifts down by 1 since we already removed the item
const insertAt = to > from ? to - 1 : to
reordered.splice(insertAt, 0, moved)
favoritesStore.reorderFavorites(reordered.map(f => f.id!))
}
dragFavIndex.value = null
dropTargetIndex.value = null
}
function moveFavorite(index: number, direction: -1 | 1) {
const newIndex = index + direction
if (newIndex < 0 || newIndex >= favoritesStore.favorites.length) return
const reordered = [...favoritesStore.favorites]
const [moved] = reordered.splice(index, 1)
reordered.splice(newIndex, 0, moved)
favoritesStore.reorderFavorites(reordered.map(f => f.id!))
}
// Delete a favorite
async function removeFavorite(id: number) {
await favoritesStore.deleteFavorite(id)
}
// Save current inputs as a favorite
async function saveAsFavorite() {
if (!selectedProject.value) return
@@ -445,7 +958,8 @@ onMounted(async () => {
entriesStore.fetchEntries(),
settingsStore.fetchSettings(),
favoritesStore.fetchFavorites(),
tagsStore.fetchTags()
tagsStore.fetchTags(),
loadRecentDescriptions()
])
// Restore timer state

View File

@@ -7,10 +7,11 @@
<div class="flex items-center gap-3">
<button
@click="prevWeek"
class="p-1.5 text-text-secondary hover:text-text-primary transition-colors duration-150"
v-tooltip="'Previous week'"
class="p-1.5 rounded-lg text-text-tertiary hover:text-text-secondary hover:bg-bg-elevated transition-colors duration-150"
aria-label="Previous week"
>
<ChevronLeft class="w-5 h-5" :stroke-width="2" aria-hidden="true" />
<ChevronLeft class="w-4 h-4" :stroke-width="2" aria-hidden="true" />
</button>
<button
@@ -30,13 +31,14 @@
<button
@click="nextWeek"
class="p-1.5 text-text-secondary hover:text-text-primary transition-colors duration-150"
v-tooltip="'Next week'"
class="p-1.5 rounded-lg text-text-tertiary hover:text-text-secondary hover:bg-bg-elevated transition-colors duration-150"
aria-label="Next week"
>
<ChevronRight class="w-5 h-5" :stroke-width="2" aria-hidden="true" />
<ChevronRight class="w-4 h-4" :stroke-width="2" aria-hidden="true" />
</button>
<span class="text-[0.8125rem] text-text-primary font-medium ml-2" role="status" aria-live="polite">
<span class="text-[0.75rem] text-text-secondary font-medium ml-2" role="status" aria-live="polite">
{{ weekRangeLabel }}
</span>
@@ -70,8 +72,8 @@
<div :key="weekStart" class="bg-bg-surface rounded-lg overflow-hidden">
<table class="w-full">
<thead>
<tr class="border-b border-border-subtle">
<th class="px-3 py-2 text-left text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium w-56">
<tr class="border-b border-border-subtle bg-bg-surface">
<th class="px-4 py-3 text-left text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium w-56">
<span class="flex items-center gap-1.5">
Project / Task
<Lock v-if="isCurrentWeekLocked" class="w-3 h-3 text-amber-500" :stroke-width="2" aria-label="Week is locked" />
@@ -80,12 +82,12 @@
<th
v-for="(day, i) in dayHeaders"
:key="i"
class="px-3 py-2 text-right text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium"
class="px-4 py-3 text-right text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium"
:class="{ 'text-text-secondary': isToday(i) }"
>
{{ day }}
</th>
<th class="px-3 py-2 text-right text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">
<th class="px-4 py-3 text-right text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">
Total
</th>
</tr>
@@ -97,9 +99,9 @@
:key="rowIndex"
class="border-b border-border-subtle transition-colors duration-150"
:class="isCurrentWeekLocked ? 'bg-bg-inset' : 'hover:bg-bg-elevated'"
:title="isCurrentWeekLocked ? 'This week is locked' : undefined"
:aria-label="isCurrentWeekLocked ? 'This week is locked' : undefined"
>
<td class="px-3 py-2">
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<div
class="w-2 h-2 rounded-full shrink-0"
@@ -118,7 +120,7 @@
tabindex="0"
role="gridcell"
:aria-label="'Hours for ' + row.project_name + ' on ' + getDayLabel(dayIndex) + ': ' + formatHM(seconds)"
class="px-3 py-2 text-right text-[0.75rem] font-mono transition-colors duration-150"
class="px-4 py-3 text-right text-[0.75rem] font-mono transition-colors duration-150"
:class="[
seconds > 0 ? 'text-accent-text' : 'text-text-tertiary',
!isCurrentWeekLocked ? 'cursor-pointer hover:bg-bg-elevated' : ''
@@ -140,14 +142,14 @@
{{ formatHM(seconds) }}
</template>
</td>
<td class="px-3 py-2 text-right text-[0.75rem] font-mono text-accent-text font-medium">
<td class="px-4 py-3 text-right text-[0.75rem] font-mono text-accent-text font-medium">
{{ formatHM(rowTotal(row)) }}
</td>
</tr>
<!-- Add Row (inline) -->
<tr v-if="showAddRow" class="border-b border-border-subtle bg-bg-inset">
<td class="px-3 py-2" colspan="9">
<td class="px-4 py-3" colspan="9">
<div class="flex items-center gap-3">
<div class="w-48">
<AppSelect
@@ -193,12 +195,12 @@
<!-- Empty state -->
<tr v-if="rows.length === 0 && !showAddRow">
<td colspan="9" class="px-3 py-12">
<td colspan="9" class="px-4 py-12">
<div class="flex flex-col items-center justify-center">
<ClockIcon class="w-10 h-10 text-text-tertiary" :stroke-width="1.5" aria-hidden="true" />
<p class="text-sm text-text-secondary mt-3">No timesheet data for this week</p>
<p class="text-xs text-text-tertiary mt-1">Start tracking time to see your weekly timesheet.</p>
<router-link to="/timer" class="mt-3 px-4 py-1.5 bg-accent text-bg-base text-xs font-medium rounded-lg hover:bg-accent-hover transition-colors">
<ClockIcon class="w-12 h-12 text-text-tertiary animate-float" :stroke-width="1.5" aria-hidden="true" />
<p class="text-sm text-text-secondary mt-4">No timesheet data for this week</p>
<p class="text-xs text-text-tertiary mt-2">Start tracking time to see your weekly timesheet.</p>
<router-link to="/timer" class="mt-4 px-4 py-2 bg-accent text-bg-base text-xs font-medium rounded-lg hover:bg-accent-hover transition-colors">
Go to Timer
</router-link>
<button
@@ -215,17 +217,17 @@
<tfoot>
<!-- Column totals -->
<tr class="border-t border-border-visible bg-bg-elevated">
<td class="px-3 py-2 text-[0.6875rem] text-text-secondary uppercase tracking-[0.08em] font-medium">
<td class="px-4 py-3 text-[0.6875rem] text-text-secondary uppercase tracking-[0.08em] font-medium">
Total
</td>
<td
v-for="(total, i) in columnTotals"
:key="i"
class="px-3 py-2 text-right text-[0.75rem] font-mono text-text-primary font-medium"
class="px-4 py-3 text-right text-[0.75rem] font-mono text-text-primary font-medium"
>
{{ formatHM(total) }}
</td>
<td class="px-3 py-2 text-right text-[0.75rem] font-mono text-accent-text font-bold">
<td class="px-4 py-3 text-right text-[0.75rem] font-mono text-accent-text font-bold">
{{ formatHM(grandTotal) }}
</td>
</tr>
@@ -240,7 +242,7 @@
@click="startAddRow"
:disabled="isCurrentWeekLocked"
class="mt-3 flex items-center gap-1.5 text-text-secondary text-xs hover:text-text-primary transition-colors duration-150 disabled:opacity-40 disabled:cursor-not-allowed"
:title="isCurrentWeekLocked ? 'Cannot add rows to a locked week' : undefined"
:aria-label="isCurrentWeekLocked ? 'Cannot add rows to a locked week' : 'Add row'"
>
<Plus class="w-4 h-4" :stroke-width="2" aria-hidden="true" />
Add Row
@@ -297,7 +299,7 @@
</button>
<button
@click="unlockWeek"
class="px-4 py-2 border border-status-error text-status-error font-medium rounded-lg hover:bg-status-error/10 transition-colors duration-150"
class="px-4 py-2 border border-status-error text-status-error-text font-medium rounded-lg hover:bg-status-error/10 transition-colors duration-150"
>
Unlock
</button>