tooltips, two-column timer, font selector, tray behavior, icons, readme

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