2588 lines
102 KiB
Vue
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 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>
|