Files
zeroclock/src/views/Settings.vue
T

2588 lines
102 KiB
Vue

<template>
<div class="flex h-full">
<!-- Sidebar -->
<aside class="w-44 bg-bg-surface border-r border-border-subtle shrink-0 p-3">
<h1 class="text-[0.8125rem] font-medium text-text-tertiary uppercase tracking-[0.08em] px-2 mb-3">Settings</h1>
<nav role="tablist" aria-label="Settings sections" aria-orientation="vertical" @keydown="onTabKeydown" class="flex flex-col gap-0.5">
<button
v-for="tab in tabs"
:key="tab.id"
@click="activeTab = tab.id"
role="tab"
:aria-selected="activeTab === tab.id"
:aria-controls="'tabpanel-' + tab.id"
:id="'tab-' + tab.id"
:tabindex="activeTab === tab.id ? 0 : -1"
class="relative flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[0.8125rem] transition-colors duration-150"
:class="activeTab === tab.id
? 'bg-bg-elevated text-text-primary'
: 'text-text-secondary hover:bg-bg-elevated hover:text-text-primary'"
>
<!-- Active indicator -->
<div
v-if="activeTab === tab.id"
class="absolute left-0 top-1.5 bottom-1.5 w-[2px] rounded-full bg-accent"
aria-hidden="true"
/>
<component :is="tab.icon" class="w-4 h-4 shrink-0" :stroke-width="1.5" aria-hidden="true" />
{{ tab.label }}
</button>
</nav>
</aside>
<!-- 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="space-y-4">
<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
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-48">
<AppSelect
v-model="themeMode"
aria-labelledby="label-theme"
:options="themeModes"
label-key="label"
value-key="value"
placeholder="Dark"
:placeholder-value="'dark'"
@update:model-value="saveThemeSettings"
/>
</div>
</div>
<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>
</div>
<div class="flex gap-2">
<button
v-for="ac in accentColors"
:key="ac.value"
@click="accentColor = ac.value; saveThemeSettings()"
class="w-6 h-6 rounded-full border-2 transition-all hover:scale-110 cursor-pointer"
:class="accentColor === ac.value ? 'border-text-primary scale-110' : 'border-transparent'"
:style="{ backgroundColor: ac.color }"
:aria-label="ac.label + ' accent color'"
:aria-pressed="accentColor === ac.value"
/>
</div>
</div>
<div class="border-t border-border-subtle" />
<div class="flex items-center justify-between">
<div>
<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-48">
<AppSelect
v-model="reduceMotion"
aria-labelledby="label-reduce-motion"
:options="reduceMotionOptions"
label-key="label"
value-key="value"
placeholder="System (default)"
:placeholder-value="'system'"
@update:model-value="saveReduceMotionSetting"
/>
</div>
</div>
<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>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Use OpenDyslexic font throughout the interface</p>
</div>
<button
@click="toggleDyslexiaMode"
role="switch"
:aria-checked="dyslexiaMode"
aria-label="Dyslexia-friendly mode"
:class="[
'relative inline-flex h-5 w-9 items-center rounded-full transition-colors duration-150',
dyslexiaMode ? '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',
dyslexiaMode ? 'translate-x-[18px]' : 'translate-x-[3px]'
]"
/>
</button>
</div>
<div class="flex items-center justify-between">
<div>
<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"
placeholder="Inter (Default)"
:placeholder-value="'Inter'"
:disabled="dyslexiaMode"
@update:model-value="saveUIFontSetting"
>
<template #option="{ item, label }">
<span :style="{ fontFamily: `'${item.value}', system-ui, sans-serif` }">{{ label }}</span>
</template>
<template #selected="{ label, isPlaceholder }">
<span
:class="isPlaceholder ? 'text-text-tertiary' : 'text-text-primary'"
class="truncate"
:style="{ fontFamily: `'${uiFont}', system-ui, sans-serif` }"
>{{ label }}</span>
</template>
</AppSelect>
</div>
</div>
<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>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Play audio cues for timer events</p>
</div>
<button
@click="toggleSoundEnabled"
role="switch"
:aria-checked="soundEnabled"
aria-label="Sound effects"
:class="[
'relative inline-flex h-5 w-9 items-center rounded-full transition-colors duration-150',
soundEnabled ? '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',
soundEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'
]"
/>
</button>
</div>
<div v-if="soundEnabled" class="space-y-4">
<div class="flex items-center justify-between">
<div>
<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"
placeholder="Synthesized"
:placeholder-value="'synthesized'"
@update:model-value="saveSoundSettings"
/>
</div>
</div>
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">Volume</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Audio cue loudness</p>
</div>
<div class="flex items-center gap-3">
<input
type="range"
min="0"
max="100"
step="1"
v-model.number="soundVolume"
@input="saveSoundSettings"
aria-label="Sound volume"
class="w-32 h-1 accent-accent bg-bg-elevated rounded-full appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-webkit-slider-thumb]:cursor-pointer"
/>
<span class="text-[0.75rem] text-text-secondary font-mono w-10 text-right">{{ soundVolume }}%</span>
</div>
</div>
<div>
<p class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium mb-3">Event sounds</p>
<div class="grid grid-cols-1 gap-2">
<div
v-for="evt in soundEventsList"
:key="evt.key"
class="flex items-center justify-between py-1"
>
<label :for="'sound-evt-' + evt.key" class="flex items-center gap-2.5 cursor-pointer">
<input
type="checkbox"
:id="'sound-evt-' + evt.key"
:checked="soundEventsMap[evt.key]"
@change="toggleSoundEvent(evt.key)"
class="w-3.5 h-3.5 rounded border-border-visible bg-bg-inset accent-accent cursor-pointer"
/>
<span class="text-[0.8125rem] text-text-primary">{{ evt.label }}</span>
</label>
<button
@click="testSound(evt.key)"
class="px-2 py-0.5 text-[0.6875rem] border border-border-subtle text-text-tertiary rounded hover:bg-bg-elevated hover:text-text-secondary transition-colors"
:aria-label="'Test ' + evt.label + ' sound'"
>
Test
</button>
</div>
</div>
</div>
</div>
<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>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Show the onboarding guide on the Dashboard</p>
</div>
<div class="flex items-center gap-3">
<button
@click="onboardingStore.reset()"
class="px-2.5 py-1 text-[0.6875rem] border border-border-subtle text-text-tertiary rounded-md hover:bg-bg-elevated hover:text-text-secondary transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
aria-label="Reset getting started checklist progress"
>
Reset
</button>
<button
@click="onboardingStore.isDismissed ? onboardingStore.restore() : onboardingStore.dismiss()"
role="switch"
:aria-checked="!onboardingStore.isDismissed"
aria-label="Getting Started checklist"
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',
!onboardingStore.isDismissed ? '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',
!onboardingStore.isDismissed ? 'translate-x-[18px]' : 'translate-x-[3px]'
]"
/>
</button>
</div>
</div>
<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>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Keep toast notifications visible until manually dismissed</p>
</div>
<button
@click="togglePersistentNotifications"
role="switch"
:aria-checked="persistentNotifications"
aria-label="Persistent notifications"
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',
persistentNotifications ? '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',
persistentNotifications ? 'translate-x-[18px]' : 'translate-x-[3px]'
]"
/>
</button>
</div>
</div>
</div>
<!-- Timer -->
<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-4">
<!-- Idle Detection toggle -->
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">Idle Detection</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Detect when you're idle and prompt to continue or stop</p>
</div>
<button
@click="toggleIdleDetection"
role="switch"
:aria-checked="idleDetection"
aria-label="Idle detection"
:class="[
'relative inline-flex h-5 w-9 items-center rounded-full transition-colors duration-150',
idleDetection ? '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',
idleDetection ? 'translate-x-[18px]' : 'translate-x-[3px]'
]"
/>
</button>
</div>
<!-- Idle sub-settings - progressive disclosure -->
<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>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Minutes before idle pause triggers</p>
</div>
<AppNumberInput
v-model="idleTimeout"
:min="1"
:max="60"
:step="1"
:precision="0"
suffix="min"
@update:model-value="saveSettings"
/>
</div>
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">Reminder Interval</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Minutes between reminders while running</p>
</div>
<AppNumberInput
v-model="reminderInterval"
:min="0"
:max="120"
:step="5"
:precision="0"
suffix="min"
@update:model-value="saveSettings"
/>
</div>
</div>
<!-- Divider -->
<div class="border-t border-border-subtle" />
<!-- App Tracking Mode -->
<div class="flex items-center justify-between">
<div>
<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"
placeholder="Auto-pause"
:placeholder-value="'auto'"
@update:model-value="saveSettings"
/>
</div>
</div>
<!-- Linux app tracking notice -->
<p v-if="platform === 'linux'" class="text-[0.6875rem] text-text-tertiary -mt-1">
On Linux, window visibility cannot be detected. The timer will only pause when the tracked app's process exits entirely.
</p>
<!-- Check Interval -->
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">Check Interval</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">How often to check idle & app visibility</p>
</div>
<AppNumberInput
v-model="appCheckInterval"
:min="1"
:max="60"
:step="1"
:precision="0"
suffix="sec"
@update:model-value="saveSettings"
/>
</div>
<!-- Divider -->
<div class="border-t border-border-subtle" />
<!-- Timeline Recording -->
<div :class="['flex items-center justify-between', platform === 'linux' && 'opacity-50 pointer-events-none']">
<div>
<p class="text-[0.8125rem] text-text-primary">Record app timeline</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Capture which apps and windows are active while the timer runs. Data stays local.</p>
</div>
<button
@click="toggleTimelineRecording"
role="switch"
:aria-checked="timelineRecording"
:disabled="platform === 'linux'"
aria-label="Record app timeline"
:class="[
'relative inline-flex h-5 w-9 items-center rounded-full transition-colors duration-150',
timelineRecording ? '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',
timelineRecording ? 'translate-x-[18px]' : 'translate-x-[3px]'
]"
/>
</button>
</div>
<!-- Linux timeline notice -->
<p v-if="platform === 'linux'" class="text-[0.6875rem] text-text-tertiary -mt-1">
Timeline recording is not available on Linux - Wayland's security model prevents detecting the foreground window.
</p>
<!-- Divider -->
<div class="border-t border-border-subtle" />
<!-- Timer Font -->
<div class="flex items-center justify-between">
<div>
<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"
placeholder="JetBrains Mono"
:placeholder-value="'JetBrains Mono'"
@update:model-value="saveTimerFontSetting"
>
<template #option="{ item, label }">
<span :style="{ fontFamily: `'${item.value}', monospace` }">{{ label }}</span>
</template>
<template #selected="{ label, isPlaceholder }">
<span
:class="isPlaceholder ? 'text-text-tertiary' : 'text-text-primary'"
class="truncate"
:style="{ fontFamily: `'${timerFont}', monospace` }"
>{{ label }}</span>
</template>
</AppSelect>
</div>
</div>
<!-- Divider -->
<div class="border-t border-border-subtle" />
<!-- Keyboard Shortcuts -->
<h3 class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Keyboard Shortcuts</h3>
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">Toggle Timer</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Start/stop the active timer</p>
</div>
<AppShortcutRecorder
:model-value="shortcutToggleTimer"
@update:model-value="(v: string) => { shortcutToggleTimer = v; saveShortcutSettings() }"
label="Toggle timer shortcut"
/>
</div>
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">Show App</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Bring the app to front</p>
</div>
<AppShortcutRecorder
:model-value="shortcutShowApp"
@update:model-value="(v: string) => { shortcutShowApp = v; saveShortcutSettings() }"
label="Show app shortcut"
/>
</div>
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">Quick Entry</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Open quick entry dialog from anywhere</p>
</div>
<AppShortcutRecorder
:model-value="shortcutQuickEntry"
@update:model-value="(v: string) => { shortcutQuickEntry = v; saveShortcutSettings() }"
label="Quick entry shortcut"
/>
</div>
<!-- Divider -->
<div class="border-t border-border-subtle" />
<!-- Goals -->
<h3 class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Goals</h3>
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">Daily Goal</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Target hours per day</p>
</div>
<AppNumberInput
v-model="dailyGoalHours"
:min="0"
:max="24"
:step="0.5"
:precision="1"
suffix="hrs"
@update:model-value="saveGoalSettings"
/>
</div>
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">Weekly Goal</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Target hours per week</p>
</div>
<AppNumberInput
v-model="weeklyGoalHours"
:min="0"
:max="168"
:step="1"
:precision="0"
suffix="hrs"
@update:model-value="saveGoalSettings"
/>
</div>
<!-- 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>
<div v-if="recurringEntries.length > 0" class="space-y-2">
<div
v-for="rec in recurringEntries"
:key="rec.id"
class="flex items-center justify-between py-2 px-3 bg-bg-inset rounded-lg"
>
<div class="flex-1 min-w-0">
<p class="text-[0.8125rem] text-text-primary truncate">
{{ getProjectName(rec.project_id) }}
<span v-if="rec.description" class="text-text-tertiary"> - {{ rec.description }}</span>
</p>
<p class="text-[0.6875rem] text-text-tertiary">
{{ formatRecurrenceRule(rec.recurrence_rule) }} at {{ rec.time_of_day }} - {{ formatDuration(rec.duration) }} - {{ formatMode(rec.mode) }}
</p>
</div>
<div class="flex items-center gap-2 ml-3">
<button
@click="recurringStore.toggleEnabled(rec)"
role="switch"
:aria-checked="rec.enabled === 1"
:aria-label="'Toggle ' + (rec.description || 'recurring entry')"
:class="[
'relative inline-flex h-4 w-7 items-center rounded-full transition-colors duration-150',
rec.enabled === 1 ? 'bg-status-running' : 'bg-bg-elevated'
]"
>
<span
:class="[
'inline-block h-3 w-3 transform rounded-full bg-text-primary transition-transform duration-150',
rec.enabled === 1 ? 'translate-x-[13px]' : 'translate-x-[2px]'
]"
/>
</button>
<button
@click="editRecurringEntry(rec)"
class="text-text-tertiary hover:text-text-primary transition-colors"
:aria-label="'Edit ' + (rec.description || 'recurring entry')"
>
<Pencil class="w-3.5 h-3.5" />
</button>
<button
@click="deleteRecurringEntry(rec.id!)"
class="text-text-tertiary hover:text-status-error transition-colors"
:aria-label="'Delete ' + (rec.description || 'recurring entry')"
>
<Trash2 class="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
<div v-if="!showRecurringForm">
<button
@click="showRecurringForm = true; resetRecurringForm()"
class="flex items-center gap-1.5 text-[0.8125rem] text-accent-text hover:text-accent transition-colors"
>
<Plus class="w-3.5 h-3.5" />
Add recurring entry
</button>
</div>
<!-- Add/Edit Recurring Entry Form (inline) -->
<div v-if="showRecurringForm" class="space-y-4 p-4 bg-bg-inset rounded-lg border border-border-subtle">
<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">Project</label>
<AppSelect
v-model="recProjectId"
:options="recProjects"
label-key="name"
value-key="id"
placeholder="Select project"
/>
</div>
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Task</label>
<AppSelect
v-model="recTaskId"
:options="recTasks"
label-key="name"
value-key="id"
placeholder="No task"
:disabled="!recProjectId"
/>
</div>
</div>
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Description</label>
<input
v-model="recDescription"
type="text"
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"
placeholder="What is this for?"
/>
</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">Duration</label>
<AppNumberInput
v-model="recDurationMinutes"
:min="1"
:max="480"
:step="5"
:precision="0"
suffix="min"
/>
</div>
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Time of Day</label>
<AppTimePicker
v-model:hour="recTimeHour"
v-model:minute="recTimeMinute"
/>
</div>
</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">Schedule</label>
<AppSelect
v-model="recPattern"
:options="recurrencePatterns"
label-key="label"
value-key="value"
/>
</div>
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Mode</label>
<AppSelect
v-model="recMode"
:options="recurringModes"
label-key="label"
value-key="value"
/>
</div>
</div>
<!-- Weekly day picker -->
<div v-if="recPattern === 'weekly'" class="flex gap-2">
<button
v-for="day in weekDays"
:key="day.value"
@click="toggleWeekDay(day.value)"
:class="[
'px-2 py-1 text-[0.75rem] rounded border transition-colors',
recWeeklyDays.includes(day.value)
? 'bg-accent text-bg-base border-accent'
: 'border-border-subtle text-text-secondary hover:bg-bg-elevated'
]"
>
{{ day.label }}
</button>
</div>
<!-- Monthly day picker -->
<div v-if="recPattern === 'monthly'">
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Day of month</label>
<AppNumberInput
v-model="recMonthlyDay"
:min="1"
:max="31"
:step="1"
:precision="0"
/>
</div>
<div class="flex justify-end gap-2">
<button
@click="showRecurringForm = false; editingRecurring = null"
class="px-3 py-1.5 text-[0.75rem] border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated transition-colors"
>
Cancel
</button>
<button
@click="saveRecurringEntry"
:disabled="!recProjectId"
class="px-3 py-1.5 text-[0.75rem] bg-accent text-bg-base rounded-lg hover:bg-accent-hover transition-colors disabled:opacity-40"
>
{{ editingRecurring ? 'Update' : 'Add' }}
</button>
</div>
</div>
<!-- Divider -->
<div class="border-t border-border-subtle" />
<!-- Entry Templates -->
<h3 class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium">Entry Templates</h3>
<div v-if="entryTemplatesStore.templates.length > 0" class="space-y-2">
<div
v-for="tpl in entryTemplatesStore.templates"
:key="tpl.id"
class="flex items-center justify-between py-2 px-3 bg-bg-inset rounded-lg"
>
<div class="flex-1 min-w-0">
<p class="text-[0.8125rem] text-text-primary truncate">{{ tpl.name }}</p>
<p class="text-[0.6875rem] text-text-tertiary">
{{ getTemplateProjectName(tpl.project_id) }}
<span v-if="tpl.duration"> - {{ formatTemplateDuration(tpl.duration) }}</span>
</p>
</div>
<button
@click="deleteEntryTemplate(tpl.id!)"
class="text-text-tertiary hover:text-status-error transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
:aria-label="'Delete template ' + tpl.name"
>
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</div>
</div>
<p v-else class="text-[0.75rem] text-text-tertiary">No saved templates. Use "Save as Template" in the Entries view.</p>
</div>
</div>
<!-- Billing -->
<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="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"
/>
</div>
<div class="border-t border-border-subtle" />
<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]'
]"
/>
</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="border-t border-border-subtle" />
<!-- 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>
<label for="biz-name" class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Business Name</label>
<input
v-model="businessName"
type="text"
id="biz-name"
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="Your Company Name"
@change="saveBusinessSettings"
/>
</div>
<div>
<label for="biz-address" class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Address</label>
<textarea
v-model="businessAddress"
id="biz-address"
rows="3"
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 resize-none"
placeholder="123 Business St&#10;City, State ZIP"
@change="saveBusinessSettings"
></textarea>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="biz-email" class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Email</label>
<input
v-model="businessEmail"
type="email"
id="biz-email"
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="billing@company.com"
@change="saveBusinessSettings"
/>
</div>
<div>
<label for="biz-phone" class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Phone</label>
<input
v-model="businessPhone"
type="tel"
id="biz-phone"
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="(555) 123-4567"
@change="saveBusinessSettings"
/>
</div>
</div>
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Logo</label>
<div class="flex items-center gap-4">
<div
v-if="businessLogo"
class="w-20 h-12 border border-border-subtle rounded flex items-center justify-center overflow-hidden bg-white"
>
<img :src="businessLogo" alt="Business logo preview" class="max-w-full max-h-full object-contain" />
</div>
<div class="flex gap-2">
<button
@click="uploadLogo"
class="px-3 py-1.5 text-[0.75rem] border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated transition-colors"
>
{{ businessLogo ? 'Change' : 'Upload' }}
</button>
<button
v-if="businessLogo"
@click="removeLogo"
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>
</div>
</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>
<!-- Calendar -->
<div v-if="activeTab === 'calendar'" id="tabpanel-calendar" role="tabpanel" aria-labelledby="tab-calendar" tabindex="0">
<h2 class="text-[1.25rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-6">Calendar</h2>
<p class="text-[0.6875rem] text-text-tertiary mb-4">Import ICS calendar files or subscribe to URLs to see external events on your calendar view.</p>
<!-- Source list -->
<div v-if="calendarSources.length > 0" class="space-y-2 mb-5">
<div
v-for="source in calendarSources"
:key="source.id"
class="flex items-center justify-between py-2.5 px-3 bg-bg-inset rounded-lg"
>
<div class="flex items-center gap-3 flex-1 min-w-0">
<component
:is="source.source_type === 'url' ? Globe : FileText"
class="w-4 h-4 shrink-0 text-text-tertiary"
:stroke-width="1.5"
aria-hidden="true"
/>
<div class="min-w-0">
<p class="text-[0.8125rem] text-text-primary truncate">{{ source.name }}</p>
<p class="text-[0.6875rem] text-text-tertiary truncate">
{{ source.source_type === 'url' ? source.url : 'Local file' }}
<span v-if="source.last_synced"> - synced {{ formatSyncedDate(source.last_synced) }}</span>
</p>
</div>
</div>
<div class="flex items-center gap-2 ml-3">
<button
v-if="source.source_type === 'url'"
@click="syncCalendarSource(source)"
:disabled="syncingSourceId === source.id"
class="p-1.5 text-text-tertiary hover:text-text-primary transition-colors disabled:opacity-40"
:aria-label="'Sync ' + source.name"
>
<RefreshCw class="w-3.5 h-3.5" :class="syncingSourceId === source.id ? 'animate-spin' : ''" />
</button>
<button
@click="toggleCalendarSourceEnabled(source)"
role="switch"
:aria-checked="!!source.enabled"
:aria-label="'Toggle ' + source.name"
:class="[
'relative inline-flex h-4 w-7 items-center rounded-full transition-colors duration-150',
source.enabled ? 'bg-status-running' : 'bg-bg-elevated'
]"
>
<span
:class="[
'inline-block h-3 w-3 transform rounded-full bg-text-primary transition-transform duration-150',
source.enabled ? 'translate-x-[13px]' : 'translate-x-[2px]'
]"
/>
</button>
<button
@click="deleteCalendarSource(source.id!)"
class="text-text-tertiary hover:text-status-error transition-colors"
:aria-label="'Delete ' + source.name"
>
<Trash2 class="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
<div v-else class="py-6 text-center text-[0.75rem] text-text-tertiary mb-5">
No calendar sources added yet.
</div>
<!-- Add Source Form -->
<div v-if="!showCalendarForm">
<button
@click="showCalendarForm = true; resetCalendarForm()"
class="flex items-center gap-1.5 text-[0.8125rem] text-accent-text hover:text-accent transition-colors"
>
<Plus class="w-3.5 h-3.5" />
Add source
</button>
</div>
<div v-if="showCalendarForm" class="space-y-4 p-4 bg-bg-inset rounded-lg border border-border-subtle">
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Name</label>
<input
v-model="calSourceName"
type="text"
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"
placeholder="My Calendar"
/>
</div>
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Type</label>
<div class="flex gap-2">
<button
@click="calSourceType = 'file'"
:class="[
'px-3 py-1.5 text-[0.75rem] rounded-lg border transition-colors',
calSourceType === 'file'
? 'bg-accent text-bg-base border-accent'
: 'border-border-subtle text-text-secondary hover:bg-bg-elevated'
]"
>
File
</button>
<button
@click="calSourceType = 'url'"
:class="[
'px-3 py-1.5 text-[0.75rem] rounded-lg border transition-colors',
calSourceType === 'url'
? 'bg-accent text-bg-base border-accent'
: 'border-border-subtle text-text-secondary hover:bg-bg-elevated'
]"
>
URL
</button>
</div>
</div>
<div v-if="calSourceType === 'url'">
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">URL</label>
<input
v-model="calSourceUrl"
type="url"
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"
placeholder="https://calendar.example.com/feed.ics"
/>
</div>
<div v-if="calSourceType === 'url'">
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Sync Interval</label>
<div class="w-48">
<AppSelect
v-model="calSyncInterval"
:options="calSyncIntervalOptions"
label-key="label"
value-key="value"
/>
</div>
</div>
<div v-if="calSourceType === 'file'">
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">ICS File</label>
<div class="flex items-center gap-3">
<button
@click="pickCalendarFile"
class="px-3 py-1.5 text-[0.75rem] border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated transition-colors"
>
Choose File
</button>
<span v-if="calSelectedFileName" class="text-[0.75rem] text-text-secondary truncate">{{ calSelectedFileName }}</span>
</div>
</div>
<div class="flex justify-end gap-2">
<button
@click="showCalendarForm = false"
class="px-3 py-1.5 text-[0.75rem] border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated transition-colors"
>
Cancel
</button>
<button
@click="saveCalendarSource"
:disabled="!calSourceName || (calSourceType === 'url' && !calSourceUrl) || (calSourceType === 'file' && !calFileContent)"
class="px-3 py-1.5 text-[0.75rem] bg-accent text-bg-base rounded-lg hover:bg-accent-hover transition-colors disabled:opacity-40"
>
Add
</button>
</div>
</div>
</div>
<!-- Data -->
<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-4">
<!-- Export -->
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">Export All Data</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Download as JSON backup</p>
</div>
<button
@click="exportData"
class="px-4 py-1.5 border border-border-visible text-text-primary text-[0.8125rem] rounded-lg hover:bg-bg-elevated transition-colors duration-150"
>
Export
</button>
</div>
<!-- Last exported -->
<div v-if="lastExported" class="flex items-center justify-between" role="status">
<div>
<p class="text-[0.8125rem] text-text-primary">Last exported</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">{{ lastExportedFormatted }}</p>
</div>
</div>
<div class="border-t border-border-subtle" />
<!-- Auto-backup on close -->
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">Auto-backup on close</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Save a backup when the app closes</p>
</div>
<button @click="toggleAutoBackup" role="switch" :aria-checked="autoBackupEnabled" aria-label="Auto-backup on close"
class="relative inline-flex h-5 w-9 items-center rounded-full transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
:class="autoBackupEnabled ? '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',
autoBackupEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]']" />
</button>
</div>
<!-- Backup path -->
<div v-if="autoBackupEnabled" class="flex items-center justify-between pl-4 border-l-2 border-border-subtle ml-1">
<div class="flex-1 min-w-0">
<p class="text-[0.8125rem] text-text-primary">Backup directory</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5 truncate">{{ backupPath || 'Not set' }}</p>
</div>
<button @click="chooseBackupPath"
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"
aria-label="Choose backup directory">
{{ backupPath ? 'Change' : 'Choose' }}
</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>
<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.8125rem] 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">
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Format</label>
<AppSelect
v-model="importFormat"
:options="importFormats"
label-key="label"
value-key="value"
/>
</div>
<button
@click="handleImportFile"
class="flex items-center gap-2 px-4 py-2 border border-border-visible text-text-primary text-xs rounded-lg hover:bg-bg-elevated transition-colors duration-150"
>
<Upload class="w-3.5 h-3.5" aria-hidden="true" />
Choose File
</button>
</div>
<div v-if="importFileName" class="mb-4">
<p class="text-[0.75rem] text-text-secondary mb-2">File: {{ importFileName }}</p>
<!-- CSV Preview -->
<div v-if="importPreview.length > 0" class="overflow-x-auto mb-3">
<table class="text-[0.6875rem]">
<thead v-if="importPreview.length > 0">
<tr class="text-text-tertiary font-medium">
<th v-for="(cell, j) in importPreview[0]" :key="j" class="pr-4 py-0.5 whitespace-nowrap text-left font-medium">{{ cell }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, i) in importPreview.slice(1)" :key="i" class="text-text-secondary">
<td v-for="(cell, j) in row" :key="j" class="pr-4 py-0.5 whitespace-nowrap">{{ cell }}</td>
</tr>
</tbody>
</table>
</div>
<!-- Date format detection -->
<div v-if="importFormat !== 'json' && importFormat !== 'generic'" class="flex items-center gap-3 mb-3">
<template v-if="importDateDetected !== 'ambiguous'">
<span class="text-[0.6875rem] text-text-tertiary">
Date format detected: {{ importDateFormat === 'DMY' ? 'DD/MM/YYYY' : 'MM/DD/YYYY' }}
</span>
<button
@click="importDateDetected = 'ambiguous'"
class="text-[0.6875rem] text-accent hover:text-accent-hover transition-colors"
>
Change
</button>
</template>
<template v-else>
<label class="text-[0.6875rem] text-text-tertiary">Date format:</label>
<div class="w-36">
<AppSelect
v-model="importDateFormat"
:options="dateFormatOptions"
label-key="label"
value-key="value"
/>
</div>
</template>
</div>
<button
@click="executeImport"
:disabled="isImporting"
class="px-4 py-2 bg-accent text-bg-base text-xs font-medium rounded-lg hover:bg-accent-hover transition-colors duration-150 disabled:opacity-40"
>
{{ isImporting ? 'Importing...' : 'Import' }}
</button>
</div>
<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="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">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>
</div>
</div>
</div>
</div>
</div>
</Transition>
</div>
</div>
<!-- Clear Data Confirmation Dialog -->
<Transition name="modal">
<div
v-if="showClearDataDialog"
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
@click.self="showClearDataDialog = false"
>
<div ref="clearDialogRef" role="alertdialog" aria-modal="true" aria-labelledby="clear-data-title" aria-describedby="clear-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="clear-data-title" class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-2">Clear All Data</h2>
<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-text mb-6">
All time entries, projects, clients, and invoices will be permanently deleted.
</p>
<div class="flex justify-end gap-3">
<button
@click="showClearDataDialog = 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="clearAllData"
class="px-4 py-2 bg-status-error text-white font-medium rounded-lg hover:bg-status-error/80 transition-colors duration-150"
>
Delete Everything
</button>
</div>
</div>
</div>
</Transition>
<JsonImportWizard
:show="showJsonImportWizard"
@close="showJsonImportWizard = false"
@imported="showJsonImportWizard = false"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, markRaw } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { Settings as SettingsIcon, Clock, Receipt, Database, Calendar as CalendarIcon, Plus, Minus, Upload, Pencil, Trash2, RefreshCw, Globe, FileText } from 'lucide-vue-next'
import { useSettingsStore } from '../stores/settings'
import { useToastStore } from '../stores/toast'
import { useOnboardingStore } from '../stores/onboarding'
import { useRecurringStore } from '../stores/recurring'
import type { RecurringEntry } from '../stores/recurring'
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, mapClockifyCSV, mapHarvestCSV, mapGenericCSV, detectDateFormat, findCol, type ImportEntry, type DateFormat, type DateDetectResult } 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()
const recurringStore = useRecurringStore()
const entryTemplatesStore = useEntryTemplatesStore()
const onboardingStore = useOnboardingStore()
const recurringEntries = computed(() => recurringStore.entries)
// Recurring entry form state
const showRecurringForm = ref(false)
const editingRecurring = ref<RecurringEntry | null>(null)
const recProjectId = ref<number | null>(null)
const recTaskId = ref<number | null>(null)
const recDescription = ref('')
const recDurationMinutes = ref(60)
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 }>>([])
const weekDays = [
{ value: 'mon', label: 'Mon' },
{ value: 'tue', label: 'Tue' },
{ value: 'wed', label: 'Wed' },
{ value: 'thu', label: 'Thu' },
{ value: 'fri', label: 'Fri' },
{ value: 'sat', label: 'Sat' },
{ value: 'sun', label: 'Sun' },
]
const recurrencePatterns = [
{ value: 'daily', label: 'Daily' },
{ value: 'weekdays', label: 'Weekdays (Mon-Fri)' },
{ value: 'weekly', label: 'Weekly' },
{ value: 'monthly', label: 'Monthly' },
]
const recurringModes = [
{ value: 'auto_create', label: 'Auto-create entry' },
{ value: 'prompt', label: 'Show notification' },
{ value: 'prefill_timer', label: 'Pre-fill timer' },
]
// Tabs
const tabs = [
{ id: 'general', label: 'General', icon: markRaw(SettingsIcon) },
{ id: 'timer', label: 'Timer', icon: markRaw(Clock) },
{ id: 'billing', label: 'Billing', icon: markRaw(Receipt) },
{ id: 'calendar', label: 'Calendar', icon: markRaw(CalendarIcon) },
{ id: 'data', label: 'Data', icon: markRaw(Database) },
]
const activeTab = ref('general')
function onTabKeydown(e: KeyboardEvent) {
const tabIds = tabs.map(t => t.id)
const current = tabIds.indexOf(activeTab.value)
let next = current
if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
e.preventDefault()
next = (current + 1) % tabIds.length
} else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
e.preventDefault()
next = (current - 1 + tabIds.length) % tabIds.length
} else if (e.key === 'Home') {
e.preventDefault()
next = 0
} else if (e.key === 'End') {
e.preventDefault()
next = tabIds.length - 1
} else {
return
}
activeTab.value = tabIds[next]
document.getElementById(`tab-${tabIds[next]}`)?.focus()
}
// Settings state
const hourlyRate = ref(0)
const idleDetection = ref(true)
const idleTimeout = ref(5)
const reminderInterval = ref(15)
const appTrackingMode = ref('auto')
const appCheckInterval = ref(5)
const zoomLevel = ref(100)
const locale = ref('system')
const currency = ref('USD')
const currencyOptions = getCurrencies()
const themeMode = ref('dark')
const accentColor = ref('amber')
const shortcutToggleTimer = ref('CmdOrCtrl+Shift+T')
const shortcutShowApp = ref('CmdOrCtrl+Shift+Z')
const shortcutQuickEntry = ref('CmdOrCtrl+Shift+N')
const timelineRecording = ref(false)
const platform = ref('')
const timerFont = ref('JetBrains Mono')
const timerFontOptions = TIMER_FONTS
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)
// Sound settings
const soundEnabled = ref(false)
const soundMode = ref('synthesized')
const soundVolume = ref(70)
const soundEventsMap = ref<Record<SoundEvent, boolean>>({ ...DEFAULT_EVENTS })
const soundEventsList = SOUND_EVENTS
const soundModes = [
{ value: 'synthesized', label: 'Synthesized' },
{ value: 'system', label: 'System' },
{ value: 'custom', label: 'Custom' },
]
// Goal settings
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)
const roundingMethod = ref('nearest')
// Business identity settings
const businessName = ref('')
const businessAddress = ref('')
const businessEmail = ref('')
const businessPhone = ref('')
const businessLogo = ref('')
const roundingIncrements = [
{ value: 1, label: '1 minute' },
{ value: 5, label: '5 minutes' },
{ value: 6, label: '6 minutes' },
{ value: 10, label: '10 minutes' },
{ value: 15, label: '15 minutes' },
{ value: 30, label: '30 minutes' },
{ value: 60, label: '1 hour' },
]
const roundingMethods = [
{ value: 'nearest', label: 'Nearest' },
{ value: 'up', label: 'Round up' },
{ value: 'down', label: 'Round down' },
]
const themeModes = [
{ value: 'dark', label: 'Dark' },
{ value: 'light', label: 'Light' },
{ value: 'system', label: 'System' },
]
const accentColors = [
{ value: 'amber', label: 'Amber', color: '#D97706' },
{ value: 'blue', label: 'Blue', color: '#3B82F6' },
{ value: 'purple', label: 'Purple', color: '#8B5CF6' },
{ value: 'green', label: 'Green', color: '#10B981' },
{ value: 'red', label: 'Red', color: '#EF4444' },
{ value: 'pink', label: 'Pink', color: '#EC4899' },
{ value: 'cyan', label: 'Cyan', color: '#06B6D4' },
]
const reduceMotionOptions = [
{ label: 'System (default)', value: 'system' },
{ label: 'On', value: 'on' },
{ label: 'Off', value: 'off' },
]
const appTrackingModes = [
{ value: 'auto', label: 'Auto-pause/resume' },
{ value: 'notify', label: 'Notify + auto-resume' },
{ value: 'prompt', label: 'Prompt user' },
]
// Calendar source types
interface CalendarSource {
id?: number
name: string
source_type: string
url?: string
last_synced?: string
sync_interval?: number
enabled: number
}
// Calendar source state
const calendarSources = ref<CalendarSource[]>([])
const showCalendarForm = ref(false)
const calSourceName = ref('')
const calSourceType = ref<'file' | 'url'>('file')
const calSourceUrl = ref('')
const calSyncInterval = ref(60)
const calFileContent = ref('')
const calSelectedFileName = ref('')
const syncingSourceId = ref<number | null>(null)
const calSyncIntervalOptions = [
{ value: 15, label: 'Every 15 minutes' },
{ value: 30, label: 'Every 30 minutes' },
{ value: 60, label: 'Every hour' },
{ value: 120, label: 'Every 2 hours' },
]
function resetCalendarForm() {
calSourceName.value = ''
calSourceType.value = 'file'
calSourceUrl.value = ''
calSyncInterval.value = 60
calFileContent.value = ''
calSelectedFileName.value = ''
}
function formatSyncedDate(dateStr: string): string {
try {
const d = new Date(dateStr)
const now = new Date()
const diffMs = now.getTime() - d.getTime()
const diffMin = Math.floor(diffMs / 60000)
if (diffMin < 1) return 'just now'
if (diffMin < 60) return `${diffMin}m ago`
const diffHours = Math.floor(diffMin / 60)
if (diffHours < 24) return `${diffHours}h ago`
const diffDays = Math.floor(diffHours / 24)
return `${diffDays}d ago`
} catch {
return dateStr
}
}
async function fetchCalendarSources() {
try {
calendarSources.value = await invoke<CalendarSource[]>('get_calendar_sources')
} catch (e) {
console.error('Failed to fetch calendar sources:', e)
}
}
async function pickCalendarFile() {
try {
const { open: openFileDialog } = await import('@tauri-apps/plugin-dialog')
const selected = await openFileDialog({
filters: [{ name: 'ICS Calendar Files', extensions: ['ics'] }]
})
if (!selected) return
const { readTextFile } = await import('@tauri-apps/plugin-fs')
const content: string = await readTextFile(selected as string)
calFileContent.value = content
calSelectedFileName.value = (selected as string).split(/[/\\]/).pop() || 'file.ics'
} catch (e) {
console.error('Failed to read ICS file:', e)
toastStore.error('Failed to read calendar file')
}
}
async function saveCalendarSource() {
if (!calSourceName.value) return
try {
const source: any = {
name: calSourceName.value,
sourceType: calSourceType.value,
enabled: 1,
}
if (calSourceType.value === 'url') {
source.url = calSourceUrl.value
source.syncInterval = calSyncInterval.value
}
const sourceId = await invoke<number>('create_calendar_source', { source })
// For file type, import the ICS content
if (calSourceType.value === 'file' && calFileContent.value) {
const count = await invoke<number>('import_ics_file', {
sourceId,
content: calFileContent.value
})
toastStore.success(`Imported ${count} events from ${calSelectedFileName.value}`)
}
showCalendarForm.value = false
resetCalendarForm()
await fetchCalendarSources()
} catch (e) {
console.error('Failed to create calendar source:', e)
toastStore.error('Failed to add calendar source')
}
}
async function syncCalendarSource(source: CalendarSource) {
if (!source.id || !source.url) return
syncingSourceId.value = source.id
try {
const resp = await fetch(source.url)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const content = await resp.text()
const count = await invoke<number>('import_ics_file', {
sourceId: source.id,
content
})
toastStore.success(`Synced ${count} events from ${source.name}`)
await fetchCalendarSources()
} catch (e) {
console.error('Calendar sync failed for', source.name, e)
toastStore.error(`Failed to sync ${source.name}`)
} finally {
syncingSourceId.value = null
}
}
async function toggleCalendarSourceEnabled(source: CalendarSource) {
if (!source.id) return
try {
const updated = { ...source, enabled: source.enabled ? 0 : 1, sourceType: source.source_type }
await invoke('update_calendar_source', { source: updated })
await fetchCalendarSources()
} catch (e) {
console.error('Failed to toggle calendar source:', e)
}
}
async function deleteCalendarSource(id: number) {
try {
await invoke('delete_calendar_source', { id })
await fetchCalendarSources()
toastStore.success('Calendar source removed')
} catch (e) {
console.error('Failed to delete calendar source:', e)
toastStore.error('Failed to remove calendar source')
}
}
// Dialog state
const showClearDataDialog = ref(false)
const clearDialogRef = ref<HTMLElement | null>(null)
const { activate: activateClearTrap, deactivate: deactivateClearTrap } = useFocusTrap()
watch(showClearDataDialog, (val) => {
if (val) {
setTimeout(() => {
if (clearDialogRef.value) activateClearTrap(clearDialogRef.value, { onDeactivate: () => { showClearDataDialog.value = false } })
}, 50)
} else {
deactivateClearTrap()
}
})
// 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 ''
return new Date(lastExported.value).toLocaleString()
})
async function toggleAutoBackup() {
autoBackupEnabled.value = !autoBackupEnabled.value
await settingsStore.updateSetting('auto_backup', autoBackupEnabled.value ? 'true' : 'false')
}
async function chooseBackupPath() {
try {
const { open } = await import('@tauri-apps/plugin-dialog')
const selected = await open({ directory: true, title: 'Choose backup directory' })
if (selected && typeof selected === 'string') {
backupPath.value = selected
await settingsStore.updateSetting('backup_path', selected)
}
} catch {
// User cancelled
}
}
// Import state
const importFormat = ref('toggl')
const importFileContent = ref('')
const importFileName = ref('')
const importPreview = ref<string[][]>([])
const importStatus = ref('')
const isImporting = ref(false)
const importDateDetected = ref<DateDetectResult>('ambiguous')
const importDateFormat = ref<DateFormat>('MDY')
const dateFormatOptions = [
{ label: 'MM/DD/YYYY', value: 'MDY' },
{ label: 'DD/MM/YYYY', value: 'DMY' },
]
const importFormats = [
{ label: 'Toggl CSV', value: 'toggl' },
{ label: 'Clockify CSV', value: 'clockify' },
{ label: 'Harvest CSV', value: 'harvest' },
{ label: 'Generic CSV', value: 'generic' },
{ label: 'ZeroClock JSON', value: 'json' },
]
// Zoom steps
const zoomSteps = [80, 90, 100, 110, 120, 130, 150]
function increaseZoom() {
const currentIndex = zoomSteps.indexOf(zoomLevel.value)
if (currentIndex < zoomSteps.length - 1) {
zoomLevel.value = zoomSteps[currentIndex + 1]
} else if (currentIndex === -1) {
const next = zoomSteps.find(s => s > zoomLevel.value)
if (next) zoomLevel.value = next
}
applyZoom()
}
function decreaseZoom() {
const currentIndex = zoomSteps.indexOf(zoomLevel.value)
if (currentIndex > 0) {
zoomLevel.value = zoomSteps[currentIndex - 1]
} else if (currentIndex === -1) {
const prev = [...zoomSteps].reverse().find(s => s < zoomLevel.value)
if (prev) zoomLevel.value = prev
}
applyZoom()
}
function applyZoom() {
const app = document.getElementById('app')
if (app) {
(app.style as any).zoom = `${zoomLevel.value}%`
}
resetPositionCache()
settingsStore.updateSetting('ui_zoom', zoomLevel.value.toString())
}
// Toggle idle detection with auto-save
function toggleIdleDetection() {
idleDetection.value = !idleDetection.value
saveSettings()
}
// Toggle timeline recording
async function toggleTimelineRecording() {
timelineRecording.value = !timelineRecording.value
await settingsStore.updateSetting('timeline_recording', timelineRecording.value ? 'on' : 'off')
}
// Save settings (called on any change)
async function saveSettings() {
try {
await settingsStore.updateSetting('hourly_rate', hourlyRate.value.toString())
await settingsStore.updateSetting('idle_detection', idleDetection.value.toString())
await settingsStore.updateSetting('idle_timeout', idleTimeout.value.toString())
await settingsStore.updateSetting('reminder_interval', reminderInterval.value.toString())
await settingsStore.updateSetting('app_tracking_mode', appTrackingMode.value)
await settingsStore.updateSetting('app_check_interval', appCheckInterval.value.toString())
} catch (error) {
console.error('Failed to save settings:', error)
toastStore.error('Failed to save settings')
}
}
// Save locale/currency settings
async function saveLocaleSettings() {
await settingsStore.updateSetting('locale', locale.value)
await settingsStore.updateSetting('currency', currency.value)
}
// Save theme settings
async function saveThemeSettings() {
await settingsStore.updateSetting('theme_mode', themeMode.value)
await settingsStore.updateSetting('accent_color', accentColor.value)
}
// Save reduce motion setting
async function saveReduceMotionSetting() {
await settingsStore.updateSetting('reduce_motion', reduceMotion.value)
}
// Toggle dyslexia mode
async function toggleDyslexiaMode() {
dyslexiaMode.value = !dyslexiaMode.value
await settingsStore.updateSetting('dyslexia_mode', dyslexiaMode.value ? 'true' : 'false')
}
// Save UI font setting
async function saveUIFontSetting() {
await settingsStore.updateSetting('ui_font', uiFont.value)
}
// Save shortcut settings
async function saveShortcutSettings() {
await settingsStore.updateSetting('shortcut_toggle_timer', shortcutToggleTimer.value)
await settingsStore.updateSetting('shortcut_show_app', shortcutShowApp.value)
await settingsStore.updateSetting('shortcut_quick_entry', shortcutQuickEntry.value)
}
// Save timer font setting
async function saveTimerFontSetting() {
await settingsStore.updateSetting('timer_font', timerFont.value)
loadAndApplyTimerFont(timerFont.value)
}
// Toggle sound enabled
async function toggleSoundEnabled() {
soundEnabled.value = !soundEnabled.value
await settingsStore.updateSetting('sound_enabled', soundEnabled.value ? 'true' : 'false')
}
// Toggle persistent notifications
async function togglePersistentNotifications() {
persistentNotifications.value = !persistentNotifications.value
toastStore.setPersistentNotifications(persistentNotifications.value)
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)
await settingsStore.updateSetting('sound_volume', soundVolume.value.toString())
}
// Toggle individual sound event
async function toggleSoundEvent(key: SoundEvent) {
soundEventsMap.value[key] = !soundEventsMap.value[key]
await settingsStore.updateSetting('sound_events', JSON.stringify(soundEventsMap.value))
}
// Test a sound event in the settings UI
function testSound(key: SoundEvent) {
audioEngine.playTest(key)
}
// Save goal settings
async function saveGoalSettings() {
await settingsStore.updateSetting('daily_goal_hours', dailyGoalHours.value.toString())
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) {
recTasks.value = await invoke('get_tasks', { projectId: pid })
} else {
recTasks.value = []
}
recTaskId.value = null
})
// Recurring entry helpers
function getProjectName(id: number): string {
return recProjects.value.find(p => p.id === id)?.name || 'Unknown'
}
function formatRecurrenceRule(rule: string): string {
if (rule === 'daily') return 'Daily'
if (rule === 'weekdays') return 'Weekdays'
if (rule.startsWith('weekly:')) {
const days = rule.replace('weekly:', '').split(',')
return 'Weekly: ' + days.map(d => d.charAt(0).toUpperCase() + d.slice(1)).join(', ')
}
if (rule.startsWith('monthly:')) {
return 'Monthly on day ' + rule.replace('monthly:', '')
}
return rule
}
function formatMode(mode: string): string {
if (mode === 'auto_create') return 'Auto-create'
if (mode === 'prompt') return 'Notify'
if (mode === 'prefill_timer') return 'Pre-fill'
return mode
}
function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
if (h > 0 && m > 0) return `${h}h ${m}m`
if (h > 0) return `${h}h`
return `${m}m`
}
// Entry template helpers
function getTemplateProjectName(projectId: number): string {
return recProjects.value.find(p => p.id === projectId)?.name || 'Unknown'
}
function formatTemplateDuration(seconds: number): string {
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
if (h > 0 && m > 0) return `${h}h ${m}m`
if (h > 0) return `${h}h`
if (m > 0) return `${m}m`
return '0m'
}
async function deleteEntryTemplate(id: number) {
await entryTemplatesStore.deleteTemplate(id)
}
function buildRecurrenceRule(): string {
if (recPattern.value === 'weekly') {
return 'weekly:' + recWeeklyDays.value.join(',')
}
if (recPattern.value === 'monthly') {
return 'monthly:' + recMonthlyDay.value
}
return recPattern.value // 'daily' or 'weekdays'
}
function toggleWeekDay(day: string) {
const idx = recWeeklyDays.value.indexOf(day)
if (idx >= 0) {
recWeeklyDays.value.splice(idx, 1)
} else {
recWeeklyDays.value.push(day)
}
}
function resetRecurringForm() {
recProjectId.value = null
recTaskId.value = null
recDescription.value = ''
recDurationMinutes.value = 60
recPattern.value = 'daily'
recWeeklyDays.value = []
recMonthlyDay.value = 1
recTimeOfDay.value = '09:00'
recTimeHour.value = 9
recTimeMinute.value = 0
recMode.value = 'prompt'
}
function editRecurringEntry(rec: RecurringEntry) {
editingRecurring.value = rec
recProjectId.value = rec.project_id
recTaskId.value = rec.task_id || null
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
if (rec.recurrence_rule === 'daily' || rec.recurrence_rule === 'weekdays') {
recPattern.value = rec.recurrence_rule
} else if (rec.recurrence_rule.startsWith('weekly:')) {
recPattern.value = 'weekly'
recWeeklyDays.value = rec.recurrence_rule.replace('weekly:', '').split(',')
} else if (rec.recurrence_rule.startsWith('monthly:')) {
recPattern.value = 'monthly'
recMonthlyDay.value = parseInt(rec.recurrence_rule.replace('monthly:', ''))
}
showRecurringForm.value = true
}
async function saveRecurringEntry() {
if (!recProjectId.value) return
const entry: RecurringEntry = {
id: editingRecurring.value?.id,
project_id: recProjectId.value,
task_id: recTaskId.value || undefined,
description: recDescription.value || undefined,
duration: recDurationMinutes.value * 60,
recurrence_rule: buildRecurrenceRule(),
time_of_day: recTimeOfDay.value,
mode: recMode.value as 'auto_create' | 'prompt' | 'prefill_timer',
enabled: 1,
}
if (editingRecurring.value?.id) {
await recurringStore.updateEntry(entry)
} else {
await recurringStore.createEntry(entry)
}
showRecurringForm.value = false
editingRecurring.value = null
resetRecurringForm()
}
async function deleteRecurringEntry(id: number) {
await recurringStore.deleteEntry(id)
}
// Toggle rounding
function toggleRounding() {
roundingEnabled.value = !roundingEnabled.value
saveRoundingSettings()
}
// Save rounding settings
async function saveRoundingSettings() {
await settingsStore.updateSetting('rounding_enabled', roundingEnabled.value.toString())
await settingsStore.updateSetting('rounding_increment', roundingIncrement.value.toString())
await settingsStore.updateSetting('rounding_method', roundingMethod.value)
}
// Save business identity settings
async function saveBusinessSettings() {
await settingsStore.updateSetting('business_name', businessName.value)
await settingsStore.updateSetting('business_address', businessAddress.value)
await settingsStore.updateSetting('business_email', businessEmail.value)
await settingsStore.updateSetting('business_phone', businessPhone.value)
}
async function uploadLogo() {
try {
const { open } = await import('@tauri-apps/plugin-dialog')
const selected = await open({
filters: [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg'] }]
})
if (!selected) return
const { readFile } = await import('@tauri-apps/plugin-fs')
const bytes = await readFile(selected as string)
const ext = (selected as string).toLowerCase().endsWith('.png') ? 'png' : 'jpeg'
const base64 = btoa(String.fromCharCode(...new Uint8Array(bytes)))
const dataUrl = `data:image/${ext};base64,${base64}`
businessLogo.value = dataUrl
await settingsStore.updateSetting('business_logo', dataUrl)
} catch (e) {
console.error('Failed to upload logo:', e)
toastStore.error('Failed to upload logo')
}
}
async function removeLogo() {
businessLogo.value = ''
await settingsStore.updateSetting('business_logo', '')
}
// Import file handling
async function handleImportFile() {
try {
const { open } = await import('@tauri-apps/plugin-dialog')
const selected = await open({
filters: [
{ name: 'Data Files', extensions: ['csv', 'json'] }
]
})
if (!selected) return
const filePath = selected as string
// @ts-ignore - plugin-fs types are available at runtime
const { readTextFile } = await import('@tauri-apps/plugin-fs')
const content: string = await readTextFile(filePath)
importFileContent.value = content
importFileName.value = filePath.split(/[/\\]/).pop() || 'file'
if (importFormat.value === 'json') {
importPreview.value = []
importDateDetected.value = 'ambiguous'
importDateFormat.value = 'MDY'
} else {
const allRows = parseCSV(content)
importPreview.value = allRows.slice(0, 6)
// Detect date format from CSV data
const header = allRows[0]?.map(h => h.toLowerCase().trim()) || []
let dateColIdx = -1
if (importFormat.value === 'harvest') {
dateColIdx = findCol(header, 'date')
} else {
dateColIdx = findCol(header, 'start date')
}
const detected = detectDateFormat(allRows, dateColIdx)
importDateDetected.value = detected
importDateFormat.value = detected === 'ambiguous' ? 'MDY' : detected
}
} catch (e) {
console.error('Failed to read file:', e)
}
}
async function executeImport() {
if (!importFileContent.value) return
isImporting.value = true
importStatus.value = 'Importing...'
try {
if (importFormat.value === 'json') {
const data = JSON.parse(importFileContent.value)
await invoke('import_json_data', { data })
importStatus.value = 'Import complete!'
} else {
const rows = parseCSV(importFileContent.value)
let entries: ImportEntry[]
const df = importDateFormat.value
if (importFormat.value === 'toggl') {
entries = mapTogglCSV(rows, df)
} else if (importFormat.value === 'clockify') {
entries = mapClockifyCSV(rows, df)
} else if (importFormat.value === 'harvest') {
entries = mapHarvestCSV(rows, df)
} else {
entries = mapGenericCSV(rows, { project: 0, description: 1, start_time: 2, duration: 3, client: -1, task: -1 })
}
await invoke('import_entries', { entries })
importStatus.value = `Imported ${entries.length} entries!`
}
importFileContent.value = ''
importFileName.value = ''
importPreview.value = []
importDateDetected.value = 'ambiguous'
importDateFormat.value = 'MDY'
} catch (e) {
importStatus.value = `Error: ${e}`
} finally {
isImporting.value = false
}
}
// Export all data
async function exportData() {
try {
const { save } = await import('@tauri-apps/plugin-dialog')
const { writeTextFile } = await import('@tauri-apps/plugin-fs')
const filePath = await save({
defaultPath: `zeroclock-export-${new Date().toISOString().split('T')[0]}.json`,
filters: [{ name: 'JSON', extensions: ['json'] }],
})
if (!filePath) return
const data = await invoke('export_data')
const json = JSON.stringify(data, null, 2)
await writeTextFile(filePath, json)
const now = new Date().toISOString()
await settingsStore.updateSetting('last_exported', now)
lastExported.value = now
toastStore.success('Data exported successfully')
} catch (error) {
console.error('Failed to export data:', error)
toastStore.error('Failed to export data')
}
}
// Clear all data
async function clearAllData() {
try {
await invoke('clear_all_data')
showClearDataDialog.value = false
toastStore.success('All data has been cleared')
} catch (error) {
console.error('Failed to clear data:', error)
toastStore.error('Failed to clear data')
}
}
// Load settings on mount
onMounted(async () => {
try { platform.value = await invoke('get_platform') } catch { /* non-critical */ }
await settingsStore.fetchSettings()
hourlyRate.value = parseFloat(settingsStore.settings.hourly_rate) || 0
idleDetection.value = settingsStore.settings.idle_detection !== 'false'
idleTimeout.value = parseInt(settingsStore.settings.idle_timeout) || 5
reminderInterval.value = parseInt(settingsStore.settings.reminder_interval) || 15
appTrackingMode.value = settingsStore.settings.app_tracking_mode || 'auto'
appCheckInterval.value = parseInt(settingsStore.settings.app_check_interval) || 5
zoomLevel.value = parseInt(settingsStore.settings.ui_zoom) || 100
locale.value = settingsStore.settings.locale || 'system'
currency.value = settingsStore.settings.currency || 'USD'
themeMode.value = settingsStore.settings.theme_mode || 'dark'
accentColor.value = settingsStore.settings.accent_color || 'amber'
shortcutToggleTimer.value = settingsStore.settings.shortcut_toggle_timer || 'CmdOrCtrl+Shift+T'
shortcutShowApp.value = settingsStore.settings.shortcut_show_app || 'CmdOrCtrl+Shift+Z'
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'
businessName.value = settingsStore.settings.business_name || ''
businessAddress.value = settingsStore.settings.business_address || ''
businessEmail.value = settingsStore.settings.business_email || ''
businessPhone.value = settingsStore.settings.business_phone || ''
businessLogo.value = settingsStore.settings.business_logo || ''
timelineRecording.value = platform.value !== 'linux' && settingsStore.settings.timeline_recording === 'on'
timerFont.value = settingsStore.settings.timer_font || 'JetBrains Mono'
reduceMotion.value = settingsStore.settings.reduce_motion || 'system'
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'
// Auto-backup settings
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'
soundMode.value = settingsStore.settings.sound_mode || 'synthesized'
soundVolume.value = parseInt(settingsStore.settings.sound_volume) || 70
try {
const parsed = JSON.parse(settingsStore.settings.sound_events || '{}')
soundEventsMap.value = { ...DEFAULT_EVENTS, ...parsed }
} catch { /* use defaults */ }
// Preload all timer fonts for preview in dropdown
TIMER_FONTS.forEach(f => loadGoogleFont(f.value))
// Preload all UI fonts for preview in dropdown
UI_FONTS.forEach(f => loadUIFont(f.value))
// Load recurring entries and projects list
await recurringStore.fetchEntries()
recProjects.value = await invoke<Array<{ id: number; name: string; color: string }>>('get_projects')
// Load entry templates
await entryTemplatesStore.fetchTemplates()
// Load calendar sources
await fetchCalendarSources()
// Load onboarding state
await onboardingStore.load()
})
</script>